feat: macros (multi-rep, hygiene), Drop field-move, lean multi-OS CI
ci / build (ubuntu) (push) Has been cancelled
ci / unit + fmt (push) Has been cancelled
ci / examples (push) Has been cancelled
ci / goldens + tools (push) Has been cancelled
ci / apps (push) Has been cancelled
ci / selfhost smoke (push) Has been cancelled
ci / macos smoke (push) Has been cancelled
ci / windows smoke (push) Has been cancelled
ci / CI gate (push) Has been cancelled
selfhost-loop / bootstrap determinism (push) Has been cancelled

Sessions 56–69: declarative macro! with rep/zip/literal/block and
unhygienic var $name binders; partial field-move skip Drop; @[Release]
polish; LSP type hierarchy; CI Nim cache + lean macOS + Windows smoke.
This commit is contained in:
2026-07-20 17:19:46 +03:00
parent 6f2a3b1d88
commit fe3b1e8b6a
41 changed files with 5281 additions and 141 deletions
+65 -4
View File
@@ -17,6 +17,8 @@
# v0.13.0: textDocument/implementation (interface → types / methods).
# v0.14.0: workspace-wide import path index (no open-doc required).
# v0.15.0: type hierarchy (prepare / supertypes / subtypes via extend for).
# v0.16.0: workspace type-impl index — hierarchy works for closed multi-file
# docs even when `extend T for I` has no methods (no open required).
import std/[json, os, strutils, streams, tables, osproc, sequtils, sets]
import lexer, parser, ast, sema, types, scope, source_location
@@ -154,6 +156,10 @@ var
## 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]]]()
## Type ↔ interface relations from `extend Type for Iface` (v0.16).
## Keyed by file URI so re-analyze replaces stale entries (open or closed).
## Does not require methods in the extend body (unlike workspaceImpls).
workspaceTypeRels = initTable[string, seq[tuple[typeName, iface: string, line: int]]]()
cachedStdlibDir = ""
cachedStdlibDecls: seq[Decl] = @[]
stdlibLoaded = false
@@ -170,6 +176,11 @@ proc registerWorkspaceImports(uri: string, segs: seq[PathSegInfo]) =
paths.add(s.path)
workspaceImportPaths[uri] = paths
proc registerWorkspaceTypeRels(uri: string, impls: seq[tuple[typeName, iface: string, line: int]]) =
## Replace type↔interface relations for this URI (from analyzeFile `impls`).
## Empty impls clears prior entries so deleted extends disappear from hierarchy.
workspaceTypeRels[uri] = impls
proc getDoc(uri: string): DocumentState =
if not documents.hasKey(uri):
documents[uri] = DocumentState(uri: uri)
@@ -630,6 +641,8 @@ proc analyzeFile(path: string, content: string): DocumentState =
# Always refresh workspace import index for this URI (empty clears stale paths)
registerWorkspaceImports(result.uri, result.importPaths)
# Type hierarchy / implementation: keep extend-for relations for closed files
registerWorkspaceTypeRels(result.uri, result.impls)
# ---------------------------------------------------------------------------
# Real sema types for hover
@@ -2631,8 +2644,30 @@ proc collectImplementorFuncs(iface, meth: string): seq[FuncSym] =
proc collectTypeImplementorLocs(iface: string): seq[JsonNode] =
## Locations of types that `extend Type for iface`.
## Uses workspace type-rel index so closed multi-file works without methods.
result = @[]
var seen = initHashSet[string]()
# 1) Workspace type relations
for uri, rels in workspaceTypeRels.pairs:
for impl in rels:
if impl.iface != iface: continue
let key = uri & "#" & impl.typeName
if seen.contains(key): continue
seen.incl(key)
if workspaceSymbols.hasKey(impl.typeName):
let ws = workspaceSymbols[impl.typeName]
result.add(locationJson(ws.uri, ws.info.line, ws.info.col, impl.typeName.len))
elif documents.hasKey(uri):
let doc = documents[uri]
ensureAnalyzed(doc)
if doc.symbols.hasKey(impl.typeName):
let info = doc.symbols[impl.typeName]
result.add(locationJson(uri, info.line, info.col, impl.typeName.len))
else:
result.add(locationJson(uri, impl.line, 0, max(1, impl.typeName.len)))
else:
result.add(locationJson(uri, impl.line, 0, max(1, impl.typeName.len)))
# 2) Open docs
for uri, doc in documents.pairs:
ensureAnalyzed(doc)
for impl in doc.impls:
@@ -2644,9 +2679,8 @@ proc collectTypeImplementorLocs(iface: string): seq[JsonNode] =
let info = doc.symbols[impl.typeName]
result.add(locationJson(uri, info.line, info.col, impl.typeName.len))
else:
# Fall back to the `extend` line
result.add(locationJson(uri, impl.line, 0, max(1, impl.typeName.len)))
# Derive types from workspaceImpls (Iface.Method → typeName)
# 3) Fallback: workspaceImpls (Iface.Method → typeName)
for wkey, impls in workspaceImpls.pairs:
if not wkey.startsWith(iface & "."): continue
for impl in impls:
@@ -2990,8 +3024,22 @@ proc typeHierarchyItemSynthetic(uri: string, name: string, kind: string, line: i
proc collectSubtypeItems(iface: string): seq[JsonNode] =
## Types that `extend Type for iface`.
## Prefer workspace type-rel index (closed multi-file; empty extend bodies OK).
result = @[]
var seen = initHashSet[string]()
# 1) Workspace type relations (scan + every analyzeFile) — no open required
for uri, rels in workspaceTypeRels.pairs:
for impl in rels:
if impl.iface != iface: continue
let key = uri & "#" & impl.typeName
if seen.contains(key): continue
seen.incl(key)
let (ok, u, info) = resolveTypeSymbol(impl.typeName, uri)
if ok:
result.add(typeHierarchyItem(u, impl.typeName, info))
else:
result.add(typeHierarchyItemSynthetic(uri, impl.typeName, "struct", impl.line))
# 2) Open docs (live buffer may differ from last register)
for uri, doc in documents.pairs:
ensureAnalyzed(doc)
for impl in doc.impls:
@@ -3004,7 +3052,7 @@ proc collectSubtypeItems(iface: string): seq[JsonNode] =
result.add(typeHierarchyItem(u, impl.typeName, info))
else:
result.add(typeHierarchyItemSynthetic(uri, impl.typeName, "struct", impl.line))
# workspaceImpls: "Iface.Method" → (uri, typeName, meth)
# 3) Fallback: workspaceImpls "Iface.Method" (methods in extend body)
for wkey, impls in workspaceImpls.pairs:
if not wkey.startsWith(iface & "."): continue
for impl in impls:
@@ -3021,6 +3069,18 @@ proc collectSupertypeItems(typeName: string): seq[JsonNode] =
## Interfaces that `typeName` implements via `extend typeName for I`.
result = @[]
var seen = initHashSet[string]()
# 1) Workspace type relations (closed multi-file)
for uri, rels in workspaceTypeRels.pairs:
for impl in rels:
if impl.typeName != typeName: continue
if seen.contains(impl.iface): continue
seen.incl(impl.iface)
let (ok, u, info) = resolveTypeSymbol(impl.iface, uri)
if ok:
result.add(typeHierarchyItem(u, impl.iface, info))
else:
result.add(typeHierarchyItemSynthetic(uri, impl.iface, "interface", impl.line))
# 2) Open docs
for uri, doc in documents.pairs:
ensureAnalyzed(doc)
for impl in doc.impls:
@@ -3032,6 +3092,7 @@ proc collectSupertypeItems(typeName: string): seq[JsonNode] =
result.add(typeHierarchyItem(u, impl.iface, info))
else:
result.add(typeHierarchyItemSynthetic(uri, impl.iface, "interface", impl.line))
# 3) Fallback: method-based workspaceImpls
for wkey, impls in workspaceImpls.pairs:
for impl in impls:
if impl.typeName != typeName: continue
@@ -3137,7 +3198,7 @@ proc handleMessage(stream: FileStream, msg: JsonNode) =
"implementationProvider": true,
"typeHierarchyProvider": true
},
"serverInfo": {"name": "bux-lsp", "version": "0.15.0"}
"serverInfo": {"name": "bux-lsp", "version": "0.16.0"}
})
if paramsNode.hasKey("rootPath") and paramsNode["rootPath"].kind != JNull:
rootPath = paramsNode["rootPath"].getStr()
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Golden-ish smoke: field-move + partial field-move Drop emission.
# Ensures C for TakeItems has no Bag_Drop (would double-free returned Array).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUXC="${BUXC:-$ROOT/buxc}"
export BUX_STDLIB="${BUX_STDLIB:-$ROOT/lib}"
unset BUX_DEBUG_FILE || true
if [[ ! -x "$BUXC" ]]; then
(cd "$ROOT" && make build)
fi
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
# --- move_field (whole local into field) ---
echo "=== smoke: move_field ==="
mkdir -p "$TMP/mf/src"
cp -a "$ROOT/rt" "$TMP/mf/"
cat > "$TMP/mf/bux.toml" <<'EOF'
[Package]
Name = "move_field"
Version = "0.1.0"
Type = "bin"
[Build]
Output = "Bin"
EOF
cp "$ROOT/examples/move_field.bux" "$TMP/mf/src/Main.bux"
(cd "$TMP/mf" && "$BUXC" run .)
# Only the MakeBox *body* (not prototypes / other functions)
if sed -n '/^Box MakeBox(void) {/,/^}/p' "$TMP/mf/build/main.c" | grep -q 'Array_Drop\|Bag_Drop'; then
echo "error: MakeBox still drops moved Array" >&2
sed -n '/^Box MakeBox(void) {/,/^}/p' "$TMP/mf/build/main.c"
exit 1
fi
echo " move_field: PASS (run + no Array_Drop of moved local)"
# --- partial field move ---
echo "=== smoke: move_field_partial ==="
mkdir -p "$TMP/mp/src"
cp -a "$ROOT/rt" "$TMP/mp/"
cat > "$TMP/mp/bux.toml" <<'EOF'
[Package]
Name = "move_field_partial"
Version = "0.1.0"
Type = "bin"
[Build]
Output = "Bin"
EOF
cp "$ROOT/examples/move_field_partial.bux" "$TMP/mp/src/Main.bux"
(cd "$TMP/mp" && "$BUXC" run .)
# TakeItems must not call Bag_Drop after moving bag.items out
if sed -n '/^Array_int TakeItems/,/^}/p' "$TMP/mp/build/main.c" | grep -q 'Bag_Drop'; then
echo "error: TakeItems still Bag_Drops after partial field move" >&2
sed -n '/^Array_int TakeItems/,/^}/p' "$TMP/mp/build/main.c"
exit 1
fi
echo " move_field_partial: PASS (run + TakeItems has no Bag_Drop)"
# --- early return Drop counts ---
echo "=== smoke: drop_early_return ==="
mkdir -p "$TMP/de/src"
cp -a "$ROOT/rt" "$TMP/de/"
cat > "$TMP/de/bux.toml" <<'EOF'
[Package]
Name = "drop_early_return"
Version = "0.1.0"
Type = "bin"
[Build]
Output = "Bin"
EOF
cp "$ROOT/examples/drop_early_return.bux" "$TMP/de/src/Main.bux"
out=$(cd "$TMP/de" && "$BUXC" run .)
echo "$out" | grep -q 'PASS'
echo " drop_early_return: PASS"
echo "PASS: smoke_drop_move (field-move + partial + early-return)"
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Smoke: type hierarchy prepare / subtypes / supertypes (bux-lsp 0.15)
# Smoke: type hierarchy prepare / subtypes / supertypes (single-file; bux-lsp 0.15+)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
LSP="$ROOT/tools/bux-lsp"
@@ -62,8 +62,8 @@ URI="file://$TMP/Main.bux"
rpc '{"jsonrpc":"2.0","method":"exit","params":null}'
} | "$LSP" 2>/dev/null | tr '\r' '\n' > "$TMP/out.txt"
if ! grep -q '0.15.0' "$TMP/out.txt"; then
echo "WARN: version not 0.15.0"
if ! grep -qE '0\.(15|16)\.0' "$TMP/out.txt"; then
echo "WARN: unexpected LSP version (expected 0.15+)"
fi
if ! grep -q 'typeHierarchyProvider' "$TMP/out.txt"; then
@@ -120,5 +120,5 @@ if 'Drawable' not in snames:
sys.exit(1)
print(f' supertypes Circle → {sorted(snames)}')
print('PASS: LSP type hierarchy (0.15)')
print('PASS: LSP type hierarchy (single-file)')
PY
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# Smoke: type hierarchy across closed multi-file workspace (bux-lsp 0.16)
# Only Main.bux is opened. Drawable.bux + Shapes.bux stay closed (scanWorkspace).
# Empty `extend T for I {}` bodies — no methods — must still populate hierarchy
# via workspaceTypeRels (not method-only workspaceImpls).
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/Drawable.bux" <<'EOF'
interface Drawable {
func Draw(self: &Self);
}
interface Named {
}
EOF
cat > "$TMP/Shapes.bux" <<'EOF'
struct Circle {
radius: int;
}
struct Square {
side: int;
}
// Empty extend bodies — no methods; relation must still be indexed
extend Circle for Drawable {
}
extend Square for Drawable {
}
extend Circle for Named {
}
EOF
# Only this file is opened. Types are closed on disk.
cat > "$TMP/Main.bux" <<'EOF'
func Use(d: Drawable, c: Circle) -> int {
return 0;
}
func Main() -> 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"
# Drawable at col 12, Circle at col 25 in "func Use(d: Drawable, c: Circle)..."
DRAW_COL=12
CIRC_COL=25
{
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"'}}}'
# prepare on Drawable (type annotation; def in closed Drawable.bux)
rpc '{"jsonrpc":"2.0","id":2,"method":"textDocument/prepareTypeHierarchy","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":0,"character":'"$DRAW_COL"'}}}'
# subtypes of Drawable → Circle + Square (closed Shapes.bux, empty extends)
rpc '{"jsonrpc":"2.0","id":3,"method":"typeHierarchy/subtypes","params":{"item":{"name":"Drawable","kind":11,"uri":"file://'"$TMP"'/Drawable.bux","data":"Drawable","range":{"start":{"line":0,"character":0},"end":{"line":0,"character":8}},"selectionRange":{"start":{"line":0,"character":0},"end":{"line":0,"character":8}}}}}'
# prepare on Circle
rpc '{"jsonrpc":"2.0","id":4,"method":"textDocument/prepareTypeHierarchy","params":{"textDocument":{"uri":"'"$URI"'"},"position":{"line":0,"character":'"$CIRC_COL"'}}}'
# supertypes of Circle → Drawable + Named
rpc '{"jsonrpc":"2.0","id":5,"method":"typeHierarchy/supertypes","params":{"item":{"name":"Circle","kind":23,"uri":"file://'"$TMP"'/Shapes.bux","data":"Circle","range":{"start":{"line":0,"character":0},"end":{"line":0,"character":6}},"selectionRange":{"start":{"line":0,"character":0},"end":{"line":0,"character":6}}}}}'
# subtypes of Named → Circle only
rpc '{"jsonrpc":"2.0","id":6,"method":"typeHierarchy/subtypes","params":{"item":{"name":"Named","kind":11,"uri":"file://'"$TMP"'/Drawable.bux","data":"Named","range":{"start":{"line":0,"character":0},"end":{"line":0,"character":5}},"selectionRange":{"start":{"line":0,"character":0},"end":{"line":0,"character":5}}}}}'
rpc '{"jsonrpc":"2.0","id":7,"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.16.0' "$TMP/out.txt"; then
echo "WARN: version not 0.16.0"
fi
python3 - <<'PY' "$TMP/out.txt" "$TMP"
import json, sys, re
raw = open(sys.argv[1]).read()
tmp = sys.argv[2]
got = {}
for p in re.split(r'Content-Length:\s*\d+\s*', raw):
p = p.strip()
if not p.startswith('{'):
continue
try:
j = json.loads(p)
except Exception:
continue
if 'id' in j and 'result' in j:
got[j['id']] = j['result']
def names(r):
if not isinstance(r, list):
return set()
return {x.get('name') for x in r if isinstance(x, dict)}
def uris(r):
if not isinstance(r, list):
return set()
return {x.get('uri', '') for x in r if isinstance(x, dict)}
r2 = got.get(2) or []
if 'Drawable' not in names(r2):
print('FAIL: prepare Drawable (closed def) missing')
print(got.get(2))
sys.exit(1)
# Prefer closed file URI for the type item
u2 = uris(r2)
if not any('Drawable.bux' in u for u in u2):
print(f'FAIL: prepare Drawable should point at Drawable.bux, got {u2}')
sys.exit(1)
print(' prepare Drawable → closed Drawable.bux: OK')
r3 = got.get(3) or []
n3 = names(r3)
if 'Circle' not in n3 or 'Square' not in n3:
print(f'FAIL: subtypes Drawable expected Circle+Square (empty extends), got {n3}')
print(json.dumps(r3, indent=2)[:800])
sys.exit(1)
u3 = uris(r3)
if not any('Shapes.bux' in u for u in u3):
print(f'FAIL: subtypes should reference closed Shapes.bux, got {u3}')
sys.exit(1)
print(f' subtypes Drawable → {sorted(n3)} (closed, empty extend): OK')
r4 = got.get(4) or []
if 'Circle' not in names(r4):
print('FAIL: prepare Circle missing')
print(got.get(4))
sys.exit(1)
print(' prepare Circle → closed Shapes.bux: OK')
r5 = got.get(5) or []
n5 = names(r5)
if 'Drawable' not in n5 or 'Named' not in n5:
print(f'FAIL: supertypes Circle expected Drawable+Named, got {n5}')
print(json.dumps(r5, indent=2)[:800])
sys.exit(1)
print(f' supertypes Circle → {sorted(n5)}: OK')
r6 = got.get(6) or []
n6 = names(r6)
if n6 != {'Circle'}:
print(f'FAIL: subtypes Named expected only Circle, got {n6}')
sys.exit(1)
print(f' subtypes Named → {sorted(n6)}: OK')
print('PASS: LSP type hierarchy workspace index (0.16)')
PY
+86 -1
View File
@@ -166,4 +166,89 @@ if ! echo "$main_body" | grep -vE '#line 1 "' | grep -qE '#line [0-9]+ ".*Main\.
fi
echo " Expr/Stmt sourceFile: PASS (Main stmts → Main.bux only)"
echo "PASS: selfhost smoke (move_field + multi-file #line + HirNode/Expr sourceFile)"
# ---------------------------------------------------------------------------
# 5) Binary op parentheses — C precedence must not rewrite Mul(Add,c)
# Without parens selfhost emitted `a + b * c` → 7 instead of (a+b)*c → 9
# ---------------------------------------------------------------------------
echo "=== selfhost: binary op parentheses (C precedence) ==="
PREC="$TMP/c_precedence"
mkdir -p "$PREC/src"
cp -a "$ROOT/rt" "$PREC/"
cat > "$PREC/bux.toml" <<'EOF'
[Package]
Name = "c_precedence"
Version = "0.1.0"
Type = "bin"
[Build]
Output = "Bin"
EOF
cp "$ROOT/examples/c_precedence.bux" "$PREC/src/Main.bux"
(cd "$PREC" && "$BUXC2" project .)
prec_out=$("$PREC/build/c_precedence")
echo "$prec_out" | tee "$TMP/prec.out"
grep -q 'PASS c_precedence' "$TMP/prec.out"
# Generated C must parenthesize the sum before multiply
if ! grep -A3 '^int MulSum' "$PREC/build/main.c" | grep -qE '\(a \+ b\) \* c|\(\(a \+ b\) \* c\)'; then
echo "error: MulSum C lacks parentheses around a+b before *c" >&2
grep -n -A5 '^int MulSum' "$PREC/build/main.c" | head -20
exit 1
fi
if ! grep -A3 '^int SubDiv' "$PREC/build/main.c" | grep -qE '\(a - b\) / c|\(\(a - b\) / c\)'; then
echo "error: SubDiv C lacks parentheses around a-b before /c" >&2
grep -n -A5 '^int SubDiv' "$PREC/build/main.c" | head -20
exit 1
fi
echo " binary parens: PASS (run 9/3/6/7 + C has (a + b) * c)"
# ---------------------------------------------------------------------------
# 6) declarative macro! / quote! expand (session 60 selfhost parity)
# ---------------------------------------------------------------------------
echo "=== selfhost: macro! expand ==="
MAC="$TMP/macro_twice"
mkdir -p "$MAC/src"
cp -a "$ROOT/rt" "$MAC/"
cat > "$MAC/bux.toml" <<'EOF'
[Package]
Name = "macro_twice"
Version = "0.1.0"
Type = "bin"
[Build]
Output = "Bin"
EOF
cp "$ROOT/examples/macro_twice.bux" "$MAC/src/Main.bux"
(cd "$MAC" && "$BUXC2" project .)
mac_out=$("$MAC/build/macro_twice")
echo "$mac_out" | tee "$TMP/mac.out"
grep -q 'PASS macro_twice' "$TMP/mac.out"
grep -q '42' "$TMP/mac.out"
echo " macro!: PASS (twice/add2/quote → 42/42/43)"
# ---------------------------------------------------------------------------
# 7) multi-rep / compound zip / nested template $(…)* (session 63)
# ---------------------------------------------------------------------------
echo "=== selfhost: macro_nested multi-rep ==="
MACN="$TMP/macro_nested"
mkdir -p "$MACN/src"
cp -a "$ROOT/rt" "$MACN/"
cat > "$MACN/bux.toml" <<'EOF'
[Package]
Name = "macro_nested"
Version = "0.1.0"
Type = "bin"
[Build]
Output = "Bin"
EOF
cp "$ROOT/examples/macro_nested.bux" "$MACN/src/Main.bux"
(cd "$MACN" && "$BUXC2" project .)
macn_out=$("$MACN/build/macro_nested")
echo "$macn_out" | tee "$TMP/macn.out"
grep -q 'PASS macro_nested' "$TMP/macn.out"
grep -q '33' "$TMP/macn.out"
grep -q '63' "$TMP/macn.out"
echo " macro_nested: PASS (add_pairs/sum_groups/double_each/named_sum)"
echo "PASS: selfhost smoke (move_field + multi-file #line + HirNode/Expr sourceFile + binop parens + macro! + multi-rep)"