const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { createRequire } = require('node:module'); const test = require('node:test'); const root = path.resolve(__dirname, '../..'); const extension = path.join(root, 'editor/vscode'); const extensionRequire = createRequire(path.join(extension, 'package.json')); const textmate = extensionRequire('vscode-textmate'); const oniguruma = extensionRequire('vscode-oniguruma'); const cliArguments = process.argv.slice(2); assert.equal(cliArguments.length, 2, 'pass --markdown-grammar followed by the real VS Code grammar path'); assert.equal(cliArguments[0], '--markdown-grammar'); const markdownPath = path.resolve(cliArguments[1]); const manifest = JSON.parse(fs.readFileSync(path.join(extension, 'package.json'), 'utf8')); const configuration = JSON.parse(fs.readFileSync(path.join(extension, 'language-configuration.json'), 'utf8')); async function loadGrammars() { const wasm = fs.readFileSync(extensionRequire.resolve('vscode-oniguruma/release/onig.wasm')); await oniguruma.loadWASM(wasm.buffer.slice(wasm.byteOffset, wasm.byteOffset + wasm.byteLength)); const catalog = new Map(); for (const contribution of manifest.contributes.grammars) { const filename = path.join(extension, contribution.path); catalog.set(contribution.scopeName, textmate.parseRawGrammar(fs.readFileSync(filename, 'utf8'), filename)); } const markdown = textmate.parseRawGrammar(fs.readFileSync(markdownPath, 'utf8'), markdownPath); assert.equal(markdown.scopeName, 'text.html.markdown'); catalog.set(markdown.scopeName, markdown); const registry = new textmate.Registry({ onigLib: Promise.resolve({ createOnigScanner: patterns => new oniguruma.OnigScanner(patterns), createOnigString: value => new oniguruma.OnigString(value) }), loadGrammar: scope => catalog.get(scope) || null, getInjections: scope => manifest.contributes.grammars .filter(contribution => contribution.injectTo?.includes(scope)) .map(contribution => contribution.scopeName) }); return { source: await registry.loadGrammarWithConfiguration('source.dever', 1, {}), markdown: await registry.loadGrammarWithConfiguration('text.html.markdown', 2, { embeddedLanguages: { 'meta.embedded.block.dever': 1 }, tokenTypes: { 'meta.embedded.block.dever': 0 } }) }; } const grammars = loadGrammars(); function tokenize(grammar, document) { let state = textmate.INITIAL; return document.split(/\r\n|\r|\n/).map(line => { const encoded = grammar.tokenizeLine2(line, state); const result = grammar.tokenizeLine(line, state); state = result.ruleStack; return { line, tokens: result.tokens, encoded: encoded.tokens }; }); } function tokenAt(rows, line, fragment) { const row = rows[line]; const offset = row.line.indexOf(fragment); assert.ok(offset >= 0, `line ${line + 1} does not contain ${fragment}`); const token = row.tokens.find(candidate => candidate.startIndex <= offset && candidate.endIndex > offset); assert.ok(token, `no token for ${fragment} on line ${line + 1}`); return token; } function hasScope(rows, line, fragment, scope) { const actual = tokenAt(rows, line, fragment).scopes; assert.ok(actual.includes(scope), `${fragment}: expected ${scope}, received ${actual.join(' ')}`); } function sourceFiles(directory, suffix) { return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => { const filename = path.join(directory, entry.name); return entry.isDirectory() ? sourceFiles(filename, suffix) : filename.endsWith(suffix) ? [filename] : []; }); } test('types, fields and named function inputs/outputs receive Dever scopes', async () => { const rows = tokenize((await grammars).source, [ 'type Profile {', ' id: Int?', ' name: Text', '}', 'greet(name: Text) (message: Text) {', ' message = app.format(name)', '}' ].join('\n')); hasScope(rows, 0, 'type', 'storage.type.dever'); hasScope(rows, 0, 'Profile', 'entity.name.type.dever'); hasScope(rows, 1, 'id', 'variable.other.member.dever'); hasScope(rows, 1, 'Int', 'support.type.dever'); hasScope(rows, 4, 'greet', 'entity.name.function.dever'); hasScope(rows, 4, 'name', 'variable.other.member.dever'); hasScope(rows, 4, 'message', 'variable.other.member.dever'); hasScope(rows, 5, 'app', 'entity.name.namespace.dever'); hasScope(rows, 5, 'format', 'entity.name.function.dever'); }); test('API, Model, Job and external Adapter declarations are covered', async () => { const source = (await grammars).source; for (const method of ['get', 'post', 'put', 'delete', 'cmd', 'rest']) { const rows = tokenize(source, `${method} profile = app.profile`); hasScope(rows, 0, method, 'keyword.control.api.dever'); } const rows = tokenize(source, [ 'public get profile = app.profile', ' private owner user_id: user.account.model.id = dever.auth.user_id()', 'index(user_id, created_at)', 'sql owned_articles(id: Int) (articles: List
) {', 'job send(message: Text) () fails app.Error {', 'schedule send_every_minute {', 'external npm "worker.js" {}' ].join('\n')); hasScope(rows, 0, 'public', 'storage.modifier.dever'); hasScope(rows, 1, 'owner', 'storage.modifier.dever'); hasScope(rows, 2, 'index', 'keyword.other.declaration.dever'); hasScope(rows, 3, 'sql', 'keyword.other.declaration.dever'); hasScope(rows, 4, 'job', 'storage.modifier.dever'); hasScope(rows, 4, 'fails', 'storage.modifier.dever'); hasScope(rows, 5, 'schedule', 'keyword.other.declaration.dever'); hasScope(rows, 6, 'npm', 'keyword.other.external.dever'); }); test('hash comments, quoted text and escapes do not leak into subsequent lines', async () => { const rows = tokenize((await grammars).source, [ '# type Fake and return', String.raw`message = "中文 # and type \n \u{4E2D} \""`, String.raw`invalid = "\q"`, 'broken = "unfinished', 'type Next { value: Text }' ].join('\n')); hasScope(rows, 0, 'type', 'comment.line.number-sign.dever'); hasScope(rows, 1, '#', 'string.quoted.double.dever'); hasScope(rows, 1, '\\n', 'constant.character.escape.dever'); hasScope(rows, 1, '\\u{4E2D}', 'constant.character.escape.dever'); hasScope(rows, 2, '\\q', 'invalid.illegal.escape.dever'); hasScope(rows, 4, 'type', 'storage.type.dever'); assert.ok(!tokenAt(rows, 4, 'type').scopes.includes('string.quoted.double.dever')); }); test('integer division is an operator, not a JavaScript line comment', async () => { const rows = tokenize((await grammars).source, 'count = 12 // 3\namount = 1.25e-2\nvalid = true and not false'); hasScope(rows, 0, '//', 'keyword.operator.dever'); hasScope(rows, 0, '3', 'constant.numeric.dever'); assert.ok(!tokenAt(rows, 0, '3').scopes.some(scope => scope.startsWith('comment.'))); hasScope(rows, 1, '1.25e-2', 'constant.numeric.dever'); hasScope(rows, 2, 'true', 'constant.language.dever'); hasScope(rows, 2, 'and', 'keyword.operator.logical.dever'); }); test('excluded control-flow syntax is not presented as a valid function', async () => { const rows = tokenize((await grammars).source, 'if(value)\nreturn value\nimport other\npackage main'); for (const [line, keyword] of ['if', 'return', 'import', 'package'].entries()) { hasScope(rows, line, keyword, 'invalid.illegal.keyword.dever'); } }); test('Markdown fences reuse source.dever while prose keeps Markdown scopes', async () => { for (const marker of ['```', '~~~']) { for (const language of ['dever', 'typescript dever']) { const rows = tokenize((await grammars).markdown, [ '# 文档', '', '## 类型', `${marker}${language}`, 'type Profile { name: Text }', marker, '', '正文 type Profile' ].join('\n')); hasScope(rows, 4, 'type', 'storage.type.dever'); hasScope(rows, 4, 'Text', 'support.type.dever'); hasScope(rows, 4, 'Profile', 'meta.embedded.block.dever'); assert.ok(!tokenAt(rows, 7, 'type').scopes.includes('source.dever')); } } }); test('embedded tokens carry the Dever language ID and are not string tokens', async () => { const rows = tokenize((await grammars).markdown, '```dever\ntype Profile {}\n```'); const metadata = rows[1].encoded[1]; assert.equal(metadata & 0xff, 1); assert.equal((metadata >>> 8) & 3, 0); }); test('fence closing honors its character and minimum opening length', async () => { const rows = tokenize((await grammars).markdown, [ '````dever', 'type First {}', '```', 'type Second {}', '~~~~', 'type Third {}', '`````', '正文 type Outside' ].join('\n')); for (const line of [1, 3, 5]) hasScope(rows, line, 'type', 'storage.type.dever'); assert.ok(!tokenAt(rows, 7, 'type').scopes.includes('source.dever')); const waves = tokenize((await grammars).markdown, '~~~dever\ntype Inside {}\n~~~~\n正文 type Outside'); assert.ok(!tokenAt(waves, 3, 'type').scopes.includes('source.dever')); }); test('valid indentation and CRLF work without taking over other language fences', async () => { const rows = tokenize((await grammars).markdown, ' ```dever\r\ntype Profile {}\r\n ```\r\n正文'); hasScope(rows, 1, 'type', 'storage.type.dever'); for (const language of ['typescript', 'javascript', 'dever extra', 'typescript dever']) { const other = tokenize((await grammars).markdown, `\`\`\`${language}\ntype Profile {}\n\`\`\``); assert.ok(!tokenAt(other, 1, 'type').scopes.includes('source.dever'), language); } }); test('quotes, lists, showcase fences, indented code and comments retain Markdown scopes', async () => { for (const [document, line] of [ ['> ```dever\n> type Quoted {}\n> ```', 1], ['- 示例\n\n ```dever\n type Nested {}\n ```', 3], ['````markdown\n```dever\ntype Showcase {}\n```\n````', 2], [' ```dever\n type Indented {}\n ```', 1], ['', 2] ]) { const rows = tokenize((await grammars).markdown, document); assert.ok(!tokenAt(rows, line, 'type').scopes.includes('source.dever'), document); } }); test('language configuration uses Dever comments, pairs and indentation', () => { assert.equal(configuration.comments.lineComment, '#'); assert.equal(configuration.comments.blockComment, undefined); assert.deepEqual(configuration.brackets, [['{', '}'], ['[', ']'], ['(', ')']]); const increase = new RegExp(configuration.indentationRules.increaseIndentPattern); const decrease = new RegExp(configuration.indentationRules.decreaseIndentPattern); for (const line of ['greet(name: Text) (message: Text) {', ' values = [', 'type Profile { # 字段']) { assert.ok(increase.test(line), line); } for (const line of ['# example {', 'message = "literal {"', 'message = "value" # {']) { assert.ok(!increase.test(line), line); } assert.ok(decrease.test(' }')); assert.ok(decrease.test(' ]')); assert.ok(configuration.autoClosingPairs.every(pair => pair.notIn.includes('string') && pair.notIn.includes('comment'))); assert.deepEqual(manifest.contributes.languages[0].extensions, ['.dever']); const injection = manifest.contributes.grammars.find(contribution => contribution.injectTo?.includes('text.html.markdown')); assert.deepEqual(injection.embeddedLanguages, { 'meta.embedded.block.dever': 'dever' }); assert.deepEqual(injection.tokenTypes, { 'meta.embedded.block.dever': 'other' }); assert.equal(manifest.main, undefined); assert.equal(manifest.dependencies, undefined); }); test('maintained plain and Markdown CMS files tokenize without illegal tokens', async () => { const loaded = await grammars; for (const [directory, suffix, grammar] of [ ['examples/cms/dever', '.dever', loaded.source], ['examples/cms/md', '.dever.md', loaded.markdown] ]) { const files = sourceFiles(path.join(root, directory), suffix); assert.ok(files.length > 0, directory); let highlighted = 0; for (const filename of files) { const rows = tokenize(grammar, fs.readFileSync(filename, 'utf8')); let fileHighlighted = 0; for (const row of rows) { for (const token of row.tokens) { assert.ok(!token.scopes.some(scope => scope.startsWith('invalid.illegal.')), `${filename}: ${row.line}`); if (token.scopes.includes('source.dever')) fileHighlighted += 1; } } assert.ok(fileHighlighted > 0, `${filename} must actually enter the Dever grammar`); highlighted += fileHighlighted; } assert.ok(highlighted > files.length, `${directory} must actually enter the Dever grammar`); process.stdout.write(`CMS ${directory}: ${files.length} files, ${highlighted} Dever tokens\n`); } });