feat(lsp): method call hierarchy for extend Type / .Method (0.9)

Index methods inside extend/impl blocks and include .Method( call sites
in the call graph so IDEs can navigate receiver methods.

- analyzeFile: track extend/impl brace scope; kind=method + container
- Call hierarchy treats methods as callables; display Type.Method
- smoke_lsp_method_hierarchy (Scale→Len, Main→Scale)
This commit is contained in:
2026-07-19 23:22:13 +03:00
parent 5de8d8912d
commit 8f645ad68b
5 changed files with 202 additions and 38 deletions
+3
View File
@@ -202,6 +202,9 @@ test-lsp: lsp
@echo "=== LSP call hierarchy smoke ===" @echo "=== LSP call hierarchy smoke ==="
@chmod +x tools/smoke_lsp_call_hierarchy.sh @chmod +x tools/smoke_lsp_call_hierarchy.sh
@tools/smoke_lsp_call_hierarchy.sh @tools/smoke_lsp_call_hierarchy.sh
@echo "=== LSP method call hierarchy smoke ==="
@chmod +x tools/smoke_lsp_method_hierarchy.sh
@tools/smoke_lsp_method_hierarchy.sh
.PHONY: test-registry .PHONY: test-registry
test-registry: build test-registry: build
+1 -1
View File
@@ -1,7 +1,7 @@
# Bux — План към „добър“ език (v0.5 → v1.0) # Bux — План към „добър“ език (v0.5 → v1.0)
> **Дата:** 2026-07-19 > **Дата:** 2026-07-19
> **Текущо:** v0.5.x — multi-file #line, **selfhost CI smoke**, LSP 0.8, Nexus KA > **Текущо:** v0.5.x — multi-file #line, selfhost CI, **LSP 0.9 method hierarchy**, Nexus KA
> **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain. > **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain.
--- ---
+104 -35
View File
@@ -10,6 +10,7 @@
# v0.6.0: workspace/symbol search. # v0.6.0: workspace/symbol search.
# v0.7.0: deeper rename — struct fields, enum variants, .member / ::Variant. # v0.7.0: deeper rename — struct fields, enum variants, .member / ::Variant.
# v0.8.0: call hierarchy (prepare / incoming / outgoing). # v0.8.0: call hierarchy (prepare / incoming / outgoing).
# v0.9.0: method call hierarchy (extend Type / .Method() sites).
import std/[json, os, strutils, streams, tables, osproc, sequtils, sets] import std/[json, os, strutils, streams, tables, osproc, sequtils, sets]
import lexer, parser, ast, sema, types, scope, source_location import lexer, parser, ast, sema, types, scope, source_location
@@ -313,6 +314,11 @@ proc analyzeFile(path: string, content: string): DocumentState =
var inString = false var inString = false
var stringDelim = '\0' var stringDelim = '\0'
var escape = false var escape = false
## extend/impl Type { ... } tracking for method indexing
var braceDepth = 0
var activeExtend = "" ## Type name of open extend/impl block
var extendBodyDepth = 0 ## braceDepth of the extend/impl opening `{`
var pendingExtend = "" ## Type name after `extend Name` before `{`
while i < content.len: while i < content.len:
let c = content[i] let c = content[i]
@@ -358,12 +364,41 @@ proc analyzeFile(path: string, content: string): DocumentState =
inc i inc i
continue continue
# Brace tracking for extend/impl bodies (outside strings/comments)
if c == '{':
inc braceDepth
if pendingExtend.len > 0 and extendBodyDepth == 0:
activeExtend = pendingExtend
extendBodyDepth = braceDepth
pendingExtend = ""
inc i
continue
if c == '}':
if extendBodyDepth > 0 and braceDepth == extendBodyDepth:
activeExtend = ""
extendBodyDepth = 0
if braceDepth > 0:
dec braceDepth
inc i
continue
# Keyword must be at token boundary # Keyword must be at token boundary
template atWord(kw: string): bool = template atWord(kw: string): bool =
(i + kw.len <= content.len and content[i ..< i + kw.len] == kw and (i + kw.len <= content.len and content[i ..< i + kw.len] == kw and
(i == 0 or not isIdentChar(content[i - 1])) and (i == 0 or not isIdentChar(content[i - 1])) and
(i + kw.len >= content.len or not isIdentChar(content[i + kw.len]))) (i + kw.len >= content.len or not isIdentChar(content[i + kw.len])))
# extend Type / impl Type — methods live in the following { block }
if atWord("extend") or atWord("impl"):
let kwLen = if content[i] == 'e': 6 else: 4
i += kwLen
skipWs(content, i)
# optional "Type for Trait" — take first type name
let tname = readIdent(content, i)
if tname.len > 0:
pendingExtend = tname
continue
if atWord("func"): if atWord("func"):
let kwPos = i let kwPos = i
i += 4 i += 4
@@ -399,8 +434,13 @@ proc analyzeFile(path: string, content: string): DocumentState =
var sig = content[kwPos ..< min(sigEnd, content.len)].strip() var sig = content[kwPos ..< min(sigEnd, content.len)].strip()
# collapse whitespace # collapse whitespace
sig = sig.replace("\n", " ").multiReplace([(" ", " "), (" ", " "), (" ", " ")]) sig = sig.replace("\n", " ").multiReplace([(" ", " "), (" ", " "), (" ", " ")])
let isMethod = activeExtend.len > 0
let kind = if isMethod: "method" else: "function"
let container = if isMethod: activeExtend else: ""
if isMethod:
sig = activeExtend & "." & sig
addSymbol(result, name, SymbolInfo( addSymbol(result, name, SymbolInfo(
line: line, col: col, kind: "function", detail: sig, container: "")) line: line, col: col, kind: kind, detail: sig, container: container))
continue continue
if atWord("let") or atWord("var") or atWord("const"): if atWord("let") or atWord("var") or atWord("const"):
@@ -1091,6 +1131,7 @@ proc ensureAnalyzed(doc: DocumentState) =
proc completionKind(kind: string): int = proc completionKind(kind: string): int =
case kind case kind
of "function": 3 of "function": 3
of "method": 2
of "variable": 6 of "variable": 6
of "constant": 14 of "constant": 14
of "struct": 22 of "struct": 22
@@ -1822,6 +1863,7 @@ proc handleRename(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
proc symbolKindLsp(kind: string): int = proc symbolKindLsp(kind: string): int =
case kind case kind
of "function": 12 of "function": 12
of "method": 6
of "variable": 13 of "variable": 13
of "constant": 14 of "constant": 14
of "struct": 23 of "struct": 23
@@ -1909,7 +1951,7 @@ proc handleWorkspaceSymbol(stream: FileStream, id: JsonNode, paramsNode: JsonNod
sendResponse(stream, id, arr) sendResponse(stream, id, arr)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Call hierarchy (v0.8) — lightweight textual call graph # Call hierarchy (v0.8 + v0.9 methods) — textual call graph
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
type type
@@ -1921,34 +1963,38 @@ type
callee: string callee: string
line: int line: int
col: int col: int
## Enclosing function name ("" if top-level / unknown) ## Enclosing function/method name ("" if top-level / unknown)
caller: string caller: string
callerUri: string callerUri: string
isMethodCall: bool ## true if site was `.Name(` (receiver call)
proc isCallableKind(kind: string): bool =
kind == "function" or kind == "method"
proc listFunctionSymbols(doc: DocumentState): seq[tuple[name: string, info: SymbolInfo]] = proc listFunctionSymbols(doc: DocumentState): seq[tuple[name: string, info: SymbolInfo]] =
result = @[] result = @[]
ensureAnalyzed(doc) ensureAnalyzed(doc)
for name, info in doc.symbols.pairs: for name, info in doc.symbols.pairs:
if info.kind == "function": if isCallableKind(info.kind):
result.add((name, info)) result.add((name, info))
proc allKnownFuncs(): Table[string, FuncSym] = proc allKnownFuncs(): Table[string, FuncSym] =
## name → first seen FuncSym (workspace + open docs) ## name → first seen FuncSym (workspace + open docs); methods included
result = initTable[string, FuncSym]() result = initTable[string, FuncSym]()
for uri, doc in documents.pairs: for uri, doc in documents.pairs:
for (name, info) in listFunctionSymbols(doc): for (name, info) in listFunctionSymbols(doc):
if not result.hasKey(name): if not result.hasKey(name):
result[name] = FuncSym(name: name, uri: uri, info: info) result[name] = FuncSym(name: name, uri: uri, info: info)
for name, ws in workspaceSymbols.pairs: for name, ws in workspaceSymbols.pairs:
if ws.info.kind == "function" and not result.hasKey(name): if isCallableKind(ws.info.kind) and not result.hasKey(name):
result[name] = FuncSym(name: name, uri: ws.uri, info: ws.info) result[name] = FuncSym(name: name, uri: ws.uri, info: ws.info)
proc enclosingFuncName(doc: DocumentState, line: int): string = proc enclosingFuncName(doc: DocumentState, line: int): string =
## Nearest function whose decl line ≤ line (same file). ## Nearest function/method whose decl line ≤ line (same file).
result = "" result = ""
var best = -1 var best = -1
for name, info in doc.symbols.pairs: for name, info in doc.symbols.pairs:
if info.kind != "function": continue if not isCallableKind(info.kind): continue
if info.line <= line and info.line >= best: if info.line <= line and info.line >= best:
best = info.line best = info.line
result = name result = name
@@ -1975,16 +2021,16 @@ proc collectCallSitesInDoc(doc: DocumentState, known: HashSet[string]): seq[Call
off += h.col off += h.col
if not isCallSiteAt(doc.content, off, h.len): if not isCallSiteAt(doc.content, off, h.len):
continue continue
# Skip the function declaration itself (func Name() — also has () ) # Skip the function/method declaration itself (func Name() — also has () )
if doc.symbols.hasKey(name): if doc.symbols.hasKey(name):
let info = doc.symbols[name] let info = doc.symbols[name]
if info.line == h.line and info.col == h.col: if info.line == h.line and info.col == h.col:
continue continue
# Skip .method-style if access is dot (method calls still useful — keep)
let caller = enclosingFuncName(doc, h.line) let caller = enclosingFuncName(doc, h.line)
result.add(CallSite( result.add(CallSite(
callee: name, line: h.line, col: h.col, callee: name, line: h.line, col: h.col,
caller: caller, callerUri: doc.uri)) caller: caller, callerUri: doc.uri,
isMethodCall: h.access == iaDot))
proc collectAllCallSites(): seq[CallSite] = proc collectAllCallSites(): seq[CallSite] =
result = @[] result = @[]
@@ -2031,9 +2077,14 @@ proc collectAllCallSites(): seq[CallSite] =
proc callHierarchyItem(fs: FuncSym): JsonNode = proc callHierarchyItem(fs: FuncSym): JsonNode =
let nameLen = fs.name.len let nameLen = fs.name.len
# SymbolKind: Method=6, Function=12
let sk = if fs.info.kind == "method": 6 else: 12
var name = fs.name
if fs.info.kind == "method" and fs.info.container.len > 0:
name = fs.info.container & "." & fs.name
%*{ %*{
"name": fs.name, "name": name,
"kind": 12, # SymbolKind.Function "kind": sk,
"detail": fs.info.detail, "detail": fs.info.detail,
"uri": fs.uri, "uri": fs.uri,
"range": { "range": {
@@ -2043,25 +2094,30 @@ proc callHierarchyItem(fs: FuncSym): JsonNode =
"selectionRange": { "selectionRange": {
"start": {"line": fs.info.line, "character": fs.info.col}, "start": {"line": fs.info.line, "character": fs.info.col},
"end": {"line": fs.info.line, "character": fs.info.col + nameLen} "end": {"line": fs.info.line, "character": fs.info.col + nameLen}
} },
"data": fs.name # bare name for graph matching
} }
proc lookupFuncSym(name, uri: string): FuncSym = proc lookupFuncSym(name, uri: string): FuncSym =
result = FuncSym(name: name, uri: uri) ## Resolve by bare name (Area) or qualified display (Rectangle.Area).
var bare = name
let dot = name.rfind('.')
if dot >= 0:
bare = name[dot + 1 .. ^1]
result = FuncSym(name: bare, uri: uri)
if documents.hasKey(uri): if documents.hasKey(uri):
let doc = documents[uri] let doc = documents[uri]
ensureAnalyzed(doc) ensureAnalyzed(doc)
if doc.symbols.hasKey(name) and doc.symbols[name].kind == "function": if doc.symbols.hasKey(bare) and isCallableKind(doc.symbols[bare].kind):
result.info = doc.symbols[name] result.info = doc.symbols[bare]
return return
if workspaceSymbols.hasKey(name) and workspaceSymbols[name].info.kind == "function": if workspaceSymbols.hasKey(bare) and isCallableKind(workspaceSymbols[bare].info.kind):
result.uri = workspaceSymbols[name].uri result.uri = workspaceSymbols[bare].uri
result.info = workspaceSymbols[name].info result.info = workspaceSymbols[bare].info
return return
# Fallback from allKnownFuncs
let all = allKnownFuncs() let all = allKnownFuncs()
if all.hasKey(name): if all.hasKey(bare):
return all[name] return all[bare]
proc handlePrepareCallHierarchy(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = proc handlePrepareCallHierarchy(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
let uri = paramsNode["textDocument"]["uri"].getStr() let uri = paramsNode["textDocument"]["uri"].getStr()
@@ -2077,17 +2133,17 @@ proc handlePrepareCallHierarchy(stream: FileStream, id: JsonNode, paramsNode: Js
if word.len == 0: if word.len == 0:
sendResponse(stream, id, %*[]) sendResponse(stream, id, %*[])
return return
# Prefer function symbol under cursor # Prefer function/method symbol under cursor
if doc.symbols.hasKey(word) and doc.symbols[word].kind == "function": if doc.symbols.hasKey(word) and isCallableKind(doc.symbols[word].kind):
let fs = FuncSym(name: word, uri: uri, info: doc.symbols[word]) let fs = FuncSym(name: word, uri: uri, info: doc.symbols[word])
sendResponse(stream, id, %*[callHierarchyItem(fs)]) sendResponse(stream, id, %*[callHierarchyItem(fs)])
return return
if workspaceSymbols.hasKey(word) and workspaceSymbols[word].info.kind == "function": if workspaceSymbols.hasKey(word) and isCallableKind(workspaceSymbols[word].info.kind):
let ws = workspaceSymbols[word] let ws = workspaceSymbols[word]
let fs = FuncSym(name: word, uri: ws.uri, info: ws.info) let fs = FuncSym(name: word, uri: ws.uri, info: ws.info)
sendResponse(stream, id, %*[callHierarchyItem(fs)]) sendResponse(stream, id, %*[callHierarchyItem(fs)])
return return
# Allow prepare on a call site: Foo( → hierarchy for Foo # Call site: Foo( or .Method(
let lines = doc.content.split("\n") let lines = doc.content.split("\n")
if lineNum < lines.len: if lineNum < lines.len:
let l = lines[lineNum] let l = lines[lineNum]
@@ -2105,13 +2161,24 @@ proc handlePrepareCallHierarchy(stream: FileStream, id: JsonNode, paramsNode: Js
return return
sendResponse(stream, id, %*[]) sendResponse(stream, id, %*[])
proc bareCallName(itemName: string): string =
## "Rectangle.Area" → "Area"; "Add" → "Add"
let dot = itemName.rfind('.')
if dot >= 0: return itemName[dot + 1 .. ^1]
# Prefer data field if present (from our CallHierarchyItem)
result = itemName
proc handleIncomingCalls(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = proc handleIncomingCalls(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
## Who calls this function? ## Who calls this function/method?
if not paramsNode.hasKey("item"): if not paramsNode.hasKey("item"):
sendResponse(stream, id, %*[]) sendResponse(stream, id, %*[])
return return
let item = paramsNode["item"] let item = paramsNode["item"]
let name = item["name"].getStr() var name = item["name"].getStr()
if item.hasKey("data") and item["data"].kind == JString:
name = item["data"].getStr()
else:
name = bareCallName(name)
let sites = collectAllCallSites() let sites = collectAllCallSites()
# Group by caller # Group by caller
var groups = initTable[string, tuple[fs: FuncSym, ranges: seq[tuple[line, col, len: int]]]]() var groups = initTable[string, tuple[fs: FuncSym, ranges: seq[tuple[line, col, len: int]]]]()
@@ -2139,12 +2206,16 @@ proc handleIncomingCalls(stream: FileStream, id: JsonNode, paramsNode: JsonNode)
sendResponse(stream, id, arr) sendResponse(stream, id, arr)
proc handleOutgoingCalls(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = proc handleOutgoingCalls(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
## What does this function call? ## What does this function/method call?
if not paramsNode.hasKey("item"): if not paramsNode.hasKey("item"):
sendResponse(stream, id, %*[]) sendResponse(stream, id, %*[])
return return
let item = paramsNode["item"] let item = paramsNode["item"]
let name = item["name"].getStr() var name = item["name"].getStr()
if item.hasKey("data") and item["data"].kind == JString:
name = item["data"].getStr()
else:
name = bareCallName(name)
let uri = if item.hasKey("uri"): item["uri"].getStr() else: "" let uri = if item.hasKey("uri"): item["uri"].getStr() else: ""
let sites = collectAllCallSites() let sites = collectAllCallSites()
# Group by callee among sites whose caller is `name` # Group by callee among sites whose caller is `name`
@@ -2154,10 +2225,8 @@ proc handleOutgoingCalls(stream: FileStream, id: JsonNode, paramsNode: JsonNode)
if uri.len > 0 and s.callerUri != uri: continue if uri.len > 0 and s.callerUri != uri: continue
let key = s.callee let key = s.callee
if not groups.hasKey(key): if not groups.hasKey(key):
let fs = lookupFuncSym(s.callee, s.callerUri)
# Prefer known func uri
let all = allKnownFuncs() let all = allKnownFuncs()
let fs2 = if all.hasKey(s.callee): all[s.callee] else: fs let fs2 = if all.hasKey(s.callee): all[s.callee] else: lookupFuncSym(s.callee, s.callerUri)
groups[key] = (fs2, @[]) groups[key] = (fs2, @[])
groups[key].ranges.add((s.line, s.col, s.callee.len)) groups[key].ranges.add((s.line, s.col, s.callee.len))
@@ -2201,7 +2270,7 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
"workspaceSymbolProvider": true, "workspaceSymbolProvider": true,
"callHierarchyProvider": true "callHierarchyProvider": true
}, },
"serverInfo": {"name": "bux-lsp", "version": "0.8.0"} "serverInfo": {"name": "bux-lsp", "version": "0.9.0"}
}) })
if paramsNode.hasKey("rootPath") and paramsNode["rootPath"].kind != JNull: if paramsNode.hasKey("rootPath") and paramsNode["rootPath"].kind != JNull:
rootPath = paramsNode["rootPath"].getStr() rootPath = paramsNode["rootPath"].getStr()
+2 -2
View File
@@ -57,8 +57,8 @@ if ! grep -q 'callHierarchyProvider' "$TMP/out.txt"; then
cat "$TMP/out.txt" cat "$TMP/out.txt"
exit 1 exit 1
fi fi
if ! grep -q '0.8.0' "$TMP/out.txt"; then if ! grep -qE '0\.[89]\.0' "$TMP/out.txt"; then
echo "WARN: version not 0.8.0" echo "WARN: unexpected bux-lsp version"
fi fi
# prepare should mention Add # prepare should mention Add
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Smoke: method call hierarchy — extend Type + .Method() (bux-lsp 0.9)
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'
struct Point {
x: int;
y: int;
}
extend Point {
func Len(self: Point) -> int {
return self.x + self.y;
}
func Scale(self: Point, n: int) -> int {
return self.Len() * n;
}
}
func Main() -> int {
let p: Point = Point { x: 3, y: 4 };
return p.Scale(2);
}
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"
# Len is around line 5 (0-based: extend block)
# Scale around line 8
# Main line 11
{
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"'}}}'
# prepare on Len method
rpc '{"jsonrpc":"2.0","id":2,"method":"textDocument/prepareCallHierarchy","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":5,"character":9}}}'
# incoming for Len — expect Scale (method call self.Len())
rpc '{"jsonrpc":"2.0","id":3,"method":"callHierarchy/incomingCalls","params":{"item":{"name":"Point.Len","kind":6,"data":"Len","uri":"'"$URI"'","range":{"start":{"line":5,"character":0},"end":{"line":5,"character":12}},"selectionRange":{"start":{"line":5,"character":9},"end":{"line":5,"character":12}}}}}'
# outgoing for Scale — expect Len
rpc '{"jsonrpc":"2.0","id":4,"method":"callHierarchy/outgoingCalls","params":{"item":{"name":"Point.Scale","kind":6,"data":"Scale","uri":"'"$URI"'","range":{"start":{"line":8,"character":0},"end":{"line":8,"character":14}},"selectionRange":{"start":{"line":8,"character":9},"end":{"line":8,"character":14}}}}}'
# incoming for Scale — expect Main
rpc '{"jsonrpc":"2.0","id":5,"method":"callHierarchy/incomingCalls","params":{"item":{"name":"Point.Scale","kind":6,"data":"Scale","uri":"'"$URI"'","range":{"start":{"line":8,"character":0},"end":{"line":8,"character":14}},"selectionRange":{"start":{"line":8,"character":9},"end":{"line":8,"character":14}}}}}'
rpc '{"jsonrpc":"2.0","id":6,"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 '0.9.0' "$TMP/out.txt"; then
echo "WARN: version not 0.9.0"
fi
# prepare should return method (Point.Len or Len)
if ! grep -qE '"name":"(Point\.)?Len"' "$TMP/out.txt"; then
echo "FAIL: prepare did not return Len method"
cat "$TMP/out.txt"
exit 1
fi
# Scale calls Len
if ! grep -qE '"name":"(Point\.)?Scale"' "$TMP/out.txt"; then
echo "FAIL: missing Scale in hierarchy"
cat "$TMP/out.txt"
exit 1
fi
# Main calls Scale
if ! grep -q '"name":"Main"' "$TMP/out.txt"; then
echo "FAIL: incoming Scale should include Main"
cat "$TMP/out.txt"
exit 1
fi
# kind 6 = Method somewhere
if ! grep -q '"kind":6' "$TMP/out.txt"; then
echo "FAIL: expected SymbolKind.Method (6)"
exit 1
fi
echo "PASS: LSP method call hierarchy (extend + .Method)"