feat(lsp): workspace-wide import path index (0.14)

Index import paths from scanWorkspace so path classification works without
open documents. Smoke renames Std::Io with Util.bux closed on disk.
This commit is contained in:
2026-07-20 00:09:51 +03:00
parent a296f1695d
commit b332e98ab3
4 changed files with 131 additions and 11 deletions
+3
View File
@@ -188,6 +188,9 @@ test-lsp: lsp
@echo "==> LSP implementation smoke" @echo "==> LSP implementation smoke"
@chmod +x tools/smoke_lsp_implementation.sh @chmod +x tools/smoke_lsp_implementation.sh
@tools/smoke_lsp_implementation.sh @tools/smoke_lsp_implementation.sh
@echo "==> LSP workspace import index smoke"
@chmod +x tools/smoke_lsp_workspace_imports.sh
@tools/smoke_lsp_workspace_imports.sh
.PHONY: test-registry .PHONY: test-registry
test-registry: build test-registry: build
+18 -5
View File
@@ -1,7 +1,7 @@
# Bux — План към „добър“ език (v0.5 → v1.0) # Bux — План към „добър“ език (v0.5 → v1.0)
> **Дата:** 2026-07-19 > **Дата:** 2026-07-19
> **Текущо:** v0.5.x — **LSP 0.13 implementation**, HirNode sourceFile, optional selfhost-loop CI > **Текущо:** v0.5.x — **LSP 0.14 workspace imports**, implementation, selfhost-loop CI
> **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain. > **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain.
--- ---
@@ -786,9 +786,22 @@ A (stdlib ergonomics) → B (compiler holes) → C (ownership depth)
--- ---
## Сесия 50 (LSP 0.14 workspace import path index)
1. **`workspaceImportPaths`**: URI → full import paths (`["Std","Io"]`)
2. **`registerWorkspaceImports`** at end of `analyzeFile` (scan + open/edit)
3. **`isKnownImportPathPrefix`** uses workspace index first — no open doc required
4. Path segment snapshot fix (no shared seq mutation across segments)
5. Smoke: `tools/smoke_lsp_workspace_imports.sh`
- only Main opened; Util closed on disk via `rootUri` scan
- Io→Net ≥2 edits including `Util.bux` in WorkspaceEdit
6. Version **bux-lsp 0.14.0**; `make test-lsp`
---
## Следващи стъпки ## Следващи стъпки
1. Workspace-wide import path index without open documents (optional polish) 1. Expr/Stmt-level sourceFile if macros / cross-file inlining land
2. Expr/Stmt-level sourceFile if macros / cross-file inlining land 2. Fix selfhost C backend so buxc2→buxc3 fixed-point is green
3. Fix selfhost C backend so buxc2→buxc3 fixed-point is green 3. Main PR CI workflow (`make test`) beyond optional selfhost-loop
4. Main PR CI workflow (`make test`) beyond optional selfhost-loop 4. LSP type hierarchy / prepareTypeHierarchy (optional)
+30 -6
View File
@@ -15,6 +15,7 @@
# 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::{…}). # v0.12.0: module-path segment rename (import Std::Io / Std::Io::{…}).
# v0.13.0: textDocument/implementation (interface → types / methods). # v0.13.0: textDocument/implementation (interface → types / methods).
# v0.14.0: workspace-wide import path index (no open-doc required).
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
@@ -149,10 +150,25 @@ var
workspaceSymbols = initTable[string, tuple[uri: string, info: SymbolInfo]]() workspaceSymbols = initTable[string, tuple[uri: string, info: SymbolInfo]]()
## Cross-file: "Iface.Method" → list of implementors ## Cross-file: "Iface.Method" → list of implementors
workspaceImpls = initTable[string, seq[tuple[uri, typeName, meth: string]]]() workspaceImpls = initTable[string, seq[tuple[uri, typeName, meth: string]]]()
## Import paths by file URI (from scanWorkspace + open docs) — v0.14
## Each entry is a full path like @["Std", "Io"] (not open-doc dependent).
workspaceImportPaths = initTable[string, seq[seq[string]]]()
cachedStdlibDir = "" cachedStdlibDir = ""
cachedStdlibDecls: seq[Decl] = @[] cachedStdlibDecls: seq[Decl] = @[]
stdlibLoaded = false stdlibLoaded = false
proc registerWorkspaceImports(uri: string, segs: seq[PathSegInfo]) =
## Index unique full import paths for this URI (replaces prior entry).
var paths: seq[seq[string]] = @[]
var seen = initHashSet[string]()
for s in segs:
if s.path.len == 0: continue
let key = s.path.join("::")
if seen.contains(key): continue
seen.incl(key)
paths.add(s.path)
workspaceImportPaths[uri] = paths
proc getDoc(uri: string): DocumentState = proc getDoc(uri: string): DocumentState =
if not documents.hasKey(uri): if not documents.hasKey(uri):
documents[uri] = DocumentState(uri: uri) documents[uri] = DocumentState(uri: uri)
@@ -453,8 +469,11 @@ proc analyzeFile(path: string, content: string): DocumentState =
break break
let (line, col) = lineColAt(content, nameStart) let (line, col) = lineColAt(content, nameStart)
path.add(name) path.add(name)
# Snapshot path so later segments do not mutate earlier PathSegInfo
var pathSnap: seq[string] = @[]
for p in path: pathSnap.add(p)
result.importPaths.add(PathSegInfo( result.importPaths.add(PathSegInfo(
name: name, line: line, col: col, path: path, index: path.len - 1)) name: name, line: line, col: col, path: pathSnap, index: pathSnap.len - 1))
skipWs(content, i) skipWs(content, i)
if i + 1 < content.len and content[i] == ':' and content[i + 1] == ':': if i + 1 < content.len and content[i] == ':' and content[i + 1] == ':':
i += 2 i += 2
@@ -608,6 +627,9 @@ proc analyzeFile(path: string, content: string): DocumentState =
if not matchedTypeKw: if not matchedTypeKw:
inc i inc i
# Always refresh workspace import index for this URI (empty clears stale paths)
registerWorkspaceImports(result.uri, result.importPaths)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Real sema types for hover # Real sema types for hover
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1751,12 +1773,14 @@ proc pathStartsWith(path, prefix: seq[string]): bool =
true true
proc isKnownImportPathPrefix(prefix: seq[string], name: string): bool = proc isKnownImportPathPrefix(prefix: seq[string], name: string): bool =
## True if some open document's import path starts with prefix ++ name. ## True if any workspace (or open-doc) import path starts with prefix ++ name.
## Uses workspaceImportPaths from scanWorkspace — no open document required.
let full = prefix & name let full = prefix & name
for _, paths in workspaceImportPaths.pairs:
for p in paths:
if pathStartsWith(p, full):
return true
for _, d in documents.pairs: 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: for seg in d.importPaths:
if pathStartsWith(seg.path, full): if pathStartsWith(seg.path, full):
return true return true
@@ -2914,7 +2938,7 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
"callHierarchyProvider": true, "callHierarchyProvider": true,
"implementationProvider": true "implementationProvider": true
}, },
"serverInfo": {"name": "bux-lsp", "version": "0.13.0"} "serverInfo": {"name": "bux-lsp", "version": "0.14.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()
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Smoke: workspace-wide import path index (bux-lsp 0.14)
# Util.bux is never opened — only indexed via scanWorkspace(rootUri).
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
# Only Main is opened. Util lives on disk and must still be renamed via
# workspace import index + path-seg disk scan.
cat > "$TMP/Main.bux" <<'EOF'
import Std::Io::{PrintLine};
func Main() -> int {
PrintLine("hi");
return 0;
}
EOF
cat > "$TMP/Util.bux" <<'EOF'
import Std::Io::PrintLine;
func Util() -> int {
return 0;
}
EOF
# Extra module path only in closed file — classification of use sites can still
# match workspace-known prefixes after scan.
cat > "$TMP/Closed.bux" <<'EOF'
import Vendor::Crypto::Hash;
func Closed() -> 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"
# Open ONLY Main — Util/Closed stay closed (indexed by initialized → scanWorkspace)
{
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 Io under Std (Main open; Util closed) → Net
rpc '{"jsonrpc":"2.0","id":2,"method":"textDocument/rename","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":0,"character":12},"newName":"Net"}}'
rpc '{"jsonrpc":"2.0","id":3,"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.14.0' "$TMP/out.txt"; then
echo "WARN: version not 0.14.0"
fi
# Io→Net must hit Main + closed Util (≥2). URI for Util should appear in changes.
net_edits=$(grep -o '"newText":"Net"' "$TMP/out.txt" | wc -l)
if [[ "$net_edits" -lt 2 ]]; then
echo "FAIL: Io→Net expected ≥2 edits with Util closed, got $net_edits"
cat "$TMP/out.txt"
exit 1
fi
echo " path Io→Net edits (Main open, Util closed): $net_edits"
# Workspace edit should mention Util.bux
if ! grep -q 'Util.bux' "$TMP/out.txt"; then
echo "FAIL: rename did not include closed Util.bux in WorkspaceEdit"
cat "$TMP/out.txt"
exit 1
fi
echo " closed Util.bux included in WorkspaceEdit"
echo "PASS: LSP workspace import path index (0.14)"