Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[versions]
lsp4j = "1.0.0"
openfasttrace = "4.7.0"
openfasttrace = "4.9.0"
junit = "5.13.4"
hamcrest = "3.0"

Expand Down
2 changes: 1 addition & 1 deletion server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ java {
}

application {
mainClass = 'org.itsallcode.openfasttrace.lsp.OpenFastTraceLanguageServerMain'
mainClass = 'org.itsallcode.openfasttrace.lsp.OftLanguageServerMain'
applicationName = 'openfasttrace-language-server'
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,22 +22,25 @@
* 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
public CompletableFuture<InitializeResult> 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));
capabilities.setSemanticTokensProvider(
new SemanticTokensWithRegistrationOptions(semanticTokensLegend(), true));
return CompletableFuture.completedFuture(new InitializeResult(capabilities));
}

static SemanticTokensLegend semanticTokensLegend() {
return OftTextDocumentService.semanticTokensLegend();
}

@Override
public CompletableFuture<Object> shutdown() {
return CompletableFuture.completedFuture(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}

Expand All @@ -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<LanguageClient> launcher = LSPLauncher.createServerLauncher(server, System.in, System.out);
server.connect(launcher.getRemoteProxy());
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<String, String> documents = new ConcurrentHashMap<>();
private final TagImporterFactory tagImporterFactory = tagImporterFactory();

Expand Down Expand Up @@ -70,57 +78,90 @@ public CompletableFuture<SemanticTokens> semanticTokensFull(final SemanticTokens
return CompletableFuture.completedFuture(new SemanticTokens(tokensFor(file, text)));
}

@Override
public CompletableFuture<SemanticTokens> 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<Integer> tokensFor(final InputFile file, final String text) {
static OftSemanticToken coverageTagToken() {
return COVERAGE_TAG_TOKEN;
}

private List<Integer> tokensFor(final InputFile file, final String text) {
if (text == null) {
return List.of();
}
final var tokens = new ArrayList<Integer>();
final List<SpecificationItem> items = importedItemsFor(file);
final List<SourceRange> 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<Integer> tokens = new ArrayList<Integer>();
int previousLine = 0;
int previousStart = 0;
final List<String> 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<TagRange> tagRanges(final InputFile file, final int lineNumber, final String line) {
final var ranges = new ArrayList<TagRange>();
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<SpecificationItem> 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<SourceRange> rangesFor(final SpecificationItem item) {
final var ranges = new ArrayList<SourceRange>();
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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();

Expand All @@ -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<MessageParams> startupMessage = new AtomicReference<>();
server.connect(loggingClient(startupMessage));

Expand All @@ -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]"))));
Expand All @@ -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"))));
Expand All @@ -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")));
Expand All @@ -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,
"<!-- " + coverageTag("dsn~editor-presentation~5") + " -->")));

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<Integer> tokensFor(final OpenFastTraceTextDocumentService documents, final String uri) {
private static List<Integer> tokensFor(final OftTextDocumentService documents, final String uri) {
return documents.semanticTokensFull(new SemanticTokensParams(new TextDocumentIdentifier(uri))).join().getData();
}

Expand All @@ -145,7 +147,7 @@ private static String coverageTag(final String target) {

private static LanguageClient loggingClient(final AtomicReference<MessageParams> 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]);
Expand Down
Loading