dev/pbs-lsp-remaining-editor-surface #31

Merged
bquarkz merged 8 commits from dev/pbs-lsp-remaining-editor-surface into master 2026-09-22 09:00:26 +00:00
18 changed files with 489 additions and 2 deletions
Showing only changes of commit 3320f222bb - Show all commits

View File

@ -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 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. `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. 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.

View File

@ -21,6 +21,7 @@ import p.studio.compiler.pbs.semantics.PbsQuickFixCollector;
import p.studio.compiler.services.PBSFrontendPhaseService.PbsSemanticReadSurface; import p.studio.compiler.services.PBSFrontendPhaseService.PbsSemanticReadSurface;
import p.studio.compiler.services.FrontendCodeAction; import p.studio.compiler.services.FrontendCodeAction;
import p.studio.compiler.services.FrontendCompletionCandidate; import p.studio.compiler.services.FrontendCompletionCandidate;
import p.studio.compiler.services.FrontendDocumentLink;
import p.studio.compiler.services.FrontendDefinitionLocation; import p.studio.compiler.services.FrontendDefinitionLocation;
import p.studio.compiler.services.FrontendDocumentRequest; import p.studio.compiler.services.FrontendDocumentRequest;
import p.studio.compiler.services.FrontendDocumentSymbol; 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.diagnostics.DiagnosticSink;
import p.studio.compiler.source.identifiers.FileId; import p.studio.compiler.source.identifiers.FileId;
import p.studio.compiler.source.tables.FileTableReader; import p.studio.compiler.source.tables.FileTableReader;
import p.studio.compiler.source.tables.ModuleReference;
import p.studio.utilities.structures.ReadOnlyList; import p.studio.utilities.structures.ReadOnlyList;
import java.nio.file.Files; import java.nio.file.Files;
@ -401,6 +403,46 @@ public final class PBSFrontendLanguageService implements FrontendLanguageService
return FrontendRenameResult.applied(List.copyOf(edits.values())); return FrontendRenameResult.applied(List.copyOf(edits.values()));
} }
@Override
public boolean documentLinksSupported() {
return true;
}
@Override
public List<FrontendDocumentLink> 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<FrontendDocumentLink> 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 @Override
public boolean codeActionsSupported() { public boolean codeActionsSupported() {
return true; return true;

View File

@ -15,9 +15,12 @@ import p.studio.compiler.pbs.stdlib.StdlibEnvironmentResolver;
import p.studio.compiler.source.diagnostics.DiagnosticSink; import p.studio.compiler.source.diagnostics.DiagnosticSink;
import p.studio.compiler.source.identifiers.FileId; import p.studio.compiler.source.identifiers.FileId;
import p.studio.compiler.source.identifiers.ModuleId; import p.studio.compiler.source.identifiers.ModuleId;
import p.studio.compiler.source.tables.ModuleReference;
import p.studio.utilities.logs.LogAggregator; import p.studio.utilities.logs.LogAggregator;
import p.studio.utilities.structures.ReadOnlyList; import p.studio.utilities.structures.ReadOnlyList;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*; import java.util.*;
@Slf4j @Slf4j
@ -153,14 +156,46 @@ public class PBSFrontendPhaseService implements FrontendPhaseService {
Map.copyOf(astByFile), Map.copyOf(astByFile),
Map.copyOf(supplementalTopDeclsByFile), Map.copyOf(supplementalTopDeclsByFile),
Map.copyOf(inlineHintsByFile), Map.copyOf(inlineHintsByFile),
Map.copyOf(sourceKindByFile)); Map.copyOf(sourceKindByFile),
physicalModuleFiles(ctx, assembly));
}
private static Map<ModuleReference, Path> physicalModuleFiles(
final FrontendPhaseContext ctx,
final PbsModuleAssembly assembly) {
final Map<ModuleReference, Path> 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( public record PbsSemanticReadSurface(
Map<FileId, p.studio.compiler.pbs.ast.PbsAst.File> astByFile, Map<FileId, p.studio.compiler.pbs.ast.PbsAst.File> astByFile,
Map<FileId, ReadOnlyList<p.studio.compiler.pbs.ast.PbsAst.TopDecl>> supplementalTopDeclsByFile, Map<FileId, ReadOnlyList<p.studio.compiler.pbs.ast.PbsAst.TopDecl>> supplementalTopDeclsByFile,
Map<FileId, ReadOnlyList<PbsInlineHintSurface>> inlineHintsByFile, Map<FileId, ReadOnlyList<PbsInlineHintSurface>> inlineHintsByFile,
Map<FileId, SourceKind> sourceKindByFile) { Map<FileId, SourceKind> sourceKindByFile,
Map<ModuleReference, Path> physicalModuleFileByReference) {
} }
private IRBackend mergeCompiledSources( private IRBackend mergeCompiledSources(

View File

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

View File

@ -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");
}
}
}

View File

@ -139,6 +139,20 @@ public interface FrontendLanguageService {
return rename(request, offset, newName); return rename(request, offset, newName);
} }
default boolean documentLinksSupported() {
return false;
}
default List<FrontendDocumentLink> documentLinks(final FrontendDocumentRequest request) {
return List.of();
}
default List<FrontendDocumentLink> documentLinks(
final FrontendDocumentRequest request,
final FrontendEditorialContext editorialContext) {
return documentLinks(request);
}
default boolean codeActionsSupported() { default boolean codeActionsSupported() {
return false; return false;
} }

View File

@ -30,6 +30,9 @@ class FrontendLanguageServiceTest {
assertTrue(service.prepareRename(request, 0).isEmpty()); assertTrue(service.prepareRename(request, 0).isEmpty());
assertEquals(FrontendRenameResult.Status.UNSUPPORTED, service.rename(request, 0, "renamed").status()); assertEquals(FrontendRenameResult.Status.UNSUPPORTED, service.rename(request, 0, "renamed").status());
assertTrue(service.rename(request, 0, "renamed").edits().isEmpty()); 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()); assertFalse(service.codeActionsSupported());
assertTrue(service.codeActions(request).isEmpty()); assertTrue(service.codeActions(request).isEmpty());
assertTrue(service.codeActions(request, null).isEmpty()); assertTrue(service.codeActions(request, null).isEmpty());

View File

@ -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;
}
}

View File

@ -0,0 +1,13 @@
package p.studio.lsp.messages;
import java.util.List;
public record BaselineDocumentLinks(List<BaselineDocumentLink> links) {
public BaselineDocumentLinks {
links = links == null ? List.of() : List.copyOf(links);
}
public static BaselineDocumentLinks empty() {
return new BaselineDocumentLinks(List.of());
}
}

View File

@ -16,6 +16,7 @@ public record BaselineServerDescription(
boolean workspaceSymbolsSupported, boolean workspaceSymbolsSupported,
boolean renameSupported, boolean renameSupported,
boolean codeActionsSupported, boolean codeActionsSupported,
boolean documentLinksSupported,
List<String> semanticTokenTypes, List<String> semanticTokenTypes,
List<BaselineSemanticHostProjection> semanticHostProjections, List<BaselineSemanticHostProjection> semanticHostProjections,
List<BaselineVisualTheme> visualThemes, List<BaselineVisualTheme> visualThemes,

View File

@ -9,6 +9,7 @@ import p.studio.lsp.messages.BaselineReferences;
import p.studio.lsp.messages.BaselineWorkspaceSymbols; import p.studio.lsp.messages.BaselineWorkspaceSymbols;
import p.studio.lsp.messages.BaselineCodeActionDiagnostic; import p.studio.lsp.messages.BaselineCodeActionDiagnostic;
import p.studio.lsp.messages.BaselineCodeActions; import p.studio.lsp.messages.BaselineCodeActions;
import p.studio.lsp.messages.BaselineDocumentLinks;
import p.studio.lsp.messages.BaselinePrepareRename; import p.studio.lsp.messages.BaselinePrepareRename;
import p.studio.lsp.messages.BaselineRename; import p.studio.lsp.messages.BaselineRename;
import p.studio.lsp.messages.BaselineSemanticTokens; import p.studio.lsp.messages.BaselineSemanticTokens;
@ -60,6 +61,10 @@ public interface LanguageServiceBridge {
BaselineSemanticTokens semanticTokens(LspProjectContext context, String documentUri, String text); BaselineSemanticTokens semanticTokens(LspProjectContext context, String documentUri, String text);
default BaselineDocumentLinks documentLinks(LspProjectContext context, String documentUri, String text) {
return BaselineDocumentLinks.empty();
}
BaselineCodeActions codeActions( BaselineCodeActions codeActions(
LspProjectContext context, LspProjectContext context,
String documentUri, String documentUri,

View File

@ -6,6 +6,7 @@ import p.studio.compiler.messages.*;
import p.studio.compiler.models.*; import p.studio.compiler.models.*;
import p.studio.compiler.services.FrontendCodeAction; import p.studio.compiler.services.FrontendCodeAction;
import p.studio.compiler.services.FrontendCompletionCandidate; import p.studio.compiler.services.FrontendCompletionCandidate;
import p.studio.compiler.services.FrontendDocumentLink;
import p.studio.compiler.services.FrontendLanguageService; import p.studio.compiler.services.FrontendLanguageService;
import p.studio.compiler.services.FrontendDefinitionLocation; import p.studio.compiler.services.FrontendDefinitionLocation;
import p.studio.compiler.services.FrontendDocumentRequest; import p.studio.compiler.services.FrontendDocumentRequest;
@ -57,6 +58,9 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
final boolean codeActionsSupported = languageService final boolean codeActionsSupported = languageService
.map(FrontendLanguageService::codeActionsSupported) .map(FrontendLanguageService::codeActionsSupported)
.orElse(false); .orElse(false);
final boolean documentLinksSupported = languageService
.map(FrontendLanguageService::documentLinksSupported)
.orElse(false);
return new BaselineServerDescription( return new BaselineServerDescription(
"Prometeu Studio LSP", "Prometeu Studio LSP",
"0.1.0", "0.1.0",
@ -70,6 +74,7 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
languageServicePresent, languageServicePresent,
languageServicePresent, languageServicePresent,
codeActionsSupported, codeActionsSupported,
documentLinksSupported,
presentation.semanticKeys(), presentation.semanticKeys(),
presentation.hostProjections().stream().map(this::mapSemanticHostProjection).toList(), presentation.hostProjections().stream().map(this::mapSemanticHostProjection).toList(),
presentation.themes().stream().map(this::mapVisualTheme).toList(), presentation.themes().stream().map(this::mapVisualTheme).toList(),
@ -365,6 +370,29 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
.orElseGet(() -> BaselineRename.refused("not renameable")); .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 @Override
public BaselineCodeActions codeActions( public BaselineCodeActions codeActions(
final LspProjectContext context, final LspProjectContext context,
@ -830,6 +858,28 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
return List.copyOf(mapped); return List.copyOf(mapped);
} }
private List<BaselineDocumentLink> mapDocumentLinks(
final List<FrontendDocumentLink> links,
final String currentDocumentText) {
if (links == null || links.isEmpty()) {
return List.of();
}
final DocumentPositionMapper mapper = new DocumentPositionMapper(
currentDocumentText == null ? "" : currentDocumentText);
final ArrayList<BaselineDocumentLink> 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<BaselineDefinitionLocation> mapDefinitionLocations( private List<BaselineDefinitionLocation> mapDefinitionLocations(
final List<FrontendDefinitionLocation> locations, final List<FrontendDefinitionLocation> locations,
final Path currentDocumentPath, final Path currentDocumentPath,

View File

@ -168,6 +168,14 @@ public final class PrometeuTextDocumentService implements TextDocumentService {
return CompletableFuture.completedFuture(protocolMessageMapper.mapRename(result)); return CompletableFuture.completedFuture(protocolMessageMapper.mapRename(result));
} }
@Override
public CompletableFuture<List<DocumentLink>> 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 @Override
public CompletableFuture<List<Either<Command, CodeAction>>> codeAction(final CodeActionParams params) { public CompletableFuture<List<Either<Command, CodeAction>>> codeAction(final CodeActionParams params) {
if (!quickFixRequested(params.getContext())) { if (!quickFixRequested(params.getContext())) {

View File

@ -53,6 +53,11 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper {
codeActionOptions.setCodeActionKinds(List.of(CodeActionKind.QuickFix)); codeActionOptions.setCodeActionKinds(List.of(CodeActionKind.QuickFix));
capabilities.setCodeActionProvider(codeActionOptions); capabilities.setCodeActionProvider(codeActionOptions);
} }
if (description.documentLinksSupported()) {
final DocumentLinkOptions documentLinkOptions = new DocumentLinkOptions();
documentLinkOptions.setResolveProvider(false);
capabilities.setDocumentLinkProvider(documentLinkOptions);
}
final SemanticTokensWithRegistrationOptions semanticTokens = new SemanticTokensWithRegistrationOptions(); final SemanticTokensWithRegistrationOptions semanticTokens = new SemanticTokensWithRegistrationOptions();
semanticTokens.setLegend(new SemanticTokensLegend(description.semanticTokenTypes(), List.of())); semanticTokens.setLegend(new SemanticTokensLegend(description.semanticTokenTypes(), List.of()));
semanticTokens.setFull(true); semanticTokens.setFull(true);
@ -159,6 +164,23 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper {
return edit; return edit;
} }
@Override
public List<DocumentLink> mapDocumentLinks(final BaselineDocumentLinks links) {
if (links == null || links.links().isEmpty()) {
return List.of();
}
final ArrayList<DocumentLink> 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 @Override
public List<Either<Command, CodeAction>> mapCodeActions( public List<Either<Command, CodeAction>> mapCodeActions(
final BaselineCodeActions actions, final BaselineCodeActions actions,

View File

@ -9,6 +9,7 @@ import org.eclipse.lsp4j.PublishDiagnosticsParams;
import org.eclipse.lsp4j.SignatureHelp; import org.eclipse.lsp4j.SignatureHelp;
import org.eclipse.lsp4j.SemanticTokens; import org.eclipse.lsp4j.SemanticTokens;
import org.eclipse.lsp4j.Diagnostic; import org.eclipse.lsp4j.Diagnostic;
import org.eclipse.lsp4j.DocumentLink;
import org.eclipse.lsp4j.DocumentSymbol; import org.eclipse.lsp4j.DocumentSymbol;
import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.Location;
import org.eclipse.lsp4j.Range; 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.PrepareRenameDefaultBehavior;
import org.eclipse.lsp4j.PrepareRenameResult; import org.eclipse.lsp4j.PrepareRenameResult;
import p.studio.lsp.messages.BaselineDocumentAnalysis; import p.studio.lsp.messages.BaselineDocumentAnalysis;
import p.studio.lsp.messages.BaselineDocumentLinks;
import p.studio.lsp.messages.BaselineDocumentSymbols; import p.studio.lsp.messages.BaselineDocumentSymbols;
import p.studio.lsp.messages.BaselineWorkspaceSymbols; import p.studio.lsp.messages.BaselineWorkspaceSymbols;
import p.studio.lsp.messages.BaselineCodeActions; import p.studio.lsp.messages.BaselineCodeActions;
@ -59,6 +61,10 @@ public interface ProtocolMessageMapper {
List<Either<Command, CodeAction>> mapCodeActions(BaselineCodeActions actions, List<Diagnostic> contextDiagnostics); List<Either<Command, CodeAction>> mapCodeActions(BaselineCodeActions actions, List<Diagnostic> contextDiagnostics);
default List<DocumentLink> mapDocumentLinks(BaselineDocumentLinks links) {
return List.of();
}
CompletionList mapCompletion(BaselineCompletion completion); CompletionList mapCompletion(BaselineCompletion completion);
SignatureHelp mapSignatureHelp(BaselineSignatureHelp signatureHelp); SignatureHelp mapSignatureHelp(BaselineSignatureHelp signatureHelp);

View File

@ -1,6 +1,7 @@
package p.studio.lsp.services.compiler; package p.studio.lsp.services.compiler;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.studio.compiler.messages.BuildingIssueSink; import p.studio.compiler.messages.BuildingIssueSink;
import p.studio.compiler.messages.FrontendPhaseContext; import p.studio.compiler.messages.FrontendPhaseContext;
import p.studio.compiler.models.FrontendSpec; 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.logs.LogAggregator;
import p.studio.utilities.structures.ReadOnlySet; import p.studio.utilities.structures.ReadOnlySet;
import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@ -74,6 +76,7 @@ class CompilerLanguageServiceBridgeTest {
assertTrue(bridge.codeActions(context, documentUri, "fn main() {}", List.of( assertTrue(bridge.codeActions(context, documentUri, "fn main() {}", List.of(
new BaselineCodeActionDiagnostic("E_SEM_DUPLICATE_RESERVED_ATTRIBUTE", 0, 0, 0, 1))) new BaselineCodeActionDiagnostic("E_SEM_DUPLICATE_RESERVED_ATTRIBUTE", 0, 0, 0, 1)))
.actions().isEmpty()); .actions().isEmpty());
assertTrue(bridge.documentLinks(context, documentUri, "fn main() {}").links().isEmpty());
} }
@Test @Test
@ -768,6 +771,7 @@ class CompilerLanguageServiceBridgeTest {
assertTrue(description.workspaceSymbolsSupported()); assertTrue(description.workspaceSymbolsSupported());
assertTrue(description.renameSupported()); assertTrue(description.renameSupported());
assertTrue(description.codeActionsSupported()); assertTrue(description.codeActionsSupported());
assertTrue(description.documentLinksSupported());
assertEquals(1, description.semanticHostProjections().size()); assertEquals(1, description.semanticHostProjections().size());
assertEquals("vscode", description.semanticHostProjections().getFirst().hostId()); assertEquals("vscode", description.semanticHostProjections().getFirst().hostId());
assertTrue(description.semanticHostProjections().getFirst().tokenProjections().stream() assertTrue(description.semanticHostProjections().getFirst().tokenProjections().stream()
@ -781,6 +785,77 @@ class CompilerLanguageServiceBridgeTest {
&& tokenStyle.foreground().equals("#7fb8ff"))); && 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) { private Path findRepoRoot(final Path start) {
var current = start; var current = start;
while (current != null) { while (current != null) {

View File

@ -105,6 +105,7 @@ class PrometeuLanguageServerTest {
true, true,
true, true,
false, false,
false,
List.of(), List.of(),
List.of(new BaselineSemanticHostProjection( List.of(new BaselineSemanticHostProjection(
"vscode", "vscode",

View File

@ -10,6 +10,8 @@ import p.studio.lsp.messages.BaselineCodeActions;
import p.studio.lsp.messages.BaselineCompletion; import p.studio.lsp.messages.BaselineCompletion;
import p.studio.lsp.messages.BaselineDocumentAnalysis; import p.studio.lsp.messages.BaselineDocumentAnalysis;
import p.studio.lsp.messages.BaselineDocumentIssue; 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.BaselineIssueSeverity;
import p.studio.lsp.messages.BaselineCompletionItem; import p.studio.lsp.messages.BaselineCompletionItem;
import p.studio.lsp.messages.BaselineCompletionItemKind; import p.studio.lsp.messages.BaselineCompletionItemKind;
@ -55,6 +57,7 @@ final class Lsp4jProtocolMessageMapperTest {
true, true,
true, true,
true, true,
true,
List.of("demo-keyword"), List.of("demo-keyword"),
List.of(new BaselineSemanticHostProjection( List.of(new BaselineSemanticHostProjection(
"vscode", "vscode",
@ -82,6 +85,7 @@ final class Lsp4jProtocolMessageMapperTest {
assertEquals(Boolean.TRUE, result.getCapabilities().getRenameProvider().getRight().getPrepareProvider()); assertEquals(Boolean.TRUE, result.getCapabilities().getRenameProvider().getRight().getPrepareProvider());
assertEquals(List.of(CodeActionKind.QuickFix), result.getCapabilities().getCodeActionProvider().getRight().getCodeActionKinds()); assertEquals(List.of(CodeActionKind.QuickFix), result.getCapabilities().getCodeActionProvider().getRight().getCodeActionKinds());
assertEquals(Boolean.FALSE, result.getCapabilities().getCodeActionProvider().getRight().getResolveProvider()); 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 experimental = assertInstanceOf(Map.class, result.getCapabilities().getExperimental());
final var semanticPayload = assertInstanceOf(Map.class, experimental.get("prometeuSemanticHostProjections")); final var semanticPayload = assertInstanceOf(Map.class, experimental.get("prometeuSemanticHostProjections"));
@ -298,4 +302,53 @@ final class Lsp4jProtocolMessageMapperTest {
assertEquals(1, action.getDiagnostics().size()); assertEquals(1, action.getDiagnostics().size());
assertEquals(diagnostic, action.getDiagnostics().getFirst()); 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");
}
} }