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
21 changed files with 854 additions and 0 deletions
Showing only changes of commit ce2e3e4cd3 - Show all commits

View File

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

View File

@ -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<FrontendFoldingRange> 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<FrontendSelectionRange> selectionRanges(
final FrontendDocumentRequest request,
final FrontendEditorialContext editorialContext,
final List<Integer> offsets) {
return PbsStructureRanges.selectionRanges(
request == null || request.documentText() == null ? "" : request.documentText(),
offsets);
}
@Override
public boolean codeActionsSupported() {
return true;

View File

@ -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<FrontendFoldingRange> foldingRanges(final String documentText) {
final String text = documentText == null ? "" : documentText;
final ReadOnlyList<PbsToken> 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<FrontendSelectionRange> selectionRanges(
final String documentText,
final List<Integer> offsets) {
final String text = documentText == null ? "" : documentText;
final ReadOnlyList<PbsToken> 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<SpanPair> delimiters = delimiters(tokens, textEnd(text));
final ArrayList<FrontendSelectionRange> 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<FrontendFoldingRange> foldingRanges(
final String text,
final PbsAst.File ast,
final ReadOnlyList<PbsToken> tokens) {
final Collected collected = collect(ast);
final LinkedHashSet<String> emitted = new LinkedHashSet<>();
final ArrayList<SpanPair> 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<PbsToken> tokens,
final Collected collected,
final List<SpanPair> 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<PbsAst.Attribute> 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<SpanPair> delimiters(
final ReadOnlyList<PbsToken> tokens,
final int textEnd) {
final ArrayList<PbsToken> stack = new ArrayList<>();
final ArrayList<SpanPair> 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<SpanPair> identifierSpans(final ReadOnlyList<PbsToken> tokens) {
final ArrayList<SpanPair> 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<SpanPair> 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<SpanPair> spans, final Span span) {
if (span == null) {
return;
}
addPair(spans, toOffset(span.getStart()), toOffset(span.getEnd()));
}
private static void addPair(final List<SpanPair> 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<SpanPair> ranges,
final LinkedHashSet<String> 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<SpanPair> folding = new ArrayList<>();
private final ArrayList<SpanPair> covered = new ArrayList<>();
private final ArrayList<SpanPair> blocks = new ArrayList<>();
private final ArrayList<SpanPair> declarations = new ArrayList<>();
private List<SpanPair> folding() {
return folding;
}
private void cover(final Span span) {
addSpan(covered, span);
}
private LinkedHashSet<String> coveredKeys() {
final LinkedHashSet<String> keys = new LinkedHashSet<>();
for (final SpanPair span : covered) {
keys.add(span.key());
}
return keys;
}
private List<SpanPair> blocks() {
return blocks;
}
private List<SpanPair> declarations() {
return declarations;
}
}
}

View File

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

View File

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

View File

@ -153,6 +153,37 @@ public interface FrontendLanguageService {
return documentLinks(request);
}
default boolean foldingRangesSupported() {
return false;
}
default List<FrontendFoldingRange> foldingRanges(final FrontendDocumentRequest request) {
return List.of();
}
default List<FrontendFoldingRange> foldingRanges(
final FrontendDocumentRequest request,
final FrontendEditorialContext editorialContext) {
return foldingRanges(request);
}
default boolean selectionRangesSupported() {
return false;
}
default List<FrontendSelectionRange> selectionRanges(
final FrontendDocumentRequest request,
final List<Integer> offsets) {
return List.of();
}
default List<FrontendSelectionRange> selectionRanges(
final FrontendDocumentRequest request,
final FrontendEditorialContext editorialContext,
final List<Integer> offsets) {
return selectionRanges(request, offsets);
}
default boolean codeActionsSupported() {
return false;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,6 +17,8 @@ public record BaselineServerDescription(
boolean renameSupported,
boolean codeActionsSupported,
boolean documentLinksSupported,
boolean foldingRangesSupported,
boolean selectionRangesSupported,
List<String> semanticTokenTypes,
List<BaselineSemanticHostProjection> semanticHostProjections,
List<BaselineVisualTheme> visualThemes,

View File

@ -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<DocumentPosition> positions) {
return BaselineSelectionRanges.empty();
}
BaselineCodeActions codeActions(
LspProjectContext context,
String documentUri,

View File

@ -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<DocumentPosition> 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<DocumentPosition> 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<Integer> 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<BaselineFoldingRange> mapFoldingRanges(
final List<FrontendFoldingRange> ranges,
final DocumentPositionMapper mapper) {
if (ranges == null || ranges.isEmpty()) {
return List.of();
}
final ArrayList<BaselineFoldingRange> 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<BaselineSelectionRange> mapSelectionRanges(
final List<FrontendSelectionRange> ranges,
final DocumentPositionMapper mapper) {
if (ranges == null || ranges.isEmpty()) {
return List.of();
}
final ArrayList<BaselineSelectionRange> 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,

View File

@ -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<List<FoldingRange>> 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<List<SelectionRange>> selectionRange(final SelectionRangeParams params) {
final String uri = params.getTextDocument().getUri();
final String text = documentTextByUri.get(uri);
final ArrayList<DocumentPosition> 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<List<DocumentLink>> documentLink(final DocumentLinkParams params) {
final String uri = params.getTextDocument().getUri();

View File

@ -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<FoldingRange> mapFoldingRanges(final BaselineFoldingRanges ranges) {
if (ranges == null || ranges.ranges().isEmpty()) {
return List.of();
}
final ArrayList<FoldingRange> 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<SelectionRange> mapSelectionRanges(final BaselineSelectionRanges ranges) {
if (ranges == null || ranges.ranges().isEmpty()) {
return List.of();
}
final ArrayList<SelectionRange> 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<Either<Command, CodeAction>> mapCodeActions(
final BaselineCodeActions actions,

View File

@ -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<FoldingRange> mapFoldingRanges(BaselineFoldingRanges ranges) {
return List.of();
}
default List<SelectionRange> mapSelectionRanges(BaselineSelectionRanges ranges) {
return List.of();
}
CompletionList mapCompletion(BaselineCompletion completion);
SignatureHelp mapSignatureHelp(BaselineSignatureHelp signatureHelp);

View File

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

View File

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

View File

@ -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",