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)"