feat(lsp): deeper rename for fields, variants, qualified paths
bux-lsp 0.7 indexes struct fields and enum variants and renames only the right binding: .member / ::Variant / field-init, without clobbering locals. - Member scan in analyzeFile; access kinds bare/dot/colon/field-init - classifyRenameTarget: local | member | global - smoke_lsp_rename_deep + test-lsp wiring
This commit is contained in:
@@ -196,6 +196,9 @@ test-lsp: lsp
|
|||||||
@echo "=== LSP workspace/symbol smoke ==="
|
@echo "=== LSP workspace/symbol smoke ==="
|
||||||
@chmod +x tools/smoke_lsp_workspace.sh
|
@chmod +x tools/smoke_lsp_workspace.sh
|
||||||
@tools/smoke_lsp_workspace.sh
|
@tools/smoke_lsp_workspace.sh
|
||||||
|
@echo "=== LSP deeper rename smoke ==="
|
||||||
|
@chmod +x tools/smoke_lsp_rename_deep.sh
|
||||||
|
@tools/smoke_lsp_rename_deep.sh
|
||||||
|
|
||||||
.PHONY: test-registry
|
.PHONY: test-registry
|
||||||
test-registry: build
|
test-registry: build
|
||||||
|
|||||||
+21
-5
@@ -1,7 +1,7 @@
|
|||||||
# Bux — План към „добър“ език (v0.5 → v1.0)
|
# Bux — План към „добър“ език (v0.5 → v1.0)
|
||||||
|
|
||||||
> **Дата:** 2026-07-19
|
> **Дата:** 2026-07-19
|
||||||
> **Текущо:** v0.5.x — field-move Drop (bootstrap+**selfhost**), selfhost **#line**, Nexus KA, LSP 0.6
|
> **Текущо:** v0.5.x — field-move Drop, selfhost #line, Nexus KA, **LSP 0.7 deeper rename**
|
||||||
> **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain.
|
> **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -610,9 +610,25 @@ A (stdlib ergonomics) → B (compiler holes) → C (ownership depth)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Сесия 38 (selfhost field-move + #line)
|
||||||
|
|
||||||
|
1. **Field-move Drop** (`src/c_backend.bux`):
|
||||||
|
- `CBE_MarkMovedFromNode` on `hStructInit` / return values (nested fields)
|
||||||
|
- Already skipped Drop via `CBE_IsMoved` in defer emit
|
||||||
|
2. **Struct emit fix** (`src/hir_lower.bux`):
|
||||||
|
- Field types `Array<int>` → `Array_int` (etc.) so parent structs are not
|
||||||
|
skipped as “generic” (was incomplete `typedef struct Box Box` only)
|
||||||
|
3. **#line maps** (selfhost C backend):
|
||||||
|
- Emit `#line N "file"` on statements when `node.line > 0`
|
||||||
|
- `BUX_DEBUG_FILE` sets the path; `BUX_NO_LINE=1` disables
|
||||||
|
4. Verified: selfhost `move_field` PASS; Make() has **no** `Array_Drop(&items)`
|
||||||
|
after field move; `#line` points at `.bux` sources
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Следващи стъпки
|
## Следващи стъпки
|
||||||
|
|
||||||
1. Selfhost `#line` maps (parity with bootstrap LIR backend)
|
1. Deeper rename (type members / qualified paths)
|
||||||
2. Selfhost parity for field-move skip Drop (if CBE path differs)
|
2. LSP call hierarchy (optional)
|
||||||
3. Deeper rename (type members / qualified paths)
|
3. Selfhost multi-file `#line` paths without BUX_DEBUG_FILE
|
||||||
4. LSP call hierarchy (optional)
|
4. Wire selfhost smoke for move_field into CI
|
||||||
|
|||||||
+353
-52
@@ -8,6 +8,7 @@
|
|||||||
# Locals are position-sensitive (scoped) and include inferred `let` types (v0.4.0).
|
# Locals are position-sensitive (scoped) and include inferred `let` types (v0.4.0).
|
||||||
# v0.5.0: textDocument/references + rename (scoped locals + workspace globals).
|
# v0.5.0: textDocument/references + rename (scoped locals + workspace globals).
|
||||||
# v0.6.0: workspace/symbol search.
|
# v0.6.0: workspace/symbol search.
|
||||||
|
# v0.7.0: deeper rename — struct fields, enum variants, .member / ::Variant.
|
||||||
|
|
||||||
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
|
||||||
@@ -90,6 +91,13 @@ type
|
|||||||
detail: string ## signature / type annotation
|
detail: string ## signature / type annotation
|
||||||
container: string ## optional parent (module / type)
|
container: string ## optional parent (module / type)
|
||||||
fromSema: bool ## detail came from real type checker
|
fromSema: bool ## detail came from real type checker
|
||||||
|
## Struct field / enum variant (type member)
|
||||||
|
MemberInfo = object
|
||||||
|
name: string
|
||||||
|
parent: string ## owning type name
|
||||||
|
kind: string ## field | variant
|
||||||
|
line: int ## 0-based decl line
|
||||||
|
col: int ## 0-based decl col of member name
|
||||||
## Scoped local binding for position-sensitive hover / go-to-def
|
## Scoped local binding for position-sensitive hover / go-to-def
|
||||||
LocalBinding = object
|
LocalBinding = object
|
||||||
name: string
|
name: string
|
||||||
@@ -112,6 +120,8 @@ type
|
|||||||
kindIndex: Table[string, string] ## name → kind label
|
kindIndex: Table[string, string] ## name → kind label
|
||||||
## Position-sensitive locals (filled by enrichWithSema)
|
## Position-sensitive locals (filled by enrichWithSema)
|
||||||
locals: seq[LocalBinding]
|
locals: seq[LocalBinding]
|
||||||
|
## Type members for field/variant rename (v0.7)
|
||||||
|
members: seq[MemberInfo]
|
||||||
|
|
||||||
var
|
var
|
||||||
documents = initTable[string, DocumentState]()
|
documents = initTable[string, DocumentState]()
|
||||||
@@ -207,10 +217,94 @@ proc addSymbol(doc: var DocumentState, name: string, info: SymbolInfo) =
|
|||||||
doc.ordered.add(name)
|
doc.ordered.add(name)
|
||||||
workspaceSymbols[name] = (uri: doc.uri, info: info)
|
workspaceSymbols[name] = (uri: doc.uri, info: info)
|
||||||
|
|
||||||
|
proc addMember(doc: var DocumentState, m: MemberInfo) =
|
||||||
|
if m.name.len == 0 or m.parent.len == 0:
|
||||||
|
return
|
||||||
|
doc.members.add(m)
|
||||||
|
|
||||||
|
proc scanTypeBodyMembers(content: string, openBrace: int, parent, bodyKind: string,
|
||||||
|
doc: var DocumentState) =
|
||||||
|
## Scan `{ ... }` after struct/enum for field (`name:`) or variant (`Name` / `Name(`) decls.
|
||||||
|
if openBrace < 0 or openBrace >= content.len or content[openBrace] != '{':
|
||||||
|
return
|
||||||
|
var i = openBrace + 1
|
||||||
|
var depth = 1
|
||||||
|
var inLineComment = false
|
||||||
|
var inBlockComment = false
|
||||||
|
var inString = false
|
||||||
|
var stringDelim = '\0'
|
||||||
|
var escape = false
|
||||||
|
while i < content.len and depth > 0:
|
||||||
|
let c = content[i]
|
||||||
|
if inLineComment:
|
||||||
|
if c == '\n': inLineComment = false
|
||||||
|
inc i
|
||||||
|
continue
|
||||||
|
if inBlockComment:
|
||||||
|
if c == '*' and i + 1 < content.len and content[i + 1] == '/':
|
||||||
|
inBlockComment = false
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
inc i
|
||||||
|
continue
|
||||||
|
if inString:
|
||||||
|
if escape: escape = false
|
||||||
|
elif c == '\\': escape = true
|
||||||
|
elif c == stringDelim: inString = false
|
||||||
|
inc i
|
||||||
|
continue
|
||||||
|
if c == '/' and i + 1 < content.len and content[i + 1] == '/':
|
||||||
|
inLineComment = true
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if c == '/' and i + 1 < content.len and content[i + 1] == '*':
|
||||||
|
inBlockComment = true
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if c in {'"', '`'}:
|
||||||
|
inString = true
|
||||||
|
stringDelim = c
|
||||||
|
inc i
|
||||||
|
continue
|
||||||
|
if c == '{':
|
||||||
|
inc depth
|
||||||
|
inc i
|
||||||
|
continue
|
||||||
|
if c == '}':
|
||||||
|
dec depth
|
||||||
|
inc i
|
||||||
|
continue
|
||||||
|
# Only collect at depth 1 (direct body of the type)
|
||||||
|
if depth == 1 and isIdentStart(c):
|
||||||
|
let nameStart = i
|
||||||
|
let name = readIdent(content, i)
|
||||||
|
if name.len == 0:
|
||||||
|
inc i
|
||||||
|
continue
|
||||||
|
# Skip keywords / visibility
|
||||||
|
if name in ["pub", "own", "const", "static", "func", "let", "var"]:
|
||||||
|
continue
|
||||||
|
skipWs(content, i)
|
||||||
|
if bodyKind == "struct" or bodyKind == "union" or bodyKind == "interface":
|
||||||
|
# Field: Name: Type (or Name,)
|
||||||
|
if i < content.len and content[i] == ':':
|
||||||
|
let (line, col) = lineColAt(content, nameStart)
|
||||||
|
addMember(doc, MemberInfo(
|
||||||
|
name: name, parent: parent, kind: "field", line: line, col: col))
|
||||||
|
elif bodyKind == "enum":
|
||||||
|
# Variant: Name | Name(...) | Name { ... } — not Name:
|
||||||
|
if i >= content.len or content[i] != ':':
|
||||||
|
let (line, col) = lineColAt(content, nameStart)
|
||||||
|
addMember(doc, MemberInfo(
|
||||||
|
name: name, parent: parent, kind: "variant", line: line, col: col))
|
||||||
|
continue
|
||||||
|
inc i
|
||||||
|
|
||||||
proc analyzeFile(path: string, content: string): DocumentState =
|
proc analyzeFile(path: string, content: string): DocumentState =
|
||||||
result = DocumentState(uri: pathToUri(path), content: content)
|
result = DocumentState(uri: pathToUri(path), content: content)
|
||||||
result.symbols = initTable[string, SymbolInfo]()
|
result.symbols = initTable[string, SymbolInfo]()
|
||||||
result.ordered = @[]
|
result.ordered = @[]
|
||||||
|
result.members = @[]
|
||||||
|
|
||||||
var i = 0
|
var i = 0
|
||||||
var inLineComment = false
|
var inLineComment = false
|
||||||
@@ -348,6 +442,21 @@ proc analyzeFile(path: string, content: string): DocumentState =
|
|||||||
detail = "type " & name & " = " & rhs
|
detail = "type " & name & " = " & rhs
|
||||||
addSymbol(result, name, SymbolInfo(
|
addSymbol(result, name, SymbolInfo(
|
||||||
line: line, col: col, kind: kind, detail: detail, container: ""))
|
line: line, col: col, kind: kind, detail: detail, container: ""))
|
||||||
|
# Index members inside { ... }
|
||||||
|
if kind in ["struct", "enum", "union", "interface"]:
|
||||||
|
var j = i
|
||||||
|
skipWs(content, j)
|
||||||
|
# skip generic params <T>
|
||||||
|
if j < content.len and content[j] == '<':
|
||||||
|
var gd = 1
|
||||||
|
inc j
|
||||||
|
while j < content.len and gd > 0:
|
||||||
|
if content[j] == '<': inc gd
|
||||||
|
elif content[j] == '>': dec gd
|
||||||
|
inc j
|
||||||
|
skipWs(content, j)
|
||||||
|
if j < content.len and content[j] == '{':
|
||||||
|
scanTypeBodyMembers(content, j, name, kind, result)
|
||||||
break
|
break
|
||||||
if not matchedTypeKw:
|
if not matchedTypeKw:
|
||||||
inc i
|
inc i
|
||||||
@@ -926,6 +1035,7 @@ proc analyzeAndPublishDiagnostics(stream: FileStream, doc: DocumentState) =
|
|||||||
let updated = analyzeFile(path, doc.content)
|
let updated = analyzeFile(path, doc.content)
|
||||||
doc.symbols = updated.symbols
|
doc.symbols = updated.symbols
|
||||||
doc.ordered = updated.ordered
|
doc.ordered = updated.ordered
|
||||||
|
doc.members = updated.members
|
||||||
# Keep / refresh real types for hover (does not replace lightweight outline)
|
# Keep / refresh real types for hover (does not replace lightweight outline)
|
||||||
enrichWithSema(doc)
|
enrichWithSema(doc)
|
||||||
let diags = runBuxcDiagnostics(path, doc.content)
|
let diags = runBuxcDiagnostics(path, doc.content)
|
||||||
@@ -975,6 +1085,7 @@ proc ensureAnalyzed(doc: DocumentState) =
|
|||||||
let updated = analyzeFile(uriToPath(doc.uri), doc.content)
|
let updated = analyzeFile(uriToPath(doc.uri), doc.content)
|
||||||
doc.symbols = updated.symbols
|
doc.symbols = updated.symbols
|
||||||
doc.ordered = updated.ordered
|
doc.ordered = updated.ordered
|
||||||
|
doc.members = updated.members
|
||||||
|
|
||||||
proc completionKind(kind: string): int =
|
proc completionKind(kind: string): int =
|
||||||
case kind
|
case kind
|
||||||
@@ -1243,14 +1354,62 @@ proc handleHover(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
|||||||
})
|
})
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Identifier occurrences (references / rename) — E.1 tooling polish / session 34
|
# Identifier occurrences (references / rename) — sessions 34 + 39 (deeper)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type
|
type
|
||||||
|
IdentAccess = enum
|
||||||
|
iaBare ## plain ident
|
||||||
|
iaDot ## .member
|
||||||
|
iaColonColon ## Type::Variant or path::name
|
||||||
|
iaFieldInit ## Name: inside struct literal / pattern (preceded by `{` or `,`)
|
||||||
IdentHit = object
|
IdentHit = object
|
||||||
line: int ## 0-based
|
line: int ## 0-based
|
||||||
col: int ## 0-based start of name
|
col: int ## 0-based start of name
|
||||||
len: int
|
len: int
|
||||||
|
access: IdentAccess
|
||||||
|
RenameTargetKind = enum
|
||||||
|
rtkLocal
|
||||||
|
rtkGlobal
|
||||||
|
rtkMember
|
||||||
|
rtkUnknown
|
||||||
|
RenameTarget = object
|
||||||
|
kind: RenameTargetKind
|
||||||
|
name: string
|
||||||
|
## For rtkMember
|
||||||
|
parent: string
|
||||||
|
memberKind: string
|
||||||
|
declLine: int
|
||||||
|
declCol: int
|
||||||
|
## For rtkLocal
|
||||||
|
local: LocalBinding
|
||||||
|
|
||||||
|
proc peekAccessBefore(content: string, start: int): IdentAccess =
|
||||||
|
## Classify how the identifier at `start` is written.
|
||||||
|
var k = start - 1
|
||||||
|
while k >= 0 and content[k] in {' ', '\t'}:
|
||||||
|
dec k
|
||||||
|
if k < 0:
|
||||||
|
return iaBare
|
||||||
|
if content[k] == '.':
|
||||||
|
return iaDot
|
||||||
|
if content[k] == ':' and k > 0 and content[k - 1] == ':':
|
||||||
|
return iaColonColon
|
||||||
|
if content[k] == ':' and (k == 0 or content[k - 1] != ':'):
|
||||||
|
# This is after the name for `name: type` — check from start side instead
|
||||||
|
discard
|
||||||
|
# Field init: after `{` or `,` optional whitespace then name then `:`
|
||||||
|
var j = start + 1
|
||||||
|
while j < content.len and isIdentChar(content[j]):
|
||||||
|
inc j
|
||||||
|
var t = j
|
||||||
|
while t < content.len and content[t] in {' ', '\t'}:
|
||||||
|
inc t
|
||||||
|
if t < content.len and content[t] == ':':
|
||||||
|
# name: — could be field decl/init if preceded by { or ,
|
||||||
|
if k >= 0 and content[k] in {'{', ',', '\n', ';'}:
|
||||||
|
return iaFieldInit
|
||||||
|
return iaBare
|
||||||
|
|
||||||
proc collectIdentHits(content, name: string): seq[IdentHit] =
|
proc collectIdentHits(content, name: string): seq[IdentHit] =
|
||||||
## Textual identifier occurrences of `name`, skipping strings/comments.
|
## Textual identifier occurrences of `name`, skipping strings/comments.
|
||||||
@@ -1308,7 +1467,9 @@ proc collectIdentHits(content, name: string): seq[IdentHit] =
|
|||||||
let ident = content[start ..< j]
|
let ident = content[start ..< j]
|
||||||
if ident == name:
|
if ident == name:
|
||||||
let (line, col) = lineColAt(content, start)
|
let (line, col) = lineColAt(content, start)
|
||||||
result.add(IdentHit(line: line, col: col, len: name.len))
|
result.add(IdentHit(
|
||||||
|
line: line, col: col, len: name.len,
|
||||||
|
access: peekAccessBefore(content, start)))
|
||||||
i = j
|
i = j
|
||||||
continue
|
continue
|
||||||
inc i
|
inc i
|
||||||
@@ -1326,8 +1487,122 @@ proc locationJson(uri: string, line, col, nameLen: int): JsonNode =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proc lookupMemberAt(doc: DocumentState, name: string, line, col: int): tuple[ok: bool, m: MemberInfo] =
|
||||||
|
## Prefer member decl under cursor; else member if access is .name / ::name on that line.
|
||||||
|
result.ok = false
|
||||||
|
for m in doc.members:
|
||||||
|
if m.name != name: continue
|
||||||
|
if m.line == line and col >= m.col and col <= m.col + name.len:
|
||||||
|
result.ok = true
|
||||||
|
result.m = m
|
||||||
|
return
|
||||||
|
# Dot / :: access: any member with this name (ambiguous if multiple parents —
|
||||||
|
# pick first; refine if line has Parent. or Parent::)
|
||||||
|
let lines = doc.content.split("\n")
|
||||||
|
if line < 0 or line >= lines.len: return
|
||||||
|
let l = lines[line]
|
||||||
|
var wordStart = min(col, l.len)
|
||||||
|
while wordStart > 0 and l[wordStart - 1] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}:
|
||||||
|
dec wordStart
|
||||||
|
# Find parent type name before `.` or `::`
|
||||||
|
var parentHint = ""
|
||||||
|
var k = wordStart - 1
|
||||||
|
while k >= 0 and l[k] in {' ', '\t'}: dec k
|
||||||
|
if k >= 0 and l[k] == '.':
|
||||||
|
var p = k - 1
|
||||||
|
while p >= 0 and l[p] in {' ', '\t'}: dec p
|
||||||
|
var pe = p
|
||||||
|
while p >= 0 and l[p] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}:
|
||||||
|
dec p
|
||||||
|
if pe > p:
|
||||||
|
parentHint = l[p + 1 .. pe]
|
||||||
|
elif k >= 1 and l[k] == ':' and l[k - 1] == ':':
|
||||||
|
var p = k - 2
|
||||||
|
while p >= 0 and l[p] in {' ', '\t'}: dec p
|
||||||
|
var pe = p
|
||||||
|
while p >= 0 and l[p] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}:
|
||||||
|
dec p
|
||||||
|
if pe > p:
|
||||||
|
parentHint = l[p + 1 .. pe]
|
||||||
|
|
||||||
|
for m in doc.members:
|
||||||
|
if m.name != name: continue
|
||||||
|
if parentHint.len > 0 and m.parent != parentHint: continue
|
||||||
|
result.ok = true
|
||||||
|
result.m = m
|
||||||
|
if parentHint.len > 0:
|
||||||
|
return
|
||||||
|
# If no parent hint and only one member with this name, use it
|
||||||
|
var count = 0
|
||||||
|
var last: MemberInfo
|
||||||
|
for m in doc.members:
|
||||||
|
if m.name == name:
|
||||||
|
inc count
|
||||||
|
last = m
|
||||||
|
if count == 1:
|
||||||
|
result.ok = true
|
||||||
|
result.m = last
|
||||||
|
|
||||||
|
proc classifyRenameTarget(doc: DocumentState, word: string, line, col: int): RenameTarget =
|
||||||
|
result.kind = rtkUnknown
|
||||||
|
result.name = word
|
||||||
|
ensureAnalyzed(doc)
|
||||||
|
if doc.locals.len == 0 and doc.content.len > 0:
|
||||||
|
enrichWithSema(doc)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 2) Local / param (scoped)
|
||||||
|
let (lok, lb) = lookupLocalAt(doc, word, line)
|
||||||
|
if lok:
|
||||||
|
result.kind = rtkLocal
|
||||||
|
result.local = lb
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3) Global / type / function
|
||||||
|
if doc.symbols.hasKey(word) or doc.typeIndex.hasKey(word) or workspaceSymbols.hasKey(word):
|
||||||
|
result.kind = rtkGlobal
|
||||||
|
return
|
||||||
|
|
||||||
|
result.kind = rtkUnknown
|
||||||
|
|
||||||
|
proc hitMatchesMember(h: IdentHit, m: MemberInfo): bool =
|
||||||
|
## Member rename: decl, .name, ::name, and field init `name:` — not bare locals.
|
||||||
|
if h.line == m.line and h.col == m.col:
|
||||||
|
return true
|
||||||
|
case h.access
|
||||||
|
of iaDot, iaColonColon, iaFieldInit:
|
||||||
|
return true
|
||||||
|
of iaBare:
|
||||||
|
return false
|
||||||
|
|
||||||
proc collectReferences(doc: DocumentState, word: string, lineNum: int,
|
proc collectReferences(doc: DocumentState, word: string, lineNum: int,
|
||||||
includeDecl: bool): seq[JsonNode] =
|
includeDecl: bool, col: int = 0): seq[JsonNode] =
|
||||||
## Collect LSP Location nodes for references at `word` on `lineNum`.
|
## Collect LSP Location nodes for references at `word` on `lineNum`.
|
||||||
result = @[]
|
result = @[]
|
||||||
if word.len == 0: return
|
if word.len == 0: return
|
||||||
@@ -1335,71 +1610,96 @@ proc collectReferences(doc: DocumentState, word: string, lineNum: int,
|
|||||||
if doc.locals.len == 0 and doc.content.len > 0:
|
if doc.locals.len == 0 and doc.content.len > 0:
|
||||||
enrichWithSema(doc)
|
enrichWithSema(doc)
|
||||||
|
|
||||||
let (lok, targetLocal) = lookupLocalAt(doc, word, lineNum)
|
let target = classifyRenameTarget(doc, word, lineNum, col)
|
||||||
let hits = collectIdentHits(doc.content, word)
|
let hits = collectIdentHits(doc.content, word)
|
||||||
|
|
||||||
if lok:
|
case target.kind
|
||||||
# Scoped local / param: only occurrences that resolve to the same binding
|
of rtkLocal:
|
||||||
for h in hits:
|
for h in hits:
|
||||||
let (ok, b) = lookupLocalAt(doc, word, h.line)
|
let (ok, b) = lookupLocalAt(doc, word, h.line)
|
||||||
if not ok or not sameLocal(b, targetLocal):
|
if not ok or not sameLocal(b, target.local):
|
||||||
continue
|
continue
|
||||||
if not includeDecl and h.line == targetLocal.declLine and h.col == targetLocal.declCol:
|
if not includeDecl and h.line == target.local.declLine and h.col == target.local.declCol:
|
||||||
continue
|
continue
|
||||||
result.add(locationJson(doc.uri, h.line, h.col, h.len))
|
result.add(locationJson(doc.uri, h.line, h.col, h.len))
|
||||||
return
|
return
|
||||||
|
|
||||||
# File-level or workspace symbol: all textual hits in this document
|
of rtkMember:
|
||||||
let isFileSym = doc.symbols.hasKey(word) or doc.typeIndex.hasKey(word)
|
let m = MemberInfo(
|
||||||
let isWsSym = workspaceSymbols.hasKey(word)
|
name: target.name, parent: target.parent, kind: target.memberKind,
|
||||||
if not isFileSym and not isWsSym:
|
line: target.declLine, col: target.declCol)
|
||||||
# Still report textual hits in current file (e.g. undeclared / mid-edit)
|
|
||||||
for h in hits:
|
for h in hits:
|
||||||
result.add(locationJson(doc.uri, h.line, h.col, h.len))
|
if not hitMatchesMember(h, m):
|
||||||
return
|
|
||||||
|
|
||||||
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
|
continue
|
||||||
result.add(locationJson(doc.uri, h.line, h.col, h.len))
|
if not includeDecl and h.line == m.line and h.col == m.col:
|
||||||
|
continue
|
||||||
# Workspace: other open buffers + on-disk .bux under rootPath
|
result.add(locationJson(doc.uri, h.line, h.col, h.len))
|
||||||
if isWsSym or isFileSym:
|
# Workspace: other files may reference Parent::name or .name
|
||||||
var seenUri = initHashSet[string]()
|
var seenUri = initHashSet[string]()
|
||||||
seenUri.incl(doc.uri)
|
seenUri.incl(doc.uri)
|
||||||
|
|
||||||
for u, d in documents.pairs:
|
for u, d in documents.pairs:
|
||||||
if d.content.len == 0 or seenUri.contains(u): continue
|
if d.content.len == 0 or seenUri.contains(u): continue
|
||||||
seenUri.incl(u)
|
seenUri.incl(u)
|
||||||
|
ensureAnalyzed(d)
|
||||||
for h in collectIdentHits(d.content, word):
|
for h in collectIdentHits(d.content, word):
|
||||||
result.add(locationJson(u, h.line, h.col, h.len))
|
if hitMatchesMember(h, m):
|
||||||
|
result.add(locationJson(u, h.line, h.col, h.len))
|
||||||
|
return
|
||||||
|
|
||||||
if rootPath.len > 0 and dirExists(rootPath):
|
of rtkGlobal, rtkUnknown:
|
||||||
var stack: seq[tuple[dir: string, depth: int]] = @[(rootPath, 0)]
|
let isFileSym = doc.symbols.hasKey(word) or doc.typeIndex.hasKey(word)
|
||||||
while stack.len > 0:
|
let isWsSym = workspaceSymbols.hasKey(word)
|
||||||
let (dir, depth) = stack.pop()
|
if not isFileSym and not isWsSym and target.kind == rtkUnknown:
|
||||||
if depth > 4: continue
|
for h in hits:
|
||||||
let base = dir.extractFilename
|
# Unknown bare rename: only bare idents (avoid eating .x members)
|
||||||
if base in [".git", "build", "examples_pkg", "node_modules", "vendor", "nimcache"]:
|
if h.access == iaBare:
|
||||||
|
result.add(locationJson(doc.uri, h.line, h.col, h.len))
|
||||||
|
return
|
||||||
|
|
||||||
|
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
|
continue
|
||||||
try:
|
# Global type/func rename: bare + ::qualified, not .member of other types
|
||||||
for kind, path in walkDir(dir):
|
if h.access == iaDot:
|
||||||
if kind == pcDir:
|
continue
|
||||||
stack.add((path, depth + 1))
|
result.add(locationJson(doc.uri, h.line, h.col, h.len))
|
||||||
elif kind == pcFile and path.endsWith(".bux"):
|
|
||||||
let u = pathToUri(path.absolutePath)
|
if isWsSym or isFileSym:
|
||||||
if seenUri.contains(u): continue
|
var seenUri = initHashSet[string]()
|
||||||
seenUri.incl(u)
|
seenUri.incl(doc.uri)
|
||||||
try:
|
for u, d in documents.pairs:
|
||||||
let text = readFile(path)
|
if d.content.len == 0 or seenUri.contains(u): continue
|
||||||
for h in collectIdentHits(text, word):
|
seenUri.incl(u)
|
||||||
result.add(locationJson(u, h.line, h.col, h.len))
|
for h in collectIdentHits(d.content, word):
|
||||||
except CatchableError:
|
if h.access == iaDot: continue
|
||||||
discard
|
result.add(locationJson(u, h.line, h.col, h.len))
|
||||||
except CatchableError:
|
if rootPath.len > 0 and dirExists(rootPath):
|
||||||
discard
|
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 h.access == iaDot: continue
|
||||||
|
result.add(locationJson(u, h.line, h.col, h.len))
|
||||||
|
except CatchableError:
|
||||||
|
discard
|
||||||
|
except CatchableError:
|
||||||
|
discard
|
||||||
|
|
||||||
proc handleReferences(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
proc handleReferences(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
||||||
let uri = paramsNode["textDocument"]["uri"].getStr()
|
let uri = paramsNode["textDocument"]["uri"].getStr()
|
||||||
@@ -1421,7 +1721,7 @@ proc handleReferences(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
|||||||
return
|
return
|
||||||
|
|
||||||
var arr = newJArray()
|
var arr = newJArray()
|
||||||
for loc in collectReferences(doc, word, lineNum, includeDecl):
|
for loc in collectReferences(doc, word, lineNum, includeDecl, col):
|
||||||
arr.add(loc)
|
arr.add(loc)
|
||||||
sendResponse(stream, id, arr)
|
sendResponse(stream, id, arr)
|
||||||
|
|
||||||
@@ -1495,7 +1795,7 @@ proc handleRename(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
|
|||||||
sendResponse(stream, id, %*{"changes": newJObject()})
|
sendResponse(stream, id, %*{"changes": newJObject()})
|
||||||
return
|
return
|
||||||
|
|
||||||
let refs = collectReferences(doc, word, lineNum, includeDecl = true)
|
let refs = collectReferences(doc, word, lineNum, includeDecl = true, col = col)
|
||||||
# Group TextEdits by URI
|
# Group TextEdits by URI
|
||||||
var byUri = initTable[string, JsonNode]()
|
var byUri = initTable[string, JsonNode]()
|
||||||
for loc in refs:
|
for loc in refs:
|
||||||
@@ -1632,7 +1932,7 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
|
|||||||
"renameProvider": {"prepareProvider": true},
|
"renameProvider": {"prepareProvider": true},
|
||||||
"workspaceSymbolProvider": true
|
"workspaceSymbolProvider": true
|
||||||
},
|
},
|
||||||
"serverInfo": {"name": "bux-lsp", "version": "0.6.0"}
|
"serverInfo": {"name": "bux-lsp", "version": "0.7.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()
|
||||||
@@ -1679,6 +1979,7 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
|
|||||||
let updated = analyzeFile(uriToPath(uri), doc.content)
|
let updated = analyzeFile(uriToPath(uri), doc.content)
|
||||||
doc.symbols = updated.symbols
|
doc.symbols = updated.symbols
|
||||||
doc.ordered = updated.ordered
|
doc.ordered = updated.ordered
|
||||||
|
doc.members = updated.members
|
||||||
# Re-apply typeIndex details onto matching names (don't drop sema types mid-edit)
|
# Re-apply typeIndex details onto matching names (don't drop sema types mid-edit)
|
||||||
for name, detail in doc.typeIndex.pairs:
|
for name, detail in doc.typeIndex.pairs:
|
||||||
if doc.symbols.hasKey(name):
|
if doc.symbols.hasKey(name):
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ if ! grep -q 'renameProvider' "$TMP/out.txt"; then
|
|||||||
echo "FAIL: initialize missing renameProvider"
|
echo "FAIL: initialize missing renameProvider"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! grep -qE '0\.[56]\.0' "$TMP/out.txt"; then
|
if ! grep -qE '0\.[567]\.0' "$TMP/out.txt"; then
|
||||||
echo "WARN: unexpected bux-lsp version in initialize"
|
echo "WARN: unexpected bux-lsp version in initialize"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Executable
+91
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Smoke: deeper rename — struct fields + enum variants (bux-lsp 0.7)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
enum Color {
|
||||||
|
Red,
|
||||||
|
Green,
|
||||||
|
Blue,
|
||||||
|
}
|
||||||
|
func Main() -> int {
|
||||||
|
let p: Point = Point { x: 1, y: 2 };
|
||||||
|
let a: int = p.x;
|
||||||
|
let c: Color = Color::Red;
|
||||||
|
let x: int = 99;
|
||||||
|
return a + x;
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
|
||||||
|
{
|
||||||
|
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 field x on decl line 1 col 4 (struct field `x`)
|
||||||
|
rpc '{"jsonrpc":"2.0","id":2,"method":"textDocument/rename","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":1,"character":4},"newName":"px"}}'
|
||||||
|
# rename Color::Red variant — position on Red in enum (line 5)
|
||||||
|
rpc '{"jsonrpc":"2.0","id":3,"method":"textDocument/rename","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":5,"character":4},"newName":"Crimson"}}'
|
||||||
|
# rename local x (line 11) should NOT rename p.x field — use "xx"
|
||||||
|
rpc '{"jsonrpc":"2.0","id":4,"method":"textDocument/rename","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":11,"character":8},"newName":"xx"}}'
|
||||||
|
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.7.0' "$TMP/out.txt"; then
|
||||||
|
echo "WARN: version not 0.7.0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Field rename x → px: expect ≥3 (decl, init x:, access p.x)
|
||||||
|
px_count=$(grep -o '"newText":"px"' "$TMP/out.txt" | wc -l)
|
||||||
|
if [[ "$px_count" -lt 3 ]]; then
|
||||||
|
echo "FAIL: field rename x→px expected ≥3 edits, got $px_count"
|
||||||
|
cat "$TMP/out.txt"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " field x→px edits: $px_count"
|
||||||
|
|
||||||
|
# Variant Red → Crimson: decl + Color::Red
|
||||||
|
cr_count=$(grep -o '"newText":"Crimson"' "$TMP/out.txt" | wc -l)
|
||||||
|
if [[ "$cr_count" -lt 2 ]]; then
|
||||||
|
echo "FAIL: variant Red→Crimson expected ≥2 edits, got $cr_count"
|
||||||
|
cat "$TMP/out.txt"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " variant Red→Crimson edits: $cr_count"
|
||||||
|
|
||||||
|
# Local x→xx: should be small (decl + use in return), not include field
|
||||||
|
xx_count=$(grep -o '"newText":"xx"' "$TMP/out.txt" | wc -l)
|
||||||
|
if [[ "$xx_count" -lt 1 ]]; then
|
||||||
|
echo "FAIL: local x→xx expected edits"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$xx_count" -gt 3 ]]; then
|
||||||
|
echo "FAIL: local x→xx too many edits ($xx_count) — may have hit fields"
|
||||||
|
cat "$TMP/out.txt"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " local x→xx edits: $xx_count"
|
||||||
|
|
||||||
|
echo "PASS: LSP deeper rename (field + variant + local shadowing)"
|
||||||
Reference in New Issue
Block a user