diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 79ef30b..940eb86 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,4 +1,6 @@
-# Default PR / main CI: full `make test` (not selfhost fixed-point — see selfhost-loop.yml).
+# Default PR / main CI: split Linux jobs + lean macOS + Windows bootstrap smoke.
+# Full sequential suite locally: `make test`.
+# Selfhost fixed-point: see selfhost-loop.yml (not on every PR).
name: ci
on:
@@ -11,42 +13,403 @@ concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
+env:
+ # Pin patch for stable toolchain cache keys (was 2.0.x).
+ NIM_VERSION: "2.0.8"
+ # setup-nim-action install dir (relative to workspace)
+ NIM_INSTALL_DIR: ".nim_runtime"
+
jobs:
- test:
- name: make test
+ # ── Shared bootstrap build (Linux) ──────────────────────────────────────
+ build:
+ name: build (ubuntu)
runs-on: ubuntu-latest
- timeout-minutes: 90
+ timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
+ - name: Cache Nim toolchain
+ id: cache-nim
+ uses: actions/cache@v4
+ with:
+ path: ${{ env.NIM_INSTALL_DIR }}
+ key: ${{ runner.os }}-nim-${{ env.NIM_VERSION }}-v1
+
- name: Install Nim
+ if: steps.cache-nim.outputs.cache-hit != 'true'
uses: jiro4989/setup-nim-action@v2
with:
- nim-version: "2.0.x"
+ nim-version: ${{ env.NIM_VERSION }}
+ nim-install-directory: ${{ env.NIM_INSTALL_DIR }}
repo-token: ${{ secrets.GITHUB_TOKEN }}
+ - name: PATH for cached Nim
+ if: steps.cache-nim.outputs.cache-hit == 'true'
+ run: |
+ echo "$PWD/${{ env.NIM_INSTALL_DIR }}/bin" >> "$GITHUB_PATH"
+ echo "$HOME/.nimble/bin" >> "$GITHUB_PATH"
+
+ - name: Cache nimcache (bootstrap)
+ uses: actions/cache@v4
+ with:
+ path: nimcache
+ key: ${{ runner.os }}-nimcache-build-${{ hashFiles('bootstrap/**/*.nim') }}-v1
+ restore-keys: |
+ ${{ runner.os }}-nimcache-build-
+
- name: Install build deps
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc make binutils libssl-dev python3
- - name: Bootstrap + full test suite
+ - name: Build buxc
+ run: |
+ nim -v
+ make build
+
+ - name: Upload buxc
+ uses: actions/upload-artifact@v4
+ with:
+ name: buxc-linux
+ path: buxc
+ retention-days: 3
+
+ # ── Parallel Linux suites (reuse prebuilt buxc) ─────────────────────────
+ unit:
+ name: unit + fmt
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Cache Nim toolchain
+ id: cache-nim
+ uses: actions/cache@v4
+ with:
+ path: ${{ env.NIM_INSTALL_DIR }}
+ key: ${{ runner.os }}-nim-${{ env.NIM_VERSION }}-v1
+
+ - name: Install Nim
+ if: steps.cache-nim.outputs.cache-hit != 'true'
+ uses: jiro4989/setup-nim-action@v2
+ with:
+ nim-version: ${{ env.NIM_VERSION }}
+ nim-install-directory: ${{ env.NIM_INSTALL_DIR }}
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: PATH for cached Nim
+ if: steps.cache-nim.outputs.cache-hit == 'true'
+ run: |
+ echo "$PWD/${{ env.NIM_INSTALL_DIR }}/bin" >> "$GITHUB_PATH"
+ echo "$HOME/.nimble/bin" >> "$GITHUB_PATH"
+
+ - name: Cache nimcache (unit tests)
+ uses: actions/cache@v4
+ with:
+ path: nimcache
+ key: ${{ runner.os }}-nimcache-unit-${{ hashFiles('bootstrap/**/*.nim', 'tests/**/*.nim') }}-v1
+ restore-keys: |
+ ${{ runner.os }}-nimcache-unit-
+ ${{ runner.os }}-nimcache-build-
+
+ - name: Install build deps
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends gcc make libssl-dev
+
+ - name: Download buxc
+ uses: actions/download-artifact@v4
+ with:
+ name: buxc-linux
+
+ - name: Prepare buxc
+ run: chmod +x buxc && ./buxc --version
+
+ - name: fmt-check + unit tests
env:
- # Ensure registry / selfhost smokes see a clean env
- BUX_NO_LINE: ""
+ BUX_SKIP_BUILD: "1"
+ run: |
+ unset BUX_DEBUG_FILE || true
+ make fmt-check BUX_SKIP_BUILD=1
+ make test-unit BUX_SKIP_BUILD=1
+
+ examples:
+ name: examples
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 40
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install build deps
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends gcc make libssl-dev
+ - name: Download buxc
+ uses: actions/download-artifact@v4
+ with:
+ name: buxc-linux
+ - name: Prepare buxc
+ run: chmod +x buxc && ./buxc --version
+ - name: test-examples
+ env:
+ BUX_SKIP_BUILD: "1"
+ run: |
+ unset BUX_DEBUG_FILE || true
+ make test-examples BUX_SKIP_BUILD=1
+
+ goldens:
+ name: goldens + tools
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install build deps
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ gcc make binutils libssl-dev python3 gdb
+ - name: Download buxc
+ uses: actions/download-artifact@v4
+ with:
+ name: buxc-linux
+ - name: Prepare buxc
+ run: chmod +x buxc && ./buxc --version
+ - name: errors + stdlib + registry + dwarf
+ env:
+ BUX_SKIP_BUILD: "1"
+ run: |
+ unset BUX_DEBUG_FILE || true
+ make test-errors BUX_SKIP_BUILD=1
+ make test-stdlib BUX_SKIP_BUILD=1
+ make test-registry BUX_SKIP_BUILD=1
+ make test-dwarf BUX_SKIP_BUILD=1
+ make test-drop-move BUX_SKIP_BUILD=1
+
+ apps:
+ name: apps
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install build deps
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends gcc make libssl-dev
+ - name: Download buxc
+ uses: actions/download-artifact@v4
+ with:
+ name: buxc-linux
+ - name: Prepare buxc
+ run: chmod +x buxc && ./buxc --version
+ - name: test-apps
+ env:
+ BUX_SKIP_BUILD: "1"
+ run: |
+ unset BUX_DEBUG_FILE || true
+ make test-apps BUX_SKIP_BUILD=1
+
+ selfhost:
+ name: selfhost smoke
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install build deps
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends gcc make libssl-dev
+ - name: Download buxc
+ uses: actions/download-artifact@v4
+ with:
+ name: buxc-linux
+ - name: Prepare buxc
+ run: chmod +x buxc && ./buxc --version
+ - name: test-selfhost-smoke
+ env:
+ BUX_SKIP_BUILD: "1"
run: |
unset BUX_DEBUG_FILE || true
unset BUX_SELFHOST_FIXED_POINT || true
- make test
-
+ make test-selfhost-smoke BUX_SKIP_BUILD=1
- name: Upload selfhost artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
- name: ci-failure-logs
+ name: ci-failure-selfhost
path: |
build/selfhost/build/main.c
_test_tmp_pkg/**
if-no-files-found: ignore
+
+ # ── macOS smoke (platform matrix) — lean: cache Nim + smoke examples ───
+ # Full EXAMPLES / goldens / selfhost stay on Linux. macOS still builds
+ # bootstrap + runs unit tests + a representative example subset.
+ macos:
+ name: macos smoke
+ runs-on: macos-14
+ timeout-minutes: 35
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Cache Nim toolchain
+ id: cache-nim
+ uses: actions/cache@v4
+ with:
+ path: ${{ env.NIM_INSTALL_DIR }}
+ # macOS builds Nim from source — cache is the main time win
+ key: ${{ runner.os }}-nim-${{ env.NIM_VERSION }}-v1
+
+ - name: Install Nim
+ if: steps.cache-nim.outputs.cache-hit != 'true'
+ uses: jiro4989/setup-nim-action@v2
+ with:
+ nim-version: ${{ env.NIM_VERSION }}
+ nim-install-directory: ${{ env.NIM_INSTALL_DIR }}
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: PATH for cached Nim
+ if: steps.cache-nim.outputs.cache-hit == 'true'
+ run: |
+ echo "$PWD/${{ env.NIM_INSTALL_DIR }}/bin" >> "$GITHUB_PATH"
+ echo "$HOME/.nimble/bin" >> "$GITHUB_PATH"
+
+ - name: Cache nimcache (bootstrap + unit)
+ uses: actions/cache@v4
+ with:
+ path: nimcache
+ key: ${{ runner.os }}-nimcache-macos-${{ hashFiles('bootstrap/**/*.nim', 'tests/**/*.nim') }}-v1
+ restore-keys: |
+ ${{ runner.os }}-nimcache-macos-
+
+ - name: OpenSSL (Homebrew)
+ run: |
+ # Prefer already-installed openssl@3 (common on GHA images)
+ if ! brew list openssl@3 &>/dev/null; then
+ brew install openssl@3
+ fi
+ OPENSSL_PREFIX="$(brew --prefix openssl@3)"
+ echo "OPENSSL_PREFIX=$OPENSSL_PREFIX" >> "$GITHUB_ENV"
+ # buxc passes BUX_CFLAGS through to cc (needed for Homebrew libcrypto)
+ echo "BUX_CFLAGS=-I${OPENSSL_PREFIX}/include -L${OPENSSL_PREFIX}/lib" >> "$GITHUB_ENV"
+
+ - name: Build buxc
+ run: |
+ nim -v
+ make build
+
+ - name: unit + smoke examples
+ run: |
+ unset BUX_DEBUG_FILE || true
+ # fmt-check is Linux-only in split CI (unit job); skip here to save time
+ make test-unit
+ make test-examples-smoke
+
+ # ── Windows smoke (bootstrap + pure Nim unit tests) ─────────────────────
+ # Full bux→C examples need POSIX runtime (ucontext / pthread sockets in
+ # rt/runtime.c) — not ported yet. This job still catches Nim/bootstrap
+ # regressions on Windows (prebuilt Nim zip = fast install).
+ windows:
+ name: windows smoke
+ runs-on: windows-latest
+ timeout-minutes: 30
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Cache Nim toolchain
+ id: cache-nim
+ uses: actions/cache@v4
+ with:
+ path: ${{ env.NIM_INSTALL_DIR }}
+ key: ${{ runner.os }}-nim-${{ env.NIM_VERSION }}-v1
+
+ - name: Install Nim
+ if: steps.cache-nim.outputs.cache-hit != 'true'
+ uses: jiro4989/setup-nim-action@v2
+ with:
+ nim-version: ${{ env.NIM_VERSION }}
+ nim-install-directory: ${{ env.NIM_INSTALL_DIR }}
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: PATH for cached Nim
+ if: steps.cache-nim.outputs.cache-hit == 'true'
+ run: |
+ echo "$PWD/${{ env.NIM_INSTALL_DIR }}/bin" >> "$GITHUB_PATH"
+ echo "$HOME/.nimble/bin" >> "$GITHUB_PATH"
+
+ - name: Cache nimcache (bootstrap + unit)
+ uses: actions/cache@v4
+ with:
+ path: nimcache
+ key: ${{ runner.os }}-nimcache-win-${{ hashFiles('bootstrap/**/*.nim', 'tests/**/*.nim') }}-v1
+ restore-keys: |
+ ${{ runner.os }}-nimcache-win-
+
+ - name: Build buxc
+ run: |
+ set -e
+ nim -v
+ # Windows produces buxc.exe; keep name predictable for the smoke steps
+ nim c --nimcache:nimcache -o:buxc.exe -d:release --opt:size bootstrap/main.nim
+ ./buxc.exe --version
+
+ - name: Pure Nim unit tests + CLI smoke
+ run: |
+ set -e
+ unset BUX_DEBUG_FILE || true
+ export NIMFLAGS=--nimcache:nimcache
+ echo "Running lexer tests..."
+ nim c $NIMFLAGS -r tests/lexer_test.nim
+ echo "Running parser tests..."
+ nim c $NIMFLAGS -r tests/parser_test.nim
+ echo "Running sema tests..."
+ nim c $NIMFLAGS -r tests/sema_test.nim
+ echo "Running HIR tests..."
+ nim c $NIMFLAGS -r tests/hir_test.nim
+ echo "Running borrow checker tests..."
+ nim c $NIMFLAGS -r tests/borrow_test.nim
+ echo "CLI smoke..."
+ rm -rf _test_tmp_pkg
+ ./buxc.exe new _test_tmp_pkg
+ ./buxc.exe --version
+ echo "windows smoke: PASS (bootstrap + unit + CLI)"
+
+ # ── Single required status for branch protection ────────────────────────
+ ci-gate:
+ name: CI gate
+ if: always()
+ needs: [unit, examples, goldens, apps, selfhost, macos, windows]
+ runs-on: ubuntu-latest
+ steps:
+ - name: All jobs green?
+ run: |
+ set -e
+ echo "unit=${{ needs.unit.result }}"
+ echo "examples=${{ needs.examples.result }}"
+ echo "goldens=${{ needs.goldens.result }}"
+ echo "apps=${{ needs.apps.result }}"
+ echo "selfhost=${{ needs.selfhost.result }}"
+ echo "macos=${{ needs.macos.result }}"
+ echo "windows=${{ needs.windows.result }}"
+ for r in \
+ "${{ needs.unit.result }}" \
+ "${{ needs.examples.result }}" \
+ "${{ needs.goldens.result }}" \
+ "${{ needs.apps.result }}" \
+ "${{ needs.selfhost.result }}" \
+ "${{ needs.macos.result }}" \
+ "${{ needs.windows.result }}"; do
+ if [ "$r" != "success" ]; then
+ echo "CI gate failed: a required job is $r"
+ exit 1
+ fi
+ done
+ echo "CI gate: all required jobs passed"
diff --git a/.github/workflows/selfhost-loop.yml b/.github/workflows/selfhost-loop.yml
index 2e3441e..12c168d 100644
--- a/.github/workflows/selfhost-loop.yml
+++ b/.github/workflows/selfhost-loop.yml
@@ -28,6 +28,10 @@ concurrency:
group: selfhost-loop-${{ github.ref }}
cancel-in-progress: true
+env:
+ NIM_VERSION: "2.0.8"
+ NIM_INSTALL_DIR: ".nim_runtime"
+
jobs:
selfhost-loop:
name: bootstrap determinism
@@ -37,12 +41,36 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
+ - name: Cache Nim toolchain
+ id: cache-nim
+ uses: actions/cache@v4
+ with:
+ path: ${{ env.NIM_INSTALL_DIR }}
+ key: ${{ runner.os }}-nim-${{ env.NIM_VERSION }}-v1
+
- name: Install Nim
+ if: steps.cache-nim.outputs.cache-hit != 'true'
uses: jiro4989/setup-nim-action@v2
with:
- nim-version: "2.0.x"
+ nim-version: ${{ env.NIM_VERSION }}
+ nim-install-directory: ${{ env.NIM_INSTALL_DIR }}
repo-token: ${{ secrets.GITHUB_TOKEN }}
+ - name: PATH for cached Nim
+ if: steps.cache-nim.outputs.cache-hit == 'true'
+ run: |
+ echo "$PWD/${{ env.NIM_INSTALL_DIR }}/bin" >> "$GITHUB_PATH"
+ echo "$HOME/.nimble/bin" >> "$GITHUB_PATH"
+
+ - name: Cache nimcache
+ uses: actions/cache@v4
+ with:
+ path: nimcache
+ key: ${{ runner.os }}-nimcache-loop-${{ hashFiles('bootstrap/**/*.nim', 'src/**/*.bux') }}-v1
+ restore-keys: |
+ ${{ runner.os }}-nimcache-loop-
+ ${{ runner.os }}-nimcache-build-
+
- name: Install build deps
run: |
sudo apt-get update
diff --git a/.gitignore b/.gitignore
index d73f263..27fdd46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,4 @@ _test_*/
# Log files
*.log
+.nim_runtime/
diff --git a/Makefile b/Makefile
index 3d766fe..0854724 100644
--- a/Makefile
+++ b/Makefile
@@ -2,41 +2,63 @@ NIM := nim
SRC := bootstrap/main.nim
OUT := buxc
BUILD_DIR := build
+# Project-local nimcache so CI can cache compiles (default is ~/.cache/nim).
+NIMFLAGS ?= --nimcache:nimcache
-EXAMPLES := hello fibonacci factorial structs enums methods algebraic_enums generics generics_struct generic_infer generic_infer2 extend_generic pattern_matching strings strings2 map result_option try_operator ownership ownership_checked drop_early_return lifetime_elision ctfe async concurrency os_time process json iter trait_bounds channel sync jwt stdlib_ergonomics tuples func_ptr map_remove array_iter_extra string_extra multi_closure iter_hof closure_control match_let string_interp iter_generic generic_infer_hof struct_tuple_pat match_block nested_patterns match_guards pattern_shadow move_field
+EXAMPLES := hello fibonacci factorial structs enums methods algebraic_enums generics generics_struct generic_infer generic_infer2 extend_generic pattern_matching strings strings2 map result_option try_operator ownership ownership_checked ownership_release drop_early_return lifetime_elision ctfe async concurrency os_time process json iter trait_bounds channel sync jwt stdlib_ergonomics tuples func_ptr map_remove array_iter_extra string_extra multi_closure iter_hof closure_control match_let string_interp iter_generic generic_infer_hof struct_tuple_pat match_block nested_patterns match_guards pattern_shadow move_field move_field_partial c_precedence macro_twice macro_repeat macro_nested macro_hygiene macro_unhygienic
-.PHONY: all build dev debug test clean clean-all test-examples selfhost test-golden test-errors test-stdlib selfhost-loop lsp fmt-check docs bench test-apps test-dwarf test-selfhost-smoke
+# Platform smoke (macOS CI): full EXAMPLES still runs on Linux.
+EXAMPLES_SMOKE := hello ownership ownership_release strings map move_field move_field_partial c_precedence macro_twice macro_repeat macro_nested macro_hygiene macro_unhygienic
+
+.PHONY: all build dev debug test clean clean-all test-examples test-examples-smoke selfhost test-golden test-errors test-stdlib selfhost-loop lsp fmt-check docs bench test-apps test-dwarf test-selfhost-smoke test-unit ensure-buxc
all: build
-build:
- $(NIM) c -o:$(OUT) -d:release --opt:size $(SRC)
+# Rebuild only when bootstrap sources change (CI can set BUX_SKIP_BUILD=1
+# after downloading a prebuilt buxc artifact).
+$(OUT): $(wildcard bootstrap/*.nim)
+ $(NIM) c $(NIMFLAGS) -o:$(OUT) -d:release --opt:size $(SRC)
# strip $(OUT)
+build: $(OUT)
+
+# CI parallel jobs download buxc and set BUX_SKIP_BUILD=1 to avoid rebuild.
+ensure-buxc:
+ifeq ($(BUX_SKIP_BUILD),1)
+ @test -x ./$(OUT) || (echo "error: ./$(OUT) missing (BUX_SKIP_BUILD=1)"; exit 1)
+else
+ @$(MAKE) $(OUT)
+endif
+
dev:
- $(NIM) c -o:buxc_debug -d:debug --stackTrace:on --lineTrace:on $(SRC)
+ $(NIM) c $(NIMFLAGS) -o:buxc_debug -d:debug --stackTrace:on --lineTrace:on $(SRC)
debug: dev
@echo "Debug binary: buxc_debug"
-test: build fmt-check test-examples test-errors test-stdlib test-registry test-dwarf test-apps test-selfhost-smoke
+# Full local / sequential suite (same coverage as split CI jobs combined).
+test: build fmt-check test-examples test-errors test-stdlib test-registry test-dwarf test-drop-move test-apps test-selfhost-smoke test-unit
+
+# Nim unit tests + tiny CLI smoke (needs Nim + buxc).
+test-unit: ensure-buxc
@echo "Running lexer tests..."
- $(NIM) c -r tests/lexer_test.nim
+ $(NIM) c $(NIMFLAGS) -r tests/lexer_test.nim
@echo "Running parser tests..."
- $(NIM) c -r tests/parser_test.nim
+ $(NIM) c $(NIMFLAGS) -r tests/parser_test.nim
@echo "Running sema tests..."
- $(NIM) c -r tests/sema_test.nim
+ $(NIM) c $(NIMFLAGS) -r tests/sema_test.nim
@echo "Running HIR tests..."
- $(NIM) c -r tests/hir_test.nim
+ $(NIM) c $(NIMFLAGS) -r tests/hir_test.nim
@echo "Running borrow checker tests..."
- $(NIM) c -r tests/borrow_test.nim
+ $(NIM) c $(NIMFLAGS) -r tests/borrow_test.nim
@echo "Running integration tests..."
rm -rf _test_tmp_pkg
./$(OUT) new _test_tmp_pkg
./$(OUT) --version
-test-examples: build
- @for ex in $(EXAMPLES); do \
+# Shared loop body for full + smoke example runners.
+define run-examples
+ @for ex in $(1); do \
echo "=== Testing example: $$ex ==="; \
mkdir -p examples_pkg/$$ex/src; \
cp examples/$$ex.bux examples_pkg/$$ex/src/Main.bux; \
@@ -49,10 +71,23 @@ test-examples: build
echo '[Build]' >> examples_pkg/$$ex/bux.toml; \
echo 'Output = "Bin"' >> examples_pkg/$$ex/bux.toml; \
fi; \
- (cd examples_pkg/$$ex && timeout 10 ../../$(OUT) run) || exit 1; \
+ if command -v timeout >/dev/null 2>&1; then \
+ (cd examples_pkg/$$ex && timeout 30 ../../$(OUT) run) || exit 1; \
+ else \
+ (cd examples_pkg/$$ex && ../../$(OUT) run) || exit 1; \
+ fi; \
done
+endef
+
+test-examples: ensure-buxc
+ $(call run-examples,$(EXAMPLES))
@echo "All examples passed!"
+# Subset for macOS / quick platform smoke (Linux CI runs full EXAMPLES).
+test-examples-smoke: ensure-buxc
+ $(call run-examples,$(EXAMPLES_SMOKE))
+ @echo "Smoke examples passed!"
+
clean:
rm -f $(OUT) buxc_debug
rm -rf $(BUILD_DIR)
@@ -65,7 +100,7 @@ clean-all: clean
rm -rf build/selfhost build/selfhost-loop-a build/selfhost-loop-b build/selfhost-loop-c
rm -rf tests/golden/*/build
-selfhost: build
+selfhost: ensure-buxc
@echo "=== Building self-hosted compiler ==="
@rm -rf build/selfhost
@mkdir -p build/selfhost/src
@@ -80,7 +115,7 @@ selfhost: build
GOLDEN_TESTS := hello fibonacci structs generics algebraic_enums enums methods strings modern_features
-test-golden: build
+test-golden: ensure-buxc
@echo "=== Golden tests ==="
@passed=0; failed=0; \
for test in $(GOLDEN_TESTS); do \
@@ -100,24 +135,24 @@ test-golden: build
echo "Golden tests: $$passed passed, $$failed failed"; \
if [ $$failed -gt 0 ]; then exit 1; fi
-test-errors: build
+test-errors: ensure-buxc
@echo "=== Error diagnostic golden tests ==="
@chmod +x tests/error_golden/run.sh
@tests/error_golden/run.sh ./$(OUT)
-test-stdlib: build
+test-stdlib: ensure-buxc
@echo "=== Stdlib golden tests ==="
@chmod +x tests/stdlib_golden/run.sh
@tests/stdlib_golden/run.sh ./$(OUT)
# Generate stdlib API docs from /// comments → docs/api/stdlib.md
-docs: build
+docs: ensure-buxc
@mkdir -p docs/api
@./$(OUT) doc --out docs/api/stdlib.md lib/
@echo "docs/api/stdlib.md updated"
# CI: full-tree format check (lib / examples / src / tests / apps) + dirty-path smoke.
-fmt-check: build
+fmt-check: ensure-buxc
@echo "=== fmt --check (full tree) ==="
@./$(OUT) fmt --check lib/
@./$(OUT) fmt --check examples/
@@ -134,7 +169,7 @@ fmt-check: build
# One-shot reformat of the same trees (run before committing style-only fixes)
.PHONY: fmt
-fmt: build
+fmt: ensure-buxc
@./$(OUT) fmt lib/
@./$(OUT) fmt examples/
@./$(OUT) fmt src/
@@ -144,7 +179,7 @@ fmt: build
# Fixed-point: bootstrap buxc → buxc2 → buxc3 (path-normalized C + stripped ELF).
# Slow; not part of default `make test`. Optional CI: .github/workflows/selfhost-loop.yml
-selfhost-loop: build
+selfhost-loop: ensure-buxc
@chmod +x tools/selfhost_loop.sh
@tools/selfhost_loop.sh
@@ -194,42 +229,53 @@ test-lsp: lsp
@echo "==> LSP type hierarchy smoke"
@chmod +x tools/smoke_lsp_type_hierarchy.sh
@tools/smoke_lsp_type_hierarchy.sh
+ @echo "==> LSP type hierarchy workspace (closed multi-file)"
+ @chmod +x tools/smoke_lsp_type_hierarchy_ws.sh
+ @tools/smoke_lsp_type_hierarchy_ws.sh
.PHONY: test-registry
-test-registry: build
+test-registry: ensure-buxc
@echo "=== Registry smoke (E.1 + HTTP) ==="
@chmod +x tools/smoke_registry.sh
@tools/smoke_registry.sh
# E.2 — build showcase apps + simpledb/jwt CLI smoke
.PHONY: test-apps
-test-apps: build
+test-apps: ensure-buxc
@echo "=== Apps smoke (E.2) ==="
@chmod +x tools/smoke_apps.sh
@tools/smoke_apps.sh
# E.5 — micro-benchmarks (Bux + C/Nim/Zig twins)
.PHONY: bench
-bench: build
+bench: ensure-buxc
@chmod +x tools/bench.sh
@tools/bench.sh
# E.5 — Nexus HTTP throughput (wrk); optional via BENCH_NEXUS=1 make bench
.PHONY: bench-nexus
-bench-nexus: build
+bench-nexus: ensure-buxc
@chmod +x tools/bench_nexus.sh
@tools/bench_nexus.sh
# E.4 — DWARF / #line debugger smoke
.PHONY: test-dwarf
-test-dwarf: build
+test-dwarf: ensure-buxc
@echo "=== DWARF / #line smoke (E.4) ==="
@chmod +x tools/smoke_dwarf.sh
@tools/smoke_dwarf.sh
+# Drop / field-move goldens (whole + partial field move; early-return counts)
+.PHONY: test-drop-move
+test-drop-move: ensure-buxc
+ @echo "=== Drop / field-move smoke ==="
+ @chmod +x tools/smoke_drop_move.sh
+ @tools/smoke_drop_move.sh
+
# Selfhost (buxc2): move_field ownership + multi-file #line (session 41/42)
+# When BUX_SKIP_BUILD=1, reuse prebuilt buxc; still builds buxc2 via selfhost.
.PHONY: test-selfhost-smoke
-test-selfhost-smoke: selfhost
+test-selfhost-smoke: ensure-buxc selfhost
@echo "=== Selfhost smoke (move_field + multi-file #line) ==="
@chmod +x tools/smoke_selfhost.sh
@tools/smoke_selfhost.sh
diff --git a/README.md b/README.md
index 94b9195..8496a28 100644
--- a/README.md
+++ b/README.md
@@ -237,7 +237,7 @@ func Main() -> int {
| **Backend** | LIR → C transpiler (clean 3-address code, then gcc/clang) |
| **Strings** | Raw multi-line backticks, `f"..."` interp (bootstrap), `ReplaceAll` / `IsBlank` / `Repeat` |
| **Gradual Ownership** | `@[Checked]` + `@[Release]` + `@[Shared]` + `borrow &mut` / `borrow &` |
-| **Drop Trait** | Auto-drop for `@[Drop]` types (Array, Map, user-defined structs) |
+| **Drop / RAII** | Auto-drop (`@[Drop]` / `extend … for Drop`); **field-move skips Drop** (no double-free) |
| **Green Threads** | M:N scheduler (ucontext + SIGVTALRM), work-stealing queues |
| **Async/Await** | `async func`, `spawn`, `.await` with stackful coroutines |
| **Concurrency** | `Task`/`Channel`/`Sync` (pthread-based), `bux_async_yield`/`spawn` |
@@ -318,8 +318,11 @@ make test-examples
# Golden diagnostic tests (Rust-style error format)
make test-errors
-# Full unit + example suite (also run on every PR via GitHub Actions `ci.yml`)
+# Full unit + example suite (local; CI splits the same coverage across jobs)
make test
+# Individual suites (also used by .github/workflows/ci.yml in parallel):
+# make test-unit / test-examples / test-errors / test-stdlib /
+# test-registry / test-dwarf / test-apps / test-selfhost-smoke
# Full-tree format check (lib/ examples/ src/ tests/ apps/)
make fmt-check
diff --git a/bootstrap/ast.nim b/bootstrap/ast.nim
index a3f3681..5bf219d 100644
--- a/bootstrap/ast.nim
+++ b/bootstrap/ast.nim
@@ -133,6 +133,7 @@ type
ekMatch
ekStringInterp
ekClosure
+ ekMacroCall ## name!(args) — expanded before sema
MatchArm* = object
loc*: SourceLocation
@@ -236,6 +237,12 @@ type
captureCount*: int
captureNames*: seq[string]
captureTypeKinds*: seq[int]
+ of ekMacroCall:
+ exprMacroName*: string
+ exprMacroArgs*: seq[Expr]
+ ## Group lengths for multi-rep: `m!(1,2; 3,4)` → @[2, 2].
+ ## Empty means a single group of all args.
+ exprMacroGroupLens*: seq[int]
# ---------------------------------------------------------------------------
# Statements
@@ -258,6 +265,7 @@ type
skDefer
skSwitch
skDecl
+ skMacroRep ## $( … )* template repetition (macro body only)
ElseIf* = object
loc*: SourceLocation
@@ -330,6 +338,8 @@ type
stmtSwitchDefault*: Block
of skDecl:
stmtDecl*: Decl
+ of skMacroRep: ## $( stmts… )* in macro templates
+ stmtMacroRepBody*: Block
# ---------------------------------------------------------------------------
# Type Parameters (for generics with trait bounds)
@@ -356,6 +366,28 @@ type
dkExternFunc
dkExternVar
dkExternBlock
+ dkMacro ## macro! name { ($x:expr) => { … } }
+
+ ## One declarative macro arm: ($a:expr, $($x:expr),*) => { template }
+ MacroFragKind* = enum
+ mfkExpr ## any expression
+ mfkIdent ## bare identifier (after expand must be ekIdent)
+ mfkTt ## token-tree (MVP: same as expr)
+ mfkLiteral ## int/float/string/char/bool literal only
+ mfkBlock ## block expression `{ … }`
+
+ MacroFragment* = object
+ name*: string ## primary / first name (compat)
+ kind*: MacroFragKind ## primary kind (compat)
+ names*: seq[string] ## one or more $names (compound rep: $a,$b)
+ kinds*: seq[MacroFragKind] ## parallel to names
+ isRep*: bool ## true for $( … ),* or $( … )*
+ repSep*: string ## "," if separator was present before *, else ""
+
+ MacroRule* = object
+ loc*: SourceLocation
+ frags*: seq[MacroFragment]
+ body*: Block ## template (substituted, then used as ekBlock)
Param* = object
loc*: SourceLocation
@@ -448,6 +480,9 @@ type
declExtBlockDll*: string
declExtBlockCallConv*: CallingConvention
declExtBlockItems*: seq[Decl]
+ of dkMacro:
+ declMacroName*: string
+ declMacroRules*: seq[MacroRule]
# ---------------------------------------------------------------------------
# Module (AST root)
diff --git a/bootstrap/cli.nim b/bootstrap/cli.nim
index 851d061..768000f 100644
--- a/bootstrap/cli.nim
+++ b/bootstrap/cli.nim
@@ -4,6 +4,7 @@ import source_location
import fmt
import docgen
import registry
+import macroexpand
type
ColorMode* = enum
@@ -653,6 +654,12 @@ proc cmdCheck*(args: seq[string], opts: GlobalOptions): int =
if status != 0:
return status
let unifiedModule = mergeProject(pctx)
+ let macRes = expandMacros(unifiedModule)
+ if macRes.diagnostics.len > 0:
+ printError("macro expansion errors", useColor)
+ for d in macRes.diagnostics:
+ printDiagnostic("error", d.message, d.loc, useColor)
+ return 1
let semaRes = analyze(unifiedModule)
if semaRes.hasErrors:
printError("type errors in project", useColor)
@@ -689,6 +696,7 @@ proc getDeclName(d: Decl): string =
of dkInterface: d.declInterfaceName
of dkConst: d.declConstName
of dkTypeAlias: d.declAliasName
+ of dkMacro: d.declMacroName
else: ""
proc collectDepDecls(lock: Lockfile, root: string, opts: GlobalOptions): seq[Decl] =
@@ -763,6 +771,14 @@ proc cmdBuild*(args: seq[string], opts: GlobalOptions): int =
let unifiedModule = mergeProject(pctx)
+ # Phase 2b: expand declarative macro! / quote! before type checking
+ let macRes = expandMacros(unifiedModule)
+ if macRes.diagnostics.len > 0:
+ printError("macro expansion errors", useColor)
+ for d in macRes.diagnostics:
+ printDiagnostic("error", d.message, d.loc, useColor)
+ return 1
+
# Phase 3: Sema + HIR + C codegen on unified module
let (semaRes, semaCtx) = analyzeFull(unifiedModule)
if semaRes.hasErrors:
@@ -809,7 +825,9 @@ proc cmdBuild*(args: seq[string], opts: GlobalOptions): int =
let optFlags = if opts.release: "-O2 -DNDEBUG" else: "-O0 -g"
let extraCflags = getEnv("BUX_CFLAGS")
let cflags = if extraCflags.len > 0: optFlags & " " & extraCflags else: optFlags
- let ccCmd = &"cc {cflags} -pthread -Wl,--build-id=none -o {outputFile} {cFile} {runtimeDst} {ioDst} -lm -lcrypto 2>&1"
+ # --build-id is GNU ld only (breaks Apple ld). Reproducible selfhost-loop uses Linux CI.
+ let ldStable = when defined(linux): " -Wl,--build-id=none" else: ""
+ let ccCmd = &"cc {cflags} -pthread{ldStable} -o {outputFile} {cFile} {runtimeDst} {ioDst} -lm -lcrypto 2>&1"
if opts.verbose:
printInfo(&"running: {ccCmd}", useColor)
let (output, exitCode) = execCmdEx(ccCmd)
diff --git a/bootstrap/hir_lower.nim b/bootstrap/hir_lower.nim
index 84c5e89..e9def44 100644
--- a/bootstrap/hir_lower.nim
+++ b/bootstrap/hir_lower.nim
@@ -85,12 +85,23 @@ proc markMovedOutLocal(ctx: var LowerCtx, name: string) =
if name.len > 0 and ctx.hasPendingDrop(name):
ctx.movedOutLocals.incl(name)
+# Forward decls (used by markMovedOutFromAst before their full definitions)
+proc resolveExprType(ctx: var LowerCtx, expr: Expr): Type
+proc autoDropFuncName(ctx: var LowerCtx, ty: Type): string
+
proc markMovedOutFromAst(ctx: var LowerCtx, expr: Expr) =
## Mark droppable locals used by-value in ownership-taking contexts.
+ ## Partial field moves: `return bag.items` / `let x = bag.items` mark `bag`
+ ## so auto-Drop of the parent is skipped — **only when the field type itself
+ ## is droppable** (not `return bag.tag` for an int field).
if expr == nil: return
case expr.kind
of ekIdent:
ctx.markMovedOutLocal(expr.exprIdent)
+ of ekField:
+ let fieldTy = ctx.resolveExprType(expr)
+ if ctx.autoDropFuncName(fieldTy).len > 0:
+ ctx.markMovedOutFromAst(expr.exprFieldObj)
of ekStructInit:
for f in expr.exprStructInitFields:
ctx.markMovedOutFromAst(f.value)
@@ -2131,6 +2142,11 @@ proc lowerStmt(ctx: var LowerCtx, stmt: Stmt): HirNode =
return HirNode(kind: hLit, litToken: Token(kind: tkIntLiteral, text: "0", loc: loc),
typ: makeVoid(), loc: loc)
+ of skMacroRep:
+ # Expanded before lowering
+ return HirNode(kind: hLit, litToken: Token(kind: tkIntLiteral, text: "0", loc: loc),
+ typ: makeVoid(), loc: loc)
+
proc lowerBlock(ctx: var LowerCtx, blk: Block, asExpr = false): HirNode =
## asExpr=true: block is used as a value (`let x = { ... }`, match arm body).
## Last skExpr becomes the block result. Statement blocks (func body, if/while)
@@ -2168,15 +2184,30 @@ proc lowerBlock(ctx: var LowerCtx, blk: Block, asExpr = false): HirNode =
expr = last.blockExpr
# Scope exit: Drop locals introduced in this block (not outer ones).
# Skip Drop for block result and any moved-out locals (field / let / return move).
+ # If the last statement always returns, drops were already injected on that
+ # path — re-emitting them here produces dead double-Drop after `return`.
+ proc blockAlwaysReturns(n: HirNode): bool =
+ if n == nil: return false
+ if n.kind == hReturn: return true
+ if n.kind == hBlock:
+ if n.blockStmts.len == 0: return false
+ return blockAlwaysReturns(n.blockStmts[^1])
+ false
+
var skipDrop = ""
if expr != nil and expr.kind == hVar:
skipDrop = expr.varName
ctx.markMovedOutLocal(expr.varName)
- if ctx.deferStmts.len > deferBase:
+ let lastAlwaysReturns = stmts.len > 0 and blockAlwaysReturns(stmts[^1])
+ if ctx.deferStmts.len > deferBase and not lastAlwaysReturns:
for i in countdown(ctx.deferStmts.len - 1, deferBase):
if not ctx.shouldSkipDrop(ctx.deferStmts[i], skipDrop):
stmts.add(ctx.deferStmts[i])
ctx.deferStmts.setLen(deferBase)
+ elif ctx.deferStmts.len > deferBase and lastAlwaysReturns:
+ # Return path already owns these drops; pop so outer scopes don't re-run them
+ # for the same locals when this block is nested. Outer live locals remain.
+ ctx.deferStmts.setLen(deferBase)
let typ = if expr != nil and expr.typ != nil: expr.typ else: makeVoid()
return hirBlock(stmts, expr, typ, blk.loc, isScope = true)
diff --git a/bootstrap/lexer.nim b/bootstrap/lexer.nim
index 2db82ab..d3ed348 100644
--- a/bootstrap/lexer.nim
+++ b/bootstrap/lexer.nim
@@ -464,6 +464,17 @@ proc scanSymbol(lex: var Lexer, startLoc: SourceLocation): Token =
return lex.makeToken(tkCaretAssign, startLoc, startPos)
else:
return lex.makeToken(tkCaret, startLoc, startPos)
+ of '$':
+ # $name fragment, or bare $ for macro repetition $( ... )*
+ if isIdentStart(lex.peek()):
+ discard lex.advance() # first ident char ( $ already consumed as c1)
+ while not lex.isAtEnd() and isIdentChar(lex.peek()):
+ discard lex.advance()
+ # text includes leading '$'
+ return lex.makeToken(tkIdent, startLoc, startPos)
+ else:
+ # bare $ (c1 already consumed)
+ return lex.makeToken(tkDollar, startLoc, startPos)
of '#':
# Check for intrinsics: #line, #column, #file, #function, #date, #time, #module
let afterHash = lex.peek()
diff --git a/bootstrap/lir_c_backend.nim b/bootstrap/lir_c_backend.nim
index 514c38f..41e63e1 100644
--- a/bootstrap/lir_c_backend.nim
+++ b/bootstrap/lir_c_backend.nim
@@ -120,14 +120,15 @@ proc emitInstr(be: var LirCBackend, instr: LirInstr) =
of lirShl: "<<"
of lirShr: ">>"
else: "?"
- be.emitLine(&"{v(instr.dst)} = {v(instr.src)} {op} {v(instr.src2)};")
+ # Parenthesize so future non-temp operands cannot be rewritten by C precedence
+ be.emitLine(&"{v(instr.dst)} = ({v(instr.src)} {op} {v(instr.src2)});")
of lirNeg:
- be.emitLine(&"{v(instr.dst)} = -{v(instr.src)};")
+ be.emitLine(&"{v(instr.dst)} = -({v(instr.src)});")
of lirNot:
- be.emitLine(&"{v(instr.dst)} = !{v(instr.src)};")
+ be.emitLine(&"{v(instr.dst)} = !({v(instr.src)});")
of lirBNot:
- be.emitLine(&"{v(instr.dst)} = ~{v(instr.src)};")
+ be.emitLine(&"{v(instr.dst)} = ~({v(instr.src)});")
# ── Comparison ──
of lirCmpEq, lirCmpNe, lirCmpLt, lirCmpLe, lirCmpGt, lirCmpGe:
diff --git a/bootstrap/macroexpand.nim b/bootstrap/macroexpand.nim
new file mode 100644
index 0000000..f5463c8
--- /dev/null
+++ b/bootstrap/macroexpand.nim
@@ -0,0 +1,1180 @@
+## Declarative macro! expansion (session 59).
+## Expands `name!(args)` using `macro! name { ($x:expr) => { … } }` rules.
+## Hygiene: substitute clones args at call-site, graft call-site SourceLocation
+## onto expanded template nodes (Ast_QuoteCallSite policy from QUALITY_PLAN).
+
+import std/[tables, sequtils, sets]
+import ast, token, source_location
+
+type
+ MacroDiagnostic* = object
+ loc*: SourceLocation
+ message*: string
+
+ MacroExpandResult* = object
+ diagnostics*: seq[MacroDiagnostic]
+
+proc emitErr(res: var MacroExpandResult, loc: SourceLocation, msg: string) =
+ res.diagnostics.add(MacroDiagnostic(loc: loc, message: msg))
+
+# ---------------------------------------------------------------------------
+# Deep clone (bootstrap has no Ast_Clone*)
+# ---------------------------------------------------------------------------
+
+proc cloneExpr*(e: Expr): Expr
+proc cloneStmt*(s: Stmt): Stmt
+proc cloneBlock*(b: Block): Block
+
+proc cloneBlock*(b: Block): Block =
+ if b == nil: return nil
+ result = Block(loc: b.loc, stmts: @[])
+ for s in b.stmts:
+ result.stmts.add(cloneStmt(s))
+
+proc clonePattern(p: Pattern): Pattern =
+ if p == nil: return nil
+ case p.kind
+ of pkWildcard:
+ result = Pattern(kind: pkWildcard, loc: p.loc)
+ of pkLiteral:
+ result = Pattern(kind: pkLiteral, loc: p.loc, patLit: p.patLit)
+ of pkIdent:
+ result = Pattern(kind: pkIdent, loc: p.loc, patIdent: p.patIdent)
+ of pkRange:
+ result = Pattern(kind: pkRange, loc: p.loc,
+ patRangeLo: clonePattern(p.patRangeLo),
+ patRangeHi: clonePattern(p.patRangeHi),
+ patRangeInclusive: p.patRangeInclusive)
+ of pkEnum:
+ result = Pattern(kind: pkEnum, loc: p.loc, patEnumPath: p.patEnumPath,
+ patEnumArgs: @[], patEnumNamed: @[])
+ for a in p.patEnumArgs:
+ result.patEnumArgs.add(clonePattern(a))
+ for nf in p.patEnumNamed:
+ result.patEnumNamed.add((nf.name, clonePattern(nf.pattern)))
+ of pkStruct:
+ result = Pattern(kind: pkStruct, loc: p.loc, patStructName: p.patStructName,
+ patStructFields: @[])
+ for f in p.patStructFields:
+ result.patStructFields.add((f.name, clonePattern(f.pattern)))
+ of pkTuple:
+ result = Pattern(kind: pkTuple, loc: p.loc, patTupleElements: @[])
+ for el in p.patTupleElements:
+ result.patTupleElements.add(clonePattern(el))
+ of pkGuarded:
+ result = Pattern(kind: pkGuarded, loc: p.loc,
+ patGuardedInner: clonePattern(p.patGuardedInner),
+ patGuardedExpr: cloneExpr(p.patGuardedExpr))
+
+proc cloneExpr*(e: Expr): Expr =
+ if e == nil: return nil
+ case e.kind
+ of ekLiteral:
+ result = Expr(kind: ekLiteral, loc: e.loc, exprLit: e.exprLit)
+ of ekIdent:
+ result = Expr(kind: ekIdent, loc: e.loc, exprIdent: e.exprIdent)
+ of ekSelf:
+ result = Expr(kind: ekSelf, loc: e.loc)
+ of ekPath:
+ result = Expr(kind: ekPath, loc: e.loc, exprPath: e.exprPath)
+ of ekSizeOf:
+ result = Expr(kind: ekSizeOf, loc: e.loc, exprSizeOfType: e.exprSizeOfType)
+ of ekIntrinsic:
+ result = Expr(kind: ekIntrinsic, loc: e.loc, exprIntrinsic: e.exprIntrinsic)
+ of ekUnary:
+ result = Expr(kind: ekUnary, loc: e.loc, exprUnaryOp: e.exprUnaryOp,
+ exprUnaryOperand: cloneExpr(e.exprUnaryOperand))
+ of ekPostfix:
+ result = Expr(kind: ekPostfix, loc: e.loc, exprPostfixOp: e.exprPostfixOp,
+ exprPostfixOperand: cloneExpr(e.exprPostfixOperand))
+ of ekBinary:
+ result = Expr(kind: ekBinary, loc: e.loc, exprBinaryOp: e.exprBinaryOp,
+ exprBinaryLeft: cloneExpr(e.exprBinaryLeft),
+ exprBinaryRight: cloneExpr(e.exprBinaryRight))
+ of ekAssign:
+ result = Expr(kind: ekAssign, loc: e.loc, exprAssignOp: e.exprAssignOp,
+ exprAssignTarget: cloneExpr(e.exprAssignTarget),
+ exprAssignValue: cloneExpr(e.exprAssignValue))
+ of ekTernary:
+ result = Expr(kind: ekTernary, loc: e.loc,
+ exprTernaryCond: cloneExpr(e.exprTernaryCond),
+ exprTernaryThen: cloneExpr(e.exprTernaryThen),
+ exprTernaryElse: cloneExpr(e.exprTernaryElse))
+ of ekRange:
+ result = Expr(kind: ekRange, loc: e.loc,
+ exprRangeLo: cloneExpr(e.exprRangeLo),
+ exprRangeHi: cloneExpr(e.exprRangeHi),
+ exprRangeInclusive: e.exprRangeInclusive)
+ of ekCall:
+ result = Expr(kind: ekCall, loc: e.loc,
+ exprCallCallee: cloneExpr(e.exprCallCallee),
+ exprCallArgs: @[], exprCallArgNames: e.exprCallArgNames,
+ exprCallInferredTypeArgs: e.exprCallInferredTypeArgs)
+ for a in e.exprCallArgs:
+ result.exprCallArgs.add(cloneExpr(a))
+ of ekGenericCall:
+ result = Expr(kind: ekGenericCall, loc: e.loc,
+ exprGenericCallee: e.exprGenericCallee,
+ exprGenericTypeArgs: e.exprGenericTypeArgs)
+ of ekIndex:
+ result = Expr(kind: ekIndex, loc: e.loc,
+ exprIndexObj: cloneExpr(e.exprIndexObj),
+ exprIndexIdx: cloneExpr(e.exprIndexIdx),
+ exprIndexBoundsCheck: e.exprIndexBoundsCheck)
+ of ekField:
+ result = Expr(kind: ekField, loc: e.loc,
+ exprFieldObj: cloneExpr(e.exprFieldObj),
+ exprFieldName: e.exprFieldName)
+ of ekStructInit:
+ result = Expr(kind: ekStructInit, loc: e.loc,
+ exprStructInitName: e.exprStructInitName,
+ exprStructInitTypeArgs: e.exprStructInitTypeArgs,
+ exprStructInitFields: @[])
+ for f in e.exprStructInitFields:
+ result.exprStructInitFields.add((f.name, cloneExpr(f.value)))
+ of ekSlice:
+ result = Expr(kind: ekSlice, loc: e.loc, exprSliceElements: @[])
+ for el in e.exprSliceElements:
+ result.exprSliceElements.add(cloneExpr(el))
+ of ekSpread:
+ result = Expr(kind: ekSpread, loc: e.loc,
+ exprSpreadOperand: cloneExpr(e.exprSpreadOperand))
+ of ekTuple:
+ result = Expr(kind: ekTuple, loc: e.loc, exprTupleElements: @[])
+ for el in e.exprTupleElements:
+ result.exprTupleElements.add(cloneExpr(el))
+ of ekCast:
+ result = Expr(kind: ekCast, loc: e.loc,
+ exprCastOperand: cloneExpr(e.exprCastOperand),
+ exprCastType: e.exprCastType)
+ of ekIs:
+ result = Expr(kind: ekIs, loc: e.loc,
+ exprIsOperand: cloneExpr(e.exprIsOperand),
+ exprIsType: e.exprIsType)
+ of ekTry:
+ result = Expr(kind: ekTry, loc: e.loc,
+ exprTryOperand: cloneExpr(e.exprTryOperand),
+ exprTryType: e.exprTryType)
+ of ekUnwrap:
+ result = Expr(kind: ekUnwrap, loc: e.loc,
+ exprUnwrapOperand: cloneExpr(e.exprUnwrapOperand))
+ of ekSpawn:
+ result = Expr(kind: ekSpawn, loc: e.loc,
+ exprSpawnCallee: cloneExpr(e.exprSpawnCallee),
+ exprSpawnArgs: @[], exprSpawnAsync: e.exprSpawnAsync)
+ for a in e.exprSpawnArgs:
+ result.exprSpawnArgs.add(cloneExpr(a))
+ of ekAwait:
+ result = Expr(kind: ekAwait, loc: e.loc,
+ exprAwaitOperand: cloneExpr(e.exprAwaitOperand))
+ of ekBorrow:
+ result = Expr(kind: ekBorrow, loc: e.loc,
+ exprBorrowOperand: cloneExpr(e.exprBorrowOperand),
+ exprBorrowMutable: e.exprBorrowMutable)
+ of ekBlock:
+ result = Expr(kind: ekBlock, loc: e.loc, exprBlock: cloneBlock(e.exprBlock))
+ of ekMatch:
+ result = Expr(kind: ekMatch, loc: e.loc,
+ exprMatchSubject: cloneExpr(e.exprMatchSubject),
+ exprMatchArms: @[])
+ for arm in e.exprMatchArms:
+ result.exprMatchArms.add(MatchArm(loc: arm.loc,
+ pattern: clonePattern(arm.pattern), body: cloneExpr(arm.body)))
+ of ekStringInterp:
+ result = Expr(kind: ekStringInterp, loc: e.loc,
+ exprInterpTexts: e.exprInterpTexts, exprInterpExprs: @[])
+ for ie in e.exprInterpExprs:
+ result.exprInterpExprs.add(cloneExpr(ie))
+ of ekClosure:
+ result = Expr(kind: ekClosure, loc: e.loc,
+ exprClosureParams: e.exprClosureParams,
+ exprClosureBody: cloneBlock(e.exprClosureBody),
+ exprClosureReturnType: e.exprClosureReturnType,
+ captureCount: 0, captureNames: @[], captureTypeKinds: @[])
+ of ekMacroCall:
+ result = Expr(kind: ekMacroCall, loc: e.loc,
+ exprMacroName: e.exprMacroName, exprMacroArgs: @[])
+ for a in e.exprMacroArgs:
+ result.exprMacroArgs.add(cloneExpr(a))
+
+proc cloneStmt*(s: Stmt): Stmt =
+ if s == nil: return nil
+ case s.kind
+ of skExpr:
+ result = Stmt(kind: skExpr, loc: s.loc, stmtExpr: cloneExpr(s.stmtExpr))
+ of skLet:
+ result = Stmt(kind: skLet, loc: s.loc, stmtLetMut: s.stmtLetMut,
+ stmtLetName: s.stmtLetName, stmtLetPattern: clonePattern(s.stmtLetPattern),
+ stmtLetType: s.stmtLetType, stmtLetInit: cloneExpr(s.stmtLetInit))
+ of skIf:
+ result = Stmt(kind: skIf, loc: s.loc,
+ stmtIfCond: cloneExpr(s.stmtIfCond),
+ stmtIfThen: cloneBlock(s.stmtIfThen),
+ stmtIfElseIfs: @[],
+ stmtIfElse: cloneBlock(s.stmtIfElse))
+ for ei in s.stmtIfElseIfs:
+ result.stmtIfElseIfs.add(ElseIf(loc: ei.loc, cond: cloneExpr(ei.cond),
+ blk: cloneBlock(ei.blk)))
+ of skWhile:
+ result = Stmt(kind: skWhile, loc: s.loc, stmtWhileLabel: s.stmtWhileLabel,
+ stmtWhileCond: cloneExpr(s.stmtWhileCond),
+ stmtWhileBody: cloneBlock(s.stmtWhileBody))
+ of skDoWhile:
+ result = Stmt(kind: skDoWhile, loc: s.loc, stmtDoWhileLabel: s.stmtDoWhileLabel,
+ stmtDoWhileBody: cloneBlock(s.stmtDoWhileBody),
+ stmtDoWhileCond: cloneExpr(s.stmtDoWhileCond))
+ of skLoop:
+ result = Stmt(kind: skLoop, loc: s.loc, stmtLoopLabel: s.stmtLoopLabel,
+ stmtLoopBody: cloneBlock(s.stmtLoopBody))
+ of skFor:
+ result = Stmt(kind: skFor, loc: s.loc, stmtForLabel: s.stmtForLabel,
+ stmtForVar: s.stmtForVar, stmtForIter: cloneExpr(s.stmtForIter),
+ stmtForBody: cloneBlock(s.stmtForBody))
+ of skMatch:
+ result = Stmt(kind: skMatch, loc: s.loc,
+ stmtMatchSubject: cloneExpr(s.stmtMatchSubject), stmtMatchArms: @[])
+ for arm in s.stmtMatchArms:
+ result.stmtMatchArms.add(MatchArm(loc: arm.loc,
+ pattern: clonePattern(arm.pattern), body: cloneExpr(arm.body)))
+ of skReturn:
+ result = Stmt(kind: skReturn, loc: s.loc,
+ stmtReturnValue: cloneExpr(s.stmtReturnValue))
+ of skBreak:
+ result = Stmt(kind: skBreak, loc: s.loc, stmtBreakLabel: s.stmtBreakLabel)
+ of skContinue:
+ result = Stmt(kind: skContinue, loc: s.loc, stmtContinueLabel: s.stmtContinueLabel)
+ of skStaticAssert:
+ result = Stmt(kind: skStaticAssert, loc: s.loc,
+ stmtStaticAssertCond: cloneExpr(s.stmtStaticAssertCond),
+ stmtStaticAssertMsg: cloneExpr(s.stmtStaticAssertMsg))
+ of skComptime:
+ result = Stmt(kind: skComptime, loc: s.loc,
+ stmtComptimeBlock: cloneBlock(s.stmtComptimeBlock))
+ of skEmit:
+ result = Stmt(kind: skEmit, loc: s.loc, stmtEmitExpr: cloneExpr(s.stmtEmitExpr),
+ stmtEmitEvaluated: s.stmtEmitEvaluated)
+ of skDefer:
+ result = Stmt(kind: skDefer, loc: s.loc, stmtDeferBody: cloneExpr(s.stmtDeferBody))
+ of skSwitch:
+ result = Stmt(kind: skSwitch, loc: s.loc,
+ stmtSwitchExpr: cloneExpr(s.stmtSwitchExpr),
+ stmtSwitchCases: @[],
+ stmtSwitchDefault: cloneBlock(s.stmtSwitchDefault))
+ for c in s.stmtSwitchCases:
+ result.stmtSwitchCases.add(SwitchCase(loc: c.loc,
+ caseValue: cloneExpr(c.caseValue), caseBody: cloneBlock(c.caseBody)))
+ of skDecl:
+ # Nested decl — share pointer (macros don't template decls)
+ result = Stmt(kind: skDecl, loc: s.loc, stmtDecl: s.stmtDecl)
+ of skMacroRep:
+ result = Stmt(kind: skMacroRep, loc: s.loc,
+ stmtMacroRepBody: cloneBlock(s.stmtMacroRepBody))
+
+# ---------------------------------------------------------------------------
+# Call-site graft (overwrite locations)
+# ---------------------------------------------------------------------------
+
+proc graftExprLoc(e: Expr, loc: SourceLocation)
+proc graftStmtLoc(s: Stmt, loc: SourceLocation)
+proc graftBlockLoc(b: Block, loc: SourceLocation)
+
+proc graftExprLoc(e: Expr, loc: SourceLocation) =
+ if e == nil: return
+ e.loc = loc
+ case e.kind
+ of ekUnary: graftExprLoc(e.exprUnaryOperand, loc)
+ of ekPostfix: graftExprLoc(e.exprPostfixOperand, loc)
+ of ekBinary:
+ graftExprLoc(e.exprBinaryLeft, loc)
+ graftExprLoc(e.exprBinaryRight, loc)
+ of ekAssign:
+ graftExprLoc(e.exprAssignTarget, loc)
+ graftExprLoc(e.exprAssignValue, loc)
+ of ekTernary:
+ graftExprLoc(e.exprTernaryCond, loc)
+ graftExprLoc(e.exprTernaryThen, loc)
+ graftExprLoc(e.exprTernaryElse, loc)
+ of ekRange:
+ graftExprLoc(e.exprRangeLo, loc)
+ graftExprLoc(e.exprRangeHi, loc)
+ of ekCall:
+ graftExprLoc(e.exprCallCallee, loc)
+ for a in e.exprCallArgs: graftExprLoc(a, loc)
+ of ekIndex:
+ graftExprLoc(e.exprIndexObj, loc)
+ graftExprLoc(e.exprIndexIdx, loc)
+ of ekField: graftExprLoc(e.exprFieldObj, loc)
+ of ekStructInit:
+ for f in e.exprStructInitFields: graftExprLoc(f.value, loc)
+ of ekSlice:
+ for el in e.exprSliceElements: graftExprLoc(el, loc)
+ of ekSpread: graftExprLoc(e.exprSpreadOperand, loc)
+ of ekTuple:
+ for el in e.exprTupleElements: graftExprLoc(el, loc)
+ of ekCast: graftExprLoc(e.exprCastOperand, loc)
+ of ekIs: graftExprLoc(e.exprIsOperand, loc)
+ of ekTry: graftExprLoc(e.exprTryOperand, loc)
+ of ekUnwrap: graftExprLoc(e.exprUnwrapOperand, loc)
+ of ekSpawn:
+ graftExprLoc(e.exprSpawnCallee, loc)
+ for a in e.exprSpawnArgs: graftExprLoc(a, loc)
+ of ekAwait: graftExprLoc(e.exprAwaitOperand, loc)
+ of ekBorrow: graftExprLoc(e.exprBorrowOperand, loc)
+ of ekBlock: graftBlockLoc(e.exprBlock, loc)
+ of ekMatch:
+ graftExprLoc(e.exprMatchSubject, loc)
+ for arm in e.exprMatchArms: graftExprLoc(arm.body, loc)
+ of ekStringInterp:
+ for ie in e.exprInterpExprs: graftExprLoc(ie, loc)
+ of ekClosure: graftBlockLoc(e.exprClosureBody, loc)
+ of ekMacroCall:
+ for a in e.exprMacroArgs: graftExprLoc(a, loc)
+ else: discard
+
+proc graftStmtLoc(s: Stmt, loc: SourceLocation) =
+ if s == nil: return
+ s.loc = loc
+ case s.kind
+ of skExpr: graftExprLoc(s.stmtExpr, loc)
+ of skLet: graftExprLoc(s.stmtLetInit, loc)
+ of skIf:
+ graftExprLoc(s.stmtIfCond, loc)
+ graftBlockLoc(s.stmtIfThen, loc)
+ for ei in s.stmtIfElseIfs:
+ graftExprLoc(ei.cond, loc)
+ graftBlockLoc(ei.blk, loc)
+ graftBlockLoc(s.stmtIfElse, loc)
+ of skWhile:
+ graftExprLoc(s.stmtWhileCond, loc)
+ graftBlockLoc(s.stmtWhileBody, loc)
+ of skDoWhile:
+ graftBlockLoc(s.stmtDoWhileBody, loc)
+ graftExprLoc(s.stmtDoWhileCond, loc)
+ of skLoop: graftBlockLoc(s.stmtLoopBody, loc)
+ of skFor:
+ graftExprLoc(s.stmtForIter, loc)
+ graftBlockLoc(s.stmtForBody, loc)
+ of skMatch:
+ graftExprLoc(s.stmtMatchSubject, loc)
+ for arm in s.stmtMatchArms: graftExprLoc(arm.body, loc)
+ of skReturn: graftExprLoc(s.stmtReturnValue, loc)
+ of skStaticAssert:
+ graftExprLoc(s.stmtStaticAssertCond, loc)
+ graftExprLoc(s.stmtStaticAssertMsg, loc)
+ of skComptime: graftBlockLoc(s.stmtComptimeBlock, loc)
+ of skEmit: graftExprLoc(s.stmtEmitExpr, loc)
+ of skDefer: graftExprLoc(s.stmtDeferBody, loc)
+ of skSwitch:
+ graftExprLoc(s.stmtSwitchExpr, loc)
+ for c in s.stmtSwitchCases:
+ graftExprLoc(c.caseValue, loc)
+ graftBlockLoc(c.caseBody, loc)
+ graftBlockLoc(s.stmtSwitchDefault, loc)
+ of skMacroRep:
+ graftBlockLoc(s.stmtMacroRepBody, loc)
+ else: discard
+
+proc graftBlockLoc(b: Block, loc: SourceLocation) =
+ if b == nil: return
+ b.loc = loc
+ for s in b.stmts:
+ graftStmtLoc(s, loc)
+
+# ---------------------------------------------------------------------------
+# Substitution of $frags (singles + list bindings for $(…)*)
+# ---------------------------------------------------------------------------
+
+type
+ MacroEnv = object
+ singles: Table[string, Expr]
+ lists: Table[string, seq[Expr]]
+
+var macroGensymCounter = 0
+# Call-site binders introduced via `var $name` / `for $i` (skip gensym)
+var expandUnhygienic: HashSet[string]
+
+proc binderIdentFromFrag(env: MacroEnv, name: string): string =
+ ## If `name` is a $frag bound to a bare ident, return that ident (unhygienic binder).
+ if name.len == 0 or not env.singles.hasKey(name): return ""
+ let e = env.singles[name]
+ if e != nil and e.kind == ekIdent and e.exprIdent.len > 0:
+ return e.exprIdent
+ ""
+
+proc gensymLocals(b: Block, callLoc: SourceLocation): Block
+proc renameIdents(e: Expr, map: Table[string, string]): Expr
+proc renameIdentsStmt(s: Stmt, map: Table[string, string]): Stmt
+proc renameIdentsBlock(b: Block, map: Table[string, string]): Block
+
+proc renameIdents(e: Expr, map: Table[string, string]): Expr =
+ if e == nil: return nil
+ let c = cloneExpr(e)
+ if c.kind == ekIdent and map.hasKey(c.exprIdent):
+ c.exprIdent = map[c.exprIdent]
+ case c.kind
+ of ekUnary: c.exprUnaryOperand = renameIdents(c.exprUnaryOperand, map)
+ of ekBinary:
+ c.exprBinaryLeft = renameIdents(c.exprBinaryLeft, map)
+ c.exprBinaryRight = renameIdents(c.exprBinaryRight, map)
+ of ekAssign:
+ c.exprAssignTarget = renameIdents(c.exprAssignTarget, map)
+ c.exprAssignValue = renameIdents(c.exprAssignValue, map)
+ of ekCall:
+ c.exprCallCallee = renameIdents(c.exprCallCallee, map)
+ var args: seq[Expr] = @[]
+ for a in c.exprCallArgs: args.add(renameIdents(a, map))
+ c.exprCallArgs = args
+ of ekBlock: c.exprBlock = renameIdentsBlock(c.exprBlock, map)
+ of ekTernary:
+ c.exprTernaryCond = renameIdents(c.exprTernaryCond, map)
+ c.exprTernaryThen = renameIdents(c.exprTernaryThen, map)
+ c.exprTernaryElse = renameIdents(c.exprTernaryElse, map)
+ else: discard
+ result = c
+
+proc renameIdentsStmt(s: Stmt, map: Table[string, string]): Stmt =
+ if s == nil: return nil
+ let c = cloneStmt(s)
+ case c.kind
+ of skLet:
+ if map.hasKey(c.stmtLetName):
+ c.stmtLetName = map[c.stmtLetName]
+ c.stmtLetInit = renameIdents(c.stmtLetInit, map)
+ of skExpr: c.stmtExpr = renameIdents(c.stmtExpr, map)
+ of skIf:
+ c.stmtIfCond = renameIdents(c.stmtIfCond, map)
+ c.stmtIfThen = renameIdentsBlock(c.stmtIfThen, map)
+ c.stmtIfElse = renameIdentsBlock(c.stmtIfElse, map)
+ of skWhile:
+ c.stmtWhileCond = renameIdents(c.stmtWhileCond, map)
+ c.stmtWhileBody = renameIdentsBlock(c.stmtWhileBody, map)
+ of skFor:
+ if map.hasKey(c.stmtForVar):
+ c.stmtForVar = map[c.stmtForVar]
+ c.stmtForIter = renameIdents(c.stmtForIter, map)
+ c.stmtForBody = renameIdentsBlock(c.stmtForBody, map)
+ of skReturn: c.stmtReturnValue = renameIdents(c.stmtReturnValue, map)
+ of skMacroRep:
+ c.stmtMacroRepBody = renameIdentsBlock(c.stmtMacroRepBody, map)
+ else: discard
+ result = c
+
+proc renameIdentsBlock(b: Block, map: Table[string, string]): Block =
+ if b == nil: return nil
+ result = Block(loc: b.loc, stmts: @[])
+ for s in b.stmts:
+ result.stmts.add(renameIdentsStmt(s, map))
+
+proc collectLetNames(blk: Block, map: var Table[string, string])
+proc collectLetNamesExpr(e: Expr, map: var Table[string, string])
+
+proc collectLetNamesExpr(e: Expr, map: var Table[string, string]) =
+ if e == nil: return
+ case e.kind
+ of ekBlock:
+ collectLetNames(e.exprBlock, map)
+ of ekUnary:
+ collectLetNamesExpr(e.exprUnaryOperand, map)
+ of ekBinary:
+ collectLetNamesExpr(e.exprBinaryLeft, map)
+ collectLetNamesExpr(e.exprBinaryRight, map)
+ of ekCall:
+ collectLetNamesExpr(e.exprCallCallee, map)
+ for a in e.exprCallArgs: collectLetNamesExpr(a, map)
+ of ekAssign:
+ collectLetNamesExpr(e.exprAssignTarget, map)
+ collectLetNamesExpr(e.exprAssignValue, map)
+ else:
+ discard
+
+proc collectLetNames(blk: Block, map: var Table[string, string]) =
+ if blk == nil: return
+ for s in blk.stmts:
+ if s == nil: continue
+ if s.kind == skLet and s.stmtLetName.len > 0 and not map.hasKey(s.stmtLetName):
+ # Unhygienic: call-site binder from `var $name` — keep the name
+ if s.stmtLetName notin expandUnhygienic:
+ inc macroGensymCounter
+ map[s.stmtLetName] = "__m" & $macroGensymCounter & "_" & s.stmtLetName
+ if s.kind == skFor and s.stmtForVar.len > 0 and not map.hasKey(s.stmtForVar):
+ if s.stmtForVar notin expandUnhygienic:
+ inc macroGensymCounter
+ map[s.stmtForVar] = "__m" & $macroGensymCounter & "_" & s.stmtForVar
+ if s.kind == skLet:
+ collectLetNamesExpr(s.stmtLetInit, map)
+ if s.kind == skExpr:
+ collectLetNamesExpr(s.stmtExpr, map)
+ if s.kind == skMacroRep:
+ collectLetNames(s.stmtMacroRepBody, map)
+ if s.kind == skIf:
+ collectLetNames(s.stmtIfThen, map)
+ collectLetNames(s.stmtIfElse, map)
+ if s.kind == skWhile:
+ collectLetNames(s.stmtWhileBody, map)
+ if s.kind == skFor:
+ collectLetNamesExpr(s.stmtForIter, map)
+ collectLetNames(s.stmtForBody, map)
+
+proc gensymLocals(b: Block, callLoc: SourceLocation): Block =
+ ## Rename template let/var locals so multiple expansions don't collide in CBE.
+ if b == nil: return nil
+ var map = initTable[string, string]()
+ collectLetNames(b, map)
+ if map.len == 0:
+ return cloneBlock(b)
+ result = renameIdentsBlock(b, map)
+ if result != nil:
+ result.loc = callLoc
+
+proc substExpr(e: Expr, env: MacroEnv, callLoc: SourceLocation): Expr
+proc substStmt(s: Stmt, env: MacroEnv, callLoc: SourceLocation): Stmt
+proc substBlock(b: Block, env: MacroEnv, callLoc: SourceLocation): Block
+proc substStmtsFlat(stmts: seq[Stmt], env: MacroEnv, callLoc: SourceLocation): seq[Stmt]
+
+proc substBlock(b: Block, env: MacroEnv, callLoc: SourceLocation): Block =
+ if b == nil: return nil
+ result = Block(loc: callLoc, stmts: substStmtsFlat(b.stmts, env, callLoc))
+
+proc collectListNames(e: Expr, env: MacroEnv, into: var seq[string]) =
+ if e == nil: return
+ if e.kind == ekIdent and env.lists.hasKey(e.exprIdent):
+ if e.exprIdent notin into:
+ into.add(e.exprIdent)
+ case e.kind
+ of ekUnary: collectListNames(e.exprUnaryOperand, env, into)
+ of ekBinary:
+ collectListNames(e.exprBinaryLeft, env, into)
+ collectListNames(e.exprBinaryRight, env, into)
+ of ekCall:
+ collectListNames(e.exprCallCallee, env, into)
+ for a in e.exprCallArgs: collectListNames(a, env, into)
+ of ekAssign:
+ collectListNames(e.exprAssignTarget, env, into)
+ collectListNames(e.exprAssignValue, env, into)
+ of ekBlock:
+ if e.exprBlock != nil:
+ for st in e.exprBlock.stmts:
+ if st == nil: continue
+ if st.kind == skExpr: collectListNames(st.stmtExpr, env, into)
+ elif st.kind == skLet: collectListNames(st.stmtLetInit, env, into)
+ else: discard
+
+proc collectListNamesStmt(st: Stmt, env: MacroEnv, into: var seq[string]) =
+ if st == nil: return
+ case st.kind
+ of skExpr: collectListNames(st.stmtExpr, env, into)
+ of skLet: collectListNames(st.stmtLetInit, env, into)
+ of skIf: collectListNames(st.stmtIfCond, env, into)
+ of skReturn: collectListNames(st.stmtReturnValue, env, into)
+ of skMacroRep:
+ if st.stmtMacroRepBody != nil:
+ for inner in st.stmtMacroRepBody.stmts:
+ collectListNamesStmt(inner, env, into)
+ else: discard
+
+proc substStmtsFlat(stmts: seq[Stmt], env: MacroEnv, callLoc: SourceLocation): seq[Stmt] =
+ ## Flatten skMacroRep into repeated statements (zip lists / once for singles).
+ result = @[]
+ for s in stmts:
+ if s == nil: continue
+ if s.kind == skMacroRep:
+ var listNames: seq[string] = @[]
+ if s.stmtMacroRepBody != nil:
+ for st in s.stmtMacroRepBody.stmts:
+ collectListNamesStmt(st, env, listNames)
+ # Nested same-list: if no list names but singles used, expand once
+ if listNames.len == 0:
+ let body = substBlock(s.stmtMacroRepBody, env, callLoc)
+ if body != nil:
+ for st in body.stmts:
+ result.add(st)
+ continue
+ # Zip all referenced lists by index
+ var n = 0
+ for ln in listNames:
+ if env.lists.hasKey(ln):
+ n = max(n, env.lists[ln].len)
+ if n == 0:
+ continue
+ for i in 0 ..< n:
+ var singles = initTable[string, Expr]()
+ for k, v in env.singles.pairs: singles[k] = v
+ var lists = initTable[string, seq[Expr]]()
+ for k, v in env.lists.pairs:
+ if k notin listNames:
+ lists[k] = v
+ for ln in listNames:
+ if env.lists.hasKey(ln) and i < env.lists[ln].len:
+ singles[ln] = env.lists[ln][i]
+ let subEnv = MacroEnv(singles: singles, lists: lists)
+ let body = substBlock(s.stmtMacroRepBody, subEnv, callLoc)
+ if body != nil:
+ for st in body.stmts:
+ result.add(st)
+ else:
+ result.add(substStmt(s, env, callLoc))
+
+proc substStmt(s: Stmt, env: MacroEnv, callLoc: SourceLocation): Stmt =
+ if s == nil: return nil
+ if s.kind == skMacroRep:
+ # Should be flattened by substStmtsFlat; expand empty as no-op expr
+ return Stmt(kind: skExpr, loc: callLoc,
+ stmtExpr: newLiteralExpr(Token(kind: tkIntLiteral, text: "0", loc: callLoc)))
+ let c = cloneStmt(s)
+ case c.kind
+ of skExpr:
+ c.stmtExpr = substExpr(c.stmtExpr, env, callLoc)
+ of skLet:
+ # Unhygienic binder: `var $name: T = …` with $name:ident → call-site name
+ let letBn = binderIdentFromFrag(env, c.stmtLetName)
+ if letBn.len > 0:
+ c.stmtLetName = letBn
+ expandUnhygienic.incl(letBn)
+ c.stmtLetInit = substExpr(c.stmtLetInit, env, callLoc)
+ of skIf:
+ c.stmtIfCond = substExpr(c.stmtIfCond, env, callLoc)
+ c.stmtIfThen = substBlock(c.stmtIfThen, env, callLoc)
+ var eifs: seq[ElseIf] = @[]
+ for ei in c.stmtIfElseIfs:
+ eifs.add(ElseIf(loc: callLoc, cond: substExpr(ei.cond, env, callLoc),
+ blk: substBlock(ei.blk, env, callLoc)))
+ c.stmtIfElseIfs = eifs
+ c.stmtIfElse = substBlock(c.stmtIfElse, env, callLoc)
+ of skWhile:
+ c.stmtWhileCond = substExpr(c.stmtWhileCond, env, callLoc)
+ c.stmtWhileBody = substBlock(c.stmtWhileBody, env, callLoc)
+ of skDoWhile:
+ c.stmtDoWhileBody = substBlock(c.stmtDoWhileBody, env, callLoc)
+ c.stmtDoWhileCond = substExpr(c.stmtDoWhileCond, env, callLoc)
+ of skLoop:
+ c.stmtLoopBody = substBlock(c.stmtLoopBody, env, callLoc)
+ of skFor:
+ let forBn = binderIdentFromFrag(env, c.stmtForVar)
+ if forBn.len > 0:
+ c.stmtForVar = forBn
+ expandUnhygienic.incl(forBn)
+ c.stmtForIter = substExpr(c.stmtForIter, env, callLoc)
+ c.stmtForBody = substBlock(c.stmtForBody, env, callLoc)
+ of skMatch:
+ c.stmtMatchSubject = substExpr(c.stmtMatchSubject, env, callLoc)
+ var arms: seq[MatchArm] = @[]
+ for arm in c.stmtMatchArms:
+ arms.add(MatchArm(loc: callLoc, pattern: arm.pattern,
+ body: substExpr(arm.body, env, callLoc)))
+ c.stmtMatchArms = arms
+ of skReturn:
+ c.stmtReturnValue = substExpr(c.stmtReturnValue, env, callLoc)
+ of skStaticAssert:
+ c.stmtStaticAssertCond = substExpr(c.stmtStaticAssertCond, env, callLoc)
+ c.stmtStaticAssertMsg = substExpr(c.stmtStaticAssertMsg, env, callLoc)
+ of skComptime:
+ c.stmtComptimeBlock = substBlock(c.stmtComptimeBlock, env, callLoc)
+ of skEmit:
+ c.stmtEmitExpr = substExpr(c.stmtEmitExpr, env, callLoc)
+ of skDefer:
+ c.stmtDeferBody = substExpr(c.stmtDeferBody, env, callLoc)
+ of skSwitch:
+ c.stmtSwitchExpr = substExpr(c.stmtSwitchExpr, env, callLoc)
+ var cases: seq[SwitchCase] = @[]
+ for sc in c.stmtSwitchCases:
+ cases.add(SwitchCase(loc: callLoc,
+ caseValue: substExpr(sc.caseValue, env, callLoc),
+ caseBody: substBlock(sc.caseBody, env, callLoc)))
+ c.stmtSwitchCases = cases
+ c.stmtSwitchDefault = substBlock(c.stmtSwitchDefault, env, callLoc)
+ of skMacroRep:
+ discard
+ else:
+ discard
+ c.loc = callLoc
+ result = c
+
+proc substExpr(e: Expr, env: MacroEnv, callLoc: SourceLocation): Expr =
+ if e == nil: return nil
+ # Fragment splice: $x → clone of bound argument (already call-site loc)
+ if e.kind == ekIdent and env.singles.hasKey(e.exprIdent):
+ result = cloneExpr(env.singles[e.exprIdent])
+ graftExprLoc(result, callLoc)
+ return
+ # Bare use of list frag outside $(…)* → first element if any, else 0
+ if e.kind == ekIdent and env.lists.hasKey(e.exprIdent):
+ let items = env.lists[e.exprIdent]
+ if items.len > 0:
+ result = cloneExpr(items[0])
+ graftExprLoc(result, callLoc)
+ return
+ return newLiteralExpr(Token(kind: tkIntLiteral, text: "0", loc: callLoc))
+ let c = cloneExpr(e)
+ case c.kind
+ of ekUnary:
+ c.exprUnaryOperand = substExpr(c.exprUnaryOperand, env, callLoc)
+ of ekPostfix:
+ c.exprPostfixOperand = substExpr(c.exprPostfixOperand, env, callLoc)
+ of ekBinary:
+ c.exprBinaryLeft = substExpr(c.exprBinaryLeft, env, callLoc)
+ c.exprBinaryRight = substExpr(c.exprBinaryRight, env, callLoc)
+ of ekAssign:
+ c.exprAssignTarget = substExpr(c.exprAssignTarget, env, callLoc)
+ c.exprAssignValue = substExpr(c.exprAssignValue, env, callLoc)
+ of ekTernary:
+ c.exprTernaryCond = substExpr(c.exprTernaryCond, env, callLoc)
+ c.exprTernaryThen = substExpr(c.exprTernaryThen, env, callLoc)
+ c.exprTernaryElse = substExpr(c.exprTernaryElse, env, callLoc)
+ of ekRange:
+ c.exprRangeLo = substExpr(c.exprRangeLo, env, callLoc)
+ c.exprRangeHi = substExpr(c.exprRangeHi, env, callLoc)
+ of ekCall:
+ c.exprCallCallee = substExpr(c.exprCallCallee, env, callLoc)
+ var args: seq[Expr] = @[]
+ for a in c.exprCallArgs:
+ args.add(substExpr(a, env, callLoc))
+ c.exprCallArgs = args
+ of ekIndex:
+ c.exprIndexObj = substExpr(c.exprIndexObj, env, callLoc)
+ c.exprIndexIdx = substExpr(c.exprIndexIdx, env, callLoc)
+ of ekField:
+ c.exprFieldObj = substExpr(c.exprFieldObj, env, callLoc)
+ of ekStructInit:
+ var fields: seq[tuple[name: string, value: Expr]] = @[]
+ for f in c.exprStructInitFields:
+ fields.add((f.name, substExpr(f.value, env, callLoc)))
+ c.exprStructInitFields = fields
+ of ekSlice:
+ var els: seq[Expr] = @[]
+ for el in c.exprSliceElements:
+ els.add(substExpr(el, env, callLoc))
+ c.exprSliceElements = els
+ of ekSpread:
+ c.exprSpreadOperand = substExpr(c.exprSpreadOperand, env, callLoc)
+ of ekTuple:
+ var els: seq[Expr] = @[]
+ for el in c.exprTupleElements:
+ els.add(substExpr(el, env, callLoc))
+ c.exprTupleElements = els
+ of ekCast:
+ c.exprCastOperand = substExpr(c.exprCastOperand, env, callLoc)
+ of ekIs:
+ c.exprIsOperand = substExpr(c.exprIsOperand, env, callLoc)
+ of ekTry:
+ c.exprTryOperand = substExpr(c.exprTryOperand, env, callLoc)
+ of ekUnwrap:
+ c.exprUnwrapOperand = substExpr(c.exprUnwrapOperand, env, callLoc)
+ of ekSpawn:
+ c.exprSpawnCallee = substExpr(c.exprSpawnCallee, env, callLoc)
+ var args: seq[Expr] = @[]
+ for a in c.exprSpawnArgs:
+ args.add(substExpr(a, env, callLoc))
+ c.exprSpawnArgs = args
+ of ekAwait:
+ c.exprAwaitOperand = substExpr(c.exprAwaitOperand, env, callLoc)
+ of ekBorrow:
+ c.exprBorrowOperand = substExpr(c.exprBorrowOperand, env, callLoc)
+ of ekBlock:
+ c.exprBlock = substBlock(c.exprBlock, env, callLoc)
+ of ekMatch:
+ c.exprMatchSubject = substExpr(c.exprMatchSubject, env, callLoc)
+ var arms: seq[MatchArm] = @[]
+ for arm in c.exprMatchArms:
+ arms.add(MatchArm(loc: callLoc, pattern: arm.pattern,
+ body: substExpr(arm.body, env, callLoc)))
+ c.exprMatchArms = arms
+ of ekStringInterp:
+ var ies: seq[Expr] = @[]
+ for ie in c.exprInterpExprs:
+ ies.add(substExpr(ie, env, callLoc))
+ c.exprInterpExprs = ies
+ of ekClosure:
+ c.exprClosureBody = substBlock(c.exprClosureBody, env, callLoc)
+ of ekMacroCall:
+ # Nested macro call — expand outer pass will re-walk; still subst args
+ var args: seq[Expr] = @[]
+ for a in c.exprMacroArgs:
+ args.add(substExpr(a, env, callLoc))
+ c.exprMacroArgs = args
+ else:
+ discard
+ c.loc = callLoc
+ result = c
+
+# ---------------------------------------------------------------------------
+# Expand one call
+# ---------------------------------------------------------------------------
+
+# Mutual recursion
+proc expandExpr(e: Expr, macros: Table[string, Decl], res: var MacroExpandResult,
+ depth: int): Expr
+proc expandBlock(b: Block, macros: Table[string, Decl], res: var MacroExpandResult,
+ depth: int): Block
+proc expandStmt(s: Stmt, macros: Table[string, Decl], res: var MacroExpandResult,
+ depth: int): Stmt
+proc expandDecl(d: Decl, macros: Table[string, Decl], res: var MacroExpandResult,
+ depth: int)
+
+proc expandOneCall(call: Expr, macros: Table[string, Decl],
+ res: var MacroExpandResult, depth: int): Expr =
+ if call == nil or call.kind != ekMacroCall:
+ return call
+ if depth > 32:
+ res.emitErr(call.loc, "macro expansion depth exceeded")
+ return newLiteralExpr(Token(kind: tkIntLiteral, text: "0", loc: call.loc))
+
+ let name = call.exprMacroName
+ # Built-in quote!(e) — identity with call-site graft (hygiene API demo)
+ if name == "quote":
+ if call.exprMacroArgs.len != 1:
+ res.emitErr(call.loc, "quote! expects exactly 1 argument")
+ return newLiteralExpr(Token(kind: tkIntLiteral, text: "0", loc: call.loc))
+ result = cloneExpr(call.exprMacroArgs[0])
+ # Expand nested macros inside quoted expr first
+ result = expandExpr(result, macros, res, depth + 1)
+ graftExprLoc(result, call.loc)
+ return
+
+ if not macros.hasKey(name):
+ res.emitErr(call.loc, "unknown macro '" & name & "'")
+ return newLiteralExpr(Token(kind: tkIntLiteral, text: "0", loc: call.loc))
+
+ let mdecl = macros[name]
+ let nargs = call.exprMacroArgs.len
+
+ # Expand args first
+ var args: seq[Expr] = @[]
+ for a in call.exprMacroArgs:
+ args.add(expandExpr(a, macros, res, depth + 1))
+
+ # Build arg groups: m!(a,b; c,d) → [[a,b],[c,d]]
+ var groups: seq[seq[Expr]] = @[]
+ if call.exprMacroGroupLens.len == 0:
+ groups.add(args)
+ else:
+ var off = 0
+ for glen in call.exprMacroGroupLens:
+ var g: seq[Expr] = @[]
+ var j = 0
+ while j < glen and off < args.len:
+ g.add(args[off])
+ inc off
+ inc j
+ groups.add(g)
+ # leftover args append to last group
+ while off < args.len:
+ if groups.len == 0: groups.add(@[])
+ groups[^1].add(args[off])
+ inc off
+
+ proc fragNames(f: MacroFragment): seq[string] =
+ if f.names.len > 0: return f.names
+ if f.name.len > 0: return @[f.name]
+ @[]
+
+ proc fragKinds(f: MacroFragment): seq[MacroFragKind] =
+ if f.kinds.len > 0: return f.kinds
+ @[f.kind]
+
+ proc fragMatches(k: MacroFragKind, arg: Expr): bool =
+ ## Kind constraint at match time (after arg expand).
+ if arg == nil: return false
+ case k
+ of mfkIdent: arg.kind == ekIdent
+ of mfkLiteral: arg.kind == ekLiteral
+ of mfkBlock: arg.kind == ekBlock
+ of mfkExpr, mfkTt: true
+
+ var matched: MacroRule
+ var env: MacroEnv
+ var found = false
+ for rule in mdecl.declMacroRules:
+ var e = MacroEnv(singles: initTable[string, Expr](), lists: initTable[string, seq[Expr]]())
+ var failed = false
+ let nReps = rule.frags.countIt(it.isRep)
+ var gi = 0
+ var ai = 0
+ let useGroups = nReps > 1 and groups.len > 1
+ let flat = args
+
+ for frag in rule.frags:
+ if failed: break
+ let ns = fragNames(frag)
+ let ks = fragKinds(frag)
+ if frag.isRep:
+ let chunk = max(1, ns.len)
+ for n in ns:
+ e.lists[n] = @[]
+ if ns.len == 0:
+ failed = true
+ break
+ if useGroups:
+ if gi >= groups.len:
+ continue # empty rep
+ let g = groups[gi]
+ inc gi
+ if g.len mod chunk != 0:
+ failed = true
+ break
+ var i = 0
+ while i < g.len:
+ for c in 0 ..< chunk:
+ let arg = g[i + c]
+ let k = if c < ks.len: ks[c] else: mfkExpr
+ if not fragMatches(k, arg):
+ failed = true
+ break
+ e.lists[ns[c]].add(arg)
+ if failed: break
+ i += chunk
+ else:
+ if (flat.len - ai) mod chunk != 0:
+ failed = true
+ break
+ while ai < flat.len:
+ for c in 0 ..< chunk:
+ let arg = flat[ai]
+ let k = if c < ks.len: ks[c] else: mfkExpr
+ if not fragMatches(k, arg):
+ failed = true
+ break
+ e.lists[ns[c]].add(arg)
+ inc ai
+ if failed: break
+ else:
+ var arg: Expr = nil
+ if useGroups:
+ if gi >= groups.len or ai >= groups[gi].len:
+ failed = true
+ break
+ arg = groups[gi][ai]
+ inc ai
+ if ai >= groups[gi].len:
+ inc gi
+ ai = 0
+ else:
+ if ai >= flat.len:
+ failed = true
+ break
+ arg = flat[ai]
+ inc ai
+ let k = if ks.len > 0: ks[0] else: frag.kind
+ if not fragMatches(k, arg):
+ failed = true
+ break
+ let n = if ns.len > 0: ns[0] else: frag.name
+ e.singles[n] = arg
+
+ if not failed:
+ if useGroups:
+ if gi < groups.len: failed = true
+ else:
+ if ai != flat.len: failed = true
+
+ if failed: continue
+ matched = rule
+ env = e
+ found = true
+ break
+
+ if not found:
+ res.emitErr(call.loc, "macro '" & name & "' has no matching rule for " &
+ $nargs & " argument(s)")
+ return newLiteralExpr(Token(kind: tkIntLiteral, text: "0", loc: call.loc))
+
+ if matched.body == nil:
+ res.emitErr(call.loc, "macro '" & name & "' rule has empty body")
+ return newLiteralExpr(Token(kind: tkIntLiteral, text: "0", loc: call.loc))
+
+ # Splice $frags / $(…)* , then gensym hygienic locals (skip unhygienic binders)
+ expandUnhygienic = initHashSet[string]()
+ let body = substBlock(matched.body, env, call.loc)
+ let body2 = gensymLocals(body, call.loc)
+ result = Expr(kind: ekBlock, loc: call.loc, exprBlock: body2)
+ # Expand any macro calls introduced by substitution
+ result = expandExpr(result, macros, res, depth + 1)
+
+# ---------------------------------------------------------------------------
+# Walk + expand trees
+# ---------------------------------------------------------------------------
+
+proc expandExpr(e: Expr, macros: Table[string, Decl], res: var MacroExpandResult,
+ depth: int): Expr =
+ if e == nil: return nil
+ if e.kind == ekMacroCall:
+ return expandOneCall(e, macros, res, depth)
+
+ case e.kind
+ of ekUnary:
+ e.exprUnaryOperand = expandExpr(e.exprUnaryOperand, macros, res, depth)
+ of ekPostfix:
+ e.exprPostfixOperand = expandExpr(e.exprPostfixOperand, macros, res, depth)
+ of ekBinary:
+ e.exprBinaryLeft = expandExpr(e.exprBinaryLeft, macros, res, depth)
+ e.exprBinaryRight = expandExpr(e.exprBinaryRight, macros, res, depth)
+ of ekAssign:
+ e.exprAssignTarget = expandExpr(e.exprAssignTarget, macros, res, depth)
+ e.exprAssignValue = expandExpr(e.exprAssignValue, macros, res, depth)
+ of ekTernary:
+ e.exprTernaryCond = expandExpr(e.exprTernaryCond, macros, res, depth)
+ e.exprTernaryThen = expandExpr(e.exprTernaryThen, macros, res, depth)
+ e.exprTernaryElse = expandExpr(e.exprTernaryElse, macros, res, depth)
+ of ekRange:
+ e.exprRangeLo = expandExpr(e.exprRangeLo, macros, res, depth)
+ e.exprRangeHi = expandExpr(e.exprRangeHi, macros, res, depth)
+ of ekCall:
+ e.exprCallCallee = expandExpr(e.exprCallCallee, macros, res, depth)
+ for i in 0 ..< e.exprCallArgs.len:
+ e.exprCallArgs[i] = expandExpr(e.exprCallArgs[i], macros, res, depth)
+ of ekIndex:
+ e.exprIndexObj = expandExpr(e.exprIndexObj, macros, res, depth)
+ e.exprIndexIdx = expandExpr(e.exprIndexIdx, macros, res, depth)
+ of ekField:
+ e.exprFieldObj = expandExpr(e.exprFieldObj, macros, res, depth)
+ of ekStructInit:
+ for i in 0 ..< e.exprStructInitFields.len:
+ e.exprStructInitFields[i].value =
+ expandExpr(e.exprStructInitFields[i].value, macros, res, depth)
+ of ekSlice:
+ for i in 0 ..< e.exprSliceElements.len:
+ e.exprSliceElements[i] = expandExpr(e.exprSliceElements[i], macros, res, depth)
+ of ekSpread:
+ e.exprSpreadOperand = expandExpr(e.exprSpreadOperand, macros, res, depth)
+ of ekTuple:
+ for i in 0 ..< e.exprTupleElements.len:
+ e.exprTupleElements[i] = expandExpr(e.exprTupleElements[i], macros, res, depth)
+ of ekCast:
+ e.exprCastOperand = expandExpr(e.exprCastOperand, macros, res, depth)
+ of ekIs:
+ e.exprIsOperand = expandExpr(e.exprIsOperand, macros, res, depth)
+ of ekTry:
+ e.exprTryOperand = expandExpr(e.exprTryOperand, macros, res, depth)
+ of ekUnwrap:
+ e.exprUnwrapOperand = expandExpr(e.exprUnwrapOperand, macros, res, depth)
+ of ekSpawn:
+ e.exprSpawnCallee = expandExpr(e.exprSpawnCallee, macros, res, depth)
+ for i in 0 ..< e.exprSpawnArgs.len:
+ e.exprSpawnArgs[i] = expandExpr(e.exprSpawnArgs[i], macros, res, depth)
+ of ekAwait:
+ e.exprAwaitOperand = expandExpr(e.exprAwaitOperand, macros, res, depth)
+ of ekBorrow:
+ e.exprBorrowOperand = expandExpr(e.exprBorrowOperand, macros, res, depth)
+ of ekBlock:
+ e.exprBlock = expandBlock(e.exprBlock, macros, res, depth)
+ of ekMatch:
+ e.exprMatchSubject = expandExpr(e.exprMatchSubject, macros, res, depth)
+ for i in 0 ..< e.exprMatchArms.len:
+ e.exprMatchArms[i].body = expandExpr(e.exprMatchArms[i].body, macros, res, depth)
+ of ekStringInterp:
+ for i in 0 ..< e.exprInterpExprs.len:
+ e.exprInterpExprs[i] = expandExpr(e.exprInterpExprs[i], macros, res, depth)
+ of ekClosure:
+ e.exprClosureBody = expandBlock(e.exprClosureBody, macros, res, depth)
+ else:
+ discard
+ result = e
+
+proc expandBlock(b: Block, macros: Table[string, Decl], res: var MacroExpandResult,
+ depth: int): Block =
+ if b == nil: return nil
+ for i in 0 ..< b.stmts.len:
+ b.stmts[i] = expandStmt(b.stmts[i], macros, res, depth)
+ result = b
+
+proc expandStmt(s: Stmt, macros: Table[string, Decl], res: var MacroExpandResult,
+ depth: int): Stmt =
+ if s == nil: return nil
+ case s.kind
+ of skExpr:
+ s.stmtExpr = expandExpr(s.stmtExpr, macros, res, depth)
+ of skLet:
+ s.stmtLetInit = expandExpr(s.stmtLetInit, macros, res, depth)
+ of skIf:
+ s.stmtIfCond = expandExpr(s.stmtIfCond, macros, res, depth)
+ s.stmtIfThen = expandBlock(s.stmtIfThen, macros, res, depth)
+ for i in 0 ..< s.stmtIfElseIfs.len:
+ s.stmtIfElseIfs[i].cond = expandExpr(s.stmtIfElseIfs[i].cond, macros, res, depth)
+ s.stmtIfElseIfs[i].blk = expandBlock(s.stmtIfElseIfs[i].blk, macros, res, depth)
+ s.stmtIfElse = expandBlock(s.stmtIfElse, macros, res, depth)
+ of skWhile:
+ s.stmtWhileCond = expandExpr(s.stmtWhileCond, macros, res, depth)
+ s.stmtWhileBody = expandBlock(s.stmtWhileBody, macros, res, depth)
+ of skDoWhile:
+ s.stmtDoWhileBody = expandBlock(s.stmtDoWhileBody, macros, res, depth)
+ s.stmtDoWhileCond = expandExpr(s.stmtDoWhileCond, macros, res, depth)
+ of skLoop:
+ s.stmtLoopBody = expandBlock(s.stmtLoopBody, macros, res, depth)
+ of skFor:
+ s.stmtForIter = expandExpr(s.stmtForIter, macros, res, depth)
+ s.stmtForBody = expandBlock(s.stmtForBody, macros, res, depth)
+ of skMatch:
+ s.stmtMatchSubject = expandExpr(s.stmtMatchSubject, macros, res, depth)
+ for i in 0 ..< s.stmtMatchArms.len:
+ s.stmtMatchArms[i].body = expandExpr(s.stmtMatchArms[i].body, macros, res, depth)
+ of skReturn:
+ s.stmtReturnValue = expandExpr(s.stmtReturnValue, macros, res, depth)
+ of skStaticAssert:
+ s.stmtStaticAssertCond = expandExpr(s.stmtStaticAssertCond, macros, res, depth)
+ s.stmtStaticAssertMsg = expandExpr(s.stmtStaticAssertMsg, macros, res, depth)
+ of skComptime:
+ s.stmtComptimeBlock = expandBlock(s.stmtComptimeBlock, macros, res, depth)
+ of skEmit:
+ s.stmtEmitExpr = expandExpr(s.stmtEmitExpr, macros, res, depth)
+ of skDefer:
+ s.stmtDeferBody = expandExpr(s.stmtDeferBody, macros, res, depth)
+ of skSwitch:
+ s.stmtSwitchExpr = expandExpr(s.stmtSwitchExpr, macros, res, depth)
+ for i in 0 ..< s.stmtSwitchCases.len:
+ s.stmtSwitchCases[i].caseValue =
+ expandExpr(s.stmtSwitchCases[i].caseValue, macros, res, depth)
+ s.stmtSwitchCases[i].caseBody =
+ expandBlock(s.stmtSwitchCases[i].caseBody, macros, res, depth)
+ s.stmtSwitchDefault = expandBlock(s.stmtSwitchDefault, macros, res, depth)
+ of skDecl:
+ expandDecl(s.stmtDecl, macros, res, depth)
+ else:
+ discard
+ result = s
+
+proc expandDecl(d: Decl, macros: Table[string, Decl], res: var MacroExpandResult,
+ depth: int) =
+ if d == nil: return
+ case d.kind
+ of dkFunc:
+ if d.declFuncBody != nil:
+ d.declFuncBody = expandBlock(d.declFuncBody, macros, res, depth)
+ of dkImpl:
+ for m in d.declImplMethods:
+ expandDecl(m, macros, res, depth)
+ of dkModule:
+ for it in d.declModuleItems:
+ expandDecl(it, macros, res, depth)
+ of dkConst:
+ d.declConstValue = expandExpr(d.declConstValue, macros, res, depth)
+ of dkInterface:
+ for m in d.declInterfaceMethods:
+ expandDecl(m, macros, res, depth)
+ of dkExternBlock:
+ for it in d.declExtBlockItems:
+ expandDecl(it, macros, res, depth)
+ else:
+ discard
+
+proc collectMacroDeclsFrom(d: Decl, tab: var Table[string, Decl]) =
+ if d == nil: return
+ case d.kind
+ of dkMacro:
+ if not tab.hasKey(d.declMacroName):
+ tab[d.declMacroName] = d
+ of dkModule:
+ for it in d.declModuleItems:
+ collectMacroDeclsFrom(it, tab)
+ else:
+ discard
+
+proc collectMacroDecls(modu: Module): Table[string, Decl] =
+ result = initTable[string, Decl]()
+ for it in modu.items:
+ collectMacroDeclsFrom(it, result)
+
+proc expandMacros*(modu: Module): MacroExpandResult =
+ ## Expand all declarative macro! invocations in the module (in place).
+ result = MacroExpandResult(diagnostics: @[])
+ let macros = collectMacroDecls(modu)
+ for d in modu.items:
+ expandDecl(d, macros, result, 0)
diff --git a/bootstrap/parser.nim b/bootstrap/parser.nim
index 54a84e1..200ab30 100644
--- a/bootstrap/parser.nim
+++ b/bootstrap/parser.nim
@@ -21,12 +21,14 @@ type
pos: int
diagnostics: seq[ParserDiagnostic]
structInitAllowed: bool ## disabled inside if/while/for/match conditions
+ macroTemplateMode: bool ## true while parsing macro! rule body (allows $(…)*)
proc initParser*(tokens: seq[Token], sourceName: string = ""): Parser =
result.tokens = tokens
result.sourceName = sourceName
result.pos = 0
result.structInitAllowed = true
+ result.macroTemplateMode = false
# ---------------------------------------------------------------------------
# Token helpers
@@ -152,7 +154,7 @@ proc synchronize(p: var Parser) =
if p.previous.kind == tkSemicolon: return
case p.peek()
of tkFunc, tkStruct, tkEnum, tkUnion, tkInterface, tkExtend,
- tkModule, tkImport, tkConst, tkType, tkExtern, tkPub:
+ tkModule, tkImport, tkConst, tkType, tkExtern, tkPub, tkMacro:
return
else:
discard p.advance()
@@ -183,7 +185,10 @@ type
release*: bool ## @[Release] — explicit zero-cost (no borrow checks)
proc parseAttrs(p: var Parser): ParsedAttrs =
- while p.check(tkAt):
+ while true:
+ p.skipNewlines()
+ if not p.check(tkAt):
+ break
discard p.advance() # @
discard p.expect(tkLBracket, "expected '[' after '@'")
let name = p.expect(tkIdent, "expected attribute name").text
@@ -728,7 +733,43 @@ proc parsePostfix(p: var Parser): Expr =
left = Expr(kind: ekTry, loc: loc, exprTryOperand: left, exprTryType: nil)
of tkBang:
discard p.advance()
- left = Expr(kind: ekUnwrap, loc: loc, exprUnwrapOperand: left)
+ # name!(args) → declarative macro call (not unwrap)
+ if left.kind == ekIdent and p.check(tkLParen):
+ discard p.advance() # (
+ var margs: seq[Expr] = @[]
+ var groupLens: seq[int] = @[]
+ var curGroup = 0
+ while not p.check(tkRParen) and not p.isAtEnd:
+ p.skipNewlines()
+ if p.check(tkRParen): break
+ # `;` starts a new arg group for multi-rep patterns
+ if p.check(tkSemicolon):
+ discard p.advance()
+ groupLens.add(curGroup)
+ curGroup = 0
+ p.skipNewlines()
+ continue
+ margs.add(p.parseExpr())
+ inc curGroup
+ p.skipNewlines()
+ if p.check(tkComma):
+ discard p.advance()
+ elif p.check(tkSemicolon):
+ discard
+ # handled at loop top
+ else:
+ # allow end of args
+ discard
+ if curGroup > 0 or groupLens.len == 0:
+ groupLens.add(curGroup)
+ # single group of all args → empty groupLens means "one group" for expander
+ if groupLens.len == 1:
+ groupLens = @[]
+ discard p.expect(tkRParen, "expected ')' to close macro arguments")
+ left = Expr(kind: ekMacroCall, loc: loc, exprMacroName: left.exprIdent,
+ exprMacroArgs: margs, exprMacroGroupLens: groupLens)
+ else:
+ left = Expr(kind: ekUnwrap, loc: loc, exprUnwrapOperand: left)
of tkLBrace:
if p.structInitAllowed and left.kind in {ekIdent, ekPath, ekGenericCall}:
discard p.advance()
@@ -952,7 +993,26 @@ proc parseBlock(p: var Parser): Block =
# ---------------------------------------------------------------------------
proc parseStmt(p: var Parser): Stmt =
+ while p.check(tkNewLine):
+ discard p.advance()
let loc = p.currentLoc
+ # Macro template repetition: $( stmts… )*
+ if p.macroTemplateMode and p.check(tkDollar) and p.peek(1) == tkLParen:
+ discard p.advance() # $
+ discard p.advance() # (
+ var stmts: seq[Stmt] = @[]
+ while not p.check(tkRParen) and not p.isAtEnd:
+ while p.check(tkNewLine):
+ discard p.advance()
+ if p.check(tkRParen) or p.isAtEnd:
+ break
+ stmts.add(p.parseStmt())
+ discard p.expect(tkRParen, "expected ')' to close macro repetition")
+ discard p.expect(tkStar, "expected '*' after macro repetition")
+ if p.check(tkSemicolon):
+ discard p.advance()
+ return Stmt(kind: skMacroRep, loc: loc,
+ stmtMacroRepBody: Block(loc: loc, stmts: stmts))
case p.peek()
of tkLet, tkVar:
let isMut = p.peek() == tkVar
@@ -1561,6 +1621,145 @@ proc parseExternDecl(p: var Parser, isPublic: bool, attrs: ParsedAttrs): Decl =
return Decl(kind: dkExternVar, loc: loc, isPublic: isPublic,
declExtVarName: vName, declExtVarType: vType)
+proc parseMacroFragKind(p: var Parser, kindTok: Token): MacroFragKind =
+ case kindTok.text
+ of "expr": mfkExpr
+ of "ident": mfkIdent
+ of "tt": mfkTt
+ of "literal", "lit": mfkLiteral
+ of "block": mfkBlock
+ else:
+ p.emitError(kindTok.loc,
+ "unsupported macro fragment kind '" & kindTok.text &
+ "' (expr|ident|tt|literal|block)")
+ mfkExpr
+
+proc parseMacroFragment(p: var Parser): MacroFragment =
+ ## $name:kind (single non-rep fragment)
+ let fragTok = p.expect(tkIdent, "expected $name fragment in macro pattern")
+ if not fragTok.text.startsWith("$"):
+ p.emitError(fragTok.loc, "macro fragment must start with '$' (e.g. $x:expr)")
+ discard p.expect(tkColon, "expected ':' after macro fragment name")
+ let kindTok = p.expect(tkIdent, "expected fragment kind (expr|ident|tt|literal|block)")
+ let k = p.parseMacroFragKind(kindTok)
+ result = MacroFragment(
+ name: fragTok.text,
+ kind: k,
+ names: @[fragTok.text],
+ kinds: @[k],
+ isRep: false,
+ repSep: "")
+
+proc parseMacroRepGroup(p: var Parser): MacroFragment =
+ ## $( $a:kind , $b:kind , … ) ,* or … )*
+ ## Compound: multiple frags inside one rep → parallel lists (zipped).
+ discard p.expect(tkDollar, "expected '$'")
+ discard p.expect(tkLParen, "expected '(' after '$'")
+ p.skipNewlines()
+ var names: seq[string] = @[]
+ var kinds: seq[MacroFragKind] = @[]
+ while not p.check(tkRParen) and not p.isAtEnd:
+ let fragTok = p.expect(tkIdent, "expected $name inside repetition")
+ if not fragTok.text.startsWith("$"):
+ p.emitError(fragTok.loc, "macro fragment must start with '$'")
+ discard p.expect(tkColon, "expected ':' after fragment name")
+ let kindTok = p.expect(tkIdent, "expected fragment kind")
+ names.add(fragTok.text)
+ kinds.add(p.parseMacroFragKind(kindTok))
+ p.skipNewlines()
+ if p.check(tkComma):
+ discard p.advance()
+ p.skipNewlines()
+ else:
+ break
+ if names.len == 0:
+ p.emitError(p.currentLoc, "empty macro repetition group")
+ names.add("$x")
+ kinds.add(mfkExpr)
+ discard p.expect(tkRParen, "expected ')' after repeated fragment(s)")
+ var sep = ""
+ if p.check(tkComma):
+ discard p.advance()
+ sep = ","
+ discard p.expect(tkStar, "expected '*' after macro repetition")
+ result = MacroFragment(
+ name: names[0],
+ kind: kinds[0],
+ names: names,
+ kinds: kinds,
+ isRep: true,
+ repSep: sep)
+
+proc parseMacroDecl(p: var Parser, isPublic: bool): Decl =
+ ## macro! name {
+ ## ( $a:ident, $($x:expr),* ) => { … }
+ ## ( $($a:expr, $b:expr),* ) => { … } # compound / zipped
+ ## ( $($x:expr),* ; $($y:expr),* ) => { … } # multi-rep groups
+ ## }
+ let loc = p.currentLoc
+ discard p.expect(tkMacro, "expected 'macro'")
+ discard p.expect(tkBang, "expected '!' after macro")
+ let name = p.expect(tkIdent, "expected macro name").text
+ p.skipNewlines()
+ discard p.expect(tkLBrace, "expected '{' to start macro body")
+ var rules: seq[MacroRule] = @[]
+ while not p.check(tkRBrace) and not p.isAtEnd:
+ p.skipNewlines()
+ if p.check(tkRBrace) or p.isAtEnd:
+ break
+ let rloc = p.currentLoc
+ discard p.expect(tkLParen, "expected '(' to start macro pattern")
+ var frags: seq[MacroFragment] = @[]
+ while not p.check(tkRParen) and not p.isAtEnd:
+ p.skipNewlines()
+ if p.check(tkRParen): break
+ # Group separator for multi-rep: `;` between pattern elements
+ if p.check(tkSemicolon):
+ discard p.advance()
+ p.skipNewlines()
+ continue
+ # $( … ),* compound or single rep
+ if p.check(tkDollar) and p.peek(1) == tkLParen:
+ frags.add(p.parseMacroRepGroup())
+ p.skipNewlines()
+ # optional `;` after rep continues with more elements
+ if p.check(tkSemicolon):
+ discard p.advance()
+ p.skipNewlines()
+ continue
+ if p.check(tkComma):
+ discard p.advance()
+ p.skipNewlines()
+ continue
+ # no more separators → only rparen expected next
+ break
+ else:
+ frags.add(p.parseMacroFragment())
+ p.skipNewlines()
+ if p.check(tkComma):
+ discard p.advance()
+ elif p.check(tkSemicolon):
+ discard p.advance()
+ else:
+ break
+ discard p.expect(tkRParen, "expected ')' to close macro pattern")
+ p.skipNewlines()
+ discard p.expect(tkFatArrow, "expected '=>' after macro pattern")
+ p.skipNewlines()
+ let savedTpl = p.macroTemplateMode
+ p.macroTemplateMode = true
+ let body = p.parseBlock()
+ p.macroTemplateMode = savedTpl
+ rules.add(MacroRule(loc: rloc, frags: frags, body: body))
+ p.skipNewlines()
+ if p.check(tkComma) or p.check(tkSemicolon):
+ discard p.advance()
+ discard p.expect(tkRBrace, "expected '}' to close macro")
+ if rules.len == 0:
+ p.emitError(loc, "macro '" & name & "' has no rules")
+ return Decl(kind: dkMacro, loc: loc, isPublic: isPublic,
+ declMacroName: name, declMacroRules: rules)
+
proc parseDecl(p: var Parser): Decl =
let loc = p.currentLoc
var isPublic = false
@@ -1606,6 +1805,8 @@ proc parseDecl(p: var Parser): Decl =
return p.parseTypeAliasDecl(isPublic)
of tkExtern:
return p.parseExternDecl(isPublic, attrs)
+ of tkMacro:
+ return p.parseMacroDecl(isPublic)
else:
p.emitError(loc, "expected declaration")
p.synchronize()
diff --git a/bootstrap/sema.nim b/bootstrap/sema.nim
index 6894504..a110b4d 100644
--- a/bootstrap/sema.nim
+++ b/bootstrap/sema.nim
@@ -41,7 +41,8 @@ type
# Interface name -> interface decl
interfaceTable*: Table[string, Decl]
# Borrow checker state
- checkedFunc*: bool ## true inside @[Checked] function
+ checkedFunc*: bool ## true inside @[Checked] and not @[Release]
+ releaseFunc*: bool ## true inside @[Release] (zero-cost: no borrow checks)
currentFuncIsAsync*: bool ## true inside async func
movedVars*: seq[string] ## variables moved in current checked function
## Active exclusive borrows: source var → borrow site (let-bound &mut lasts for rest of fn)
@@ -1897,6 +1898,10 @@ proc checkExpr*(sema: var Sema, expr: Expr, scope: Scope): Type =
for e in expr.exprInterpExprs:
discard sema.checkExpr(e, scope)
return makeStr()
+ of ekMacroCall:
+ # Should have been expanded before analyze; leftover is a compiler bug
+ sema.emitError(expr.loc, "unexpanded macro call '" & expr.exprMacroName & "!'")
+ return makeUnknown()
of ekClosure:
let savedRetType = sema.currentRetType
let savedClosureDepth = sema.closureDepth
@@ -2088,6 +2093,10 @@ proc checkStmt(sema: var Sema, stmt: Stmt, scope: Scope): Type =
else:
discard
return makeVoid()
+ of skMacroRep:
+ # Templates with $(…)* must be expanded before type-check
+ sema.emitError(stmt.loc, "unexpanded macro repetition '$(…)*'")
+ return makeVoid()
# ---------------------------------------------------------------------------
# Function body checking
# ---------------------------------------------------------------------------
@@ -2106,8 +2115,11 @@ proc checkFunc(sema: var Sema, decl: Decl) =
if hasTypeGeneric:
return
let wasChecked = sema.checkedFunc
+ let wasRelease = sema.releaseFunc
let wasAsync = sema.currentFuncIsAsync
- sema.checkedFunc = "Checked" in decl.declAttrs
+ # C.4: @[Release] is the zero-cost escape — disables borrow checks even with @[Checked]
+ sema.releaseFunc = "Release" in decl.declAttrs
+ sema.checkedFunc = "Checked" in decl.declAttrs and not sema.releaseFunc
sema.currentFuncIsAsync = decl.declFuncIsAsync
if sema.checkedFunc:
sema.movedVars = @[]
@@ -2139,6 +2151,7 @@ proc checkFunc(sema: var Sema, decl: Decl) =
for tp in addedTypeParams:
sema.typeTable.del(tp)
sema.checkedFunc = wasChecked
+ sema.releaseFunc = wasRelease
sema.currentFuncIsAsync = wasAsync
sema.varRefLifetime = initTable[string, string]()
sema.returnLifetime = ""
diff --git a/bootstrap/token.nim b/bootstrap/token.nim
index 53a7642..9f59ec1 100644
--- a/bootstrap/token.nim
+++ b/bootstrap/token.nim
@@ -64,6 +64,8 @@ type
tkDyn # dyn
tkDefer # defer
tkLifetime # 'a (lifetime parameter)
+ tkMacro # macro (declarative macro! definitions)
+ tkDollar # bare $ for macro rep $( ... )*
##Punctuation
tkLParen # (
@@ -224,6 +226,7 @@ proc keywordKind*(text: string): TokenKind =
of "comptime": tkComptime
of "dyn": tkDyn
of "defer": tkDefer
+ of "macro": tkMacro
of "true", "false": tkBoolLiteral
else: tkIdent
@@ -281,6 +284,8 @@ proc tokenKindName*(kind: TokenKind): string =
of tkComptime: "'comptime'"
of tkDyn: "'dyn'"
of tkDefer: "'defer'"
+ of tkMacro: "'macro'"
+ of tkDollar: "'$'"
of tkLifetime: "lifetime"
of tkLParen: "'('"
of tkRParen: "')'"
diff --git a/docs/BuildAndTest.md b/docs/BuildAndTest.md
index 0575038..a4e284c 100644
--- a/docs/BuildAndTest.md
+++ b/docs/BuildAndTest.md
@@ -191,14 +191,40 @@ Use `Std::Test` module for assertions inside test code.
### Continuous integration
```bash
-make test # what PR CI runs
+make test # full sequential suite (local)
```
-| Workflow | When | Command |
-|----------|------|---------|
-| **`.github/workflows/ci.yml`** | every PR + push to `main` | `make test` |
+| Workflow | When | What runs |
+|----------|------|-----------|
+| **`.github/workflows/ci.yml`** | every PR + push to `main` | **split jobs** (see below) + macOS smoke |
| **`.github/workflows/selfhost-loop.yml`** | weekly / manual / path-filtered main | `make selfhost-loop` |
-`make test` includes examples, goldens, registry, apps, DWARF, and selfhost smoke
+**`ci.yml` layout (faster PR feedback):**
+
+| Job | OS | Targets |
+|-----|-----|---------|
+| `build` | ubuntu | `make build` → upload `buxc` artifact |
+| `unit` | ubuntu | `fmt-check` + `test-unit` (reuse artifact) |
+| `examples` | ubuntu | `test-examples` (full list) |
+| `goldens` | ubuntu | `test-errors` + `test-stdlib` + `test-registry` + `test-dwarf` + `test-drop-move` |
+| `apps` | ubuntu | `test-apps` |
+| `selfhost` | ubuntu | `test-selfhost-smoke` |
+| `macos` | macos-14 | rebuild + `test-unit` + `test-examples-smoke` (subset) |
+| `windows` | windows-latest | rebuild `buxc.exe` + pure Nim unit tests + CLI smoke |
+| `ci-gate` | ubuntu | fails if any required job failed (branch protection) |
+
+**CI speed helpers:**
+- Pin Nim **2.0.8**; cache `.nim_runtime` (big win on macOS — Nim is built from source there;
+ Windows uses a prebuilt Nim zip)
+- Project-local `nimcache/` via `NIMFLAGS=--nimcache:nimcache`, cached per job by source hash
+- macOS skips full EXAMPLES (Linux already runs them) and skips `fmt-check` (Linux unit job)
+- **Windows** does **not** run `bux run` examples yet: `rt/runtime.c` needs POSIX
+ (`ucontext`, `pthread`, BSD sockets). Smoke still validates bootstrap + unit tests on Win.
+
+Parallel Linux jobs set `BUX_SKIP_BUILD=1` after downloading the `buxc` artifact.
+Locally, `make test` still runs the full suite sequentially and builds once.
+`make test-examples-smoke` runs the macOS-sized subset locally.
+
+`make test` includes examples, goldens, registry, apps, DWARF, unit tests, and selfhost smoke
(not the slow gen2↔gen3 fixed-point).
### Selfhost loop (optional CI)
diff --git a/docs/LanguageRef.md b/docs/LanguageRef.md
index ec46e8d..05fda37 100644
--- a/docs/LanguageRef.md
+++ b/docs/LanguageRef.md
@@ -16,11 +16,13 @@ This document describes the Bux programming language as implemented by the boots
8. [Pattern Matching](#pattern-matching)
9. [Methods and Interfaces](#methods-and-interfaces)
10. [Generics](#generics)
-11. [Error Handling](#error-handling)
-12. [Modules and Imports](#modules-and-imports)
-13. [Async/Await](#asyncawait)
-14. [Operator Overloading](#operator-overloading)
-15. [Operators](#operators)
+11. [Gradual Ownership](#gradual-ownership-phase-82--implemented) — Checked / Release / [Drop & RAII](#drop-and-raii)
+12. [Error Handling](#error-handling)
+13. [Modules and Imports](#modules-and-imports)
+14. [Async/Await](#asyncawait)
+15. [Operator Overloading](#operator-overloading)
+16. [Operators](#operators)
+17. [Macros](#macros)
---
@@ -522,32 +524,44 @@ func Main() -> int {
## Gradual Ownership (Phase 8.2) ✅ Implemented
-Bux introduces **gradual ownership** — opt-in borrow checking. By default, Bux is permissive like C. With `@[Checked]`, the borrow checker enforces memory safety rules.
+Bux has **gradual ownership** — opt-in borrow checking. Default is permissive
+(C-like). Turn safety on where it matters; turn it off on hot paths with zero cost.
-### Syntax
+### Three tiers
+
+| Mode | Attribute | Checks | Cost |
+|------|-----------|--------|------|
+| **Default** | (none) | None | Zero — raw `*T`, free aliasing |
+| **Checked** | `@[Checked]` | Moves, exclusive `&mut`, shared/`&mut` conflicts, dangling returns, elision | Compile-time only |
+| **Release** | `@[Release]` | **Forced off** (even if also `@[Checked]`) | Zero — same codegen as default |
+
+**Story:** write most code unchecked for speed of iteration; mark critical APIs
+`@[Checked]`; mark micro-hotspots `@[Release]` (or both) when you need C-level
+performance without false positives.
```bux
-// Default: permissive mode (like C/Nim) — raw pointers, no checks
+// Tier 1 — default: C-like, no borrow checker
func QuickSort(arr: *int, len: int) {
- for i in 0..len {
- arr[i] = arr[i] * 2;
- }
+ // free to alias, no move tracking
}
-// Opt-in: @[Checked] enables borrow checking
+// Tier 2 — opt-in safety
@[Checked]
func Scale(val: &mut int) {
- *val = *val * 2; // OK: &mut T allows mutation
+ *val = *val * 2;
}
-@[Checked]
-func Read(val: &int) -> int {
- return *val; // OK: &T allows reading
+// Tier 3 — zero-cost escape (e.g. hot loop helper)
+@[Release]
+func HotInc(p: *int) {
+ *p = *p + 1; // no checks; same as default, documents intent
}
+// Release wins over Checked when both are present
@[Checked]
-func BadWrite(val: &int) {
- *val = 42; // ERROR: cannot write through shared reference '&T'
+@[Release]
+func HotButDocumented(p: &mut int) {
+ *p = *p + 1; // no borrow checks
}
```
@@ -586,37 +600,57 @@ Moves happen in three contexts:
- **Assignment**: `b = a` moves `a` into `b`
- **Return**: `return x` moves `x` out of the function
-### Rules in @[Checked] functions
+### Rules in `@[Checked]` functions (not `@[Release]`)
- `&T` cannot be used to mutate data (compile-time error)
- `&mut T` allows mutation
- `*T` pointers are unrestricted (escape hatch)
- `&mut T` coerces to `&T` and `*T`
-- **Double mutable borrow**: passing `&mut x` twice to the same call is an error
+- **Double mutable borrow**: two live `&mut` of the same var (call args or let-bound)
```bux
- Swap(&mut x, &mut x); // ERROR: double mutable borrow of x
+ Swap(&mut x, &mut x); // ERROR
+ let a: &mut int = &mut x;
+ let b: &mut int = &mut x; // ERROR: exclusive mut already live
```
-- **Use after move**: using a moved `own T` value is an error until reassigned
- ```bux
- let msg: own String = "hello";
- Process(msg); // move
- PrintLine(msg); // ERROR: use of moved value
- msg = "reassigned"; // OK: reinitialization
- PrintLine(msg);
- ```
-- **No dangling returns**: cannot return a reference to a local (or by-value parameter)
+- **Use while mutably borrowed**: assign/use of `x` while a let-bound `&mut x` is live
+- **Shared while mut**: cannot form `&x` while `&mut x` is live
+- **Use after move**: using a moved `own T` until reassigned
+- **No dangling returns**: cannot return a reference to a local
```bux
@[Checked]
func Bad(p: &int) -> &int {
var x: int = 1;
- return &x; // ERROR: cannot return reference to local variable
+ return &x; // ERROR
}
```
+### `@[Release]` (C.4 zero-cost path)
+
+Use when a function must stay check-free:
+
+1. **Documented hot path** — same IR as unchecked, but the attribute states intent.
+2. **Override Checked** — `@[Checked] @[Release]` on a method that would otherwise inherit team-wide Checked defaults.
+
+There is **no runtime cost**: the attribute only disables the checker for that function body. Prefer `@[Release]` on the smallest possible surface; keep call boundaries `@[Checked]` when you still want API-level safety.
+
+```bux
+@[Checked]
+func SafeApi(buf: &mut int) {
+ // checked here
+ HotPath(buf);
+}
+
+@[Release]
+func HotPath(p: &mut int) {
+ // no move / borrow tracking — write like C
+ *p = *p + 1;
+}
+```
+
### Lifetime elision (C.1)
-In `@[Checked]` functions, most reference signatures need **no** lifetime annotations.
-Elision applies the usual single-input rules:
+In `@[Checked]` functions (and not `@[Release]`), most reference signatures need
+**no** lifetime annotations. Elision applies the usual single-input rules:
1. Each elided input `&T` / `&mut T` parameter gets a distinct lifetime.
2. If there is **exactly one** input lifetime, it is assigned to all elided outputs.
@@ -640,8 +674,180 @@ func Pick<'a>(a: &'a int, b: &'a int) -> &'a int {
// Type parameters: func F<'a, T>(...)
```
-Unchecked functions ignore lifetime rules (C-like). Explicit `'a` is optional
-documentation when a single input would already elide correctly.
+Default and `@[Release]` functions ignore lifetime rules (C-like). Explicit `'a`
+is optional documentation when a single input would already elide correctly.
+
+### Drop and RAII
+
+Bux uses **static destructors** (no GC): when a value goes out of scope, the
+compiler may emit `TypeName_Drop(&local)`. That is the RAII story — resources
+are released at every exit path without manual `defer` on every return.
+
+#### Declaring cleanup
+
+Two equivalent ways to opt a type into auto-drop:
+
+```bux
+// 1) Attribute — compiler looks up TypeName_Drop
+@[Drop]
+struct Token {
+ id: int,
+ counter: *int,
+}
+
+func Token_Drop(self: *Token) {
+ // free / close / decrement …
+}
+
+// 2) Interface (stdlib `lib/Drop.bux`) — same static call, no vtable
+import Drop;
+
+extend Buffer for Drop {
+ func Drop(self: *Buffer) {
+ Mem_Free(self.data);
+ }
+}
+```
+
+Stdlib collections implement Drop (`Array_Drop`, `Map_Drop`, …). Calling
+`Array_Drop` is the same cleanup as `Array_Free` for `Array`.
+
+#### When auto-drop runs
+
+Auto-drop is **not** gated on `@[Checked]`. Any function can receive injected
+`Type_Drop` at:
+
+| Exit | Behavior |
+|------|----------|
+| End of block / function | Drop locals still owned |
+| Early `return` | Drop all live locals **after** materializing the return value |
+| Branch scope end | Only locals from the taken branch |
+| Nested scopes | Drop in reverse order of declaration |
+
+```bux
+@[Drop]
+struct Token { id: int, counter: *int }
+func Token_Drop(self: *Token) { /* … */ }
+
+func Early(flag: int, counter: *int) -> int {
+ let t: Token = Token { id: 1, counter: counter };
+ if flag == 0 {
+ return 0; // still runs Token_Drop(&t)
+ }
+ return 1; // Token_Drop(&t) here too
+}
+```
+
+See `examples/drop_early_return.bux` for branch-local vs fallthrough counts.
+
+#### Field-move: skip Drop of the source (critical)
+
+**Problem:** a local is moved **by value** into a struct field (or another local).
+If the compiler still auto-dropped the source, you get a **double free** — the
+field and the original local would both run `Array_Drop` on the same buffer.
+
+**Rule:** after a **value move** out of a local, that local is **not** dropped.
+
+```bux
+struct Box {
+ items: Array;
+}
+
+func MakeBox() -> Box {
+ var items: Array = Array_New(4);
+ Array_Push(&items, 10);
+ Array_Push(&items, 20);
+ // Move `items` into the field — compiler skips Drop of `items`
+ let b: Box = Box { items: items };
+ return b; // also: return-by-value skips Drop of `b` (caller owns it)
+}
+```
+
+What the C backend does for `MakeBox` (simplified):
+
+```c
+Box MakeBox(void) {
+ Array_int items = Array_New_int(4);
+ Array_Push_int(&items, 10);
+ Array_Push_int(&items, 20);
+ Box b = (Box){ .items = items };
+ return b;
+ /* no Array_Drop_int(&items); — moved into b.items */
+ /* no Array_Drop on b; — moved to caller via return */
+}
+```
+
+Ownership after `MakeBox`:
+
+1. Heap buffer lives inside `b.items` (and later the caller's `Box`).
+2. `items` is **moved-out** → skip auto-Drop.
+3. `b` is **returned by value** → skip auto-Drop at the return site; the caller
+ (or the next owner) is responsible.
+
+The same skip applies to:
+
+- **Struct field init** — `S { field: local }` (field-move)
+- **Assignment** — `a = b` when `b` is moved (value types with Drop)
+- **Call argument** by value into a consuming parameter
+- **`return x`** — move-on-return
+
+Live, unmoved Drop locals still clean up on error paths (e.g. early `return`
+before the move). That is intentional: only the **successful transfer** path
+skips Drop.
+
+Runnable check: `examples/move_field.bux` (also covered by
+`make test-selfhost-smoke` on buxc2).
+
+#### Partial field moves
+
+Moving a **droppable field** out of a local (return or `let`) also skips Drop
+of the **parent** local:
+
+```bux
+@[Drop]
+struct Bag {
+ items: Array,
+ tag: int,
+}
+func Bag_Drop(self: *Bag) {
+ Array_Drop(&self.items);
+}
+
+func TakeItems() -> Array {
+ var items: Array = Array_New(4);
+ Array_Push(&items, 42);
+ let bag: Bag = Bag { items: items, tag: 7 };
+ return bag.items; // Bag_Drop skipped — items ownership transferred
+}
+```
+
+Rules:
+
+- Applies only when the **field type** is droppable (`Array_*`, `@[Drop]` types,
+ etc.). Reading `bag.tag` (`int`) does **not** mark `bag` moved.
+- After `let moved = bag.items`, `Bag_Drop(&bag)` is skipped; `moved` owns the
+ array and is auto-dropped at scope end.
+- Avoid using other droppable fields of the parent after a partial move (they
+ may be left in a moved-from state without per-field Drop).
+
+Golden smoke: `make test-drop-move` / `examples/move_field_partial.bux`.
+
+#### Manual Drop and non-Drop types
+
+- Types **without** `@[Drop]` / `Drop` impl are never auto-dropped (plain C layout).
+- You can still call `Type_Drop(&x)` or use `defer` for explicit cleanup.
+- `@[Release]` / default functions still get auto-drop for Drop types — Release
+ only turns off the **borrow checker**, not RAII.
+
+#### Limits (honest)
+
+- Partial field moves mark the **whole parent local** as moved for Drop purposes
+ (not per-field Drop of remaining fields).
+- Nested `a.b.c` path moves and moving through pointers are limited.
+- Interface Drop uses a static `TypeName_Drop` symbol (zero cost), not dynamic
+ dispatch through a vtable.
+- Double-free bugs in **unchecked** code that manually free *and* auto-drop are
+ still possible if you free without invalidating the value — prefer one owner.
---
@@ -893,3 +1099,198 @@ Overloadable operators use the naming convention `TypeName_operator_`:
- `..` — Range (exclusive): `0..10`
- `..=` — Range (inclusive): `0..=10`
- `sizeof` — Size of type: `sizeof(Type)`
+
+---
+
+## Macros
+
+Bux supports **declarative macros**. Expansion runs after parse and before
+type-checking. Expanded AST uses **call-site** source locations (quote hygiene).
+Both bootstrap and selfhost (`buxc2`) expand macros.
+
+### Definition
+
+```bux
+macro! twice {
+ ($x:expr) => {
+ ($x) + ($x)
+ }
+}
+
+// Trailing repetition
+macro! sum_n {
+ ( $($x:expr),* ) => {
+ var acc: int = 0;
+ $( acc = acc + $x; )*
+ acc
+ }
+}
+
+// Compound / zip: parallel lists from interleaved args
+macro! add_pairs {
+ ( $($a:expr, $b:expr),* ) => {
+ var acc: int = 0;
+ $( acc = acc + ($a + $b); )*
+ acc
+ }
+}
+
+// Multi-rep groups: `;` separates arg groups at the call site
+macro! sum_groups {
+ ( $($x:expr),* ; $($y:expr),* ) => {
+ var s: int = 0;
+ $( s = s + $x; )*
+ $( s = s + $y; )*
+ s
+ }
+}
+
+// Nested template repetition (outer list → inner expands once per item)
+macro! double_each_sum {
+ ( $($x:expr),* ) => {
+ var t: int = 0;
+ $(
+ $( t = t + $x; )*
+ $( t = t + $x; )*
+ )*
+ t
+ }
+}
+
+// ident fragment: bare identifier at the call site
+macro! call0 {
+ ( $f:ident ) => {
+ $f()
+ }
+}
+
+// literal (alias: lit) — only int/float/string/char/bool literals
+macro! only_lit {
+ ( $x:literal ) => { $x }
+}
+
+// block — only `{ … }` block expressions
+macro! wrap_block {
+ ( $b:block ) => { $b }
+}
+
+// gensym: template locals renamed per expansion
+macro! with_acc {
+ ( $start:literal ) => {
+ var n: int = $start;
+ n = n + 1;
+ n
+ }
+}
+```
+
+- Introduced with the `macro!` keyword.
+- Each **rule** is `( pattern ) => { template }`.
+- **Fragment kinds:**
+
+ | Kind | Matches |
+ |------|---------|
+ | `expr` | any expression |
+ | `ident` | bare identifier (`ekIdent`) |
+ | `tt` | token-tree (MVP: same as `expr`) |
+ | `literal` / `lit` | int/float/string/char/bool literal only |
+ | `block` | block expression `{ … }` |
+
+- Fragment names start with `$` (lexer `$ident`).
+- **Repetition:** `$( $x:expr ),*` / `$( $x:expr )*` — one or more rep fragments per pattern.
+- **Compound rep:** `$( $a:expr, $b:expr ),*` — interleaved args zip into parallel lists.
+- **Multi-rep:** two (or more) `$(…)*` in one pattern; call site uses `;` between groups:
+ `sum_groups!(1, 2; 10, 20, 30)`.
+- Template `$( stmt; … )*` expands once per list item (zip when multiple lists used).
+- Nested `$( $(…)* )*`: after outer binds list items as singles, inner expands once.
+
+### Invocation
+
+```bux
+let n = twice!(21);
+let s = sum_n!(1, 2, 3); // 6
+let z = sum_n!(); // 0
+let p = add_pairs!(1, 10, 2, 20); // (1+10)+(2+20) = 33
+let g = sum_groups!(1, 2; 10, 20, 30); // 63
+let d = double_each_sum!(3, 4); // 14
+call0!(SomeFunc);
+let a = with_acc!(10); // 11
+let b = with_acc!(20); // 21 — different gensym'd `n`
+let c = only_lit!(7);
+// only_lit!(1 + 2); // ERROR: no matching rule
+let w = wrap_block!({ 1 + 2 }); // 3
+```
+
+- Syntax: `name!( arg, … )` (not unwrap: unwrap is `expr!` without `(`).
+- Matching: fixed-arity by count; kind constraints; rep by groups / remaining args / chunk.
+
+### Built-in `quote!`
+
+```bux
+let x = quote!(1 + 2); // identity expand; locations grafted to call site
+```
+
+### Hygiene
+
+Two layers (both bootstrap + selfhost):
+
+1. **Call-site graft** — expanded AST uses the call site’s line/col/`sourceFile`
+ (so diagnostics and `#line` point at the user call, not the macro definition).
+2. **Gensym of template binders** — each expansion renames:
+ - `let` / `var` locals introduced by the template
+ - `for` loop binders in the template
+ - Nested scopes (if/while/for bodies, MacroRep bodies)
+
+ so two expansions of the same macro in one function do not collide under the
+ C backend’s **function-scoped** locals (e.g. `__m1_n` and `__m2_n`).
+
+Spliced `$frags` in expression positions are **not** gensym’d — they keep
+call-site names/values.
+
+#### Unhygienic binders (`var $name`)
+
+To **introduce a binder whose name comes from the call site**, use a `$frag`
+as the binder itself. That name is **not** gensym’d:
+
+```bux
+macro! let_mut {
+ ( $name:ident, $init:literal ) => {
+ var $name: int = $init; // unhygienic: becomes `counter`, not __m1_…
+ $name = $name + 1;
+ $name
+ }
+}
+
+// expands with local `counter` (and hygienic locals still unique)
+let a = let_mut!(counter, 10); // 11
+let b = let_mut!(other, 20); // 21
+
+macro! double_acc {
+ ( $start:literal ) => {
+ var acc: int = $start; // hygienic → __m1_acc / __m2_acc
+ acc = acc + acc;
+ acc
+ }
+}
+```
+
+| Binder form | After expand | Gensym? |
+|-------------|--------------|---------|
+| `var acc = …` (plain name in template) | `__mN_acc` | yes |
+| `var $name = …` with `$name:ident` | call-site ident | **no** |
+| `for $i in …` with `$i:ident` | call-site ident | **no** |
+
+The binder must be a **`:ident` fragment** bound to a bare identifier. A plain
+template name is always hygienic.
+
+Examples: `examples/macro_hygiene.bux`, `examples/macro_unhygienic.bux`.
+
+### Limits
+
+- Up to two named rep lists per rule on selfhost (enough for zip + multi-rep).
+- Compound chunk size currently 1 or 2.
+- Nested macro *calls* expanded recursively (depth limit 32).
+- Unhygienic binders only rename `let`/`var`/`for` binders — not full
+ Scheme/Rust colored identifiers or `stmt`/`pat` token trees.
+- Macro expansion still yields a **block expression**; unhygienic names are
+ scoped to that block (not automatically injected into the caller scope).
diff --git a/docs/QUALITY_PLAN.md b/docs/QUALITY_PLAN.md
index 3ff32b7..525fdeb 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 — quote/graft hygiene, LSP 0.15, CI, fixed-point
+> **Дата:** 2026-07-20
+> **Текущо:** v0.5.x — macros (unhygienic binders + multi-rep), partial field-move, lean CI
> **Цел:** Език, с който се пишат реални проекти комфортно, безопасно (по избор) и с надежден toolchain.
---
@@ -72,7 +72,7 @@
| C.1 | Lifetime elision за common cases | Без `'a` в 90% от API-тата | ✅ bootstrap + selfhost |
| C.2 | Exclusive `&mut` vs shared `&` data-flow | По-малко false negatives | ✅ let-bound + use-while + call conflict |
| C.3 | Auto-drop edge cases (early return, branches) | RAII да е надежден | ✅ bootstrap + selfhost |
-| C.4 | `@[Release]` zero-cost path документация + golden tests | Killer story: safe default, free hot path | ✅ partial (unchecked path + goldens) |
+| C.4 | `@[Release]` zero-cost path документация + golden tests | Killer story: safe default, free hot path | ✅ full (docs + Release wins + tests + example) |
### D — Tooling (P1)
@@ -864,9 +864,261 @@ A (stdlib ergonomics) → B (compiler holes) → C (ownership depth)
---
+## Сесия 56 (CBE binary parentheses — C precedence safety)
+
+1. **Bug (selfhost):** tree HIR→C emitted nested binaries **without** parens.
+ - `return (a + b) * c` → C `return a + b * c;` → **7** instead of **9**
+ - `return (a - b) / c` → `a - b / c` → **8** instead of **3**
+2. **Fix selfhost** (`src/c_backend.bux`):
+ - `hBinary` always emits `(left op right)` (same policy as bootstrap HIR CBE)
+ - Unary operand parens (session 52) kept: `!(a && b)`
+3. **Bootstrap LIR** (`bootstrap/lir_c_backend.nim`): defensive parens on
+ arith/bitwise and unary `!`/`-`/`~` (operands are temps today; future-proof)
+4. **Example:** `examples/c_precedence.bux` — MulSum/SubDiv/ShiftSum/Mix
+5. **Smoke:** `tools/smoke_selfhost.sh` checks run values **and** generated C
+ contains `(a + b) * c` / `(a - b) / c`
+6. Wired into `EXAMPLES` + `make test-selfhost-smoke`
+7. Verified: bootstrap + **buxc2** → `9/3/6/7` + `PASS c_precedence`;
+ smoke PASS
+
+---
+
+## Сесия 57 (CI split jobs + macOS smoke)
+
+1. **`.github/workflows/ci.yml`** — no longer one 90m monolithic `make test`:
+ - **`build`** (ubuntu): `make build` → artifact `buxc-linux`
+ - **Parallel** (reuse artifact, `BUX_SKIP_BUILD=1`):
+ - `unit` — `fmt-check` + `test-unit`
+ - `examples` — `test-examples`
+ - `goldens` — errors + stdlib + registry + dwarf
+ - `apps` — `test-apps`
+ - `selfhost` — `test-selfhost-smoke`
+ - **`macos`**: Homebrew OpenSSL + rebuild + fmt/unit/examples
+ - **`ci-gate`**: single required status (all of the above must succeed)
+2. **Makefile:**
+ - `test-unit` extracted from `test`
+ - `ensure-buxc` + `BUX_SKIP_BUILD=1` for CI artifact reuse
+ - `$(OUT)` rebuild only when `bootstrap/*.nim` changes
+ - portable examples runner (optional `timeout`; macOS without coreutils OK)
+3. **macOS / non-GNU ld:**
+ - bootstrap: `-Wl,--build-id=none` only `when defined(linux)`
+ - selfhost: `bux_cc_ld_stable()` in `rt/runtime.c` (Linux-only build-id)
+ - CI sets `BUX_CFLAGS=-I… -L…` for Homebrew `libcrypto`
+4. Docs: BuildAndTest + README CI table; local `make test` still full sequential
+5. Verified locally: `BUX_SKIP_BUILD=1 make fmt-check test-unit test-errors`
+
+---
+
+## Сесия 58 (LSP 0.16 workspace type hierarchy index)
+
+1. **Gap:** type hierarchy subtypes/supertypes only saw open-doc `impls` or
+ method-keyed `workspaceImpls`. Closed files with **empty**
+ `extend T for I {}` (no methods) returned **[]**.
+2. **`workspaceTypeRels`** (`tools/lsp_server.nim`):
+ - URI → `seq[(typeName, iface, line)]` from every `analyzeFile`
+ - `registerWorkspaceTypeRels` replaces per-URI (re-open / re-scan safe)
+ - filled on `scanWorkspace` + open/edit — **no open doc required**
+3. **Consumers:**
+ - `collectSubtypeItems` / `collectSupertypeItems` prefer type-rel index
+ - `collectTypeImplementorLocs` (textDocument/implementation) same
+4. **Smoke:** `tools/smoke_lsp_type_hierarchy_ws.sh`
+ - open only Main; Drawable.bux + Shapes.bux closed
+ - empty extends → Drawable subtypes Circle+Square; Circle supers Drawable+Named
+5. Version **bux-lsp 0.16.0**; wired into `make test-lsp`
+6. Verified: single-file hierarchy + workspace smoke PASS
+
+---
+
+## Сесия 59 (user-facing `macro!` / `quote!` — declarative MVP)
+
+1. **Syntax (bootstrap):**
+ - `macro! name { ($x:expr, …) => { template } }`
+ - Invoke: `name!(args…)` — distinct from unwrap `expr!` via following `(`
+ - Built-in **`quote!(e)`** — identity expand + call-site graft
+2. **Lexer:** keyword `macro`; `$ident` fragment tokens (`$x`)
+3. **AST:** `dkMacro` + `MacroRule`/`MacroFragment`; `ekMacroCall`
+4. **Expansion** (`bootstrap/macroexpand.nim`) before sema:
+ - Collect macro decls; match rule by arity
+ - Deep clone + substitute `$frags` + graft call-site `SourceLocation`
+ - Nested expand (depth ≤ 32)
+5. **CLI:** `build` / `check` / `run` call `expandMacros` after merge
+6. **Example:** `examples/macro_twice.bux` → 42 / 42 / 43 + PASS
+7. **LanguageRef:** Macros section (limits documented)
+8. Verified: `./buxc run macro_twice`; hello + lexer/parser tests green
+9. **Not yet:** selfhost expand parity; `$(…)*` repetition; more frag kinds
+
+---
+
+## Сесия 60 (selfhost `macro!` / `quote!` expand parity)
+
+1. **Lexer/token:** `tkMacro`, keyword `macro`, `$ident` fragments
+2. **AST:** `dkMacro` (rules in `childDecl1` chain), `ekMacroCall`
+3. **Parser:** `macro! name { ($x:expr) => {…} }`, invoke `name!(…)`
+4. **`src/macroexpand.bux`:**
+ - collect macros → match rule by arity → clone+subst `$frags`
+ - call-site graft (line/col/`sourceFile`)
+ - built-in `quote!(e)`
+5. **CLI:** expand before sema (project / check / compile paths)
+6. **Sema fixes** (needed for block templates):
+ - `ekBlock` value = last `skExpr` type (was always `tyVoid`)
+ - `let x: T = …` sets `sym.typeKind` from annotation (not only init)
+7. **Smoke:** `tools/smoke_selfhost.sh` runs `examples/macro_twice.bux` via buxc2
+8. Verified: **buxc2** + bootstrap → `42/42/43` + `PASS macro_twice`
+
+---
+
+## Сесия 61 (macro `$(…)*` + `ident`/`tt` fragments)
+
+1. **Fragment kinds:** `expr` | `ident` | `tt` (tt ≡ expr for now)
+2. **Pattern rep (trailing):** `$( $x:expr ),*` / `$( $x:expr )*`
+3. **Template rep:** `$( stmts… )*` → `skMacroRep`, expanded per list item
+4. **Lexer:** bare `tkDollar` for `$(…)` (vs `$ident`)
+5. **Bootstrap** `macroexpand.nim`: list bindings, match rules, gensym locals
+6. **Selfhost** parity: `useNames` encodes kinds/`rep:`, `Subst_Block_Flat`, gensym
+7. **Sema:** block-as-expr checks last value **inside** child scope (no UAF of locals)
+8. **Example:** `examples/macro_repeat.bux` — sum_n / empty / call0 / id_tt
+9. Verified: bootstrap + **buxc2** → `6/0/42/7` + `PASS macro_repeat`
+
+---
+
+## Сесия 62 (C.4 `@[Release]` polish + Checked docs)
+
+1. **Three-tier model** documented in LanguageRef:
+ - default (no checks) → `@[Checked]` → `@[Release]` (force off)
+2. **Bootstrap:** `releaseFunc`; `checkedFunc = Checked ∧ ¬Release`
+3. **Selfhost:** same rule; **stacked attrs** loop (`@[Checked]` + `@[Release]`)
+4. **Parser:** multi-line stacked `@[…]` (skip newlines between attrs)
+5. **Tests** (`borrow_test`): Release alone; Checked+Release wins; Checked still errors
+6. **Example:** `examples/ownership_release.bux` — Unchecked / Safe / Hot / HotDangle
+7. Verified: 27/27 borrow tests; example PASS
+
+---
+
+## Сесия 63 (nested `$(…)*` / multi-rep / compound zip)
+
+1. **Bootstrap** (`macroexpand.nim` + parser):
+ - Compound rep: `$( $a:expr, $b:expr ),*` → parallel lists, zip in template
+ - Multi-rep: `$(…)* ; $(…)*` with call-site `;` groups (`exprMacroGroupLens`)
+ - Nested template: outer binds list → inner `$(…)*` expands once (no list names left)
+ - `MacroFragment.names` / `.kinds` for multi-name frags
+2. **Selfhost** parity (`src/macroexpand.bux`, `parser.bux`):
+ - Two named rep lists + zip in `Subst_Block_Flat`
+ - Kinds encoding `rep:expr+expr,@2` / multi-seg `rep:…;rep:…`
+ - Macro call `;` groups → `genericCallee` group-length string
+3. **Example:** `examples/macro_nested.bux`
+ - `add_pairs` → 33, `sum_groups` → 63, `double_each_sum` → 14, `named_sum` → 18
+4. **LanguageRef:** multi-rep / compound / nested docs; limits updated
+5. Verified: bootstrap + **buxc2** → PASS macro_nested / macro_repeat / macro_twice
+
+---
+
+## Сесия 64 (CI Nim cache + faster macOS)
+
+1. **Nim pin + toolchain cache:**
+ - `NIM_VERSION: 2.0.8` (stable cache keys; was `2.0.x`)
+ - Cache `.nim_runtime` on `build` / `unit` / `macos` / `selfhost-loop`
+ - Skip `setup-nim-action` on cache hit; restore `PATH` only
+2. **`nimcache` project-local:**
+ - `Makefile` `NIMFLAGS ?= --nimcache:nimcache` for bootstrap + unit tests
+ - `actions/cache` keyed on `bootstrap/**/*.nim` (+ tests for unit)
+3. **Leaner macOS job:**
+ - Runner `macos-14`; timeout 35m
+ - `make test-unit` + `make test-examples-smoke` (not full EXAMPLES / not fmt)
+ - `EXAMPLES_SMOKE`: hello, ownership*, strings, map, c_precedence, macro_*
+ - OpenSSL: install only if missing (`brew list`)
+4. **Docs:** BuildAndTest CI table; `.gitignore` `.nim_runtime/`
+5. Verified locally: `make build` uses `nimcache/`; `test-examples-smoke` PASS
+
+---
+
+## Сесия 65 (Drop / RAII docs — field-move story)
+
+1. **LanguageRef — Drop and RAII** (under Gradual Ownership):
+ - `@[Drop]` vs `extend T for Drop` (static `Type_Drop`, no vtable)
+ - When auto-drop runs (block end, early return, branches) — not gated on Checked
+ - **Field-move skip Drop** with `MakeBox` + simplified C (no `Array_Drop(&items)`)
+ - Move-on-return, assignment, call-arg transfers; error-path still Drops
+ - Limits: whole-local moves, static dispatch, manual free pitfalls
+2. **TOC** links Ownership + Drop; **Stdlib** `Array_Drop` + `Std::Drop` section
+3. **README** Drop line mentions field-move
+4. Cross-refs: `examples/move_field.bux`, `examples/drop_early_return.bux`,
+ selfhost smoke
+5. Verified: `move_field` C has no `Array_Drop` on moved `items`; example PASS
+
+---
+
+## Сесия 66 (macro hygiene + frag kinds `literal` / `block`)
+
+1. **Fragment kinds** (bootstrap + selfhost):
+ - `literal` / `lit` — only `ekLiteral` (rejects `1 + 2`)
+ - `block` — only `ekBlock` `{ … }`
+ - Shared `fragMatches` / `Macro_FragMatches` at match time
+2. **Hygiene gensym:**
+ - Bootstrap: also rename **`for` binders**; walk for bodies in collect
+ - Selfhost: gensym `skFor` + recurse if/while/for/MacroRep bodies
+ - CBE: two `with_acc!` → `__m1_n` / `__m2_n` (no collision)
+3. **Example:** `examples/macro_hygiene.bux` → 11/21/7/3/42 + PASS
+4. **LanguageRef:** kind table + hygiene layers (graft + gensym)
+5. **Makefile:** `macro_hygiene` in EXAMPLES + EXAMPLES_SMOKE
+6. Verified: bootstrap + **buxc2**; negative `only_lit!(1+2)` → no matching rule
+
+---
+
+## Сесия 67 (CI Windows smoke)
+
+1. **`.github/workflows/ci.yml` — `windows` job** (`windows-latest`, bash shell):
+ - Cache Nim **2.0.8** (prebuilt zip — fast) + `nimcache`
+ - `nim c -o:buxc.exe` bootstrap
+ - Pure Nim unit tests: lexer / parser / sema / hir / borrow
+ - CLI smoke: `buxc.exe new` + `--version`
+2. **Scope (honest):** no `bux run` examples on Windows yet —
+ `rt/runtime.c` is POSIX (`ucontext`, `pthread`, sockets, OpenSSL link).
+ Job still gates bootstrap regressions on Win.
+3. **`ci-gate`:** `windows` is a required job
+4. **Docs:** BuildAndTest CI table + Windows note
+5. Locally: YAML validated; full Win run is on GHA only
+
+---
+
+## Сесия 68 (partial field moves + Drop goldens)
+
+1. **Bug:** `return bag.items` still ran `Bag_Drop(&bag)` → double-free /
+ corrupt Array (ASSERT fail). Also dead double-Drop after terminal `return`.
+2. **Bootstrap** (`hir_lower.nim`):
+ - `markMovedOutFromAst` handles `ekField` when **field type is droppable**
+ (`autoDropFuncName`) — not for `return a.id` (int)
+ - Scope exit: skip re-emitting drops when last stmt always-returns; pop defers
+3. **Selfhost** (`c_backend.bux`):
+ - `CBE_MarkMovedFromNodeHint` + droppable type check; return uses `currentRetType`
+ - Store/let rhs walks field access for partial moves
+4. **Example + golden smoke:**
+ - `examples/move_field_partial.bux`
+ - `tools/smoke_drop_move.sh` + `make test-drop-move` (CI goldens job)
+5. Verified: partial PASS; `TakeItems` has **no** `Bag_Drop`; drop_early_return 5;
+ move_field PASS
+
+---
+
+## Сесия 69 (macro unhygienic binders)
+
+1. **Problem:** gensym renamed *all* template `let`/`var` binders, so
+ `var $name: int = …` with `$name:ident` could not introduce a call-site name.
+2. **Bootstrap** (`macroexpand.nim`):
+ - `binderIdentFromFrag` + `expandUnhygienic` set
+ - `substStmt`: rewrite `skLet`/`skFor` binder when name is `$frag` → ekIdent
+ - `collectLetNames` skips unhygienic names
+3. **Selfhost** (`macroexpand.bux`):
+ - `Env_AddUnhy` / `Env_IsUnhy` / `Env_BinderFromFrag`
+ - `Subst_Stmt` rewrites binders; `Macro_GensymBlock(ex, body, env)` skips them
+4. **Example:** `examples/macro_unhygienic.bux` → 11/21/6/10/1 + PASS
+ - C: `counter` / `other` / `n` kept; `acc` / `scratch` → `__mN_*`
+5. **LanguageRef:** unhygienic binder table; EXAMPLES + EXAMPLES_SMOKE
+6. Verified: bootstrap + **buxc2**; macro_hygiene still PASS
+
+---
+
## Следващи стъпки
-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
+1. Windows: MinGW + runtime stubs for `hello` smoke (stretch)
+2. Per-field Drop after partial move (stretch)
+3. Macro: true `stmt`/`pat` token-tree frags (stretch)
diff --git a/docs/Stdlib.md b/docs/Stdlib.md
index c17c071..3766bdf 100644
--- a/docs/Stdlib.md
+++ b/docs/Stdlib.md
@@ -95,6 +95,12 @@ struct Array {
| `Array_Clear` | `func Array_Clear(arr: *Array)` | Set length to 0 (keeps capacity) |
| `Array_Reserve` | `func Array_Reserve(arr: *Array, minCap: uint)` | Grow capacity if needed |
| `Array_Free` | `func Array_Free(arr: *Array)` | Free memory |
+| `Array_Drop` | `func Array_Drop(self: *Array)` | Drop trait entry (same as `Array_Free`) |
+
+**RAII:** `Array` is auto-dropped at scope exit. Prefer letting the compiler call
+`Array_Drop` over manual `Array_Free` when ownership is clear. If you move an
+array into a struct field, the **source local is not dropped** (see LanguageRef
+[Drop and RAII](LanguageRef.md#drop-and-raii) / `examples/move_field.bux`).
### Example
```bux
@@ -105,13 +111,31 @@ func Main() -> int {
Array_Push(&arr, 10);
Array_Push(&arr, 20);
PrintInt(Array_Get(&arr, 0)); // 10
- Array_Free(&arr);
+ // Array_Drop runs at end of Main (or call Array_Free manually)
return 0;
}
```
---
+## Std::Drop
+
+Trait for automatic cleanup (RAII). Defined in `lib/Drop.bux`:
+
+```bux
+interface Drop {
+ func Drop(self: *Self);
+}
+```
+
+Implement with `extend Type for Drop { func Drop(self: *Type) { … } }` or mark
+the type `@[Drop]` and provide `Type_Drop`. Full rules (early return, field-move
+skip Drop, move-on-return): **LanguageRef → Drop and RAII**.
+
+Examples: `examples/drop_early_return.bux`, `examples/move_field.bux`.
+
+---
+
## Std::Iter
Lightweight iterator over `Array` (index-based, no allocation).
diff --git a/examples/c_precedence.bux b/examples/c_precedence.bux
new file mode 100644
index 0000000..3e95a84
--- /dev/null
+++ b/examples/c_precedence.bux
@@ -0,0 +1,53 @@
+// C precedence safety: Bux AST must survive C codegen without rewrite.
+// Mul(Add(a,b), c) must stay (a+b)*c, not a+b*c.
+import Std::Io::{PrintLine};
+import Std::String::{String_FromInt};
+
+func MulSum(a: int, b: int, c: int) -> int {
+ // AST: (a+b)*c → 9 for (1,2,3); wrong C emit gives 1+2*3=7
+ return (a + b) * c;
+}
+
+func SubDiv(a: int, b: int, c: int) -> int {
+ // AST: (a-b)/c → 3 for (10,4,2); wrong C emit gives 10-4/2=8
+ return (a - b) / c;
+}
+
+func ShiftSum(a: int, b: int) -> int {
+ // (a+b)<<1 → 6 for (1,2)
+ return (a + b) << 1;
+}
+
+func Mix(a: int, b: int, c: int, d: int) -> int {
+ // ((a+b)*c)-d → 7 for (1,2,3,2)
+ return (a + b) * c - d;
+}
+
+func Main() -> int {
+ let m: int = MulSum(1, 2, 3);
+ let s: int = SubDiv(10, 4, 2);
+ let sh: int = ShiftSum(1, 2);
+ let x: int = Mix(1, 2, 3, 2);
+ PrintLine(String_FromInt(m));
+ PrintLine(String_FromInt(s));
+ PrintLine(String_FromInt(sh));
+ PrintLine(String_FromInt(x));
+ if m != 9 {
+ PrintLine("FAIL MulSum");
+ return 1;
+ }
+ if s != 3 {
+ PrintLine("FAIL SubDiv");
+ return 1;
+ }
+ if sh != 6 {
+ PrintLine("FAIL ShiftSum");
+ return 1;
+ }
+ if x != 7 {
+ PrintLine("FAIL Mix");
+ return 1;
+ }
+ PrintLine("PASS c_precedence");
+ return 0;
+}
diff --git a/examples/macro_hygiene.bux b/examples/macro_hygiene.bux
new file mode 100644
index 0000000..86e4cdb
--- /dev/null
+++ b/examples/macro_hygiene.bux
@@ -0,0 +1,73 @@
+// Session 66 — gensym hygiene + fragment kinds literal / block
+import Std::Io::{PrintLine};
+import Std::String::{String_FromInt};
+
+// Template locals are gensym'd per expansion — two uses of `n` in one Main
+// must not collide under the C backend's function-scoped locals.
+macro! with_acc {
+ ( $start:literal ) => {
+ var n: int = $start;
+ n = n + 1;
+ n
+ }
+}
+
+// literal: only int/float/string/char/bool literals (not 1+2)
+macro! only_lit {
+ ( $x:literal ) => {
+ {
+ $x
+ }
+ }
+}
+
+// block: only `{ … }` block expressions
+macro! wrap_block {
+ ( $b:block ) => {
+ $b
+ }
+}
+
+// lit alias for literal
+macro! double_lit {
+ ( $n:lit ) => {
+ ($n) + ($n)
+ }
+}
+
+func Main() -> int {
+ // gensym: two expansions both introduce `n`
+ let a: int = with_acc!(10);
+ let b: int = with_acc!(20);
+ // 11, 21
+ let c: int = only_lit!(7);
+ let d: int = wrap_block!({ 1 + 2 });
+ let e: int = double_lit!(21);
+ PrintLine(String_FromInt(a));
+ PrintLine(String_FromInt(b));
+ PrintLine(String_FromInt(c));
+ PrintLine(String_FromInt(d));
+ PrintLine(String_FromInt(e));
+ if a != 11 {
+ PrintLine("FAIL with_acc gensym a");
+ return 1;
+ }
+ if b != 21 {
+ PrintLine("FAIL with_acc gensym b");
+ return 1;
+ }
+ if c != 7 {
+ PrintLine("FAIL only_lit");
+ return 1;
+ }
+ if d != 3 {
+ PrintLine("FAIL wrap_block");
+ return 1;
+ }
+ if e != 42 {
+ PrintLine("FAIL double_lit");
+ return 1;
+ }
+ PrintLine("PASS macro_hygiene");
+ return 0;
+}
diff --git a/examples/macro_nested.bux b/examples/macro_nested.bux
new file mode 100644
index 0000000..2e8b2ad
--- /dev/null
+++ b/examples/macro_nested.bux
@@ -0,0 +1,78 @@
+// Session 63 — multi-rep patterns, compound/zipped rep, nested template $(…)*
+import Std::Io::{PrintLine};
+import Std::String::{String_FromInt};
+
+// Compound rep: $( $a:expr , $b:expr ),* → parallel lists, zip in template
+macro! add_pairs {
+ ( $($a:expr, $b:expr),* ) => {
+ var __acc: int = 0;
+ $( __acc = __acc + ($a + $b); )*
+ __acc
+ }
+}
+
+// Multi-rep groups separated by `;` in the call
+// sum_n!(1,2; 10,20,30) → sum first group + sum second group
+macro! sum_groups {
+ ( $($x:expr),* ; $($y:expr),* ) => {
+ var __s: int = 0;
+ $( __s = __s + $x; )*
+ $( __s = __s + $y; )*
+ __s
+ }
+}
+
+// Nested template: outer over $x, inner body uses current $x once
+// (same-list nested MacroRep expands once when bound as single)
+macro! double_each_sum {
+ ( $($x:expr),* ) => {
+ var __t: int = 0;
+ $(
+ $( __t = __t + $x; )*
+ $( __t = __t + $x; )*
+ )*
+ __t
+ }
+}
+
+// Prefix fixed + trailing rep still works
+macro! named_sum {
+ ( $label:ident, $($n:expr),* ) => {
+ var __u: int = 0;
+ $( __u = __u + $n; )*
+ __u
+ }
+}
+
+func Main() -> int {
+ let p: int = add_pairs!(1, 10, 2, 20);
+ // (1+10)+(2+20) = 33
+ let g: int = sum_groups!(1, 2; 10, 20, 30);
+ // 1+2+10+20+30 = 63
+ let d: int = double_each_sum!(3, 4);
+ // (3+3)+(4+4) = 14
+ let n: int = named_sum!(ignored, 5, 6, 7);
+ // 18
+ PrintLine(String_FromInt(p));
+ PrintLine(String_FromInt(g));
+ PrintLine(String_FromInt(d));
+ PrintLine(String_FromInt(n));
+ if p != 33 {
+ PrintLine("FAIL add_pairs");
+ return 1;
+ }
+ if g != 63 {
+ PrintLine("FAIL sum_groups");
+ return 1;
+ }
+ if d != 14 {
+ PrintLine("FAIL double_each_sum");
+ return 1;
+ }
+ if n != 18 {
+ PrintLine("FAIL named_sum");
+ return 1;
+ }
+ PrintLine("PASS macro_nested");
+ return 0;
+}
diff --git a/examples/macro_repeat.bux b/examples/macro_repeat.bux
new file mode 100644
index 0000000..a38bb9e
--- /dev/null
+++ b/examples/macro_repeat.bux
@@ -0,0 +1,65 @@
+// Macro repetition $(…)* + fragment kinds expr/ident/tt (session 61)
+import Std::Io::{PrintLine};
+import Std::String::{String_FromInt};
+
+// Sum any number of int exprs
+// Template body is a single block (lets + $(…)* + result expr).
+macro! sum_n {
+ ( $($x:expr),* ) => {
+ var __sum_acc: int = 0;
+ $( __sum_acc = __sum_acc + $x; )*
+ __sum_acc
+ }
+}
+
+// Bind an identifier name and call it as a zero-arg func via expr wrap
+// (ident fragment must be a bare identifier at the call site)
+macro! call0 {
+ ( $f:ident ) => {
+ {
+ $f()
+ }
+ }
+}
+
+// tt is accepted like expr (token-tree MVP)
+macro! id_tt {
+ ( $t:tt ) => {
+ {
+ $t
+ }
+ }
+}
+
+func FortyTwo() -> int {
+ return 42;
+}
+
+func Main() -> int {
+ let a: int = sum_n!(1, 2, 3);
+ let b: int = sum_n!();
+ let c: int = call0!(FortyTwo);
+ let d: int = id_tt!(7);
+ PrintLine(String_FromInt(a));
+ PrintLine(String_FromInt(b));
+ PrintLine(String_FromInt(c));
+ PrintLine(String_FromInt(d));
+ if a != 6 {
+ PrintLine("FAIL sum_n 1+2+3");
+ return 1;
+ }
+ if b != 0 {
+ PrintLine("FAIL sum_n empty");
+ return 1;
+ }
+ if c != 42 {
+ PrintLine("FAIL call0 ident");
+ return 1;
+ }
+ if d != 7 {
+ PrintLine("FAIL id_tt");
+ return 1;
+ }
+ PrintLine("PASS macro_repeat");
+ return 0;
+}
diff --git a/examples/macro_twice.bux b/examples/macro_twice.bux
new file mode 100644
index 0000000..de280fa
--- /dev/null
+++ b/examples/macro_twice.bux
@@ -0,0 +1,40 @@
+// Declarative macro! + built-in quote! (session 59)
+// Expands before type-check; $frags splice with call-site hygiene.
+import Std::Io::{PrintLine};
+import Std::String::{String_FromInt};
+
+macro! twice {
+ ($x:expr) => {
+ ($x) + ($x)
+ }
+}
+
+macro! add2 {
+ ($a:expr, $b:expr) => {
+ ($a) + ($b)
+ }
+}
+
+func Main() -> int {
+ let a: int = twice!(21);
+ let b: int = add2!(10, 32);
+ // quote! is a built-in identity expand (call-site graft)
+ let c: int = quote!(a + 1);
+ PrintLine(String_FromInt(a));
+ PrintLine(String_FromInt(b));
+ PrintLine(String_FromInt(c));
+ if a != 42 {
+ PrintLine("FAIL twice");
+ return 1;
+ }
+ if b != 42 {
+ PrintLine("FAIL add2");
+ return 1;
+ }
+ if c != 43 {
+ PrintLine("FAIL quote");
+ return 1;
+ }
+ PrintLine("PASS macro_twice");
+ return 0;
+}
diff --git a/examples/macro_unhygienic.bux b/examples/macro_unhygienic.bux
new file mode 100644
index 0000000..4cae1fc
--- /dev/null
+++ b/examples/macro_unhygienic.bux
@@ -0,0 +1,73 @@
+// Session 69 — unhygienic binders: `var $name` keeps the call-site identifier
+// (not gensym'd). Hygienic template locals (`acc`) still get unique names.
+import Std::Io::{PrintLine};
+import Std::String::{String_FromInt};
+
+// Introduce a binder named by the call-site ident; keep that name (unhygienic)
+macro! let_mut {
+ ( $name:ident, $init:literal ) => {
+ var $name: int = $init;
+ $name = $name + 1;
+ $name
+ }
+}
+
+// Hygienic local `acc` must not collide across two expansions
+macro! double_acc {
+ ( $start:literal ) => {
+ var acc: int = $start;
+ acc = acc + acc;
+ acc
+ }
+}
+
+// Mixed: unhygienic $name + hygienic scratch
+macro! bump_named {
+ ( $name:ident ) => {
+ var $name: int = 0;
+ var scratch: int = 1;
+ $name = $name + scratch;
+ $name
+ }
+}
+
+func Main() -> int {
+ // Unhygienic: binder becomes `counter` inside the expansion block
+ let a: int = let_mut!(counter, 10);
+ // 11
+ let b: int = let_mut!(other, 20);
+ // 21
+ // Hygienic: two expansions with local `acc`
+ let c: int = double_acc!(3);
+ let d: int = double_acc!(5);
+ // 6, 10
+ let e: int = bump_named!(n);
+ // 1
+ PrintLine(String_FromInt(a));
+ PrintLine(String_FromInt(b));
+ PrintLine(String_FromInt(c));
+ PrintLine(String_FromInt(d));
+ PrintLine(String_FromInt(e));
+ if a != 11 {
+ PrintLine("FAIL let_mut counter");
+ return 1;
+ }
+ if b != 21 {
+ PrintLine("FAIL let_mut other");
+ return 1;
+ }
+ if c != 6 {
+ PrintLine("FAIL double_acc 3");
+ return 1;
+ }
+ if d != 10 {
+ PrintLine("FAIL double_acc 5");
+ return 1;
+ }
+ if e != 1 {
+ PrintLine("FAIL bump_named");
+ return 1;
+ }
+ PrintLine("PASS macro_unhygienic");
+ return 0;
+}
diff --git a/examples/move_field_partial.bux b/examples/move_field_partial.bux
new file mode 100644
index 0000000..7d893c1
--- /dev/null
+++ b/examples/move_field_partial.bux
@@ -0,0 +1,63 @@
+// Session 68 — partial field moves out of @[Drop] parents
+// `return bag.items` / `let x = bag.items` must skip Bag_Drop (no double-free).
+// Non-droppable fields (`bag.tag`) do not mark the parent moved.
+import Std::Io::{PrintLine};
+import Std::Array::{Array, Array_New, Array_Push, Array_Len, Array_Get};
+import Std::String::{String_FromInt, String_Concat};
+import Std::Test::{Test_AssertTrue, Test_Pass};
+
+@[Drop]
+struct Bag {
+ items: Array,
+ tag: int
+}
+
+func Bag_Drop(self: *Bag) {
+ Array_Drop(&self.items);
+}
+
+// Move droppable field out via return
+func TakeItems() -> Array {
+ var items: Array = Array_New(4);
+ Array_Push(&items, 42);
+ let bag: Bag = Bag { items: items, tag: 7 };
+ return bag.items;
+}
+
+// Move droppable field via let; read non-droppable tag after
+func PeekTagAndTake() -> int {
+ var items: Array = Array_New(2);
+ Array_Push(&items, 1);
+ let bag: Bag = Bag { items: items, tag: 99 };
+ let moved: Array = bag.items;
+ let t: int = bag.tag;
+ discard Array_Len(&moved);
+ return t;
+}
+
+// Whole-struct return still moves bag (existing path)
+func MakeBag() -> Bag {
+ var items: Array = Array_New(2);
+ Array_Push(&items, 10);
+ Array_Push(&items, 20);
+ let bag: Bag = Bag { items: items, tag: 3 };
+ return bag;
+}
+
+func Main() -> int {
+ let taken: Array = TakeItems();
+ Test_AssertTrue(Array_Len(&taken) == 1);
+ Test_AssertTrue(Array_Get(&taken, 0) == 42);
+
+ let tag: int = PeekTagAndTake();
+ Test_AssertTrue(tag == 99);
+
+ let b: Bag = MakeBag();
+ Test_AssertTrue(Array_Len(&b.items) == 2);
+ Test_AssertTrue(Array_Get(&b.items, 0) == 10);
+ Test_AssertTrue(b.tag == 3);
+
+ PrintLine(String_Concat("ok=", String_FromInt(tag as int64)));
+ Test_Pass("move_field_partial");
+ return 0;
+}
diff --git a/examples/ownership_release.bux b/examples/ownership_release.bux
new file mode 100644
index 0000000..29fd480
--- /dev/null
+++ b/examples/ownership_release.bux
@@ -0,0 +1,58 @@
+// C.4 — @[Release] zero-cost path vs @[Checked]
+// Default / Release: free; Checked: catches double-mut and dangling returns.
+import Std::Io::{PrintLine, PrintInt};
+import Std::Test::{Test_AssertEqInt, Test_Pass};
+
+// Tier 1 — unchecked (default)
+func UncheckedInc(p: *int) {
+ *p = *p + 1;
+}
+
+// Tier 2 — borrow checked API surface
+@[Checked]
+func SafeInc(p: &mut int) {
+ *p = *p + 1;
+}
+
+@[Checked]
+func SafeGet(p: &int) -> int {
+ return *p;
+}
+
+// Tier 3 — explicit zero-cost hot path (Release wins over Checked)
+@[Checked]
+@[Release]
+func HotInc(p: &mut int) {
+ // Would be fine either way; attribute documents "no checker cost here"
+ *p = *p + 1;
+}
+
+@[Release]
+func HotDangle() -> &int {
+ // Allowed: Release disables dangling-return checks
+ var x: int = 99;
+ return &x;
+}
+
+func Main() -> int {
+ var n: int = 10;
+ UncheckedInc(&n);
+ Test_AssertEqInt(n, 11);
+
+ SafeInc(&n);
+ Test_AssertEqInt(n, 12);
+
+ HotInc(&n);
+ Test_AssertEqInt(n, 13);
+
+ let v: int = SafeGet(&n);
+ Test_AssertEqInt(v, 13);
+
+ // HotDangle is only used to prove Release compiles; do not dereference
+ discard HotDangle();
+
+ PrintInt(n);
+ PrintLine("");
+ Test_Pass("ownership_release");
+ return 0;
+}
diff --git a/rt/runtime.c b/rt/runtime.c
index f142c5c..18e0482 100644
--- a/rt/runtime.c
+++ b/rt/runtime.c
@@ -1478,6 +1478,15 @@ const char* bux_getenv(const char* name) {
return val ? val : "";
}
+/* Extra cc/ld flags for host. --build-id is GNU ld only (Apple ld rejects it). */
+const char* bux_cc_ld_stable(void) {
+#if defined(__linux__)
+ return " -Wl,--build-id=none";
+#else
+ return "";
+#endif
+}
+
int bux_setenv(const char* name, const char* value) {
if (!name || !value) return -1;
return setenv(name, value, 1);
diff --git a/src/ast.bux b/src/ast.bux
index f8439be..5ccd19e 100644
--- a/src/ast.bux
+++ b/src/ast.bux
@@ -127,6 +127,7 @@ module Ast {
const ekAwait: int = 25;
const ekStringInterp: int = 26;
const ekClosure: int = 27;
+ const ekMacroCall: int = 28; // name!(args) — expanded before sema
struct ExprList {
expr: *Expr,
@@ -218,6 +219,7 @@ module Ast {
const skDecl: int = 11;
const skDefer: int = 12;
const skSwitch: int = 13;
+ const skMacroRep: int = 14; // $( stmts… )* in macro templates
struct ElseIf {
line: uint32;
@@ -265,6 +267,7 @@ module Ast {
const dkTypeAlias: int = 9;
const dkExternFunc: int = 10;
const dkExternVar: int = 11;
+ const dkMacro: int = 12; // macro! name { rules }; rules in childDecl1
struct Param {
line: uint32;
diff --git a/src/c_backend.bux b/src/c_backend.bux
index a281fae..666fdaa 100644
--- a/src/c_backend.bux
+++ b/src/c_backend.bux
@@ -113,25 +113,64 @@ module CBackend {
return name;
}
- /// Mark droppable locals moved by-value (struct fields / nested).
+ /// True when a C type name owns heap / has auto-Drop (not primitives).
+ func CBE_IsDroppableTypeName(tn: String) -> bool {
+ if tn == null as String || String_Eq(tn, "") { return false; }
+ if String_Eq(tn, "void") || String_Eq(tn, "int") || String_Eq(tn, "bool") {
+ return false;
+ }
+ if String_Eq(tn, "int64") || String_Eq(tn, "uint") || String_Eq(tn, "uint64") {
+ return false;
+ }
+ if String_Eq(tn, "float") || String_Eq(tn, "float64") || String_Eq(tn, "char8") {
+ return false;
+ }
+ if String_Eq(tn, "String") || String_Eq(tn, "cstr") { return false; }
+ // Array_int, Map_*, user @[Drop] structs (Bag, Token, …)
+ return true;
+ }
+
+ /// Mark droppable locals moved by-value (struct fields / nested / partial field).
+ /// `valueTypeHint`: when non-empty (e.g. function return type), used to decide
+ /// whether a field access is an ownership move (`return bag.items` vs `return bag.tag`).
func CBE_MarkMovedFromNode(cbe: *CEmitter, node: *HirNode) {
+ CBE_MarkMovedFromNodeHint(cbe, node, "");
+ }
+
+ func CBE_MarkMovedFromNodeHint(cbe: *CEmitter, node: *HirNode, valueTypeHint: String) {
if node == null as *HirNode { return; }
if node.kind == hVar {
CBE_AddMoved(cbe, node.strValue);
return;
}
+ // Partial field move: only when the *value* type is droppable
+ if node.kind == hFieldPtr || node.kind == hFieldAccess {
+ var vty: String = valueTypeHint;
+ if String_Eq(vty, "") {
+ vty = node.typeName;
+ }
+ // Field HIR often stores the *base* struct typeName — prefer hint
+ if CBE_IsDroppableTypeName(vty) {
+ // Walk to base local (hVar / load / nested)
+ CBE_MarkMovedFromNodeHint(cbe, node.child1, "");
+ }
+ return;
+ }
+ if node.kind == hLoad {
+ CBE_MarkMovedFromNodeHint(cbe, node.child1, valueTypeHint);
+ return;
+ }
if node.kind == hStructInit {
var field: *HirNode = node.child1;
while field != null as *HirNode {
- CBE_MarkMovedFromNode(cbe, field.child1);
+ CBE_MarkMovedFromNodeHint(cbe, field.child1, "");
field = field.child3;
}
return;
}
if node.kind == hTupleInit {
- // child1/child2 + linked extras if any
- CBE_MarkMovedFromNode(cbe, node.child1);
- CBE_MarkMovedFromNode(cbe, node.child2);
+ CBE_MarkMovedFromNodeHint(cbe, node.child1, "");
+ CBE_MarkMovedFromNodeHint(cbe, node.child2, "");
return;
}
}
@@ -375,13 +414,16 @@ module CBackend {
return;
}
- // Binary
+ // Binary — always parenthesize so C precedence cannot rewrite the AST.
+ // Without parens, Mul(Add(a,b), c) emits `a + b * c` (= a+(b*c)) instead of (a+b)*c.
if kind == hBinary {
+ StringBuilder_Append(&cbe.sb, "(");
CBE_EmitExpr(cbe, node.child1);
StringBuilder_Append(&cbe.sb, " ");
StringBuilder_Append(&cbe.sb, CBackend_OpToC(node.intValue));
StringBuilder_Append(&cbe.sb, " ");
CBE_EmitExpr(cbe, node.child2);
+ StringBuilder_Append(&cbe.sb, ")");
return;
}
@@ -494,9 +536,15 @@ module CBackend {
// (Emitting Drop before the value used to use-after-drop on `return a.id`.)
if kind == hReturn {
CBE_EmitDebugLine(cbe, node);
- // Track moved variables via return / field-move into returned struct
+ // Track moved variables via return / field-move into returned struct.
+ // Pass currentRetType so `return bag.items` (Array) marks bag, but
+ // `return bag.tag` (int) does not.
if node.child1 != null as *HirNode {
- CBE_MarkMovedFromNode(cbe, node.child1);
+ var retHint: String = "";
+ if cbe.currentRetType != null as String {
+ retHint = cbe.currentRetType;
+ }
+ CBE_MarkMovedFromNodeHint(cbe, node.child1, retHint);
}
if node.child1 != null as *HirNode && cbe.deferCount > 0 {
// Materialize into a temp so Drop cannot clobber the returned value.
@@ -554,9 +602,13 @@ module CBackend {
// Store: combine alloca + value into single declaration
if kind == hStore {
- // Track moved variables via assignment/let
- if node.child2 != null as *HirNode && node.child2.kind == hVar {
- CBE_AddMoved(cbe, node.child2.strValue);
+ // Track moved variables via assignment/let (incl. partial field rhs)
+ if node.child2 != null as *HirNode {
+ if node.child2.kind == hVar {
+ CBE_AddMoved(cbe, node.child2.strValue);
+ } else {
+ CBE_MarkMovedFromNode(cbe, node.child2);
+ }
}
// Reinitialization removes moved status
if node.child1 != null as *HirNode && node.child1.kind == hVar {
diff --git a/src/cli.bux b/src/cli.bux
index 5dc2918..55811d2 100644
--- a/src/cli.bux
+++ b/src/cli.bux
@@ -17,6 +17,7 @@ module Cli {
extern func bux_system(cmd: String) -> int;
extern func bux_getenv(name: String) -> String;
extern func bux_setenv(name: String, value: String) -> int;
+ extern func bux_cc_ld_stable() -> String;
extern func bux_strlen(s: String) -> uint;
extern func bux_str_slice(s: String, start: uint, len: uint) -> String;
@@ -226,6 +227,25 @@ func Cli_Compile(source: String, sourceName: String, targetTriple: String) -> St
decl = decl.childDecl2;
}
+ // Phase 2b: declarative macro! / quote! expansion
+ PrintLine(" Macro expand...");
+ let macEx: *MacroExpander = MacroExpand_ExpandModule(mod);
+ if MacroExpand_DiagCount(macEx) > 0 {
+ var mi: int = 0;
+ while mi < MacroExpand_DiagCount(macEx) {
+ let md: MacroDiag = MacroExpand_GetDiag(macEx, mi);
+ let diag: Diagnostic = Diagnostic {
+ message: md.message,
+ line: md.line,
+ column: md.column,
+ severity: 0,
+ };
+ Diagnostic_Print(&diag, sourceName);
+ mi = mi + 1;
+ }
+ return "";
+ }
+
// Phase 3: Semantic analysis
PrintLine(" Sema...");
let sema: *Sema = Sema_Analyze(mod);
@@ -340,13 +360,17 @@ func Cli_Build(srcPath: String, outPath: String, targetTriple: String, isRelease
if !String_Eq(targetTriple, "") {
StringBuilder_Append(&cmdBuf, "clang ");
StringBuilder_Append(&cmdBuf, optFlags);
- StringBuilder_Append(&cmdBuf, " -pthread -Wl,--build-id=none -target ");
+ StringBuilder_Append(&cmdBuf, " -pthread");
+ StringBuilder_Append(&cmdBuf, bux_cc_ld_stable());
+ StringBuilder_Append(&cmdBuf, " -target ");
StringBuilder_Append(&cmdBuf, targetTriple);
StringBuilder_Append(&cmdBuf, " ");
} else {
StringBuilder_Append(&cmdBuf, "cc ");
StringBuilder_Append(&cmdBuf, optFlags);
- StringBuilder_Append(&cmdBuf, " -pthread -Wl,--build-id=none ");
+ StringBuilder_Append(&cmdBuf, " -pthread");
+ StringBuilder_Append(&cmdBuf, bux_cc_ld_stable());
+ StringBuilder_Append(&cmdBuf, " ");
}
StringBuilder_Append(&cmdBuf, "-o ");
StringBuilder_Append(&cmdBuf, outPath);
@@ -424,6 +448,24 @@ func Cli_Check(srcPath: String) -> int {
decl2 = decl2.childDecl2;
}
+ // Phase 2b: macro expand
+ let macEx2: *MacroExpander = MacroExpand_ExpandModule(mod);
+ if MacroExpand_DiagCount(macEx2) > 0 {
+ var mi2: int = 0;
+ while mi2 < MacroExpand_DiagCount(macEx2) {
+ let md2: MacroDiag = MacroExpand_GetDiag(macEx2, mi2);
+ let diag: Diagnostic = Diagnostic {
+ message: md2.message,
+ line: md2.line,
+ column: md2.column,
+ severity: 0,
+ };
+ Diagnostic_Print(&diag, srcPath);
+ mi2 = mi2 + 1;
+ }
+ return 1;
+ }
+
// Phase 3: Sema
let sema: *Sema = Sema_Analyze(mod);
if Sema_HasError(sema) {
@@ -479,6 +521,14 @@ func Cli_CompileSource(source: String, sourceName: String) -> *HirModule {
return null as *HirModule;
}
+ // Phase 2b: macro expand
+ let macEx3: *MacroExpander = MacroExpand_ExpandModule(mod);
+ if MacroExpand_DiagCount(macEx3) > 0 {
+ Print("Macro errors in ");
+ PrintLine(sourceName);
+ return null as *HirModule;
+ }
+
// Phase 3: Semantic analysis
let sema: *Sema = Sema_Analyze(mod);
if Sema_HasError(sema) {
@@ -1623,6 +1673,25 @@ func Cli_BuildProject(projectDir: String, targetTriple: String, isRelease: bool)
PrintInt(merged.itemCount);
PrintLine(" declarations");
+ // Declarative macro! / quote! expansion (before type-check)
+ PrintLine("Expanding macros...");
+ let macEx: *MacroExpander = MacroExpand_ExpandModule(merged);
+ if MacroExpand_DiagCount(macEx) > 0 {
+ var mi: int = 0;
+ while mi < MacroExpand_DiagCount(macEx) {
+ let md: MacroDiag = MacroExpand_GetDiag(macEx, mi);
+ let diag: Diagnostic = Diagnostic {
+ message: md.message,
+ line: md.line,
+ column: md.column,
+ severity: 0,
+ };
+ Diagnostic_Print(&diag, "");
+ mi = mi + 1;
+ }
+ return 1;
+ }
+
// Semantic analysis
PrintLine("Running sema...");
let sema: *Sema = Sema_Analyze(merged);
@@ -1705,13 +1774,17 @@ func Cli_BuildProject(projectDir: String, targetTriple: String, isRelease: bool)
if !String_Eq(targetTriple, "") {
StringBuilder_Append(&ccBuf, "clang ");
StringBuilder_Append(&ccBuf, optFlags2);
- StringBuilder_Append(&ccBuf, " -pthread -Wl,--build-id=none -target ");
+ StringBuilder_Append(&ccBuf, " -pthread");
+ StringBuilder_Append(&ccBuf, bux_cc_ld_stable());
+ StringBuilder_Append(&ccBuf, " -target ");
StringBuilder_Append(&ccBuf, targetTriple);
StringBuilder_Append(&ccBuf, " ");
} else {
StringBuilder_Append(&ccBuf, "cc ");
StringBuilder_Append(&ccBuf, optFlags2);
- StringBuilder_Append(&ccBuf, " -pthread -Wl,--build-id=none ");
+ StringBuilder_Append(&ccBuf, " -pthread");
+ StringBuilder_Append(&ccBuf, bux_cc_ld_stable());
+ StringBuilder_Append(&ccBuf, " ");
}
StringBuilder_Append(&ccBuf, "-o ");
StringBuilder_Append(&ccBuf, outBin);
diff --git a/src/lexer.bux b/src/lexer.bux
index cfe2d0b..830ce5d 100644
--- a/src/lexer.bux
+++ b/src/lexer.bux
@@ -282,6 +282,7 @@ module Lexer {
if String_Eq(text, "async") { return tkAsync; }
if String_Eq(text, "await") { return tkAwait; }
if String_Eq(text, "spawn") { return tkSpawn; }
+ if String_Eq(text, "macro") { return tkMacro; }
return tkIdent;
}
@@ -666,6 +667,19 @@ module Lexer {
lexEmitToken(lex, tkHash); return;
}
+ // Macro fragment $name, or bare $ for $(…)*
+ if c == 36 { // '$'
+ if Lex_IsIdentStart(lexPeek(lex, 0)) {
+ while !lexIsAtEnd(lex) && Lex_IsIdentChar(lexPeek(lex, 0)) {
+ discard lexAdvance(lex);
+ }
+ lexEmitToken(lex, tkIdent);
+ return;
+ }
+ lexEmitToken(lex, tkDollar);
+ return;
+ }
+
lexEmitDiag(lex, "unexpected character");
lexEmitToken(lex, tkUnknown);
}
diff --git a/src/macroexpand.bux b/src/macroexpand.bux
new file mode 100644
index 0000000..85bcd0b
--- /dev/null
+++ b/src/macroexpand.bux
@@ -0,0 +1,1105 @@
+// macroexpand.bux — declarative macro! expansion (selfhost parity, session 60)
+// Expands name!(args) using macro! rules; grafts call-site locations (quote hygiene).
+module MacroExpand {
+
+ extern func bux_alloc(size: uint) -> *void;
+ extern func bux_strlen(s: String) -> uint;
+ extern func bux_str_slice(s: String, start: uint, len: uint) -> String;
+ extern func bux_str_contains(haystack: String, needle: String) -> int;
+ extern func PrintLine(s: String);
+ extern func Print(s: String);
+ extern func PrintInt(n: int);
+
+ // ---------------------------------------------------------------------------
+ // Fragment environment (up to 9 $frags singles + 2 rep lists)
+ // ---------------------------------------------------------------------------
+
+ struct MacroEnv {
+ count: int;
+ n0: String; n1: String; n2: String; n3: String; n4: String;
+ n5: String; n6: String; n7: String; n8: String;
+ a0: *Expr; a1: *Expr; a2: *Expr; a3: *Expr; a4: *Expr;
+ a5: *Expr; a6: *Expr; a7: *Expr; a8: *Expr;
+ // Up to 2 named rep lists (multi-rep + compound zip)
+ listName0: String;
+ listCount0: int;
+ l0: *Expr; l1: *Expr; l2: *Expr; l3: *Expr; l4: *Expr;
+ l5: *Expr; l6: *Expr; l7: *Expr; l8: *Expr; l9: *Expr;
+ l10: *Expr; l11: *Expr; l12: *Expr; l13: *Expr; l14: *Expr; l15: *Expr;
+ listName1: String;
+ listCount1: int;
+ m0: *Expr; m1: *Expr; m2: *Expr; m3: *Expr; m4: *Expr;
+ m5: *Expr; m6: *Expr; m7: *Expr; m8: *Expr; m9: *Expr;
+ m10: *Expr; m11: *Expr; m12: *Expr; m13: *Expr; m14: *Expr; m15: *Expr;
+ // Unhygienic call-site binders from `var $name` / `for $i` (skip gensym)
+ unhyCount: int;
+ u0: String; u1: String; u2: String; u3: String;
+ u4: String; u5: String; u6: String; u7: String;
+ }
+
+ func Env_New() -> MacroEnv {
+ return MacroEnv {
+ count: 0,
+ n0: "", n1: "", n2: "", n3: "", n4: "", n5: "", n6: "", n7: "", n8: "",
+ a0: null as *Expr, a1: null as *Expr, a2: null as *Expr,
+ a3: null as *Expr, a4: null as *Expr, a5: null as *Expr,
+ a6: null as *Expr, a7: null as *Expr, a8: null as *Expr,
+ listName0: "", listCount0: 0,
+ l0: null as *Expr, l1: null as *Expr, l2: null as *Expr, l3: null as *Expr,
+ l4: null as *Expr, l5: null as *Expr, l6: null as *Expr, l7: null as *Expr,
+ l8: null as *Expr, l9: null as *Expr, l10: null as *Expr, l11: null as *Expr,
+ l12: null as *Expr, l13: null as *Expr, l14: null as *Expr, l15: null as *Expr,
+ listName1: "", listCount1: 0,
+ m0: null as *Expr, m1: null as *Expr, m2: null as *Expr, m3: null as *Expr,
+ m4: null as *Expr, m5: null as *Expr, m6: null as *Expr, m7: null as *Expr,
+ m8: null as *Expr, m9: null as *Expr, m10: null as *Expr, m11: null as *Expr,
+ m12: null as *Expr, m13: null as *Expr, m14: null as *Expr, m15: null as *Expr,
+ unhyCount: 0,
+ u0: "", u1: "", u2: "", u3: "", u4: "", u5: "", u6: "", u7: ""
+ };
+ }
+
+ func Env_IsUnhy(env: *MacroEnv, name: String) -> bool {
+ if String_Eq(name, "") { return false; }
+ if env.unhyCount > 0 && String_Eq(env.u0, name) { return true; }
+ if env.unhyCount > 1 && String_Eq(env.u1, name) { return true; }
+ if env.unhyCount > 2 && String_Eq(env.u2, name) { return true; }
+ if env.unhyCount > 3 && String_Eq(env.u3, name) { return true; }
+ if env.unhyCount > 4 && String_Eq(env.u4, name) { return true; }
+ if env.unhyCount > 5 && String_Eq(env.u5, name) { return true; }
+ if env.unhyCount > 6 && String_Eq(env.u6, name) { return true; }
+ if env.unhyCount > 7 && String_Eq(env.u7, name) { return true; }
+ return false;
+ }
+
+ func Env_AddUnhy(env: *MacroEnv, name: String) {
+ if String_Eq(name, "") { return; }
+ if Env_IsUnhy(env, name) { return; }
+ if env.unhyCount >= 8 { return; }
+ if env.unhyCount == 0 { env.u0 = name; }
+ else if env.unhyCount == 1 { env.u1 = name; }
+ else if env.unhyCount == 2 { env.u2 = name; }
+ else if env.unhyCount == 3 { env.u3 = name; }
+ else if env.unhyCount == 4 { env.u4 = name; }
+ else if env.unhyCount == 5 { env.u5 = name; }
+ else if env.unhyCount == 6 { env.u6 = name; }
+ else { env.u7 = name; }
+ env.unhyCount = env.unhyCount + 1;
+ }
+
+ func Env_ListSet(env: *MacroEnv, which: int, idx: int, e: *Expr) {
+ if which == 0 {
+ if idx == 0 { env.l0 = e; }
+ else if idx == 1 { env.l1 = e; }
+ else if idx == 2 { env.l2 = e; }
+ else if idx == 3 { env.l3 = e; }
+ else if idx == 4 { env.l4 = e; }
+ else if idx == 5 { env.l5 = e; }
+ else if idx == 6 { env.l6 = e; }
+ else if idx == 7 { env.l7 = e; }
+ else if idx == 8 { env.l8 = e; }
+ else if idx == 9 { env.l9 = e; }
+ else if idx == 10 { env.l10 = e; }
+ else if idx == 11 { env.l11 = e; }
+ else if idx == 12 { env.l12 = e; }
+ else if idx == 13 { env.l13 = e; }
+ else if idx == 14 { env.l14 = e; }
+ else if idx == 15 { env.l15 = e; }
+ } else {
+ if idx == 0 { env.m0 = e; }
+ else if idx == 1 { env.m1 = e; }
+ else if idx == 2 { env.m2 = e; }
+ else if idx == 3 { env.m3 = e; }
+ else if idx == 4 { env.m4 = e; }
+ else if idx == 5 { env.m5 = e; }
+ else if idx == 6 { env.m6 = e; }
+ else if idx == 7 { env.m7 = e; }
+ else if idx == 8 { env.m8 = e; }
+ else if idx == 9 { env.m9 = e; }
+ else if idx == 10 { env.m10 = e; }
+ else if idx == 11 { env.m11 = e; }
+ else if idx == 12 { env.m12 = e; }
+ else if idx == 13 { env.m13 = e; }
+ else if idx == 14 { env.m14 = e; }
+ else if idx == 15 { env.m15 = e; }
+ }
+ }
+
+ func Env_ListGet(env: *MacroEnv, which: int, idx: int) -> *Expr {
+ if which == 0 {
+ if idx == 0 { return env.l0; }
+ if idx == 1 { return env.l1; }
+ if idx == 2 { return env.l2; }
+ if idx == 3 { return env.l3; }
+ if idx == 4 { return env.l4; }
+ if idx == 5 { return env.l5; }
+ if idx == 6 { return env.l6; }
+ if idx == 7 { return env.l7; }
+ if idx == 8 { return env.l8; }
+ if idx == 9 { return env.l9; }
+ if idx == 10 { return env.l10; }
+ if idx == 11 { return env.l11; }
+ if idx == 12 { return env.l12; }
+ if idx == 13 { return env.l13; }
+ if idx == 14 { return env.l14; }
+ if idx == 15 { return env.l15; }
+ } else {
+ if idx == 0 { return env.m0; }
+ if idx == 1 { return env.m1; }
+ if idx == 2 { return env.m2; }
+ if idx == 3 { return env.m3; }
+ if idx == 4 { return env.m4; }
+ if idx == 5 { return env.m5; }
+ if idx == 6 { return env.m6; }
+ if idx == 7 { return env.m7; }
+ if idx == 8 { return env.m8; }
+ if idx == 9 { return env.m9; }
+ if idx == 10 { return env.m10; }
+ if idx == 11 { return env.m11; }
+ if idx == 12 { return env.m12; }
+ if idx == 13 { return env.m13; }
+ if idx == 14 { return env.m14; }
+ if idx == 15 { return env.m15; }
+ }
+ return null as *Expr;
+ }
+
+ func Env_ListWhich(env: *MacroEnv, name: String) -> int {
+ if !String_Eq(env.listName0, "") && String_Eq(env.listName0, name) { return 0; }
+ if !String_Eq(env.listName1, "") && String_Eq(env.listName1, name) { return 1; }
+ return -1;
+ }
+
+ func Env_ListCountOf(env: *MacroEnv, which: int) -> int {
+ if which == 0 { return env.listCount0; }
+ if which == 1 { return env.listCount1; }
+ return 0;
+ }
+
+ func Env_CopySingles(dst: *MacroEnv, src: *MacroEnv) {
+ dst.n0 = src.n0; dst.a0 = src.a0;
+ dst.n1 = src.n1; dst.a1 = src.a1;
+ dst.n2 = src.n2; dst.a2 = src.a2;
+ dst.n3 = src.n3; dst.a3 = src.a3;
+ dst.n4 = src.n4; dst.a4 = src.a4;
+ dst.n5 = src.n5; dst.a5 = src.a5;
+ dst.n6 = src.n6; dst.a6 = src.a6;
+ dst.n7 = src.n7; dst.a7 = src.a7;
+ dst.n8 = src.n8; dst.a8 = src.a8;
+ dst.count = src.count;
+ }
+
+ func Env_Set(env: *MacroEnv, idx: int, name: String, arg: *Expr) {
+ if idx == 0 { env.n0 = name; env.a0 = arg; }
+ else if idx == 1 { env.n1 = name; env.a1 = arg; }
+ else if idx == 2 { env.n2 = name; env.a2 = arg; }
+ else if idx == 3 { env.n3 = name; env.a3 = arg; }
+ else if idx == 4 { env.n4 = name; env.a4 = arg; }
+ else if idx == 5 { env.n5 = name; env.a5 = arg; }
+ else if idx == 6 { env.n6 = name; env.a6 = arg; }
+ else if idx == 7 { env.n7 = name; env.a7 = arg; }
+ else if idx == 8 { env.n8 = name; env.a8 = arg; }
+ if idx + 1 > env.count { env.count = idx + 1; }
+ }
+
+ func Env_Lookup(env: *MacroEnv, name: String) -> *Expr {
+ if String_Eq(env.n0, name) { return env.a0; }
+ if String_Eq(env.n1, name) { return env.a1; }
+ if String_Eq(env.n2, name) { return env.a2; }
+ if String_Eq(env.n3, name) { return env.a3; }
+ if String_Eq(env.n4, name) { return env.a4; }
+ if String_Eq(env.n5, name) { return env.a5; }
+ if String_Eq(env.n6, name) { return env.a6; }
+ if String_Eq(env.n7, name) { return env.a7; }
+ if String_Eq(env.n8, name) { return env.a8; }
+ return null as *Expr;
+ }
+
+ // If binder name is a $frag bound to ekIdent, return that ident text
+ func Env_BinderFromFrag(env: *MacroEnv, name: String) -> String {
+ let bound: *Expr = Env_Lookup(env, name);
+ if bound == null as *Expr { return ""; }
+ if bound.kind != ekIdent { return ""; }
+ return bound.strValue;
+ }
+
+ // Find free single slot (prefer 0..7, then 8)
+ func Env_SetNamed(env: *MacroEnv, name: String, arg: *Expr) {
+ var i: int = 0;
+ while i < 9 {
+ var existing: String = "";
+ if i == 0 { existing = env.n0; }
+ else if i == 1 { existing = env.n1; }
+ else if i == 2 { existing = env.n2; }
+ else if i == 3 { existing = env.n3; }
+ else if i == 4 { existing = env.n4; }
+ else if i == 5 { existing = env.n5; }
+ else if i == 6 { existing = env.n6; }
+ else if i == 7 { existing = env.n7; }
+ else { existing = env.n8; }
+ if String_Eq(existing, "") || String_Eq(existing, name) {
+ Env_Set(env, i, name, arg);
+ return;
+ }
+ i = i + 1;
+ }
+ Env_Set(env, 8, name, arg);
+ }
+
+ func Rule_FragName(rule: *Decl, idx: int) -> String {
+ if idx == 0 { return rule.param0.name; }
+ if idx == 1 { return rule.param1.name; }
+ if idx == 2 { return rule.param2.name; }
+ if idx == 3 { return rule.param3.name; }
+ if idx == 4 { return rule.param4.name; }
+ if idx == 5 { return rule.param5.name; }
+ if idx == 6 { return rule.param6.name; }
+ if idx == 7 { return rule.param7.name; }
+ if idx == 8 { return rule.param8.name; }
+ return "";
+ }
+
+ func Macro_RenameIdentsInExpr(e: *Expr, oldN: String, newN: String) {
+ if e == null as *Expr { return; }
+ if e.kind == ekIdent && String_Eq(e.strValue, oldN) {
+ e.strValue = newN;
+ }
+ Macro_RenameIdentsInExpr(e.child1, oldN, newN);
+ Macro_RenameIdentsInExpr(e.child2, oldN, newN);
+ Macro_RenameIdentsInExpr(e.child3, oldN, newN);
+ if e.refBlock != null as *Block {
+ Macro_GensymBlockApply(e.refBlock, oldN, newN);
+ }
+ var args: *ExprList = e.callArgs;
+ while args != null as *ExprList {
+ Macro_RenameIdentsInExpr(args.expr, oldN, newN);
+ args = args.next;
+ }
+ }
+
+ func Macro_GensymBlockApply(b: *Block, oldN: String, newN: String) {
+ if b == null as *Block { return; }
+ var s: *Stmt = b.firstStmt;
+ while s != null as *Stmt {
+ // let/var and for-loop binder
+ if (s.kind == skLet || s.kind == skFor) && String_Eq(s.strValue, oldN) {
+ s.strValue = newN;
+ }
+ Macro_RenameIdentsInExpr(s.child1, oldN, newN);
+ Macro_RenameIdentsInExpr(s.child2, oldN, newN);
+ Macro_RenameIdentsInExpr(s.child3, oldN, newN);
+ if s.refStmtBlock != null as *Block {
+ Macro_GensymBlockApply(s.refStmtBlock, oldN, newN);
+ }
+ if s.refStmtElse != null as *Block {
+ Macro_GensymBlockApply(s.refStmtElse, oldN, newN);
+ }
+ s = s.nextStmt;
+ }
+ }
+
+ // Collect template-introduced binders (let/var + for) then rename whole body.
+ // Skip unhygienic call-site binders from `var $name` (tracked on env).
+ func Macro_GensymBlock(ex: *MacroExpander, b: *Block, env: *MacroEnv) {
+ if b == null as *Block || ex == null as *MacroExpander { return; }
+ var s: *Stmt = b.firstStmt;
+ while s != null as *Stmt {
+ if (s.kind == skLet || s.kind == skFor) && !String_Eq(s.strValue, "") {
+ var skip: bool = false;
+ if env != null as *MacroEnv && Env_IsUnhy(env, s.strValue) {
+ skip = true;
+ }
+ if !skip {
+ ex.gensymCounter = ex.gensymCounter + 1;
+ let neu: String = String_Concat(String_Concat("__m", String_FromInt(ex.gensymCounter)), String_Concat("_", s.strValue));
+ let oldN: String = s.strValue;
+ Macro_GensymBlockApply(b, oldN, neu);
+ }
+ }
+ if s.refStmtBlock != null as *Block {
+ Macro_GensymBlock(ex, s.refStmtBlock, env);
+ }
+ if s.refStmtElse != null as *Block {
+ Macro_GensymBlock(ex, s.refStmtElse, env);
+ }
+ if s.child1 != null as *Expr && s.child1.kind == ekBlock {
+ Macro_GensymBlock(ex, s.child1.refBlock, env);
+ }
+ s = s.nextStmt;
+ }
+ }
+
+ // Fragment kind check: "ident" | "literal" | "block" | expr|tt (any)
+ func Macro_FragMatches(kindStr: String, aexp: *Expr) -> bool {
+ if aexp == null as *Expr { return false; }
+ if String_Eq(kindStr, "ident") {
+ return aexp.kind == ekIdent;
+ }
+ if String_Eq(kindStr, "literal") {
+ return aexp.kind == ekLiteral;
+ }
+ if String_Eq(kindStr, "block") {
+ return aexp.kind == ekBlock;
+ }
+ // expr / tt / unknown → accept
+ return true;
+ }
+
+ // kinds encoded as "expr;ident;rep:expr," — return fi-th segment
+ func Macro_KindAt(kinds: String, fi: int) -> String {
+ if kinds == null as String || String_Eq(kinds, "") { return "expr"; }
+ var start: int = 0;
+ var idx: int = 0;
+ let n: int = bux_strlen(kinds) as int;
+ var i: int = 0;
+ while i <= n {
+ if i == n || kinds[i] == 59 as char8 { // ';'
+ if idx == fi {
+ let len: int = i - start;
+ if len <= 0 { return "expr"; }
+ return bux_str_slice(kinds, start as uint, len as uint);
+ }
+ idx = idx + 1;
+ start = i + 1;
+ }
+ i = i + 1;
+ }
+ return "expr";
+ }
+
+ // Does body (or nested MacroRep) reference list name as an ident?
+ func Macro_BodyUsesListName(b: *Block, name: String) -> bool {
+ if b == null as *Block || String_Eq(name, "") { return false; }
+ var s: *Stmt = b.firstStmt;
+ while s != null as *Stmt {
+ if Macro_ExprUsesListName(s.child1, name) { return true; }
+ if Macro_ExprUsesListName(s.child2, name) { return true; }
+ if Macro_ExprUsesListName(s.child3, name) { return true; }
+ if s.refStmtBlock != null as *Block {
+ if Macro_BodyUsesListName(s.refStmtBlock, name) { return true; }
+ }
+ if s.refStmtElse != null as *Block {
+ if Macro_BodyUsesListName(s.refStmtElse, name) { return true; }
+ }
+ s = s.nextStmt;
+ }
+ return false;
+ }
+
+ func Macro_ExprUsesListName(e: *Expr, name: String) -> bool {
+ if e == null as *Expr { return false; }
+ if e.kind == ekIdent && String_Eq(e.strValue, name) { return true; }
+ if Macro_ExprUsesListName(e.child1, name) { return true; }
+ if Macro_ExprUsesListName(e.child2, name) { return true; }
+ if Macro_ExprUsesListName(e.child3, name) { return true; }
+ if e.refBlock != null as *Block {
+ if Macro_BodyUsesListName(e.refBlock, name) { return true; }
+ }
+ var args: *ExprList = e.callArgs;
+ while args != null as *ExprList {
+ if Macro_ExprUsesListName(args.expr, name) { return true; }
+ args = args.next;
+ }
+ return false;
+ }
+
+ func Macro_AppendBlockStmts(n: *Block, part: *Block) {
+ if part == null as *Block { return; }
+ var ps: *Stmt = part.firstStmt;
+ while ps != null as *Stmt {
+ let nxt: *Stmt = ps.nextStmt;
+ ps.nextStmt = null as *Stmt;
+ if n.firstStmt == null as *Stmt {
+ n.firstStmt = ps;
+ n.lastStmt = ps;
+ } else {
+ n.lastStmt.nextStmt = ps;
+ n.lastStmt = ps;
+ }
+ n.stmtCount = n.stmtCount + 1;
+ ps = nxt;
+ }
+ }
+
+ // Flatten $(…)* while substituting — zip multi-lists; expand once when no lists left
+ func Subst_Block_Flat(b: *Block, env: *MacroEnv, file: String, line: uint32, col: uint32) -> *Block {
+ if b == null as *Block { return null as *Block; }
+ let n: *Block = bux_alloc(sizeof(Block)) as *Block;
+ n.line = line;
+ n.column = col;
+ n.sourceFile = file;
+ n.stmtCount = 0;
+ n.firstStmt = null as *Stmt;
+ n.lastStmt = null as *Stmt;
+
+ var s: *Stmt = b.firstStmt;
+ while s != null as *Stmt {
+ if s.kind == skMacroRep && s.refStmtBlock != null as *Block {
+ let use0: bool = Macro_BodyUsesListName(s.refStmtBlock, env.listName0);
+ let use1: bool = Macro_BodyUsesListName(s.refStmtBlock, env.listName1);
+ // Nested same-list after outer bound items as singles: expand once
+ if !use0 && !use1 {
+ let partOnce: *Block = Subst_Block(s.refStmtBlock, env, file, line, col);
+ Macro_AppendBlockStmts(n, partOnce);
+ } else {
+ var nRep: int = 0;
+ if use0 && env.listCount0 > nRep { nRep = env.listCount0; }
+ if use1 && env.listCount1 > nRep { nRep = env.listCount1; }
+ var li: int = 0;
+ while li < nRep {
+ var inner: MacroEnv = Env_New();
+ Env_CopySingles(&inner, env);
+ // Keep unreferenced lists for nested MacroRep siblings
+ if !use0 && !String_Eq(env.listName0, "") {
+ inner.listName0 = env.listName0;
+ inner.listCount0 = env.listCount0;
+ var ci: int = 0;
+ while ci < env.listCount0 {
+ Env_ListSet(&inner, 0, ci, Env_ListGet(env, 0, ci));
+ ci = ci + 1;
+ }
+ }
+ if !use1 && !String_Eq(env.listName1, "") {
+ inner.listName1 = env.listName1;
+ inner.listCount1 = env.listCount1;
+ var cj: int = 0;
+ while cj < env.listCount1 {
+ Env_ListSet(&inner, 1, cj, Env_ListGet(env, 1, cj));
+ cj = cj + 1;
+ }
+ }
+ if use0 && li < env.listCount0 {
+ Env_SetNamed(&inner, env.listName0, Env_ListGet(env, 0, li));
+ }
+ if use1 && li < env.listCount1 {
+ Env_SetNamed(&inner, env.listName1, Env_ListGet(env, 1, li));
+ }
+ let part: *Block = Subst_Block(s.refStmtBlock, &inner, file, line, col);
+ Macro_AppendBlockStmts(n, part);
+ li = li + 1;
+ }
+ }
+ } else {
+ let one: *Stmt = Subst_Stmt(s, env, file, line, col);
+ if one != null as *Stmt {
+ one.nextStmt = null as *Stmt;
+ if n.firstStmt == null as *Stmt {
+ n.firstStmt = one;
+ n.lastStmt = one;
+ } else {
+ n.lastStmt.nextStmt = one;
+ n.lastStmt = one;
+ }
+ n.stmtCount = n.stmtCount + 1;
+ }
+ }
+ s = s.nextStmt;
+ }
+ return n;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Diagnostics
+ // ---------------------------------------------------------------------------
+
+ const maxMacroDiags: int = 64;
+
+ struct MacroDiag {
+ line: uint32;
+ column: uint32;
+ message: String;
+ }
+
+ struct MacroEntry {
+ name: String;
+ decl: *Decl;
+ }
+
+ struct MacroExpander {
+ diagCount: int;
+ diags: *MacroDiag;
+ // Flat macro table — max 64 macros
+ macroCount: int;
+ entries: *MacroEntry;
+ gensymCounter: int;
+ }
+
+ func MacroExpand_New() -> *MacroExpander {
+ let ex: *MacroExpander = bux_alloc(sizeof(MacroExpander)) as *MacroExpander;
+ ex.diagCount = 0;
+ ex.diags = bux_alloc((maxMacroDiags * sizeof(MacroDiag)) as uint) as *MacroDiag;
+ ex.macroCount = 0;
+ ex.entries = bux_alloc((64 * sizeof(MacroEntry)) as uint) as *MacroEntry;
+ ex.gensymCounter = 0;
+ return ex;
+ }
+
+ func MacroExpand_Err(ex: *MacroExpander, line: uint32, col: uint32, msg: String) {
+ if ex.diagCount < maxMacroDiags {
+ ex.diags[ex.diagCount] = MacroDiag { line: line, column: col, message: msg };
+ ex.diagCount = ex.diagCount + 1;
+ }
+ }
+
+ func MacroExpand_Register(ex: *MacroExpander, name: String, d: *Decl) {
+ if ex.macroCount >= 64 { return; }
+ ex.entries[ex.macroCount] = MacroEntry { name: name, decl: d };
+ ex.macroCount = ex.macroCount + 1;
+ }
+
+ func MacroExpand_Lookup(ex: *MacroExpander, name: String) -> *Decl {
+ var i: int = 0;
+ while i < ex.macroCount {
+ if String_Eq(ex.entries[i].name, name) {
+ return ex.entries[i].decl;
+ }
+ i = i + 1;
+ }
+ return null as *Decl;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Call-site graft: force line/col + sourceFile on tree
+ // ---------------------------------------------------------------------------
+
+ func Graft_Expr(e: *Expr, file: String, line: uint32, col: uint32) {
+ if e == null as *Expr { return; }
+ e.line = line;
+ e.column = col;
+ if file != null as String && !String_Eq(file, "") {
+ e.sourceFile = file;
+ }
+ Graft_Expr(e.child1, file, line, col);
+ Graft_Expr(e.child2, file, line, col);
+ Graft_Expr(e.child3, file, line, col);
+ if e.refBlock != null as *Block {
+ Graft_Block(e.refBlock, file, line, col);
+ }
+ var args: *ExprList = e.callArgs;
+ while args != null as *ExprList {
+ Graft_Expr(args.expr, file, line, col);
+ args = args.next;
+ }
+ var arm: *MatchArm = e.matchArms;
+ while arm != null as *MatchArm {
+ Graft_Expr(arm.body, file, line, col);
+ arm = arm.next;
+ }
+ }
+
+ func Graft_Stmt(s: *Stmt, file: String, line: uint32, col: uint32) {
+ if s == null as *Stmt { return; }
+ s.line = line;
+ s.column = col;
+ if file != null as String && !String_Eq(file, "") {
+ s.sourceFile = file;
+ }
+ Graft_Expr(s.child1, file, line, col);
+ Graft_Expr(s.child2, file, line, col);
+ Graft_Expr(s.child3, file, line, col);
+ if s.refStmtBlock != null as *Block {
+ Graft_Block(s.refStmtBlock, file, line, col);
+ }
+ if s.refStmtElse != null as *Block {
+ Graft_Block(s.refStmtElse, file, line, col);
+ }
+ Graft_Stmt(s.nextStmt, file, line, col);
+ }
+
+ func Graft_Block(b: *Block, file: String, line: uint32, col: uint32) {
+ if b == null as *Block { return; }
+ b.line = line;
+ b.column = col;
+ if file != null as String && !String_Eq(file, "") {
+ b.sourceFile = file;
+ }
+ Graft_Stmt(b.firstStmt, file, line, col);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Substitute $frags in a cloned tree
+ // ---------------------------------------------------------------------------
+
+ func Subst_Expr(e: *Expr, env: *MacroEnv, file: String, line: uint32, col: uint32) -> *Expr {
+ if e == null as *Expr { return null as *Expr; }
+ if e.kind == ekIdent {
+ let bound: *Expr = Env_Lookup(env, e.strValue);
+ if bound != null as *Expr {
+ let n: *Expr = Ast_CloneExpr(bound);
+ Graft_Expr(n, file, line, col);
+ return n;
+ }
+ }
+ // In-place subst on clone of e
+ let c: *Expr = Ast_CloneExpr(e);
+ if c == null as *Expr { return null as *Expr; }
+ c.child1 = Subst_Expr(c.child1, env, file, line, col);
+ c.child2 = Subst_Expr(c.child2, env, file, line, col);
+ c.child3 = Subst_Expr(c.child3, env, file, line, col);
+ if c.refBlock != null as *Block {
+ c.refBlock = Subst_Block_Flat(c.refBlock, env, file, line, col);
+ }
+ // callArgs list
+ var args: *ExprList = c.callArgs;
+ while args != null as *ExprList {
+ args.expr = Subst_Expr(args.expr, env, file, line, col);
+ args = args.next;
+ }
+ var arm: *MatchArm = c.matchArms;
+ while arm != null as *MatchArm {
+ arm.body = Subst_Expr(arm.body, env, file, line, col);
+ arm = arm.next;
+ }
+ c.line = line;
+ c.column = col;
+ if file != null as String && !String_Eq(file, "") {
+ c.sourceFile = file;
+ }
+ return c;
+ }
+
+ func Subst_Stmt(s: *Stmt, env: *MacroEnv, file: String, line: uint32, col: uint32) -> *Stmt {
+ if s == null as *Stmt { return null as *Stmt; }
+ let c: *Stmt = Ast_CloneStmt(s);
+ // Ast_CloneStmt clones nextStmt chain — break to single and rebuild
+ c.nextStmt = null as *Stmt;
+ // Unhygienic binder: `var $name` / `for $i` with $frag:ident → call-site name
+ if (c.kind == skLet || c.kind == skFor) && !String_Eq(c.strValue, "") {
+ let bn: String = Env_BinderFromFrag(env, c.strValue);
+ if !String_Eq(bn, "") {
+ c.strValue = bn;
+ Env_AddUnhy(env, bn);
+ }
+ }
+ c.child1 = Subst_Expr(c.child1, env, file, line, col);
+ c.child2 = Subst_Expr(c.child2, env, file, line, col);
+ c.child3 = Subst_Expr(c.child3, env, file, line, col);
+ if c.refStmtBlock != null as *Block {
+ c.refStmtBlock = Subst_Block(c.refStmtBlock, env, file, line, col);
+ }
+ if c.refStmtElse != null as *Block {
+ c.refStmtElse = Subst_Block(c.refStmtElse, env, file, line, col);
+ }
+ c.line = line;
+ c.column = col;
+ if file != null as String && !String_Eq(file, "") {
+ c.sourceFile = file;
+ }
+ // Do NOT walk nextStmt — Subst_Block_Flat iterates the chain itself
+ c.nextStmt = null as *Stmt;
+ return c;
+ }
+
+ func Subst_Block(b: *Block, env: *MacroEnv, file: String, line: uint32, col: uint32) -> *Block {
+ // Always flatten $(…)* so nested expression-blocks expand correctly
+ return Subst_Block_Flat(b, env, file, line, col);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Expand trees
+ // ---------------------------------------------------------------------------
+
+ func Expand_Expr(ex: *MacroExpander, e: *Expr, depth: int) -> *Expr {
+ if e == null as *Expr { return null as *Expr; }
+ if depth > 32 {
+ MacroExpand_Err(ex, e.line, e.column, "macro expansion depth exceeded");
+ return e;
+ }
+ if e.kind == ekMacroCall {
+ return Expand_OneCall(ex, e, depth);
+ }
+ e.child1 = Expand_Expr(ex, e.child1, depth);
+ e.child2 = Expand_Expr(ex, e.child2, depth);
+ e.child3 = Expand_Expr(ex, e.child3, depth);
+ if e.refBlock != null as *Block {
+ e.refBlock = Expand_Block(ex, e.refBlock, depth);
+ }
+ var args: *ExprList = e.callArgs;
+ while args != null as *ExprList {
+ args.expr = Expand_Expr(ex, args.expr, depth);
+ args = args.next;
+ }
+ var arm: *MatchArm = e.matchArms;
+ while arm != null as *MatchArm {
+ arm.body = Expand_Expr(ex, arm.body, depth);
+ arm = arm.next;
+ }
+ return e;
+ }
+
+ func Expand_OneCall(ex: *MacroExpander, call: *Expr, depth: int) -> *Expr {
+ let name: String = call.strValue;
+ let siteLine: uint32 = call.line;
+ let siteCol: uint32 = call.column;
+ let siteFile: String = call.sourceFile;
+
+ // Built-in quote!(e)
+ if String_Eq(name, "quote") {
+ if call.callArgCount != 1 || call.callArgs == null as *ExprList {
+ MacroExpand_Err(ex, siteLine, siteCol, "quote! expects exactly 1 argument");
+ return call;
+ }
+ let arg: *Expr = Expand_Expr(ex, call.callArgs.expr, depth + 1);
+ let n: *Expr = Ast_CloneExpr(arg);
+ Graft_Expr(n, siteFile, siteLine, siteCol);
+ return n;
+ }
+
+ let mdecl: *Decl = MacroExpand_Lookup(ex, name);
+ if mdecl == null as *Decl {
+ MacroExpand_Err(ex, siteLine, siteCol, String_Concat("unknown macro '", String_Concat(name, "'")));
+ return call;
+ }
+
+ // Match rule: fixed, trailing rep, multi-rep groups (;), compound zip (chunk>1).
+ // Group lengths encoded on call.genericCallee as "n0;n1;…" (empty = one flat group).
+ var rule: *Decl = mdecl.childDecl1;
+ var matched: *Decl = null as *Decl;
+ var env: MacroEnv = Env_New();
+
+ // Pre-expand all args once
+ var expArgs: *ExprList = null as *ExprList;
+ var expTail: *ExprList = null as *ExprList;
+ var nargs: int = 0;
+ var rawArg: *ExprList = call.callArgs;
+ while rawArg != null as *ExprList {
+ let aexp: *Expr = Expand_Expr(ex, rawArg.expr, depth + 1);
+ let node: *ExprList = bux_alloc(sizeof(ExprList)) as *ExprList;
+ node.expr = aexp;
+ node.next = null as *ExprList;
+ node.argName = "";
+ if expArgs == null as *ExprList {
+ expArgs = node;
+ expTail = node;
+ } else {
+ expTail.next = node;
+ expTail = node;
+ }
+ nargs = nargs + 1;
+ rawArg = rawArg.next;
+ }
+
+ // Parse group lengths from genericCallee ("2;3") — empty means one group of all args
+ var g0: int = nargs;
+ var g1: int = 0;
+ var nGroups: int = 1;
+ let glens: String = call.genericCallee;
+ if glens != null as String && !String_Eq(glens, "") {
+ var gstart: int = 0;
+ var gidx: int = 0;
+ let glen: int = bux_strlen(glens) as int;
+ var gi: int = 0;
+ while gi <= glen {
+ if gi == glen || glens[gi] == 59 as char8 {
+ let gpart: String = bux_str_slice(glens, gstart as uint, (gi - gstart) as uint);
+ let gv: int = String_ToInt(gpart) as int;
+ if gidx == 0 { g0 = gv; }
+ else if gidx == 1 { g1 = gv; nGroups = 2; }
+ gidx = gidx + 1;
+ gstart = gi + 1;
+ }
+ gi = gi + 1;
+ }
+ if gidx > 0 { nGroups = gidx; }
+ }
+
+ while rule != null as *Decl {
+ let kinds: String = rule.useNames;
+ var nSeg: int = 0;
+ let klen: int = bux_strlen(kinds) as int;
+ if klen == 0 {
+ nSeg = 0;
+ } else {
+ nSeg = 1;
+ var ci: int = 0;
+ while ci < klen {
+ if kinds[ci] == 59 as char8 { nSeg = nSeg + 1; }
+ ci = ci + 1;
+ }
+ }
+ var nReps: int = 0;
+ var sgi: int = 0;
+ while sgi < nSeg {
+ let ks0: String = Macro_KindAt(kinds, sgi);
+ if String_StartsWith(ks0, "rep:") { nReps = nReps + 1; }
+ sgi = sgi + 1;
+ }
+ let useGroups: bool = nReps > 1 && nGroups > 1;
+
+ env = Env_New();
+ var ok: bool = true;
+ var argList: *ExprList = expArgs;
+ var flatLeft: int = nargs;
+ var gIdx: int = 0;
+ var gOff: int = 0; // offset within current group when useGroups
+ var paramIdx: int = 0;
+ var listSlot: int = 0; // next free list slot (0 or 1)
+ var seg: int = 0;
+
+ while seg < nSeg && ok {
+ var kindStr: String = Macro_KindAt(kinds, seg);
+ if String_StartsWith(kindStr, "rep:") {
+ var rest: String = bux_str_slice(kindStr, 4, bux_strlen(kindStr) - 4);
+ var chunk: int = 1;
+ var atPos: int = -1;
+ var ri: int = 0;
+ let rlen: int = bux_strlen(rest) as int;
+ while ri < rlen {
+ if rest[ri] == 64 as char8 { atPos = ri; break; }
+ ri = ri + 1;
+ }
+ if atPos >= 0 {
+ let numStr: String = bux_str_slice(rest, (atPos + 1) as uint, (rlen - atPos - 1) as uint);
+ chunk = String_ToInt(numStr) as int;
+ if chunk < 1 { chunk = 1; }
+ }
+
+ // Determine how many args this rep consumes
+ var take: int = 0;
+ if useGroups {
+ if gIdx >= nGroups {
+ take = 0; // empty trailing rep
+ } else {
+ if gIdx == 0 { take = g0; }
+ else { take = g1; }
+ gIdx = gIdx + 1;
+ gOff = 0;
+ }
+ } else {
+ take = flatLeft;
+ }
+ if take % chunk != 0 { ok = false; break; }
+
+ // Compound zip: de-interleave into parallel lists
+ if chunk == 1 {
+ if listSlot > 1 { ok = false; break; }
+ let nm: String = Rule_FragName(rule, paramIdx);
+ if listSlot == 0 {
+ env.listName0 = nm;
+ env.listCount0 = 0;
+ } else {
+ env.listName1 = nm;
+ env.listCount1 = 0;
+ }
+ var ti: int = 0;
+ while ti < take && argList != null as *ExprList {
+ Env_ListSet(&env, listSlot, Env_ListCountOf(&env, listSlot), argList.expr);
+ if listSlot == 0 { env.listCount0 = env.listCount0 + 1; }
+ else { env.listCount1 = env.listCount1 + 1; }
+ argList = argList.next;
+ flatLeft = flatLeft - 1;
+ ti = ti + 1;
+ }
+ if ti != take { ok = false; break; }
+ listSlot = listSlot + 1;
+ paramIdx = paramIdx + 1;
+ } else if chunk == 2 {
+ // Parallel lists for $a and $b
+ if listSlot > 0 { ok = false; break; } // need both free slots
+ let nm0: String = Rule_FragName(rule, paramIdx);
+ let nm1: String = Rule_FragName(rule, paramIdx + 1);
+ env.listName0 = nm0;
+ env.listName1 = nm1;
+ env.listCount0 = 0;
+ env.listCount1 = 0;
+ var ti2: int = 0;
+ while ti2 < take && argList != null as *ExprList {
+ // even → list0, odd → list1
+ if ti2 % 2 == 0 {
+ Env_ListSet(&env, 0, env.listCount0, argList.expr);
+ env.listCount0 = env.listCount0 + 1;
+ } else {
+ Env_ListSet(&env, 1, env.listCount1, argList.expr);
+ env.listCount1 = env.listCount1 + 1;
+ }
+ argList = argList.next;
+ flatLeft = flatLeft - 1;
+ ti2 = ti2 + 1;
+ }
+ if ti2 != take { ok = false; break; }
+ listSlot = 2;
+ paramIdx = paramIdx + 2;
+ } else {
+ ok = false;
+ break;
+ }
+ } else {
+ // Fixed fragment
+ if useGroups {
+ // advance group if exhausted
+ var gAvail: int = 0;
+ if gIdx == 0 { gAvail = g0 - gOff; }
+ else if gIdx == 1 { gAvail = g1 - gOff; }
+ else { gAvail = 0; }
+ if gAvail <= 0 {
+ gIdx = gIdx + 1;
+ gOff = 0;
+ if gIdx == 0 { gAvail = g0; }
+ else if gIdx == 1 { gAvail = g1; }
+ else { gAvail = 0; }
+ }
+ if gAvail <= 0 || argList == null as *ExprList { ok = false; break; }
+ gOff = gOff + 1;
+ } else {
+ if argList == null as *ExprList { ok = false; break; }
+ }
+ let aexp: *Expr = argList.expr;
+ if !Macro_FragMatches(kindStr, aexp) { ok = false; break; }
+ Env_Set(&env, paramIdx, Rule_FragName(rule, paramIdx), aexp);
+ argList = argList.next;
+ flatLeft = flatLeft - 1;
+ paramIdx = paramIdx + 1;
+ }
+ seg = seg + 1;
+ }
+ if ok {
+ if useGroups {
+ if gIdx < nGroups { ok = false; }
+ // remaining empty groups ok only if trailing empty reps handled
+ } else {
+ if argList != null as *ExprList || flatLeft != 0 { ok = false; }
+ }
+ }
+ if ok {
+ matched = rule;
+ break;
+ }
+ rule = rule.childDecl2;
+ }
+ if matched == null as *Decl {
+ MacroExpand_Err(ex, siteLine, siteCol, String_Concat("macro has no matching rule for '", String_Concat(name, "'")));
+ return call;
+ }
+
+ if matched.refBody == null as *Block {
+ MacroExpand_Err(ex, siteLine, siteCol, "macro rule has empty body");
+ return call;
+ }
+
+ let body: *Block = Subst_Block_Flat(matched.refBody, &env, siteFile, siteLine, siteCol);
+ // Hygiene: unique let names per expansion (CBE is function-scoped)
+ Macro_GensymBlock(ex, body, &env);
+ let blk: *Expr = bux_alloc(sizeof(Expr)) as *Expr;
+ // zero via MakeExpr-like
+ let made: Expr = Ast_MakeExpr(ekBlock, siteLine, siteCol);
+ blk.kind = made.kind;
+ blk.line = siteLine;
+ blk.column = siteCol;
+ blk.sourceFile = siteFile;
+ blk.strValue = "";
+ blk.intValue = 0;
+ blk.boolValue = false;
+ blk.tokKind = 0;
+ blk.tokText = "";
+ blk.child1 = null as *Expr;
+ blk.child2 = null as *Expr;
+ blk.child3 = null as *Expr;
+ blk.refType = null as *TypeExpr;
+ blk.refBlock = body;
+ blk.callArgs = null as *ExprList;
+ blk.callArgCount = 0;
+ blk.matchArms = null as *MatchArm;
+ blk.matchArmCount = 0;
+ blk.genericCallee = "";
+ blk.genericTypeArgCount = 0;
+ blk.structName = "";
+ blk.structFieldCount = 0;
+ blk.closureParams = null as *Decl;
+ blk.captureCount = 0;
+
+ // Nested macros inside expansion
+ return Expand_Expr(ex, blk, depth + 1);
+ }
+
+ func Expand_Block(ex: *MacroExpander, b: *Block, depth: int) -> *Block {
+ if b == null as *Block { return null as *Block; }
+ var s: *Stmt = b.firstStmt;
+ while s != null as *Stmt {
+ Expand_Stmt(ex, s, depth);
+ s = s.nextStmt;
+ }
+ return b;
+ }
+
+ func Expand_Stmt(ex: *MacroExpander, s: *Stmt, depth: int) {
+ if s == null as *Stmt { return; }
+ s.child1 = Expand_Expr(ex, s.child1, depth);
+ s.child2 = Expand_Expr(ex, s.child2, depth);
+ s.child3 = Expand_Expr(ex, s.child3, depth);
+ if s.refStmtBlock != null as *Block {
+ Expand_Block(ex, s.refStmtBlock, depth);
+ }
+ if s.refStmtElse != null as *Block {
+ Expand_Block(ex, s.refStmtElse, depth);
+ }
+ if s.refStmtDecl != null as *Decl {
+ Expand_Decl(ex, s.refStmtDecl, depth);
+ }
+ }
+
+ func Expand_Decl(ex: *MacroExpander, d: *Decl, depth: int) {
+ if d == null as *Decl { return; }
+ if d.kind == dkFunc {
+ if d.refBody != null as *Block {
+ Expand_Block(ex, d.refBody, depth);
+ }
+ } else if d.kind == dkImpl {
+ var m: *Decl = d.childDecl1;
+ while m != null as *Decl {
+ Expand_Decl(ex, m, depth);
+ m = m.childDecl2;
+ }
+ } else if d.kind == dkModule {
+ var it: *Decl = d.childDecl1;
+ while it != null as *Decl {
+ Expand_Decl(ex, it, depth);
+ it = it.childDecl2;
+ }
+ } else if d.kind == dkConst {
+ d.constValue = Expand_Expr(ex, d.constValue, depth);
+ }
+ // do not expand into nested macros' templates (rules stay as templates)
+ }
+
+ func Collect_Macros(ex: *MacroExpander, d: *Decl) {
+ if d == null as *Decl { return; }
+ if d.kind == dkMacro {
+ // Only top-level macros have non-empty name; rules have empty strValue
+ if !String_Eq(d.strValue, "") {
+ MacroExpand_Register(ex, d.strValue, d);
+ }
+ } else if d.kind == dkModule {
+ var it: *Decl = d.childDecl1;
+ while it != null as *Decl {
+ Collect_Macros(ex, it);
+ it = it.childDecl2;
+ }
+ }
+ }
+
+ /// Expand all macro! calls in module. Returns number of errors.
+ func MacroExpand_ExpandModule(mod: *Module) -> *MacroExpander {
+ let ex: *MacroExpander = MacroExpand_New();
+ if mod == null as *Module { return ex; }
+ var d: *Decl = mod.firstItem;
+ while d != null as *Decl {
+ Collect_Macros(ex, d);
+ d = d.childDecl2;
+ }
+ d = mod.firstItem;
+ while d != null as *Decl {
+ Expand_Decl(ex, d, 0);
+ d = d.childDecl2;
+ }
+ return ex;
+ }
+
+ func MacroExpand_DiagCount(ex: *MacroExpander) -> int {
+ if ex == null as *MacroExpander { return 0; }
+ return ex.diagCount;
+ }
+
+ func MacroExpand_GetDiag(ex: *MacroExpander, i: int) -> MacroDiag {
+ return ex.diags[i];
+ }
+}
diff --git a/src/parser.bux b/src/parser.bux
index c507942..1640f5a 100644
--- a/src/parser.bux
+++ b/src/parser.bux
@@ -27,6 +27,7 @@ module Parser {
diagCount: int,
diags: *ParserDiag,
structInitAllowed: bool,
+ macroTemplateMode: bool, // allows $(…)* in macro! bodies
}
struct ParserDiag {
@@ -1252,11 +1253,81 @@ module Parser {
continue;
}
- // ! (unwrap operator)
+ // ! — macro call name!(args) or unwrap
if kind == tkBang {
discard parserAdvance(p);
let line: uint32 = parserCurToken(p).line;
let col: uint32 = parserCurToken(p).column;
+ if left.kind == ekIdent && parserCheck(p, tkLParen) {
+ discard parserAdvance(p); // (
+ let e: *Expr = parserMakeExpr(ekMacroCall, line, col);
+ e.strValue = left.strValue;
+ var argCount: int = 0;
+ var firstArg: *ExprList = null as *ExprList;
+ var lastArg: *ExprList = null as *ExprList;
+ // Multi-rep groups: m!(a,b; c,d) — lengths encoded in genericCallee
+ var groupLens: String = "";
+ var curGroup: int = 0;
+ var nGroups: int = 0;
+ while !parserCheck(p, tkRParen) && parserPeek(p, 0) != tkEndOfFile {
+ while parserCheck(p, tkNewLine) { discard parserAdvance(p); }
+ if parserCheck(p, tkRParen) { break; }
+ // `;` starts a new arg group
+ if parserMatch(p, tkSemicolon) {
+ if String_Eq(groupLens, "") {
+ groupLens = String_FromInt(curGroup as int64);
+ } else {
+ groupLens = String_Concat(groupLens, String_Concat(";", String_FromInt(curGroup as int64)));
+ }
+ nGroups = nGroups + 1;
+ curGroup = 0;
+ while parserCheck(p, tkNewLine) { discard parserAdvance(p); }
+ continue;
+ }
+ let argExpr: *Expr = parserParseExpr(p);
+ let argNode: *ExprList = bux_alloc(sizeof(ExprList)) as *ExprList;
+ argNode.expr = argExpr;
+ argNode.next = null as *ExprList;
+ argNode.argName = "";
+ if firstArg == null as *ExprList {
+ firstArg = argNode;
+ lastArg = argNode;
+ } else {
+ lastArg.next = argNode;
+ lastArg = argNode;
+ }
+ argCount = argCount + 1;
+ curGroup = curGroup + 1;
+ while parserCheck(p, tkNewLine) { discard parserAdvance(p); }
+ if parserMatch(p, tkComma) {
+ continue;
+ }
+ // allow `;` at loop top; otherwise end of args
+ if !parserCheck(p, tkSemicolon) {
+ break;
+ }
+ }
+ // finalize last group
+ if curGroup > 0 || nGroups == 0 {
+ if String_Eq(groupLens, "") {
+ groupLens = String_FromInt(curGroup as int64);
+ } else {
+ groupLens = String_Concat(groupLens, String_Concat(";", String_FromInt(curGroup as int64)));
+ }
+ nGroups = nGroups + 1;
+ }
+ // single group → empty genericCallee (flat match)
+ if nGroups <= 1 {
+ e.genericCallee = "";
+ } else {
+ e.genericCallee = groupLens;
+ }
+ e.callArgs = firstArg;
+ e.callArgCount = argCount;
+ discard parserExpect(p, tkRParen, "expected ')' to close macro arguments");
+ left = e;
+ continue;
+ }
let e: *Expr = parserMakeExpr(ekUnwrap, line, col);
e.child1 = left;
left = e;
@@ -1466,11 +1537,50 @@ module Parser {
// ---------------------------------------------------------------------------
func parserParseStmt(p: *Parser) -> *Stmt {
+ while parserCheck(p, tkNewLine) {
+ discard parserAdvance(p);
+ }
let tok: LexToken = parserCurToken(p);
let line: uint32 = tok.line;
let col: uint32 = tok.column;
let kind: int = tok.kind;
+ // Macro template: $( stmts… )*
+ if p.macroTemplateMode && kind == tkDollar && parserPeek(p, 1) == tkLParen {
+ discard parserAdvance(p); // $
+ discard parserAdvance(p); // (
+ let body: *Block = bux_alloc(sizeof(Block)) as *Block;
+ body.line = line;
+ body.column = col;
+ body.sourceFile = "";
+ body.stmtCount = 0;
+ body.firstStmt = null as *Stmt;
+ body.lastStmt = null as *Stmt;
+ while !parserCheck(p, tkRParen) && parserPeek(p, 0) != tkEndOfFile {
+ while parserCheck(p, tkNewLine) { discard parserAdvance(p); }
+ if parserCheck(p, tkRParen) { break; }
+ let inner: *Stmt = parserParseStmt(p);
+ if body.firstStmt == null as *Stmt {
+ body.firstStmt = inner;
+ body.lastStmt = inner;
+ } else {
+ body.lastStmt.nextStmt = inner;
+ body.lastStmt = inner;
+ }
+ body.stmtCount = body.stmtCount + 1;
+ }
+ discard parserExpect(p, tkRParen, "expected ')' to close macro repetition");
+ discard parserExpect(p, tkStar, "expected '*' after macro repetition");
+ parserMatch(p, tkSemicolon);
+ let s: *Stmt = bux_alloc(sizeof(Stmt)) as *Stmt;
+ s.kind = skMacroRep;
+ s.line = line;
+ s.column = col;
+ s.refStmtBlock = body;
+ s.nextStmt = null as *Stmt;
+ return s;
+ }
+
// let / var
if kind == tkLet || kind == tkVar {
let isVar: bool = (kind == tkVar);
@@ -2197,6 +2307,178 @@ module Parser {
}
// ---------------------------------------------------------------------------
+ // macro! name { ($x:expr, …) => { template } … }
+ // ---------------------------------------------------------------------------
+
+ func parserSetMacroFragName(d: *Decl, idx: int, name: String) {
+ if idx == 0 { d.param0.name = name; }
+ else if idx == 1 { d.param1.name = name; }
+ else if idx == 2 { d.param2.name = name; }
+ else if idx == 3 { d.param3.name = name; }
+ else if idx == 4 { d.param4.name = name; }
+ else if idx == 5 { d.param5.name = name; }
+ else if idx == 6 { d.param6.name = name; }
+ else if idx == 7 { d.param7.name = name; }
+ else if idx == 8 { d.param8.name = name; }
+ }
+
+ func parserParseMacroDecl(p: *Parser, isPublic: bool) -> *Decl {
+ let line: uint32 = parserCurToken(p).line;
+ let col: uint32 = parserCurToken(p).column;
+ discard parserExpect(p, tkMacro, "expected 'macro'");
+ discard parserExpect(p, tkBang, "expected '!' after macro");
+ let nameTok: LexToken = parserExpect(p, tkIdent, "expected macro name");
+ while parserCheck(p, tkNewLine) { discard parserAdvance(p); }
+ discard parserExpect(p, tkLBrace, "expected '{' to start macro body");
+
+ let d: *Decl = bux_alloc(sizeof(Decl)) as *Decl;
+ d.kind = dkMacro;
+ d.line = line;
+ d.column = col;
+ d.isPublic = isPublic;
+ d.strValue = nameTok.text;
+ d.childDecl1 = null as *Decl;
+ d.childDecl2 = null as *Decl;
+
+ var firstRule: *Decl = null as *Decl;
+ var lastRule: *Decl = null as *Decl;
+ var ruleCount: int = 0;
+
+ while !parserCheck(p, tkRBrace) && parserPeek(p, 0) != tkEndOfFile {
+ while parserCheck(p, tkNewLine) || parserCheck(p, tkSemicolon) {
+ discard parserAdvance(p);
+ }
+ if parserCheck(p, tkRBrace) { break; }
+
+ let rline: uint32 = parserCurToken(p).line;
+ let rcol: uint32 = parserCurToken(p).column;
+ discard parserExpect(p, tkLParen, "expected '(' to start macro pattern");
+
+ let rule: *Decl = bux_alloc(sizeof(Decl)) as *Decl;
+ rule.kind = dkMacro;
+ rule.line = rline;
+ rule.column = rcol;
+ rule.strValue = "";
+ rule.paramCount = 0;
+ rule.childDecl2 = null as *Decl;
+
+ // useNames encodes: "expr" | "ident" | "tt" | "rep:expr," | "rep:expr+expr," (compound)
+ // Multiple pattern elements joined by `;` → multi-rep groups at call site
+ var kindsEnc: String = "";
+ while !parserCheck(p, tkRParen) && parserPeek(p, 0) != tkEndOfFile {
+ while parserCheck(p, tkNewLine) { discard parserAdvance(p); }
+ if parserCheck(p, tkRParen) { break; }
+ if parserMatch(p, tkSemicolon) { continue; }
+ // $( $a:kind , $b:kind ),*
+ if parserCheck(p, tkDollar) && parserPeek(p, 1) == tkLParen {
+ discard parserAdvance(p); // $
+ discard parserAdvance(p); // (
+ var repNames: String = "";
+ var repKinds: String = "";
+ var nIn: int = 0;
+ while !parserCheck(p, tkRParen) && parserPeek(p, 0) != tkEndOfFile {
+ while parserCheck(p, tkNewLine) { discard parserAdvance(p); }
+ if parserCheck(p, tkRParen) { break; }
+ let fragTok: LexToken = parserExpect(p, tkIdent, "expected $name fragment");
+ if !String_StartsWith(fragTok.text, "$") {
+ parserEmitDiag(p, fragTok.line, fragTok.column, "macro fragment must start with '$'");
+ }
+ discard parserExpect(p, tkColon, "expected ':' after fragment name");
+ let kindTok: LexToken = parserExpect(p, tkIdent, "expected fragment kind");
+ var kname: String = kindTok.text;
+ if String_Eq(kname, "lit") { kname = "literal"; }
+ if !(String_Eq(kname, "expr") || String_Eq(kname, "ident") || String_Eq(kname, "tt")
+ || String_Eq(kname, "literal") || String_Eq(kname, "block")) {
+ kname = "expr";
+ }
+ if nIn == 0 {
+ repNames = fragTok.text;
+ repKinds = kname;
+ } else {
+ repNames = String_Concat(repNames, String_Concat("+", fragTok.text));
+ repKinds = String_Concat(repKinds, String_Concat("+", kname));
+ }
+ if rule.paramCount < 9 {
+ parserSetMacroFragName(rule, rule.paramCount, fragTok.text);
+ rule.paramCount = rule.paramCount + 1;
+ }
+ nIn = nIn + 1;
+ while parserCheck(p, tkNewLine) { discard parserAdvance(p); }
+ if !parserMatch(p, tkComma) { break; }
+ }
+ discard parserExpect(p, tkRParen, "expected ')' after repeated fragment");
+ var sep: String = "";
+ if parserMatch(p, tkComma) { sep = ","; }
+ discard parserExpect(p, tkStar, "expected '*' after macro repetition");
+ let enc: String = String_Concat("rep:", String_Concat(repKinds, sep));
+ // mark compound count via leading digit in typeParam0 of rule (hack: use isDrop)
+ if rule.paramCount > 0 {
+ // store chunk size on last param via isVariadic false; use methodCount as chunk
+ // Encode: kindsEnc entry includes chunk after @
+ let enc2: String = String_Concat(enc, String_Concat("@", String_FromInt(nIn)));
+ if String_Eq(kindsEnc, "") { kindsEnc = enc2; }
+ else { kindsEnc = String_Concat(kindsEnc, String_Concat(";", enc2)); }
+ }
+ while parserCheck(p, tkNewLine) { discard parserAdvance(p); }
+ if parserMatch(p, tkSemicolon) { continue; }
+ if parserMatch(p, tkComma) { continue; }
+ break;
+ } else {
+ let fragTok: LexToken = parserExpect(p, tkIdent, "expected $name fragment");
+ if !String_StartsWith(fragTok.text, "$") {
+ parserEmitDiag(p, fragTok.line, fragTok.column, "macro fragment must start with '$'");
+ }
+ discard parserExpect(p, tkColon, "expected ':' after fragment name");
+ let kindTok: LexToken = parserExpect(p, tkIdent, "expected fragment kind");
+ var kname: String = kindTok.text;
+ if String_Eq(kname, "lit") { kname = "literal"; }
+ if !(String_Eq(kname, "expr") || String_Eq(kname, "ident") || String_Eq(kname, "tt")
+ || String_Eq(kname, "literal") || String_Eq(kname, "block")) {
+ kname = "expr";
+ }
+ if rule.paramCount < 9 {
+ parserSetMacroFragName(rule, rule.paramCount, fragTok.text);
+ rule.paramCount = rule.paramCount + 1;
+ }
+ if String_Eq(kindsEnc, "") { kindsEnc = kname; }
+ else { kindsEnc = String_Concat(kindsEnc, String_Concat(";", kname)); }
+ while parserCheck(p, tkNewLine) { discard parserAdvance(p); }
+ if parserMatch(p, tkComma) { continue; }
+ if parserMatch(p, tkSemicolon) { continue; }
+ break;
+ }
+ }
+ rule.useNames = kindsEnc;
+ discard parserExpect(p, tkRParen, "expected ')' to close macro pattern");
+ while parserCheck(p, tkNewLine) { discard parserAdvance(p); }
+ discard parserExpect(p, tkFatArrow, "expected '=>' after macro pattern");
+ while parserCheck(p, tkNewLine) { discard parserAdvance(p); }
+ let savedTpl: bool = p.macroTemplateMode;
+ p.macroTemplateMode = true;
+ rule.refBody = parserParseBlock(p);
+ p.macroTemplateMode = savedTpl;
+
+ if firstRule == null as *Decl {
+ firstRule = rule;
+ lastRule = rule;
+ } else {
+ lastRule.childDecl2 = rule;
+ lastRule = rule;
+ }
+ ruleCount = ruleCount + 1;
+ while parserCheck(p, tkNewLine) || parserCheck(p, tkComma) || parserCheck(p, tkSemicolon) {
+ discard parserAdvance(p);
+ }
+ }
+ discard parserExpect(p, tkRBrace, "expected '}' to close macro");
+ d.childDecl1 = firstRule;
+ d.methodCount = ruleCount;
+ if ruleCount == 0 {
+ parserEmitDiag(p, line, col, "macro has no rules");
+ }
+ return d;
+ }
+
// Top-level declaration
// ---------------------------------------------------------------------------
@@ -2207,11 +2489,15 @@ module Parser {
}
let isPublic: bool = parserMatch(p, tkPub);
- // Parse @[Checked] / @[Drop] / @[Release] attribute
+ // Parse stacked @[Checked] / @[Drop] / @[Release] attributes
var isChecked: int = 0;
var isDrop: int = 0;
var isRelease: int = 0;
- if parserCheck(p, tkAt) {
+ while true {
+ while parserCheck(p, tkNewLine) || parserCheck(p, tkSemicolon) {
+ discard parserAdvance(p);
+ }
+ if !parserCheck(p, tkAt) { break; }
discard parserAdvance(p); // @
if parserCheck(p, tkLBracket) {
discard parserAdvance(p); // [
@@ -2232,10 +2518,9 @@ module Parser {
discard parserAdvance(p); // ]
}
}
- // Skip newlines after attribute before the declaration
- while parserCheck(p, tkNewLine) || parserCheck(p, tkSemicolon) {
- discard parserAdvance(p);
- }
+ }
+ while parserCheck(p, tkNewLine) || parserCheck(p, tkSemicolon) {
+ discard parserAdvance(p);
}
let kind: int = parserPeek(p, 0);
@@ -2270,6 +2555,7 @@ module Parser {
if kind == tkImport { return parserParseImportDecl(p, isPublic); }
if kind == tkExtern { return parserParseExternDecl(p, isPublic); }
if kind == tkInterface { return parserParseInterfaceDecl(p, isPublic); }
+ if kind == tkMacro { return parserParseMacroDecl(p, isPublic); }
if kind == tkExtend {
discard parserAdvance(p);
@@ -2396,6 +2682,7 @@ module Parser {
p.tokenCount = tokenCount;
p.pos = 0;
p.structInitAllowed = true;
+ p.macroTemplateMode = false;
let diagBuf: *ParserDiag = bux_alloc(256 as uint * sizeof(ParserDiag)) as *ParserDiag;
p.diags = diagBuf;
p.diagCount = 0;
diff --git a/src/sema.bux b/src/sema.bux
index bf175af..3242b1f 100644
--- a/src/sema.bux
+++ b/src/sema.bux
@@ -1202,16 +1202,34 @@ module Sema {
}
// Block expression (boolValue = true means unsafe block)
+ // Value is the last skExpr (macro! templates and `{ e }` as expr).
+ // Check stmts inside a child scope and return last expr type WITHOUT
+ // re-checking outside that scope (locals must stay visible).
if kind == ekBlock {
if expr.refBlock != null as *Block {
+ let prevChecked: bool = sema.checkedFunc;
if expr.boolValue {
- let prevChecked: bool = sema.checkedFunc;
sema.checkedFunc = false;
- Sema_CheckBlock(sema, expr.refBlock);
- sema.checkedFunc = prevChecked;
- } else {
- Sema_CheckBlock(sema, expr.refBlock);
}
+ var blockScope: Scope = Scope_NewChild(sema.scope);
+ let prevScope: *Scope = sema.scope;
+ sema.scope = &blockScope;
+ var lastType: int = tyVoid;
+ var blkWalk: *Stmt = expr.refBlock.firstStmt;
+ while blkWalk != null as *Stmt {
+ Sema_CheckStmt(sema, blkWalk);
+ if blkWalk.kind == skExpr && blkWalk.child1 != null as *Expr {
+ // CheckStmt already typed the expr; re-read via CheckExpr in-scope
+ lastType = Sema_CheckExpr(sema, blkWalk.child1);
+ if blkWalk.child1.refType != null as *TypeExpr {
+ expr.refType = blkWalk.child1.refType;
+ }
+ }
+ blkWalk = blkWalk.nextStmt;
+ }
+ sema.scope = prevScope;
+ sema.checkedFunc = prevChecked;
+ return lastType;
}
return tyVoid;
}
@@ -1424,6 +1442,8 @@ module Sema {
sym.refType = null as *TypeExpr;
if stmt.refStmtType != null as *TypeExpr {
sym.refType = stmt.refStmtType;
+ // Prefer annotation typeKind (block inits previously left tyVoid)
+ sym.typeKind = Sema_ResolveType(sema, stmt.refStmtType);
if stmt.refStmtType.kind == tekPointer && stmt.refStmtType.pointerPointee != null as *TypeExpr {
sym.typeName = String_Concat(stmt.refStmtType.pointerPointee.typeName, "*");
} else {
@@ -2329,13 +2349,13 @@ module Sema {
s.currentRetType = tyVoid;
}
- // Enable borrow checking for @[Checked] functions
+ // @[Checked] enables borrow checks; @[Release] forces zero-cost (C.4)
let wasChecked: bool = s.checkedFunc;
- s.checkedFunc = decl.isChecked != 0;
let wasRelease: bool = s.releaseFunc;
s.releaseFunc = decl.isRelease != 0;
+ s.checkedFunc = (decl.isChecked != 0) && !s.releaseFunc;
s.movedCount = 0;
- // C.1: lifetime elision before walking the body
+ // C.1: lifetime elision before walking the body (no-op if not checked)
Sema_ApplyLifetimeElision(s, decl);
// Check body statements
diff --git a/src/token.bux b/src/token.bux
index d371751..95c75a0 100644
--- a/src/token.bux
+++ b/src/token.bux
@@ -147,6 +147,10 @@ module Token {
// Lifetime parameter token: 'a, 'b, ... (not a char literal)
const tkLifetime: int = 111;
+ // Declarative macros (session 60 — selfhost parity)
+ const tkMacro: int = 112;
+ const tkDollar: int = 113; // bare $ for $(…)*
+
// ---------------------------------------------------------------------------
// Token struct
// ---------------------------------------------------------------------------
@@ -229,6 +233,7 @@ module Token {
if String_Eq(text, "async") { return tkAsync; }
if String_Eq(text, "await") { return tkAwait; }
if String_Eq(text, "spawn") { return tkSpawn; }
+ if String_Eq(text, "macro") { return tkMacro; }
if String_Eq(text, "true") { return tkBoolLiteral; }
if String_Eq(text, "false") { return tkBoolLiteral; }
return tkIdent;
@@ -318,6 +323,8 @@ module Token {
if kind == tkAmpAmp { return "&&"; }
if kind == tkPipePipe { return "||"; }
if kind == tkBang { return "!"; }
+ if kind == tkMacro { return "macro"; }
+ if kind == tkDollar { return "$"; }
if kind == tkEq { return "=="; }
if kind == tkNe { return "!="; }
if kind == tkLt { return "<"; }
diff --git a/tests/borrow_test.nim b/tests/borrow_test.nim
index e105a26..2d532b1 100644
--- a/tests/borrow_test.nim
+++ b/tests/borrow_test.nim
@@ -370,4 +370,47 @@ func Main() -> int {
return 0;
}
""")
- check(not res.hasErrors)
\ No newline at end of file
+ check(not res.hasErrors)
+
+ test "@[Release] alone disables checks (zero-cost path)":
+ let res = checkSource("""
+@[Release]
+func Dangle() -> &int {
+ var x: int = 1;
+ return &x;
+}
+func Main() -> int {
+ return 0;
+}
+""")
+ check(not res.hasErrors)
+
+ test "@[Checked] @[Release] — Release wins, no use-after-move error":
+ let res = checkSource("""
+@[Checked]
+@[Release]
+func Consume(s: own String) {
+ // move then use — allowed because Release disables checker
+ let t: own String = s;
+ let u: own String = s;
+}
+func Main() -> int {
+ return 0;
+}
+""")
+ check(not res.hasErrors)
+
+ test "@[Checked] still errors without Release":
+ let res = checkSource("""
+@[Checked]
+func Bad() -> &int {
+ var x: int = 1;
+ return &x;
+}
+func Main() -> int {
+ return 0;
+}
+""")
+ check(res.hasErrors)
+ check(res.diagnostics[0].message.contains("local") or
+ res.diagnostics[0].message.contains("reference"))
\ No newline at end of file
diff --git a/tools/lsp_server.nim b/tools/lsp_server.nim
index 4922bd0..62b90f2 100644
--- a/tools/lsp_server.nim
+++ b/tools/lsp_server.nim
@@ -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()
diff --git a/tools/smoke_drop_move.sh b/tools/smoke_drop_move.sh
new file mode 100755
index 0000000..a14e8cc
--- /dev/null
+++ b/tools/smoke_drop_move.sh
@@ -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)"
diff --git a/tools/smoke_lsp_type_hierarchy.sh b/tools/smoke_lsp_type_hierarchy.sh
index 67dceea..6a68109 100755
--- a/tools/smoke_lsp_type_hierarchy.sh
+++ b/tools/smoke_lsp_type_hierarchy.sh
@@ -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
diff --git a/tools/smoke_lsp_type_hierarchy_ws.sh b/tools/smoke_lsp_type_hierarchy_ws.sh
new file mode 100755
index 0000000..7dd7713
--- /dev/null
+++ b/tools/smoke_lsp_type_hierarchy_ws.sh
@@ -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
diff --git a/tools/smoke_selfhost.sh b/tools/smoke_selfhost.sh
index 09c82df..36c64f5 100755
--- a/tools/smoke_selfhost.sh
+++ b/tools/smoke_selfhost.sh
@@ -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)"