From ed07cf8921942f5bbcc76e0174d64ae146c60bf0 Mon Sep 17 00:00:00 2001 From: dimgigov Date: Sun, 19 Jul 2026 23:11:10 +0300 Subject: [PATCH] feat(lsp): call hierarchy prepare / incoming / outgoing (0.8) Expose LSP call hierarchy over a lightweight textual call graph of known functions and Name( call sites across the workspace. - prepareCallHierarchy on func decl or call site - incomingCalls / outgoingCalls with fromRanges - smoke_lsp_call_hierarchy + test-lsp wiring --- Makefile | 3 + docs/QUALITY_PLAN.md | 28 ++- tools/lsp_server.nim | 282 +++++++++++++++++++++++++++++- tools/smoke_lsp_call_hierarchy.sh | 91 ++++++++++ tools/smoke_lsp_rename.sh | 2 +- 5 files changed, 397 insertions(+), 9 deletions(-) create mode 100755 tools/smoke_lsp_call_hierarchy.sh diff --git a/Makefile b/Makefile index e3e3436..04d61ff 100644 --- a/Makefile +++ b/Makefile @@ -199,6 +199,9 @@ test-lsp: lsp @echo "=== LSP deeper rename smoke ===" @chmod +x tools/smoke_lsp_rename_deep.sh @tools/smoke_lsp_rename_deep.sh + @echo "=== LSP call hierarchy smoke ===" + @chmod +x tools/smoke_lsp_call_hierarchy.sh + @tools/smoke_lsp_call_hierarchy.sh .PHONY: test-registry test-registry: build diff --git a/docs/QUALITY_PLAN.md b/docs/QUALITY_PLAN.md index 4622442..823f365 100644 --- a/docs/QUALITY_PLAN.md +++ b/docs/QUALITY_PLAN.md @@ -1,7 +1,7 @@ # Bux — План към „добър“ език (v0.5 → v1.0) > **Дата:** 2026-07-19 -> **Текущо:** v0.5.x — field-move Drop, selfhost #line, Nexus KA, **LSP 0.7 deeper rename** +> **Текущо:** v0.5.x — field-move, selfhost #line, Nexus KA, **LSP 0.8 call hierarchy** > **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain. --- @@ -78,7 +78,7 @@ | # | Задача | Защо | Статус | |---|--------|------|--------| -| D.1 | LSP: hover, go-to-def, diagnostics | IDE = adoption | ✅ v0.6.0: refs/rename + **workspace/symbol** | +| D.1 | LSP: hover, go-to-def, diagnostics | IDE = adoption | ✅ v0.7.0: + **field/variant rename** + workspace/symbol | | D.2 | `bux fmt` стабилен + CI check | Единен style | ✅ full-tree format + `make fmt-check` enforce | | D.3 | `bux test` с `--filter`, exit codes, summary table | CI-friendly | ✅ `--filter` / summary / exit 0\|1 | | D.4 | `bux doc` от `///` comments | Самодокументиращ се stdlib | ✅ bootstrap+selfhost + `make docs` | @@ -626,9 +626,25 @@ A (stdlib ergonomics) → B (compiler holes) → C (ownership depth) --- +## Сесия 39 (LSP 0.7 deeper rename) + +1. **Member index** in `analyzeFile`: + - struct/union/interface fields (`name: Type`) + - enum variants (`Name` / `Name(...)`) +2. **Access classification** for each hit: + - bare / `.member` / `::Variant` / `name:` field-init +3. **Rename targets**: + - **local** — same binding only (shadowing-safe) + - **member** — decl + `.x` + `::Red` + `{ x: }` — **not** bare locals named `x` + - **global** — bare + `::` paths; skips `.member` false positives +4. Smoke: `tools/smoke_lsp_rename_deep.sh` (x→px ≥3, Red→Crimson ≥2, local x→xx =2) +5. Version **bux-lsp 0.7.0**; wired into `make test-lsp` + +--- + ## Следващи стъпки -1. Deeper rename (type members / qualified paths) -2. LSP call hierarchy (optional) -3. Selfhost multi-file `#line` paths without BUX_DEBUG_FILE -4. Wire selfhost smoke for move_field into CI +1. LSP call hierarchy (optional) +2. Selfhost multi-file `#line` paths without BUX_DEBUG_FILE +3. Wire selfhost smoke for move_field into CI +4. Rename of method receivers / qualified module paths (edge cases) diff --git a/tools/lsp_server.nim b/tools/lsp_server.nim index effaf58..a852b6c 100644 --- a/tools/lsp_server.nim +++ b/tools/lsp_server.nim @@ -9,6 +9,7 @@ # v0.5.0: textDocument/references + rename (scoped locals + workspace globals). # v0.6.0: workspace/symbol search. # v0.7.0: deeper rename — struct fields, enum variants, .member / ::Variant. +# v0.8.0: call hierarchy (prepare / incoming / outgoing). import std/[json, os, strutils, streams, tables, osproc, sequtils, sets] import lexer, parser, ast, sema, types, scope, source_location @@ -1907,6 +1908,273 @@ proc handleWorkspaceSymbol(stream: FileStream, id: JsonNode, paramsNode: JsonNod sendResponse(stream, id, arr) +# --------------------------------------------------------------------------- +# Call hierarchy (v0.8) — lightweight textual call graph +# --------------------------------------------------------------------------- + +type + FuncSym = object + name: string + uri: string + info: SymbolInfo + CallSite = object + callee: string + line: int + col: int + ## Enclosing function name ("" if top-level / unknown) + caller: string + callerUri: string + +proc listFunctionSymbols(doc: DocumentState): seq[tuple[name: string, info: SymbolInfo]] = + result = @[] + ensureAnalyzed(doc) + for name, info in doc.symbols.pairs: + if info.kind == "function": + result.add((name, info)) + +proc allKnownFuncs(): Table[string, FuncSym] = + ## name → first seen FuncSym (workspace + open docs) + result = initTable[string, FuncSym]() + for uri, doc in documents.pairs: + for (name, info) in listFunctionSymbols(doc): + if not result.hasKey(name): + result[name] = FuncSym(name: name, uri: uri, info: info) + for name, ws in workspaceSymbols.pairs: + if ws.info.kind == "function" and not result.hasKey(name): + result[name] = FuncSym(name: name, uri: ws.uri, info: ws.info) + +proc enclosingFuncName(doc: DocumentState, line: int): string = + ## Nearest function whose decl line ≤ line (same file). + result = "" + var best = -1 + for name, info in doc.symbols.pairs: + if info.kind != "function": continue + if info.line <= line and info.line >= best: + best = info.line + result = name + +proc isCallSiteAt(content: string, start, nameLen: int): bool = + ## True if `name` is followed by optional space then `(`. + var j = start + nameLen + while j < content.len and content[j] in {' ', '\t'}: + inc j + result = j < content.len and content[j] == '(' + +proc collectCallSitesInDoc(doc: DocumentState, known: HashSet[string]): seq[CallSite] = + result = @[] + ensureAnalyzed(doc) + if doc.content.len == 0: return + for name in known: + for h in collectIdentHits(doc.content, name): + # absolute offset for call-site check + let lines = doc.content.split("\n") + if h.line < 0 or h.line >= lines.len: continue + var off = 0 + for li in 0 ..< h.line: + off += lines[li].len + 1 + off += h.col + if not isCallSiteAt(doc.content, off, h.len): + continue + # Skip the function declaration itself (func Name() — also has () ) + if doc.symbols.hasKey(name): + let info = doc.symbols[name] + if info.line == h.line and info.col == h.col: + continue + # Skip .method-style if access is dot (method calls still useful — keep) + let caller = enclosingFuncName(doc, h.line) + result.add(CallSite( + callee: name, line: h.line, col: h.col, + caller: caller, callerUri: doc.uri)) + +proc collectAllCallSites(): seq[CallSite] = + result = @[] + let funcs = allKnownFuncs() + var known = initHashSet[string]() + for k in funcs.keys: + known.incl(k) + if known.len == 0: return + + var seenUri = initHashSet[string]() + for uri, doc in documents.pairs: + seenUri.incl(uri) + result.add(collectCallSitesInDoc(doc, known)) + + # Disk scan for other workspace files + if rootPath.len > 0 and dirExists(rootPath): + var stack: seq[tuple[dir: string, depth: int]] = @[(rootPath, 0)] + while stack.len > 0: + let (dir, depth) = stack.pop() + if depth > 4: continue + let base = dir.extractFilename + if base in [".git", "build", "examples_pkg", "node_modules", "vendor", "nimcache"]: + continue + try: + for kind, path in walkDir(dir): + if kind == pcDir: + stack.add((path, depth + 1)) + elif kind == pcFile and path.endsWith(".bux"): + let u = pathToUri(path.absolutePath) + if seenUri.contains(u): continue + seenUri.incl(u) + try: + let text = readFile(path) + var d = DocumentState(uri: u, content: text) + let updated = analyzeFile(path, text) + d.symbols = updated.symbols + d.ordered = updated.ordered + d.members = updated.members + result.add(collectCallSitesInDoc(d, known)) + except CatchableError: + discard + except CatchableError: + discard + +proc callHierarchyItem(fs: FuncSym): JsonNode = + let nameLen = fs.name.len + %*{ + "name": fs.name, + "kind": 12, # SymbolKind.Function + "detail": fs.info.detail, + "uri": fs.uri, + "range": { + "start": {"line": fs.info.line, "character": 0}, + "end": {"line": fs.info.line, "character": fs.info.col + nameLen} + }, + "selectionRange": { + "start": {"line": fs.info.line, "character": fs.info.col}, + "end": {"line": fs.info.line, "character": fs.info.col + nameLen} + } + } + +proc lookupFuncSym(name, uri: string): FuncSym = + result = FuncSym(name: name, uri: uri) + if documents.hasKey(uri): + let doc = documents[uri] + ensureAnalyzed(doc) + if doc.symbols.hasKey(name) and doc.symbols[name].kind == "function": + result.info = doc.symbols[name] + return + if workspaceSymbols.hasKey(name) and workspaceSymbols[name].info.kind == "function": + result.uri = workspaceSymbols[name].uri + result.info = workspaceSymbols[name].info + return + # Fallback from allKnownFuncs + let all = allKnownFuncs() + if all.hasKey(name): + return all[name] + +proc handlePrepareCallHierarchy(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = + let uri = paramsNode["textDocument"]["uri"].getStr() + let position = paramsNode["position"] + let lineNum = position["line"].getInt() + let col = position["character"].getInt() + let doc = getDoc(uri) + if doc.content == "": + sendResponse(stream, id, %*[]) + return + ensureAnalyzed(doc) + let word = findWordAt(doc.content, lineNum, col) + if word.len == 0: + sendResponse(stream, id, %*[]) + return + # Prefer function symbol under cursor + if doc.symbols.hasKey(word) and doc.symbols[word].kind == "function": + let fs = FuncSym(name: word, uri: uri, info: doc.symbols[word]) + sendResponse(stream, id, %*[callHierarchyItem(fs)]) + return + if workspaceSymbols.hasKey(word) and workspaceSymbols[word].info.kind == "function": + let ws = workspaceSymbols[word] + let fs = FuncSym(name: word, uri: ws.uri, info: ws.info) + sendResponse(stream, id, %*[callHierarchyItem(fs)]) + return + # Allow prepare on a call site: Foo( → hierarchy for Foo + let lines = doc.content.split("\n") + if lineNum < lines.len: + let l = lines[lineNum] + var ws = min(col, l.len) + while ws > 0 and l[ws - 1] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}: + dec ws + var off = 0 + for li in 0 ..< lineNum: + off += lines[li].len + 1 + off += ws + if isCallSiteAt(doc.content, off, word.len): + let all = allKnownFuncs() + if all.hasKey(word): + sendResponse(stream, id, %*[callHierarchyItem(all[word])]) + return + sendResponse(stream, id, %*[]) + +proc handleIncomingCalls(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = + ## Who calls this function? + if not paramsNode.hasKey("item"): + sendResponse(stream, id, %*[]) + return + let item = paramsNode["item"] + let name = item["name"].getStr() + let sites = collectAllCallSites() + # Group by caller + var groups = initTable[string, tuple[fs: FuncSym, ranges: seq[tuple[line, col, len: int]]]]() + for s in sites: + if s.callee != name: continue + if s.caller.len == 0: continue + let key = s.callerUri & "#" & s.caller + if not groups.hasKey(key): + let fs = lookupFuncSym(s.caller, s.callerUri) + groups[key] = (fs, @[]) + groups[key].ranges.add((s.line, s.col, name.len)) + + var arr = newJArray() + for _, g in groups.pairs: + var fromRanges = newJArray() + for r in g.ranges: + fromRanges.add(%*{ + "start": {"line": r.line, "character": r.col}, + "end": {"line": r.line, "character": r.col + r.len} + }) + arr.add(%*{ + "from": callHierarchyItem(g.fs), + "fromRanges": fromRanges + }) + sendResponse(stream, id, arr) + +proc handleOutgoingCalls(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = + ## What does this function call? + if not paramsNode.hasKey("item"): + sendResponse(stream, id, %*[]) + return + let item = paramsNode["item"] + let name = item["name"].getStr() + let uri = if item.hasKey("uri"): item["uri"].getStr() else: "" + let sites = collectAllCallSites() + # Group by callee among sites whose caller is `name` + var groups = initTable[string, tuple[fs: FuncSym, ranges: seq[tuple[line, col, len: int]]]]() + for s in sites: + if s.caller != name: continue + if uri.len > 0 and s.callerUri != uri: continue + let key = s.callee + if not groups.hasKey(key): + let fs = lookupFuncSym(s.callee, s.callerUri) + # Prefer known func uri + let all = allKnownFuncs() + let fs2 = if all.hasKey(s.callee): all[s.callee] else: fs + groups[key] = (fs2, @[]) + groups[key].ranges.add((s.line, s.col, s.callee.len)) + + var arr = newJArray() + for _, g in groups.pairs: + var fromRanges = newJArray() + for r in g.ranges: + fromRanges.add(%*{ + "start": {"line": r.line, "character": r.col}, + "end": {"line": r.line, "character": r.col + r.len} + }) + arr.add(%*{ + "to": callHierarchyItem(g.fs), + "fromRanges": fromRanges + }) + sendResponse(stream, id, arr) + # --------------------------------------------------------------------------- # Main message loop # --------------------------------------------------------------------------- @@ -1930,9 +2198,10 @@ proc handleMessage(stream: FileStream, msg: JsonNode) = "documentSymbolProvider": true, "referencesProvider": true, "renameProvider": {"prepareProvider": true}, - "workspaceSymbolProvider": true + "workspaceSymbolProvider": true, + "callHierarchyProvider": true }, - "serverInfo": {"name": "bux-lsp", "version": "0.7.0"} + "serverInfo": {"name": "bux-lsp", "version": "0.8.0"} }) if paramsNode.hasKey("rootPath") and paramsNode["rootPath"].kind != JNull: rootPath = paramsNode["rootPath"].getStr() @@ -2020,6 +2289,15 @@ proc handleMessage(stream: FileStream, msg: JsonNode) = of "workspace/symbol": handleWorkspaceSymbol(stream, id, paramsNode) + of "textDocument/prepareCallHierarchy": + handlePrepareCallHierarchy(stream, id, paramsNode) + + of "callHierarchy/incomingCalls": + handleIncomingCalls(stream, id, paramsNode) + + of "callHierarchy/outgoingCalls": + handleOutgoingCalls(stream, id, paramsNode) + else: if id != nil: sendError(stream, id, -32601, "method not found: " & methodName) diff --git a/tools/smoke_lsp_call_hierarchy.sh b/tools/smoke_lsp_call_hierarchy.sh new file mode 100755 index 0000000..c6c1d00 --- /dev/null +++ b/tools/smoke_lsp_call_hierarchy.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Smoke: call hierarchy prepare / incoming / outgoing (bux-lsp 0.8) +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +LSP="$ROOT/tools/bux-lsp" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +if [[ ! -x "$LSP" ]]; then + (cd "$ROOT" && make lsp >/dev/null) +fi + +cat > "$TMP/Main.bux" <<'EOF' +func Add(a: int, b: int) -> int { + return a + b; +} +func Mul(a: int, b: int) -> int { + return a * b; +} +func Compute(n: int) -> int { + let s: int = Add(n, 1); + let p: int = Mul(s, 2); + return Add(p, s); +} +func Main() -> int { + return Compute(3); +} +EOF + +rpc() { + local body="$1" + local len + len=$(printf '%s' "$body" | wc -c) + printf 'Content-Length: %s\r\n\r\n%s' "$len" "$body" +} + +CONTENT_JSON=$(python3 -c 'import json,sys; print(json.dumps(open(sys.argv[1]).read()))' "$TMP/Main.bux") +URI="file://$TMP/Main.bux" + +# Prepare on Add (line 0, character 5) +# Incoming for Add — expect Compute +# Outgoing for Compute — expect Add and Mul +{ + rpc '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{},"rootUri":"file://'"$TMP"'"}}' + rpc '{"jsonrpc":"2.0","method":"initialized","params":{}}' + rpc '{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"'"$URI"'","languageId":"bux","version":1,"text":'"$CONTENT_JSON"'}}}' + rpc '{"jsonrpc":"2.0","id":2,"method":"textDocument/prepareCallHierarchy","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":0,"character":5}}}' + # incomingCalls — item for Add from prepare (we construct manually) + rpc '{"jsonrpc":"2.0","id":3,"method":"callHierarchy/incomingCalls","params":{"item":{"name":"Add","kind":12,"uri":"'"$URI"'","range":{"start":{"line":0,"character":0},"end":{"line":0,"character":8}},"selectionRange":{"start":{"line":0,"character":5},"end":{"line":0,"character":8}}}}}' + rpc '{"jsonrpc":"2.0","id":4,"method":"callHierarchy/outgoingCalls","params":{"item":{"name":"Compute","kind":12,"uri":"'"$URI"'","range":{"start":{"line":6,"character":0},"end":{"line":6,"character":12}},"selectionRange":{"start":{"line":6,"character":5},"end":{"line":6,"character":12}}}}}' + rpc '{"jsonrpc":"2.0","id":5,"method":"shutdown","params":null}' + rpc '{"jsonrpc":"2.0","method":"exit","params":null}' +} | "$LSP" 2>/dev/null | tr '\r' '\n' > "$TMP/out.txt" + +if ! grep -q 'callHierarchyProvider' "$TMP/out.txt"; then + echo "FAIL: missing callHierarchyProvider" + cat "$TMP/out.txt" + exit 1 +fi +if ! grep -q '0.8.0' "$TMP/out.txt"; then + echo "WARN: version not 0.8.0" +fi + +# prepare should mention Add +if ! grep -q '"name":"Add"' "$TMP/out.txt"; then + echo "FAIL: prepareCallHierarchy did not return Add" + cat "$TMP/out.txt" + exit 1 +fi + +# incoming: Compute calls Add +if ! grep -q '"name":"Compute"' "$TMP/out.txt"; then + echo "FAIL: incomingCalls missing Compute" + cat "$TMP/out.txt" + exit 1 +fi + +# outgoing from Compute: Add and Mul +if ! grep -q '"name":"Mul"' "$TMP/out.txt"; then + echo "FAIL: outgoingCalls missing Mul" + cat "$TMP/out.txt" + exit 1 +fi + +# fromRanges present +if ! grep -q 'fromRanges' "$TMP/out.txt"; then + echo "FAIL: missing fromRanges" + exit 1 +fi + +echo "PASS: LSP call hierarchy (prepare + incoming + outgoing)" diff --git a/tools/smoke_lsp_rename.sh b/tools/smoke_lsp_rename.sh index 0566c48..6efe764 100755 --- a/tools/smoke_lsp_rename.sh +++ b/tools/smoke_lsp_rename.sh @@ -59,7 +59,7 @@ if ! grep -q 'renameProvider' "$TMP/out.txt"; then echo "FAIL: initialize missing renameProvider" exit 1 fi -if ! grep -qE '0\.[567]\.0' "$TMP/out.txt"; then +if ! grep -qE '0\.[5678]\.0' "$TMP/out.txt"; then echo "WARN: unexpected bux-lsp version in initialize" fi