diff --git a/editors/vscode/syntaxes/wrn.tmLanguage.json b/editors/vscode/syntaxes/wrn.tmLanguage.json index 32556013..62b2ed12 100644 --- a/editors/vscode/syntaxes/wrn.tmLanguage.json +++ b/editors/vscode/syntaxes/wrn.tmLanguage.json @@ -113,10 +113,10 @@ "include": "#action-block" }, { - "include": "#mode-block" + "include": "#api-block" }, { - "include": "#api-block" + "include": "#apis-block" }, { "include": "#functions-block" @@ -513,8 +513,8 @@ } ] }, - "mode-block": { - "begin": "\\b(ssr|client)\\b\\s*(\\{)", + "apis-block": { + "begin": "\\b(apis)\\b\\s*(\\{)", "beginCaptures": { "1": { "name": "keyword.control.wrn" @@ -534,21 +534,18 @@ "include": "#comments" }, { - "begin": "\\b(api)\\b\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)", + "begin": "\\b([A-Za-z_$][A-Za-z0-9_$]*)\\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b\\s*([^\\s{]*)\\s*(\\{)", "beginCaptures": { "1": { - "name": "keyword.control.wrn" - }, - "2": { "name": "entity.name.function.wrn" }, - "3": { + "2": { "name": "constant.language.http-method.wrn" }, - "4": { + "3": { "name": "string.unquoted.route.wrn" }, - "5": { + "4": { "name": "punctuation.definition.block.begin.wrn" } }, @@ -558,8 +555,36 @@ "name": "punctuation.definition.block.end.wrn" } }, - "contentName": "meta.embedded.block.ts", "patterns": [ + { + "include": "#comments" + }, + { + "begin": "\\b(request|response|error)\\b\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "keyword.control.wrn" + }, + "2": { + "name": "punctuation.definition.block.begin.wrn" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.end.wrn" + } + }, + "contentName": "meta.embedded.block.ts", + "patterns": [ + { + "include": "#ts-braces" + }, + { + "include": "source.ts" + } + ] + }, { "include": "#ts-braces" }, @@ -567,9 +592,6 @@ "include": "source.ts" } ] - }, - { - "include": "#functions-block" } ] }, diff --git a/editors/vscode/test/grammar-apis.test.js b/editors/vscode/test/grammar-apis.test.js new file mode 100644 index 00000000..9329b562 --- /dev/null +++ b/editors/vscode/test/grammar-apis.test.js @@ -0,0 +1,136 @@ +"use strict"; + +const assert = require("node:assert"); +const { test } = require("node:test"); +const { readFileSync } = require("node:fs"); +const { join } = require("node:path"); + +const grammarPath = join(__dirname, "../syntaxes/wrn.tmLanguage.json"); +const grammar = JSON.parse(readFileSync(grammarPath, "utf8")); + +test("the grammar knows the apis block", () => { + assert.ok(grammar.repository["apis-block"], "apis should appear as a block keyword"); +}); + +test("client keeps its highlighting where it is still valid", () => { + // client state {}, runtime = "client", and client function all survive. + // Only the client {} data block was removed. + assert.match(JSON.stringify(grammar), /client/, "client must still be matched"); + assert.match( + JSON.stringify(grammar), + /shared/, + "the shared function modifier must still be matched", + ); +}); + +// --- Structural assertions: these fail if the rule they name is deleted, --- +// --- even though the substrings "apis"/"client" remain elsewhere in the --- +// --- file (e.g. inside unrelated pattern names or comments). --- + +/** + * vscode-textmate is not a dependency of this repo (confirmed via + * `require.resolve`), so real tokenization is unavailable here. Instead we + * pull each rule's own `begin`/`match` regex out of the parsed grammar + * object and exercise it directly with the JS regex engine. The grammar's + * patterns use only `\b`, character classes, and `(?<=...)` lookbehind -- + * all supported by native JS regexes -- so this is a faithful proxy for + * "does this specific rule match this specific source line" without + * needing an Oniguruma-backed tokenizer. + */ +function ruleByTopLevelBlockName(name) { + const entry = grammar.repository[name]; + assert.ok(entry, `repository.${name} should exist`); + return entry; +} + +function topLevelBlockIncludes(name) { + return grammar.repository.blocks.patterns.some((p) => p.include === `#${name}`); +} + +test("blocks includes the apis-block pattern", () => { + assert.ok(topLevelBlockIncludes("apis-block"), "#blocks must include #apis-block"); +}); + +test("the apis-block begin pattern matches the container keyword", () => { + const rule = ruleByTopLevelBlockName("apis-block"); + const beginRe = new RegExp(rule.begin); + assert.ok(beginRe.test("apis {"), "apis-block begin should match 'apis {'"); + assert.ok(!beginRe.test("ssr {"), "apis-block begin must not match 'ssr {'"); + assert.ok(!beginRe.test("client {"), "apis-block begin must not match 'client {'"); +}); + +test("the apis-block declares an entry pattern for ", () => { + const rule = ruleByTopLevelBlockName("apis-block"); + const entryPattern = (rule.patterns || []).find( + (p) => p.name === "apis-entry" || (p.begin && /GET\|POST/.test(p.begin)), + ); + assert.ok(entryPattern, "apis-block should contain an entry declaration pattern"); + const entryRe = new RegExp(entryPattern.begin || entryPattern.match); + assert.ok( + entryRe.test("searchUsers POST /api/users {"), + "the entry pattern should match ' {'", + ); +}); + +test("the ssr/client data-block pattern (mode-block) is gone", () => { + assert.strictEqual( + grammar.repository["mode-block"], + undefined, + "mode-block (the removed ssr {}/client {} data-block rule) must be deleted from the repository", + ); + assert.ok( + !topLevelBlockIncludes("mode-block"), + "#blocks must no longer include the removed #mode-block pattern", + ); +}); + +// --- Regression coverage: constructs that must NOT be broken by this change --- + +test("REGRESSION: client state {} still highlights via grouped-state-block", () => { + const rule = ruleByTopLevelBlockName("grouped-state-block"); + const beginRe = new RegExp(rule.begin); + assert.ok( + beginRe.test("client state {"), + "grouped-state-block must still match 'client state {'", + ); +}); + +test('REGRESSION: runtime = "client" still highlights via execution-decl', () => { + const rule = ruleByTopLevelBlockName("execution-decl"); + const beginRe = new RegExp(rule.begin); + assert.ok( + beginRe.test('runtime = "client"'), + "execution-decl must still match 'runtime = \"client\"'", + ); +}); + +test("REGRESSION: the client function modifier still highlights via functions-block", () => { + const rule = ruleByTopLevelBlockName("functions-block"); + const modifierPattern = rule.patterns.find( + (p) => (p.match && /storage\.modifier\.async/.test("")) || p.match, + ); + const matchRe = new RegExp( + rule.patterns.find( + (p) => p.captures && p.captures["1"]?.name === "storage.modifier.runtime.wrn", + ).match, + ); + assert.ok( + matchRe.test("client async function go(): Promise {"), + "functions-block must still recognize the 'client' runtime modifier on a function", + ); +}); + +test("REGRESSION: the client function modifier also highlights via v060-keywords", () => { + const rule = ruleByTopLevelBlockName("v060-keywords"); + const modifierPattern = rule.patterns.find((p) => p.name === "storage.modifier.runtime.wrn"); + assert.ok(modifierPattern, "v060-keywords should have a storage.modifier.runtime.wrn rule"); + const matchRe = new RegExp(modifierPattern.match); + assert.ok( + matchRe.test("client function go"), + "v060-keywords must still recognize 'client' before 'function'", + ); + assert.ok( + matchRe.test("client state"), + "v060-keywords must still recognize 'client' before 'state'", + ); +});