diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 700bd3d..ae48e78 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] lsp4j = "1.0.0" -openfasttrace = "4.7.0" +openfasttrace = "4.9.0" junit = "5.13.4" hamcrest = "3.0" diff --git a/server/build.gradle b/server/build.gradle index 83f52e8..72b9c52 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -14,7 +14,7 @@ java { } application { - mainClass = 'org.itsallcode.openfasttrace.lsp.OpenFastTraceLanguageServerMain' + mainClass = 'org.itsallcode.openfasttrace.lsp.OftLanguageServerMain' applicationName = 'openfasttrace-language-server' } diff --git a/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServer.java b/server/src/main/java/org/itsallcode/openfasttrace/lsp/OftLanguageServer.java similarity index 79% rename from server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServer.java rename to server/src/main/java/org/itsallcode/openfasttrace/lsp/OftLanguageServer.java index 95572a5..9daa294 100644 --- a/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServer.java +++ b/server/src/main/java/org/itsallcode/openfasttrace/lsp/OftLanguageServer.java @@ -8,7 +8,6 @@ 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; @@ -23,10 +22,9 @@ * Minimal LSP4J server bootstrap; OFT parsing and navigation are added * 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(); +public final class OftLanguageServer implements LanguageServer, LanguageClientAware { + private final TextDocumentService textDocuments = new OftTextDocumentService(); + private final WorkspaceService workspace = new OftWorkspaceService(); private LanguageClient client = null; @Override @@ -34,11 +32,15 @@ public CompletableFuture initialize(final InitializeParams par 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)); + capabilities.setSemanticTokensProvider( + new SemanticTokensWithRegistrationOptions(semanticTokensLegend(), true)); return CompletableFuture.completedFuture(new InitializeResult(capabilities)); } + static SemanticTokensLegend semanticTokensLegend() { + return OftTextDocumentService.semanticTokensLegend(); + } + @Override public CompletableFuture shutdown() { return CompletableFuture.completedFuture(null); diff --git a/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServerMain.java b/server/src/main/java/org/itsallcode/openfasttrace/lsp/OftLanguageServerMain.java similarity index 85% rename from server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServerMain.java rename to server/src/main/java/org/itsallcode/openfasttrace/lsp/OftLanguageServerMain.java index 96c2a1d..3ce0a79 100644 --- a/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServerMain.java +++ b/server/src/main/java/org/itsallcode/openfasttrace/lsp/OftLanguageServerMain.java @@ -7,8 +7,8 @@ import org.eclipse.lsp4j.services.LanguageClient; /** Starts the OpenFastTrace language server over the standard LSP transport. */ -public final class OpenFastTraceLanguageServerMain { - private OpenFastTraceLanguageServerMain() { +public final class OftLanguageServerMain { + private OftLanguageServerMain() { // Executable class; no instances are needed. } @@ -19,7 +19,7 @@ private OpenFastTraceLanguageServerMain() { */ @SuppressWarnings("java:S106") // Using System.out is required by the LSP public static void main(final String[] arguments) { - final OpenFastTraceLanguageServer server = new OpenFastTraceLanguageServer(); + final OftLanguageServer server = new OftLanguageServer(); final Launcher launcher = LSPLauncher.createServerLauncher(server, System.in, System.out); server.connect(launcher.getRemoteProxy()); try { diff --git a/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceTextDocumentService.java b/server/src/main/java/org/itsallcode/openfasttrace/lsp/OftTextDocumentService.java similarity index 52% rename from server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceTextDocumentService.java rename to server/src/main/java/org/itsallcode/openfasttrace/lsp/OftTextDocumentService.java index b94778e..ee70775 100644 --- a/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceTextDocumentService.java +++ b/server/src/main/java/org/itsallcode/openfasttrace/lsp/OftTextDocumentService.java @@ -5,6 +5,7 @@ import java.net.URI; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -14,21 +15,28 @@ import org.eclipse.lsp4j.DidCloseTextDocumentParams; import org.eclipse.lsp4j.DidOpenTextDocumentParams; import org.eclipse.lsp4j.DidSaveTextDocumentParams; +import org.eclipse.lsp4j.SemanticTokenTypes; import org.eclipse.lsp4j.SemanticTokens; import org.eclipse.lsp4j.SemanticTokensParams; +import org.eclipse.lsp4j.SemanticTokensRangeParams; +import org.eclipse.lsp4j.SemanticTokensLegend; import org.eclipse.lsp4j.services.TextDocumentService; +import org.itsallcode.openfasttrace.api.core.SourcePosition; +import org.itsallcode.openfasttrace.api.core.SourceRange; +import org.itsallcode.openfasttrace.api.core.SpecificationItem; import org.itsallcode.openfasttrace.api.importer.ImportSettings; +import org.itsallcode.openfasttrace.api.importer.Importer; import org.itsallcode.openfasttrace.api.importer.ImporterContext; import org.itsallcode.openfasttrace.api.importer.SpecificationListBuilder; import org.itsallcode.openfasttrace.api.importer.input.InputFile; import org.itsallcode.openfasttrace.importer.tag.TagImporterFactory; -import org.itsallcode.openfasttrace.importer.tag.common.CoverageTagParser; /** * Tracks open documents and provides semantic highlighting for coverage tags. */ // [impl->dsn~editor-presentation~5] -final class OpenFastTraceTextDocumentService implements TextDocumentService { +final class OftTextDocumentService implements TextDocumentService { + private static final OftSemanticToken COVERAGE_TAG_TOKEN = new OftSemanticToken(SemanticTokenTypes.Type, 0); private final Map documents = new ConcurrentHashMap<>(); private final TagImporterFactory tagImporterFactory = tagImporterFactory(); @@ -70,57 +78,90 @@ public CompletableFuture semanticTokensFull(final SemanticTokens return CompletableFuture.completedFuture(new SemanticTokens(tokensFor(file, text))); } + @Override + public CompletableFuture semanticTokensRange(final SemanticTokensRangeParams params) { + // TODO: Implement semanticTokensRange() to support incremental highlighting of + // coverage tags. + throw new UnsupportedOperationException(); + } + private static TagImporterFactory tagImporterFactory() { final TagImporterFactory factory = new TagImporterFactory(); factory.init(new ImporterContext(ImportSettings.createDefault())); return factory; } - private static List tokensFor(final InputFile file, final String text) { + static OftSemanticToken coverageTagToken() { + return COVERAGE_TAG_TOKEN; + } + + private List tokensFor(final InputFile file, final String text) { if (text == null) { return List.of(); } - final var tokens = new ArrayList(); + final List items = importedItemsFor(file); + final List ranges = items.stream() + .flatMap(item -> rangesFor(item).stream()) + .filter(OftTextDocumentService::sameLine) + .sorted(Comparator.comparingInt((final SourceRange range) -> range.getStart().getLine()) + .thenComparingInt(range -> range.getStart().getColumn())) + .toList(); + final List tokens = new ArrayList(); int previousLine = 0; int previousStart = 0; - final List lines = text.lines().toList(); - for (int lineNumber = 0; lineNumber < lines.size(); lineNumber++) { - for (final TagRange range : tagRanges(file, lineNumber + 1, lines.get(lineNumber))) { - tokens.add(lineNumber - previousLine); - tokens.add(lineNumber == previousLine ? (range.start() - previousStart) : range.start()); - tokens.add(range.end() - range.start()); - tokens.add(0); - tokens.add(0); - previousLine = lineNumber; - previousStart = range.start(); - } + for (final SourceRange range : ranges) { + final int lineNumber = range.getStart().getLine(); + final int start = range.getStart().getColumn(); + final int end = range.getEnd().getColumn(); + tokens.add(lineNumber - previousLine); + tokens.add(lineNumber == previousLine ? (start - previousStart) : start); + tokens.add(end - start); + tokens.add(coverageTagToken().tokenTypeIndex()); + tokens.add(0); + previousLine = lineNumber; + previousStart = start; } return tokens; } - private static List tagRanges(final InputFile file, final int lineNumber, final String line) { - final var ranges = new ArrayList(); - int start = line.indexOf('['); - while (start >= 0) { - final int end = line.indexOf(']', start + 1); - if (end < 0) { - return ranges; - } - if (isCoverageTag(file, lineNumber, line.substring(start, end + 1))) { - ranges.add(new TagRange(start, end + 1)); - } - start = line.indexOf('[', end + 1); + private List importedItemsFor(final InputFile file) { + final SpecificationListBuilder listener = SpecificationListBuilder.create(); + final Importer importer = this.tagImporterFactory.createImporter(file, listener); + importer.runImport(); + return listener.build(); + } + + private static List rangesFor(final SpecificationItem item) { + final var ranges = new ArrayList(); + final var locatedId = item.getLocatedId(); + if (locatedId != null && locatedId.getRange() != null) { + ranges.add(locatedId.getRange()); } + item.getLocatedCoveredIds().stream() + .map(id -> id == null ? null : id.getRange()) + .filter(range -> range != null) + .forEach(ranges::add); + item.getLocatedDependOnIds().stream() + .map(id -> id == null ? null : id.getRange()) + .filter(range -> range != null) + .forEach(ranges::add); return ranges; } - private static boolean isCoverageTag(final InputFile file, final int lineNumber, final String candidate) { - final SpecificationListBuilder listener = SpecificationListBuilder.create(); - CoverageTagParser.create(null, file, listener).readLine(lineNumber, candidate); - return !listener.build().isEmpty(); + private static boolean sameLine(final SourceRange range) { + final SourcePosition start = range.getStart(); + final SourcePosition end = range.getEnd(); + return start.getLine() == end.getLine() && end.getColumn() >= start.getColumn(); } - private record TagRange(int start, int end) { + static SemanticTokensLegend semanticTokensLegend() { + return coverageTagToken().legend(); + } + + record OftSemanticToken(String tokenType, int tokenTypeIndex) { + SemanticTokensLegend legend() { + return new SemanticTokensLegend(List.of(this.tokenType), List.of()); + } } private record DocumentInput(String uri, String text) implements InputFile { diff --git a/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceWorkspaceService.java b/server/src/main/java/org/itsallcode/openfasttrace/lsp/OftWorkspaceService.java similarity index 94% rename from server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceWorkspaceService.java rename to server/src/main/java/org/itsallcode/openfasttrace/lsp/OftWorkspaceService.java index 8aba9ca..9af8735 100644 --- a/server/src/main/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceWorkspaceService.java +++ b/server/src/main/java/org/itsallcode/openfasttrace/lsp/OftWorkspaceService.java @@ -12,7 +12,7 @@ import org.eclipse.lsp4j.services.WorkspaceService; /** Placeholder for workspace indexing, configuration, and trace commands. */ -final class OpenFastTraceWorkspaceService implements WorkspaceService { +final class OftWorkspaceService implements WorkspaceService { @Override public void didChangeConfiguration(final DidChangeConfigurationParams parameters) { // Configuration support is added with trace profiles. diff --git a/server/src/test/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServerTest.java b/server/src/test/java/org/itsallcode/openfasttrace/lsp/OftLanguageServerTest.java similarity index 78% rename from server/src/test/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServerTest.java rename to server/src/test/java/org/itsallcode/openfasttrace/lsp/OftLanguageServerTest.java index 13f35ee..b6e8140 100644 --- a/server/src/test/java/org/itsallcode/openfasttrace/lsp/OpenFastTraceLanguageServerTest.java +++ b/server/src/test/java/org/itsallcode/openfasttrace/lsp/OftLanguageServerTest.java @@ -26,10 +26,10 @@ import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; -class OpenFastTraceLanguageServerTest { +class OftLanguageServerTest { @Test void negotiatesFullDocumentSynchronization() { - final OpenFastTraceLanguageServer server = new OpenFastTraceLanguageServer(); + final OftLanguageServer server = new OftLanguageServer(); final var result = server.initialize(new InitializeParams()).join(); @@ -38,7 +38,7 @@ void negotiatesFullDocumentSynchronization() { @Test void negotiatesWorkspaceSymbolsAndReturnsAnEmptyResult() { - final OpenFastTraceLanguageServer server = new OpenFastTraceLanguageServer(); + final OftLanguageServer server = new OftLanguageServer(); final var result = server.initialize(new InitializeParams()).join(); @@ -50,19 +50,21 @@ void negotiatesWorkspaceSymbolsAndReturnsAnEmptyResult() { @Test void negotiatesSemanticTokensForCoverageTags() { - final OpenFastTraceLanguageServer server = new OpenFastTraceLanguageServer(); + final OftLanguageServer server = new OftLanguageServer(); final var result = server.initialize(new InitializeParams()).join(); assertAll( + () -> assertThat(OftTextDocumentService.coverageTagToken().tokenType(), is("type")), + () -> assertThat(OftTextDocumentService.coverageTagToken().tokenTypeIndex(), is(0)), () -> assertThat(result.getCapabilities().getSemanticTokensProvider().getLegend().getTokenTypes(), - is(List.of(OpenFastTraceLanguageServer.COVERAGE_TAG_TOKEN_TYPE))), + is(OftTextDocumentService.semanticTokensLegend().getTokenTypes())), () -> assertThat(result.getCapabilities().getSemanticTokensProvider().getFull().getLeft(), is(true))); } @Test void logsServerStartupToTheLanguageClient() { - final OpenFastTraceLanguageServer server = new OpenFastTraceLanguageServer(); + final OftLanguageServer server = new OftLanguageServer(); final AtomicReference startupMessage = new AtomicReference<>(); server.connect(loggingClient(startupMessage)); @@ -75,13 +77,13 @@ void logsServerStartupToTheLanguageClient() { } @Test - void returnsTokensForValidCoverageTagsInSupportedFilesAndUpdatesThemOnChange() { - final OpenFastTraceTextDocumentService documents = new OpenFastTraceTextDocumentService(); + void returnsTokensForLocatedCoverageIdsInSupportedFilesAndUpdatesThemOnChange() { + final OftTextDocumentService documents = new OftTextDocumentService(); final String uri = "file:///workspace/source.ts"; documents.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem(uri, "typescript", 1, "// " + coverageTag("dsn~editor-presentation~5")))); - assertThat(tokensFor(documents, uri), is(List.of(0, 3, 33, 0, 0))); + assertThat(tokensFor(documents, uri), is(List.of(0, 10, 25, 0, 0))); documents.didChange(new DidChangeTextDocumentParams(new VersionedTextDocumentIdentifier(uri, 2), List.of(new TextDocumentContentChangeEvent("// [impl->dsn~1invalid~3]")))); @@ -91,7 +93,7 @@ void returnsTokensForValidCoverageTagsInSupportedFilesAndUpdatesThemOnChange() { @Test void recognizesSourceConfigurationAndMarkupFilesButIgnoresMalformedAndUnsupportedTags() { - final OpenFastTraceTextDocumentService documents = new OpenFastTraceTextDocumentService(); + final OftTextDocumentService documents = new OftTextDocumentService(); documents.didOpen( new DidOpenTextDocumentParams(new TextDocumentItem("file:///workspace/source.ts", "typescript", 1, "// " + coverageTag("dsn~editor-presentation~5")))); @@ -104,9 +106,9 @@ void recognizesSourceConfigurationAndMarkupFilesButIgnoresMalformedAndUnsupporte coverageTag("dsn~editor-presentation~5")))); 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/source.ts"), is(List.of(0, 10, 25, 0, 0))), + () -> assertThat(tokensFor(documents, "file:///workspace/source.yaml"), is(List.of(0, 9, 25, 0, 0))), + () -> assertThat(tokensFor(documents, "file:///workspace/page.html"), is(List.of(0, 12, 25, 0, 0))), () -> assertThat(tokensFor(documents, "file:///workspace/requirements.md"), is(List.of()))); documents.didClose(new DidCloseTextDocumentParams(new TextDocumentIdentifier("file:///workspace/source.ts"))); @@ -116,26 +118,26 @@ void recognizesSourceConfigurationAndMarkupFilesButIgnoresMalformedAndUnsupporte @Test void usesTheTagImporterForSupportedFilesAndCoverageTagSyntax() { - final OpenFastTraceTextDocumentService documents = new OpenFastTraceTextDocumentService(); + final OftTextDocumentService documents = new OftTextDocumentService(); final String uri = "file:///workspace/page.xml"; documents.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem(uri, "xml", 1, ""))); - assertThat(tokensFor(documents, uri), is(List.of(0, 5, 33, 0, 0))); + assertThat(tokensFor(documents, uri), is(List.of(0, 12, 25, 0, 0))); } @Test - void returnsRangesForEachOftRecognizedTagOnALine() { - final OpenFastTraceTextDocumentService documents = new OpenFastTraceTextDocumentService(); + void returnsRangesForEachLocatedCoverageIdOnALine() { + final OftTextDocumentService documents = new OftTextDocumentService(); final String uri = "file:///workspace/source.ts"; documents.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem(uri, "typescript", 1, "// " + coverageTag("dsn~editor-presentation~5") + " " + coverageTag("req~highlight-coverage-tags~1")))); - assertThat(tokensFor(documents, uri), is(List.of(0, 3, 33, 0, 0, 0, 34, 37, 0, 0))); + assertThat(tokensFor(documents, uri), is(List.of(0, 10, 25, 0, 0, 0, 34, 29, 0, 0))); } - private static List tokensFor(final OpenFastTraceTextDocumentService documents, final String uri) { + private static List tokensFor(final OftTextDocumentService documents, final String uri) { return documents.semanticTokensFull(new SemanticTokensParams(new TextDocumentIdentifier(uri))).join().getData(); } @@ -145,7 +147,7 @@ private static String coverageTag(final String target) { private static LanguageClient loggingClient(final AtomicReference startupMessage) { return (LanguageClient) Proxy.newProxyInstance( - OpenFastTraceLanguageServerTest.class.getClassLoader(), new Class[] { LanguageClient.class }, + OftLanguageServerTest.class.getClassLoader(), new Class[] { LanguageClient.class }, (proxy, method, arguments) -> { if (method.getName().equals("logMessage")) { startupMessage.set((MessageParams) arguments[0]);