From 200adf4d68506d9cee91533a2bc34fdd6d2fa10b Mon Sep 17 00:00:00 2001 From: dimgigov Date: Sun, 19 Jul 2026 23:35:54 +0300 Subject: [PATCH] feat(lsp): module-path segment rename for Std::Io imports (0.12) Index import A::B path segments and rename with left-prefix match so Std::Io does not clobber Foo::Io, bare idents, or enum Color::Red. --- Makefile | 3 + docs/QUALITY_PLAN.md | 40 +++++++- tools/lsp_server.nim | 174 ++++++++++++++++++++++++++++++++- tools/smoke_lsp_rename_path.sh | 104 ++++++++++++++++++++ 4 files changed, 315 insertions(+), 6 deletions(-) create mode 100755 tools/smoke_lsp_rename_path.sh diff --git a/Makefile b/Makefile index 86d0e2e..ba06c70 100644 --- a/Makefile +++ b/Makefile @@ -211,6 +211,9 @@ test-lsp: lsp @echo "=== LSP interface dispatch hierarchy smoke ===" @chmod +x tools/smoke_lsp_iface_hierarchy.sh @tools/smoke_lsp_iface_hierarchy.sh + @echo "==> LSP path rename smoke" + @chmod +x tools/smoke_lsp_rename_path.sh + @tools/smoke_lsp_rename_path.sh .PHONY: test-registry test-registry: build diff --git a/docs/QUALITY_PLAN.md b/docs/QUALITY_PLAN.md index 1aec5d2..8d4512d 100644 --- a/docs/QUALITY_PLAN.md +++ b/docs/QUALITY_PLAN.md @@ -78,7 +78,7 @@ | # | Задача | Защо | Статус | |---|--------|------|--------| -| D.1 | LSP: hover, go-to-def, diagnostics | IDE = adoption | ✅ v0.10.0: + **method/type/self rename** + method hierarchy | +| D.1 | LSP: hover, go-to-def, diagnostics | IDE = adoption | ✅ v0.11.0: + **interface dispatch hierarchy** | | 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` | @@ -710,9 +710,39 @@ A (stdlib ergonomics) → B (compiler holes) → C (ownership depth) --- +## Сесия 45 (LSP 0.11 interface dispatch hierarchy) + +1. **Index:** + - `interface I { func M… }` → iface methods + symbol + - `extend Type for I` → `impls` relation + implementor methods +2. **Call hierarchy:** + - prepare on interface method → item with `data: "I#M"`, kind Interface + - **outgoing** on iface method → implementor methods (dispatch targets) + - **incoming** on iface method → callers of `.M(` +3. Smoke: `tools/smoke_lsp_iface_hierarchy.sh` + - Drawable.Draw → Circle implementor; Render → Draw +4. Version **bux-lsp 0.11.0** + +--- + +## Сесия 46 (LSP 0.12 module-path segment rename) + +1. **Index** `import A::B::C` / `import A::B::{…}` path segments (`PathSegInfo`) +2. **`rtkPathSeg` rename** with left-prefix match: + - `Std::Io` → only segments under prefix `Std` (not `Foo::Io`, not bare `Io`) + - path head `Std` only when followed by `::` (not bare locals) + - does not clobber enum `Color::Red` (member path still wins on variants) +3. Last import segment that is also a known symbol falls through to global rename +4. Workspace scan for unopened `.bux` files +5. Smoke: `tools/smoke_lsp_rename_path.sh` + - Io→Net ≥2 (Main+Util), not Foo::Io; Std→Core ≥2; Red→Crimson ≥2 +6. Version **bux-lsp 0.12.0**; `make test-lsp` + +--- + ## Следващи стъпки -1. Interface dispatch in call hierarchy (dynamic) -2. Module-path segment rename (`Std::Io` style imports) -3. HirNode-level file for statements spanning multiple files (rare) -4. Optional: selfhost-loop as optional CI job (slow) +1. HirNode-level file for statements spanning multiple files (rare) +2. Optional: selfhost-loop as optional CI job (slow) +3. LSP find-implementations request (dedicated, beyond call hierarchy) +4. Workspace-wide import path index without open documents (optional polish) diff --git a/tools/lsp_server.nim b/tools/lsp_server.nim index 8460643..73bb73e 100644 --- a/tools/lsp_server.nim +++ b/tools/lsp_server.nim @@ -13,6 +13,7 @@ # v0.9.0: method call hierarchy (extend Type / .Method() sites). # v0.10.0: method rename + qualified path / extend Type rename edges. # v0.11.0: interface dispatch in call hierarchy (extend Type for Trait). +# v0.12.0: module-path segment rename (import Std::Io / Std::Io::{…}). import std/[json, os, strutils, streams, tables, osproc, sequtils, sets] import lexer, parser, ast, sema, types, scope, source_location @@ -102,6 +103,13 @@ type kind: string ## field | variant line: int ## 0-based decl line col: int ## 0-based decl col of member name + ## One segment of an `import A::B::C` / `import A::B::{…}` path (v0.12) + PathSegInfo = object + name: string + line: int ## 0-based + col: int ## 0-based start of segment name + path: seq[string] ## full path including this segment (prefix…name) + index: int ## index of this segment in path ## Scoped local binding for position-sensitive hover / go-to-def LocalBinding = object name: string @@ -130,6 +138,8 @@ type ifaceMethods: seq[MemberInfo] ## Type implements Interface (from `extend Type for Interface`) impls: seq[tuple[typeName, iface: string, line: int]] + ## Import path segments for module-path rename (v0.12) + importPaths: seq[PathSegInfo] var documents = initTable[string, DocumentState]() @@ -336,6 +346,7 @@ proc analyzeFile(path: string, content: string): DocumentState = result.ifaceMethods = @[] result.impls = @[] + result.importPaths = @[] while i < content.len: let c = content[i] @@ -429,6 +440,31 @@ proc analyzeFile(path: string, content: string): DocumentState = pendingInterface = iname continue + # import A::B::C | import A::B::{X, Y} | import A::B::* + if atWord("import"): + i += 6 + skipWs(content, i) + var path: seq[string] = @[] + while i < content.len and isIdentStart(content[i]): + let nameStart = i + let name = readIdent(content, i) + if name.len == 0: + break + let (line, col) = lineColAt(content, nameStart) + path.add(name) + result.importPaths.add(PathSegInfo( + name: name, line: line, col: col, path: path, index: path.len - 1)) + skipWs(content, i) + if i + 1 < content.len and content[i] == ':' and content[i + 1] == ':': + i += 2 + skipWs(content, i) + # stop before multi-import `{` or glob `*` + if i < content.len and (content[i] == '{' or content[i] == '*'): + break + continue + break + continue + # extend Type / impl Type [for Interface] — methods in following { block } if atWord("extend") or atWord("impl"): let kwLen = if content[i] == 'e': 6 else: 4 @@ -1148,6 +1184,7 @@ proc analyzeAndPublishDiagnostics(stream: FileStream, doc: DocumentState) = doc.members = updated.members doc.ifaceMethods = updated.ifaceMethods doc.impls = updated.impls + doc.importPaths = updated.importPaths # Keep / refresh real types for hover (does not replace lightweight outline) enrichWithSema(doc) let diags = runBuxcDiagnostics(path, doc.content) @@ -1200,6 +1237,7 @@ proc ensureAnalyzed(doc: DocumentState) = doc.members = updated.members doc.ifaceMethods = updated.ifaceMethods doc.impls = updated.impls + doc.importPaths = updated.importPaths proc completionKind(kind: string): int = case kind @@ -1488,6 +1526,7 @@ type rtkGlobal ## free func / type / const (not method) rtkMethod ## extend/impl method — decl + .Name( + bare Name( rtkMember ## struct field / enum variant + rtkPathSeg ## module path segment in import / A::B path (v0.12) rtkUnknown RenameTarget = object kind: RenameTargetKind @@ -1501,6 +1540,8 @@ type local: LocalBinding ## For rtkGlobal type symbols isType: bool + ## For rtkPathSeg: segments before this name (e.g. ["Std"] for Io in Std::Io) + pathPrefix: seq[string] proc peekAccessBefore(content: string, start: int): IdentAccess = ## Classify how the identifier at `start` is written. @@ -1678,10 +1719,68 @@ proc isCallSiteAt(content: string, start, nameLen: int): bool = proc isCallableKind(kind: string): bool = kind == "function" or kind == "method" +proc followedByColonColon(content: string, start, nameLen: int): bool = + ## True if ident is followed by optional space then `::`. + var j = start + nameLen + while j < content.len and content[j] in {' ', '\t'}: + inc j + result = j + 1 < content.len and content[j] == ':' and content[j + 1] == ':' + +proc pathPrefixBefore(content: string, start: int): seq[string] = + ## Segments immediately before `start` via `A::B::` (left of the current ident). + result = @[] + var pos = start - 1 + while pos >= 0 and content[pos] in {' ', '\t'}: + dec pos + while pos >= 1 and content[pos] == ':' and content[pos - 1] == ':': + pos -= 2 + while pos >= 0 and content[pos] in {' ', '\t'}: + dec pos + if pos < 0 or not isIdentChar(content[pos]): + break + var e = pos + while pos >= 0 and isIdentChar(content[pos]): + dec pos + result.insert(content[pos + 1 .. e], 0) + +proc pathStartsWith(path, prefix: seq[string]): bool = + if path.len < prefix.len: return false + for i, s in prefix: + if path[i] != s: return false + true + +proc isKnownImportPathPrefix(prefix: seq[string], name: string): bool = + ## True if some open document's import path starts with prefix ++ name. + let full = prefix & name + for _, d in documents.pairs: + if d.importPaths.len == 0 and d.content.len > 0: + # lazy: may not have been copied yet + discard + for seg in d.importPaths: + if pathStartsWith(seg.path, full): + return true + false + +proc hitMatchesPathSeg(content: string, h: IdentHit, prefix: seq[string], + name: string): bool = + ## Match path segment with exact left-prefix (avoids enum Color::X / bare locals). + if h.access in {iaDot, iaFieldInit}: + return false + let off = absOffset(content, h.line, h.col) + let pfx = pathPrefixBefore(content, off) + if pfx != prefix: + return false + if prefix.len == 0: + # Module path head: must introduce a path (`Std::…`), not bare ident + return followedByColonColon(content, off, name.len) + # Middle / tail: written as `…::Name` + return h.access == iaColonColon + proc classifyRenameTarget(doc: DocumentState, word: string, line, col: int): RenameTarget = result.kind = rtkUnknown result.name = word result.isType = false + result.pathPrefix = @[] ensureAnalyzed(doc) if doc.locals.len == 0 and doc.content.len > 0: enrichWithSema(doc) @@ -1742,6 +1841,35 @@ proc classifyRenameTarget(doc: DocumentState, word: string, line, col: int): Ren result.declCol = info.col return + # 2b) Module-path segment (import Std::Io / Std::Io::{…} / matching A::B uses) + # Prefer path rename when cursor sits on an indexed import segment (except last + # segment that is also a known symbol — leave that to global/symbol rename). + for seg in doc.importPaths: + if seg.name != word: continue + if seg.line != line: continue + if col < seg.col or col > seg.col + word.len: continue + let isLast = seg.index == seg.path.len - 1 + let isKnownSym = doc.symbols.hasKey(word) or workspaceSymbols.hasKey(word) or + doc.typeIndex.hasKey(word) + if isLast and isKnownSym: + break # fall through + result.kind = rtkPathSeg + result.name = word + if seg.index > 0: + result.pathPrefix = seg.path[0 ..< seg.index] + else: + result.pathPrefix = @[] + return + # Path use site matching a known import prefix (not enum Type::Variant alone) + let pfx = pathPrefixBefore(doc.content, absStart) + let inPathCtx = (pfx.len == 0 and followedByColonColon(doc.content, absStart, word.len)) or + (pfx.len > 0 and acc == iaColonColon) + if inPathCtx and isKnownImportPathPrefix(pfx, word): + result.kind = rtkPathSeg + result.name = word + result.pathPrefix = pfx + return + # 3) Local / param (scoped) — including `self` receiver let (lok, lb) = lookupLocalAt(doc, word, line) if lok: @@ -1911,6 +2039,49 @@ proc collectReferences(doc: DocumentState, word: string, lineNum: int, result.add(locationJson(u, h.line, h.col, h.len)) return + of rtkPathSeg: + # Rename only path segments with the same left-prefix (Std::Io not Foo::Io). + let pfx = target.pathPrefix + for h in hits: + if hitMatchesPathSeg(doc.content, h, pfx, word): + result.add(locationJson(doc.uri, h.line, h.col, h.len)) + 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 hitMatchesPathSeg(d.content, h, pfx, word): + result.add(locationJson(u, h.line, h.col, h.len)) + # On-disk workspace .bux files not yet opened + 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) + for h in collectIdentHits(text, word): + if hitMatchesPathSeg(text, h, pfx, word): + result.add(locationJson(u, h.line, h.col, h.len)) + except CatchableError: + discard + except CatchableError: + discard + return + of rtkGlobal, rtkUnknown: let isFileSym = doc.symbols.hasKey(word) or doc.typeIndex.hasKey(word) let isWsSym = workspaceSymbols.hasKey(word) @@ -2600,7 +2771,7 @@ proc handleMessage(stream: FileStream, msg: JsonNode) = "workspaceSymbolProvider": true, "callHierarchyProvider": true }, - "serverInfo": {"name": "bux-lsp", "version": "0.11.0"} + "serverInfo": {"name": "bux-lsp", "version": "0.12.0"} }) if paramsNode.hasKey("rootPath") and paramsNode["rootPath"].kind != JNull: rootPath = paramsNode["rootPath"].getStr() @@ -2650,6 +2821,7 @@ proc handleMessage(stream: FileStream, msg: JsonNode) = doc.members = updated.members doc.ifaceMethods = updated.ifaceMethods doc.impls = updated.impls + doc.importPaths = updated.importPaths # Re-apply typeIndex details onto matching names (don't drop sema types mid-edit) for name, detail in doc.typeIndex.pairs: if doc.symbols.hasKey(name): diff --git a/tools/smoke_lsp_rename_path.sh b/tools/smoke_lsp_rename_path.sh new file mode 100755 index 0000000..22f43b0 --- /dev/null +++ b/tools/smoke_lsp_rename_path.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Smoke: module-path segment rename (bux-lsp 0.12) — import Std::Io style +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 + +# Two files share Std::Io imports; also an unrelated Foo::Io and enum Color::Red +# so rename of Io under Std:: must not touch them. +cat > "$TMP/Main.bux" <<'EOF' +import Std::Io::{PrintLine, PrintInt}; +import Foo::Io::Helper; +enum Color { + Red, + Green +} +func Main() -> int { + let c: Color = Color::Red; + PrintLine("hi"); + return 0; +} +EOF + +cat > "$TMP/Util.bux" <<'EOF' +import Std::Io::PrintLine; +func Util() -> int { + return 0; +} +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" +URI2="file://$TMP/Util.bux" +CONTENT2_JSON=$(python3 -c 'import json,sys; print(json.dumps(open(sys.argv[1]).read()))' "$TMP/Util.bux") + +# import Std::Io — "Io" starts after "import Std::" = col 12 on line 0 +# "Std" starts at col 7 on line 0 +{ + 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","method":"textDocument/didOpen","params":{"textDocument":{"uri":"'"$URI2"'","languageId":"bux","version":1,"text":'"$CONTENT2_JSON"'}}}' + # rename Io (module segment under Std) → Net + rpc '{"jsonrpc":"2.0","id":2,"method":"textDocument/rename","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":0,"character":12},"newName":"Net"}}' + # rename Std (path head) → Core — should hit Std:: only + rpc '{"jsonrpc":"2.0","id":3,"method":"textDocument/rename","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":0,"character":7},"newName":"Core"}}' + # rename Red (enum variant) must still work as member — not path + rpc '{"jsonrpc":"2.0","id":4,"method":"textDocument/rename","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":3,"character":4},"newName":"Crimson"}}' + 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.12.0' "$TMP/out.txt"; then + echo "WARN: version not 0.12.0" +fi + +# Io → Net: Main import Std::Io + Util import Std::Io (≥2), NOT Foo::Io +net_edits=$(grep -o '"newText":"Net"' "$TMP/out.txt" | wc -l) +if [[ "$net_edits" -lt 2 ]]; then + echo "FAIL: path Io→Net expected ≥2 edits (Main+Util), got $net_edits" + cat "$TMP/out.txt" + exit 1 +fi +# Must not rewrite Foo::Io — if it did, we'd still only get Net on Io segments. +# Check that a Net edit range is not on the Foo line by ensuring we have exactly +# the Std::Io sites (2) and not 3 (which would include Foo::Io). +if [[ "$net_edits" -ge 3 ]]; then + echo "FAIL: path Io→Net too many edits ($net_edits) — may have hit Foo::Io" + cat "$TMP/out.txt" + exit 1 +fi +echo " path Io→Net edits: $net_edits" + +# Std → Core: Main Std::Io + Util Std::Io (≥2); not Color:: or bare +core_edits=$(grep -o '"newText":"Core"' "$TMP/out.txt" | wc -l) +if [[ "$core_edits" -lt 2 ]]; then + echo "FAIL: path Std→Core expected ≥2 edits, got $core_edits" + cat "$TMP/out.txt" + exit 1 +fi +echo " path Std→Core edits: $core_edits" + +# Red → Crimson: decl + Color::Red use (≥2) +crimson_edits=$(grep -o '"newText":"Crimson"' "$TMP/out.txt" | wc -l) +if [[ "$crimson_edits" -lt 2 ]]; then + echo "FAIL: enum Red→Crimson expected ≥2 (still member rename), got $crimson_edits" + cat "$TMP/out.txt" + exit 1 +fi +echo " enum Red→Crimson edits: $crimson_edits" + +echo "PASS: LSP module-path segment rename (0.12)"