feat: LSP references/rename (0.5) and expand CI make test

Add workspace-aware Find All References and Rename for scoped locals and
globals, plus registry/apps/dwarf smokes in the default test target.

- bux-lsp: textDocument/references, prepareRename, rename
- make test-lsp runs smoke_lsp_rename; make test includes registry/dwarf/apps
This commit is contained in:
2026-07-19 22:49:45 +03:00
parent eb81856565
commit cd7a82822c
5 changed files with 383 additions and 6 deletions
+4 -1
View File
@@ -19,7 +19,7 @@ dev:
debug: dev
@echo "Debug binary: buxc_debug"
test: build fmt-check test-examples test-errors test-stdlib
test: build fmt-check test-examples test-errors test-stdlib test-registry test-dwarf test-apps
@echo "Running lexer tests..."
$(NIM) c -r tests/lexer_test.nim
@echo "Running parser tests..."
@@ -190,6 +190,9 @@ test-lsp: lsp
@echo "=== LSP hover smoke ==="
@chmod +x tools/smoke_lsp_hover.sh
@tools/smoke_lsp_hover.sh
@echo "=== LSP references / rename smoke ==="
@chmod +x tools/smoke_lsp_rename.sh
@tools/smoke_lsp_rename.sh
.PHONY: test-registry
test-registry: build
+4 -2
View File
@@ -135,8 +135,10 @@ make test-examples # all examples/ programs (40+)
make test-errors # golden Rust-style diagnostic output
make test-stdlib # stdlib golden packages
make test-registry # package registry (local + HTTP index)
make test-apps # showcase apps build + simpledb/jwt CLI smoke
make test-dwarf # #line maps + .debug_info + --release (E.4)
make test-apps # showcase apps build + simpledb/jwt CLI smoke (in `make test`)
make test-dwarf # #line maps + .debug_info + --release (in `make test`)
make test-registry # package registry local + HTTP (in `make test`)
make test-lsp # hover + references/rename smokes
make bench # micro-benchmarks (Bux + C/Nim/Zig twins)
make bench-nexus # wrk throughput vs apps/nexus /api/health
```
+1 -1
View File
@@ -1,7 +1,7 @@
# Bux — План към „добър“ език (v0.5 → v1.0)
> **Дата:** 2026-07-19
> **Текущо:** v0.5.x — registry HTTP, apps smoke, E.5 benches, **E.4 DWARF `#line` + gdb**
> **Текущо:** v0.5.x — E.4 DWARF, E.5 benches, **LSP 0.5 references/rename**, CI apps/dwarf/registry
> **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain.
---
+287 -2
View File
@@ -6,6 +6,7 @@
#
# Hover uses real bootstrap sema types when possible (globals + stdlib).
# Locals are position-sensitive (scoped) and include inferred `let` types (v0.4.0).
# v0.5.0: textDocument/references + rename (scoped locals + workspace globals).
import std/[json, os, strutils, streams, tables, osproc, sequtils, sets]
import lexer, parser, ast, sema, types, scope, source_location
@@ -1239,6 +1240,279 @@ proc handleHover(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
"end": {"line": lineNum, "character": endC}
}
})
# ---------------------------------------------------------------------------
# Identifier occurrences (references / rename) — E.1 tooling polish / session 34
# ---------------------------------------------------------------------------
type
IdentHit = object
line: int ## 0-based
col: int ## 0-based start of name
len: int
proc collectIdentHits(content, name: string): seq[IdentHit] =
## Textual identifier occurrences of `name`, skipping strings/comments.
result = @[]
if name.len == 0 or content.len == 0:
return
var i = 0
var inLineComment = false
var inBlockComment = false
var inString = false
var stringDelim = '\0'
var escape = false
while i < content.len:
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 {'"', '`'} or (c == 'f' and i + 1 < content.len and content[i + 1] == '"'):
inString = true
if c == 'f':
stringDelim = '"'
i += 2
else:
stringDelim = c
inc i
continue
if isIdentStart(c):
let start = i
var j = i + 1
while j < content.len and isIdentChar(content[j]):
inc j
let ident = content[start ..< j]
if ident == name:
let (line, col) = lineColAt(content, start)
result.add(IdentHit(line: line, col: col, len: name.len))
i = j
continue
inc i
proc sameLocal*(a, b: LocalBinding): bool =
a.name == b.name and a.declLine == b.declLine and a.declCol == b.declCol and
a.container == b.container
proc locationJson(uri: string, line, col, nameLen: int): JsonNode =
%*{
"uri": uri,
"range": {
"start": {"line": line, "character": col},
"end": {"line": line, "character": col + nameLen}
}
}
proc collectReferences(doc: DocumentState, word: string, lineNum: int,
includeDecl: bool): seq[JsonNode] =
## Collect LSP Location nodes for references at `word` on `lineNum`.
result = @[]
if word.len == 0: return
ensureAnalyzed(doc)
if doc.locals.len == 0 and doc.content.len > 0:
enrichWithSema(doc)
let (lok, targetLocal) = lookupLocalAt(doc, word, lineNum)
let hits = collectIdentHits(doc.content, word)
if lok:
# Scoped local / param: only occurrences that resolve to the same binding
for h in hits:
let (ok, b) = lookupLocalAt(doc, word, h.line)
if not ok or not sameLocal(b, targetLocal):
continue
if not includeDecl and h.line == targetLocal.declLine and h.col == targetLocal.declCol:
continue
result.add(locationJson(doc.uri, h.line, h.col, h.len))
return
# File-level or workspace symbol: all textual hits in this document
let isFileSym = doc.symbols.hasKey(word) or doc.typeIndex.hasKey(word)
let isWsSym = workspaceSymbols.hasKey(word)
if not isFileSym and not isWsSym:
# Still report textual hits in current file (e.g. undeclared / mid-edit)
for h in hits:
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
result.add(locationJson(doc.uri, h.line, h.col, h.len))
# Workspace: other open buffers + on-disk .bux under rootPath
if isWsSym or isFileSym:
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)
for h in collectIdentHits(d.content, word):
result.add(locationJson(u, h.line, h.col, h.len))
if rootPath.len > 0 and dirExists(rootPath):
var stack: seq[tuple[dir: string, depth: int]] = @[(rootPath, 0)]
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):
result.add(locationJson(u, h.line, h.col, h.len))
except CatchableError:
discard
except CatchableError:
discard
proc handleReferences(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
let uri = paramsNode["textDocument"]["uri"].getStr()
let position = paramsNode["position"]
let lineNum = position["line"].getInt()
let col = position["character"].getInt()
var includeDecl = true
if paramsNode.hasKey("context") and paramsNode["context"].hasKey("includeDeclaration"):
includeDecl = paramsNode["context"]["includeDeclaration"].getBool()
let doc = getDoc(uri)
if doc.content == "":
sendResponse(stream, id, %*[])
return
let word = findWordAt(doc.content, lineNum, col)
if word.len == 0:
sendResponse(stream, id, %*[])
return
var arr = newJArray()
for loc in collectReferences(doc, word, lineNum, includeDecl):
arr.add(loc)
sendResponse(stream, id, arr)
proc isValidIdentName(s: string): bool =
if s.len == 0: return false
if not isIdentStart(s[0]): return false
for i in 1 ..< s.len:
if not isIdentChar(s[i]): return false
true
proc handlePrepareRename(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
let uri = paramsNode["textDocument"]["uri"].getStr()
let position = paramsNode["position"]
let lineNum = position["line"].getInt()
let col = position["character"].getInt()
let doc = getDoc(uri)
if doc.content == "":
sendResponse(stream, id, newJNull())
return
let lines = doc.content.split("\n")
if lineNum >= lines.len:
sendResponse(stream, id, newJNull())
return
let l = lines[lineNum]
var start = min(col, l.len)
var endC = start
while start > 0 and l[start - 1] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}:
dec start
while endC < l.len and l[endC] in {'a'..'z', 'A'..'Z', '0'..'9', '_'}:
inc endC
if start >= endC:
sendResponse(stream, id, newJNull())
return
let word = l[start ..< endC]
# Reject keywords
const kws = ["func", "var", "let", "if", "else", "while", "for", "return",
"struct", "enum", "true", "false", "null", "self", "match",
"import", "module", "type", "const", "pub", "own"]
if word in kws:
sendResponse(stream, id, newJNull())
return
sendResponse(stream, id, %*{
"range": {
"start": {"line": lineNum, "character": start},
"end": {"line": lineNum, "character": endC}
},
"placeholder": word
})
proc handleRename(stream: FileStream, id: JsonNode, paramsNode: JsonNode) =
let uri = paramsNode["textDocument"]["uri"].getStr()
let position = paramsNode["position"]
let lineNum = position["line"].getInt()
let col = position["character"].getInt()
let newName = if paramsNode.hasKey("newName"): paramsNode["newName"].getStr() else: ""
if not isValidIdentName(newName):
sendError(stream, id, -32602, "invalid identifier: '" & newName & "'")
return
let doc = getDoc(uri)
if doc.content == "":
sendResponse(stream, id, %*{"changes": newJObject()})
return
let word = findWordAt(doc.content, lineNum, col)
if word.len == 0:
sendResponse(stream, id, %*{"changes": newJObject()})
return
if word == newName:
sendResponse(stream, id, %*{"changes": newJObject()})
return
let refs = collectReferences(doc, word, lineNum, includeDecl = true)
# Group TextEdits by URI
var byUri = initTable[string, JsonNode]()
for loc in refs:
let u = loc["uri"].getStr()
if not byUri.hasKey(u):
byUri[u] = newJArray()
let r = loc["range"]
byUri[u].add(%*{
"range": r,
"newText": newName
})
var changes = newJObject()
for u, edits in byUri.pairs:
changes[u] = edits
sendResponse(stream, id, %*{"changes": changes})
# ---------------------------------------------------------------------------
# Document symbols (outline)
# ---------------------------------------------------------------------------
@@ -1302,9 +1576,11 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
"completionProvider": {"triggerCharacters": [".", ":"]},
"definitionProvider": true,
"hoverProvider": true,
"documentSymbolProvider": true
"documentSymbolProvider": true,
"referencesProvider": true,
"renameProvider": {"prepareProvider": true}
},
"serverInfo": {"name": "bux-lsp", "version": "0.4.0"}
"serverInfo": {"name": "bux-lsp", "version": "0.5.0"}
})
if paramsNode.hasKey("rootPath") and paramsNode["rootPath"].kind != JNull:
rootPath = paramsNode["rootPath"].getStr()
@@ -1379,6 +1655,15 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
of "textDocument/documentSymbol":
handleDocumentSymbol(stream, id, paramsNode)
of "textDocument/references":
handleReferences(stream, id, paramsNode)
of "textDocument/prepareRename":
handlePrepareRename(stream, id, paramsNode)
of "textDocument/rename":
handleRename(stream, id, paramsNode)
else:
if id != nil:
sendError(stream, id, -32601, "method not found: " & methodName)
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Smoke: textDocument/references + rename (bux-lsp 0.5.0)
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
echo "building bux-lsp..."
(cd "$ROOT" && make lsp >/dev/null)
fi
cat > "$TMP/Main.bux" <<'EOF'
func Add(a: int, b: int) -> int {
let sum = a + b;
return sum;
}
func Main() -> int {
let n = 10;
return Add(n, 2);
}
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"'}}}'
# references on `sum` (line 1, col 8)
rpc '{"jsonrpc":"2.0","id":2,"method":"textDocument/references","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":1,"character":8},"context":{"includeDeclaration":true}}}'
# prepareRename on sum
rpc '{"jsonrpc":"2.0","id":3,"method":"textDocument/prepareRename","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":1,"character":8}}}'
# rename sum → total
rpc '{"jsonrpc":"2.0","id":4,"method":"textDocument/rename","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":1,"character":8},"newName":"total"}}'
# references on Add (line 0)
rpc '{"jsonrpc":"2.0","id":5,"method":"textDocument/references","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":0,"character":5},"context":{"includeDeclaration":true}}}'
rpc '{"jsonrpc":"2.0","id":6,"method":"shutdown","params":null}'
rpc '{"jsonrpc":"2.0","method":"exit","params":null}'
} | "$LSP" 2>/dev/null | tr '\r' '\n' > "$TMP/out.txt"
echo "---- excerpt ----"
# Show initialize capabilities mention
if ! grep -q 'referencesProvider' "$TMP/out.txt"; then
echo "FAIL: initialize missing referencesProvider"
cat "$TMP/out.txt"
exit 1
fi
if ! grep -q 'renameProvider' "$TMP/out.txt"; then
echo "FAIL: initialize missing renameProvider"
exit 1
fi
if ! grep -q '0.5.0' "$TMP/out.txt"; then
echo "WARN: version not 0.5.0 in initialize (may be ok)"
fi
# Rename workspace edit should propose "total"
if ! grep -q '"newText":"total"' "$TMP/out.txt"; then
echo "FAIL: rename did not emit newText total"
cat "$TMP/out.txt"
exit 1
fi
# Should have at least 2 edits for sum (decl + return)
total_edits=$(grep -o '"newText":"total"' "$TMP/out.txt" | wc -l)
if [[ "$total_edits" -lt 2 ]]; then
echo "FAIL: expected ≥2 renames for sum, got $total_edits"
exit 1
fi
echo " rename sum→total edits: $total_edits"
# References responses are JSON arrays with uri/range — check line numbers for sum
if ! grep -q 'Main.bux' "$TMP/out.txt"; then
echo "FAIL: no file URI in responses"
exit 1
fi
echo "PASS: LSP references + rename smoke"