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 7e2850c5..ab924de8 100644 --- a/docs/specs/compiler/23. Compiler Pipeline Entry Points Specification.md +++ b/docs/specs/compiler/23. Compiler Pipeline Entry Points Specification.md @@ -290,6 +290,10 @@ When tooling publishes an editor diagnostic, `source` MUST be the `languageId` o 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. +When a frontend exposes folding ranges, the ranges MUST come from recovered syntax spans for top-level `fn`, `struct`, `service`, `contract`, `enum`, `error`, `callback`, and `host` declarations, their bodies, and `Doc` text blocks. A parameter list MUST fold from the parenthesis pair. A brace, parenthesis, or text-block delimiter that the recovered tree does not cover MUST still produce a token range, including an unmatched opener through the end of the text. Folding ranges MUST NOT be document symbols. + +When a frontend exposes selection ranges, the chain at a cursor MUST run from the inside outward in this order: identifier, argument or parameter list, block or `Doc` text block, declaration. A layer that does not contain the cursor MUST be omitted. A missing folding or selection 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 8333ede3..14439297 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 @@ -16,12 +16,15 @@ import p.studio.compiler.pbs.semantics.PbsEditorialSupportService.EditorialDocum import p.studio.compiler.models.SourceKind; import p.studio.compiler.pbs.semantics.PbsEditorialRename; import p.studio.compiler.pbs.semantics.PbsEditorialWorkspaceSymbol; +import p.studio.compiler.pbs.PbsStructureRanges; import p.studio.compiler.pbs.semantics.PbsQuickFix; 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.FrontendFoldingRange; +import p.studio.compiler.services.FrontendSelectionRange; import p.studio.compiler.services.FrontendDefinitionLocation; import p.studio.compiler.services.FrontendDocumentRequest; import p.studio.compiler.services.FrontendDocumentSymbol; @@ -443,6 +446,35 @@ public final class PBSFrontendLanguageService implements FrontendLanguageService return List.copyOf(links); } + @Override + public boolean foldingRangesSupported() { + return true; + } + + @Override + public List foldingRanges( + final FrontendDocumentRequest request, + final FrontendEditorialContext editorialContext) { + return PbsStructureRanges.foldingRanges(request == null || request.documentText() == null + ? "" + : request.documentText()); + } + + @Override + public boolean selectionRangesSupported() { + return true; + } + + @Override + public List selectionRanges( + final FrontendDocumentRequest request, + final FrontendEditorialContext editorialContext, + final List offsets) { + return PbsStructureRanges.selectionRanges( + request == null || request.documentText() == null ? "" : request.documentText(), + offsets); + } + @Override public boolean codeActionsSupported() { return true; diff --git a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/pbs/PbsStructureRanges.java b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/pbs/PbsStructureRanges.java new file mode 100644 index 00000000..e5d6c127 --- /dev/null +++ b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/main/java/p/studio/compiler/pbs/PbsStructureRanges.java @@ -0,0 +1,380 @@ +package p.studio.compiler.pbs; + +import p.studio.compiler.pbs.ast.PbsAst; +import p.studio.compiler.pbs.lexer.PbsLexer; +import p.studio.compiler.pbs.lexer.PbsToken; +import p.studio.compiler.pbs.lexer.PbsTokenKind; +import p.studio.compiler.pbs.parser.PbsParser; +import p.studio.compiler.services.FrontendFoldingRange; +import p.studio.compiler.services.FrontendSelectionRange; +import p.studio.compiler.source.Span; +import p.studio.compiler.source.diagnostics.DiagnosticSink; +import p.studio.compiler.source.identifiers.FileId; +import p.studio.utilities.structures.ReadOnlyList; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; + +public final class PbsStructureRanges { + private PbsStructureRanges() { + } + + public static List foldingRanges(final String documentText) { + final String text = documentText == null ? "" : documentText; + final ReadOnlyList tokens = PbsLexer.lex(text, FileId.none(), DiagnosticSink.empty()); + final PbsAst.File ast = PbsParser.parse(tokens, FileId.none(), DiagnosticSink.empty()); + return foldingRanges(text, ast, tokens); + } + + public static List selectionRanges( + final String documentText, + final List offsets) { + final String text = documentText == null ? "" : documentText; + final ReadOnlyList tokens = PbsLexer.lex(text, FileId.none(), DiagnosticSink.empty()); + final PbsAst.File ast = PbsParser.parse(tokens, FileId.none(), DiagnosticSink.empty()); + if (offsets == null || offsets.isEmpty()) { + return List.of(); + } + final Collected collected = collect(ast); + final List delimiters = delimiters(tokens, textEnd(text)); + final ArrayList ranges = new ArrayList<>(); + for (final Integer offset : offsets) { + if (offset == null || offset < 0) { + continue; + } + final FrontendSelectionRange range = selectionAt(offset, tokens, collected, delimiters); + if (range != null) { + ranges.add(range); + } + } + return List.copyOf(ranges); + } + + private static List foldingRanges( + final String text, + final PbsAst.File ast, + final ReadOnlyList tokens) { + final Collected collected = collect(ast); + final LinkedHashSet emitted = new LinkedHashSet<>(); + final ArrayList ranges = new ArrayList<>(); + for (final SpanPair span : collected.folding()) { + addRange(ranges, emitted, span); + } + for (final SpanPair delimiter : delimiters(tokens, textEnd(text))) { + if (collected.coveredKeys().contains(delimiter.key())) { + continue; + } + addRange(ranges, emitted, delimiter); + } + ranges.sort(Comparator.comparingInt(SpanPair::start).thenComparingInt(SpanPair::end)); + return ranges.stream() + .map(span -> new FrontendFoldingRange(span.start(), span.end())) + .toList(); + } + + private static FrontendSelectionRange selectionAt( + final int offset, + final ReadOnlyList tokens, + final Collected collected, + final List delimiters) { + final SpanPair identifier = smallest(identifierSpans(tokens), offset); + final SpanPair parameters = smallest(delimiters, offset); + final SpanPair block = smallest(collected.blocks(), offset); + final SpanPair declaration = smallest(collected.declarations(), offset); + FrontendSelectionRange node = null; + SpanPair previous = null; + for (final SpanPair layer : new SpanPair[] {declaration, block, parameters, identifier}) { + if (layer == null) { + continue; + } + if (previous != null && previous.start() == layer.start() && previous.end() == layer.end()) { + continue; + } + node = new FrontendSelectionRange(layer.start(), layer.end(), node); + previous = layer; + } + return node; + } + + private static Collected collect(final PbsAst.File ast) { + final Collected collected = new Collected(); + if (ast == null || ast.topDecls() == null) { + return collected; + } + for (final PbsAst.TopDecl topDecl : ast.topDecls()) { + visitTop(topDecl, true, collected); + } + return collected; + } + + private static void visitTop( + final PbsAst.TopDecl topDecl, + final boolean topLevel, + final Collected collected) { + if (topDecl == null) { + return; + } + if (topLevel && isListedDeclaration(topDecl)) { + addSpan(collected.folding(), topDecl.span()); + } + if (isListedDeclaration(topDecl) || topDecl instanceof PbsAst.FunctionDecl) { + addSpan(collected.declarations(), topDecl.span()); + } + switch (topDecl) { + case PbsAst.FunctionDecl functionDecl -> visitFunction(functionDecl, collected); + case PbsAst.StructDecl structDecl -> { + visitAttributes(structDecl.attributes(), collected); + if (structDecl.methods() != null) { + for (final PbsAst.FunctionDecl method : structDecl.methods()) { + visitTop(method, false, collected); + } + } + if (structDecl.ctors() != null) { + for (final PbsAst.CtorDecl ctor : structDecl.ctors()) { + visitCtor(ctor, collected); + } + } + } + case PbsAst.ServiceDecl serviceDecl -> { + visitAttributes(serviceDecl.attributes(), collected); + if (serviceDecl.methods() != null) { + for (final PbsAst.FunctionDecl method : serviceDecl.methods()) { + visitTop(method, false, collected); + } + } + } + case PbsAst.ContractDecl contractDecl -> visitAttributes(contractDecl.attributes(), collected); + case PbsAst.HostDecl hostDecl -> visitAttributes(hostDecl.attributes(), collected); + case PbsAst.EnumDecl enumDecl -> visitAttributes(enumDecl.attributes(), collected); + case PbsAst.ErrorDecl errorDecl -> visitAttributes(errorDecl.attributes(), collected); + case PbsAst.CallbackDecl callbackDecl -> visitAttributes(callbackDecl.attributes(), collected); + default -> { + } + } + } + + private static void visitFunction( + final PbsAst.FunctionDecl functionDecl, + final Collected collected) { + visitAttributes(functionDecl.attributes(), collected); + coverBlock(functionDecl.body(), true, collected); + } + + private static void visitCtor( + final PbsAst.CtorDecl ctor, + final Collected collected) { + if (ctor == null) { + return; + } + addSpan(collected.declarations(), ctor.span()); + coverBlock(ctor.body(), true, collected); + } + + private static void visitAttributes( + final ReadOnlyList attributes, + final Collected collected) { + if (attributes == null) { + return; + } + for (final PbsAst.Attribute attribute : attributes) { + if (attribute == null || attribute.arguments() == null) { + continue; + } + for (final PbsAst.AttributeArgument argument : attribute.arguments()) { + if (argument != null && argument.value() instanceof PbsAst.AttributeDocTextBlockValue doc) { + addSpan(collected.folding(), doc.span()); + addSpan(collected.blocks(), doc.span()); + collected.cover(doc.span()); + } + } + } + } + + private static void coverBlock( + final PbsAst.Block block, + final boolean emit, + final Collected collected) { + if (block == null) { + return; + } + if (emit) { + addSpan(collected.folding(), block.span()); + } + addSpan(collected.blocks(), block.span()); + collected.cover(block.span()); + if (block.statements() == null) { + return; + } + for (final PbsAst.Statement statement : block.statements()) { + coverStatement(statement, collected); + } + } + + private static void coverStatement( + final PbsAst.Statement statement, + final Collected collected) { + switch (statement) { + case null -> { + } + case PbsAst.IfStatement ifStatement -> { + coverBlock(ifStatement.thenBlock(), false, collected); + coverBlock(ifStatement.elseBlock(), false, collected); + coverStatement(ifStatement.elseIf(), collected); + } + case PbsAst.ForStatement forStatement -> coverBlock(forStatement.body(), false, collected); + case PbsAst.WhileStatement whileStatement -> coverBlock(whileStatement.body(), false, collected); + default -> { + } + } + } + + private static boolean isListedDeclaration(final PbsAst.TopDecl topDecl) { + return topDecl instanceof PbsAst.FunctionDecl + || topDecl instanceof PbsAst.StructDecl + || topDecl instanceof PbsAst.ServiceDecl + || topDecl instanceof PbsAst.ContractDecl + || topDecl instanceof PbsAst.HostDecl + || topDecl instanceof PbsAst.EnumDecl + || topDecl instanceof PbsAst.ErrorDecl + || topDecl instanceof PbsAst.CallbackDecl; + } + + private static List delimiters( + final ReadOnlyList tokens, + final int textEnd) { + final ArrayList stack = new ArrayList<>(); + final ArrayList pairs = new ArrayList<>(); + if (tokens == null) { + return pairs; + } + for (final PbsToken token : tokens) { + if (token == null) { + continue; + } + if (token.kind() == PbsTokenKind.LEFT_BRACE || token.kind() == PbsTokenKind.LEFT_PAREN) { + stack.add(token); + continue; + } + if (token.kind() == PbsTokenKind.DOC_TEXT_BLOCK) { + addPair(pairs, token.start(), token.end()); + continue; + } + if (token.kind() == PbsTokenKind.RIGHT_BRACE || token.kind() == PbsTokenKind.RIGHT_PAREN) { + final PbsTokenKind openKind = token.kind() == PbsTokenKind.RIGHT_BRACE + ? PbsTokenKind.LEFT_BRACE + : PbsTokenKind.LEFT_PAREN; + if (!stack.isEmpty() && stack.getLast().kind() == openKind) { + final PbsToken open = stack.removeLast(); + addPair(pairs, open.start(), token.end()); + } + } + } + for (final PbsToken open : stack) { + addPair(pairs, open.start(), textEnd); + } + return pairs; + } + + private static List identifierSpans(final ReadOnlyList tokens) { + final ArrayList spans = new ArrayList<>(); + if (tokens == null) { + return spans; + } + for (final PbsToken token : tokens) { + if (token != null && token.kind() == PbsTokenKind.IDENTIFIER) { + addPair(spans, token.start(), token.end()); + } + } + return spans; + } + + private static SpanPair smallest(final List spans, final int offset) { + SpanPair best = null; + for (final SpanPair span : spans) { + if (span.start() > offset || offset >= span.end()) { + continue; + } + if (best == null + || span.end() - span.start() < best.end() - best.start() + || (span.end() - span.start() == best.end() - best.start() && span.start() > best.start())) { + best = span; + } + } + return best; + } + + private static void addSpan(final List spans, final Span span) { + if (span == null) { + return; + } + addPair(spans, toOffset(span.getStart()), toOffset(span.getEnd())); + } + + private static void addPair(final List spans, final int start, final int end) { + if (start < 0 || end <= start) { + return; + } + spans.add(new SpanPair(start, end)); + } + + private static void addRange( + final List ranges, + final LinkedHashSet emitted, + final SpanPair span) { + if (span == null || !emitted.add(span.key())) { + return; + } + ranges.add(span); + } + + private static int textEnd(final String text) { + return text.getBytes(StandardCharsets.UTF_8).length; + } + + private static int toOffset(final long value) { + if (value <= 0L) { + return 0; + } + return (int) Math.min(Integer.MAX_VALUE, value); + } + + private record SpanPair(int start, int end) { + private String key() { + return start + ":" + end; + } + } + + private static final class Collected { + private final ArrayList folding = new ArrayList<>(); + private final ArrayList covered = new ArrayList<>(); + private final ArrayList blocks = new ArrayList<>(); + private final ArrayList declarations = new ArrayList<>(); + + private List folding() { + return folding; + } + + private void cover(final Span span) { + addSpan(covered, span); + } + + private LinkedHashSet coveredKeys() { + final LinkedHashSet keys = new LinkedHashSet<>(); + for (final SpanPair span : covered) { + keys.add(span.key()); + } + return keys; + } + + private List blocks() { + return blocks; + } + + private List declarations() { + return declarations; + } + } +} diff --git a/prometeu-compiler/frontends/prometeu-frontend-pbs/src/test/java/p/studio/compiler/pbs/PbsStructureRangesTest.java b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/test/java/p/studio/compiler/pbs/PbsStructureRangesTest.java new file mode 100644 index 00000000..7f2737ec --- /dev/null +++ b/prometeu-compiler/frontends/prometeu-frontend-pbs/src/test/java/p/studio/compiler/pbs/PbsStructureRangesTest.java @@ -0,0 +1,73 @@ +package p.studio.compiler.pbs; + +import org.junit.jupiter.api.Test; +import p.studio.compiler.services.FrontendFoldingRange; +import p.studio.compiler.services.FrontendSelectionRange; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PbsStructureRangesTest { + private static final String SOURCE = """ + [Doc(markdown = \"\"\" + hello + \"\"\")] + fn length(value: int) -> int { + return value; + } + """; + + @Test + void foldingUsesDeclarationBodyParameterListAndDocText() { + final var ranges = PbsStructureRanges.foldingRanges(SOURCE); + final var slices = ranges.stream().map(range -> slice(SOURCE, range)).toList(); + + assertTrue(slices.stream().anyMatch(slice -> slice.contains("hello")), slices.toString()); + assertTrue(slices.stream().anyMatch(slice -> slice.equals("(value: int)")), slices.toString()); + assertTrue(slices.stream().anyMatch(slice -> slice.startsWith("{")), slices.toString()); + assertTrue(slices.stream().anyMatch(slice -> slice.contains("fn length")), slices.toString()); + } + + @Test + void selectionGrowsFromIdentifierToBlockToDeclaration() { + final int marker = SOURCE.indexOf("return value"); + assertTrue(marker >= 0, SOURCE); + final int offset = marker + "return ".length(); + final var selections = PbsStructureRanges.selectionRanges(SOURCE, List.of(offset)); + assertEquals(1, selections.size(), "offset " + offset); + final FrontendSelectionRange selection = selections.getFirst(); + + assertEquals("value", slice(SOURCE, selection.startOffset(), selection.endOffset())); + assertNotNull(selection.parent()); + assertTrue(slice(SOURCE, selection.parent().startOffset(), selection.parent().endOffset()).startsWith("{")); + assertNotNull(selection.parent().parent()); + assertTrue(slice(SOURCE, selection.parent().parent().startOffset(), selection.parent().parent().endOffset()) + .contains("fn length")); + } + + @Test + void unmatchedBraceFoldsThroughTheEndOfTheText() { + final String broken = """ + fn broken() -> void { + let x: int = 1; + """; + final int textEnd = broken.getBytes(StandardCharsets.UTF_8).length; + final var ranges = PbsStructureRanges.foldingRanges(broken); + + assertTrue(ranges.stream().anyMatch(range -> + range.endOffset() == textEnd && slice(broken, range).startsWith("{"))); + } + + private static String slice(final String text, final FrontendFoldingRange range) { + return slice(text, range.startOffset(), range.endOffset()); + } + + private static String slice(final String text, final int start, final int end) { + final byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + return new String(bytes, start, end - start, StandardCharsets.UTF_8); + } +} diff --git a/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendFoldingRange.java b/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendFoldingRange.java new file mode 100644 index 00000000..1e0d977f --- /dev/null +++ b/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendFoldingRange.java @@ -0,0 +1,15 @@ +package p.studio.compiler.services; + +public record FrontendFoldingRange( + int startOffset, + int endOffset) { + + public FrontendFoldingRange { + 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 5934fd1e..48a33e00 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 @@ -153,6 +153,37 @@ public interface FrontendLanguageService { return documentLinks(request); } + default boolean foldingRangesSupported() { + return false; + } + + default List foldingRanges(final FrontendDocumentRequest request) { + return List.of(); + } + + default List foldingRanges( + final FrontendDocumentRequest request, + final FrontendEditorialContext editorialContext) { + return foldingRanges(request); + } + + default boolean selectionRangesSupported() { + return false; + } + + default List selectionRanges( + final FrontendDocumentRequest request, + final List offsets) { + return List.of(); + } + + default List selectionRanges( + final FrontendDocumentRequest request, + final FrontendEditorialContext editorialContext, + final List offsets) { + return selectionRanges(request, offsets); + } + default boolean codeActionsSupported() { return false; } diff --git a/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendSelectionRange.java b/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendSelectionRange.java new file mode 100644 index 00000000..15750bc9 --- /dev/null +++ b/prometeu-compiler/prometeu-frontend-api/src/main/java/p/studio/compiler/services/FrontendSelectionRange.java @@ -0,0 +1,16 @@ +package p.studio.compiler.services; + +public record FrontendSelectionRange( + int startOffset, + int endOffset, + FrontendSelectionRange parent) { + + public FrontendSelectionRange { + 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/test/java/p/studio/compiler/services/FrontendLanguageServiceTest.java b/prometeu-compiler/prometeu-frontend-api/src/test/java/p/studio/compiler/services/FrontendLanguageServiceTest.java index a5c5a7d5..dfb2d63d 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 @@ -33,6 +33,12 @@ class FrontendLanguageServiceTest { assertFalse(service.documentLinksSupported()); assertTrue(service.documentLinks(request).isEmpty()); assertTrue(service.documentLinks(request, null).isEmpty()); + assertFalse(service.foldingRangesSupported()); + assertTrue(service.foldingRanges(request).isEmpty()); + assertTrue(service.foldingRanges(request, null).isEmpty()); + assertFalse(service.selectionRangesSupported()); + assertTrue(service.selectionRanges(request, List.of(0)).isEmpty()); + assertTrue(service.selectionRanges(request, null, List.of(0)).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/BaselineFoldingRange.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineFoldingRange.java new file mode 100644 index 00000000..877122e5 --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineFoldingRange.java @@ -0,0 +1,14 @@ +package p.studio.lsp.messages; + +public record BaselineFoldingRange( + int startLine, + int startCharacter, + int endLine, + int endCharacter) { + + public BaselineFoldingRange { + if (startLine < 0 || startCharacter < 0 || endLine < 0 || endCharacter < 0) { + throw new IllegalArgumentException("folding range coordinates must not be negative"); + } + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineFoldingRanges.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineFoldingRanges.java new file mode 100644 index 00000000..0d759b9a --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineFoldingRanges.java @@ -0,0 +1,13 @@ +package p.studio.lsp.messages; + +import java.util.List; + +public record BaselineFoldingRanges(List ranges) { + public BaselineFoldingRanges { + ranges = ranges == null ? List.of() : List.copyOf(ranges); + } + + public static BaselineFoldingRanges empty() { + return new BaselineFoldingRanges(List.of()); + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineSelectionRange.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineSelectionRange.java new file mode 100644 index 00000000..09aac25c --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineSelectionRange.java @@ -0,0 +1,15 @@ +package p.studio.lsp.messages; + +public record BaselineSelectionRange( + int startLine, + int startCharacter, + int endLine, + int endCharacter, + BaselineSelectionRange parent) { + + public BaselineSelectionRange { + if (startLine < 0 || startCharacter < 0 || endLine < 0 || endCharacter < 0) { + throw new IllegalArgumentException("selection range coordinates must not be negative"); + } + } +} diff --git a/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineSelectionRanges.java b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineSelectionRanges.java new file mode 100644 index 00000000..94cc6abc --- /dev/null +++ b/prometeu-lsp/prometeu-lsp-v1/src/main/java/p/studio/lsp/messages/BaselineSelectionRanges.java @@ -0,0 +1,13 @@ +package p.studio.lsp.messages; + +import java.util.List; + +public record BaselineSelectionRanges(List ranges) { + public BaselineSelectionRanges { + ranges = ranges == null ? List.of() : List.copyOf(ranges); + } + + public static BaselineSelectionRanges empty() { + return new BaselineSelectionRanges(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 85e3c003..ef9eba20 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 @@ -17,6 +17,8 @@ public record BaselineServerDescription( boolean renameSupported, boolean codeActionsSupported, boolean documentLinksSupported, + boolean foldingRangesSupported, + boolean selectionRangesSupported, 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 838c073a..f704f8df 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 @@ -10,6 +10,9 @@ 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.BaselineFoldingRanges; +import p.studio.lsp.messages.BaselineSelectionRanges; +import p.studio.lsp.messages.DocumentPosition; import p.studio.lsp.messages.BaselinePrepareRename; import p.studio.lsp.messages.BaselineRename; import p.studio.lsp.messages.BaselineSemanticTokens; @@ -65,6 +68,18 @@ public interface LanguageServiceBridge { return BaselineDocumentLinks.empty(); } + default BaselineFoldingRanges foldingRanges(LspProjectContext context, String documentUri, String text) { + return BaselineFoldingRanges.empty(); + } + + default BaselineSelectionRanges selectionRanges( + LspProjectContext context, + String documentUri, + String text, + List positions) { + return BaselineSelectionRanges.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 f34faec2..cf85b739 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 @@ -7,6 +7,8 @@ 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.FrontendFoldingRange; +import p.studio.compiler.services.FrontendSelectionRange; import p.studio.compiler.services.FrontendLanguageService; import p.studio.compiler.services.FrontendDefinitionLocation; import p.studio.compiler.services.FrontendDocumentRequest; @@ -61,6 +63,12 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg final boolean documentLinksSupported = languageService .map(FrontendLanguageService::documentLinksSupported) .orElse(false); + final boolean foldingRangesSupported = languageService + .map(FrontendLanguageService::foldingRangesSupported) + .orElse(false); + final boolean selectionRangesSupported = languageService + .map(FrontendLanguageService::selectionRangesSupported) + .orElse(false); return new BaselineServerDescription( "Prometeu Studio LSP", "0.1.0", @@ -75,6 +83,8 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg languageServicePresent, codeActionsSupported, documentLinksSupported, + foldingRangesSupported, + selectionRangesSupported, presentation.semanticKeys(), presentation.hostProjections().stream().map(this::mapSemanticHostProjection).toList(), presentation.themes().stream().map(this::mapVisualTheme).toList(), @@ -393,6 +403,120 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg .orElseGet(BaselineDocumentLinks::empty); } + @Override + public BaselineFoldingRanges foldingRanges( + final LspProjectContext context, + final String documentUri, + final String text) { + return structureRanges(context, documentUri, text, true, List.of()).folding(); + } + + @Override + public BaselineSelectionRanges selectionRanges( + final LspProjectContext context, + final String documentUri, + final String text, + final List positions) { + return structureRanges(context, documentUri, text, false, positions == null ? List.of() : positions).selection(); + } + + private StructureRanges structureRanges( + final LspProjectContext context, + final String documentUri, + final String text, + final boolean folding, + final List positions) { + Objects.requireNonNull(context, "context"); + final var languageService = frontendProvider(context).languageService(); + if (languageService.isEmpty()) { + return StructureRanges.empty(); + } + final boolean supported = folding + ? languageService.orElseThrow().foldingRangesSupported() + : languageService.orElseThrow().selectionRangesSupported(); + if (!supported) { + return StructureRanges.empty(); + } + final String effectiveText = text == null ? "" : text; + final FrontendDocumentRequest request = new FrontendDocumentRequest( + context.projectRoot(), + normalizeDocumentPath(documentUri), + effectiveText); + final DocumentPositionMapper mapper = new DocumentPositionMapper(effectiveText); + if (folding) { + return new StructureRanges(new BaselineFoldingRanges(mapFoldingRanges( + languageService.orElseThrow().foldingRanges(request, null), + mapper)), BaselineSelectionRanges.empty()); + } + final ArrayList offsets = new ArrayList<>(); + for (final DocumentPosition position : positions) { + if (position == null) { + offsets.add(0); + continue; + } + offsets.add(mapper.byteOffsetOf(position.line(), position.character())); + } + return new StructureRanges( + BaselineFoldingRanges.empty(), + new BaselineSelectionRanges(mapSelectionRanges( + languageService.orElseThrow().selectionRanges(request, null, offsets), + mapper))); + } + + private List mapFoldingRanges( + final List ranges, + final DocumentPositionMapper mapper) { + if (ranges == null || ranges.isEmpty()) { + return List.of(); + } + final ArrayList mapped = new ArrayList<>(); + for (final FrontendFoldingRange range : ranges) { + final DocumentPosition start = mapper.positionOf(range.startOffset()); + final DocumentPosition end = mapper.positionOf(range.endOffset()); + mapped.add(new BaselineFoldingRange( + start.line(), + start.character(), + end.line(), + end.character())); + } + return List.copyOf(mapped); + } + + private List mapSelectionRanges( + final List ranges, + final DocumentPositionMapper mapper) { + if (ranges == null || ranges.isEmpty()) { + return List.of(); + } + final ArrayList mapped = new ArrayList<>(); + for (final FrontendSelectionRange range : ranges) { + mapped.add(mapSelectionRange(range, mapper)); + } + return List.copyOf(mapped); + } + + private BaselineSelectionRange mapSelectionRange( + final FrontendSelectionRange range, + final DocumentPositionMapper mapper) { + if (range == null) { + return null; + } + final DocumentPosition start = mapper.positionOf(range.startOffset()); + final DocumentPosition end = mapper.positionOf(range.endOffset()); + return new BaselineSelectionRange( + start.line(), + start.character(), + end.line(), + end.character(), + mapSelectionRange(range.parent(), mapper)); + } + + private record StructureRanges(BaselineFoldingRanges folding, BaselineSelectionRanges selection) { + private static StructureRanges empty() { + return new StructureRanges(BaselineFoldingRanges.empty(), BaselineSelectionRanges.empty()); + } + } + @Override public BaselineCodeActions codeActions( final LspProjectContext context, 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 4d4cad03..a8b8ea89 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 @@ -9,6 +9,7 @@ import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode; import org.eclipse.lsp4j.services.LanguageClient; import org.eclipse.lsp4j.services.TextDocumentService; import p.studio.lsp.messages.BaselineCodeActionDiagnostic; +import p.studio.lsp.messages.DocumentPosition; import p.studio.lsp.messages.LspProjectContext; import p.studio.lsp.services.LanguageServiceBridge; import p.studio.lsp.services.protocol.mapping.ProtocolMessageMapper; @@ -168,6 +169,31 @@ public final class PrometeuTextDocumentService implements TextDocumentService { return CompletableFuture.completedFuture(protocolMessageMapper.mapRename(result)); } + @Override + public CompletableFuture> foldingRange(final FoldingRangeRequestParams params) { + final String uri = params.getTextDocument().getUri(); + final String text = documentTextByUri.get(uri); + return CompletableFuture.completedFuture(protocolMessageMapper.mapFoldingRanges( + languageServiceBridge.foldingRanges(project, uri, text))); + } + + @Override + public CompletableFuture> selectionRange(final SelectionRangeParams params) { + final String uri = params.getTextDocument().getUri(); + final String text = documentTextByUri.get(uri); + final ArrayList positions = new ArrayList<>(); + if (params.getPositions() != null) { + for (final Position position : params.getPositions()) { + if (position == null) { + continue; + } + positions.add(new DocumentPosition(position.getLine(), position.getCharacter())); + } + } + return CompletableFuture.completedFuture(protocolMessageMapper.mapSelectionRanges( + languageServiceBridge.selectionRanges(project, uri, text, positions))); + } + @Override public CompletableFuture> documentLink(final DocumentLinkParams params) { final String uri = params.getTextDocument().getUri(); 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 b3608e2e..e1bdf41c 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 @@ -58,6 +58,12 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper { documentLinkOptions.setResolveProvider(false); capabilities.setDocumentLinkProvider(documentLinkOptions); } + if (description.foldingRangesSupported()) { + capabilities.setFoldingRangeProvider(true); + } + if (description.selectionRangesSupported()) { + capabilities.setSelectionRangeProvider(true); + } final SemanticTokensWithRegistrationOptions semanticTokens = new SemanticTokensWithRegistrationOptions(); semanticTokens.setLegend(new SemanticTokensLegend(description.semanticTokenTypes(), List.of())); semanticTokens.setFull(true); @@ -181,6 +187,47 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper { return List.copyOf(mapped); } + @Override + public List mapFoldingRanges(final BaselineFoldingRanges ranges) { + if (ranges == null || ranges.ranges().isEmpty()) { + return List.of(); + } + final ArrayList mapped = new ArrayList<>(); + for (final BaselineFoldingRange range : ranges.ranges()) { + final FoldingRange foldingRange = new FoldingRange(); + foldingRange.setStartLine(range.startLine()); + foldingRange.setStartCharacter(range.startCharacter()); + foldingRange.setEndLine(range.endLine()); + foldingRange.setEndCharacter(range.endCharacter()); + mapped.add(foldingRange); + } + return List.copyOf(mapped); + } + + @Override + public List mapSelectionRanges(final BaselineSelectionRanges ranges) { + if (ranges == null || ranges.ranges().isEmpty()) { + return List.of(); + } + final ArrayList mapped = new ArrayList<>(); + for (final BaselineSelectionRange range : ranges.ranges()) { + mapped.add(mapSelectionRange(range)); + } + return List.copyOf(mapped); + } + + private SelectionRange mapSelectionRange(final BaselineSelectionRange range) { + if (range == null) { + return null; + } + final SelectionRange selectionRange = new SelectionRange(); + selectionRange.setRange(new Range( + new Position(range.startLine(), range.startCharacter()), + new Position(range.endLine(), range.endCharacter()))); + selectionRange.setParent(mapSelectionRange(range.parent())); + return selectionRange; + } + @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 51bb2f84..b7de3e24 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 @@ -11,6 +11,8 @@ import org.eclipse.lsp4j.SemanticTokens; import org.eclipse.lsp4j.Diagnostic; import org.eclipse.lsp4j.DocumentLink; import org.eclipse.lsp4j.DocumentSymbol; +import org.eclipse.lsp4j.FoldingRange; +import org.eclipse.lsp4j.SelectionRange; import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.SymbolInformation; @@ -21,6 +23,8 @@ 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.BaselineFoldingRanges; +import p.studio.lsp.messages.BaselineSelectionRanges; import p.studio.lsp.messages.BaselineDocumentSymbols; import p.studio.lsp.messages.BaselineWorkspaceSymbols; import p.studio.lsp.messages.BaselineCodeActions; @@ -65,6 +69,14 @@ public interface ProtocolMessageMapper { return List.of(); } + default List mapFoldingRanges(BaselineFoldingRanges ranges) { + return List.of(); + } + + default List mapSelectionRanges(BaselineSelectionRanges ranges) { + 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 3f00e44b..a9983601 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 @@ -11,6 +11,7 @@ import p.studio.compiler.services.FrontendProvider; import p.studio.compiler.source.diagnostics.DiagnosticSink; import p.studio.lsp.messages.BaselineCodeActionDiagnostic; import p.studio.lsp.messages.BaselineIssueSeverity; +import p.studio.lsp.messages.DocumentPosition; import p.studio.lsp.messages.LspProjectContext; import p.studio.utilities.logs.LogAggregator; import p.studio.utilities.structures.ReadOnlySet; @@ -77,6 +78,9 @@ class CompilerLanguageServiceBridgeTest { new BaselineCodeActionDiagnostic("E_SEM_DUPLICATE_RESERVED_ATTRIBUTE", 0, 0, 0, 1))) .actions().isEmpty()); assertTrue(bridge.documentLinks(context, documentUri, "fn main() {}").links().isEmpty()); + assertTrue(bridge.foldingRanges(context, documentUri, "fn main() {}").ranges().isEmpty()); + assertTrue(bridge.selectionRanges(context, documentUri, "fn main() {}", List.of(new DocumentPosition(0, 0))) + .ranges().isEmpty()); } @Test @@ -772,6 +776,8 @@ class CompilerLanguageServiceBridgeTest { assertTrue(description.renameSupported()); assertTrue(description.codeActionsSupported()); assertTrue(description.documentLinksSupported()); + assertTrue(description.foldingRangesSupported()); + assertTrue(description.selectionRangesSupported()); assertEquals(1, description.semanticHostProjections().size()); assertEquals("vscode", description.semanticHostProjections().getFirst().hostId()); assertTrue(description.semanticHostProjections().getFirst().tokenProjections().stream() 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 0f93d292..97d4aaee 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 @@ -106,6 +106,8 @@ class PrometeuLanguageServerTest { true, false, false, + 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 85ce742d..284cee4b 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 @@ -58,6 +58,8 @@ final class Lsp4jProtocolMessageMapperTest { true, true, true, + true, + true, List.of("demo-keyword"), List.of(new BaselineSemanticHostProjection( "vscode", @@ -86,6 +88,8 @@ final class Lsp4jProtocolMessageMapperTest { 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()); + assertEquals(Boolean.TRUE, result.getCapabilities().getFoldingRangeProvider().getLeft()); + assertEquals(Boolean.TRUE, result.getCapabilities().getSelectionRangeProvider().getLeft()); final var experimental = assertInstanceOf(Map.class, result.getCapabilities().getExperimental()); final var semanticPayload = assertInstanceOf(Map.class, experimental.get("prometeuSemanticHostProjections")); @@ -308,6 +312,8 @@ final class Lsp4jProtocolMessageMapperTest { final var mapper = new Lsp4jProtocolMessageMapper(); final var unsupported = mapper.mapInitializeResult(serverDescription(false)); assertNull(unsupported.getCapabilities().getDocumentLinkProvider()); + assertNull(unsupported.getCapabilities().getFoldingRangeProvider()); + assertNull(unsupported.getCapabilities().getSelectionRangeProvider()); final var mapped = mapper.mapDocumentLinks(new BaselineDocumentLinks(List.of( new BaselineDocumentLink("file:///tmp/a/source.pbs", 0, 19, 0, 25)))); @@ -335,6 +341,8 @@ final class Lsp4jProtocolMessageMapperTest { true, true, documentLinksSupported, + documentLinksSupported, + documentLinksSupported, List.of("demo-keyword"), List.of(new BaselineSemanticHostProjection( "vscode",