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.
This commit is contained in:
@@ -211,6 +211,9 @@ test-lsp: lsp
|
|||||||
@echo "=== LSP interface dispatch hierarchy smoke ==="
|
@echo "=== LSP interface dispatch hierarchy smoke ==="
|
||||||
@chmod +x tools/smoke_lsp_iface_hierarchy.sh
|
@chmod +x tools/smoke_lsp_iface_hierarchy.sh
|
||||||
@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
|
.PHONY: test-registry
|
||||||
test-registry: build
|
test-registry: build
|
||||||
|
|||||||
+35
-5
@@ -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.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` |
|
||||||
@@ -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)
|
1. HirNode-level file for statements spanning multiple files (rare)
|
||||||
2. Module-path segment rename (`Std::Io` style imports)
|
2. Optional: selfhost-loop as optional CI job (slow)
|
||||||
3. HirNode-level file for statements spanning multiple files (rare)
|
3. LSP find-implementations request (dedicated, beyond call hierarchy)
|
||||||
4. Optional: selfhost-loop as optional CI job (slow)
|
4. Workspace-wide import path index without open documents (optional polish)
|
||||||
|
|||||||
+173
-1
@@ -13,6 +13,7 @@
|
|||||||
# 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.
|
# v0.10.0: method rename + qualified path / extend Type rename edges.
|
||||||
# v0.11.0: interface dispatch in call hierarchy (extend Type for Trait).
|
# 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 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
|
||||||
@@ -102,6 +103,13 @@ type
|
|||||||
kind: string ## field | variant
|
kind: string ## field | variant
|
||||||
line: int ## 0-based decl line
|
line: int ## 0-based decl line
|
||||||
col: int ## 0-based decl col of member name
|
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
|
## Scoped local binding for position-sensitive hover / go-to-def
|
||||||
LocalBinding = object
|
LocalBinding = object
|
||||||
name: string
|
name: string
|
||||||
@@ -130,6 +138,8 @@ type
|
|||||||
ifaceMethods: seq[MemberInfo]
|
ifaceMethods: seq[MemberInfo]
|
||||||
## Type implements Interface (from `extend Type for Interface`)
|
## Type implements Interface (from `extend Type for Interface`)
|
||||||
impls: seq[tuple[typeName, iface: string, line: int]]
|
impls: seq[tuple[typeName, iface: string, line: int]]
|
||||||
|
## Import path segments for module-path rename (v0.12)
|
||||||
|
importPaths: seq[PathSegInfo]
|
||||||
|
|
||||||
var
|
var
|
||||||
documents = initTable[string, DocumentState]()
|
documents = initTable[string, DocumentState]()
|
||||||
@@ -336,6 +346,7 @@ proc analyzeFile(path: string, content: string): DocumentState =
|
|||||||
|
|
||||||
result.ifaceMethods = @[]
|
result.ifaceMethods = @[]
|
||||||
result.impls = @[]
|
result.impls = @[]
|
||||||
|
result.importPaths = @[]
|
||||||
|
|
||||||
while i < content.len:
|
while i < content.len:
|
||||||
let c = content[i]
|
let c = content[i]
|
||||||
@@ -429,6 +440,31 @@ proc analyzeFile(path: string, content: string): DocumentState =
|
|||||||
pendingInterface = iname
|
pendingInterface = iname
|
||||||
continue
|
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 }
|
# extend Type / impl Type [for Interface] — methods in following { block }
|
||||||
if atWord("extend") or atWord("impl"):
|
if atWord("extend") or atWord("impl"):
|
||||||
let kwLen = if content[i] == 'e': 6 else: 4
|
let kwLen = if content[i] == 'e': 6 else: 4
|
||||||
@@ -1148,6 +1184,7 @@ proc analyzeAndPublishDiagnostics(stream: FileStream, doc: DocumentState) =
|
|||||||
doc.members = updated.members
|
doc.members = updated.members
|
||||||
doc.ifaceMethods = updated.ifaceMethods
|
doc.ifaceMethods = updated.ifaceMethods
|
||||||
doc.impls = updated.impls
|
doc.impls = updated.impls
|
||||||
|
doc.importPaths = updated.importPaths
|
||||||
# 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)
|
||||||
@@ -1200,6 +1237,7 @@ proc ensureAnalyzed(doc: DocumentState) =
|
|||||||
doc.members = updated.members
|
doc.members = updated.members
|
||||||
doc.ifaceMethods = updated.ifaceMethods
|
doc.ifaceMethods = updated.ifaceMethods
|
||||||
doc.impls = updated.impls
|
doc.impls = updated.impls
|
||||||
|
doc.importPaths = updated.importPaths
|
||||||
|
|
||||||
proc completionKind(kind: string): int =
|
proc completionKind(kind: string): int =
|
||||||
case kind
|
case kind
|
||||||
@@ -1488,6 +1526,7 @@ type
|
|||||||
rtkGlobal ## free func / type / const (not method)
|
rtkGlobal ## free func / type / const (not method)
|
||||||
rtkMethod ## extend/impl method — decl + .Name( + bare Name(
|
rtkMethod ## extend/impl method — decl + .Name( + bare Name(
|
||||||
rtkMember ## struct field / enum variant
|
rtkMember ## struct field / enum variant
|
||||||
|
rtkPathSeg ## module path segment in import / A::B path (v0.12)
|
||||||
rtkUnknown
|
rtkUnknown
|
||||||
RenameTarget = object
|
RenameTarget = object
|
||||||
kind: RenameTargetKind
|
kind: RenameTargetKind
|
||||||
@@ -1501,6 +1540,8 @@ type
|
|||||||
local: LocalBinding
|
local: LocalBinding
|
||||||
## For rtkGlobal type symbols
|
## For rtkGlobal type symbols
|
||||||
isType: bool
|
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 =
|
proc peekAccessBefore(content: string, start: int): IdentAccess =
|
||||||
## Classify how the identifier at `start` is written.
|
## 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 =
|
proc isCallableKind(kind: string): bool =
|
||||||
kind == "function" or kind == "method"
|
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 =
|
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
|
result.isType = false
|
||||||
|
result.pathPrefix = @[]
|
||||||
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)
|
||||||
@@ -1742,6 +1841,35 @@ proc classifyRenameTarget(doc: DocumentState, word: string, line, col: int): Ren
|
|||||||
result.declCol = info.col
|
result.declCol = info.col
|
||||||
return
|
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
|
# 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:
|
||||||
@@ -1911,6 +2039,49 @@ proc collectReferences(doc: DocumentState, word: string, lineNum: int,
|
|||||||
result.add(locationJson(u, h.line, h.col, h.len))
|
result.add(locationJson(u, h.line, h.col, h.len))
|
||||||
return
|
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:
|
of rtkGlobal, rtkUnknown:
|
||||||
let isFileSym = doc.symbols.hasKey(word) or doc.typeIndex.hasKey(word)
|
let isFileSym = doc.symbols.hasKey(word) or doc.typeIndex.hasKey(word)
|
||||||
let isWsSym = workspaceSymbols.hasKey(word)
|
let isWsSym = workspaceSymbols.hasKey(word)
|
||||||
@@ -2600,7 +2771,7 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
|
|||||||
"workspaceSymbolProvider": true,
|
"workspaceSymbolProvider": true,
|
||||||
"callHierarchyProvider": 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:
|
if paramsNode.hasKey("rootPath") and paramsNode["rootPath"].kind != JNull:
|
||||||
rootPath = paramsNode["rootPath"].getStr()
|
rootPath = paramsNode["rootPath"].getStr()
|
||||||
@@ -2650,6 +2821,7 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
|
|||||||
doc.members = updated.members
|
doc.members = updated.members
|
||||||
doc.ifaceMethods = updated.ifaceMethods
|
doc.ifaceMethods = updated.ifaceMethods
|
||||||
doc.impls = updated.impls
|
doc.impls = updated.impls
|
||||||
|
doc.importPaths = updated.importPaths
|
||||||
# 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):
|
||||||
|
|||||||
Executable
+104
@@ -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)"
|
||||||
Reference in New Issue
Block a user