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
17 changed files with 366 additions and 0 deletions
Showing only changes of commit 7629a73e0a - Show all commits

View File

@ -294,6 +294,8 @@ When a frontend exposes folding ranges, the ranges MUST come from recovered synt
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.
When a frontend exposes formatting, it MUST be full-document formatting only. Range formatting and on-type formatting MUST NOT be advertised. The formatter MUST reprint the existing token stream. It MUST NOT reorder declarations, join lines, or split lines. Indentation MUST be four spaces and MUST change only where brace or parenthesis nesting changes. The interior of a `Doc` text block MUST be copied unchanged. A comment MUST stay on the line of the token it already follows. A missing formatting capability MUST NOT be advertised and MUST produce an empty edit 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,6 +16,7 @@ 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.PbsDocumentFormatter;
import p.studio.compiler.pbs.PbsStructureRanges;
import p.studio.compiler.pbs.semantics.PbsQuickFix;
import p.studio.compiler.pbs.semantics.PbsQuickFixCollector;
@ -475,6 +476,17 @@ public final class PBSFrontendLanguageService implements FrontendLanguageService
offsets);
}
@Override
public boolean formattingSupported() {
return true;
}
@Override
public Optional<String> formatDocument(final FrontendDocumentRequest request) {
return Optional.of(PbsDocumentFormatter.format(
request == null || request.documentText() == null ? "" : request.documentText()));
}
@Override
public boolean codeActionsSupported() {
return true;

View File

@ -0,0 +1,179 @@
package p.studio.compiler.pbs;
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.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;
public final class PbsDocumentFormatter {
private static final int INDENT_SPACES = 4;
private PbsDocumentFormatter() {
}
public static String format(final String documentText) {
final String text = documentText == null ? "" : documentText;
if (text.isEmpty()) {
return text;
}
final ReadOnlyList<PbsToken> tokens = PbsLexer.lex(text, FileId.none(), DiagnosticSink.empty());
final ArrayList<SourceLine> lines = splitLines(text);
final StringBuilder formatted = new StringBuilder(text.length());
int depth = 0;
for (final SourceLine line : lines) {
if (isDocInterior(line, tokens)) {
formatted.append(line.content()).append(line.ending());
continue;
}
final int leadingClosers = leadingClosers(line, tokens);
final int indent = Math.max(0, depth - leadingClosers);
formatted.append(reindent(line.content(), indent)).append(line.ending());
depth = depthAfter(line, tokens, depth);
}
return formatted.toString();
}
private static boolean isDocInterior(final SourceLine line, final ReadOnlyList<PbsToken> tokens) {
for (final PbsToken token : tokens) {
if (token.kind() != PbsTokenKind.DOC_TEXT_BLOCK) {
continue;
}
if (line.startByte() > token.start() && line.endByte() <= token.end()) {
return true;
}
}
return false;
}
private static int leadingClosers(final SourceLine line, final ReadOnlyList<PbsToken> tokens) {
int closers = 0;
boolean seenCode = false;
for (final PbsToken token : tokens) {
if (token.start() < line.startByte() || token.start() >= line.endByte()) {
continue;
}
if (token.kind() == PbsTokenKind.COMMENT) {
continue;
}
if (!seenCode && isCloser(token.kind())) {
closers += 1;
continue;
}
seenCode = true;
}
return closers;
}
private static int depthAfter(
final SourceLine line,
final ReadOnlyList<PbsToken> tokens,
final int depth) {
int next = depth;
for (final PbsToken token : tokens) {
if (token.start() < line.startByte() || token.start() >= line.endByte()) {
continue;
}
if (isOpener(token.kind())) {
next += 1;
} else if (isCloser(token.kind())) {
next = Math.max(0, next - 1);
}
}
return next;
}
private static boolean isOpener(final PbsTokenKind kind) {
return kind == PbsTokenKind.LEFT_BRACE || kind == PbsTokenKind.LEFT_PAREN;
}
private static boolean isCloser(final PbsTokenKind kind) {
return kind == PbsTokenKind.RIGHT_BRACE || kind == PbsTokenKind.RIGHT_PAREN;
}
private static String reindent(final String content, final int indent) {
int index = 0;
while (index < content.length()) {
final char character = content.charAt(index);
if (character != ' ' && character != '\t') {
break;
}
index += 1;
}
if (index == content.length()) {
return content;
}
return " ".repeat(indent * INDENT_SPACES) + content.substring(index);
}
private static ArrayList<SourceLine> splitLines(final String text) {
final ArrayList<SourceLine> lines = new ArrayList<>();
final byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
int charIndex = 0;
int byteIndex = 0;
int lineCharStart = 0;
int lineByteStart = 0;
while (charIndex < text.length()) {
final int codePoint = text.codePointAt(charIndex);
final int charWidth = Character.charCount(codePoint);
final int byteWidth = utf8Length(codePoint);
if (codePoint == '\n') {
lines.add(new SourceLine(
text.substring(lineCharStart, charIndex),
"\n",
lineByteStart,
byteIndex));
charIndex += charWidth;
byteIndex += byteWidth;
lineCharStart = charIndex;
lineByteStart = byteIndex;
continue;
}
if (codePoint == '\r' && charIndex + charWidth < text.length() && text.codePointAt(charIndex + charWidth) == '\n') {
lines.add(new SourceLine(
text.substring(lineCharStart, charIndex),
"\r\n",
lineByteStart,
byteIndex));
charIndex += charWidth + 1;
byteIndex += byteWidth + 1;
lineCharStart = charIndex;
lineByteStart = byteIndex;
continue;
}
charIndex += charWidth;
byteIndex += byteWidth;
}
if (lineCharStart < text.length()) {
lines.add(new SourceLine(
text.substring(lineCharStart),
"",
lineByteStart,
bytes.length));
}
if (lines.isEmpty()) {
lines.add(new SourceLine(text, "", 0, bytes.length));
}
return lines;
}
private static int utf8Length(final int codePoint) {
if (codePoint <= 0x7F) {
return 1;
}
if (codePoint <= 0x7FF) {
return 2;
}
if (codePoint <= 0xFFFF) {
return 3;
}
return 4;
}
private record SourceLine(String content, String ending, int startByte, int endByte) {
}
}

View File

@ -0,0 +1,53 @@
package p.studio.compiler.pbs;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PbsDocumentFormatterTest {
@Test
void indentsOnlyWhereBraceOrParenNestingChanges() {
final String source = """
fn length(value: int) -> int {
return value;
}
fn other() -> int {
return 1;
}
""";
final String formatted = PbsDocumentFormatter.format(source);
assertEquals("""
fn length(value: int) -> int {
return value;
}
fn other() -> int {
return 1;
}
""", formatted);
assertTrue(formatted.indexOf("fn length") < formatted.indexOf("fn other"));
assertFalse(formatted.contains("\nreturn value;\nreturn 1;"));
}
@Test
void leavesDocTextBlockInteriorAndTrailingCommentUntouched() {
final String source = """
[Doc(markdown = \"\"\"
hello
\"\"\")]
fn length() -> int { // keep
return 1;
}
""";
final String formatted = PbsDocumentFormatter.format(source);
assertTrue(formatted.contains(" hello\n"));
assertTrue(formatted.contains("fn length() -> int { // keep"));
assertTrue(formatted.contains("\n return 1;\n"));
}
}

View File

@ -184,6 +184,14 @@ public interface FrontendLanguageService {
return selectionRanges(request, offsets);
}
default boolean formattingSupported() {
return false;
}
default java.util.Optional<String> formatDocument(final FrontendDocumentRequest request) {
return java.util.Optional.empty();
}
default boolean codeActionsSupported() {
return false;
}

View File

@ -36,6 +36,8 @@ class FrontendLanguageServiceTest {
assertFalse(service.foldingRangesSupported());
assertTrue(service.foldingRanges(request).isEmpty());
assertTrue(service.foldingRanges(request, null).isEmpty());
assertFalse(service.formattingSupported());
assertTrue(service.formatDocument(request).isEmpty());
assertFalse(service.selectionRangesSupported());
assertTrue(service.selectionRanges(request, List.of(0)).isEmpty());
assertTrue(service.selectionRanges(request, null, List.of(0)).isEmpty());

View File

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

View File

@ -19,6 +19,7 @@ public record BaselineServerDescription(
boolean documentLinksSupported,
boolean foldingRangesSupported,
boolean selectionRangesSupported,
boolean formattingSupported,
List<String> semanticTokenTypes,
List<BaselineSemanticHostProjection> semanticHostProjections,
List<BaselineVisualTheme> visualThemes,

View File

@ -0,0 +1,18 @@
package p.studio.lsp.messages;
import java.util.Objects;
public record BaselineTextEdit(
int startLine,
int startCharacter,
int endLine,
int endCharacter,
String newText) {
public BaselineTextEdit {
if (startLine < 0 || startCharacter < 0 || endLine < 0 || endCharacter < 0) {
throw new IllegalArgumentException("text edit coordinates must not be negative");
}
newText = Objects.requireNonNull(newText, "newText");
}
}

View File

@ -11,6 +11,7 @@ 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.BaselineFormatting;
import p.studio.lsp.messages.BaselineSelectionRanges;
import p.studio.lsp.messages.DocumentPosition;
import p.studio.lsp.messages.BaselinePrepareRename;
@ -72,6 +73,10 @@ public interface LanguageServiceBridge {
return BaselineFoldingRanges.empty();
}
default BaselineFormatting formatDocument(LspProjectContext context, String documentUri, String text) {
return BaselineFormatting.empty();
}
default BaselineSelectionRanges selectionRanges(
LspProjectContext context,
String documentUri,

View File

@ -69,6 +69,9 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
final boolean selectionRangesSupported = languageService
.map(FrontendLanguageService::selectionRangesSupported)
.orElse(false);
final boolean formattingSupported = languageService
.map(FrontendLanguageService::formattingSupported)
.orElse(false);
return new BaselineServerDescription(
"Prometeu Studio LSP",
"0.1.0",
@ -85,6 +88,7 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
documentLinksSupported,
foldingRangesSupported,
selectionRangesSupported,
formattingSupported,
presentation.semanticKeys(),
presentation.hostProjections().stream().map(this::mapSemanticHostProjection).toList(),
presentation.themes().stream().map(this::mapVisualTheme).toList(),
@ -403,6 +407,34 @@ public final class CompilerLanguageServiceBridge implements LanguageServiceBridg
.orElseGet(BaselineDocumentLinks::empty);
}
@Override
public BaselineFormatting formatDocument(
final LspProjectContext context,
final String documentUri,
final String text) {
Objects.requireNonNull(context, "context");
final var languageService = frontendProvider(context).languageService();
if (languageService.isEmpty() || !languageService.orElseThrow().formattingSupported()) {
return BaselineFormatting.empty();
}
final String effectiveText = text == null ? "" : text;
final String formatted = languageService.orElseThrow().formatDocument(new FrontendDocumentRequest(
context.projectRoot(),
normalizeDocumentPath(documentUri),
effectiveText)).orElse(effectiveText);
if (formatted.equals(effectiveText)) {
return BaselineFormatting.empty();
}
final DocumentPosition end = new DocumentPositionMapper(effectiveText)
.positionOf(effectiveText.getBytes(StandardCharsets.UTF_8).length);
return new BaselineFormatting(List.of(new BaselineTextEdit(
0,
0,
end.line(),
end.character(),
formatted)));
}
@Override
public BaselineFoldingRanges foldingRanges(
final LspProjectContext context,

View File

@ -169,6 +169,14 @@ public final class PrometeuTextDocumentService implements TextDocumentService {
return CompletableFuture.completedFuture(protocolMessageMapper.mapRename(result));
}
@Override
public CompletableFuture<List<? extends TextEdit>> formatting(final DocumentFormattingParams params) {
final String uri = params.getTextDocument().getUri();
final String text = documentTextByUri.get(uri);
return CompletableFuture.completedFuture(protocolMessageMapper.mapFormatting(
languageServiceBridge.formatDocument(project, uri, text)));
}
@Override
public CompletableFuture<List<FoldingRange>> foldingRange(final FoldingRangeRequestParams params) {
final String uri = params.getTextDocument().getUri();

View File

@ -64,6 +64,9 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper {
if (description.selectionRangesSupported()) {
capabilities.setSelectionRangeProvider(true);
}
if (description.formattingSupported()) {
capabilities.setDocumentFormattingProvider(true);
}
final SemanticTokensWithRegistrationOptions semanticTokens = new SemanticTokensWithRegistrationOptions();
semanticTokens.setLegend(new SemanticTokensLegend(description.semanticTokenTypes(), List.of()));
semanticTokens.setFull(true);
@ -187,6 +190,22 @@ public final class Lsp4jProtocolMessageMapper implements ProtocolMessageMapper {
return List.copyOf(mapped);
}
@Override
public List<TextEdit> mapFormatting(final BaselineFormatting formatting) {
if (formatting == null || formatting.edits().isEmpty()) {
return List.of();
}
final ArrayList<TextEdit> mapped = new ArrayList<>();
for (final BaselineTextEdit edit : formatting.edits()) {
mapped.add(new TextEdit(
new Range(
new Position(edit.startLine(), edit.startCharacter()),
new Position(edit.endLine(), edit.endCharacter())),
edit.newText()));
}
return List.copyOf(mapped);
}
@Override
public List<FoldingRange> mapFoldingRanges(final BaselineFoldingRanges ranges) {
if (ranges == null || ranges.ranges().isEmpty()) {

View File

@ -12,6 +12,7 @@ import org.eclipse.lsp4j.Diagnostic;
import org.eclipse.lsp4j.DocumentLink;
import org.eclipse.lsp4j.DocumentSymbol;
import org.eclipse.lsp4j.FoldingRange;
import org.eclipse.lsp4j.TextEdit;
import org.eclipse.lsp4j.SelectionRange;
import org.eclipse.lsp4j.Location;
import org.eclipse.lsp4j.Range;
@ -24,6 +25,7 @@ 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.BaselineFormatting;
import p.studio.lsp.messages.BaselineSelectionRanges;
import p.studio.lsp.messages.BaselineDocumentSymbols;
import p.studio.lsp.messages.BaselineWorkspaceSymbols;
@ -73,6 +75,10 @@ public interface ProtocolMessageMapper {
return List.of();
}
default List<TextEdit> mapFormatting(BaselineFormatting formatting) {
return List.of();
}
default List<SelectionRange> mapSelectionRanges(BaselineSelectionRanges ranges) {
return List.of();
}

View File

@ -78,6 +78,7 @@ 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.formatDocument(context, documentUri, "fn main() {}").edits().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());
@ -778,6 +779,7 @@ class CompilerLanguageServiceBridgeTest {
assertTrue(description.documentLinksSupported());
assertTrue(description.foldingRangesSupported());
assertTrue(description.selectionRangesSupported());
assertTrue(description.formattingSupported());
assertEquals(1, description.semanticHostProjections().size());
assertEquals("vscode", description.semanticHostProjections().getFirst().hostId());
assertTrue(description.semanticHostProjections().getFirst().tokenProjections().stream()

View File

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

View File

@ -60,6 +60,7 @@ final class Lsp4jProtocolMessageMapperTest {
true,
true,
true,
true,
List.of("demo-keyword"),
List.of(new BaselineSemanticHostProjection(
"vscode",
@ -90,6 +91,9 @@ final class Lsp4jProtocolMessageMapperTest {
assertEquals(Boolean.FALSE, result.getCapabilities().getDocumentLinkProvider().getResolveProvider());
assertEquals(Boolean.TRUE, result.getCapabilities().getFoldingRangeProvider().getLeft());
assertEquals(Boolean.TRUE, result.getCapabilities().getSelectionRangeProvider().getLeft());
assertEquals(Boolean.TRUE, result.getCapabilities().getDocumentFormattingProvider().getLeft());
assertNull(result.getCapabilities().getDocumentRangeFormattingProvider());
assertNull(result.getCapabilities().getDocumentOnTypeFormattingProvider());
final var experimental = assertInstanceOf(Map.class, result.getCapabilities().getExperimental());
final var semanticPayload = assertInstanceOf(Map.class, experimental.get("prometeuSemanticHostProjections"));
@ -343,6 +347,7 @@ final class Lsp4jProtocolMessageMapperTest {
documentLinksSupported,
documentLinksSupported,
documentLinksSupported,
documentLinksSupported,
List.of("demo-keyword"),
List.of(new BaselineSemanticHostProjection(
"vscode",