diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 62b2895c..55111e4a 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -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":[]} diff --git a/discussion/workflow/plans/PLN-0030-studio-active-container-and-active-scope-gutter-wave-1.md b/discussion/workflow/plans/PLN-0030-studio-active-container-and-active-scope-gutter-wave-1.md index 5332ab3e..d1380570 100644 --- a/discussion/workflow/plans/PLN-0030-studio-active-container-and-active-scope-gutter-wave-1.md +++ b/discussion/workflow/plans/PLN-0030-studio-active-container-and-active-scope-gutter-wave-1.md @@ -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 diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentScopeGuideGraphicFactory.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentScopeGuideGraphicFactory.java index 03f6f3c8..6109cc6e 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentScopeGuideGraphicFactory.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentScopeGuideGraphicFactory.java @@ -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 segments; - private final int maxDepth; + private final java.util.List segments; private ScopeGuideCanvas( - final java.util.List segments, - final int maxDepth) { + final java.util.List 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); } } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentScopeGuideModel.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentScopeGuideModel.java index 2fe8db7b..e7b2512d 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentScopeGuideModel.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorDocumentScopeGuideModel.java @@ -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> lines; - private final int maxDepth; + private final List ranges; + private final List lineStarts; + private final int contentLength; private EditorDocumentScopeGuideModel( - final List> lines, - final int maxDepth) { - this.lines = lines; - this.maxDepth = maxDepth; + final List ranges, + final List 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 symbols) { - final int totalLines = totalLines(content); - final List> lines = new ArrayList<>(totalLines); - for (int line = 0; line < totalLines; line++) { - lines.add(new ArrayList<>()); - } - final List lineStarts = lineStarts(content); - final int[] maxDepth = new int[] {0}; + final List 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> 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 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 segmentsForLine( + final int lineIndex, + final ActiveGuides activeGuides) { + if (lineIndex < 0 || lineIndex >= totalLines() || activeGuides.isEmpty()) { return List.of(); } - return lines.get(lineIndex); + final List 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> lines, + final List ranges, final List 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> lines, + private static GuideRange toGuideRange( final List 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; - } - maxDepth[0] = Math.max(maxDepth[0], depth + 1); - for (int line = startLine; line <= endLine; line++) { - final GuideSegmentKind kind; - if (line == startLine) { - kind = GuideSegmentKind.START; - } else if (line == endLine) { - kind = GuideSegmentKind.END; - } else { - kind = GuideSegmentKind.CONTINUE; - } - lines.get(line).add(new GuideSegment(depth, kind)); + return null; } + return new GuideRange(id, depth, startOffset, endOffset, startLine, endLine, parentId); } - 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++; + private GuideRange findById(final int id) { + for (final GuideRange range : ranges) { + if (range.id() == id) { + return range; } } - return total; + 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 segments, + final int lineIndex, + final GuideRange range, + final GuideRole role) { + if (range == null || lineIndex < range.startLine() || lineIndex > range.endLine()) { + return; + } + final GuideSegmentKind kind; + if (lineIndex == range.startLine()) { + kind = GuideSegmentKind.START; + } else if (lineIndex == range.endLine()) { + kind = GuideSegmentKind.END; + } else { + kind = GuideSegmentKind.CONTINUE; + } + segments.add(new ActiveGuideSegment(role, kind)); } private static List 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"); + } } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java index 3bf3beef..58526e9f 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/editor/EditorWorkspace.java @@ -45,6 +45,7 @@ public final class EditorWorkspace extends Workspace { private final List activePresentationStylesheets = new ArrayList<>(); private final IntFunction 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(); + } } diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentScopeGuideModelTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentScopeGuideModelTest.java index adc8b899..dd1be621 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentScopeGuideModelTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/editor/EditorDocumentScopeGuideModelTest.java @@ -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()); } }