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
This commit is contained in:
2026-07-19 23:11:10 +03:00
parent 42f9453e18
commit ed07cf8921
5 changed files with 397 additions and 9 deletions
+280 -2
View File
@@ -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)
+91
View File
@@ -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)"
+1 -1
View File
@@ -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