implement PLN-0030 active scope gutter wave 1

This commit is contained in:
bQUARKz 2026-04-03 09:56:30 +01:00
parent e06be29f4b
commit 17eb4eaf91
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
6 changed files with 265 additions and 114 deletions

View File

@ -14,4 +14,4 @@
{"type":"discussion","id":"DSC-0013","status":"done","ticket":"studio-editor-write-wave-supported-non-frontend-files","title":"Definir a wave inicial de edicao no Code Editor apenas para arquivos aceitos e nao relacionados ao FE","created_at":"2026-03-31","updated_at":"2026-04-02","tags":["studio","editor","workspace","write","read-only","vfs","frontend-boundary"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0028","file":"discussion/lessons/DSC-0013-studio-editor-write-wave-supported-non-frontend-files/LSN-0028-controlled-editor-write-wave-and-read-only-frontend-semantic-phase.md","status":"done","created_at":"2026-04-02","updated_at":"2026-04-02"}]}
{"type":"discussion","id":"DSC-0014","status":"done","ticket":"studio-frontend-owned-semantic-editor-presentation","title":"Definir ownership do schema visual semantico do editor por frontend","created_at":"2026-04-02","updated_at":"2026-04-02","tags":["studio","editor","frontend","presentation","semantic-highlighting","compiler","pbs"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0029","file":"discussion/lessons/DSC-0014-studio-frontend-owned-semantic-editor-presentation/LSN-0029-frontend-owned-semantic-presentation-descriptor-and-host-consumption.md","status":"done","created_at":"2026-04-02","updated_at":"2026-04-02"}]}
{"type":"discussion","id":"DSC-0015","status":"done","ticket":"pbs-service-facade-reserved-metadata","title":"SDK Service Bodies Calling Builtin/Intrinsic Proxies as Ordinary PBS Code","created_at":"2026-04-03","updated_at":"2026-04-03","tags":["compiler","pbs","sdk","stdlib","lowering","service","intrinsic","sdk-interface"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0030","file":"discussion/lessons/DSC-0015-pbs-service-facade-reserved-metadata/LSN-0030-sdk-service-bodies-over-private-reserved-proxies.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03"}]}
{"type":"discussion","id":"DSC-0016","status":"open","ticket":"studio-editor-scope-guides-and-brace-anchoring","title":"Scope Guides do Code Editor com ancoragem exata em braces e destaque do escopo ativo","created_at":"2026-04-03","updated_at":"2026-04-03","tags":["studio","editor","scope-guides","braces","semantic-read","frontend-contract"],"agendas":[{"id":"AGD-0017","file":"AGD-0017-studio-editor-scope-guides-and-brace-anchoring.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03"}],"decisions":[{"id":"DEC-0014","file":"DEC-0014-studio-editor-active-scope-and-structural-anchors.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03"}],"plans":[{"id":"PLN-0030","file":"PLN-0030-studio-active-container-and-active-scope-gutter-wave-1.md","status":"review","created_at":"2026-04-03","updated_at":"2026-04-03"},{"id":"PLN-0031","file":"PLN-0031-studio-structural-anchor-semantic-surface-specification.md","status":"review","created_at":"2026-04-03","updated_at":"2026-04-03"},{"id":"PLN-0032","file":"PLN-0032-frontend-structural-anchor-payloads-and-anchor-aware-tests.md","status":"review","created_at":"2026-04-03","updated_at":"2026-04-03"}],"lessons":[]}
{"type":"discussion","id":"DSC-0016","status":"open","ticket":"studio-editor-scope-guides-and-brace-anchoring","title":"Scope Guides do Code Editor com ancoragem exata em braces e destaque do escopo ativo","created_at":"2026-04-03","updated_at":"2026-04-03","tags":["studio","editor","scope-guides","braces","semantic-read","frontend-contract"],"agendas":[{"id":"AGD-0017","file":"AGD-0017-studio-editor-scope-guides-and-brace-anchoring.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03"}],"decisions":[{"id":"DEC-0014","file":"DEC-0014-studio-editor-active-scope-and-structural-anchors.md","status":"accepted","created_at":"2026-04-03","updated_at":"2026-04-03"}],"plans":[{"id":"PLN-0030","file":"PLN-0030-studio-active-container-and-active-scope-gutter-wave-1.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03"},{"id":"PLN-0031","file":"PLN-0031-studio-structural-anchor-semantic-surface-specification.md","status":"review","created_at":"2026-04-03","updated_at":"2026-04-03"},{"id":"PLN-0032","file":"PLN-0032-frontend-structural-anchor-payloads-and-anchor-aware-tests.md","status":"review","created_at":"2026-04-03","updated_at":"2026-04-03"}],"lessons":[]}

View File

@ -2,9 +2,9 @@
id: PLN-0030
ticket: studio-editor-scope-guides-and-brace-anchoring
title: Wave 1 editor implementation for active container and active scope gutter indicators
status: review
status: done
created: 2026-04-03
completed:
completed: 2026-04-03
tags:
- studio
- editor
@ -119,11 +119,11 @@ Do not hardcode semantic color contracts into decision logic. The renderer may e
## Acceptance Criteria
- [ ] The default editor gutter no longer renders the old full stacked active-scope presentation.
- [ ] `activeScope` is selected as the smallest structural range containing the caret.
- [ ] `activeContainer` is selected as the immediate structural ancestor of `activeScope`.
- [ ] The editor renders at most two active semantic indicators in the gutter.
- [ ] The implementation remains language-agnostic and uses only existing structural ranges.
- [x] The default editor gutter no longer renders the old full stacked active-scope presentation.
- [x] `activeScope` is selected as the smallest structural range containing the caret.
- [x] `activeContainer` is selected as the immediate structural ancestor of `activeScope`.
- [x] The editor renders at most two active semantic indicators in the gutter.
- [x] The implementation remains language-agnostic and uses only existing structural ranges.
## Dependencies

View File

@ -10,8 +10,11 @@ import javafx.scene.paint.Color;
final class EditorDocumentScopeGuideGraphicFactory {
private static final double COLUMN_WIDTH = 10.0;
private static final double CANVAS_PADDING = 6.0;
private static final Color GUIDE_COLOR = Color.web("#384657");
private static final Color GUIDE_CAP_COLOR = Color.web("#6fa8dc");
private static final double CONTAINER_X = CANVAS_PADDING + (COLUMN_WIDTH / 2.0);
private static final double SCOPE_X = CANVAS_PADDING + COLUMN_WIDTH + (COLUMN_WIDTH / 2.0);
private static final double GUIDE_CAP = 4.0;
private static final Color CONTAINER_COLOR = Color.web("#5f7896");
private static final Color SCOPE_COLOR = Color.web("#8f72ff");
private EditorDocumentScopeGuideGraphicFactory() {
}
@ -19,35 +22,33 @@ final class EditorDocumentScopeGuideGraphicFactory {
static Node create(
final Node lineNumberNode,
final int paragraphIndex,
final EditorDocumentScopeGuideModel model) {
final EditorDocumentScopeGuideModel model,
final EditorDocumentScopeGuideModel.ActiveGuides activeGuides) {
final var container = new HBox(lineNumberNode);
final int maxDepth = model.maxDepth();
if (maxDepth > 0) {
container.getChildren().add(new ScopeGuideCanvas(model.segmentsForLine(paragraphIndex), maxDepth));
final var segments = model.segmentsForLine(paragraphIndex, activeGuides);
if (!segments.isEmpty()) {
container.getChildren().add(new ScopeGuideCanvas(segments));
}
return container;
}
private static final class ScopeGuideCanvas extends Region {
private final Canvas canvas = new Canvas();
private final java.util.List<EditorDocumentScopeGuideModel.GuideSegment> segments;
private final int maxDepth;
private final java.util.List<EditorDocumentScopeGuideModel.ActiveGuideSegment> segments;
private ScopeGuideCanvas(
final java.util.List<EditorDocumentScopeGuideModel.GuideSegment> segments,
final int maxDepth) {
final java.util.List<EditorDocumentScopeGuideModel.ActiveGuideSegment> segments) {
this.segments = segments;
this.maxDepth = maxDepth;
getStyleClass().add("editor-workspace-scope-guide-gutter");
getChildren().add(canvas);
setMinWidth(computeGuideWidth(maxDepth));
setPrefWidth(computeGuideWidth(maxDepth));
setMaxWidth(computeGuideWidth(maxDepth));
setMinWidth(computeGuideWidth());
setPrefWidth(computeGuideWidth());
setMaxWidth(computeGuideWidth());
}
@Override
protected double computePrefWidth(final double height) {
return computeGuideWidth(maxDepth);
return computeGuideWidth();
}
@Override
@ -67,27 +68,26 @@ final class EditorDocumentScopeGuideGraphicFactory {
}
final double centerY = Math.floor(height / 2.0);
for (final var segment : segments) {
final double x = CANVAS_PADDING + (segment.depth() * COLUMN_WIDTH) + (COLUMN_WIDTH / 2.0);
graphics.setLineWidth(1.2);
graphics.setStroke(GUIDE_COLOR);
final boolean container = segment.role() == EditorDocumentScopeGuideModel.GuideRole.ACTIVE_CONTAINER;
final double x = container ? CONTAINER_X : SCOPE_X;
graphics.setLineWidth(container ? 1.0 : 1.4);
graphics.setStroke(container ? CONTAINER_COLOR : SCOPE_COLOR);
switch (segment.kind()) {
case START -> {
graphics.strokeLine(x, centerY, x, height);
graphics.setStroke(GUIDE_CAP_COLOR);
graphics.strokeLine(x, centerY, x + 4.0, centerY);
graphics.strokeLine(x, centerY, x + GUIDE_CAP, centerY);
}
case CONTINUE -> graphics.strokeLine(x, 0, x, height);
case END -> {
graphics.strokeLine(x, 0, x, centerY);
graphics.setStroke(GUIDE_CAP_COLOR);
graphics.strokeLine(x, centerY, x + 4.0, centerY);
graphics.strokeLine(x, centerY, x + GUIDE_CAP, centerY);
}
}
}
}
private static double computeGuideWidth(final int maxDepth) {
return maxDepth <= 0 ? 0.0 : CANVAS_PADDING * 2.0 + (maxDepth * COLUMN_WIDTH);
private static double computeGuideWidth() {
return CANVAS_PADDING * 2.0 + (COLUMN_WIDTH * 2.0);
}
}
}

View File

@ -6,19 +6,25 @@ import p.studio.lsp.messages.LspSymbolKind;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
final class EditorDocumentScopeGuideModel {
private static final EditorDocumentScopeGuideModel EMPTY = new EditorDocumentScopeGuideModel(List.of(), 0);
private static final EditorDocumentScopeGuideModel EMPTY =
new EditorDocumentScopeGuideModel(List.of(), List.of(0), 0);
private final List<List<GuideSegment>> lines;
private final int maxDepth;
private final List<GuideRange> ranges;
private final List<Integer> lineStarts;
private final int contentLength;
private EditorDocumentScopeGuideModel(
final List<List<GuideSegment>> lines,
final int maxDepth) {
this.lines = lines;
this.maxDepth = maxDepth;
final List<GuideRange> ranges,
final List<Integer> lineStarts,
final int contentLength) {
this.ranges = ranges;
this.lineStarts = lineStarts;
this.contentLength = contentLength;
}
static EditorDocumentScopeGuideModel empty() {
@ -28,90 +34,141 @@ final class EditorDocumentScopeGuideModel {
static EditorDocumentScopeGuideModel from(
final String content,
final List<LspSymbolDTO> symbols) {
final int totalLines = totalLines(content);
final List<List<GuideSegment>> lines = new ArrayList<>(totalLines);
for (int line = 0; line < totalLines; line++) {
lines.add(new ArrayList<>());
}
final List<Integer> lineStarts = lineStarts(content);
final int[] maxDepth = new int[] {0};
final List<GuideRange> ranges = new ArrayList<>();
final int[] nextId = new int[] {1};
for (final LspSymbolDTO symbol : symbols) {
appendSymbol(lines, lineStarts, content.length(), symbol, 0, maxDepth);
appendSymbol(ranges, lineStarts, content.length(), symbol, 0, null, nextId);
}
final List<List<GuideSegment>> frozen = lines.stream()
.map(List::copyOf)
.toList();
return new EditorDocumentScopeGuideModel(frozen, maxDepth[0]);
ranges.sort(Comparator
.comparingInt(GuideRange::startOffset)
.thenComparingInt(GuideRange::depth)
.thenComparingInt(GuideRange::endOffset));
return new EditorDocumentScopeGuideModel(List.copyOf(ranges), lineStarts, content.length());
}
List<GuideSegment> segmentsForLine(final int lineIndex) {
if (lineIndex < 0 || lineIndex >= lines.size()) {
ActiveGuides resolveActiveGuides(final int caretOffset) {
if (ranges.isEmpty()) {
return ActiveGuides.empty();
}
final int probeOffset = clampCaretOffset(caretOffset);
GuideRange activeScope = null;
for (final GuideRange range : ranges) {
if (!contains(range, probeOffset)) {
continue;
}
if (activeScope == null
|| span(range) < span(activeScope)
|| (span(range) == span(activeScope) && range.depth() > activeScope.depth())) {
activeScope = range;
}
}
if (activeScope == null) {
return ActiveGuides.empty();
}
final GuideRange activeContainer = activeScope.parentId() == null
? null
: findById(activeScope.parentId());
return new ActiveGuides(activeContainer, activeScope);
}
List<ActiveGuideSegment> segmentsForLine(
final int lineIndex,
final ActiveGuides activeGuides) {
if (lineIndex < 0 || lineIndex >= totalLines() || activeGuides.isEmpty()) {
return List.of();
}
return lines.get(lineIndex);
final List<ActiveGuideSegment> segments = new ArrayList<>(2);
appendSegments(segments, lineIndex, activeGuides.activeContainer(), GuideRole.ACTIVE_CONTAINER);
appendSegments(segments, lineIndex, activeGuides.activeScope(), GuideRole.ACTIVE_SCOPE);
return List.copyOf(segments);
}
int maxDepth() {
return maxDepth;
int totalLines() {
return lineStarts.size();
}
private static void appendSymbol(
final List<List<GuideSegment>> lines,
final List<GuideRange> ranges,
final List<Integer> lineStarts,
final int contentLength,
final LspSymbolDTO symbol,
final int depth,
final int[] maxDepth) {
final Integer parentId,
final int[] nextId) {
Integer currentParentId = parentId;
if (symbol.kind() != LspSymbolKind.UNKNOWN) {
appendRange(lines, lineStarts, contentLength, symbol.range(), depth, maxDepth);
final GuideRange range = toGuideRange(lineStarts, contentLength, symbol.range(), depth, parentId, nextId[0]);
if (range != null) {
ranges.add(range);
currentParentId = range.id();
nextId[0]++;
}
}
for (final LspSymbolDTO child : symbol.children()) {
appendSymbol(lines, lineStarts, contentLength, child, depth + 1, maxDepth);
appendSymbol(ranges, lineStarts, contentLength, child, depth + 1, currentParentId, nextId);
}
}
private static void appendRange(
final List<List<GuideSegment>> lines,
private static GuideRange toGuideRange(
final List<Integer> lineStarts,
final int contentLength,
final LspRangeDTO range,
final int depth,
final int[] maxDepth) {
if (lines.isEmpty()) {
return;
}
final int startLine = lineForOffset(lineStarts, clamp(range.startOffset(), 0, contentLength));
final int inclusiveEndOffset = Math.max(range.startOffset(), Math.min(contentLength, range.endOffset()) - 1);
final Integer parentId,
final int id) {
final int startOffset = clamp(range.startOffset(), 0, contentLength);
final int endOffset = Math.max(startOffset, clamp(range.endOffset(), 0, contentLength));
final int startLine = lineForOffset(lineStarts, startOffset);
final int inclusiveEndOffset = Math.max(startOffset, Math.max(startOffset, endOffset) - 1);
final int endLine = lineForOffset(lineStarts, clamp(inclusiveEndOffset, 0, contentLength));
if (endLine <= startLine) {
return null;
}
return new GuideRange(id, depth, startOffset, endOffset, startLine, endLine, parentId);
}
private GuideRange findById(final int id) {
for (final GuideRange range : ranges) {
if (range.id() == id) {
return range;
}
}
return null;
}
private int clampCaretOffset(final int offset) {
if (contentLength <= 0) {
return 0;
}
return clamp(offset, 0, contentLength - 1);
}
private static boolean contains(final GuideRange range, final int offset) {
return offset >= range.startOffset() && offset < range.endOffset();
}
private static int span(final GuideRange range) {
return range.endOffset() - range.startOffset();
}
private static void appendSegments(
final List<ActiveGuideSegment> segments,
final int lineIndex,
final GuideRange range,
final GuideRole role) {
if (range == null || lineIndex < range.startLine() || lineIndex > range.endLine()) {
return;
}
maxDepth[0] = Math.max(maxDepth[0], depth + 1);
for (int line = startLine; line <= endLine; line++) {
final GuideSegmentKind kind;
if (line == startLine) {
if (lineIndex == range.startLine()) {
kind = GuideSegmentKind.START;
} else if (line == endLine) {
} else if (lineIndex == range.endLine()) {
kind = GuideSegmentKind.END;
} else {
kind = GuideSegmentKind.CONTINUE;
}
lines.get(line).add(new GuideSegment(depth, kind));
}
}
private static int totalLines(final String content) {
if (content.isEmpty()) {
return 1;
}
int total = 1;
for (int index = 0; index < content.length(); index++) {
if (content.charAt(index) == '\n') {
total++;
}
}
return total;
segments.add(new ActiveGuideSegment(role, kind));
}
private static List<Integer> lineStarts(final String content) {
@ -152,12 +209,47 @@ final class EditorDocumentScopeGuideModel {
return Math.max(minimum, Math.min(maximum, value));
}
enum GuideRole {
ACTIVE_CONTAINER,
ACTIVE_SCOPE
}
enum GuideSegmentKind {
START,
CONTINUE,
END
}
record GuideSegment(int depth, GuideSegmentKind kind) {
record GuideRange(
int id,
int depth,
int startOffset,
int endOffset,
int startLine,
int endLine,
Integer parentId) {
}
record ActiveGuides(
GuideRange activeContainer,
GuideRange activeScope) {
private static final ActiveGuides EMPTY = new ActiveGuides(null, null);
static ActiveGuides empty() {
return EMPTY;
}
boolean isEmpty() {
return activeScope == null;
}
}
record ActiveGuideSegment(
GuideRole role,
GuideSegmentKind kind) {
ActiveGuideSegment {
Objects.requireNonNull(role, "role");
Objects.requireNonNull(kind, "kind");
}
}
}

View File

@ -45,6 +45,7 @@ public final class EditorWorkspace extends Workspace {
private final List<String> activePresentationStylesheets = new ArrayList<>();
private final IntFunction<Node> lineNumberFactory = LineNumberFactory.get(codeArea);
private EditorDocumentScopeGuideModel scopeGuideModel = EditorDocumentScopeGuideModel.empty();
private EditorDocumentScopeGuideModel.ActiveGuides activeGuides = EditorDocumentScopeGuideModel.ActiveGuides.empty();
private boolean syncingEditor;
public EditorWorkspace(
@ -59,6 +60,8 @@ public final class EditorWorkspace extends Workspace {
codeArea.setEditable(false);
codeArea.setWrapText(false);
codeArea.textProperty().addListener((ignored, previous, current) -> syncActiveDocumentToVfs(current));
codeArea.caretPositionProperty().addListener((ignored, previous, current) ->
updateActiveGuides(current == null ? 0 : current.intValue()));
codeArea.getStyleClass().add("editor-workspace-code-area");
EditorDocumentPresentationStyles.applyToCodeArea(codeArea, presentationRegistry.resolve("text"));
configureCommandBar();
@ -138,7 +141,6 @@ public final class EditorWorkspace extends Workspace {
presentation,
analysis);
scopeGuideModel = guidesFor(fileBuffer, analysis);
refreshParagraphGraphics();
applyPresentationStylesheets(presentation);
syncingEditor = true;
try {
@ -149,6 +151,8 @@ public final class EditorWorkspace extends Workspace {
} finally {
syncingEditor = false;
}
activeGuides = scopeGuideModel.resolveActiveGuides(codeArea.getCaretPosition());
refreshParagraphGraphics();
Platform.runLater(() -> {
codeArea.moveTo(0);
codeArea.showParagraphAtTop(0);
@ -184,6 +188,7 @@ public final class EditorWorkspace extends Workspace {
private void showEditorPlaceholder() {
final EditorDocumentPresentation presentation = presentationRegistry.resolve("text");
scopeGuideModel = EditorDocumentScopeGuideModel.empty();
activeGuides = EditorDocumentScopeGuideModel.ActiveGuides.empty();
refreshParagraphGraphics();
applyPresentationStylesheets(presentation);
syncingEditor = true;
@ -220,7 +225,8 @@ public final class EditorWorkspace extends Workspace {
codeArea.setParagraphGraphicFactory(paragraphIndex -> EditorDocumentScopeGuideGraphicFactory.create(
lineNumberFactory.apply(paragraphIndex),
paragraphIndex,
scopeGuideModel));
scopeGuideModel,
activeGuides));
}
private VBox buildLayout() {
@ -377,4 +383,13 @@ public final class EditorWorkspace extends Workspace {
}
return EditorDocumentScopeGuideModel.from(fileBuffer.content(), analysis.documentSymbols());
}
private void updateActiveGuides(final int caretOffset) {
final EditorDocumentScopeGuideModel.ActiveGuides next = scopeGuideModel.resolveActiveGuides(caretOffset);
if (Objects.equals(activeGuides, next)) {
return;
}
activeGuides = next;
refreshParagraphGraphics();
}
}

View File

@ -9,10 +9,11 @@ import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
final class EditorDocumentScopeGuideModelTest {
@Test
void buildsNestedGuidesForMultilineStructuralSymbols() {
void selectsActiveScopeAsSmallestContainingRange() {
final String content = """
struct Hero {
fn attack() {
@ -36,33 +37,75 @@ final class EditorDocumentScopeGuideModelTest {
final LspSymbolDTO kind = symbol(content, "enum Kind", "}\n", LspSymbolKind.ENUM, List.of());
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(hero, kind));
final EditorDocumentScopeGuideModel.ActiveGuides guides = model.resolveActiveGuides(content.indexOf("let value"));
assertEquals(2, model.maxDepth());
assertKinds(model, 0, "START");
assertKinds(model, 1, "CONTINUE", "START");
assertKinds(model, 2, "CONTINUE", "CONTINUE");
assertKinds(model, 3, "CONTINUE", "END");
assertKinds(model, 4, "END");
assertKinds(model, 5);
assertKinds(model, 6, "START");
assertKinds(model, 7, "CONTINUE");
assertKinds(model, 8, "END");
assertEquals(content.indexOf("fn attack()"), guides.activeScope().startOffset());
assertEquals(content.indexOf("struct Hero"), guides.activeContainer().startOffset());
}
@Test
void ignoresSingleLineSymbols() {
final String content = "const VALUE = 1\n";
void omitsActiveContainerWhenScopeHasNoParent() {
final String content = """
struct Hero {
hp: Int
}
""";
final LspSymbolDTO symbol = new LspSymbolDTO(
"VALUE",
LspSymbolKind.CONST,
"struct Hero",
LspSymbolKind.STRUCT,
Path.of("/tmp/example/main.pbs"),
new LspRangeDTO(0, content.indexOf('\n')),
new LspRangeDTO(0, content.length()),
List.of());
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(symbol));
final EditorDocumentScopeGuideModel.ActiveGuides guides = model.resolveActiveGuides(content.indexOf("hp"));
assertEquals(content.indexOf("struct Hero"), guides.activeScope().startOffset());
assertNull(guides.activeContainer());
}
@Test
void exposesAtMostTwoActiveSegmentsPerLine() {
final String content = """
struct Hero {
fn attack() {
let value = 1
}
}
""";
final LspSymbolDTO attack = symbol(content, "fn attack()", "}", LspSymbolKind.METHOD, List.of());
final LspSymbolDTO hero = new LspSymbolDTO(
"struct Hero",
LspSymbolKind.STRUCT,
Path.of("/tmp/example/main.pbs"),
new LspRangeDTO(0, content.length()),
List.of(attack));
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(hero));
final EditorDocumentScopeGuideModel.ActiveGuides guides = model.resolveActiveGuides(content.indexOf("let value"));
assertSegments(model, 1, guides, "ACTIVE_CONTAINER:CONTINUE", "ACTIVE_SCOPE:START");
assertSegments(model, 2, guides, "ACTIVE_CONTAINER:CONTINUE", "ACTIVE_SCOPE:CONTINUE");
assertSegments(model, 3, guides, "ACTIVE_CONTAINER:CONTINUE", "ACTIVE_SCOPE:END");
}
@Test
void ignoresSingleLineAndUnknownSymbols() {
final String content = """
if ready {
value()
}
""";
final LspSymbolDTO symbol = new LspSymbolDTO(
"if ready",
LspSymbolKind.UNKNOWN,
Path.of("/tmp/example/main.pbs"),
new LspRangeDTO(0, content.length()),
List.of());
final EditorDocumentScopeGuideModel model = EditorDocumentScopeGuideModel.from(content, List.of(symbol));
assertEquals(0, model.maxDepth());
assertKinds(model, 0);
assertEquals(EditorDocumentScopeGuideModel.ActiveGuides.empty(), model.resolveActiveGuides(content.indexOf("value")));
}
private static LspSymbolDTO symbol(
@ -81,14 +124,15 @@ final class EditorDocumentScopeGuideModelTest {
children);
}
private static void assertKinds(
private static void assertSegments(
final EditorDocumentScopeGuideModel model,
final int line,
final EditorDocumentScopeGuideModel.ActiveGuides guides,
final String... expectedKinds) {
assertEquals(
List.of(expectedKinds),
model.segmentsForLine(line).stream()
.map(segment -> segment.kind().name())
model.segmentsForLine(line, guides).stream()
.map(segment -> segment.role().name() + ":" + segment.kind().name())
.toList());
}
}