diff --git a/CHANGELOG.md b/CHANGELOG.md index addbc600..7a6a31c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ ### Added -- Mark overloaded global directive in the subsequent definition ([#232](https://github.com/marp-team/marp-vscode/pull/232)) +- `overloading-global-directive` diagnostic: Mark overloaded global directive in the subsequent definition ([#232](https://github.com/marp-team/marp-vscode/pull/232)) +- `unknown-theme` diagnostic: Mark if the specified theme name is not recognized by the extension ([#236](https://github.com/marp-team/marp-vscode/pull/236)) ### Changed diff --git a/src/diagnostics/index.test.ts b/src/diagnostics/index.test.ts index 6a99bd7b..95fe5720 100644 --- a/src/diagnostics/index.test.ts +++ b/src/diagnostics/index.test.ts @@ -2,12 +2,14 @@ import { window, TextDocument } from 'vscode' import { DirectiveParser } from '../directive-parser' import * as deprecatedDollarPrefix from './deprecated-dollar-prefix' import * as overloadingGlobalDirective from './overloading-global-directive' +import * as unknownTheme from './unknown-theme' import * as diagnostics from './index' jest.mock('lodash.debounce') jest.mock('vscode') jest.mock('./deprecated-dollar-prefix') jest.mock('./overloading-global-directive') +jest.mock('./unknown-theme') const plainTextDocMock: TextDocument = { languageId: 'plaintext', @@ -81,6 +83,7 @@ describe('Diagnostics', () => { parser, arr ) + expect(unknownTheme.register).toHaveBeenCalledWith(doc, parser, arr) }) }) }) diff --git a/src/diagnostics/index.ts b/src/diagnostics/index.ts index 66812673..61c599d7 100644 --- a/src/diagnostics/index.ts +++ b/src/diagnostics/index.ts @@ -11,6 +11,7 @@ import { DirectiveParser } from '../directive-parser' import { detectMarpDocument } from '../utils' import * as deprecatedDollarPrefix from './deprecated-dollar-prefix' import * as overloadingGlobalDirective from './overloading-global-directive' +import * as unknownTheme from './unknown-theme' export const collection = languages.createDiagnosticCollection('marp-vscode') @@ -20,6 +21,7 @@ const setDiagnostics = lodashDebounce((doc: TextDocument) => { deprecatedDollarPrefix.register(doc, directiveParser, diagnostics) overloadingGlobalDirective.register(doc, directiveParser, diagnostics) + unknownTheme.register(doc, directiveParser, diagnostics) directiveParser.parse(doc) diff --git a/src/diagnostics/unknown-theme.test.ts b/src/diagnostics/unknown-theme.test.ts new file mode 100644 index 00000000..75be6fd2 --- /dev/null +++ b/src/diagnostics/unknown-theme.test.ts @@ -0,0 +1,84 @@ +import dedent from 'dedent' +import { + Diagnostic, + DiagnosticSeverity, + Position, + Range, + TextDocument, + Uri, +} from 'vscode' +import { DirectiveParser } from '../directive-parser' +import { Themes } from '../themes' +import * as rule from './unknown-theme' + +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 theme', () => { + 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 theme directive with not recognized theme name', () => { + const diagnostics = register( + doc(dedent` + --- + marp: true + theme: unknown + test: test + --- + `) + ) + expect(diagnostics).toHaveLength(1) + + const [$theme] = diagnostics + expect($theme).toBeInstanceOf(Diagnostic) + expect($theme.code).toBe(rule.code) + expect($theme.source).toBe('marp-vscode') + expect($theme.severity).toBe(DiagnosticSeverity.Warning) + expect($theme.range).toStrictEqual( + new Range(new Position(2, 7), new Position(2, 14)) + ) + }) + + it('does not add diagnostics when theme directive is not defined', () => + expect(register(doc(''))).toHaveLength(0)) + + it('does not add diagnostics when the specified theme is recognized', () => { + expect(register(doc(''))).toHaveLength(0) + expect(register(doc(''))).toHaveLength(0) + expect(register(doc(''))).toHaveLength(0) + }) + + describe('when registered custom theme', () => { + beforeEach(() => { + jest + .spyOn(Themes.prototype, 'getRegisteredStyles') + .mockReturnValue([{ css: '/* @theme custom-theme */' } as any]) + }) + + it('does not add diagnostics when specified the name of custom theme', () => { + expect(register(doc(''))).toHaveLength(0) + }) + }) + }) +}) diff --git a/src/diagnostics/unknown-theme.ts b/src/diagnostics/unknown-theme.ts new file mode 100644 index 00000000..ee86e72d --- /dev/null +++ b/src/diagnostics/unknown-theme.ts @@ -0,0 +1,55 @@ +import { Diagnostic, DiagnosticSeverity, Range, TextDocument } from 'vscode' +import { DirectiveParser } from '../directive-parser' +import themes from '../themes' + +interface ParsedThemeValue { + value: string + range: Range +} + +export const code = 'unknown-theme' + +export function register( + doc: TextDocument, + directiveParser: DirectiveParser, + diagnostics: Diagnostic[] +) { + let parsed: ParsedThemeValue | undefined + + directiveParser.on('startParse', () => { + parsed = undefined + }) + + directiveParser.on('directive', ({ item, offset, info }) => { + if (info?.name === 'theme') { + const [start, end] = item.value.range + + parsed = { + value: item.value.value, + range: new Range( + doc.positionAt(start + offset), + doc.positionAt(end + offset) + ), + } + } + }) + + directiveParser.on('endParse', () => { + if (parsed) { + const themeSet = themes.getMarpThemeSetFor(doc) + + if (!themeSet.has(parsed.value)) { + const diagnostic = new Diagnostic( + parsed.range, + `The specified theme "${parsed.value}" is not recognized by Marp for VS Code.`, + DiagnosticSeverity.Warning + ) + + diagnostic.source = 'marp-vscode' + diagnostic.code = code + + diagnostics.push(diagnostic) + } + } + }) +} diff --git a/src/extension.ts b/src/extension.ts index 157af555..cc4e656e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,7 +10,7 @@ import { marpCoreOptionForPreview, clearMarpCoreOptionCache } from './option' import customTheme from './plugins/custom-theme' import lineNumber from './plugins/line-number' import outline, { rule as outlineRule } from './plugins/outline' -import themes from './themes' +import themes, { Themes } from './themes' import { detectMarpFromMarkdown, marpConfiguration } from './utils' const shouldRefreshConfs = [ @@ -45,10 +45,7 @@ export function extendMarkdownIt(md: any) { document.languageId === 'markdown' && document.getText().replace(/\u2028|\u2029/g, '') === markdown ) { - const workspaceFolder = workspace.getWorkspaceFolder(document.uri) - if (workspaceFolder) return workspaceFolder.uri - - return document.uri.with({ path: path.dirname(document.fileName) }) + return Themes.resolveBaseDirectoryForTheme(document) } } return undefined diff --git a/src/themes.ts b/src/themes.ts index f3750053..fb855751 100644 --- a/src/themes.ts +++ b/src/themes.ts @@ -2,6 +2,7 @@ import fs from 'fs' import path from 'path' import { URL } from 'url' import { promisify, TextDecoder } from 'util' +import Marp from '@marp-team/marp-core' import axios from 'axios' import { commands, @@ -9,6 +10,7 @@ import { Disposable, GlobPattern, RelativePattern, + TextDocument, Uri, } from 'vscode' import { marpConfiguration } from './utils' @@ -40,6 +42,13 @@ const textDecoder = new TextDecoder() export class Themes { observedThemes = new Map() + static resolveBaseDirectoryForTheme(doc: TextDocument): Uri { + const workspaceFolder = workspace.getWorkspaceFolder(doc.uri) + if (workspaceFolder) return workspaceFolder.uri + + return doc.uri.with({ path: path.dirname(doc.fileName) }) + } + dispose() { this.observedThemes.forEach((theme) => { if (theme.onDidChange) theme.onDidChange.dispose() @@ -48,6 +57,22 @@ export class Themes { this.observedThemes.clear() } + getMarpThemeSetFor(doc: TextDocument) { + const marp = new Marp() + + for (const { css } of this.getRegisteredStyles( + Themes.resolveBaseDirectoryForTheme(doc) + )) { + try { + marp.themeSet.add(css) + } catch (e) { + // no ops + } + } + + return marp.themeSet + } + getRegisteredStyles(rootUri: Uri | undefined): Theme[] { return this.getPathsFromConf(rootUri) .map((p) => this.observedThemes.get(p))