feat(lsp): method, type, and self-receiver rename (0.10)
Add rtkMethod so .Method( call sites rename with the method decl, improve type renames across extend/annotations, and clip self to the enclosing method body. - Len→Length includes self.Len(); Point→Vec2 hits extend + types - self→this stays within one method (textual func bounds) - smoke_lsp_rename_method + test-lsp wiring
This commit is contained in:
@@ -205,6 +205,9 @@ test-lsp: lsp
|
|||||||
@echo "=== LSP method call hierarchy smoke ==="
|
@echo "=== LSP method call hierarchy smoke ==="
|
||||||
@chmod +x tools/smoke_lsp_method_hierarchy.sh
|
@chmod +x tools/smoke_lsp_method_hierarchy.sh
|
||||||
@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
|
.PHONY: test-registry
|
||||||
test-registry: build
|
test-registry: build
|
||||||
|
|||||||
+19
-4
@@ -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, **LSP 0.9 method hierarchy**, Nexus KA
|
> **Текущо:** v0.5.x — multi-file #line, selfhost CI, **LSP 0.10 method/type rename**, Nexus KA
|
||||||
> **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain.
|
> **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден 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.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.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` |
|
| 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)
|
1. Rename of method receivers / qualified module paths (edge cases)
|
||||||
2. 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)
|
3. HirNode-level file for statements spanning multiple files (rare)
|
||||||
4. Optional: selfhost-loop as optional CI job (slow)
|
4. Optional: selfhost-loop as optional CI job (slow)
|
||||||
|
|||||||
+189
-41
@@ -11,6 +11,7 @@
|
|||||||
# 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).
|
# 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 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
|
||||||
@@ -1412,19 +1413,22 @@ type
|
|||||||
access: IdentAccess
|
access: IdentAccess
|
||||||
RenameTargetKind = enum
|
RenameTargetKind = enum
|
||||||
rtkLocal
|
rtkLocal
|
||||||
rtkGlobal
|
rtkGlobal ## free func / type / const (not method)
|
||||||
rtkMember
|
rtkMethod ## extend/impl method — decl + .Name( + bare Name(
|
||||||
|
rtkMember ## struct field / enum variant
|
||||||
rtkUnknown
|
rtkUnknown
|
||||||
RenameTarget = object
|
RenameTarget = object
|
||||||
kind: RenameTargetKind
|
kind: RenameTargetKind
|
||||||
name: string
|
name: string
|
||||||
## For rtkMember
|
## For rtkMember / rtkMethod
|
||||||
parent: string
|
parent: string
|
||||||
memberKind: string
|
memberKind: string
|
||||||
declLine: int
|
declLine: int
|
||||||
declCol: int
|
declCol: int
|
||||||
## For rtkLocal
|
## For rtkLocal
|
||||||
local: LocalBinding
|
local: LocalBinding
|
||||||
|
## For rtkGlobal type symbols
|
||||||
|
isType: bool
|
||||||
|
|
||||||
proc peekAccessBefore(content: string, start: int): IdentAccess =
|
proc peekAccessBefore(content: string, start: int): IdentAccess =
|
||||||
## Classify how the identifier at `start` is written.
|
## Classify how the identifier at `start` is written.
|
||||||
@@ -1585,33 +1589,51 @@ proc lookupMemberAt(doc: DocumentState, name: string, line, col: int): tuple[ok:
|
|||||||
result.ok = true
|
result.ok = true
|
||||||
result.m = last
|
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 =
|
proc classifyRenameTarget(doc: DocumentState, word: string, line, col: int): RenameTarget =
|
||||||
result.kind = rtkUnknown
|
result.kind = rtkUnknown
|
||||||
result.name = word
|
result.name = word
|
||||||
|
result.isType = false
|
||||||
ensureAnalyzed(doc)
|
ensureAnalyzed(doc)
|
||||||
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 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
|
# 1) Type member (field/variant) when cursor is on decl or qualified access
|
||||||
let (mok, mem) = lookupMemberAt(doc, word, line, col)
|
let (mok, mem) = lookupMemberAt(doc, word, line, col)
|
||||||
if mok:
|
if mok:
|
||||||
# Prefer member over local if this is clearly a member access or member decl
|
var isMemberCtx = acc in {iaDot, iaColonColon, iaFieldInit} or
|
||||||
let lines = doc.content.split("\n")
|
(mem.line == line and mem.col == wordStart)
|
||||||
var isMemberCtx = false
|
# Don't treat method calls as field members
|
||||||
if line >= 0 and line < lines.len:
|
let isMethodSym = doc.symbols.hasKey(word) and doc.symbols[word].kind == "method"
|
||||||
let l = lines[line]
|
if isMethodSym and acc == iaDot and isCallSiteAt(doc.content, absStart, word.len):
|
||||||
var ws = min(col, l.len)
|
isMemberCtx = false
|
||||||
while ws > 0 and l[ws - 1] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}:
|
if isMemberCtx or (not lookupLocalAt(doc, word, line).ok and not isMethodSym):
|
||||||
dec ws
|
if not isMethodSym:
|
||||||
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.kind = rtkMember
|
||||||
result.parent = mem.parent
|
result.parent = mem.parent
|
||||||
result.memberKind = mem.kind
|
result.memberKind = mem.kind
|
||||||
@@ -1619,16 +1641,83 @@ proc classifyRenameTarget(doc: DocumentState, word: string, line, col: int): Ren
|
|||||||
result.declCol = mem.col
|
result.declCol = mem.col
|
||||||
return
|
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)
|
let (lok, lb) = lookupLocalAt(doc, word, line)
|
||||||
if lok:
|
if lok:
|
||||||
result.kind = rtkLocal
|
result.kind = rtkLocal
|
||||||
result.local = lb
|
result.local = lb
|
||||||
return
|
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):
|
if doc.symbols.hasKey(word) or doc.typeIndex.hasKey(word) or workspaceSymbols.hasKey(word):
|
||||||
result.kind = rtkGlobal
|
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
|
return
|
||||||
|
|
||||||
result.kind = rtkUnknown
|
result.kind = rtkUnknown
|
||||||
@@ -1643,6 +1732,27 @@ proc hitMatchesMember(h: IdentHit, m: MemberInfo): bool =
|
|||||||
of iaBare:
|
of iaBare:
|
||||||
return false
|
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,
|
proc collectReferences(doc: DocumentState, word: string, lineNum: int,
|
||||||
includeDecl: bool, col: int = 0): 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`.
|
||||||
@@ -1657,15 +1767,52 @@ proc collectReferences(doc: DocumentState, word: string, lineNum: int,
|
|||||||
|
|
||||||
case target.kind
|
case target.kind
|
||||||
of rtkLocal:
|
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:
|
for h in hits:
|
||||||
let (ok, b) = lookupLocalAt(doc, word, h.line)
|
if h.line < methodStart or h.line > methodEnd:
|
||||||
if not ok or not sameLocal(b, target.local):
|
|
||||||
continue
|
continue
|
||||||
if not includeDecl and h.line == target.local.declLine and h.col == target.local.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
|
||||||
|
|
||||||
|
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:
|
of rtkMember:
|
||||||
let m = MemberInfo(
|
let m = MemberInfo(
|
||||||
name: target.name, parent: target.parent, kind: target.memberKind,
|
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:
|
for h in hits:
|
||||||
if not hitMatchesMember(h, m):
|
if not hitMatchesMember(h, m):
|
||||||
continue
|
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:
|
if not includeDecl and h.line == m.line and h.col == m.col:
|
||||||
continue
|
continue
|
||||||
result.add(locationJson(doc.uri, h.line, h.col, h.len))
|
result.add(locationJson(doc.uri, h.line, h.col, h.len))
|
||||||
# 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:
|
||||||
@@ -1693,17 +1844,21 @@ proc collectReferences(doc: DocumentState, word: string, lineNum: int,
|
|||||||
let isWsSym = workspaceSymbols.hasKey(word)
|
let isWsSym = workspaceSymbols.hasKey(word)
|
||||||
if not isFileSym and not isWsSym and target.kind == rtkUnknown:
|
if not isFileSym and not isWsSym and target.kind == rtkUnknown:
|
||||||
for h in hits:
|
for h in hits:
|
||||||
# Unknown bare rename: only bare idents (avoid eating .x members)
|
|
||||||
if h.access == iaBare:
|
if h.access == iaBare:
|
||||||
result.add(locationJson(doc.uri, h.line, h.col, h.len))
|
result.add(locationJson(doc.uri, h.line, h.col, h.len))
|
||||||
return
|
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:
|
for h in hits:
|
||||||
if not includeDecl and doc.symbols.hasKey(word):
|
if not includeDecl and h.line == declLine and h.col == declCol:
|
||||||
let info = doc.symbols[word]
|
|
||||||
if h.line == info.line and h.col == info.col:
|
|
||||||
continue
|
continue
|
||||||
# Global type/func rename: bare + ::qualified, not .member of other types
|
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:
|
if h.access == iaDot:
|
||||||
continue
|
continue
|
||||||
result.add(locationJson(doc.uri, h.line, h.col, h.len))
|
result.add(locationJson(doc.uri, h.line, h.col, h.len))
|
||||||
@@ -1715,6 +1870,9 @@ proc collectReferences(doc: DocumentState, word: string, lineNum: int,
|
|||||||
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)
|
||||||
for h in collectIdentHits(d.content, word):
|
for h in collectIdentHits(d.content, word):
|
||||||
|
if target.isType:
|
||||||
|
if h.access == iaDot: continue
|
||||||
|
else:
|
||||||
if h.access == iaDot: continue
|
if h.access == iaDot: continue
|
||||||
result.add(locationJson(u, h.line, h.col, h.len))
|
result.add(locationJson(u, h.line, h.col, h.len))
|
||||||
if rootPath.len > 0 and dirExists(rootPath):
|
if rootPath.len > 0 and dirExists(rootPath):
|
||||||
@@ -1968,9 +2126,6 @@ type
|
|||||||
callerUri: string
|
callerUri: string
|
||||||
isMethodCall: bool ## true if site was `.Name(` (receiver call)
|
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)
|
||||||
@@ -1999,13 +2154,6 @@ proc enclosingFuncName(doc: DocumentState, line: int): string =
|
|||||||
best = info.line
|
best = info.line
|
||||||
result = name
|
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] =
|
proc collectCallSitesInDoc(doc: DocumentState, known: HashSet[string]): seq[CallSite] =
|
||||||
result = @[]
|
result = @[]
|
||||||
ensureAnalyzed(doc)
|
ensureAnalyzed(doc)
|
||||||
@@ -2270,7 +2418,7 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
|
|||||||
"workspaceSymbolProvider": true,
|
"workspaceSymbolProvider": true,
|
||||||
"callHierarchyProvider": 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:
|
if paramsNode.hasKey("rootPath") and paramsNode["rootPath"].kind != JNull:
|
||||||
rootPath = paramsNode["rootPath"].getStr()
|
rootPath = paramsNode["rootPath"].getStr()
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ URI="file://$TMP/Main.bux"
|
|||||||
rpc '{"jsonrpc":"2.0","method":"exit","params":null}'
|
rpc '{"jsonrpc":"2.0","method":"exit","params":null}'
|
||||||
} | "$LSP" 2>/dev/null | tr '\r' '\n' > "$TMP/out.txt"
|
} | "$LSP" 2>/dev/null | tr '\r' '\n' > "$TMP/out.txt"
|
||||||
|
|
||||||
if ! grep -q '0.9.0' "$TMP/out.txt"; then
|
if ! grep -qE '0\.(9|10)\.0' "$TMP/out.txt"; then
|
||||||
echo "WARN: version not 0.9.0"
|
echo "WARN: unexpected bux-lsp version"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# prepare should return method (Point.Len or Len)
|
# prepare should return method (Point.Len or Len)
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ URI="file://$TMP/Main.bux"
|
|||||||
rpc '{"jsonrpc":"2.0","method":"exit","params":null}'
|
rpc '{"jsonrpc":"2.0","method":"exit","params":null}'
|
||||||
} | "$LSP" 2>/dev/null | tr '\r' '\n' > "$TMP/out.txt"
|
} | "$LSP" 2>/dev/null | tr '\r' '\n' > "$TMP/out.txt"
|
||||||
|
|
||||||
if ! grep -q '0.7.0' "$TMP/out.txt"; then
|
if ! grep -qE '0\.(7|8|9|10)\.0' "$TMP/out.txt"; then
|
||||||
echo "WARN: version not 0.7.0"
|
echo "WARN: unexpected bux-lsp version"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Field rename x → px: expect ≥3 (decl, init x:, access p.x)
|
# Field rename x → px: expect ≥3 (decl, init x:, access p.x)
|
||||||
|
|||||||
Executable
+96
@@ -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)"
|
||||||
Reference in New Issue
Block a user