diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..85beaf8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# Agent Instructions + +## Java Test Style + +* Use Hamcrest `assertThat` assertions instead of basic JUnit assertions such + as `assertEquals`, `assertTrue`, or `assertFalse`. +* Group multiple assertions in one test with JUnit Jupiter's `assertAll`. +* Import Java classes and refer to their simple class names; do not use fully + qualified names such as `java.util.List` in source code. + +## OpenFastTrace Coverage + +* Do not add `itest` items or `[itest->…]` coverage tags to Markdown files. + Add integration-test coverage tags directly to the relevant source test + files instead. diff --git a/README.md b/README.md index 02856a1..bf86f0c 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,14 @@ 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. +### OFT coverage-tag highlighting + +Valid long-form coverage tags, such as `[impl->dsn~editor-presentation~3]`, +are semantically highlighted in source, configuration, and markup files +supported by OpenFastTrace. This does not validate that the target exists; it +only recognizes the tag syntax while preserving the host language mode. See +[Known Issues](#known-issues) for language-server compatibility limitations. + ## Requirements The extension requires Java 21 or later. It uses `openfasttrace.java.home` when @@ -61,7 +69,18 @@ runtime through `JAVA_HOME` or `PATH`. ## Known Issues -Calling out known issues can help limit users opening duplicate issues against your extension. +Coverage-tag highlighting is not reliable in Java and may be unavailable in +other file types when their VS Code language extension provides a more-specific +semantic-token provider. VS Code uses that provider instead of the OFT provider +for the whole document, so the OFT tag can briefly appear highlighted and then +revert to an ordinary comment. TypeScript currently works because the OFT +provider wins the selection there. + +The planned solution is to apply editor decorations to parser-confirmed OFT +tag ranges. Decorations overlay the host language's highlighting instead of +competing with its semantic-token provider. Definition and reference navigation +are not subject to this limitation: VS Code can merge their results from OFT +and the host language server. ## Release Notes diff --git a/doc/changesets/05-semantic-coverage-tag-highlighting.md b/doc/changesets/05-semantic-coverage-tag-highlighting.md new file mode 100644 index 0000000..32b3085 --- /dev/null +++ b/doc/changesets/05-semantic-coverage-tag-highlighting.md @@ -0,0 +1,61 @@ +# 05 Semantic Coverage-Tag Highlighting + +## Goal + +Highlight valid OpenFastTrace coverage tags in files supported by the tag +importer while preserving the host language's normal syntax highlighting. + +## Scope + +In scope: + +* Provide `textDocument/semanticTokens/full` from the Java language server. +* Track opened documents and recognize valid long-form coverage tags. +* Map the semantic token to an OpenFastTrace VS Code presentation scope. + +Out of scope: + +* Short, path-configured tags, validation diagnostics, indexing, navigation, + completion, and trace execution. + +## Design References + +* [System Requirements](../system_requirements.md) +* [LSP-first Architecture](../design/architecture.md) +* [Quality Requirements](../design/quality_requirements.md) + +## Task List + +### Requirements And Design + +- [x] Confirm `req~highlight-coverage-tags~1` and + `scn~recognize-and-highlight-oft-content~1` cover this behavior. +- [x] Add traced implementation and integration-test items beneath + `dsn~editor-presentation~3`. +- [x] Add the complete recognition and editor-presentation chain to the MVP + trace. + +### Implementation + +- [x] Advertise semantic-token support, retain opened document text, and emit + tokens for valid long-form coverage tags in supported file types. +- [x] Extend the language client selector and map the custom semantic token to + the OpenFastTrace coverage-tag scope. + +### Verification + +- [x] Add Java unit/protocol tests and VS Code integration coverage for valid, + malformed, changed, and unsupported-file content. +- [x] Run server tests, extension compile/lint/unit/integration tests, the full + Gradle build, and the OpenFastTrace requirements trace. + +### Update User Documentation + +- [x] Document coverage-tag highlighting and its long-form scope in README.md. + +## 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 3795ad8..c60c10c 100644 --- a/doc/design/architecture.md +++ b/doc/design/architecture.md @@ -14,6 +14,12 @@ from each connected client. It is implemented with Eclipse LSP4J and serves the protocol over standard input and output. Standard output is reserved for protocol messages; diagnostics and operational logs are written elsewhere. +Standard navigation coexists with the host-language language server. VS Code +requests definition and reference results from applicable providers in parallel +and merges their locations. This lets OFT resolve a coverage tag to its OFT +declaration and find coverage tags for an OFT declaration without suppressing +Java or other host-language navigation. + Tags: mvp Covers: @@ -21,6 +27,15 @@ Covers: Needs: impl +#### Open Issue: Authoritative OFT Parsing + +The current coverage-tag recognition uses LSP-local parsing logic, including a +regular expression and a copied list of supported file extensions. Replace it +with the OpenFastTrace library's parser and tag-importer APIs. The language +server must obtain validation and ranges from that authoritative implementation +so its highlighting, navigation, and trace behavior cannot drift from +OpenFastTrace. + ### VS Code Language Client `dsn~vscode-language-client~2` @@ -82,7 +97,7 @@ Covers: - `impl~workspace-symbol-protocol-slice~1` ### Editor Presentation -`dsn~editor-presentation~3` +`dsn~editor-presentation~4` The VS Code adapter contributes a TextMate injection grammar for immediate OFT declaration coloring in Markdown and reStructuredText. It preserves the host @@ -92,17 +107,53 @@ 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. +valid coverage tags from lookalike text. + +The language server owns OFT tag parsing. Its semantic-token response is not a +reliable cross-language presentation mechanism: VS Code selects one +full-document semantic-token provider rather than merging their results. A +language-specific provider with a more specific document selector, such as the +Java extension, therefore replaces the OFT tokens after it activates. Raising +the OFT provider's selector score would instead suppress the host language's +semantic highlighting. The VS Code adapter must render parser-confirmed +coverage-tag ranges with editor decorations so they overlay, rather than +compete with, host language semantic highlighting. -Tags: backlog +Tags: mvp Covers: - `scn~recognize-and-highlight-oft-content~1` Needs: impl +#### Open Issue: Coverage-Tag Syntax Highlighting + +Coverage-tag syntax highlighting is not currently reliable in Java or in any +other supported file type for which an installed language extension supplies a +more-specific semantic-token provider. This can affect languages such as C#, +C/C++, Python, Go, Rust, YAML, JSON, HTML, and CSS; the exact set depends on +the extensions installed in VS Code. A semantic-token solution works only when +the OFT provider wins provider selection, as currently observed for TypeScript. +The open implementation work is to render parser-confirmed tag ranges with +editor decorations, preserving the host language's syntax and semantic +highlighting. + +### Coverage-Tag Semantic Tokens +`impl~coverage-tag-semantic-tokens~1` + +The language server tracks the current contents of opened files supported by +the OpenFastTrace tag importer and serves `textDocument/semanticTokens/full`. +It emits the standard `type` token only for syntactically valid +long-form coverage tags, letting VS Code apply the active theme's standard +semantic-token styling without replacing each document's host language grammar. + +Tags: mvp + +Covers: +- `dsn~editor-presentation~4` + +Needs: itest + ### OFT Template Snippets `dsn~oft-template-snippets~1` diff --git a/doc/system_requirements.md b/doc/system_requirements.md index 5a85906..1efa991 100644 --- a/doc/system_requirements.md +++ b/doc/system_requirements.md @@ -26,7 +26,7 @@ only that scope. The product recognizes OFT declarations in Markdown, reStructuredText, and coverage tags in supported workspace files. -Tags: backlog +Tags: mvp Needs: req @@ -107,7 +107,7 @@ Needs: req The editor highlights OFT declarations in `.md`, `.markdown`, and `.rst` documents without changing the documents' ordinary language mode. -Tags: backlog +Tags: mvp Covers: - `feat~recognize-oft-content~1` @@ -133,7 +133,7 @@ Needs: scn The editor highlights OFT coverage tags in source, configuration, and markup files supported by the OpenFastTrace tag importer. -Tags: backlog +Tags: mvp Covers: - `feat~recognize-oft-content~1` @@ -309,7 +309,7 @@ Needs: scn **Then** the extension highlights declarations or coverage tags in the relevant range while preserving the file's normal language support. -Tags: backlog +Tags: mvp Covers: - `req~highlight-oft-declarations~1` diff --git a/extension/src/extension.ts b/extension/src/extension.ts index d021d95..2749e5f 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -7,20 +7,26 @@ import { resolveJavaExecutable } from './javaRuntime'; let client: LanguageClient | undefined; export async function activate(context: vscode.ExtensionContext): Promise<{ getLanguageClient: typeof getLanguageClient }> { + const outputChannel = vscode.window.createOutputChannel('OpenFastTrace', { log: true }); + context.subscriptions.push(outputChannel); try { + outputChannel.info('Resolving Java runtime.'); const configuredJavaHome = vscode.workspace.getConfiguration('openfasttrace').get('java.home'); const javaExecutable = await resolveJavaExecutable(configuredJavaHome || undefined, process.env); + outputChannel.info(`Starting language server with ${javaExecutable}.`); client = new LanguageClient( 'openfasttraceLanguageServer', 'OpenFastTrace Language Server', serverOptions(context, javaExecutable), - clientOptions, + clientOptions(outputChannel), ); context.subscriptions.push(client); await client.start(); + outputChannel.info('OpenFastTrace extension activated.'); return { getLanguageClient }; } catch (error) { const message = error instanceof Error ? error.message : String(error); + outputChannel.error(`Unable to start the OpenFastTrace language server: ${message}`); void vscode.window.showErrorMessage(`Unable to start the OpenFastTrace language server: ${message}`); throw error; } @@ -49,6 +55,10 @@ function serverEnvironment(javaExecutable: string): NodeJS.ProcessEnv { return { ...process.env, JAVA_HOME: path.dirname(path.dirname(javaExecutable)) }; } -const clientOptions: LanguageClientOptions = { - documentSelector: ['markdown', 'restructuredtext'], -}; +function clientOptions(outputChannel: vscode.LogOutputChannel): LanguageClientOptions { + return { + documentSelector: [{ scheme: 'file' }], + outputChannel, + }; +} + diff --git a/extension/src/test/integration/extension.test.ts b/extension/src/test/integration/extension.test.ts index 97f7a5d..e302dca 100644 --- a/extension/src/test/integration/extension.test.ts +++ b/extension/src/test/integration/extension.test.ts @@ -4,7 +4,8 @@ import * as vscode from 'vscode'; // [itest->dsn~oft-template-snippets~1] interface OpenFastTraceExports { getLanguageClient(): { - sendRequest(method: string, parameters: { query: string }): Thenable; + sendRequest(method: string, parameters: unknown): Thenable; + sendNotification(method: string, parameters: unknown): Thenable; } | undefined; } @@ -125,6 +126,28 @@ suite('OpenFastTrace extension', () => { assert.equal(captures['4'].name, 'constant.numeric.openfasttrace.revision'); }); + // [itest->impl~coverage-tag-semantic-tokens~1] + test('returns coverage-tag semantic tokens from the live language server', async () => { + const extension = vscode.extensions.getExtension('itsallcode.openfasttrace'); + assert.ok(extension, 'OpenFastTrace extension must be available to the test host'); + await extension.activate(); + const client = extension.exports.getLanguageClient(); + assert.ok(client, 'OpenFastTrace language client must be started'); + + await client.sendNotification('textDocument/didOpen', { + textDocument: { + uri: 'file:///workspace/coverage-tag.java', languageId: 'java', version: 1, + text: `// ${coverageTag('dsn~editor-presentation~3')}\n// ${coverageTag('dsn~1invalid~3')}`, + }, + }); + + const tokens = await client.sendRequest('textDocument/semanticTokens/full', { + textDocument: { uri: 'file:///workspace/coverage-tag.java' }, + }) as { data: number[] }; + + assert.deepStrictEqual(tokens.data, [0, 3, 33, 0, 0]); + }); + for (const id of matchingOftIds) { test(`recognizes ${id}`, async () => { const grammar = await openFastTraceGrammar(); @@ -173,3 +196,7 @@ function backtickedDeclarationPattern(grammar: { definition: TextMateGrammar }): function declarationPattern(grammar: { definition: TextMateGrammar }): RegExp { return new RegExp(grammar.definition.patterns[1].match); } + +function coverageTag(target: string): string { + return `[impl${'->'}${target}]`; +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 967745a..1bf3ee0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,9 @@ lsp4j = "1.0.0" openfasttrace = "4.4.0" junit = "5.13.4" +hamcrest = "3.0" [libraries] lsp4j = { group = "org.eclipse.lsp4j", name = "org.eclipse.lsp4j", version.ref = "lsp4j" } openfasttrace = { group = "org.itsallcode.openfasttrace", name = "openfasttrace", version.ref = "openfasttrace" } +hamcrest = { group = "org.hamcrest", name = "hamcrest", version.ref = "hamcrest" } diff --git a/server/build.gradle b/server/build.gradle index 0ae4918..7e22065 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -35,6 +35,7 @@ testing { test { useJUnitJupiter(libs.versions.junit.get()) dependencies { + implementation libs.hamcrest } } } diff --git a/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServer.java b/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServer.java index 64d4d6a..95572a5 100644 --- a/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServer.java +++ b/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServer.java @@ -1,9 +1,16 @@ package org.itsallcode.openfasttrace.lsp; +import java.util.List; import java.util.concurrent.CompletableFuture; import org.eclipse.lsp4j.InitializeParams; import org.eclipse.lsp4j.InitializeResult; +import org.eclipse.lsp4j.InitializedParams; +import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.MessageType; +import org.eclipse.lsp4j.SemanticTokenTypes; +import org.eclipse.lsp4j.SemanticTokensLegend; +import org.eclipse.lsp4j.SemanticTokensWithRegistrationOptions; import org.eclipse.lsp4j.ServerCapabilities; import org.eclipse.lsp4j.TextDocumentSyncKind; import org.eclipse.lsp4j.services.LanguageClient; @@ -17,14 +24,18 @@ * incrementally. */ public final class OpenFastTraceLanguageServer implements LanguageServer, LanguageClientAware { + static final String COVERAGE_TAG_TOKEN_TYPE = SemanticTokenTypes.Type; private final TextDocumentService textDocuments = new OpenFastTraceTextDocumentService(); private final WorkspaceService workspace = new OpenFastTraceWorkspaceService(); + private LanguageClient client = null; @Override public CompletableFuture initialize(final InitializeParams parameters) { final ServerCapabilities capabilities = new ServerCapabilities(); capabilities.setTextDocumentSync(TextDocumentSyncKind.Full); capabilities.setWorkspaceSymbolProvider(true); + capabilities.setSemanticTokensProvider(new SemanticTokensWithRegistrationOptions( + new SemanticTokensLegend(List.of(COVERAGE_TAG_TOKEN_TYPE), List.of()), true)); return CompletableFuture.completedFuture(new InitializeResult(capabilities)); } @@ -33,6 +44,12 @@ public CompletableFuture shutdown() { return CompletableFuture.completedFuture(null); } + @Override + public void initialized(final InitializedParams parameters) { + this.client.logMessage(new MessageParams(MessageType.Log, + "OpenFastTrace language server initialized, using Java " + System.getProperty("java.version"))); + } + @Override @SuppressWarnings("java:S1147") // System.exit() is appropriate for a language server. public void exit() { @@ -51,7 +68,6 @@ public WorkspaceService getWorkspaceService() { @Override public void connect(final LanguageClient client) { - // The client will be used when OFT diagnostics and refresh notifications are - // implemented. + this.client = client; } } diff --git a/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceTextDocumentService.java b/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceTextDocumentService.java index ab30d9e..f3a36d3 100644 --- a/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceTextDocumentService.java +++ b/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceTextDocumentService.java @@ -1,30 +1,107 @@ package org.itsallcode.openfasttrace.lsp; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + import org.eclipse.lsp4j.DidChangeTextDocumentParams; import org.eclipse.lsp4j.DidCloseTextDocumentParams; import org.eclipse.lsp4j.DidOpenTextDocumentParams; import org.eclipse.lsp4j.DidSaveTextDocumentParams; +import org.eclipse.lsp4j.SemanticTokens; +import org.eclipse.lsp4j.SemanticTokensParams; import org.eclipse.lsp4j.services.TextDocumentService; -/** Placeholder for OFT document parsing and language features. */ +/** + * Tracks open documents and provides semantic highlighting for coverage tags. + */ +// [impl->dsn~editor-presentation~4] final class OpenFastTraceTextDocumentService implements TextDocumentService { + private static final Set SUPPORTED_FILE_EXTENSIONS = Set.of( + "ads", "adb", "bat", "c", "C", "cc", "cpp", "c++", "h", "H", "h++", "hh", "hpp", "c#", "cs", "cfg", + "conf", "ini", "feature", "go", "groovy", "json", "htm", "html", "xhtml", "yaml", "yml", "java", + "clj", "kt", "kts", "scala", "js", "mjs", "cjs", "ejs", "ts", "lua", "m", "mm", "php", "proto", + "pl", "pm", "py", "robot", "pu", "puml", "plantuml", "r", "rs", "sh", "bash", "zsh", "sv", "v", + "inc", "swift", "toml", "tf", "tfvars", "sql", "pls"); + private static final Pattern COVERAGE_TAG_PATTERN = Pattern.compile( + "\\[\\s*\\p{Alpha}+(?:~\\p{Alpha}[\\p{L}\\p{N}_-]*(?:\\.[\\p{L}\\p{N}_-]+)*+~\\d+)?\\s*->\\s*" + + "\\p{Alpha}+~\\p{Alpha}[\\p{L}\\p{N}_-]*(?:\\.[\\p{L}\\p{N}_-]+)*+~\\d+" + + "(?:\\s*>>\\s*\\p{Alpha}+(?:\\s*,\\s*\\p{Alpha}+)*)?\\s*\\]", + Pattern.UNICODE_CHARACTER_CLASS); + + private final Map documents = new ConcurrentHashMap<>(); + @Override public void didOpen(final DidOpenTextDocumentParams parameters) { - // Document tracking is added with the parser. + this.documents.put(parameters.getTextDocument().getUri(), parameters.getTextDocument().getText()); } @Override public void didChange(final DidChangeTextDocumentParams parameters) { - // Document tracking is added with the parser. + final List changes = parameters.getContentChanges().stream() + .filter(change -> change.getRange() == null) + .map(change -> change.getText()) + .toList(); + if (!changes.isEmpty()) { + this.documents.put(parameters.getTextDocument().getUri(), changes.getLast()); + } } @Override public void didClose(final DidCloseTextDocumentParams parameters) { - // Document tracking is added with the parser. + this.documents.remove(parameters.getTextDocument().getUri()); } @Override public void didSave(final DidSaveTextDocumentParams parameters) { - // Document tracking is added with the parser. + // The client owns the current text and has already sent it in didOpen or + // didChange. + } + + @Override + public CompletableFuture semanticTokensFull(final SemanticTokensParams parameters) { + final String uri = parameters.getTextDocument().getUri(); + if (!isSupportedFile(uri)) { + return CompletableFuture.completedFuture(new SemanticTokens(List.of())); + } + return CompletableFuture.completedFuture(new SemanticTokens(tokensFor(this.documents.get(uri)))); + } + + private static boolean isSupportedFile(final String documentUri) { + final String path = URI.create(documentUri).getPath(); + if (path == null) { + return false; + } + final int extensionStart = path.lastIndexOf('.') + 1; + return extensionStart > 0 && SUPPORTED_FILE_EXTENSIONS.contains(path.substring(extensionStart)); + } + + private static List tokensFor(final String text) { + if (text == null) { + return List.of(); + } + final var tokens = new ArrayList(); + int previousLine = 0; + int previousStart = 0; + final String[] lines = text.split("\\R", -1); + for (int lineNumber = 0; lineNumber < lines.length; lineNumber++) { + final Matcher matcher = COVERAGE_TAG_PATTERN.matcher(lines[lineNumber]); + while (matcher.find()) { + tokens.add(lineNumber - previousLine); + tokens.add(lineNumber == previousLine ? (matcher.start() - previousStart) : matcher.start()); + tokens.add(matcher.end() - matcher.start()); + tokens.add(0); + tokens.add(0); + previousLine = lineNumber; + previousStart = matcher.start(); + } + } + return tokens; } } diff --git a/server/src/test/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServerTest.java b/server/src/test/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServerTest.java index 34dced7..f1c8f20 100644 --- a/server/src/test/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServerTest.java +++ b/server/src/test/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServerTest.java @@ -1,13 +1,29 @@ package org.itsallcode.openfasttrace.lsp; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertAll; +import java.lang.reflect.Proxy; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.eclipse.lsp4j.DidChangeTextDocumentParams; +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; import org.eclipse.lsp4j.InitializeParams; +import org.eclipse.lsp4j.InitializedParams; +import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.MessageType; +import org.eclipse.lsp4j.SemanticTokensParams; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.TextDocumentItem; import org.eclipse.lsp4j.TextDocumentSyncKind; +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; import org.eclipse.lsp4j.WorkspaceSymbolParams; +import org.eclipse.lsp4j.services.LanguageClient; +import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; class OpenFastTraceLanguageServerTest { @@ -17,8 +33,7 @@ void negotiatesFullDocumentSynchronization() { final var result = server.initialize(new InitializeParams()).join(); - assertEquals(TextDocumentSyncKind.Full, - result.getCapabilities().getTextDocumentSync().getLeft()); + assertThat(result.getCapabilities().getTextDocumentSync().getLeft(), is(TextDocumentSyncKind.Full)); } @Test @@ -27,7 +42,94 @@ void negotiatesWorkspaceSymbolsAndReturnsAnEmptyResult() { final var result = server.initialize(new InitializeParams()).join(); - assertTrue(result.getCapabilities().getWorkspaceSymbolProvider().getLeft()); - assertEquals(List.of(), server.getWorkspaceService().symbol(new WorkspaceSymbolParams()).join().getLeft()); + assertAll( + () -> assertThat(result.getCapabilities().getWorkspaceSymbolProvider().getLeft(), is(true)), + () -> assertThat(server.getWorkspaceService().symbol(new WorkspaceSymbolParams()).join().getLeft(), + is(List.of()))); + } + + @Test + void negotiatesSemanticTokensForCoverageTags() { + final OpenFastTraceLanguageServer server = new OpenFastTraceLanguageServer(); + + final var result = server.initialize(new InitializeParams()).join(); + + assertAll( + () -> assertThat(result.getCapabilities().getSemanticTokensProvider().getLegend().getTokenTypes(), + is(List.of(OpenFastTraceLanguageServer.COVERAGE_TAG_TOKEN_TYPE))), + () -> assertThat(result.getCapabilities().getSemanticTokensProvider().getFull().getLeft(), is(true))); + } + + @Test + void logsServerStartupToTheLanguageClient() { + final OpenFastTraceLanguageServer server = new OpenFastTraceLanguageServer(); + final AtomicReference startupMessage = new AtomicReference<>(); + server.connect(loggingClient(startupMessage)); + + server.initialized(new InitializedParams()); + + assertAll( + () -> assertThat(startupMessage.get().getType(), is(MessageType.Log)), + () -> assertThat(startupMessage.get().getMessage(), + Matchers.startsWith("OpenFastTrace language server initialized, using Java"))); + } + + @Test + void returnsTokensForValidCoverageTagsInSupportedFilesAndUpdatesThemOnChange() { + final OpenFastTraceTextDocumentService documents = new OpenFastTraceTextDocumentService(); + final String uri = "file:///workspace/source.ts"; + documents.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem(uri, "typescript", 1, + "// " + coverageTag("dsn~editor-presentation~3")))); + + assertThat(tokensFor(documents, uri), is(List.of(0, 3, 33, 0, 0))); + + documents.didChange(new DidChangeTextDocumentParams(new VersionedTextDocumentIdentifier(uri, 2), + List.of(new TextDocumentContentChangeEvent("// [impl->dsn~1invalid~3]")))); + + assertThat(tokensFor(documents, uri), is(List.of())); + } + + @Test + void recognizesSourceConfigurationAndMarkupFilesButIgnoresMalformedAndUnsupportedTags() { + final OpenFastTraceTextDocumentService documents = new OpenFastTraceTextDocumentService(); + documents.didOpen( + new DidOpenTextDocumentParams(new TextDocumentItem("file:///workspace/source.ts", "typescript", 1, + "// " + coverageTag("dsn~editor-presentation~3")))); + documents.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem("file:///workspace/source.yaml", "yaml", 1, + "# " + coverageTag("dsn~editor-presentation~3") + "\n# " + coverageTag("dsn~1invalid~3")))); + documents.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem("file:///workspace/page.html", "html", 1, + ""))); + documents.didOpen( + new DidOpenTextDocumentParams(new TextDocumentItem("file:///workspace/requirements.md", "markdown", 1, + coverageTag("dsn~editor-presentation~3")))); + + assertAll( + () -> assertThat(tokensFor(documents, "file:///workspace/source.ts"), is(List.of(0, 3, 33, 0, 0))), + () -> assertThat(tokensFor(documents, "file:///workspace/source.yaml"), is(List.of(0, 2, 33, 0, 0))), + () -> assertThat(tokensFor(documents, "file:///workspace/page.html"), is(List.of(0, 5, 33, 0, 0))), + () -> assertThat(tokensFor(documents, "file:///workspace/requirements.md"), is(List.of()))); + + documents.didClose(new DidCloseTextDocumentParams(new TextDocumentIdentifier("file:///workspace/source.ts"))); + + assertThat(tokensFor(documents, "file:///workspace/source.ts"), is(List.of())); + } + + private static List tokensFor(final OpenFastTraceTextDocumentService documents, final String uri) { + return documents.semanticTokensFull(new SemanticTokensParams(new TextDocumentIdentifier(uri))).join().getData(); + } + + private static String coverageTag(final String target) { + return "[impl" + "->" + target + "]"; + } + + private static LanguageClient loggingClient(final AtomicReference startupMessage) { + return (LanguageClient) Proxy.newProxyInstance( + OpenFastTraceLanguageServerTest.class.getClassLoader(), new Class[] { LanguageClient.class }, + (proxy, method, arguments) -> { + if (method.getName().equals("logMessage")) { + startupMessage.set((MessageParams) arguments[0]); + } + return null; + }); } }