diff --git a/Makefile b/Makefile index c5f6a7b..2722dfb 100644 --- a/Makefile +++ b/Makefile @@ -205,6 +205,9 @@ test-lsp: lsp @echo "=== LSP method call hierarchy smoke ===" @chmod +x tools/smoke_lsp_method_hierarchy.sh @tools/smoke_lsp_method_hierarchy.sh + @echo "=== LSP method/type/receiver rename smoke ===" + @chmod +x tools/smoke_lsp_rename_method.sh + @tools/smoke_lsp_rename_method.sh .PHONY: test-registry test-registry: build diff --git a/docs/QUALITY_PLAN.md b/docs/QUALITY_PLAN.md index 444ce7f..5306301 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, **LSP 0.9 method hierarchy**, Nexus KA +> **Текущо:** v0.5.x — multi-file #line, selfhost CI, **LSP 0.10 method/type rename**, Nexus KA > **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain. --- @@ -78,7 +78,7 @@ | # | Задача | Защо | Статус | |---|--------|------|--------| -| D.1 | LSP: hover, go-to-def, diagnostics | IDE = adoption | ✅ v0.8.0: + **call hierarchy** + deeper rename + workspace/symbol | +| D.1 | LSP: hover, go-to-def, diagnostics | IDE = adoption | ✅ v0.9.0: + **method call hierarchy** (extend / `.Method`) | | 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` | @@ -682,9 +682,24 @@ A (stdlib ergonomics) → B (compiler holes) → C (ownership depth) --- +## Сесия 43 (LSP 0.9 method call hierarchy) + +1. **Index methods** in `analyzeFile`: + - Track `extend Type` / `impl Type` brace body + - `func` inside → kind `method`, container `Type`, detail `Type.func …` +2. **Call graph** includes methods as callables + - `.Method(` sites (iaDot) + free `Func(` calls + - CallHierarchyItem: SymbolKind.Method (6), display `Type.Method` + - `data` field keeps bare name for graph match +3. Smoke: `tools/smoke_lsp_method_hierarchy.sh` + - `Scale` → `Len`; `Main` → `Scale`; prepare on method +4. Version **bux-lsp 0.9.0**; `make test-lsp` + +--- + ## Следващи стъпки -1. Method call hierarchy (receiver methods / interface dispatch) -2. Rename of method receivers / qualified module paths (edge cases) +1. Rename of method receivers / qualified module paths (edge cases) +2. Interface dispatch in call hierarchy (dynamic) 3. HirNode-level file for statements spanning multiple files (rare) 4. Optional: selfhost-loop as optional CI job (slow) diff --git a/tools/lsp_server.nim b/tools/lsp_server.nim index f33aaf3..1df0ab8 100644 --- a/tools/lsp_server.nim +++ b/tools/lsp_server.nim @@ -11,6 +11,7 @@ # 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). +# v0.10.0: method rename + qualified path / extend Type rename edges. import std/[json, os, strutils, streams, tables, osproc, sequtils, sets] import lexer, parser, ast, sema, types, scope, source_location @@ -1412,19 +1413,22 @@ type access: IdentAccess RenameTargetKind = enum rtkLocal - rtkGlobal - rtkMember + rtkGlobal ## free func / type / const (not method) + rtkMethod ## extend/impl method — decl + .Name( + bare Name( + rtkMember ## struct field / enum variant rtkUnknown RenameTarget = object kind: RenameTargetKind name: string - ## For rtkMember + ## For rtkMember / rtkMethod parent: string memberKind: string declLine: int declCol: int ## For rtkLocal local: LocalBinding + ## For rtkGlobal type symbols + isType: bool proc peekAccessBefore(content: string, start: int): IdentAccess = ## Classify how the identifier at `start` is written. @@ -1585,50 +1589,135 @@ proc lookupMemberAt(doc: DocumentState, name: string, line, col: int): tuple[ok: result.ok = true result.m = last +proc absOffset(content: string, line, col: int): int = + let lines = content.split("\n") + result = 0 + for li in 0 ..< min(line, lines.len): + result += lines[li].len + 1 + result += col + +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 isCallableKind(kind: string): bool = + kind == "function" or kind == "method" + proc classifyRenameTarget(doc: DocumentState, word: string, line, col: int): RenameTarget = result.kind = rtkUnknown result.name = word + result.isType = false ensureAnalyzed(doc) if doc.locals.len == 0 and doc.content.len > 0: enrichWithSema(doc) + let lines = doc.content.split("\n") + var wordStart = col + if line >= 0 and line < lines.len: + wordStart = min(col, lines[line].len) + while wordStart > 0 and lines[line][wordStart - 1] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}: + dec wordStart + let absStart = absOffset(doc.content, line, wordStart) + let acc = peekAccessBefore(doc.content, absStart) + # 1) Type member (field/variant) when cursor is on decl or qualified access let (mok, mem) = lookupMemberAt(doc, word, line, col) if mok: - # Prefer member over local if this is clearly a member access or member decl - let lines = doc.content.split("\n") - var isMemberCtx = false - if line >= 0 and line < lines.len: - let l = lines[line] - var ws = min(col, l.len) - while ws > 0 and l[ws - 1] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}: - dec ws - let absStart = block: - var off = 0 - for li in 0 ..< line: - off += lines[li].len + 1 - off + ws - let acc = peekAccessBefore(doc.content, absStart) - isMemberCtx = acc in {iaDot, iaColonColon, iaFieldInit} or - (mem.line == line and mem.col == ws) - if isMemberCtx or not lookupLocalAt(doc, word, line).ok: - result.kind = rtkMember - result.parent = mem.parent - result.memberKind = mem.kind - result.declLine = mem.line - result.declCol = mem.col - return + var isMemberCtx = acc in {iaDot, iaColonColon, iaFieldInit} or + (mem.line == line and mem.col == wordStart) + # Don't treat method calls as field members + let isMethodSym = doc.symbols.hasKey(word) and doc.symbols[word].kind == "method" + if isMethodSym and acc == iaDot and isCallSiteAt(doc.content, absStart, word.len): + isMemberCtx = false + if isMemberCtx or (not lookupLocalAt(doc, word, line).ok and not isMethodSym): + if not isMethodSym: + result.kind = rtkMember + result.parent = mem.parent + result.memberKind = mem.kind + result.declLine = mem.line + result.declCol = mem.col + return - # 2) Local / param (scoped) + # 2) Method (extend/impl) — decl or .Method( / bare Method( + if doc.symbols.hasKey(word) and doc.symbols[word].kind == "method": + let info = doc.symbols[word] + result.kind = rtkMethod + result.parent = info.container + result.declLine = info.line + result.declCol = info.col + return + if workspaceSymbols.hasKey(word) and workspaceSymbols[word].info.kind == "method": + let info = workspaceSymbols[word].info + result.kind = rtkMethod + result.parent = info.container + result.declLine = info.line + result.declCol = info.col + return + # Call site on method: .Area( or Area( + if isCallSiteAt(doc.content, absStart, word.len): + if doc.symbols.hasKey(word) and isCallableKind(doc.symbols[word].kind): + if doc.symbols[word].kind == "method" or acc == iaDot: + # Prefer method if registered as method; .Name( may be method even if only function table + if doc.symbols.hasKey(word): + let info = doc.symbols[word] + if info.kind == "method": + result.kind = rtkMethod + result.parent = info.container + result.declLine = info.line + result.declCol = info.col + return + + # 3) Local / param (scoped) — including `self` receiver let (lok, lb) = lookupLocalAt(doc, word, line) if lok: result.kind = rtkLocal result.local = lb return - # 3) Global / type / function + # 3b) Synthetic `self` when inside a method (sema may omit method params) + if word == "self": + let lines = doc.content.split("\n") + var ms = 0 + var me = lines.len - 1 + var li = min(line, lines.len - 1) + while li >= 0: + let t = lines[li].strip() + if t.startsWith("func ") or t.startsWith("pub func "): + ms = li + break + dec li + li = ms + 1 + while li < lines.len: + let t = lines[li].strip() + if t.startsWith("func ") or t.startsWith("pub func "): + me = li - 1 + break + inc li + # Find col of "self" on the func line if present + var sc = col + if ms >= 0 and ms < lines.len: + let idx = lines[ms].find("self") + if idx >= 0: sc = idx + result.kind = rtkLocal + result.local = LocalBinding( + name: "self", detail: "param self", kind: "parameter", + declLine: ms, declCol: sc, + scopeStartLine: ms, scopeEndLine: me, + container: "", inferred: false) + return + + # 4) Global / type / free function if doc.symbols.hasKey(word) or doc.typeIndex.hasKey(word) or workspaceSymbols.hasKey(word): result.kind = rtkGlobal + if doc.symbols.hasKey(word): + let k = doc.symbols[word].kind + result.isType = k in ["struct", "enum", "union", "interface", "type"] + elif workspaceSymbols.hasKey(word): + let k = workspaceSymbols[word].info.kind + result.isType = k in ["struct", "enum", "union", "interface", "type"] return result.kind = rtkUnknown @@ -1643,6 +1732,27 @@ proc hitMatchesMember(h: IdentHit, m: MemberInfo): bool = of iaBare: return false +proc hitMatchesMethod(content: string, h: IdentHit, name: string, declLine, declCol: int): bool = + ## Method rename: decl, .Name(, bare Name( — not bare non-call idents. + if h.line == declLine and h.col == declCol: + return true + let off = absOffset(content, h.line, h.col) + if not isCallSiteAt(content, off, name.len): + return false + # .Method( or free Method( + return h.access in {iaDot, iaBare} + +proc hitMatchesType(content: string, h: IdentHit, name: string, declLine, declCol: int): bool = + ## Type rename: decl, bare Type, Type::Variant, extend Type, self: Type — not .field + if h.line == declLine and h.col == declCol: + return true + if h.access == iaDot: + return false # obj.Type would be weird; skip field-like + # bare or Type:: (iaColonColon is the *second* segment; first segment is bare) + if h.access in {iaBare, iaColonColon, iaFieldInit}: + return true + return false + proc collectReferences(doc: DocumentState, word: string, lineNum: int, includeDecl: bool, col: int = 0): seq[JsonNode] = ## Collect LSP Location nodes for references at `word` on `lineNum`. @@ -1657,15 +1767,52 @@ proc collectReferences(doc: DocumentState, word: string, lineNum: int, case target.kind of rtkLocal: + # Always clip to the textual function/method body containing the cursor. + # Find the previous line that starts a `func` and the next such line. + let lines = doc.content.split("\n") + var methodStart = 0 + var methodEnd = lines.len - 1 + var li = min(lineNum, lines.len - 1) + while li >= 0: + let t = lines[li].strip() + if t.startsWith("func ") or t.startsWith("pub func "): + methodStart = li + break + li -= 1 + li = methodStart + 1 + while li < lines.len: + let t = lines[li].strip() + if t.startsWith("func ") or t.startsWith("pub func "): + methodEnd = li - 1 + break + li += 1 for h in hits: - let (ok, b) = lookupLocalAt(doc, word, h.line) - if not ok or not sameLocal(b, target.local): + if h.line < methodStart or h.line > methodEnd: continue if not includeDecl and h.line == target.local.declLine and h.col == target.local.declCol: continue result.add(locationJson(doc.uri, h.line, h.col, h.len)) return + of rtkMethod: + for h in hits: + if not hitMatchesMethod(doc.content, h, word, target.declLine, target.declCol): + continue + if not includeDecl and h.line == target.declLine and h.col == target.declCol: + continue + result.add(locationJson(doc.uri, h.line, h.col, h.len)) + # Workspace other files + var seenUri = initHashSet[string]() + seenUri.incl(doc.uri) + for u, d in documents.pairs: + if d.content.len == 0 or seenUri.contains(u): continue + seenUri.incl(u) + ensureAnalyzed(d) + for h in collectIdentHits(d.content, word): + if hitMatchesMethod(d.content, h, word, -1, -1): + result.add(locationJson(u, h.line, h.col, h.len)) + return + of rtkMember: let m = MemberInfo( name: target.name, parent: target.parent, kind: target.memberKind, @@ -1673,10 +1820,14 @@ proc collectReferences(doc: DocumentState, word: string, lineNum: int, for h in hits: if not hitMatchesMember(h, m): continue + # Don't rename method calls that share a field name + let off = absOffset(doc.content, h.line, h.col) + if h.access == iaDot and isCallSiteAt(doc.content, off, word.len): + if doc.symbols.hasKey(word) and doc.symbols[word].kind == "method": + continue if not includeDecl and h.line == m.line and h.col == m.col: continue result.add(locationJson(doc.uri, h.line, h.col, h.len)) - # Workspace: other files may reference Parent::name or .name var seenUri = initHashSet[string]() seenUri.incl(doc.uri) for u, d in documents.pairs: @@ -1693,19 +1844,23 @@ proc collectReferences(doc: DocumentState, word: string, lineNum: int, let isWsSym = workspaceSymbols.hasKey(word) if not isFileSym and not isWsSym and target.kind == rtkUnknown: for h in hits: - # Unknown bare rename: only bare idents (avoid eating .x members) if h.access == iaBare: result.add(locationJson(doc.uri, h.line, h.col, h.len)) return + let declLine = if doc.symbols.hasKey(word): doc.symbols[word].line else: -1 + let declCol = if doc.symbols.hasKey(word): doc.symbols[word].col else: -1 + for h in hits: - if not includeDecl and doc.symbols.hasKey(word): - let info = doc.symbols[word] - if h.line == info.line and h.col == info.col: - continue - # Global type/func rename: bare + ::qualified, not .member of other types - if h.access == iaDot: + if not includeDecl and h.line == declLine and h.col == declCol: continue + if target.isType: + if not hitMatchesType(doc.content, h, word, declLine, declCol): + continue + else: + # Free function: bare + call; skip .member (fields/methods) + if h.access == iaDot: + continue result.add(locationJson(doc.uri, h.line, h.col, h.len)) if isWsSym or isFileSym: @@ -1715,7 +1870,10 @@ proc collectReferences(doc: DocumentState, word: string, lineNum: int, if d.content.len == 0 or seenUri.contains(u): continue seenUri.incl(u) for h in collectIdentHits(d.content, word): - if h.access == iaDot: continue + if target.isType: + if h.access == iaDot: continue + else: + if h.access == iaDot: continue result.add(locationJson(u, h.line, h.col, h.len)) if rootPath.len > 0 and dirExists(rootPath): var stack: seq[tuple[dir: string, depth: int]] = @[(rootPath, 0)] @@ -1968,9 +2126,6 @@ type 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) @@ -1999,13 +2154,6 @@ proc enclosingFuncName(doc: DocumentState, line: int): string = 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) @@ -2270,7 +2418,7 @@ proc handleMessage(stream: FileStream, msg: JsonNode) = "workspaceSymbolProvider": true, "callHierarchyProvider": true }, - "serverInfo": {"name": "bux-lsp", "version": "0.9.0"} + "serverInfo": {"name": "bux-lsp", "version": "0.10.0"} }) if paramsNode.hasKey("rootPath") and paramsNode["rootPath"].kind != JNull: rootPath = paramsNode["rootPath"].getStr() diff --git a/tools/smoke_lsp_method_hierarchy.sh b/tools/smoke_lsp_method_hierarchy.sh index 669d170..7876736 100755 --- a/tools/smoke_lsp_method_hierarchy.sh +++ b/tools/smoke_lsp_method_hierarchy.sh @@ -58,8 +58,8 @@ URI="file://$TMP/Main.bux" 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" +if ! grep -qE '0\.(9|10)\.0' "$TMP/out.txt"; then + echo "WARN: unexpected bux-lsp version" fi # prepare should return method (Point.Len or Len) diff --git a/tools/smoke_lsp_rename_deep.sh b/tools/smoke_lsp_rename_deep.sh index 777a1f0..b495280 100755 --- a/tools/smoke_lsp_rename_deep.sh +++ b/tools/smoke_lsp_rename_deep.sh @@ -53,8 +53,8 @@ URI="file://$TMP/Main.bux" rpc '{"jsonrpc":"2.0","method":"exit","params":null}' } | "$LSP" 2>/dev/null | tr '\r' '\n' > "$TMP/out.txt" -if ! grep -q '0.7.0' "$TMP/out.txt"; then - echo "WARN: version not 0.7.0" +if ! grep -qE '0\.(7|8|9|10)\.0' "$TMP/out.txt"; then + echo "WARN: unexpected bux-lsp version" fi # Field rename x → px: expect ≥3 (decl, init x:, access p.x) diff --git a/tools/smoke_lsp_rename_method.sh b/tools/smoke_lsp_rename_method.sh new file mode 100755 index 0000000..71de822 --- /dev/null +++ b/tools/smoke_lsp_rename_method.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Smoke: method rename + type/extend rename (bux-lsp 0.10) +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 }; + let a: int = p.Scale(2); + return a; +} +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 method decl ~ line 5 character 9 +# Point type decl line 0 character 7 +# self on line 5 ~ character 13 (param) +{ + 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"'}}}' + # rename method Len → Length + rpc '{"jsonrpc":"2.0","id":2,"method":"textDocument/rename","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":5,"character":9},"newName":"Length"}}' + # rename type Point → Vec2 (decl) + rpc '{"jsonrpc":"2.0","id":3,"method":"textDocument/rename","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":0,"character":7},"newName":"Vec2"}}' + # rename self receiver param in Len — only that method's self + rpc '{"jsonrpc":"2.0","id":4,"method":"textDocument/rename","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":5,"character":13},"newName":"this"}}' + 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 '0.10.0' "$TMP/out.txt"; then + echo "WARN: version not 0.10.0" +fi + +# Len → Length: decl + self.Len() in Scale (≥2) +len_edits=$(grep -o '"newText":"Length"' "$TMP/out.txt" | wc -l) +if [[ "$len_edits" -lt 2 ]]; then + echo "FAIL: method Len→Length expected ≥2 edits, got $len_edits" + cat "$TMP/out.txt" + exit 1 +fi +echo " method Len→Length edits: $len_edits" + +# Point → Vec2: struct, extend, type annotations, constructor (≥4) +pt_edits=$(grep -o '"newText":"Vec2"' "$TMP/out.txt" | wc -l) +if [[ "$pt_edits" -lt 4 ]]; then + echo "FAIL: type Point→Vec2 expected ≥4 edits, got $pt_edits" + cat "$TMP/out.txt" + exit 1 +fi +echo " type Point→Vec2 edits: $pt_edits" + +# self → this: param + body uses in Len only (not Scale's self) +this_edits=$(grep -o '"newText":"this"' "$TMP/out.txt" | wc -l) +if [[ "$this_edits" -lt 2 ]]; then + echo "FAIL: receiver self→this expected ≥2 edits in Len, got $this_edits" + cat "$TMP/out.txt" + exit 1 +fi +# Scale also has self — if we renamed all self, count would be higher (≥4) +if [[ "$this_edits" -gt 3 ]]; then + echo "FAIL: self→this too many edits ($this_edits) — leaked into other methods" + exit 1 +fi +echo " receiver self→this edits: $this_edits" + +echo "PASS: LSP method/type/receiver rename (0.10)"