|
10 | 10 |
|
11 | 11 | { spawnLspServer, docUri, type LspClient } from ./lsp-harness.civet |
12 | 12 | { 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 |
14 | 14 | { TextDocument } from vscode-languageserver-textdocument |
15 | 15 | { URI } from vscode-uri |
16 | 16 | path from node:path |
@@ -1474,6 +1474,61 @@ describe "LSP features (shared project server)", -> |
1474 | 1474 | // swallows it (keeping prior caches) and diagnostics still publish. |
1475 | 1475 | await openHera "hera-broken.hera", "@@@@@\n" |
1476 | 1476 |
|
| 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 | + |
1477 | 1532 | // Tests below each need their own workspace / tsconfig / initialize, so they |
1478 | 1533 | // spawn a dedicated server per test. Keep this set small. |
1479 | 1534 | describe "LSP features (isolated workspaces)", -> |
@@ -1748,3 +1803,11 @@ describe "parseHeraDocument", -> |
1748 | 1803 | doc := TextDocument.create("file:///no-hera.hera", "hera", 1, "Rule\n \"x\"\n") |
1749 | 1804 | parseHeraDocument doc, undefined |
1750 | 1805 | 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 |
0 commit comments