Skip to content

Commit dad9d5e

Browse files
STRd6claude
andcommitted
LSP: grammar-level folding and selection ranges for .hera
onFoldingRanges and onSelectionRanges had no .hera branch, so they ran TS outlining / smart-selection on the generated parser and remapped through the composed Hera→Civet→TS sourcemap — yielding folds in the parser's coordinate space, often past the source's last line. Add getFoldingRanges / getSelectionRange to the Hera analyzer, backed by the cached rule symbols (no re-parse), and short-circuit both handlers for .hera the same way onDocumentSymbol does. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3827a36 commit dad9d5e

4 files changed

Lines changed: 134 additions & 1 deletion

File tree

lsp/server/source/lib/hera-analyzer.civet

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@
1010
type CompletionItem
1111
CompletionItemKind
1212
type DocumentSymbol
13+
type FoldingRange
14+
FoldingRangeKind
1315
type Hover
1416
type Location
1517
MarkupKind
18+
type Range
19+
type SelectionRange
1620
SymbolKind
1721
} from vscode-languageserver
1822

@@ -209,6 +213,46 @@ export function getHoverFor(doc: TextDocument, pos: Position): Hover?
209213
export function getDocumentSymbols(doc: TextDocument): DocumentSymbol[]?
210214
symbolsCache.get doc.uri
211215

216+
/** One fold per grammar rule (name line → last body line) from the cached
217+
rule symbols, so folds stay in grammar coordinates — unlike the TS-outline
218+
path, which folds the transpiled parser and remaps out of range. */
219+
export function getFoldingRanges(doc: TextDocument): FoldingRange[]?
220+
syms := symbolsCache.get doc.uri
221+
return undefined unless syms
222+
folds: FoldingRange[] := []
223+
for sym of syms
224+
startLine := sym.range.start.line
225+
{ line: endLine, character: endChar } := sym.range.end
226+
// Range end is exclusive: char 0 ⇒ content ends on the previous line (next
227+
// rule's header / trailing blank); non-zero ⇒ on range.end.line itself
228+
// (last rule, no trailing newline).
229+
lastLine := endChar is 0 ? endLine - 1 : endLine
230+
/* c8 ignore next -- defensive: a parsed rule spans >= 2 lines, so this never trips */
231+
continue unless lastLine > startLine
232+
folds.push { startLine, endLine: lastLine, kind: FoldingRangeKind.Region }
233+
folds
234+
235+
/** Whether `pos` is inside `range` (start-inclusive, end-exclusive), by offset. */
236+
function rangeContains(doc: TextDocument, range: Range, pos: Position): boolean
237+
offset := doc.offsetAt pos
238+
offset >= doc.offsetAt(range.start) and offset < doc.offsetAt(range.end)
239+
240+
/** Grammar-level selection for `pos`: widen terminal → rule → file from the
241+
cached rule symbols. Undefined when `pos` is in no rule, so the caller can
242+
fall back. */
243+
export function getSelectionRange(doc: TextDocument, pos: Position): SelectionRange?
244+
syms := symbolsCache.get doc.uri
245+
return undefined unless syms
246+
rule := syms.find (s) => rangeContains doc, s.range, pos
247+
return undefined unless rule
248+
fileSel: SelectionRange :=
249+
range:
250+
start: { line: 0, character: 0 }
251+
end: doc.positionAt doc.getText()#
252+
ruleSel: SelectionRange := { range: rule.range, parent: fileSel }
253+
child := rule.children?.find (c) => rangeContains doc, c.range, pos
254+
child ? { range: child.range, parent: ruleSel } : ruleSel
255+
212256
/** Rule-name completions; each carries `{ hera, uri }` so resolveHeraCompletion
213257
can attach rule-body docs lazily. */
214258
export function getCompletionsFor(doc: TextDocument, _pos: Position): CompletionItem[]?

lsp/server/source/server.civet

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@
6262
getCompletionsFor as heraCompletionsFor
6363
getDeclarationFor as heraDeclarationFor
6464
getDocumentSymbols as heraDocumentSymbols
65+
getFoldingRanges as heraFoldingRanges
6566
getHoverFor as heraHoverFor
6667
getReferencesFor as heraReferencesFor
68+
getSelectionRange as heraSelectionRange
6769
isAtGrammarLevel as isHeraGrammarLevel
6870
parseHeraDocument
6971
resolveHeraCompletion
@@ -1163,6 +1165,16 @@ export function startServer(connection: Connection, dependencies: ServerDependen
11631165
connection.onFoldingRanges ({ textDocument }) =>
11641166
sourcePath := documentToSourcePath(textDocument)
11651167
assert sourcePath
1168+
1169+
// .hera folds wrap grammar rules; the transpiled path folds the generated
1170+
// parser and remaps out of the source's coordinate space.
1171+
if textDocument.uri.endsWith('.hera')
1172+
doc := documents.get(textDocument.uri)
1173+
if doc
1174+
await updating(textDocument)
1175+
if folds := heraFoldingRanges(doc)
1176+
return folds
1177+
11661178
service := await ensureServiceForSourcePath(sourcePath)
11671179
await updating(textDocument)
11681180

@@ -1202,6 +1214,17 @@ export function startServer(connection: Connection, dependencies: ServerDependen
12021214
connection.onSelectionRanges ({ textDocument, positions }) =>
12031215
sourcePath := documentToSourcePath(textDocument)
12041216
assert sourcePath
1217+
1218+
// .hera selections widen terminal → rule → file in grammar coordinates.
1219+
// Gate on the grammar having parsed; fall back per out-of-rule position.
1220+
if textDocument.uri.endsWith('.hera')
1221+
doc := documents.get(textDocument.uri)
1222+
if doc
1223+
await updating(textDocument)
1224+
if heraDocumentSymbols(doc)
1225+
return positions.map (pos) =>
1226+
heraSelectionRange(doc, pos) ?? { range: { start: pos, end: pos } }
1227+
12051228
service := await ensureServiceForSourcePath(sourcePath)
12061229
await updating(textDocument)
12071230

lsp/server/test/lsp-features.test.civet

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
{ spawnLspServer, docUri, type LspClient } from ./lsp-harness.civet
1212
{ getCanonicalFileName } from ../source/lib/util.civet
13-
{ isAtGrammarLevel, parseHeraDocument, getHoverFor } from ../source/lib/hera-analyzer.civet
13+
{ isAtGrammarLevel, parseHeraDocument, getHoverFor, getFoldingRanges as heraFoldingRanges, getSelectionRange as heraSelectionRange } from ../source/lib/hera-analyzer.civet
1414
{ TextDocument } from vscode-languageserver-textdocument
1515
{ URI } from vscode-uri
1616
path from node:path
@@ -1474,6 +1474,61 @@ describe "LSP features (shared project server)", ->
14741474
// swallows it (keeping prior caches) and diagnostics still publish.
14751475
await openHera "hera-broken.hera", "@@@@@\n"
14761476

1477+
it "folding ranges wrap each grammar rule and stay in source bounds", ->
1478+
// No trailing newline so the last rule's body sits *on* range.end.line
1479+
// (non-zero end character) — exercises both arms of the end-line clamp.
1480+
foldText := "Start\n A B\n\nA\n \"a\"\n\nB\n \"b\""
1481+
uri := await openHera "hera-fold.hera", foldText
1482+
lineCount := foldText.split('\n')#
1483+
1484+
folds := await client.request "textDocument/foldingRange", {
1485+
textDocument: { uri }
1486+
}
1487+
assert Array.isArray(folds), "expected folding array, got " + JSON.stringify(folds)
1488+
// One fold per rule (Start, A, B), each spanning >= 1 line and — the
1489+
// regression guard — never past the document's last line.
1490+
assert.equal folds.length, 3, "expected one fold per rule, got " + JSON.stringify(folds)
1491+
for f of folds as any[]
1492+
assert f.startLine < f.endLine, "fold must span >= 1 line: " + JSON.stringify(f)
1493+
assert f.endLine < lineCount, `fold endLine ${f.endLine} exceeds source line count ${lineCount}`
1494+
starts := (folds as any[]).map (f) => f.startLine
1495+
assert.deepEqual starts, [0, 3, 6], "folds should start at each rule's name line"
1496+
// B is the last rule with content on its final line (no trailing blank).
1497+
bFold := (folds as any[]).find (f) => f.startLine is 6
1498+
assert.equal bFold.endLine, 7, "last rule's fold should reach its body line"
1499+
1500+
it "selection ranges widen terminal → rule → file", ->
1501+
uri := await openHera "hera-sel.hera"
1502+
1503+
result := await client.request "textDocument/selectionRange", {
1504+
textDocument: { uri }
1505+
positions: [
1506+
{ line: 4, character: 11 } // inside the `Name` reference in Greeting
1507+
{ line: 6, character: 2 } // on the `Name` rule's own name
1508+
{ line: 99, character: 0 } // past EOF — outside every rule
1509+
]
1510+
}
1511+
assert Array.isArray(result) and result.length is 3, "expected 3 selection entries, got " + JSON.stringify(result)
1512+
1513+
// Terminal click: innermost = the token, then its rule (Greeting, line 3),
1514+
// then the whole file (line 0).
1515+
term := result[0]
1516+
assert.equal term.range.start.line, 4, "innermost should be the clicked terminal"
1517+
assert.equal term.parent.range.start.line, 3, "parent should be the Greeting rule"
1518+
assert.equal term.parent.parent.range.start.line, 0, "outer should be the whole file"
1519+
assert not term.parent.parent.parent, "file is the outermost range"
1520+
1521+
// Rule-name click: no enclosing terminal, so widen straight to rule → file.
1522+
ruleSel := result[1]
1523+
assert.equal ruleSel.range.start.line, 6, "should select the Name rule"
1524+
assert.equal ruleSel.parent.range.start.line, 0, "parent should be the whole file"
1525+
assert not ruleSel.parent.parent, "rule's parent is the file"
1526+
1527+
// Outside every rule: server falls back to a degenerate range at the position.
1528+
fallback := result[2]
1529+
assert.equal fallback.range.start.line, fallback.range.end.line, "fallback collapses to a point"
1530+
assert not fallback.parent, "fallback has no parent"
1531+
14771532
// Tests below each need their own workspace / tsconfig / initialize, so they
14781533
// spawn a dedicated server per test. Keep this set small.
14791534
describe "LSP features (isolated workspaces)", ->
@@ -1748,3 +1803,11 @@ describe "parseHeraDocument", ->
17481803
doc := TextDocument.create("file:///no-hera.hera", "hera", 1, "Rule\n \"x\"\n")
17491804
parseHeraDocument doc, undefined
17501805
assert.equal getHoverFor(doc, { line: 0, character: 0 }), undefined
1806+
1807+
it "folding and selection ranges return undefined without a symbol cache", ->
1808+
// No parse ran for this uri, so the analyzer has no rule spans and must
1809+
// signal "no answer" (undefined) so the caller falls through to the TS
1810+
// service instead of fabricating ranges.
1811+
doc := TextDocument.create("file:///unparsed.hera", "hera", 1, "Rule\n \"x\"\n")
1812+
assert.equal heraFoldingRanges(doc), undefined
1813+
assert.equal heraSelectionRange(doc, { line: 1, character: 2 }), undefined

lsp/vscode/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Civet VS Code Extension Changelog
22

3+
## Unreleased
4+
* LSP: grammar-level folding and selection ranges for `.hera`, fixing folds that landed in the transpiled parser's coordinates (past the source's last line)
5+
36
## 0.3.38 (2026-05-29)
47
* LSP: Hera-aware grammar-level features for `.hera` — go-to-definition, references, outline, completions, and hover for grammar rules [[#2127](https://github.com/DanielXMoore/Civet/pull/2127)]
58
* Syntax highlighting for `.hera` grammar files (handler bodies highlighted as Civet)

0 commit comments

Comments
 (0)