From 3320f222bbf474b1997928748f7501b6045243ae Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Tue, 22 Sep 2026 08:34:13 +0100 Subject: [PATCH] implements PLN-0137 (2/7) document-links Advertise document links only when the frontend supports them. A link covers the ModuleRef span when module assembly already bound that module to a regular file. Virtual stdlib and unresolved modules produce no link. --- ...ler Pipeline Entry Points Specification.md | 2 + .../compiler/PBSFrontendLanguageService.java | 42 +++++++ .../services/PBSFrontendPhaseService.java | 39 ++++++- .../compiler/PBSFrontendDocumentLinkTest.java | 109 ++++++++++++++++++ .../services/FrontendDocumentLink.java | 20 ++++ .../services/FrontendLanguageService.java | 14 +++ .../services/FrontendLanguageServiceTest.java | 3 + .../lsp/messages/BaselineDocumentLink.java | 28 +++++ .../lsp/messages/BaselineDocumentLinks.java | 13 +++ .../messages/BaselineServerDescription.java | 1 + .../lsp/services/LanguageServiceBridge.java | 5 + .../CompilerLanguageServiceBridge.java | 50 ++++++++ .../protocol/PrometeuTextDocumentService.java | 8 ++ .../mapping/Lsp4jProtocolMessageMapper.java | 22 ++++ .../mapping/ProtocolMessageMapper.java | 6 + .../CompilerLanguageServiceBridgeTest.java | 75 ++++++++++++ .../protocol/PrometeuLanguageServerTest.java | 1 + .../Lsp4jProtocolMessageMapperTest.java | 53 +++++++++ 18 files changed, 489 insertions(+), 2 deletions(-) create mode 100644 prometeu-compiler/frontends/prometeu-frontend-pbs/src/test/java/p/studio/compiler/PBSFrontendDocumentLinkTest.java create mode 100644 prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendDocumentLink.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineDocumentLink.java create mode 100644 prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineDocumentLinks.java diff --git a/docs/specs/compiler/23. Compiler Pipeline Entry Points Specification.md b/docs/specs/compiler/23. Compiler Pipeline Entry Points Specification.md index e1ff4659..7e2850c5 100644 --- a/docs/specs/compiler/23. Compiler Pipeline Entry Points Specification.md +++ b/docs/specs/compiler/23. Compiler Pipeline Entry Points Specification.md @@ -288,6 +288,8 @@ Compiler diagnostics and editor diagnostics are distinct ownership surfaces. Dia When tooling publishes an editor diagnostic, `source` MUST be the `languageId` of the bound frontend project. `code` MUST be the stable compiler diagnostic code when one exists, and MUST be empty when the compiler diagnostic has none. Tooling MUST NOT copy that code into `source`. Tooling MUST NOT publish related locations, compiler phase, or a repair payload on the editor diagnostic. +When a frontend exposes document links, each link MUST cover the span of a module reference whose resolved destination is a compiler-known regular file. A module reference that does not resolve to a regular file MUST produce no link. Tooling MUST NOT target virtual, untitled, or synthetic URIs, including virtual stdlib documents. Document links MUST NOT replace definition. A missing document-link capability MUST NOT be advertised and MUST produce an empty list rather than a protocol error. + `FrontendSpec` remains the source of static frontend-owned presentation metadata such as semantic vocabularies, host projections, and visual themes. Producing semantic tokens for a live document is an optional editor-facing capability; the existence of static presentation metadata MUST NOT imply that every frontend can provide live semantic-token results. The frontend registry MUST resolve providers by `languageId`. A lookup for an unknown `languageId` MUST fail explicitly with a diagnostic-friendly error. Unknown languages MUST NOT silently fall back to PBS or to any other frontend. diff --git a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/PBSFrontendLanguageService.java b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/PBSFrontendLanguageService.java index 3fed0117..8333ede3 100644 --- a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/PBSFrontendLanguageService.java +++ b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/PBSFrontendLanguageService.java @@ -21,6 +21,7 @@ import p.studio.compiler.pbs.semantics.PbsQuickFixCollector; import p.studio.compiler.services.PBSFrontendPhaseService.PbsSemanticReadSurface; import p.studio.compiler.services.FrontendCodeAction; import p.studio.compiler.services.FrontendCompletionCandidate; +import p.studio.compiler.services.FrontendDocumentLink; import p.studio.compiler.services.FrontendDefinitionLocation; import p.studio.compiler.services.FrontendDocumentRequest; import p.studio.compiler.services.FrontendDocumentSymbol; @@ -40,6 +41,7 @@ import p.studio.compiler.source.Span; import p.studio.compiler.source.diagnostics.DiagnosticSink; import p.studio.compiler.source.identifiers.FileId; import p.studio.compiler.source.tables.FileTableReader; +import p.studio.compiler.source.tables.ModuleReference; import p.studio.utilities.structures.ReadOnlyList; import java.nio.file.Files; @@ -401,6 +403,46 @@ public final class PBSFrontendLanguageService implements FrontendLanguageService return FrontendRenameResult.applied(List.copyOf(edits.values())); } + @Override + public boolean documentLinksSupported() { + return true; + } + + @Override + public List documentLinks( + final FrontendDocumentRequest request, + final FrontendEditorialContext editorialContext) { + if (request == null + || editorialContext == null + || !(editorialContext.syntaxTree() instanceof PbsAst.File ast) + || !(editorialContext.projectSurface() instanceof PbsSemanticReadSurface surface) + || surface.physicalModuleFileByReference() == null + || ast.imports() == null) { + return List.of(); + } + final ArrayList links = new ArrayList<>(); + for (final PbsAst.ImportDecl importDecl : ast.imports()) { + if (importDecl == null || importDecl.moduleRef() == null) { + continue; + } + final PbsAst.ModuleRef moduleRef = importDecl.moduleRef(); + final Span span = moduleRef.span(); + if (span == null || span.isNone() || moduleRef.project() == null || moduleRef.project().isBlank()) { + continue; + } + final Path target = surface.physicalModuleFileByReference().get( + new ModuleReference(moduleRef.project(), moduleRef.pathSegments())); + if (target == null) { + continue; + } + links.add(new FrontendDocumentLink( + target, + toOffset(span.getStart()), + toOffset(span.getEnd()))); + } + return List.copyOf(links); + } + @Override public boolean codeActionsSupported() { return true; diff --git a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/services/PBSFrontendPhaseService.java b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/services/PBSFrontendPhaseService.java index 7268c097..afa5edca 100644 --- a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/services/PBSFrontendPhaseService.java +++ b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/services/PBSFrontendPhaseService.java @@ -15,9 +15,12 @@ import p.studio.compiler.pbs.stdlib.StdlibEnvironmentResolver; import p.studio.compiler.source.diagnostics.DiagnosticSink; import p.studio.compiler.source.identifiers.FileId; import p.studio.compiler.source.identifiers.ModuleId; +import p.studio.compiler.source.tables.ModuleReference; import p.studio.utilities.logs.LogAggregator; import p.studio.utilities.structures.ReadOnlyList; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.*; @Slf4j @@ -153,14 +156,46 @@ public class PBSFrontendPhaseService implements FrontendPhaseService { Map.copyOf(astByFile), Map.copyOf(supplementalTopDeclsByFile), Map.copyOf(inlineHintsByFile), - Map.copyOf(sourceKindByFile)); + Map.copyOf(sourceKindByFile), + physicalModuleFiles(ctx, assembly)); + } + + private static Map physicalModuleFiles( + final FrontendPhaseContext ctx, + final PbsModuleAssembly assembly) { + final Map files = new LinkedHashMap<>(); + if (ctx == null || ctx.fileTable == null || assembly == null || assembly.parsedSourceFiles() == null) { + return Map.of(); + } + for (final PbsParsedSourceFile parsed : assembly.parsedSourceFiles()) { + if (parsed.fileId() == null || parsed.fileId().isNone() + || parsed.moduleId() == null || parsed.moduleId().isNone()) { + continue; + } + final var handle = ctx.fileTable.get(parsed.fileId()); + if (handle == null || handle.getCanonPath() == null) { + continue; + } + final Path path = handle.getCanonPath().toAbsolutePath().normalize(); + if (!Files.isRegularFile(path)) { + continue; + } + final ModuleReference reference = assembly.moduleTable().get(parsed.moduleId()); + if (reference == null) { + continue; + } + files.merge(reference, path, (left, right) -> + left.toString().compareTo(right.toString()) <= 0 ? left : right); + } + return Map.copyOf(files); } public record PbsSemanticReadSurface( Map astByFile, Map> supplementalTopDeclsByFile, Map> inlineHintsByFile, - Map sourceKindByFile) { + Map sourceKindByFile, + Map physicalModuleFileByReference) { } private IRBackend mergeCompiledSources( diff --git a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/test/java/p/studio/compiler/PBSFrontendDocumentLinkTest.java b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/test/java/p/studio/compiler/PBSFrontendDocumentLinkTest.java new file mode 100644 index 00000000..79ef48c4 --- /dev/null +++ b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/test/java/p/studio/compiler/PBSFrontendDocumentLinkTest.java @@ -0,0 +1,109 @@ +package p.studio.compiler; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.studio.AppMode; +import p.studio.compiler.messages.BuildingIssueSink; +import p.studio.compiler.messages.FrontendPhaseContext; +import p.studio.compiler.models.BuildStack; +import p.studio.compiler.models.ProjectDescriptor; +import p.studio.compiler.models.SourceHandle; +import p.studio.compiler.services.FrontendDocumentRequest; +import p.studio.compiler.source.diagnostics.DiagnosticSink; +import p.studio.compiler.source.identifiers.FileId; +import p.studio.compiler.source.tables.FileTable; +import p.studio.compiler.source.tables.ProjectTable; +import p.studio.compiler.utilities.SourceProviderFactory; +import p.studio.utilities.structures.ReadOnlyList; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PBSFrontendDocumentLinkTest { + + @TempDir + Path tempDir; + + @Test + void documentLinksUseTheAssembledPhysicalModuleFile() throws IOException { + final Path projectRoot = tempDir.resolve("app"); + final Path sourceRoot = projectRoot.resolve("src"); + final Path sourceA = sourceRoot.resolve("a").resolve("source.pbs"); + final Path sourceB = sourceRoot.resolve("b").resolve("source.pbs"); + Files.createDirectories(sourceA.getParent()); + Files.createDirectories(sourceB.getParent()); + Files.writeString(sourceA, """ + fn target() -> int { + return 1; + } + """); + Files.writeString(sourceA.getParent().resolve("mod.barrel"), "pub fn target() -> int;\n"); + final String caller = """ + import { target } from @app:a; + import { Log } from @sdk:log; + import { Missing } from @app:missing; + + fn caller() -> int { + return target(); + } + """; + Files.writeString(sourceB, caller); + Files.writeString(sourceB.getParent().resolve("mod.barrel"), "pub fn caller() -> int;\n"); + + final var projectTable = new ProjectTable(); + final var fileTable = new FileTable(1); + final var projectId = projectTable.register(ProjectDescriptor.builder() + .rootPath(projectRoot) + .name("app") + .version("1.0.0") + .sourceRoots(ReadOnlyList.wrap(List.of(sourceRoot))) + .build()); + register(projectId, projectRoot, sourceA, fileTable); + register(projectId, projectRoot, sourceA.getParent().resolve("mod.barrel"), fileTable); + final FileId callerId = register(projectId, projectRoot, sourceB, fileTable); + register(projectId, projectRoot, sourceB.getParent().resolve("mod.barrel"), fileTable); + + final var phaseContext = new FrontendPhaseContext( + projectTable, + fileTable, + new BuildStack(ReadOnlyList.wrap(List.of(projectId))), + AppMode.Game); + final var service = new PBSFrontendLanguageService(); + final var editorialContext = service.prepareEditorialContext(phaseContext, callerId).orElseThrow(); + final var links = service.documentLinks( + new FrontendDocumentRequest(projectRoot, sourceB, caller), + editorialContext); + + assertEquals(1, links.size()); + assertEquals(sourceA.toAbsolutePath().normalize(), links.getFirst().path()); + final byte[] bytes = caller.getBytes(StandardCharsets.UTF_8); + assertEquals("@app:a", new String( + bytes, + links.getFirst().startOffset(), + links.getFirst().endOffset() - links.getFirst().startOffset(), + StandardCharsets.UTF_8)); + assertTrue(service.documentLinksSupported()); + } + + private static FileId register( + final p.studio.compiler.source.identifiers.ProjectId projectId, + final Path projectRoot, + final Path file, + final FileTable fileTable) throws IOException { + final BasicFileAttributes attributes = Files.readAttributes(file, BasicFileAttributes.class); + return fileTable.register(new SourceHandle( + projectId, + projectRoot.relativize(file), + file, + attributes.size(), + attributes.lastModifiedTime().toMillis(), + SourceProviderFactory.filesystem())); + } +} diff --git a/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendDocumentLink.java b/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendDocumentLink.java new file mode 100644 index 00000000..4f7433e3 --- /dev/null +++ b/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendDocumentLink.java @@ -0,0 +1,20 @@ +package p.studio.compiler.services; + +import java.nio.file.Path; +import java.util.Objects; + +public record FrontendDocumentLink( + Path path, + int startOffset, + int endOffset) { + + public FrontendDocumentLink { + path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + if (startOffset < 0) { + throw new IllegalArgumentException("startOffset must not be negative"); + } + if (endOffset < startOffset) { + throw new IllegalArgumentException("endOffset must not be before startOffset"); + } + } +} diff --git a/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendLanguageService.java b/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendLanguageService.java index 99670e2c..5934fd1e 100644 --- a/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendLanguageService.java +++ b/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendLanguageService.java @@ -139,6 +139,20 @@ public interface FrontendLanguageService { return rename(request, offset, newName); } + default boolean documentLinksSupported() { + return false; + } + + default List documentLinks(final FrontendDocumentRequest request) { + return List.of(); + } + + default List documentLinks( + final FrontendDocumentRequest request, + final FrontendEditorialContext editorialContext) { + return documentLinks(request); + } + default boolean codeActionsSupported() { return false; } diff --git a/prometeu-compiler/prometeu-frontend-api/src/test/java/p/studio/compiler/services/FrontendLanguageServiceTest.java b/prometeu-compiler/prometeu-frontend-api/src/test/java/p/studio/compiler/services/FrontendLanguageServiceTest.java index db48db2e..a5c5a7d5 100644 --- a/prometeu-compiler/prometeu-frontend-api/src/test/java/p/studio/compiler/services/FrontendLanguageServiceTest.java +++ b/prometeu-compiler/prometeu-frontend-api/src/test/java/p/studio/compiler/services/FrontendLanguageServiceTest.java @@ -30,6 +30,9 @@ class FrontendLanguageServiceTest { assertTrue(service.prepareRename(request, 0).isEmpty()); assertEquals(FrontendRenameResult.Status.UNSUPPORTED, service.rename(request, 0, "renamed").status()); assertTrue(service.rename(request, 0, "renamed").edits().isEmpty()); + assertFalse(service.documentLinksSupported()); + assertTrue(service.documentLinks(request).isEmpty()); + assertTrue(service.documentLinks(request, null).isEmpty()); assertFalse(service.codeActionsSupported()); assertTrue(service.codeActions(request).isEmpty()); assertTrue(service.codeActions(request, null).isEmpty()); diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineDocumentLink.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineDocumentLink.java new file mode 100644 index 00000000..a158ff9c --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineDocumentLink.java @@ -0,0 +1,28 @@ +package p.studio.lsp.messages; + +import java.util.Objects; + +public record BaselineDocumentLink( + String targetUri, + int startLine, + int startCharacter, + int endLine, + int endCharacter) { + + public BaselineDocumentLink { + targetUri = requireText(targetUri, "targetUri"); + if (startLine < 0 || startCharacter < 0 || endLine < 0 || endCharacter < 0) { + throw new IllegalArgumentException("document link range coordinates must not be negative"); + } + } + + private static String requireText( + final String value, + final String field) { + final String candidate = Objects.requireNonNull(value, field).trim(); + if (candidate.isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return candidate; + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineDocumentLinks.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineDocumentLinks.java new file mode 100644 index 00000000..595619b1 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineDocumentLinks.java @@ -0,0 +1,13 @@ +package p.studio.lsp.messages; + +import java.util.List; + +public record BaselineDocumentLinks(List links) { + public BaselineDocumentLinks { + links = links == null ? List.of() : List.copyOf(links); + } + + public static BaselineDocumentLinks empty() { + return new BaselineDocumentLinks(List.of()); + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineServerDescription.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineServerDescription.java index cb592b39..85e3c003 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineServerDescription.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineServerDescription.java @@ -16,6 +16,7 @@ public record BaselineServerDescription( boolean workspaceSymbolsSupported, boolean renameSupported, boolean codeActionsSupported, + boolean documentLinksSupported, List semanticTokenTypes, List semanticHostProjections, List visualThemes, diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/LanguageServiceBridge.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/LanguageServiceBridge.java index 2949eace..838c073a 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/LanguageServiceBridge.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/LanguageServiceBridge.java @@ -9,6 +9,7 @@ import p.studio.lsp.messages.BaselineReferences; import p.studio.lsp.messages.BaselineWorkspaceSymbols; import p.studio.lsp.messages.BaselineCodeActionDiagnostic; import p.studio.lsp.messages.BaselineCodeActions; +import p.studio.lsp.messages.BaselineDocumentLinks; import p.studio.lsp.messages.BaselinePrepareRename; import p.studio.lsp.messages.BaselineRename; import p.studio.lsp.messages.BaselineSemanticTokens; @@ -60,6 +61,10 @@ public interface LanguageServiceBridge { BaselineSemanticTokens semanticTokens(LspProjectContext context, String documentUri, String text); + default BaselineDocumentLinks documentLinks(LspProjectContext context, String documentUri, String text) { + return BaselineDocumentLinks.empty(); + } + BaselineCodeActions codeActions( LspProjectContext context, String documentUri, diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridge.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridge.java index 308ddbda..f34faec2 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridge.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridge.java @@ -6,6 +6,7 @@ import p.studio.compiler.messages.*; import p.studio.compiler.models.*; import p.studio.compiler.services.FrontendCodeAction; import p.studio.compiler.services.FrontendCompletionCandidate; +import p.studio.compiler.services.FrontendDocumentLink; import p.studio.compiler.services.FrontendLanguageService; import p.studio.compiler.services.FrontendDefinitionLocation; import p.studio.compiler.services.FrontendDocumentRequest; @@ -57,6 +58,9 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg final boolean codeActionsSupported = languageService .map(FrontendLanguageService::codeActionsSupported) .orElse(false); + final boolean documentLinksSupported = languageService + .map(FrontendLanguageService::documentLinksSupported) + .orElse(false); return new BaselineServerDescription( "Prometeu Studio LSP", "0.1.0", @@ -70,6 +74,7 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg languageServicePresent, languageServicePresent, codeActionsSupported, + documentLinksSupported, presentation.semanticKeys(), presentation.hostProjections().stream().map(this::mapSemanticHostProjection).toList(), presentation.themes().stream().map(this::mapVisualTheme).toList(), @@ -365,6 +370,29 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg .orElseGet(() -> BaselineRename.refused("not renameable")); } + @Override + public BaselineDocumentLinks documentLinks( + final LspProjectContext context, + final String documentUri, + final String text) { + Objects.requireNonNull(context, "context"); + final var languageService = frontendProvider(context).languageService(); + if (languageService.isEmpty() || !languageService.orElseThrow().documentLinksSupported()) { + return BaselineDocumentLinks.empty(); + } + return editorialDocument(context, documentUri, text) + .map(document -> { + final FrontendDocumentRequest request = new FrontendDocumentRequest( + context.projectRoot(), + normalizeDocumentPath(documentUri), + document.text()); + return new BaselineDocumentLinks(mapDocumentLinks( + languageService.orElseThrow().documentLinks(request, document.editorialContext()), + document.text())); + }) + .orElseGet(BaselineDocumentLinks::empty); + } + @Override public BaselineCodeActions codeActions( final LspProjectContext context, @@ -830,6 +858,28 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg return List.copyOf(mapped); } + private List mapDocumentLinks( + final List links, + final String currentDocumentText) { + if (links == null || links.isEmpty()) { + return List.of(); + } + final DocumentPositionMapper mapper = new DocumentPositionMapper( + currentDocumentText == null ? "" : currentDocumentText); + final ArrayList mapped = new ArrayList<>(); + for (final FrontendDocumentLink link : links) { + final DocumentPosition start = mapper.positionOf(link.startOffset()); + final DocumentPosition end = mapper.positionOf(link.endOffset()); + mapped.add(new BaselineDocumentLink( + link.path().toUri().toString(), + start.line(), + start.character(), + end.line(), + end.character())); + } + return List.copyOf(mapped); + } + private List mapDefinitionLocations( final List locations, final Path currentDocumentPath, diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/PrometeuTextDocumentService.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/PrometeuTextDocumentService.java index fcafd5dc..4d4cad03 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/PrometeuTextDocumentService.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/PrometeuTextDocumentService.java @@ -168,6 +168,14 @@ public final class PrometeuTextDocumentService implements TextDocumentService { return CompletableFuture.completedFuture(protocolMessageMapper.mapRename(result)); } + @Override + public CompletableFuture> documentLink(final DocumentLinkParams params) { + final String uri = params.getTextDocument().getUri(); + final String text = documentTextByUri.get(uri); + return CompletableFuture.completedFuture(protocolMessageMapper.mapDocumentLinks( + languageServiceBridge.documentLinks(project, uri, text))); + } + @Override public CompletableFuture>> codeAction(final CodeActionParams params) { if (!quickFixRequested(params.getContext())) { diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapper.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapper.java index 482be900..b3608e2e 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapper.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapper.java @@ -53,6 +53,11 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper { codeActionOptions.setCodeActionKinds(List.of(CodeActionKind.QuickFix)); capabilities.setCodeActionProvider(codeActionOptions); } + if (description.documentLinksSupported()) { + final DocumentLinkOptions documentLinkOptions = new DocumentLinkOptions(); + documentLinkOptions.setResolveProvider(false); + capabilities.setDocumentLinkProvider(documentLinkOptions); + } final SemanticTokensWithRegistrationOptions semanticTokens = new SemanticTokensWithRegistrationOptions(); semanticTokens.setLegend(new SemanticTokensLegend(description.semanticTokenTypes(), List.of())); semanticTokens.setFull(true); @@ -159,6 +164,23 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper { return edit; } + @Override + public List mapDocumentLinks(final BaselineDocumentLinks links) { + if (links == null || links.links().isEmpty()) { + return List.of(); + } + final ArrayList mapped = new ArrayList<>(); + for (final BaselineDocumentLink link : links.links()) { + final DocumentLink documentLink = new DocumentLink(); + documentLink.setRange(new Range( + new Position(link.startLine(), link.startCharacter()), + new Position(link.endLine(), link.endCharacter()))); + documentLink.setTarget(link.targetUri()); + mapped.add(documentLink); + } + return List.copyOf(mapped); + } + @Override public List> mapCodeActions( final BaselineCodeActions actions, diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/ProtocolMessageMapper.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/ProtocolMessageMapper.java index f1a466a2..51bb2f84 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/ProtocolMessageMapper.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/services/protocol/mapping/ProtocolMessageMapper.java @@ -9,6 +9,7 @@ import org.eclipse.lsp4j.PublishDiagnosticsParams; import org.eclipse.lsp4j.SignatureHelp; import org.eclipse.lsp4j.SemanticTokens; import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DocumentLink; import org.eclipse.lsp4j.DocumentSymbol; import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.Range; @@ -19,6 +20,7 @@ import org.eclipse.lsp4j.jsonrpc.messages.Either3; import org.eclipse.lsp4j.PrepareRenameDefaultBehavior; import org.eclipse.lsp4j.PrepareRenameResult; import p.studio.lsp.messages.BaselineDocumentAnalysis; +import p.studio.lsp.messages.BaselineDocumentLinks; import p.studio.lsp.messages.BaselineDocumentSymbols; import p.studio.lsp.messages.BaselineWorkspaceSymbols; import p.studio.lsp.messages.BaselineCodeActions; @@ -59,6 +61,10 @@ public interface ProtocolMessageMapper { List> mapCodeActions(BaselineCodeActions actions, List contextDiagnostics); + default List mapDocumentLinks(BaselineDocumentLinks links) { + return List.of(); + } + CompletionList mapCompletion(BaselineCompletion completion); SignatureHelp mapSignatureHelp(BaselineSignatureHelp signatureHelp); diff --git a/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridgeTest.java b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridgeTest.java index 6b84d3b9..3f00e44b 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridgeTest.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/compiler/CompilerLanguageServiceBridgeTest.java @@ -1,6 +1,7 @@ package p.studio.lsp.services.compiler; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import p.studio.compiler.messages.BuildingIssueSink; import p.studio.compiler.messages.FrontendPhaseContext; import p.studio.compiler.models.FrontendSpec; @@ -14,6 +15,7 @@ import p.studio.lsp.messages.LspProjectContext; import p.studio.utilities.logs.LogAggregator; import p.studio.utilities.structures.ReadOnlySet; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -74,6 +76,7 @@ class CompilerLanguageServiceBridgeTest { assertTrue(bridge.codeActions(context, documentUri, "fn main() {}", List.of( new BaselineCodeActionDiagnostic("E_SEM_DUPLICATE_RESERVED_ATTRIBUTE", 0, 0, 0, 1))) .actions().isEmpty()); + assertTrue(bridge.documentLinks(context, documentUri, "fn main() {}").links().isEmpty()); } @Test @@ -768,6 +771,7 @@ class CompilerLanguageServiceBridgeTest { assertTrue(description.workspaceSymbolsSupported()); assertTrue(description.renameSupported()); assertTrue(description.codeActionsSupported()); + assertTrue(description.documentLinksSupported()); assertEquals(1, description.semanticHostProjections().size()); assertEquals("vscode", description.semanticHostProjections().getFirst().hostId()); assertTrue(description.semanticHostProjections().getFirst().tokenProjections().stream() @@ -781,6 +785,77 @@ class CompilerLanguageServiceBridgeTest { && tokenStyle.foreground().equals("#7fb8ff"))); } + @Test + void documentLinksSkipVirtualStdlibImports() throws IOException { + final Path projectRoot = findRepoRoot(Path.of("").toAbsolutePath().normalize()) + .resolve("test-projects") + .resolve("main") + .toAbsolutePath() + .normalize(); + final Path documentPath = projectRoot.resolve("src").resolve("main.pbs"); + final String text = Files.readString(documentPath); + final CompilerLanguageServiceBridge bridge = new CompilerLanguageServiceBridge(); + + final var links = bridge.documentLinks( + new LspProjectContext("main", "pbs", projectRoot), + documentPath.toUri().toString(), + text); + + assertTrue(links.links().isEmpty()); + } + + @Test + void documentLinksPointAtThePhysicalModuleFile(@TempDir final Path tempDir) throws IOException { + final Path projectRoot = tempDir.resolve("app"); + final Path sourceA = projectRoot.resolve("src").resolve("a").resolve("source.pbs"); + final Path sourceB = projectRoot.resolve("src").resolve("b").resolve("source.pbs"); + Files.createDirectories(sourceA.getParent()); + Files.createDirectories(sourceB.getParent()); + Files.writeString(projectRoot.resolve("prometeu.json"), """ + { + "name": "app", + "version": "1.0.0", + "language": "pbs", + "stdlib": "1", + "target": "Game", + "dependencies": [] + } + """); + Files.writeString(sourceA, """ + fn target() -> int { + return 1; + } + """); + Files.writeString(sourceA.getParent().resolve("mod.barrel"), "pub fn target() -> int;\n"); + final String caller = """ + import { target } from @app:a; + import { Log } from @sdk:log; + import { Missing } from @app:missing; + + fn caller() -> int { + return target(); + } + """; + Files.writeString(sourceB, caller); + Files.writeString(sourceB.getParent().resolve("mod.barrel"), "pub fn caller() -> int;\n"); + final Path realRoot = projectRoot.toRealPath(); + final Path realA = sourceA.toRealPath(); + final Path realB = sourceB.toRealPath(); + final CompilerLanguageServiceBridge bridge = new CompilerLanguageServiceBridge(); + + final var links = bridge.documentLinks( + new LspProjectContext("app", "pbs", realRoot), + realB.toUri().toString(), + caller); + + assertEquals(1, links.links().size()); + final var link = links.links().getFirst(); + assertEquals(realA.toUri().toString(), link.targetUri()); + final int start = new DocumentPositionMapper(caller).byteOffsetOf(link.startLine(), link.startCharacter()); + final int end = new DocumentPositionMapper(caller).byteOffsetOf(link.endLine(), link.endCharacter()); + assertEquals("@app:a", new String(caller.getBytes(StandardCharsets.UTF_8), start, end - start, StandardCharsets.UTF_8)); + } + private Path findRepoRoot(final Path start) { var current = start; while (current != null) { diff --git a/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/protocol/PrometeuLanguageServerTest.java b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/protocol/PrometeuLanguageServerTest.java index 1b7ab0e3..0f93d292 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/protocol/PrometeuLanguageServerTest.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/protocol/PrometeuLanguageServerTest.java @@ -105,6 +105,7 @@ class PrometeuLanguageServerTest { true, true, false, + false, List.of(), List.of(new BaselineSemanticHostProjection( "vscode", diff --git a/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapperTest.java b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapperTest.java index 603c8c9b..85ce742d 100644 --- a/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapperTest.java +++ b/prometeu-lsp/prometeu-lsp-v1/src/test/java/p/studio/lsp/services/protocol/mapping/Lsp4jProtocolMessageMapperTest.java @@ -10,6 +10,8 @@ import p.studio.lsp.messages.BaselineCodeActions; import p.studio.lsp.messages.BaselineCompletion; import p.studio.lsp.messages.BaselineDocumentAnalysis; import p.studio.lsp.messages.BaselineDocumentIssue; +import p.studio.lsp.messages.BaselineDocumentLink; +import p.studio.lsp.messages.BaselineDocumentLinks; import p.studio.lsp.messages.BaselineIssueSeverity; import p.studio.lsp.messages.BaselineCompletionItem; import p.studio.lsp.messages.BaselineCompletionItemKind; @@ -55,6 +57,7 @@ final class Lsp4jProtocolMessageMapperTest { true, true, true, + true, List.of("demo-keyword"), List.of(new BaselineSemanticHostProjection( "vscode", @@ -82,6 +85,7 @@ final class Lsp4jProtocolMessageMapperTest { assertEquals(Boolean.TRUE, result.getCapabilities().getRenameProvider().getRight().getPrepareProvider()); assertEquals(List.of(CodeActionKind.QuickFix), result.getCapabilities().getCodeActionProvider().getRight().getCodeActionKinds()); assertEquals(Boolean.FALSE, result.getCapabilities().getCodeActionProvider().getRight().getResolveProvider()); + assertEquals(Boolean.FALSE, result.getCapabilities().getDocumentLinkProvider().getResolveProvider()); final var experimental = assertInstanceOf(Map.class, result.getCapabilities().getExperimental()); final var semanticPayload = assertInstanceOf(Map.class, experimental.get("prometeuSemanticHostProjections")); @@ -298,4 +302,53 @@ final class Lsp4jProtocolMessageMapperTest { assertEquals(1, action.getDiagnostics().size()); assertEquals(diagnostic, action.getDiagnostics().getFirst()); } + + @Test + void documentLinksMapRangeAndTargetWithoutResolve() { + final var mapper = new Lsp4jProtocolMessageMapper(); + final var unsupported = mapper.mapInitializeResult(serverDescription(false)); + assertNull(unsupported.getCapabilities().getDocumentLinkProvider()); + + final var mapped = mapper.mapDocumentLinks(new BaselineDocumentLinks(List.of( + new BaselineDocumentLink("file:///tmp/a/source.pbs", 0, 19, 0, 25)))); + assertEquals(1, mapped.size()); + assertEquals("file:///tmp/a/source.pbs", mapped.getFirst().getTarget()); + assertEquals(0, mapped.getFirst().getRange().getStart().getLine()); + assertEquals(19, mapped.getFirst().getRange().getStart().getCharacter()); + assertEquals(25, mapped.getFirst().getRange().getEnd().getCharacter()); + assertNull(mapped.getFirst().getData()); + assertTrue(mapper.mapDocumentLinks(BaselineDocumentLinks.empty()).isEmpty()); + } + + private static BaselineServerDescription serverDescription(final boolean documentLinksSupported) { + return new BaselineServerDescription( + "demo", + "1", + "pbs", + true, + true, + true, + true, + true, + true, + true, + true, + true, + documentLinksSupported, + List.of("demo-keyword"), + List.of(new BaselineSemanticHostProjection( + "vscode", + List.of(new BaselineSemanticHostProjectionEntry( + "demo-keyword", + "keyword", + List.of(), + "variable", + List.of())))), + List.of(new BaselineVisualTheme( + "demo-default", + "Demo Default", + new BaselineEditorPalette("#111111", "#222222", "#333333", "#444444", "#555555", "#666666", "#777777"), + List.of(new BaselineTokenStyle("demo-keyword", "#888888", false, false, false)))), + "demo-default"); + } }