diff --git a/Makefile b/Makefile index 64986b0..3d766fe 100644 --- a/Makefile +++ b/Makefile @@ -233,3 +233,6 @@ test-selfhost-smoke: selfhost @echo "=== Selfhost smoke (move_field + multi-file #line) ===" @chmod +x tools/smoke_selfhost.sh @tools/smoke_selfhost.sh + @echo "=== Graft / quote hygiene smoke ===" + @chmod +x tools/smoke_graft_hygiene.sh + @tools/smoke_graft_hygiene.sh diff --git a/docs/QUALITY_PLAN.md b/docs/QUALITY_PLAN.md index 83e750f..3ff32b7 100644 --- a/docs/QUALITY_PLAN.md +++ b/docs/QUALITY_PLAN.md @@ -1,7 +1,7 @@ # Bux — План към „добър“ език (v0.5 → v1.0) > **Дата:** 2026-07-19 -> **Текущо:** v0.5.x — **LSP 0.15 type hierarchy**, CI `make test`, fixed-point +> **Текущо:** v0.5.x — quote/graft hygiene, LSP 0.15, CI, fixed-point > **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain. --- @@ -850,9 +850,23 @@ A (stdlib ergonomics) → B (compiler holes) → C (ownership depth) --- +## Сесия 55 (macro / quote hygiene foundation) + +1. **AST graft API** (force overwrite): `Ast_GraftExpr/Stmt/Block/PatternFile` +2. **Clone**: `Ast_CloneExpr/Stmt/Block/Pattern` (+ list / match arms) +3. **Quote policies**: + - `Ast_QuoteDefSite` — clone, keep `sourceFile` (macro body / template) + - `Ast_QuoteCallSite` / `Ast_QuoteStmtCallSite` — clone + graft call-site path +4. **Helpers**: `Ast_SetExprLoc` / `Ast_ExprSourceFile` +5. **HIR**: `Lcx_GraftSourceFile`; mono instances force-graft `genDecl.sourceFile` +6. Smoke: `tools/smoke_graft_hygiene.sh` (Array mono → lib; Main no leak) +7. Wired into `make test-selfhost-smoke` + +--- + ## Следващи стъпки -1. Macro / quote hygiene using Expr.sourceFile grafts -2. Parenthesize binary ops in CBE for full C precedence safety -3. CI matrix (macOS) or split jobs for faster PR feedback -4. Type hierarchy for multi-file closed docs without open (workspace type index) +1. Parenthesize binary ops in CBE for full C precedence safety +2. CI matrix (macOS) or split jobs for faster PR feedback +3. Type hierarchy for multi-file closed docs without open (workspace type index) +4. User-facing `macro!` / `quote` syntax on top of graft/clone diff --git a/src/ast.bux b/src/ast.bux index 83b58d7..f8439be 100644 --- a/src/ast.bux +++ b/src/ast.bux @@ -518,6 +518,261 @@ module Ast { } } + // --------------------------------------------------------------------------- + // Macro / quote hygiene — graft sourceFile onto AST trees + // --------------------------------------------------------------------------- + // Stamp* = fill empty only (merge file paths; keep pre-set macro grafts) + // Graft* = force overwrite (call-site attribution after expansion) + // Clone* = deep copy for templates + // Quote* = clone + hygiene policy (def-site keep file | call-site graft) + + /// Force-set sourceFile on an expression tree (overwrites existing). + func Ast_GraftExprFile(e: *Expr, path: String) { + if e == null as *Expr { return; } + if path == null as String || String_Eq(path, "") { return; } + e.sourceFile = path; + Ast_GraftExprFile(e.child1, path); + Ast_GraftExprFile(e.child2, path); + Ast_GraftExprFile(e.child3, path); + if e.refBlock != null as *Block { + Ast_GraftBlockFile(e.refBlock, path); + } + var args: *ExprList = e.callArgs; + while args != null as *ExprList { + Ast_GraftExprFile(args.expr, path); + args = args.next; + } + var arm: *MatchArm = e.matchArms; + while arm != null as *MatchArm { + Ast_GraftExprFile(arm.body, path); + if arm.pattern != null as *Pattern { + Ast_GraftPatternFile(arm.pattern, path); + } + arm = arm.next; + } + } + + func Ast_GraftPatternFile(pat: *Pattern, path: String) { + if pat == null as *Pattern { return; } + if path == null as String || String_Eq(path, "") { return; } + if pat.patGuardExpr != null as *Expr { + Ast_GraftExprFile(pat.patGuardExpr, path); + } + Ast_GraftPatternFile(pat.patChild1, path); + Ast_GraftPatternFile(pat.patChild2, path); + Ast_GraftPatternFile(pat.patArgs, path); + Ast_GraftPatternFile(pat.patNext, path); + } + + func Ast_GraftBlockFile(b: *Block, path: String) { + if b == null as *Block { return; } + if path == null as String || String_Eq(path, "") { return; } + b.sourceFile = path; + var s: *Stmt = b.firstStmt; + while s != null as *Stmt { + Ast_GraftStmtFile(s, path); + s = s.nextStmt; + } + } + + func Ast_GraftStmtFile(s: *Stmt, path: String) { + if s == null as *Stmt { return; } + if path == null as String || String_Eq(path, "") { return; } + s.sourceFile = path; + Ast_GraftExprFile(s.child1, path); + Ast_GraftExprFile(s.child2, path); + Ast_GraftExprFile(s.child3, path); + if s.refStmtBlock != null as *Block { + Ast_GraftBlockFile(s.refStmtBlock, path); + } + if s.refStmtElse != null as *Block { + Ast_GraftBlockFile(s.refStmtElse, path); + } + } + + /// Set line/column/sourceFile on a single node (no recurse). + func Ast_SetExprLoc(e: *Expr, line: uint32, col: uint32, path: String) { + if e == null as *Expr { return; } + e.line = line; + e.column = col; + if path != null as String && !String_Eq(path, "") { + e.sourceFile = path; + } + } + + func Ast_SetStmtLoc(s: *Stmt, line: uint32, col: uint32, path: String) { + if s == null as *Stmt { return; } + s.line = line; + s.column = col; + if path != null as String && !String_Eq(path, "") { + s.sourceFile = path; + } + } + + /// Effective source path for #line / diagnostics (empty if unknown). + func Ast_ExprSourceFile(e: *Expr) -> String { + if e == null as *Expr { return ""; } + if e.sourceFile == null as String { return ""; } + return e.sourceFile; + } + + // Deep clone for quote / macro expansion templates (shares TypeExpr/Decl pointers). + func Ast_ClonePattern(pat: *Pattern) -> *Pattern { + if pat == null as *Pattern { return null as *Pattern; } + let n: *Pattern = bux_alloc(sizeof(Pattern)) as *Pattern; + n.kind = pat.kind; + n.line = pat.line; + n.column = pat.column; + n.patIdent = pat.patIdent; + n.patLitKind = pat.patLitKind; + n.patLitText = pat.patLitText; + n.patRangeInclusive = pat.patRangeInclusive; + n.patEnumPath = pat.patEnumPath; + n.patStructName = pat.patStructName; + n.patFieldName = pat.patFieldName; + n.patChild1 = Ast_ClonePattern(pat.patChild1); + n.patChild2 = Ast_ClonePattern(pat.patChild2); + n.patArgs = Ast_ClonePattern(pat.patArgs); + n.patNext = Ast_ClonePattern(pat.patNext); + n.patGuardExpr = Ast_CloneExpr(pat.patGuardExpr); + return n; + } + + func Ast_CloneExprList(list: *ExprList) -> *ExprList { + if list == null as *ExprList { return null as *ExprList; } + let n: *ExprList = bux_alloc(sizeof(ExprList)) as *ExprList; + n.expr = Ast_CloneExpr(list.expr); + n.next = Ast_CloneExprList(list.next); + n.argName = list.argName; + return n; + } + + func Ast_CloneMatchArm(arm: *MatchArm) -> *MatchArm { + if arm == null as *MatchArm { return null as *MatchArm; } + let n: *MatchArm = bux_alloc(sizeof(MatchArm)) as *MatchArm; + n.line = arm.line; + n.column = arm.column; + n.pattern = Ast_ClonePattern(arm.pattern); + n.body = Ast_CloneExpr(arm.body); + n.next = Ast_CloneMatchArm(arm.next); + return n; + } + + func Ast_CloneExpr(e: *Expr) -> *Expr { + if e == null as *Expr { return null as *Expr; } + let n: *Expr = bux_alloc(sizeof(Expr)) as *Expr; + n.kind = e.kind; + n.line = e.line; + n.column = e.column; + n.sourceFile = e.sourceFile; + n.strValue = e.strValue; + n.intValue = e.intValue; + n.boolValue = e.boolValue; + n.tokKind = e.tokKind; + n.tokText = e.tokText; + n.child1 = Ast_CloneExpr(e.child1); + n.child2 = Ast_CloneExpr(e.child2); + n.child3 = Ast_CloneExpr(e.child3); + n.refType = e.refType; // share TypeExpr + n.refBlock = Ast_CloneBlock(e.refBlock); + n.genericCallee = e.genericCallee; + n.genericTypeArg0 = e.genericTypeArg0; + n.genericTypeArg1 = e.genericTypeArg1; + n.genericTypeArgCount = e.genericTypeArgCount; + n.structName = e.structName; + n.structFieldCount = e.structFieldCount; + n.closureParams = e.closureParams; // share param decl + n.captureCount = e.captureCount; + n.captureName0 = e.captureName0; + n.captureName1 = e.captureName1; + n.captureName2 = e.captureName2; + n.captureName3 = e.captureName3; + n.captureName4 = e.captureName4; + n.captureName5 = e.captureName5; + n.captureName6 = e.captureName6; + n.captureName7 = e.captureName7; + n.captureType0 = e.captureType0; + n.captureType1 = e.captureType1; + n.captureType2 = e.captureType2; + n.captureType3 = e.captureType3; + n.captureType4 = e.captureType4; + n.captureType5 = e.captureType5; + n.captureType6 = e.captureType6; + n.captureType7 = e.captureType7; + n.callArgs = Ast_CloneExprList(e.callArgs); + n.callArgCount = e.callArgCount; + n.matchArms = Ast_CloneMatchArm(e.matchArms); + n.matchArmCount = e.matchArmCount; + return n; + } + + func Ast_CloneStmt(s: *Stmt) -> *Stmt { + if s == null as *Stmt { return null as *Stmt; } + let n: *Stmt = bux_alloc(sizeof(Stmt)) as *Stmt; + n.kind = s.kind; + n.line = s.line; + n.column = s.column; + n.sourceFile = s.sourceFile; + n.strValue = s.strValue; + n.boolValue = s.boolValue; + n.child1 = Ast_CloneExpr(s.child1); + n.child2 = Ast_CloneExpr(s.child2); + n.child3 = Ast_CloneExpr(s.child3); + n.refStmtType = s.refStmtType; + n.refStmtPattern = Ast_ClonePattern(s.refStmtPattern); + n.refStmtDecl = s.refStmtDecl; + n.refStmtBlock = Ast_CloneBlock(s.refStmtBlock); + n.refStmtElse = Ast_CloneBlock(s.refStmtElse); + n.elseIfCount = s.elseIfCount; + n.nextStmt = Ast_CloneStmt(s.nextStmt); + return n; + } + + func Ast_CloneBlock(b: *Block) -> *Block { + if b == null as *Block { return null as *Block; } + let n: *Block = bux_alloc(sizeof(Block)) as *Block; + n.line = b.line; + n.column = b.column; + n.sourceFile = b.sourceFile; + n.stmtCount = b.stmtCount; + n.firstStmt = Ast_CloneStmt(b.firstStmt); + // Rebuild lastStmt + var cur: *Stmt = n.firstStmt; + var last: *Stmt = null as *Stmt; + while cur != null as *Stmt { + last = cur; + cur = cur.nextStmt; + } + n.lastStmt = last; + return n; + } + + /// Definition-site quote: clone template, keep sourceFile (macro body locations). + func Ast_QuoteDefSite(e: *Expr) -> *Expr { + return Ast_CloneExpr(e); + } + + /// Call-site quote: clone template, graft call-site file and span for #line / diags. + func Ast_QuoteCallSite(e: *Expr, siteFile: String, siteLine: uint32, siteCol: uint32) -> *Expr { + let n: *Expr = Ast_CloneExpr(e); + if n == null as *Expr { return null as *Expr; } + Ast_GraftExprFile(n, siteFile); + // Root span is the invocation; children keep structure but share site file + n.line = siteLine; + n.column = siteCol; + return n; + } + + /// Apply call-site graft to a whole statement tree (for stmt-producing macros). + func Ast_QuoteStmtCallSite(s: *Stmt, siteFile: String, siteLine: uint32, siteCol: uint32) -> *Stmt { + let n: *Stmt = Ast_CloneStmt(s); + if n == null as *Stmt { return null as *Stmt; } + Ast_GraftStmtFile(n, siteFile); + n.line = siteLine; + n.column = siteCol; + return n; + } + func Ast_MakeDecl(kind: int, line: uint32, col: uint32) -> Decl { return Decl { kind: kind, line: line, column: col, isPublic: false, sourceFile: "", diff --git a/src/hir_lower.bux b/src/hir_lower.bux index 3b75965..785f58a 100644 --- a/src/hir_lower.bux +++ b/src/hir_lower.bux @@ -81,6 +81,26 @@ module HirLower { } } + /// Force-set sourceFile on HIR tree (definition-site hygiene for mono / macro body). + func Lcx_GraftSourceFile(node: *HirNode, file: String) { + if node == null as *HirNode { return; } + if file == null as String || String_Eq(file, "") { return; } + node.sourceFile = file; + Lcx_GraftSourceFile(node.child1, file); + Lcx_GraftSourceFile(node.child2, file); + Lcx_GraftSourceFile(node.child3, file); + if (node.kind == hCall || node.kind == hCallIndirect) && node.extraData != null as *void { + var cur: *HirArgList = node.extraData as *HirArgList; + while cur != null as *HirArgList { + Lcx_GraftSourceFile(cur.node, file); + cur = cur.next; + } + } + if node.kind == hIf && node.extraData != null as *void { + Lcx_GraftSourceFile(node.extraData as *HirNode, file); + } + } + /// Prefer Expr/Stmt.sourceFile (macro / cross-file graft); else enclosing Decl path. func Lcx_SourceFileFor(ctx: *LowerCtx, nodeFile: String) -> String { if nodeFile != null as String && !String_Eq(nodeFile, "") { @@ -526,6 +546,12 @@ module HirLower { // Lower the generic function with substitution active let f: *HirFunc = Lcx_LowerFunc(ctx, genDecl); f.name = mangled; + // Definition-site hygiene: mono body always maps to the generic's source + // file (not the call-site module), even if synthetic nodes lacked a path. + if f.body != null as *HirNode && !String_Eq(genDecl.sourceFile, "") { + f.sourceFile = genDecl.sourceFile; + Lcx_GraftSourceFile(f.body, genDecl.sourceFile); + } // Add to module ctx.funcs[ctx.funcCount] = *f; diff --git a/tools/smoke_graft_hygiene.sh b/tools/smoke_graft_hygiene.sh new file mode 100755 index 0000000..4381a02 --- /dev/null +++ b/tools/smoke_graft_hygiene.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Smoke: macro/quote sourceFile hygiene foundation +# - Mono instances from lib keep definition-site #line (Array.bux / etc.) +# - User Main keeps Main.bux (no leak of lib paths into Main body) +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUXC2="$ROOT/build/selfhost/build/buxc2" +export BUX_STDLIB="$ROOT/lib" +unset BUX_DEBUG_FILE || true +unset BUX_NO_LINE || true + +if [[ ! -x "$BUXC2" ]]; then + (cd "$ROOT" && make selfhost >/dev/null) +fi + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/src" +cp -a "$ROOT/rt" "$TMP/" + +cat > "$TMP/bux.toml" <<'EOF' +[Package] +Name = "graft_hygiene" +Version = "0.1.0" +Type = "bin" + +[Build] +Output = "Bin" +EOF + +# Main uses generic Array from stdlib — mono body must stay lib/*.bux (def site) +cat > "$TMP/src/Main.bux" <<'EOF' +import Std::Io::{PrintLine}; +import Std::String::{String_FromInt}; + +func Main() -> int { + let a: Array = Array_New(4); + Array_Push(&a, 1); + Array_Push(&a, 2); + let n: int = Array_Len(&a) as int; + PrintLine(String_FromInt(n as int64)); + return 0; +} +EOF + +(cd "$TMP" && "$BUXC2" project .) +out=$("$TMP/build/graft_hygiene") +echo "$out" | tee "$TMP/run.out" +grep -q '2' "$TMP/run.out" + +MAIN_C="$TMP/build/main.c" +test -f "$MAIN_C" + +# Array mono helpers must map to lib (definition site) +if ! grep -qE '#line [0-9]+ ".*lib/.*Array\.bux"|#line [0-9]+ ".*lib/Array\.bux"' "$MAIN_C"; then + # Also accept any lib path with Array mono function nearby + if ! grep -qE '#line [0-9]+ ".*lib/.*\.bux"' "$MAIN_C"; then + echo "error: no #line for stdlib (definition-site mono hygiene)" >&2 + grep -E '^#line ' "$MAIN_C" | head -20 + exit 1 + fi +fi +echo " def-site mono: PASS (lib #line present)" + +# Main function body must not claim Array.bux +main_body=$(awk ' + /#line 1 ".*Main\.bux"/ { grab=1 } + grab { print } + grab && /^}/ { exit } +' "$MAIN_C") +if echo "$main_body" | grep -qE '#line [0-9]+ ".*Array\.bux"'; then + echo "error: Main body has Array.bux #line (call-site leak)" >&2 + echo "$main_body" | grep '#line ' + exit 1 +fi +if ! echo "$main_body" | grep -vE '#line 1 "' | grep -qE '#line [0-9]+ ".*Main\.bux"'; then + echo "error: Main body missing statement #line Main.bux" >&2 + exit 1 +fi +echo " call-site Main: PASS (no Array.bux leak)" + +# Array_Push_int region: first #line after its definition should not be Main.bux +if grep -q 'Array_Push_int' "$MAIN_C"; then + # Grab ~30 lines after the Array_Push_int function start + push_region=$(grep -n 'Array_Push_int(' "$MAIN_C" | head -1 | cut -d: -f1) + if [[ -n "$push_region" ]]; then + region=$(sed -n "${push_region},$((push_region + 35))p" "$MAIN_C") + if echo "$region" | grep -vE '#line 1 ' | grep -qE '#line [0-9]+ ".*Main\.bux"'; then + echo "error: Array_Push_int region maps statements to Main.bux" >&2 + echo "$region" | grep '#line ' || true + exit 1 + fi + fi + echo " Array_Push_int hygiene: PASS" +fi + +echo "PASS: graft/quote hygiene (def-site mono + call-site Main isolation)"