diff --git a/README.md b/README.md index 45ecd6b..02856a1 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,14 @@ list (or wait for it to appear), then select the desired OpenFastTrace snippet. Snippets are based on the [OFT IntelliJ plugin](https://github.com/itsallcode/openfasttrace-intellij-plugin/blob/main/src/main/resources/liveTemplates/OpenFastTrace.xml). +### OFT declaration highlighting + +Markdown and reStructuredText OFT declarations, such as `req~example~1`, are +highlighted immediately while retaining their normal document language mode. +This first lexical highlighting slice does not validate declarations. Coverage +tags in source code will be highlighted in a follow-up PR using LSP semantic +tokens. + ## Requirements The extension requires Java 21 or later. It uses `openfasttrace.java.home` when diff --git a/doc/changesets/04-textmate-syntax-highlighting.md b/doc/changesets/04-textmate-syntax-highlighting.md new file mode 100644 index 0000000..6de455c --- /dev/null +++ b/doc/changesets/04-textmate-syntax-highlighting.md @@ -0,0 +1,71 @@ +# GH-4 TextMate Syntax Highlighting + +## Goal + +Provide immediate, lexical OpenFastTrace declaration highlighting in Markdown +and reStructuredText without changing either document's normal language mode. + +## Scope + +In scope: + +* Contribute one TextMate injection grammar for Markdown and reStructuredText. +* Highlight standalone OFT declarations in the `type~name~revision` form. +* Add automated grammar contribution checks and user documentation. + +Out of scope: + +* OFT parsing, validation, diagnostics, or semantic tokens. +* Highlighting coverage tags in source, configuration, or markup files. + +Coverage tags for source code will be added in a follow-up PR using LSP +semantic tokens. + +## Design References + +* [System Requirements](../system_requirements.md) +* [LSP-first Architecture](../design/architecture.md) +* [Quality Requirements](../design/quality_requirements.md) + +## Strategy + +Inject one grammar into VS Code's `text.html.markdown` and `source.rst` host +scopes. It supplies fast lexical scopes for OFT declaration components while +leaving the host grammar responsible for all ordinary Markdown and +reStructuredText content. + +## Task List + +### Requirements And Design + +- [x] Revise the editor-presentation design item to describe TextMate-only + declaration highlighting in Markdown and reStructuredText, and justify + LSP semantic tokens for cross-language coverage-tag styling. +- [x] Preserve the existing backlog design classification and confirm the MVP + trace remains clean. + +### Implementation + +- [x] Contribute the TextMate injection grammar and target the Markdown and + reStructuredText host scopes. +- [x] Scope the declaration, artifact type, name, and revision independently + for theme authors. + +### Verification + +- [x] Add tests for the grammar contribution, its host scopes, valid OFT + declaration forms, and near-miss input. +- [x] Run extension compile, lint, unit/integration tests, and the + OpenFastTrace requirements trace. + +### Update User Documentation + +- [x] Document immediate declaration highlighting and the deferred + source-code coverage-tag highlighting follow-up. + +## Version and Changelog Update + +- [ ] Check the current version against the latest GitHub release. +- [ ] Increment the semantic version for this feature. +- [ ] Add the release changelog entry, including the bundled OpenFastTrace + version from resolved Gradle dependency metadata. diff --git a/doc/design/architecture.md b/doc/design/architecture.md index f0a4bea..3795ad8 100644 --- a/doc/design/architecture.md +++ b/doc/design/architecture.md @@ -82,11 +82,19 @@ Covers: - `impl~workspace-symbol-protocol-slice~1` ### Editor Presentation -`dsn~editor-presentation~1` - -The VS Code adapter contributes a Markdown TextMate injection grammar for -immediate declaration coloring and maps LSP semantic tokens to OFT coverage-tag -styling. It preserves the host language of every document. +`dsn~editor-presentation~3` + +The VS Code adapter contributes a TextMate injection grammar for immediate OFT +declaration coloring in Markdown and reStructuredText. It preserves the host +language of every document. A later change maps LSP semantic tokens to OFT +coverage-tag styling in source, configuration, and markup files. + +TextMate grammar injection is intentionally not used for coverage tags. The +set of supported source, configuration, and markup languages would require +per-host grammar injection rules and lexical matching cannot reliably separate +valid coverage tags from lookalike text. The language server instead owns OFT +tag parsing and supplies semantic tokens, so coverage-tag styling is consistent +across host languages and can later reflect parsed validity and link resolution. Tags: backlog diff --git a/extension/package.json b/extension/package.json index ccc4d32..10dc4d6 100644 --- a/extension/package.json +++ b/extension/package.json @@ -26,6 +26,16 @@ "path": "./snippets/openfasttrace-restructuredtext.code-snippets" } ], + "grammars": [ + { + "scopeName": "source.openfasttrace.injection", + "path": "./syntaxes/openfasttrace.tmLanguage.json", + "injectTo": [ + "text.html.markdown", + "source.rst" + ] + } + ], "configuration": { "title": "OpenFastTrace", "properties": { diff --git a/extension/src/test/integration/extension.test.ts b/extension/src/test/integration/extension.test.ts index f7e1d8d..97f7a5d 100644 --- a/extension/src/test/integration/extension.test.ts +++ b/extension/src/test/integration/extension.test.ts @@ -25,6 +25,28 @@ const templateLabels = [ ]; const supportedDocumentLanguages = ['markdown', 'restructuredtext']; +const matchingOftIds = [ + 'arch~system-architecture~1', + 'arch~System.Architecture~1', + 'arch~initial-revision~0', + 'arch~long-revision~123', + 'bconstr~budget~2', + 'constr~java-version~3', + 'custom_artifact~highlighting~1', + 'CUSTOM~UPPER.Case~1', + 'dsn~editor-presentation~2', + 'feat~recognize-oft-content~1', + 'qg~quality~1', + 'qs~fast-feedback~1', + 'req~highlight-oft-declarations~1', + 'scn~recognize-and-highlight-oft-content~1', +]; +const nonMatchingOftIds = [ + 'req~not-kebab_case~1', + 'req~missing-revision~one', + 'req~negative-revision~-1', + `[impl${'->'}dsn~editor-presentation~2]`, +]; interface SnippetDefinition { body: string[]; @@ -35,6 +57,21 @@ interface SnippetContribution { path: string; } +interface GrammarContribution { + scopeName: string; + path: string; + injectTo: string[]; +} + +interface TextMateGrammar { + scopeName: string; + injectionSelector: string; + patterns: Array<{ + match: string; + captures: Record; + }>; +} + suite('OpenFastTrace extension', () => { test('starts the language server and returns an empty workspace symbol result', async () => { const extension = vscode.extensions.getExtension('itsallcode.openfasttrace'); @@ -73,6 +110,38 @@ suite('OpenFastTrace extension', () => { }); } } + + test('contributes a declaration grammar for Markdown and reStructuredText', async () => { + const grammar = await openFastTraceGrammar(); + + assert.deepStrictEqual(grammar.injectTo, ['text.html.markdown', 'source.rst']); + assert.equal(grammar.definition.scopeName, grammar.scopeName); + assert.equal(grammar.definition.injectionSelector, 'L:text.html.markdown, L:markup.inline.raw.string.markdown, L:source.rst'); + + const captures = grammar.definition.patterns[1].captures; + assert.equal(captures['1'].name, 'entity.name.openfasttrace.declaration'); + assert.equal(captures['2'].name, 'keyword.control.openfasttrace.artifact-type'); + assert.equal(captures['3'].name, 'entity.name.openfasttrace.artifact-name'); + assert.equal(captures['4'].name, 'constant.numeric.openfasttrace.revision'); + }); + + for (const id of matchingOftIds) { + test(`recognizes ${id}`, async () => { + const grammar = await openFastTraceGrammar(); + + assert.match(id, declarationPattern(grammar)); + assert.match(`\`${id}\``, backtickedDeclarationPattern(grammar)); + }); + } + + for (const id of nonMatchingOftIds) { + test(`does not recognize ${id}`, async () => { + const grammar = await openFastTraceGrammar(); + + assert.doesNotMatch(id, declarationPattern(grammar)); + assert.doesNotMatch(`\`${id}\``, backtickedDeclarationPattern(grammar)); + }); + } }); async function snippetsFor(language: string): Promise> { @@ -85,3 +154,22 @@ async function snippetsFor(language: string): Promise; } + +async function openFastTraceGrammar(): Promise<{ scopeName: string; injectTo: string[]; definition: TextMateGrammar }> { + const extension = vscode.extensions.getExtension('itsallcode.openfasttrace'); + assert.ok(extension, 'OpenFastTrace extension must be available to the test host'); + const contributions = extension.packageJSON.contributes.grammars as GrammarContribution[]; + const grammar = contributions.find(candidate => candidate.scopeName === 'source.openfasttrace.injection'); + + assert.ok(grammar, 'OpenFastTrace must contribute its TextMate injection grammar'); + const contents = await vscode.workspace.fs.readFile(vscode.Uri.joinPath(extension.extensionUri, grammar.path)); + return { scopeName: grammar.scopeName, injectTo: grammar.injectTo, definition: JSON.parse(new TextDecoder().decode(contents)) as TextMateGrammar }; +} + +function backtickedDeclarationPattern(grammar: { definition: TextMateGrammar }): RegExp { + return new RegExp(grammar.definition.patterns[0].match); +} + +function declarationPattern(grammar: { definition: TextMateGrammar }): RegExp { + return new RegExp(grammar.definition.patterns[1].match); +} diff --git a/extension/syntaxes/openfasttrace.tmLanguage.json b/extension/syntaxes/openfasttrace.tmLanguage.json new file mode 100644 index 0000000..266ee7c --- /dev/null +++ b/extension/syntaxes/openfasttrace.tmLanguage.json @@ -0,0 +1,46 @@ +{ + "scopeName": "source.openfasttrace.injection", + "injectionSelector": "L:text.html.markdown, L:markup.inline.raw.string.markdown, L:source.rst", + "patterns": [ + { + "match": "(`+)(([^~\\s>\\[\\]]+)~([A-Za-z][A-Za-z0-9.-]*)~([0-9]+))(\\1)", + "captures": { + "1": { + "name": "punctuation.definition.raw.markdown" + }, + "2": { + "name": "entity.name.openfasttrace.declaration" + }, + "3": { + "name": "keyword.control.openfasttrace.artifact-type" + }, + "4": { + "name": "entity.name.openfasttrace.artifact-name" + }, + "5": { + "name": "constant.numeric.openfasttrace.revision" + }, + "6": { + "name": "punctuation.definition.raw.markdown" + } + } + }, + { + "match": "(?)\\b(([^~\\s>\\[\\]]+)~([A-Za-z][A-Za-z0-9.-]*)~([0-9]+))\\b", + "captures": { + "1": { + "name": "entity.name.openfasttrace.declaration" + }, + "2": { + "name": "keyword.control.openfasttrace.artifact-type" + }, + "3": { + "name": "entity.name.openfasttrace.artifact-name" + }, + "4": { + "name": "constant.numeric.openfasttrace.revision" + } + } + } + ] +}