From 8f645ad68b76be71a3c1138259d830c167322e7a Mon Sep 17 00:00:00 2001 From: dimgigov Date: Sun, 19 Jul 2026 23:22:13 +0300 Subject: [PATCH] feat(lsp): method call hierarchy for extend Type / .Method (0.9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Makefile | 3 + docs/QUALITY_PLAN.md | 2 +- tools/lsp_server.nim | 139 +++++++++++++++++++++------- tools/smoke_lsp_call_hierarchy.sh | 4 +- tools/smoke_lsp_method_hierarchy.sh | 92 ++++++++++++++++++ 5 files changed, 202 insertions(+), 38 deletions(-) create mode 100755 tools/smoke_lsp_method_hierarchy.sh diff --git a/Makefile b/Makefile index a859870..c5f6a7b 100644 --- a/Makefile +++ b/Makefile @@ -202,6 +202,9 @@ test-lsp: lsp @echo "=== LSP call hierarchy smoke ===" @chmod +x 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 test-registry: build diff --git a/docs/QUALITY_PLAN.md b/docs/QUALITY_PLAN.md index 8b46dc3..444ce7f 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 — 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. --- diff --git a/tools/lsp_server.nim b/tools/lsp_server.nim index a852b6c..f33aaf3 100644 --- a/tools/lsp_server.nim +++ b/tools/lsp_server.nim @@ -10,6 +10,7 @@ # 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). +# v0.9.0: method call hierarchy (extend Type / .Method() sites). import std/[json, os, strutils, streams, tables, osproc, sequtils, sets] import lexer, parser, ast, sema, types, scope, source_location @@ -313,6 +314,11 @@ proc analyzeFile(path: string, content: string): DocumentState = var inString = false var stringDelim = '\0' 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: let c = content[i] @@ -358,12 +364,41 @@ proc analyzeFile(path: string, content: string): DocumentState = inc i 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 template atWord(kw: string): bool = (i + kw.len <= content.len and content[i ..< i + kw.len] == kw and (i == 0 or not isIdentChar(content[i - 1])) and (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"): let kwPos = i i += 4 @@ -399,8 +434,13 @@ proc analyzeFile(path: string, content: string): DocumentState = var sig = content[kwPos ..< min(sigEnd, content.len)].strip() # collapse whitespace 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( - line: line, col: col, kind: "function", detail: sig, container: "")) + line: line, col: col, kind: kind, detail: sig, container: container)) continue if atWord("let") or atWord("var") or atWord("const"): @@ -1091,6 +1131,7 @@ proc ensureAnalyzed(doc: DocumentState) = proc completionKind(kind: string): int = case kind of "function": 3 + of "method": 2 of "variable": 6 of "constant": 14 of "struct": 22 @@ -1822,6 +1863,7 @@ proc handleRename(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = proc symbolKindLsp(kind: string): int = case kind of "function": 12 + of "method": 6 of "variable": 13 of "constant": 14 of "struct": 23 @@ -1909,7 +1951,7 @@ proc handleWorkspaceSymbol(stream: FileStream, id: JsonNode, paramsNode: JsonNod sendResponse(stream, id, arr) # --------------------------------------------------------------------------- -# Call hierarchy (v0.8) — lightweight textual call graph +# Call hierarchy (v0.8 + v0.9 methods) — textual call graph # --------------------------------------------------------------------------- type @@ -1921,34 +1963,38 @@ type callee: string line: int col: int - ## Enclosing function name ("" if top-level / unknown) + ## Enclosing function/method name ("" if top-level / unknown) caller: 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]] = result = @[] ensureAnalyzed(doc) for name, info in doc.symbols.pairs: - if info.kind == "function": + if isCallableKind(info.kind): result.add((name, info)) 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]() 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): + if isCallableKind(ws.info.kind) 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). + ## Nearest function/method whose decl line ≤ line (same file). result = "" var best = -1 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: best = info.line result = name @@ -1975,16 +2021,16 @@ proc collectCallSitesInDoc(doc: DocumentState, known: HashSet[string]): seq[Call off += h.col if not isCallSiteAt(doc.content, off, h.len): 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): 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)) + caller: caller, callerUri: doc.uri, + isMethodCall: h.access == iaDot)) proc collectAllCallSites(): seq[CallSite] = result = @[] @@ -2031,9 +2077,14 @@ proc collectAllCallSites(): seq[CallSite] = proc callHierarchyItem(fs: FuncSym): JsonNode = 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, - "kind": 12, # SymbolKind.Function + "name": name, + "kind": sk, "detail": fs.info.detail, "uri": fs.uri, "range": { @@ -2043,25 +2094,30 @@ proc callHierarchyItem(fs: FuncSym): JsonNode = "selectionRange": { "start": {"line": fs.info.line, "character": fs.info.col}, "end": {"line": fs.info.line, "character": fs.info.col + nameLen} - } + }, + "data": fs.name # bare name for graph matching } 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): let doc = documents[uri] ensureAnalyzed(doc) - if doc.symbols.hasKey(name) and doc.symbols[name].kind == "function": - result.info = doc.symbols[name] + if doc.symbols.hasKey(bare) and isCallableKind(doc.symbols[bare].kind): + result.info = doc.symbols[bare] return - if workspaceSymbols.hasKey(name) and workspaceSymbols[name].info.kind == "function": - result.uri = workspaceSymbols[name].uri - result.info = workspaceSymbols[name].info + if workspaceSymbols.hasKey(bare) and isCallableKind(workspaceSymbols[bare].info.kind): + result.uri = workspaceSymbols[bare].uri + result.info = workspaceSymbols[bare].info return - # Fallback from allKnownFuncs let all = allKnownFuncs() - if all.hasKey(name): - return all[name] + if all.hasKey(bare): + return all[bare] proc handlePrepareCallHierarchy(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = let uri = paramsNode["textDocument"]["uri"].getStr() @@ -2077,17 +2133,17 @@ proc handlePrepareCallHierarchy(stream: FileStream, id: JsonNode, paramsNode: Js if word.len == 0: sendResponse(stream, id, %*[]) return - # Prefer function symbol under cursor - if doc.symbols.hasKey(word) and doc.symbols[word].kind == "function": + # Prefer function/method symbol under cursor + if doc.symbols.hasKey(word) and isCallableKind(doc.symbols[word].kind): 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": + if workspaceSymbols.hasKey(word) and isCallableKind(workspaceSymbols[word].info.kind): 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 + # Call site: Foo( or .Method( let lines = doc.content.split("\n") if lineNum < lines.len: let l = lines[lineNum] @@ -2105,13 +2161,24 @@ proc handlePrepareCallHierarchy(stream: FileStream, id: JsonNode, paramsNode: Js return 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) = - ## Who calls this function? + ## Who calls this function/method? if not paramsNode.hasKey("item"): sendResponse(stream, id, %*[]) return 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() # Group by caller 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) proc handleOutgoingCalls(stream: FileStream, id: JsonNode, paramsNode: JsonNode) = - ## What does this function call? + ## What does this function/method call? if not paramsNode.hasKey("item"): sendResponse(stream, id, %*[]) return 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 sites = collectAllCallSites() # 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 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 + let fs2 = if all.hasKey(s.callee): all[s.callee] else: lookupFuncSym(s.callee, s.callerUri) groups[key] = (fs2, @[]) groups[key].ranges.add((s.line, s.col, s.callee.len)) @@ -2201,7 +2270,7 @@ proc handleMessage(stream: FileStream, msg: JsonNode) = "workspaceSymbolProvider": 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: rootPath = paramsNode["rootPath"].getStr() diff --git a/tools/smoke_lsp_call_hierarchy.sh b/tools/smoke_lsp_call_hierarchy.sh index c6c1d00..e0fc222 100755 --- a/tools/smoke_lsp_call_hierarchy.sh +++ b/tools/smoke_lsp_call_hierarchy.sh @@ -57,8 +57,8 @@ if ! grep -q 'callHierarchyProvider' "$TMP/out.txt"; then cat "$TMP/out.txt" exit 1 fi -if ! grep -q '0.8.0' "$TMP/out.txt"; then - echo "WARN: version not 0.8.0" +if ! grep -qE '0\.[89]\.0' "$TMP/out.txt"; then + echo "WARN: unexpected bux-lsp version" fi # prepare should mention Add diff --git a/tools/smoke_lsp_method_hierarchy.sh b/tools/smoke_lsp_method_hierarchy.sh new file mode 100755 index 0000000..669d170 --- /dev/null +++ b/tools/smoke_lsp_method_hierarchy.sh @@ -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)"