Repository: marp-team/marp-vscode
Pull request #276 (2021-08-21 09:05:07)

`size` directive language support
Added auto-completion for `size` directive and diagnostic about unknown size preset.

![](https://user-images.githubusercontent.com/3993388/130316784-9fcdeafd-ca33-4ffa-ad35-a3dad3882273.png)
![](https://user-images.githubusercontent.com/3993388/130316809-1e1f6b42-af92-49a9-a9c4-09510800158a.png)



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c97c3f..c698012 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@
 
 ## [Unreleased]
 
+### Added
+
+- Auto completion for [`size` global directive](https://github.com/marp-team/marp-core#size-global-directive) ([#276](https://github.com/marp-team/marp-vscode/pull/276))
+- `unknown-size` diagnostic: Notify if the specified size preset was not defined in a theme ([#276](https://github.com/marp-team/marp-vscode/pull/276))
+
 ### Changed
 
 - Update contribution for "New File…" in File menu and welcome screen to stable spec in VS Code 1.59 ([#270](https://github.com/marp-team/marp-vscode/pull/270))
diff --git a/README.md b/README.md
index 3465488..0cf74fc 100644
--- a/README.md
+++ b/README.md
@@ -90,11 +90,13 @@ Marp for VS Code can detect some basic problems in Marp directives. Diagnostics
 | `deprecated-dollar-prefix`      | Check [obsoleted directives prefixed by `$`][dollar-prefix]                                        |     ✅      |
 | `ignored-math-global-directive` | Report ignored `math` global directive if disabled math by the extension setting                   |             |
 | `overloading-global-directive`  | Find out overloaded global directives                                                              |             |
+| `unknown-size`                  | Notify if the specified [size preset] was not defined in a theme                                   |             |
 | `unknown-theme`                 | Notify a not recognized theme name                                                                 |             |
 
 [quick fix]: https://code.visualstudio.com/docs/editor/refactoring#_code-actions-quick-fixes-and-refactorings
 [dollar-prefix]: https://github.com/marp-team/marpit/issues/182
 [math global directive]: https://github.com/marp-team/marp-core#math-global-directive
+[size preset]: https://github.com/marp-team/marp-core/tree/main/themes#size-name-width-height
 
 ### Export slide deck to HTML, PDF, PPTX, and image 🛡️
 
diff --git a/src/diagnostics/index.ts b/src/diagnostics/index.ts
index 3a461c8..f5f7f1e 100644
--- a/src/diagnostics/index.ts
+++ b/src/diagnostics/index.ts
@@ -13,6 +13,7 @@ import * as defineMathGlobalDirective from './define-math-global-directive'
 import * as deprecatedDollarPrefix from './deprecated-dollar-prefix'
 import * as ignoredMathGlobalDirective from './ignored-math-global-directive'
 import * as overloadingGlobalDirective from './overloading-global-directive'
+import * as unknownSize from './unknown-size'
 import * as unknownTheme from './unknown-theme'
 
 export const collection = languages.createDiagnosticCollection('marp-vscode')
@@ -25,6 +26,7 @@ const setDiagnostics = lodashDebounce((doc: TextDocument) => {
   deprecatedDollarPrefix.register(doc, directiveParser, diagnostics)
   ignoredMathGlobalDirective.register(doc, directiveParser, diagnostics)
   overloadingGlobalDirective.register(doc, directiveParser, diagnostics)
+  unknownSize.register(doc, directiveParser, diagnostics)
   unknownTheme.register(doc, directiveParser, diagnostics)
 
   directiveParser.parse(doc)
diff --git a/src/diagnostics/unknown-size.ts b/src/diagnostics/unknown-size.ts
new file mode 100644
index 0000000..c17f585
--- /dev/null
+++ b/src/diagnostics/unknown-size.ts
@@ -0,0 +1,74 @@
+import { Diagnostic, DiagnosticSeverity, Range, TextDocument } from 'vscode'
+import { DirectiveParser } from '../directives/parser'
+import themes from '../themes'
+
+interface ParsedSizeValue {
+  theme?: string
+  size?: string
+  range?: Range
+  vRange?: Range
+}
+
+export const code = 'unknown-size'
+
+export function register(
+  doc: TextDocument,
+  directiveParser: DirectiveParser,
+  diagnostics: Diagnostic[]
+) {
+  let parsed: ParsedSizeValue = {}
+
+  directiveParser.on('startParse', () => {
+    parsed = {}
+  })
+
+  directiveParser.on('directive', ({ item, offset, info }) => {
+    switch (info?.name) {
+      case 'theme':
+        if (item.value?.value) parsed.theme = item.value?.value
+        break
+      case 'size':
+        if (item.key.range && item.value?.range) {
+          const [start] = item.key.range
+          const [vStart, end] = item.value.range
+
+          parsed.size = item.value.value
+          parsed.range = new Range(
+            doc.positionAt(start + offset),
+            doc.positionAt(end + offset)
+          )
+          parsed.vRange = new Range(
+            doc.positionAt(vStart + offset),
+            doc.positionAt(end + offset)
+          )
+        }
+        break
+    }
+  })
+
+  directiveParser.on('endParse', () => {
+    if (
+      parsed.size !== undefined &&
+      parsed.range &&
+      parsed.vRange &&
+      !parsed.vRange.start.isEqual(parsed.vRange.end)
+    ) {
+      const sizes = themes
+        .getSizePresets(doc, parsed.theme)
+        .map(({ name }) => name)
+
+      if (!sizes.includes(parsed.size)) {
+        const diagnostic = new Diagnostic(
+          parsed.range,
+          `A specified size preset "${parsed.size}" will be ignored because it is not defined by used theme.`,
+          DiagnosticSeverity.Warning
+        )
+
+        diagnostic.source = 'marp-vscode'
+        diagnostic.code = code
+
+        diagnostics.push(diagnostic)
+      }
+    }
+  })
+}
diff --git a/src/language/completions.ts b/src/language/completions.ts
index 5f2c71b..1d41f7f 100644
--- a/src/language/completions.ts
+++ b/src/language/completions.ts
@@ -41,7 +41,7 @@ export function register(subscriptions: Disposable[], parser: LanguageParser) {
         const definedIn = getCompletionTarget(pos, data)
         if (!definedIn) return
 
-        const provider = new CompletionProvider(doc, pos, definedIn)
+        const provider = new CompletionProvider(doc, pos, data, definedIn)
         return provider.getCompletionList()
       },
     })
@@ -74,7 +74,7 @@ class CompletionProvider {
   constructor(
     private readonly document: TextDocument,
     private readonly position: Position,
-    // private readonly data: LanguageParseData,
+    private readonly data: LanguageParseData,
     private readonly definedIn: DirectiveDefinedIn
   ) {}
 
@@ -83,6 +83,7 @@ class CompletionProvider {
       this.completionThemes() ||
       this.completionBoolean() ||
       this.completionMath() ||
+      this.completionSizePreset() ||
       this.completionDirectives()
     )
   }
@@ -146,6 +147,29 @@ class CompletionProvider {
     }
   }
 
+  private completionSizePreset() {
+    if (this.isCursorOnDirective('size')) {
+      let theme: string | undefined
+
+      for (const { info, value } of this.data.directvies) {
+        if (info.name === 'theme' && value) theme = value
+      }
+
+      const sizePresets = themes.getSizePresets(this.document, theme)
+
+      if (sizePresets.length > 0) {
+        return new CompletionList(
+          sizePresets.map((preset) => ({
+            detail: 'Marp theme size preset',
+            documentation: `${preset.width} X ${preset.height}`,
+            kind: CompletionItemKind.EnumMember,
+            label: preset.name,
+          }))
+        )
+      }
+    }
+  }
+
   private completionDirectives() {
     // Determine how to completion directive key from the focused word
     let range: Range | undefined
diff --git a/src/language/parser.ts b/src/language/parser.ts
index a74a39c..04472e7 100644
--- a/src/language/parser.ts
+++ b/src/language/parser.ts
@@ -16,6 +16,7 @@ export interface LanguageParsedDirective {
   info: DirectiveInfo
   keyRange: Range
   range: Range
+  value?: string
 }
 
 export interface LanguageParseData {
@@ -127,6 +128,7 @@ export class LanguageParser
               document.positionAt(start + offset),
               document.positionAt(vEnd + offset)
             ),
+            value: item.value?.value,
           })
         }
       })
diff --git a/src/themes.ts b/src/themes.ts
index 629963c..5e2aa78 100644
--- a/src/themes.ts
+++ b/src/themes.ts
@@ -29,6 +29,12 @@ export interface Theme {
   readonly type: ThemeType
 }
 
+export interface SizePreset {
+  height: string
+  name: string
+  width: string
+}
+
 const readFile = promisify(fs.readFile)
 
 const isRemotePath = (path: string) =>
@@ -75,7 +81,34 @@ export class Themes {
   getRegisteredStyles(rootUri: Uri | undefined): Theme[] {
     return this.getPathsFromConf(rootUri)
       .map((p) => this.observedThemes.get(p))
-      .filter((t) => t) as Theme[]
+      .filter((t): t is Theme => !!t)
+  }
+
+  getSizePresets(
+    doc: TextDocument,
+    themeName: string | undefined
+  ): SizePreset[] {
+    const themeSet = this.getMarpThemeSetFor(doc)
+    const theme = themeSet.get(themeName ?? '', true)?.name || 'default'
+
+    const sizeMeta = (themeSet.getThemeMeta(theme, 'size') as string[]) || []
+    const sizes = new Map<string, SizePreset>()
+
+    for (const size of sizeMeta) {
+      const args = size.split(/\s+/)
+
+      if (args.length === 3) {
+        sizes.set(args[0], {
+          name: args[0],
+          width: args[1],
+          height: args[2],
+        })
+      } else if (args.length === 2 && args[1] === 'false') {
+        sizes.delete(args[0])
+      }
+    }
+
+    return [...sizes.values()]
   }
 
   loadStyles(rootUri: Uri | undefined): Promise<Theme>[] {


Test patch:
diff --git a/src/diagnostics/unknown-size.test.ts b/src/diagnostics/unknown-size.test.ts
new file mode 100644
index 0000000..c13bb07
--- /dev/null
+++ b/src/diagnostics/unknown-size.test.ts
@@ -0,0 +1,112 @@
+import dedent from 'dedent'
+import {
+  Diagnostic,
+  DiagnosticSeverity,
+  Position,
+  Range,
+  TextDocument,
+  Uri,
+} from 'vscode'
+import { DirectiveParser } from '../directives/parser'
+import { Themes } from '../themes'
+import * as rule from './unknown-size'
+
+jest.mock('vscode')
+
+const doc = (text: string): TextDocument =>
+  ({
+    getText: () => text,
+    positionAt: (offset: number) => {
+      const lines = text.slice(0, offset).split('\n')
+
+      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+      return new Position(lines.length - 1, lines.pop()!.length)
+    },
+    uri: Uri.parse('/test/document'),
+    fileName: '/test/document',
+  } as any)
+
+describe('[Diagnostics rule] Unknown size', () => {
+  const register = (doc: TextDocument): Diagnostic[] => {
+    const parser = new DirectiveParser()
+    const diagnostics: Diagnostic[] = []
+
+    rule.register(doc, parser, diagnostics)
+
+    parser.parse(doc)
+    return diagnostics
+  }
+
+  describe('#register', () => {
+    it('adds diagnostics when passed size directive with not defined size preset', () => {
+      const diagnostics = register(
+        doc(dedent`
+          ---
+          marp: true
+          size: unknown
+          test: test
+          ---
+        `)
+      )
+      expect(diagnostics).toHaveLength(1)
+
+      const [$size] = diagnostics
+      expect($size).toBeInstanceOf(Diagnostic)
+      expect($size.code).toBe(rule.code)
+      expect($size.source).toBe('marp-vscode')
+      expect($size.severity).toBe(DiagnosticSeverity.Warning)
+      expect($size.range).toStrictEqual(
+        new Range(new Position(2, 0), new Position(2, 13))
+      )
+    })
+
+    it('does not add diagnostics when size directive is not defined', () =>
+      expect(register(doc(''))).toHaveLength(0))
+
+    it('does not add diagnostics when the specified size is defined in used theme', () => {
+      expect(register(doc('<!-- size: 16:9 -->'))).toHaveLength(0)
+      expect(register(doc('<!-- size: 4:3 -->'))).toHaveLength(0)
+    })
+
+    it('does not add diagnostics when the definition of size directive is incompleted', () => {
+      expect(register(doc('<!-- size:-->'))).toHaveLength(0)
+      expect(register(doc('<!-- size: -->'))).toHaveLength(0)
+      expect(register(doc('<!-- size:  -->'))).toHaveLength(0)
+      expect(register(doc('<!-- size:\t-->'))).toHaveLength(0)
+    })
+
+    it('adds diagnostics if set empty string explicitly', () => {
+      expect(register(doc('<!-- size: "" -->'))).toHaveLength(1)
+      expect(register(doc("<!-- size: '' -->"))).toHaveLength(1)
+    })
+
+    describe('when registered custom theme', () => {
+      beforeEach(() => {
+        jest.spyOn(Themes.prototype, 'getRegisteredStyles').mockReturnValue([
+          {
+            css: dedent`
+              @import "default";
+
+              /* @theme custom-theme */
+              /* @size a4 210mm 297mm */
+              /* @size 4:3 false */
+            `,
+          } as any,
+        ])
+      })
+
+      it('does not add diagnostics when specified the custom size preset', () => {
+        expect(
+          register(doc('<!--\ntheme: custom-theme\nsize: a4\n-->'))
+        ).toHaveLength(0)
+        expect(
+          register(doc('<!--\ntheme: custom-theme\nsize: 16:9\n-->'))
+        ).toHaveLength(0)
+      })
+
+      it('adds diagnostics when not used the correct theme', () => {
+        expect(register(doc('<!-- size: a4 -->'))).toHaveLength(1)
+      })
+    })
+  })
+})
diff --git a/src/language/completions.test.ts b/src/language/completions.test.ts
index cca7a9c..fd8800a 100644
--- a/src/language/completions.test.ts
+++ b/src/language/completions.test.ts
@@ -1,3 +1,4 @@
+import dedent from 'dedent'
 import {
   languages,
   window,
@@ -109,29 +110,29 @@ describe('Auto completions', () => {
         const labels = list.items.map((item) => item.label).sort()
 
         expect(labels).toMatchInlineSnapshot(`
-Array [
-  "backgroundColor",
-  "backgroundImage",
-  "backgroundPosition",
-  "backgroundRepeat",
-  "backgroundSize",
-  "class",
-  "color",
-  "description",
-  "footer",
-  "header",
-  "headingDivider",
-  "image",
-  "marp",
-  "math",
-  "paginate",
-  "size",
-  "style",
-  "theme",
-  "title",
-  "url",
-]
-`)
+          Array [
+            "backgroundColor",
+            "backgroundImage",
+            "backgroundPosition",
+            "backgroundRepeat",
+            "backgroundSize",
+            "class",
+            "color",
+            "description",
+            "footer",
+            "header",
+            "headingDivider",
+            "image",
+            "marp",
+            "math",
+            "paginate",
+            "size",
+            "style",
+            "theme",
+            "title",
+            "url",
+          ]
+        `)
 
         // The insert text has semicolon
         expect(list.items[0].insertText).toBe(`${list.items[0].label}: `)
@@ -149,28 +150,28 @@ Array [
         const labels = list.items.map((item) => item.label).sort()
 
         expect(labels).toMatchInlineSnapshot(`
-Array [
-  "backgroundColor",
-  "backgroundImage",
-  "backgroundPosition",
-  "backgroundRepeat",
-  "backgroundSize",
-  "class",
-  "color",
-  "description",
-  "footer",
-  "header",
-  "headingDivider",
-  "image",
-  "math",
-  "paginate",
-  "size",
-  "style",
-  "theme",
-  "title",
-  "url",
-]
-`)
+          Array [
+            "backgroundColor",
+            "backgroundImage",
+            "backgroundPosition",
+            "backgroundRepeat",
+            "backgroundSize",
+            "class",
+            "color",
+            "description",
+            "footer",
+            "header",
+            "headingDivider",
+            "image",
+            "math",
+            "paginate",
+            "size",
+            "style",
+            "theme",
+            "title",
+            "url",
+          ]
+        `)
         expect(labels).not.toContain('marp')
       })
 
@@ -299,11 +300,84 @@ Array [
         const labels = list.items.map((item) => item.label).sort()
 
         expect(labels).toMatchInlineSnapshot(`
-Array [
-  "katex",
-  "mathjax",
-]
-`)
+          Array [
+            "katex",
+            "mathjax",
+          ]
+        `)
+      })
+    })
+
+    describe('Size preset suggestion', () => {
+      it('suggests size presets defined in used theme when the cursor is on size directive', async () => {
+        const doc = setDocument('---\nmarp: true\nsize: \n---')
+        const list = (await provideCompletionItems()(
+          doc,
+          new Position(2, 6),
+          {} as any,
+          {} as any
+        )) as CompletionList
+
+        const labels = list.items.map((item) => item.label).sort()
+
+        expect(labels).toMatchInlineSnapshot(`
+          Array [
+            "16:9",
+            "4:3",
+          ]
+        `)
+      })
+
+      it('suggests size presets strictly defined in used custom theme', async () => {
+        jest.spyOn(Themes.prototype, 'getRegisteredStyles').mockReturnValue([
+          {
+            css: dedent`
+              @import "default";
+
+              /* @theme custom-theme */
+              /* @size a4 210mm 297mm */
+              /* @size 4:3 false */
+            `,
+          } as any,
+        ])
+
+        const doc = setDocument(
+          '---\nmarp: true\ntheme: custom-theme\nsize: \n---'
+        )
+        const list = (await provideCompletionItems()(
+          doc,
+          new Position(3, 6),
+          {} as any,
+          {} as any
+        )) as CompletionList
+
+        const labels = list.items.map((item) => item.label).sort()
+
+        expect(labels).toMatchInlineSnapshot(`
+          Array [
+            "16:9",
+            "a4",
+          ]
+        `)
+      })
+
+      it('fallback to presets for the default theme if specified theme is not registered', async () => {
+        const doc = setDocument('---\nmarp: true\ntheme: unknown\nsize: \n---')
+        const list = (await provideCompletionItems()(
+          doc,
+          new Position(3, 6),
+          {} as any,
+          {} as any
+        )) as CompletionList
+
+        const labels = list.items.map((item) => item.label).sort()
+
+        expect(labels).toMatchInlineSnapshot(`
+          Array [
+            "16:9",
+            "4:3",
+          ]
+        `)
       })
     })
   })