← Commit history

Read the CHANGELOG.md for infos

lennoxrose committed on Oct 7, 2026 at 18:44 UTC

8c09e29parent9b697a8

216 files changed+4596−1682

.gitignoremodified

+6−1
@@ -2,6 +2,10 @@
22 *.o
33 /bin/
44
5+# forgepack-installed dependencies (see ideas/ash_modules.md) -- fetched,
6+# never hand-edited, same spirit as node_modules/
7+@ash-modules/
8+
59 # Compiled benchmark binaries (keep the .cpp sources, ignore the built outputs)
610 ashvm/benchmarks/fib_cpp
711 ashvm/benchmarks/loop_cpp
@@ -24,4 +28,5 @@ skills-lock.json
2428 # Private Notes
2529 goals.md
2630 rundown.md
27-missing.md
\ No newline at end of file
31+missing.md
32+test.ash
\ No newline at end of file

CHANGELOG.mdmodified

+150−0
@@ -1,3 +1,153 @@
1+## 2026-10-07 (three more parity bugs, found verifying the JSON module before handing it off)
2+Before writing `mission.md` (a hand-off doc for shipping the first real `forgepack` module — a JSON codec in pure Ash), actually ran the module's code through both engines rather than just writing it and hoping. Found three more real bugs this way, on top of `type()` above — the module itself was the test case the existing regression suites never exercised (real, non-trivial, deeply-nested-and-recursive user code, not hand-picked feature probes).
3+
4+**Bare reassignment (`x = expr;`, no `local`) didn't exist on `ashvm` at all.** A known, previously-*documented*-but-never-fixed gap from an earlier session (flagged when asked "are both engines exactly the same" — the honest answer was no, citing this exact example) — writing a parser that reassigns a position variable throughout (`i = json_skip_ws(s, i);`) finally made it a hard blocker instead of a noted gap. `identifier_statement()` (`compiler/C/stmt.c`) handled a call, an index-assign, compound-assignment, and `++`/`--` on an already-declared variable, but fell through to "unexpected identifier statement" for a plain `=`. `kiln` already supported this (`assignment_statement()` always reassigns an existing slot). Fixed by adding the missing case: `OP_SET_LOCAL` after a full `expression()`, same as `local_statement()`'s own "already declared" path.
5+
6+**A namespaced call used as its own statement didn't parse on either engine.** `json.save(path, value);` (the entire point of a `save()` function — call it for the side effect, don't use its result) failed on both: `ashvm` with "unexpected identifier statement", `kiln` with "expected '=' after variable name". `module.func(...)` as part of an *expression* (`local x = json.load(...);`) already worked (`expr.c`'s `primary()` has a `TOKEN_DOT` case) — the gap was specifically the bare-statement entry points (`identifier_statement()` on `ashvm`, `assignment_statement()` on `kiln`), which checked for a plain call, an index-assign, etc., but never checked for `TOKEN_DOT` before falling through. Fixed by mirroring the exact dotted-call logic from each engine's own expression parser, with a trailing `OP_POP`/pop-pop to discard the unused return value, matching how a bare non-namespaced call statement already does.
7+
8+**Import compiler rejected any `local` inside an imported function's own loop.** `try_declare_import_constant()` (the mechanism that lets a top-level `local NAME = <literal>;` in an imported file become a namespaced compile-time constant, e.g. `mathlib.PI`) was called unconditionally for *every* `local` statement anywhere in an imported file's compiled body — including deep inside one of that file's own functions, since the "currently compiling an imported file" flag is set once for the whole file, not scoped by block depth. Every `local` inside `json_skip_ws`'s loop (`local c = substring(s, i, i + 1);`) got misdiagnosed as a failed attempt at a top-level namespaced constant ("imported top-level 'local' must be a literal number or string constant") even though it's three blocks deep, nowhere near the file's top level. The sibling guard in `statement()` already correctly checks `block_depth == 0` for the analogous "only forge/local allowed at top level" restriction — this specific call site just never got the same check. One-line fix, mirrored on both engines.
9+
10+All three fixes verified together: the full JSON module (parse/stringify/load/save, nested arrays-of-objects-of-arrays, malformed-input error handling via `attempt`/`handle`) now runs byte-for-byte identical on both engines, including a fourth bug that was in the *module's own code*, not the engines — `json_parse_string`'s loop had no end-of-input check (`during yes` with only "found a closing quote" as the exit condition), so an unterminated string in malformed input scanned forever: OOM-killed on `ashvm`, segfaulted on `kiln` (its bump allocator has no bounds check once the arena's exhausted). Fixed in the module, not the engines — bounded the loop on `i < len(s)` and `raise` on falling through.
11+
12+## 2026-10-07 (`type()` builtin, both engines)
13+A genuine gap found while designing the first real `forgepack` module (a JSON library): there was no way for pure Ash code to ask a value what it *is*. Every existing builtin either requires a specific type already (`keys()`/`has()` error on a non-map, `upper()` on a non-string, ...) or works the same way regardless of type (`len()`). A general `json.stringify(value)` has to decide "format this as an array vs. a map vs. a string vs. a number" from an arbitrary value with no static type — there's no way to do that without some form of runtime introspection.
14+
15+Added `type(value)` → `"number"/"string"/"array"/"map"/"function"/"none"` to both engines. `ashvm`: builtin id 27 (`builtins/C/core.c`, `builtins/C/builtins.c`), a straight switch on `VMValue.type`. `kiln`: a new module (`codegen/C/builtins/type_builtin.c`) — pop the value's tag, branch, and push one of six pre-embedded string literals via the already-existing `codegen_string_literal_bytes()` (the exact same length-prefix-embedding helper regular string literals use, so the result is a completely ordinary string value, not a special case). `ashvm`'s own print path distinguishes `"<function>"` vs. `"<closure>"` (see the array/map-printing fix further down this file), but `kiln` has no separate closure tag at all to match that with — `type()` collapses both to `"function"` on `ashvm` too, so the two engines agree on every possible result, rather than exposing a distinction only one side can make.
16+
17+## 2026-10-07 (`forgepack` — a package manager, GitHub-only for now)
18+New third project, `forgepack/`, following the same `C/H/D/O` + `-MMD` conventions as `ashvm`/`kiln`. Design doc first (`ideas/ash_modules.md`, modeled loosely on npm) before any code, per the usual rule for a new subsystem this size. Scope deliberately narrowed during the build: no registry/index yet (the domain for that is reserved, but a static index is a later phase, not now), no `path:`/plain-version dependencies yet either — `github:user/repo[@ref]` is the only dependency kind forgepack can actually install without a server, so that's the only one it accepts right now rather than recording something it can't resolve.
19+
20+**Commands:** `forgepack init` (writes `ash.pkg`), `add github:user/repo[@ref] [--as <name>]` (records the dependency and installs it), `install` (populates `@ash-modules/` from `ash.pkg` for a fresh checkout), `list`. `ash.pkg` is a plain key-value text file (`name:`/`version:`/`[dependencies]`), not JSON — no need to pull in a JSON parser for a package manager in a project whose whole premise is "no external dependencies." Dependencies land flat in `@ash-modules/<name>/`, gitignored like `node_modules` — `forgepack/src/C/git_fetch.c` shells out to the system `git` binary (validates `user`/`repo`/`ref` against a strict alnum/`-`/`_`/`.` charset *before* ever building a shell command string, since a dependency spec is attacker-controlled the moment it comes from someone else's published `ash.pkg`).
21+
22+**Wired into `@import` on both engines, not just the new tool.** A bare `@import <name>;` already resolved to `ash.libs/<name>/<name>.ash` (`import_paths.c`, identical in `ashvm` and `kiln`); it now falls back to `@ash-modules/<name>/<name>.ash` when `ash.libs/` doesn't have it, so a `forgepack`-installed dependency is actually `@import`-able, not just downloaded. `ash.libs/` still wins first — a project can always shadow an installed package name with its own local copy. No new import syntax, no change to the relative-path (`./...`) branch at all.
23+
24+**CLI output uses real Unicode symbols (✓/✗/→/•, not emoji) and ANSI color** (`forgepack/src/C/ui.c`), auto-disabled when stdout/stderr isn't a TTY or `NO_COLOR` is set — confirmed piped output (`| cat -v`) carries the plain symbol with zero escape codes, only an interactive terminal gets color.
25+
26+## 2026-10-07 (four cross-engine parity bugs, found by a side-by-side audit)
27+Ran the same `.ash` source files through both engines and diffed stdout byte-for-byte, which the previous entry's feature-parity work made possible for the first time (no point diffing output that couldn't even be compared before). Found four real bugs — three in `kiln`, one `ashvm`-only — fixed all four, then re-ran the audit to confirm convergence. The only remaining difference across every test file is map iteration order (`keys(m)`/`values(m)`), which is a documented, pre-existing architectural difference (`ashvm`'s real hash table vs. kiln's dense insertion-ordered array, see `codegen/H/emit/value.h`) and not a bug — neither engine's map iteration order was ever a language guarantee.
28+
29+**`kiln`: missing `%` operator.** `TOKEN_PERCENT` was lexed but `term()` in `codegen/C/expressions/expr.c` only handled `*`/`/` — every `kiln` test needing modulo worked around it with `x - floor(x / n) * n`, which is a *different* operation for negative operands (floor-based mod vs. truncate-based `fmod`). Implemented as `a - trunc(a / b) * b` using the SSE2 primitives `floor()`'s codegen already established the pattern for (`emit_cvttsd2si` truncates toward zero directly, no separate correction step needed the way `floor()`'s overshoot-fix does) — matches `ashvm`'s C `fmod()` exactly, including sign: `-7 % 3 == -1`, not `2`. Verified identical output on both engines across positive, negative, and fractional operands.
30+
31+**`kiln`: `say`-ing an array or map directly crashed or printed garbage.** `codegen/C/runtime/print_int.c`'s single print routine only ever had cases for `TAG_STRING` and `TAG_NIL`; anything else (including `TAG_ARRAY`/`TAG_MAP`, and, it turns out, `TAG_FUNCTION`) silently fell into the number-formatting path and tried to print a heap pointer as a double. This is why `kiln`'s own test suite never caught it: every test that uses `map()`/`filter()`/`keys()`/etc. immediately indexes into the result rather than printing it directly. Fixed with a new recursive printer (`emit_print_recursive_routine`, self-`call`s for nested arrays/maps, quotes string elements the way `ashvm`'s `vm_print_value()` does, falls back to `<function>` for anything else) plus a `number_routine` extracted out of the old inline digit-building code so both the recursive printer and the top-level routine can share it without duplicating ~90 lines of SSE2 asm. Wrote and threw away three register-clobbering bugs getting this right — hand-rolled x86-64 means `write_one_char()` (every bracket, comma, quote) clobbers RAX/RCX/RDX/RSI as a side effect of its own `write(2)`/`WriteFile` call, so anything that needs to survive across one has to live in memory (this routine's own `push rbp`-anchored frame, `[RBP-8/-16/-24/-32]`, *not* `[RSP+disp]`, which this codegen layer can't even encode — `RSP` as a base needs a SIB byte `modrm_mem_disp32()` doesn't emit), never in a register held "across" the call. Found via `gdb` on the raw statically-linked ELF (no section headers, no symbols — breakpoints by address, registers read at the exact instruction before/after a suspect load) after the first version printed `[1, ]` for `[1, 2]` instead of `[1, 2]`: traced to the loop's "index + 1, maybe print a comma, then store the new index" sequence holding the incremented index in `RCX` across the two `write_one_char(',')`/`write_one_char(' ')` calls that immediately clobber it.
32+
33+**`kiln`: `len()` on a string read garbage.** `codegen_builtin_len()` in `codegen/C/collections/arrays.c` unconditionally read offset `+8` as "the count" — correct for `TAG_ARRAY`/`TAG_MAP` (they share the same `[capacity][count][data/entries_ptr]` object shape), but a string's length lives at offset `-8` from its payload (the length prefix that precedes the raw bytes), not `+8`. `len("hello")` was reading whatever garbage byte happened to sit 8 bytes into the string's own content. Fixed by branching on `TAG_STRING` first.
34+
35+**`ashvm`: a fresh `local` declared inside a re-executed block (a `during`/`each` body, or a `given`/`otherwise` branch nested inside one) leaked a stack slot every pass instead of being replaced.** `ashvm` has no explicit "store a local" instruction — declaring one just means "whatever's on top of the stack now has this name," at a compile-time-fixed slot index (`frame->slots[n]`). `each`'s own three setup slots (array/index/var) already got cleaned up once, after the whole loop exits — but nothing cleaned up locals the *body* itself declared, every single iteration, and `given`/`during` didn't clean up after themselves at all. First iteration of `local x = i * 10; say x;` inside a `during` loop pushes `x` correctly; every iteration after that pushes *another* copy on top without popping the old one, while the compiled `OP_GET_LOCAL` for `x` keeps reading the same fixed slot index — the first iteration's value, frozen forever. `kiln` doesn't have this bug (confirmed directly, contradicting an assumption in the previous entry that this was some shared/inherited limitation): its locals are fixed `rbp`-relative memory addresses, not stack positions, so re-declaring one just overwrites the same address.
36+
37+Fixed with a new VM instruction, `OP_TRUNC_LOCALS n` (`stack_top = frame->slots + n`) — an *absolute* reset, not a fixed number of `OP_POP`s, because a block can exit early (`next`, or one branch of a nested `given`) having declared fewer locals than its "normal" path does, so only the known target depth is ever safe to assume. `compile_given_stmt`/`compile_during_stmt`/`compile_each_stmt` each save `local_count` before their re-executed block, then call a shared `truncate_block_locals()` right where `next` already lands (before the per-iteration advance, so both the normal fallthrough and an early `next` converge on the same cleanup) — compiled to nothing extra when a block doesn't declare any locals of its own, so the common case (most loop bodies) costs zero bytes and zero instructions beyond what was already there. Verified against `fib.ash`/`loop.ash` (unaffected — this machine's own baseline for both, checked against the pre-session commit with a throwaway `git worktree`, already sits well above `AGENTS.md`'s reference numbers, which are from different hardware) and against deliberately adversarial cases (nested `given`/`next` inside `during`, nested `each` inside `each`) diffed byte-for-byte against `kiln`.
38+
39+## 2026-10-07 (bring `ashvm` to the same feature level as `kiln`)
40+The reverse direction of the earlier kiln-parity work: `kiln` had six capabilities `ashvm` didn't (`stop`/`next` loop control, `raise` with a runtime string message, compound assignment, `++`/`--`, a ternary, and bitwise operators), plus `yes`/`no`/`none` literals from the same-day rename work. All ported, staying entirely within `ashvm`'s own execution model (interpreted bytecode VM, not compiled to native code like kiln) — this is language-feature parity, not architecture parity.
41+
42+**`none` (nil) — the one real new concept, not just a port.** `ashvm`'s value representation had no null type at all (`VMType` was `{VM_NUM, VM_STR, VM_ARRAY, VM_MAP, VM_FUNCTION, VM_CLOSURE}`). Added `VM_NIL` and `vm_nil()` to `value/H/value.h`+`value/C/value.c`, then threaded it through every place a value's type is inspected: `truthy()` in `vm/C/errors.c` now treats `none` as always-falsy without erroring (matching kiln's "nil's payload is always exactly 0" rule) instead of the default "non-number errors" path every other truthiness check still takes; `do_EQ`/`do_NEQ`/`do_CMP_JUMP`'s equality branch in `vm/C/dispatch.c` gained a `VM_NIL == VM_NIL` case (including the fused-comparison path, which can reach it since `x == y` where both are locals holding `none` at runtime is a fusable `identifier CMP identifier` shape even though `x == none` itself never fuses — `none` is a keyword, not a number or identifier); `vm_print_value()` prints `none`. No existing builtin needed a change — `len()`, `str()`, etc. already reject unexpected types generically, so `none` just became one more value their existing type checks correctly refuse.
43+
44+**`stop`/`next`** — a new `compiler/C/loop_stack.c`+`H/loop_stack.h` module (mirrors kiln's `parser/C/statements/loop_stack.c`), forward-jump bookkeeping per nesting level, no new VM instructions (reuses the existing `OP_JUMP`). Wired into `compile_during_stmt`/`compile_each_stmt` (`loop_push()` before the body, `loop_patch_nexts()` right before the per-iteration advance step, `loop_pop_and_patch_stops()` after) and `compile_forge_decl`/`lambda_literal` (save/reset/restore the loop-nesting depth around a function/lambda body, so `stop`/`next` inside one can't reach an enclosing loop it's textually written inside — ashvm's functions get their own `Chunk`, so a cross-chunk jump offset would be nonsense bytecode anyway; this turns that into a clean compile error instead). One genuine ordering subtlety for `each`: `stop` jumps and the loop's own condition-false exit both have to land at the *exact* same bytecode position (right before the each-loop's three cleanup `OP_POP`s, not after) so both paths fall through into the same cleanup — tested explicitly with nested `each` loops to confirm `stop` only breaks the innermost one and the stack stays balanced either way.
45+
46+**`raise`** — needed a new opcode (`OP_RAISE`) and a new VM entry point (`vm_raise_value()` in `vm/C/errors.c`), since every existing runtime error goes through a fixed-size `char error_message[512]` buffer (fine for compile-time format strings, not for an arbitrary-length runtime string value). Added `raised_value`/`has_raised_value` globals (`vm/H/state.h`) that `vm_raise_value()` sets right before unwinding and `do_TRY_PUSH`'s recovery branch checks first, falling back to the `error_message` copy path for every other kind of error — so a caught `raise` hands back the exact raised string, not a truncated-at-512-bytes copy.
47+
48+**Compound assignment / `++`/`--`** — deliberately *not* using the existing `OP_ACC_LOCAL` fused op (that only accepts a number-or-local operand, not a general expression — `x += f() + 1;` needs the full `GET_LOCAL, expression(), <op>, SET_LOCAL` path). `+=` reuses `OP_ADD` directly (same tag-aware string-concat-or-numeric-add logic `+` itself has); `-= *= /=` are number-only, same as `- * /` themselves. Unlike `ashvm`'s existing `local x = expr;` (which doubles as declare-or-reassign), these require the variable to already exist — `resolve_local_or_error()` added for the clean "undefined variable" diagnostic.
49+
50+**Ternary and bitwise** — ternary sits at the bottom of `expr.c`'s precedence chain (above `logical_or`, matching kiln's placement), same jump-patch shape `given` already uses. Bitwise is one flat precedence level between `comparison` and `logical_and` (not real C precedence — a deliberate simplification kiln made first, carried over for consistency). Bitwise genuinely needed six new opcodes (`OP_BAND/BOR/BXOR/BNOT/SHL/SHR`) — real VM additions, but zero per-existing-instruction cost: computed-goto dispatch is a jump through a pointer array indexed by opcode byte, so adding table entries doesn't slow down dispatching to any *other* opcode. Confirmed by benchmarks below, not just asserted.
51+
52+**A real, separate bug found and fixed along the way, not part of the language work:** `ashvm`'s Makefile had no `-MMD` header-dependency tracking (`AGENTS.md` explicitly documented this as a known fact: "`ashvm` has no `D/` tree"). Adding six new tokens to `lexer.h` shifted every subsequent `TokenType` enum value by 6 — `lexer.c` got recompiled (it was edited directly), but `stmt_import.c` (which only includes `lexer.h` transitively and wasn't itself touched) didn't, so it kept checking `TOKEN_LESS` against its *old* numeric value while the lexer now emitted the *new* one. Every `@import` in the regression suite broke with a confusing "expected '<' after '@import'" pointing straight at a literal `<` character. Root-caused by writing a tiny standalone program linked against just the lexer object file to print raw token type numbers, confirming the lexer was correct and the mismatch was elsewhere — then recognizing the "its Makefile makes no dependency files" line in `AGENTS.md` as the actual cause. Fixed by porting kiln's exact `-MMD -MP` Makefile pattern to `ashvm` (kiln's own Makefile comment already named this exact failure mode as the reason it has the pattern at all) — new `D/` trees added (seven subsystems, matching the existing `O/` trees), `AGENTS.md` and `README.md` updated since both explicitly documented "ashvm has no D/" as fact. Verified the fix actually works, not just that the symptom went away: touching `lexer.h` with nothing else changed now correctly recompiles all 10 dependent files, confirmed by counting recompiled files before and after.
53+
54+Added regression tests: `tests/cases/basics/literals_test.ash` (`yes`/`no`/`none`, truthiness, equality, containers), `tests/cases/basics/operators_test.ash` (compound assignment, `++`/`--`, ternary, bitwise), `tests/cases/control_flow/loop_control_test.ash` (`stop`/`next`, including nested loops), `tests/cases/errors/raise_test.ash` (`raise`/`attempt`/`handle` with dynamic messages). `basics/` is a new feature folder — one of `AGENTS.md`'s own suggested names, previously unused in `ashvm`.
55+
56+Verified: zero-warning clean rebuild, full regression suite byte-identical to a re-captured baseline, all five benchmarks within normal noise of their pre-this-round timings (fib ~0.148s, loop ~1.10s, countdown ~1.06s, loop_varbound ~1.10s, hashmap ~0.059s — no regression, as expected since none of the new opcodes touch any existing benchmark's hot path), and a hand-written smoke test exercising every new construct together, including nested `each` loops with `stop`/`next` and `none` stored in arrays/maps. `README.md`'s example and feature list updated; `AGENTS.md` corrected for the `D/` tree addition.
57+
58+## 2026-10-07 (rename kiln's own six keywords: `throw`->`raise`, `break`->`stop`, `continue`->`next`, `true`->`yes`, `false`->`no`, `nil`->`none`)
59+These six have no `ashvm` equivalent at all (`ashvm` doesn't have exception-throwing, loop control, or boolean/nil literals), so this isn't parity work — just carrying the same "Ash's own plainer words, not borrowed syntax" idea kiln already got from the parity round into kiln's own extra vocabulary. Same mechanics as every rename so far: `TOKEN_THROW`/`TOKEN_BREAK`/`TOKEN_CONTINUE`/`TOKEN_TRUE`/`TOKEN_FALSE`/`TOKEN_NIL` became `TOKEN_RAISE`/`TOKEN_STOP`/`TOKEN_NEXT`/`TOKEN_YES`/`TOKEN_NO`/`TOKEN_NONE`, `throw_statement`/`break_statement`/`continue_statement` became `raise_statement`/`stop_statement`/`next_statement`.
60+
61+Went further than previous rounds on internal consistency: `loop_stack.c`/`.h`'s `loop_record_break`/`loop_record_continue`/`loop_patch_continues`/`loop_pop_and_patch_breaks` (and their `break_count`/`continue_count` struct fields) became `loop_record_stop`/`loop_record_next`/`loop_patch_nexts`/`loop_pop_and_patch_stops`/`stop_count`/`next_count`. Unlike `local_count` (a genuinely generic "local variable" term, kept through the `let`->`local` rename), these names directly mirrored the old keyword 1:1 with nothing more generic backing them, so renaming them was the more consistent call. Also fixed two real user-facing error message strings in `loop_stack.c` that a plain token-reference grep had missed in earlier rounds (`"'break' outside a loop"` etc. — these only showed up because this round's sweep specifically grepped for the bare words, not just `TOKEN_*` references) and a stale `` `throw expr;` `` example in a comment in `errors.h`.
62+
63+**One real behavior fix, not just a rename:** the runtime's own printed representation of a nil-tagged value (`codegen/C/runtime/print_int.c`, hand-emitting the raw bytes `'n','i','l','\n'` plus a hardcoded length-4 write syscall) still said "nil" even after `nil` became `none` — unlike the `vm/C/dispatch.c` "too many nested try blocks" precedent from the `ashvm` renames, this one IS genuinely user-facing (`say none;` printing `nil` back would be actively confusing, not just an internal inconsistency), so it was updated to emit `"none\n"` with the write length corrected to 5. Caught by actually running `say none;` after the rename and noticing the mismatch, not by code review alone.
64+
65+While sweeping for leftover references, also found and fixed several **stale references from the *previous* `try`->`attempt`/`catch`->`handle` round** that earlier grepping (focused on `TOKEN_TRY`/`TOKEN_CATCH` specifically) had missed: comments in `elf/H/elf_writer.h`, `codegen/H/emit/runtime_layout.h`, `codegen/H/emit/emit.h`, `parser/H/declarations/vars.h`, and `parser/C/core/parser.c` still said "try"/"catch" where they meant "attempt"/"handle". A reminder that grepping for the token constant alone isn't enough — bare-word greps catch what token-reference greps miss, and are worth doing as a final pass after any rename.
66+
67+Verified: zero-warning rebuild, full regression suite re-baselined (one legitimate diff from the nil->none print fix, everything else byte-identical) and now clean, a hand-written smoke test exercising `stop`/`next` inside a `during` loop together with `yes`/`no`/`none`/`and`/`not`/`attempt`/`handle`/`raise`, migrated all affected test files (with the same care as before for string literals that happened to contain the old keyword words as their actual printed content — `ternary.ash`'s `"a is true"`/`"a is false"` and `true_false_nil.ash`'s `"falsy nil"` were preserved exactly, not touched by the rename).
68+
69+## 2026-10-07 (bring `kiln` to the same syntax as `ashvm`)
70+`kiln` has its own, completely separate lexer and parser (it compiles straight to native ELF/PE, no bytecode VM), so none of today's `ashvm` syntax work reached it for free. This brings it up to parity: all ten keyword renames (`let`->`local`, `fn`->`forge`, `try`->`attempt`, `print`->`say`, `if`->`given`, `else`->`otherwise`, `while`->`during`, `return`->`yield`, `catch`->`handle`, `for`->`each`), optional parens on `given`/`during` conditions, and `&&`/`||`/`!` -> `and`/`or`/`not`. Kiln-only features with no `ashvm` equivalent (`break`/`continue`/`throw`, compound assignment `+= -= *= /=`, `++`/`--`, the `?:` ternary, bitwise `& | ^ ~ << >>`, `true`/`false`/`nil` literals) are untouched — porting syntax parity doesn't mean deleting kiln's own extra capabilities, and none of them collide with the renames (bitwise `&`/`|` keep their own token types, distinct from the `&&`/`||` that became `and`/`or`).
71+
72+**The `each (x in array)` loop already existed in kiln**, fully working, with its own dedicated per-nesting-depth stack slots and proper block scoping (`vars_scope_begin`/`vars_scope_end`) — architecturally sounder than `ashvm`'s version even needed to be. It just needed the `for`->`each` rename (plus making its own parens optional, for consistency with `given`/`during`) and renaming everything that referenced the old name: the file itself (`for_loop.c`/`.h` -> `each_loop.c`/`.h`, updated in the Makefile), `for_statement`->`each_statement`, `for_depth_save/reset/restore`->`each_depth_*`, `MAX_KILN_FOR_DEPTH`/`KILN_FOR_LEVEL_SIZE`/`KILN_FOR_STACK_SIZE`->`*_EACH_*`, `for_level_offset`->`each_level_offset`. Likewise `try_catch.c` -> `attempt_handle.c`, `try_statement`->`attempt_statement`, and the internal jump-bookkeeping variables renamed for consistency (`skip_catch`->`skip_handle`, etc.) — mirroring the pattern already used for `ashvm`'s own `try_jump`->`attempt_jump` rename earlier today. `if_statement`/`while_statement`/`fn_statement` similarly became `given_statement`/`during_statement`/`forge_statement`. VM/opcode-equivalent runtime mechanism names were left alone, same principle as `ashvm`: `kiln_try_depth_addr()`, `kiln_try_handlers_addr()`, `MAX_KILN_TRY_DEPTH` (the try/catch handler stack is a genuine runtime data structure, unlike each's compile-time-known nesting, so it still needs runtime tracking regardless of what the surface keyword is called) are unrenamed, and the runtime-emitted fatal message "too many nested try blocks" was deliberately left saying "try" — matching `ashvm`'s own equivalent deliberate inconsistency in `vm/C/dispatch.c`, for the same "same status as ashvm" reason.
73+
74+Migrated all 49 of kiln's test `.ash` files (`tests/cases/**`, including 11 nested import fixtures the top-level glob doesn't directly run but the import tests exercise indirectly). Caught and fixed a real mistake from the blind word-boundary `sed`: three string literals that happened to contain the words "print"/"else" as their actual printed content (demonstrating which branch ran) got their content silently rewritten by the naive rename pass (`sed` has no concept of "inside a string literal") — caught by a regression diff for one of them, and by explicitly grepping every string literal for the new keyword words for the other two, which the diff hadn't caught (they sat in branches that never execute, so the corruption never surfaced in any program's actual output). All three restored to their original string content.
75+
76+Verified properly this time: unlike letting the working tree drift, a true pre-rename baseline was captured by `git stash`-ing every uncommitted change (including today's earlier `ashvm` work, repo-wide — `git stash` isn't directory-scoped), rebuilding the untouched original `kiln`, running its regression suite, then `git stash pop`-ing everything back before migrating. Final result: zero-warning rebuild, full regression suite byte-identical to that true baseline, and a hand-written smoke test exercising every renamed construct together (nested nesting between `given`/`during`/`each`/`attempt`/`handle`, `and`/`or`/`not`, optional parens both ways) compiled and ran correctly.
77+
78+**One real mistake from the `git stash` round-trip:** `deep_nesting_demo.ash` (the untracked demo file made earlier today) didn't survive — not because `git stash` touches untracked files (it doesn't, by default), but it was gone by the time of the `stash pop` regardless, cause not fully root-caused. Recreated from the conversation's own record of its contents, verified to produce byte-identical output to before.
79+
80+## 2026-10-07 (add the `each (x in array)` loop)
81+`in` turned out to be a second dead keyword sitting right next to `for` (lexed, never consumed by any parse rule) — so, having just renamed the first one, this built the actual feature `README.md`'s language-features list had been advertising as `for (x in array)` all along: `each (x in array) { ... }` (parens optional, same as `given`/`during`).
82+
83+**Implementation:** `compile_each_stmt()` in `stmt_control.c` hand-emits the exact bytecode shape a counting `during`-loop would produce — `len()` via the existing builtin-call opcode, `OP_INDEX_GET`, and the existing `OP_ACC_LOCAL` fused op for the index bump — rather than synthesizing source text and reparsing it. No new VM instructions, so the performance rule that held for the whole renaming phase holds for this too, even though it's a genuinely new feature, not a rename. Array-only, matching `map()`/`filter()`/`reduce()`'s own existing scope; iterate a map via `each (k in keys(m))`.
84+
85+**A real bug, caught before shipping:** the first version restored the compiler's `local_count` after the loop without emitting the matching runtime `OP_POP`s, so the three setup slots (array, index, loop variable) stayed physically on the VM stack. A second `each` right after a first one would then compile its slots to the same reused indices while the runtime stack was still three deeper — every `GET_LOCAL`/`SET_LOCAL` read the wrong position, and the loop silently ran zero iterations. A single isolated `each` looked completely correct; only testing two in a row caught it. Fixed with three explicit `OP_POP`s, the same fix shape `compile_attempt_stmt()` already uses for its own one handle-variable slot.
86+
87+**A pre-existing limitation this surfaced, not introduced here:** declaring a genuinely new `local` inside *any* loop body (`given`, `during`, or `each`) corrupts output from the second iteration on, since none of them truncate the runtime stack per iteration. Confirmed with a plain `during` loop first. Not fixed — flagged in `plans.md`'s open questions, since every loop in this codebase already uses the safe workaround (declare outside, reassign inside).
88+
89+Added a regression test, `ashvm/tests/cases/control_flow/each_test.ash` (new feature folder — `control_flow` is one of `AGENTS.md`'s own suggested names, previously unused): array iteration parenthesized and not, a map via `keys()`, an accumulator, an empty array, and nested `each` using the correct accumulate-outside idiom. Verified: zero-warning rebuild, full regression suite (including the new test) byte-identical to a re-captured baseline, all five benchmarks within normal noise (nothing in any hot path changed, since no benchmark uses `each`), `deep_nesting_demo.ash` unchanged, `kiln` untouched. `README.md`'s example and feature list updated — the feature list's `each (x in array)` line is no longer a doc/reality mismatch.
90+
91+## 2026-10-07 (rename `for` to `each`)
92+Tenth and last keyword rename of the day, and the cheapest one: `for` is now `each`. Unlike every other rename today, this one changes nothing observable — `for`/`TOKEN_FOR` (now `each`/`TOKEN_EACH`) is a dead keyword. It's lexed, but no parser rule in `stmt.c`/`stmt_control.c` ever consumes it; there is no for-loop statement in Ash today, despite `README.md`'s feature list claiming `for (x in array)` (now corrected to read `each (x in array)`, still just as unimplemented as before — a pre-existing doc/reality mismatch flagged back when this was first noticed, not something fixed today). Renamed anyway, purely for consistency: it costs one lexer keyword-length check and touches no parser logic, so there was no reason to leave the one remaining C-flavored keyword sitting in an otherwise fully-renamed set.
93+
94+No `.ash` file in the repo uses `for` (confirmed by grep before touching anything), so there was nothing to migrate except the one README mention. Verified: zero-warning rebuild, full regression suite byte-identical to baseline (expected — nothing that actually runs changed).
95+
96+All ten renames from today, for the record: `let`->`local`, `fn`->`forge`, `try`->`attempt`, `print`->`say`, `if`->`given`, `else`->`otherwise`, `while`->`during`, `return`->`yield`, `catch`->`handle`, `for`->`each`. The only Ash keyword never touched today is `import` (the `@import` sigil form) — and `in`, which like `for`/`each` is lexed but still unused by any parser rule.
97+
98+## 2026-10-07 (rename `while` to `during`, `return` to `yield`, `catch` to `handle`)
99+Three more keyword renames in quick succession, same mechanics as every rename today: `while`->`during`, `return`->`yield`, `catch`->`handle`. `TOKEN_WHILE`/`compile_while_stmt()` became `TOKEN_DURING`/`compile_during_stmt()`; `TOKEN_RETURN` became `TOKEN_YIELD` (the `OP_RETURN` opcode it still emits is unrenamed — opcode names are independent of surface keyword, same reasoning as every other opcode left alone); `TOKEN_CATCH` became `TOKEN_HANDLE`, with the compiler's internal `skip_catch` jump-target variable renamed to `skip_handle` for consistency with the earlier `try_jump`/`else_jump` renames.
100+
101+`emit_loop`/`loop_start` (used by the during-loop's backward jump) were deliberately left unrenamed — "loop" is the general mechanism's name, independent of whichever keyword (`while` or now `during`) introduces one, same reasoning that kept `fn_idx` and `local_count` alone through earlier renames.
102+
103+Migrated every existing `.ash` file through all three renames at once: `tests/cases/**` (including nested `fixtures/`/`ash.libs/`), `benchmarks/*.ash`, `README.md` (code example and the "Language features"/performance-note mentions), and `deep_nesting_demo.ash`. Verified the usual way: zero-warning rebuild, full regression suite byte-identical to baseline, all five benchmarks within a few percent of their pre-rename timings, demo file's output unchanged.
104+
105+Nine keywords renamed today, running tally: `let`->`local`, `fn`->`forge`, `try`->`attempt`, `print`->`say`, `if`->`given`, `else`->`otherwise`, `while`->`during`, `return`->`yield`, `catch`->`handle`.
106+
107+## 2026-10-07 (rename `else` to `otherwise`)
108+Sixth keyword rename of the day: `else` is now `otherwise` — `given x < n { ... } otherwise { ... }`. `TOKEN_ELSE` became `TOKEN_OTHERWISE`; the compiler's internal `else_jump` variable (the bytecode jump target patched to the otherwise-branch) was renamed to `otherwise_jump` for consistency with the earlier `try_jump`->`attempt_jump` rename. The C-language `else` keywords sprinkled through the compiler's own implementation (e.g. `if (...) { ... } else { ... }` in `stmt.c`/`stmt_control.c`) are untouched, obviously — those are C, not Ash, and renaming Ash's `else` has nothing to do with them.
109+
110+Migrated every existing `.ash` file again: `tests/cases/**` (including nested `fixtures/`/`ash.libs/`), `benchmarks/*.ash`, and `deep_nesting_demo.ash` (which uses `otherwise` extensively — five branches). `README.md`'s code example doesn't have an else-branch, so nothing there needed changing this time. Verified the same way as every rename this round: zero-warning rebuild, full regression suite byte-identical to baseline, all five benchmarks within a few percent of their pre-rename timings, demo file's output unchanged.
111+
112+Six keywords renamed today, all told: `let`->`local`, `fn`->`forge`, `try`->`attempt`, `print`->`say`, `if`->`given`, `else`->`otherwise`. Unchanged: `catch`, `while`, `return`.
113+
114+## 2026-10-07 (rename `if` to `given`)
115+Fifth keyword rename of the day, same shape as the previous four: `if` is now `given` (`else` is unchanged — `given x < n { ... } else { ... }`). `TOKEN_IF`/`compile_if_stmt()` became `TOKEN_GIVEN`/`compile_given_stmt()`. `try_fuse_condition()` wasn't touched by this rename at all — it already took its terminator token as a parameter (from the earlier optional-parens work) and has no idea which keyword called it, so renaming the caller is a pure caller-side change.
116+
117+Migrated every existing `.ash` file again: `tests/cases/**` (including nested `fixtures/`/`ash.libs/`), `benchmarks/*.ash`, `README.md` (code example only this time — the "Language features" list doesn't mention `if` by name), and `deep_nesting_demo.ash`. Verified the same way as every rename this round: zero-warning rebuild, full regression suite byte-identical to baseline, all five benchmarks within a few percent of their pre-rename timings, demo file's output unchanged.
118+
119+With this, five of Ash's keywords now read differently from where the day started: `let`->`local`, `fn`->`forge`, `try`->`attempt`, `print`->`say`, `if`->`given`. `catch`, `else`, `while`, and `return` are unchanged.
120+
121+## 2026-10-07 (rename `try` to `attempt`, `print` to `say`)
122+Two more keyword renames, same shape as the previous two: `try` is now `attempt` (`catch` is unchanged — `attempt { ... } catch (e) { ... }`), and `print` is now `say`. `TOKEN_TRY`/`compile_try_stmt()` became `TOKEN_ATTEMPT`/`compile_attempt_stmt()` (its local `try_jump` variable renamed to `attempt_jump` too, and its comments describing "the try block" now say "the attempt block"); `TOKEN_PRINT` became `TOKEN_SAY`. No behavior change — same grammar, same bytecode (`OP_PRINT`, `OP_TRY_PUSH`/`OP_TRY_POP` in the VM are unrenamed, since they're opcode names independent of what keyword triggers them, same as `fn_idx` was left alone during the `fn`->`forge` rename).
123+
124+One deliberate loose end, left alone on purpose rather than silently fixed: `vm/C/dispatch.c`'s runtime error string "too many nested try blocks" still says "try". This phase's performance rules are explicit that work stays confined to the lexer and compiler — `vm/` doesn't get touched even for a one-line string, to avoid setting a precedent of "just this once." Noted in `plans.md`.
125+
126+Migrated again, same files as every rename this round: `tests/cases/**` (including nested `fixtures/`/`ash.libs/`), `benchmarks/*.ash`, `README.md` (both the code example and the "Language features" bullet list), and `deep_nesting_demo.ash`. Verified the same way: zero-warning rebuild, full regression suite byte-identical to baseline, all five benchmarks within a few percent of their pre-rename timings, demo file's output unchanged.
127+
128+## 2026-10-07 (rename `fn` to `forge`)
129+Second rename follow-up: `fn` is now spelled `forge` — Ash's own word, not borrowed from Lua or anywhere else (chosen over the more generic `function`/`func`/`def` specifically to fit the fire/embers theme the project is named after). Same shape as the `let`->`local` rename: `TOKEN_FN`/`compile_fn_decl()` became `TOKEN_FORGE`/`compile_forge_decl()`, every user-facing message updated, no behavior change (`forge` still declares both named functions and anonymous lambdas, exactly as `fn` did). Internal `fn_idx`/`find_function`-style identifiers were left alone — they mean "function index"/"find function", a naming coincidence unrelated to which keyword spells the declaration.
130+
131+`fn` was removed outright (no alias kept), so every existing `.ash` file needed migrating again: `tests/cases/**` (including nested `fixtures/`/`ash.libs/`), `benchmarks/fib.ash` and `fib20.ash` (the only benchmarks that declare a function), `README.md`'s example, and `deep_nesting_demo.ash` (a throwaway example file at the repo root, not part of the test suite, made earlier in the day to look at how deeply nested logic reads under the new syntax).
132+
133+Verified the same way as the previous rename: zero-warning rebuild, full regression suite byte-identical to baseline, all five benchmarks within a few percent of their pre-rename timings (no codegen change, just one more lexer keyword-length check), and the demo file's output unchanged.
134+
135+## 2026-10-07 (rename `let` to `local`)
136+Small follow-up to the Lua-alike syntax entry below: `let` is now spelled `local`, matching Lua's own keyword. Purely a lexer change plus renaming the internal `TOKEN_LET`/`let_statement()` to `TOKEN_LOCAL`/`local_statement()` so the code doesn't say one thing while the language says another — behavior is unchanged, including `local` still doing double duty for both declaring a new variable and reassigning an existing one (every reassignment still repeats the keyword, e.g. `local i = i + 1;`; that's a bigger, separate change, flagged but not done here — see `plans.md`'s open questions).
137+
138+Unlike the optional-parens/`and`/`or`/`not` change above, `let` was removed outright rather than kept as an alias, so every existing `.ash` file needed migrating: all of `ashvm/tests/cases/**` (including the nested `fixtures/` and `ash.libs/` directories, which a first pass over `tests/cases/*/*.ash` missed since they're one level deeper — caught by re-grepping with `find`), `ashvm/benchmarks/*.ash`, and `README.md`'s example. All mechanical word-boundary renames (`let` only ever appeared as the keyword, never as a substring of an identifier).
139+
140+Verified: zero-warning rebuild, full regression suite byte-identical to a re-captured baseline (one diagnostic's expected text changed: the source line and hint now read `local`, re-baselined since that's the intended new output), and all five benchmarks within a few percent of their pre-rename timings — expected, since the rename adds one keyword-length check to the lexer and changes no codegen at all.
141+
142+## 2026-10-07 (Lua-alike syntax: optional condition parens, and/or/not)
143+First slice of the Lua-flavored rework from `plans.md` (goals 1-3): `if`/`while` conditions no longer require parentheses, and the symbolic logical operators become word keywords, the way Lua spells them: `&&` -> `and`, `||` -> `or`, unary `!` -> `not` (`!=` stays symbolic). Blocks stay `{ }`-delimited and `;` stays a required statement terminator — both were tried as `then`/`do`/`end` keyword blocks and optional/no-op semicolons first, then reverted: the ask was Lua-*alike*, not a literal copy of Lua's own block grammar or its semicolon quirks, and Ash didn't need to inherit either.
144+
145+Confined entirely to `ashvm/src/lexer` and the if/while-condition parsing path in `ashvm/src/compiler` (`stmt_control.c`, `calls.c`'s `try_fuse_condition`) — the VM, bytecode format and every other statement kind are untouched. `try_fuse_condition()` now takes the token that must terminate a fused comparison (`)` when parenthesized, `{` when not), so the hand-fused `OP_CMP_JUMP` superinstruction still fires either way (`while i < n { ... }` and `while (i < n) { ... }` compile identically). `!` only changes meaning when it stood alone; `!=` is unaffected since the lexer still checks for a following `=` first.
146+
147+No existing `.ash` file needed migrating: nothing in `tests/cases/**` or `benchmarks/*.ash` used unparenthesized conditions or the old logical operators, so every one of them produces byte-identical bytecode to before. Verified: zero-warning rebuild, `ashvm/tests/scripts/run_regression.sh check` byte-identical to a pre-change baseline, and all five benchmarks within measurement noise of their pre-change timings (see `plans.md`'s performance table).
148+
149+**Deferred, not done here:** `kiln` keeps its own old syntax (separate lexer/parser, not blocked on this); the VS Code syntax extension (`ash-syntax`, separate repo) still needs `and`/`or`/`not` added to its keyword highlighting.
150+
1151 ## 2026-10-07 (back on Ash + source and test restructure)
2152 First day back on this project after a break (the last entry here is from 2026-07-21), and it's good to finally be able to pick Ash up again. Before building anything new, the repo got a full cleanup so it's a place I actually want to work in: nothing in the language itself changed, only where files live.
3153

README.mdmodified

+65−27
@@ -11,34 +11,58 @@
1111 Ash is a small programming language built from scratch in C - lexer,
1212 compiler, and a bytecode VM, all hand-written with no external dependencies.
1313 It has closures, hash maps, first-class functions, real floating-point
14-numbers, `try`/`catch`, and a colorized compiler that points straight at
14+numbers, `attempt`/`handle`, and a colorized compiler that points straight at
1515 your mistakes.
1616
1717 ```ash
18-fn make_adder(x) {
19- return fn(y) { return x + y; };
18+forge make_adder(x) {
19+ yield forge(y) { yield x + y; };
2020 }
2121
22-let add5 = make_adder(5);
23-print add5(3); // 8
22+local add5 = make_adder(5);
23+say add5(3); // 8
2424
25-let nums = [1, 2, 3, 4, 5];
26-let evens = filter(nums, fn(n) { return n % 2 == 0; });
27-print evens; // [2, 4]
25+local nums = [1, 2, 3, 4, 5];
26+local evens = filter(nums, forge(n) { yield n % 2 == 0; });
27+say evens; // [2, 4]
2828
29-try {
30- let m = {"name": "Ash"};
31- print m["missing"];
32-} catch (e) {
33- print "caught: " + e;
29+attempt {
30+ local m = {"name": "Ash"};
31+ say m["missing"];
32+} handle (e) {
33+ say "caught: " + e;
3434 }
35+
36+// conditions can drop the parens, and logical operators read as words
37+given add5(3) > 7 and not (evens[0] == 1) {
38+ say "Lua-alike, not a Lua copy";
39+}
40+
41+each n in evens {
42+ say n;
43+}
44+
45+// stop/next, compound assignment, ternary, bitwise ops, and/none
46+local total = 0;
47+local i = 0;
48+during i < 10 {
49+ i += 1;
50+ given i == 3 { next; }
51+ given i == 7 { stop; }
52+ total += i;
53+}
54+say total > 0 ? "positive" : "zero or less"; // ternary
55+say (5 & 3) | (1 << 2); // bitwise: and, or, shift
56+
57+local maybe = none;
58+say maybe == none ? "nothing there" : "something there";
3559 ```
3660
3761 ## Engine
3862
3963 Ash compiles to real bytecode and runs it on a stack-based VM (`ashvm`) with
4064 computed-goto instruction dispatch and a handful of hand-fused
41-"superinstructions" for common patterns like `x = x + y` and `while (i < n)`
65+"superinstructions" for common patterns like `x = x + y` and `during (i < n)`
4266 that collapse several bytecode ops into one. Ash used to ship a second,
4367 slower tree-walk interpreter (`ashc`) as the place new features were
4468 prototyped first - it has since been retired now that the VM has full
@@ -64,12 +88,16 @@ optimizing native compiler.
6488
6589 ## Language features
6690
67-- Numbers (real doubles), strings (with escape sequences), arrays, hash maps
91+- Numbers (real doubles), strings (with escape sequences), arrays, hash maps, `none`
92+- `yes`/`no` boolean literals (plain sugar for `1`/`0` - truthiness is number-based throughout)
6893 - Functions, recursion, closures with by-value capture
6994 - First-class functions - pass them around, store them in variables, call them indirectly
70-- `map`, `filter`, `reduce`, and 20+ other built-ins (string ops, file I/O, math)
71-- `try` / `catch` error handling
72-- `for (x in array)` and `while` loops
95+- `map`, `filter`, `reduce`, `type` (runtime type introspection: `"number"/"string"/"array"/"map"/"function"/"none"`), and 20+ other built-ins (string ops, file I/O, math)
96+- `attempt` / `handle` error handling, plus `raise expr;` for user-thrown errors with a runtime string message
97+- `each (x in array)` and `during` loops, with `stop` / `next` for early exit and skip-to-next-iteration
98+- Compound assignment (`+= -= *= /=`) and `++` / `--`
99+- Ternary `cond ? a : b`
100+- Bitwise operators: `& | ^ ~ << >>`
73101 - Colorized, Rust-style diagnostics for both compile-time and runtime
74102 errors: file:line:column, a source snippet, a caret, and a contextual hint
75103 - An interactive REPL with persistent variables across lines
@@ -83,6 +111,10 @@ cd ashvm && make # builds ../bin/ashvm
83111 ../bin/ashvm # start the REPL
84112 ```
85113
114+`kiln` (compiles `.ash` straight to a native ELF/PE binary) and `forgepack`
115+(the package manager) build the same way, each from its own directory:
116+`cd kiln && make` / `cd forgepack && make`.
117+
86118 ## Project structure
87119 ashvm/ the engine - everything below is self-contained inside here
88120 src/
@@ -94,19 +126,25 @@ call/lambda emission, statement compilation
94126 builtins/ len, map, filter, reduce, string/file ops
95127 diagnostics/ shared Rust-style error reporting (compile-time and runtime)
96128
97-Every subsystem keeps sources in `C/`, headers in `H/`, and build output in a
98-mirrored `O/` tree (kiln also has `D/` for dependency files), e.g.
99-`src/vm/C/dispatch.c` includes `src/vm/H/vm.h` and builds to `src/vm/O/dispatch.o`.
129+Every subsystem keeps sources in `C/`, headers in `H/`, build output in a
130+mirrored `O/` tree, and generated dependency files in `D/` (so editing a
131+shared header correctly triggers a rebuild of everything that includes it),
132+e.g. `src/vm/C/dispatch.c` includes `src/vm/H/vm.h` and builds to
133+`src/vm/O/dispatch.o` with `src/vm/D/dispatch.d` tracking the header.
100134 app/ main.c entry point (file mode + REPL)
101135 benchmarks/ .ash and .cpp benchmark programs, plus the x86-64 JIT experiment
102136 tests/ cases/<feature>/*.ash programs covering each language feature; scripts/ holds the regression runners
103-kiln/ a standalone .ash compiler (Windows + Linux). Not started yet.
104137
105-## Editor support
138+kiln/ a second, independent implementation: compiles .ash straight to a
139+native ELF/PE binary (no VM, no libc), same language, full feature parity
140+with ashvm, cross-validated against it directly (see CHANGELOG.md).
106141
107-A VS Code extension with syntax highlighting is at https://github.com/lennoxrose/ash-syntax
108-A VS Code extension with the Icon pack is at https://github.com/lennoxrose/ash-icons/releases/tag/v1.2.12vsix
142+forgepack/ package manager -- `forgepack init/add/install/list`, dependencies
143+fetched from GitHub into a project-local @ash-modules/ folder (same idea
144+as node_modules). `@import <name>;` already works with it. No registry
145+yet -- see ideas/ash_modules.md for the full design and what's next.
109146
110-## Future of Ash
147+## Editor support
111148
112-Read the plans.md for further plans about Ash
\ No newline at end of file
149+A VS Code extension with syntax highlighting is at https://github.com/lennoxrose/ash-syntax
150+A VS Code extension with the Icon pack is at https://github.com/lennoxrose/ash-icons/releases/tag/v1.2.12vsix
\ No newline at end of file

ashvm/Makefilemodified

+18−2
@@ -18,6 +18,7 @@ ASHVM_SRC = \
1818 src/compiler/C/constants.c \
1919 src/compiler/C/expr.c \
2020 src/compiler/C/import_paths.c \
21+ src/compiler/C/loop_stack.c \
2122 src/compiler/C/state.c \
2223 src/compiler/C/stmt.c \
2324 src/compiler/C/stmt_control.c \
@@ -31,13 +32,25 @@ ASHVM_SRC = \
3132
3233 # Objects and dependency files live in trees parallel to each C tree:
3334 # src/<subsystem>/C/<feature>/x.c -> src/<subsystem>/O/<feature>/x.o
35+# src/<subsystem>/D/<feature>/x.d
3436 obj_of = $(patsubst %.c,%.o,$(subst /C/,/O/,$(1)))
37+dep_of = $(patsubst %.c,%.d,$(subst /C/,/D/,$(1)))
3538 ASHVM_OBJ = $(foreach s,$(ASHVM_SRC),$(call obj_of,$(s)))
39+ASHVM_DEP = $(foreach s,$(ASHVM_SRC),$(call dep_of,$(s)))
3640
41+# -MMD -MP: emit a per-object .d file listing the headers it includes, so
42+# editing a shared header (e.g. lexer.h, value.h, chunk.h) correctly
43+# triggers a rebuild of every .o that includes it, not just files whose
44+# own .c changed. Without this, make's only dependency is "the .c is newer
45+# than the .o" -- a header-only edit silently produces a binary built from
46+# mismatched token/opcode numbering across files, with no compile error,
47+# until something fails at runtime for reasons the source doesn't explain
48+# (this bit for real during the kiln-parity work this mirrors kiln's own
49+# Makefile comment for -- see kiln/Makefile).
3750 define compile_rule
3851 $(call obj_of,$(1)): $(1)
39- @mkdir -p $$(dir $$@)
40- $$(CC) $$(CFLAGS) -c $$< -o $$@
52+ @mkdir -p $$(dir $$@) $$(dir $(call dep_of,$(1)))
53+ $$(CC) $$(CFLAGS) -MMD -MP -MF $(call dep_of,$(1)) -MT $$@ -c $$< -o $$@
4154 endef
4255 $(foreach s,$(ASHVM_SRC),$(eval $(call compile_rule,$(s))))
4356
@@ -49,8 +62,11 @@ $(BIN): $(ASHVM_OBJ)
4962 mkdir -p ../bin
5063 $(CC) $(CFLAGS) -o $(BIN) $(ASHVM_OBJ) $(LDFLAGS)
5164
65+-include $(ASHVM_DEP)
66+
5267 clean:
5368 find src -name '*.o' -delete
69+ find src -name '*.d' -delete
5470 rm -f $(BIN)
5571
5672 .PHONY: all clean

ashvm/benchmarks/countdown.ashmodified

+6−6
@@ -1,7 +1,7 @@
1-let i = 20000000;
2-let count = 0;
3-while (i > 0) {
4- let count = count + 1;
5- let i = i - 1;
1+local i = 20000000;
2+local count = 0;
3+during (i > 0) {
4+ local count = count + 1;
5+ local i = i - 1;
66 }
7-print count;
7+say count;

ashvm/benchmarks/fib.ashmodified

+5−5
@@ -1,7 +1,7 @@
1-fn fib(n) {
2- if (n < 2) {
3- return n;
1+forge fib(n) {
2+ given (n < 2) {
3+ yield n;
44 }
5- return fib(n - 1) + fib(n - 2);
5+ yield fib(n - 1) + fib(n - 2);
66 }
7-print fib(30);
7+say fib(30);

ashvm/benchmarks/fib20.ashmodified

+1−1
@@ -1 +1 @@
1-fn fib(n) { if (n < 2) { return n; } return fib(n-1)+fib(n-2); } print fib(20);
1+forge fib(n) { given (n < 2) { yield n; } yield fib(n-1)+fib(n-2); } say fib(20);

ashvm/benchmarks/hashmap.ashmodified

+10−10
@@ -1,13 +1,13 @@
1-let m = {};
2-let i = 0;
3-while (i < 50000) {
1+local m = {};
2+local i = 0;
3+during (i < 50000) {
44 m[str(i)] = i * 2;
5- let i = i + 1;
5+ local i = i + 1;
66 }
7-let j = 0;
8-let total = 0;
9-while (j < 50000) {
10- let total = total + m[str(j)];
11- let j = j + 1;
7+local j = 0;
8+local total = 0;
9+during (j < 50000) {
10+ local total = total + m[str(j)];
11+ local j = j + 1;
1212 }
13-print total;
13+say total;

ashvm/benchmarks/loop.ashmodified

+6−6
@@ -1,7 +1,7 @@
1-let sum = 0;
2-let i = 0;
3-while (i < 20000000) {
4- let sum = sum + i;
5- let i = i + 1;
1+local sum = 0;
2+local i = 0;
3+during (i < 20000000) {
4+ local sum = sum + i;
5+ local i = i + 1;
66 }
7-print sum;
7+say sum;

ashvm/benchmarks/loop_varbound.ashmodified

+7−7
@@ -1,8 +1,8 @@
1-let n = 20000000;
2-let sum = 0;
3-let i = 0;
4-while (i < n) {
5- let sum = sum + i;
6- let i = i + 1;
1+local n = 20000000;
2+local sum = 0;
3+local i = 0;
4+during (i < n) {
5+ local sum = sum + i;
6+ local i = i + 1;
77 }
8-print sum;
8+say sum;

ashvm/src/app/D/main.dadded

+8−0
@@ -0,0 +1,8 @@
1+src/app/O/main.o: src/app/C/main.c src/compiler/H/compiler.h \
2+ src/vm/H/chunk.h src/value/H/value.h src/vm/H/vm.h \
3+ src/diagnostics/H/diagnostics.h
4+src/compiler/H/compiler.h:
5+src/vm/H/chunk.h:
6+src/value/H/value.h:
7+src/vm/H/vm.h:
8+src/diagnostics/H/diagnostics.h:

ashvm/src/builtins/C/builtins.cmodified

+3−1
@@ -7,7 +7,8 @@ static const char *builtin_names[] = {
77 "len", "push", "input", "str", "num", "sqrt", "abs", "floor",
88 "map", "filter", "reduce", "keys", "values", "has", "delete",
99 "split", "join", "substring", "indexOf", "replace", "upper", "lower", "trim",
10- "read_file", "write_file", "append_file", "file_exists"
10+ "read_file", "write_file", "append_file", "file_exists",
11+ "type"
1112 };
1213 #define NUM_BUILTINS (int)(sizeof(builtin_names) / sizeof(builtin_names[0]))
1314
@@ -23,5 +24,6 @@ VMValue vm_call_builtin(int id, VMValue *args, int argc) {
2324 if (id >= 8 && id <= 14) return vm_call_builtin_collection(id, args, argc);
2425 if (id >= 15 && id <= 22) return vm_call_builtin_string(id, args, argc);
2526 if (id >= 23 && id <= 26) return vm_call_builtin_file(id, args, argc);
27+ if (id == 27) return vm_call_builtin_core(id, args, argc); // type
2628 vm_runtime_error("unknown builtin id: %d\n", id);
2729 }

ashvm/src/builtins/C/core.cmodified

+20−0
@@ -52,6 +52,26 @@ VMValue vm_call_builtin_core(int id, VMValue *args, int argc) {
5252 if (argc != 1 || args[0].type != VM_NUM) { vm_runtime_error("floor() expects a number\n"); }
5353 return vm_num(floor(args[0].number));
5454 }
55+ case 27: { // type -- introspection, needed for anything that has to
56+ // branch on a value's own type at runtime (json's
57+ // stringify is the motivating case: it can't decide
58+ // "format this as an array vs. a map vs. a string"
59+ // from pure Ash without some way to ask a value what
60+ // it is).
61+ if (argc != 1) { vm_runtime_error("type() expects 1 argument\n"); }
62+ const char *name;
63+ switch (args[0].type) {
64+ case VM_NUM: name = "number"; break;
65+ case VM_STR: name = "string"; break;
66+ case VM_ARRAY: name = "array"; break;
67+ case VM_MAP: name = "map"; break;
68+ case VM_FUNCTION: case VM_CLOSURE: name = "function"; break;
69+ case VM_NIL: name = "none"; break;
70+ default: name = "unknown"; break;
71+ }
72+ char *copy = malloc(strlen(name) + 1); strcpy(copy, name);
73+ return vm_str(copy);
74+ }
5575 }
5676 vm_runtime_error("unknown builtin id: %d\n", id);
5777 }

ashvm/src/builtins/D/builtins.dadded

+8−0
@@ -0,0 +1,8 @@
1+src/builtins/O/builtins.o: src/builtins/C/builtins.c \
2+ src/builtins/H/builtins.h src/value/H/value.h src/builtins/H/internal.h \
3+ src/vm/H/vm.h src/vm/H/chunk.h
4+src/builtins/H/builtins.h:
5+src/value/H/value.h:
6+src/builtins/H/internal.h:
7+src/vm/H/vm.h:
8+src/vm/H/chunk.h:

ashvm/src/builtins/D/collection.dadded

+8−0
@@ -0,0 +1,8 @@
1+src/builtins/O/collection.o: src/builtins/C/collection.c \
2+ src/builtins/H/internal.h src/value/H/value.h src/vm/H/vm.h \
3+ src/vm/H/chunk.h src/value/H/hashmap.h
4+src/builtins/H/internal.h:
5+src/value/H/value.h:
6+src/vm/H/vm.h:
7+src/vm/H/chunk.h:
8+src/value/H/hashmap.h:

ashvm/src/builtins/D/core.dadded

+7−0
@@ -0,0 +1,7 @@
1+src/builtins/O/core.o: src/builtins/C/core.c src/builtins/H/internal.h \
2+ src/value/H/value.h src/vm/H/vm.h src/vm/H/chunk.h src/value/H/hashmap.h
3+src/builtins/H/internal.h:
4+src/value/H/value.h:
5+src/vm/H/vm.h:
6+src/vm/H/chunk.h:
7+src/value/H/hashmap.h:

ashvm/src/builtins/D/file.dadded

+6−0
@@ -0,0 +1,6 @@
1+src/builtins/O/file.o: src/builtins/C/file.c src/builtins/H/internal.h \
2+ src/value/H/value.h src/vm/H/vm.h src/vm/H/chunk.h
3+src/builtins/H/internal.h:
4+src/value/H/value.h:
5+src/vm/H/vm.h:
6+src/vm/H/chunk.h:

ashvm/src/builtins/D/string.dadded

+7−0
@@ -0,0 +1,7 @@
1+src/builtins/O/string.o: src/builtins/C/string.c \
2+ src/builtins/H/internal.h src/value/H/value.h src/vm/H/vm.h \
3+ src/vm/H/chunk.h
4+src/builtins/H/internal.h:
5+src/value/H/value.h:
6+src/vm/H/vm.h:
7+src/vm/H/chunk.h:

ashvm/src/compiler/C/calls.cmodified

+14−9
@@ -4,6 +4,7 @@
44 #include "compiler/H/internal.h"
55 #include "builtins/H/builtins.h"
66 #include "diagnostics/H/diagnostics.h"
7+#include "compiler/H/loop_stack.h"
78
89 void emit_call(Token id) {
910 int builtin_id = vm_builtin_lookup(id.start, id.length);
@@ -75,15 +76,15 @@ void emit_call_named(const char *name, int len) {
7576 emit((uint8_t)argc);
7677 }
7778
78-// fn(params) { body } as an expression. Captures the ENTIRE enclosing scope's
79-// locals by value at creation time (same rule as ashc's closures used to
80-// have). The captured variables get their own reserved local slots in the
81-// lambda's own chunk, placed right after its parameters, so the compiled
82-// body can reference them via the normal resolve_local() mechanism just
83-// like any other local.
79+// forge(params) { body } as an expression. Captures the ENTIRE enclosing
80+// scope's locals by value at creation time (same rule as ashc's closures
81+// used to have). The captured variables get their own reserved local slots
82+// in the lambda's own chunk, placed right after its parameters, so the
83+// compiled body can reference them via the normal resolve_local()
84+// mechanism just like any other local.
8485 void lambda_literal(void) {
8586 advance_token();
86- expect(TOKEN_LPAREN, "expected '(' after 'fn'");
87+ expect(TOKEN_LPAREN, "expected '(' after 'forge'");
8788
8889 char namebuf[32];
8990 int namelen = snprintf(namebuf, sizeof(namebuf), "__lambda_%d", lambda_counter++);
@@ -123,6 +124,9 @@ void lambda_literal(void) {
123124 char outer_locals[MAX_VM_LOCALS][64];
124125 int outer_local_count = local_count;
125126 memcpy(outer_locals, local_names, sizeof(local_names));
127+ int outer_loop_depth;
128+ loop_depth_save(&outer_loop_depth);
129+ loop_depth_reset();
126130
127131 chunk = &vm_functions[fn_idx].chunk;
128132 local_count = 0;
@@ -136,6 +140,7 @@ void lambda_literal(void) {
136140 chunk = outer_chunk;
137141 local_count = outer_local_count;
138142 memcpy(local_names, outer_locals, sizeof(local_names));
143+ loop_depth_restore(outer_loop_depth);
139144
140145 emit_op(OP_MAKE_CLOSURE);
141146 emit((uint8_t)fn_idx);
@@ -147,7 +152,7 @@ void lambda_literal(void) {
147152 // offset to patch_jump() once the branch target is known, or -1 if the
148153 // condition didn't match the fusable shape (caller falls back to a plain
149154 // expression() + OP_JUMP_IF_FALSE).
150-int try_fuse_condition(void) {
155+int try_fuse_condition(TokenType terminator) {
151156 if (current.type != TOKEN_IDENTIFIER) return -1;
152157 Token save = current;
153158 Token id = current;
@@ -185,7 +190,7 @@ int try_fuse_condition(void) {
185190 lexer_init(save.start + save.length); current = save; return -1;
186191 }
187192
188- if (current.type != TOKEN_RPAREN) {
193+ if (current.type != terminator) {
189194 lexer_init(save.start + save.length); current = save; return -1;
190195 }
191196

ashvm/src/compiler/C/constants.cmodified

+1−1
@@ -45,7 +45,7 @@ int try_declare_import_constant(const char *name, int len) {
4545 value = vm_str(vm_copy_string_escaped(current.start, current.length));
4646 advance_token();
4747 } else {
48- diagnostics_report("error", "imported top-level 'let' must be a literal number or string constant", current.start, current.length);
48+ diagnostics_report("error", "imported top-level 'local' must be a literal number or string constant", current.start, current.length);
4949 exit(1);
5050 }
5151 declare_constant(combined, combined_len, value);

ashvm/src/compiler/C/expr.cmodified

+59−4
@@ -7,7 +7,7 @@
77 // comparison -> additive -> term -> unary -> postfix -> primary.
88
99 static void primary(void) {
10- if (current.type == TOKEN_FN) { lambda_literal(); return; }
10+ if (current.type == TOKEN_FORGE) { lambda_literal(); return; }
1111 if (current.type == TOKEN_NUMBER) {
1212 double v = strtod(current.start, NULL);
1313 advance_token();
@@ -20,6 +20,17 @@ static void primary(void) {
2020 emit_constant(vm_str(s));
2121 return;
2222 }
23+ if (current.type == TOKEN_YES || current.type == TOKEN_NO) {
24+ double v = current.type == TOKEN_YES ? 1.0 : 0.0;
25+ advance_token();
26+ emit_constant(vm_num(v));
27+ return;
28+ }
29+ if (current.type == TOKEN_NONE) {
30+ advance_token();
31+ emit_constant(vm_nil());
32+ return;
33+ }
2334 if (current.type == TOKEN_LBRACKET) {
2435 advance_token();
2536 int count = 0;
@@ -129,6 +140,7 @@ static void postfix(void) {
129140 static void unary(void) {
130141 if (current.type == TOKEN_BANG) { advance_token(); unary(); emit_op(OP_NOT); return; }
131142 if (current.type == TOKEN_MINUS) { advance_token(); unary(); emit_op(OP_NEG); return; }
143+ if (current.type == TOKEN_TILDE) { advance_token(); unary(); emit_op(OP_BNOT); return; }
132144 postfix();
133145 }
134146
@@ -172,12 +184,55 @@ static void comparison(void) {
172184 }
173185 }
174186
175-static void logical_and(void) {
187+// Bitwise operators (& | ^ << >>), one flat precedence level (left to
188+// right) sitting between comparison and logical_and -- not real C
189+// precedence (which splits shift/bitand/bitxor/bitor into four separate
190+// levels), a deliberate simplification matching kiln's own choice here.
191+// Operands are truncated to int64 in the VM (do_BAND etc. in
192+// vm/C/dispatch.c), same "assume NUMBER" scope limit as -, *, /, and the
193+// comparison operators.
194+static void bitwise(void) {
176195 comparison();
177- while (current.type == TOKEN_AND) { advance_token(); comparison(); emit_op(OP_AND); }
196+ while (current.type == TOKEN_AMP || current.type == TOKEN_PIPE ||
197+ current.type == TOKEN_CARET || current.type == TOKEN_SHL || current.type == TOKEN_SHR) {
198+ TokenType op = current.type;
199+ advance_token();
200+ comparison();
201+ switch (op) {
202+ case TOKEN_AMP: emit_op(OP_BAND); break;
203+ case TOKEN_PIPE: emit_op(OP_BOR); break;
204+ case TOKEN_CARET: emit_op(OP_BXOR); break;
205+ case TOKEN_SHL: emit_op(OP_SHL); break;
206+ default: emit_op(OP_SHR); break;
207+ }
208+ }
209+}
210+
211+static void logical_and(void) {
212+ bitwise();
213+ while (current.type == TOKEN_AND) { advance_token(); bitwise(); emit_op(OP_AND); }
178214 }
179215 static void logical_or(void) {
180216 logical_and();
181217 while (current.type == TOKEN_OR) { advance_token(); logical_and(); emit_op(OP_OR); }
182218 }
183-void expression(void) { logical_or(); }
219+
220+// a ? b : c -- lowest precedence, sits above logical_or. Right-associative
221+// via the recursive expression() calls for both branches (so
222+// `a ? b : c ? d : e` parses as `a ? b : (c ? d : e)`), same jump-patch
223+// shape as given/during's own truthy-test-then-jump pattern, just as an
224+// expression instead of a statement. OP_JUMP_IF_FALSE pops the condition
225+// itself, matching how compile_given_stmt already uses it.
226+void expression(void) {
227+ logical_or();
228+ if (current.type == TOKEN_QUESTION) {
229+ advance_token();
230+ int else_jump = emit_jump(OP_JUMP_IF_FALSE);
231+ expression(); // true branch
232+ int end_jump = emit_jump(OP_JUMP);
233+ expect(TOKEN_COLON, "expected ':' in ternary expression");
234+ patch_jump(else_jump);
235+ expression(); // false branch
236+ patch_jump(end_jump);
237+ }
238+}

ashvm/src/compiler/C/import_paths.cmodified

+6−0
@@ -50,8 +50,14 @@ int import_resolve_path(const char *raw_path, int raw_len,
5050 return 0;
5151 }
5252 }
53+ // ash.libs/ (hand-written local libraries) wins first; a package
54+ // installed by forgepack (see ideas/ash_modules.md) only gets
55+ // tried if there's no local library shadowing the same name.
5356 snprintf(candidate, sizeof(candidate), "%s/ash.libs/%.*s/%.*s.ash",
5457 project_root_dir, raw_len, raw_path, raw_len, raw_path);
58+ if (realpath(candidate, canonical_out) != NULL) return 1;
59+ snprintf(candidate, sizeof(candidate), "%s/@ash-modules/%.*s/%.*s.ash",
60+ project_root_dir, raw_len, raw_path, raw_len, raw_path);
5561 }
5662
5763 if (realpath(candidate, canonical_out) == NULL) {

ashvm/src/compiler/C/loop_stack.cadded

+68−0
@@ -0,0 +1,68 @@
1+#include <stdlib.h>
2+#include "compiler/H/loop_stack.h"
3+#include "compiler/H/state.h"
4+#include "diagnostics/H/diagnostics.h"
5+
6+#define MAX_LOOP_NESTING 16
7+#define MAX_LOOP_JUMPS 64
8+
9+typedef struct {
10+ int stop_jumps[MAX_LOOP_JUMPS];
11+ int stop_count;
12+ int next_jumps[MAX_LOOP_JUMPS];
13+ int next_count;
14+} LoopContext;
15+
16+static LoopContext loop_stack[MAX_LOOP_NESTING];
17+static int loop_depth = 0;
18+
19+void loop_push(void) {
20+ if (loop_depth >= MAX_LOOP_NESTING) {
21+ diagnostics_report("error", "too many nested loops", current.start, current.length);
22+ exit(1);
23+ }
24+ loop_stack[loop_depth].stop_count = 0;
25+ loop_stack[loop_depth].next_count = 0;
26+ loop_depth++;
27+}
28+
29+void loop_record_stop(Token keyword, int patch_offset) {
30+ if (loop_depth == 0) {
31+ diagnostics_report("error", "'stop' outside a loop", keyword.start, keyword.length);
32+ exit(1);
33+ }
34+ LoopContext *ctx = &loop_stack[loop_depth - 1];
35+ if (ctx->stop_count >= MAX_LOOP_JUMPS) {
36+ diagnostics_report("error", "too many stop statements in one loop", keyword.start, keyword.length);
37+ exit(1);
38+ }
39+ ctx->stop_jumps[ctx->stop_count++] = patch_offset;
40+}
41+
42+void loop_record_next(Token keyword, int patch_offset) {
43+ if (loop_depth == 0) {
44+ diagnostics_report("error", "'next' outside a loop", keyword.start, keyword.length);
45+ exit(1);
46+ }
47+ LoopContext *ctx = &loop_stack[loop_depth - 1];
48+ if (ctx->next_count >= MAX_LOOP_JUMPS) {
49+ diagnostics_report("error", "too many next statements in one loop", keyword.start, keyword.length);
50+ exit(1);
51+ }
52+ ctx->next_jumps[ctx->next_count++] = patch_offset;
53+}
54+
55+void loop_patch_nexts(void) {
56+ LoopContext *ctx = &loop_stack[loop_depth - 1];
57+ for (int i = 0; i < ctx->next_count; i++) patch_jump(ctx->next_jumps[i]);
58+}
59+
60+void loop_pop_and_patch_stops(void) {
61+ LoopContext *ctx = &loop_stack[loop_depth - 1];
62+ for (int i = 0; i < ctx->stop_count; i++) patch_jump(ctx->stop_jumps[i]);
63+ loop_depth--;
64+}
65+
66+void loop_depth_save(int *out) { *out = loop_depth; }
67+void loop_depth_reset(void) { loop_depth = 0; }
68+void loop_depth_restore(int saved) { loop_depth = saved; }

ashvm/src/compiler/C/stmt.cmodified

+161−27
@@ -3,13 +3,14 @@
33 #include <string.h>
44 #include "compiler/H/internal.h"
55 #include "diagnostics/H/diagnostics.h"
6+#include "compiler/H/loop_stack.h"
67
78 // statement() is shared between top-level parsing (compile()'s own loop,
89 // and stmt_import.c's parse_imported_file_body()) and here, inside a
9-// fn/if/while/try body -- block_depth distinguishes the two so the "only
10-// fn/let at an imported file's top level" guard in statement() only
11-// fires for genuinely top-level statements, never for an ordinary
12-// return/if/etc. nested inside an imported function's own body.
10+// forge/given/during/attempt body -- block_depth distinguishes the two so
11+// the "only forge/local at an imported file's top level" guard in
12+// statement() only fires for genuinely top-level statements, never for an
13+// ordinary yield/given/etc. nested inside an imported function's own body.
1314 static int block_depth = 0;
1415
1516 void block(void) {
@@ -20,17 +21,24 @@ void block(void) {
2021 expect(TOKEN_RBRACE, "expected '}'");
2122 }
2223
23-// let name = value; -- also tries to fuse `let x = x OP (number|local);`
24+// local name = value; -- also tries to fuse `local x = x OP (number|local);`
2425 // into a single OP_ACC_LOCAL instead of a full expression compile, when the
2526 // shape matches exactly (see vm/compiler.c's history / rundown.md).
26-static void let_statement(void) {
27+static void local_statement(void) {
2728 advance_token();
28- expect(TOKEN_IDENTIFIER, "expected variable name after 'let'");
29+ expect(TOKEN_IDENTIFIER, "expected variable name after 'local'");
2930 const char *name = previous.start;
3031 int len = previous.length;
3132 expect(TOKEN_EQUAL, "expected '=' after variable name");
3233
33- if (try_declare_import_constant(name, len)) return;
34+ // Bug (found while writing the first real forgepack module, a JSON
35+ // codec -- any imported function with a `local` inside a loop failed
36+ // to compile): try_declare_import_constant() has no way to tell "a
37+ // top-level local in an imported file" from "an ordinary local deep
38+ // inside one of that file's own functions" except this check -- it
39+ // only makes sense at block_depth == 0, same as the sibling guard in
40+ // statement() above (which already gets this right).
41+ if (block_depth == 0 && try_declare_import_constant(name, len)) return;
3442
3543 int slot = resolve_local(name, len);
3644
@@ -88,11 +96,76 @@ static void let_statement(void) {
8896 }
8997 }
9098
91-// Either a bare call (`foo();`) or an index assignment (`foo[i] = value;`) --
92-// the two statement shapes that start with a bare identifier.
99+static int resolve_local_or_error(Token id) {
100+ int slot = resolve_local(id.start, id.length);
101+ if (slot == -1) {
102+ char msg[128];
103+ snprintf(msg, sizeof(msg), "undefined variable: %.*s", id.length, id.start);
104+ diagnostics_report("error", msg, id.start, id.length);
105+ exit(1);
106+ }
107+ return slot;
108+}
109+
110+// x += e; / x -= e; / x *= e; / x /= e; -- sugar for x = x <op> e.
111+// `+=` reuses OP_ADD directly (the exact same tag-aware string-concat-or-
112+// numeric-add logic `+` itself uses); `-=`/`*=`/`/=` are NUMBER-only, same
113+// scope limit as `-`/`*`/`/` themselves. Always the general (GET_LOCAL,
114+// expression(), op, SET_LOCAL) path, not the OP_ACC_LOCAL fusion -- the
115+// RHS here is a full expression, not just a number-or-local operand.
116+static void compound_assignment_statement(int slot, TokenType op) {
117+ advance_token(); // consume the += / -= / *= / /=
118+ emit2(OP_GET_LOCAL, (uint8_t)slot);
119+ expression();
120+ expect(TOKEN_SEMICOLON, "expected ';' after assignment");
121+ switch (op) {
122+ case TOKEN_PLUS_EQUAL: emit_op(OP_ADD); break;
123+ case TOKEN_MINUS_EQUAL: emit_op(OP_SUB); break;
124+ case TOKEN_STAR_EQUAL: emit_op(OP_MUL); break;
125+ default: emit_op(OP_DIV); break; // TOKEN_SLASH_EQUAL
126+ }
127+ emit2(OP_SET_LOCAL, (uint8_t)slot);
128+ emit_op(OP_POP);
129+}
130+
131+// x++; / x--; -- sugar for x = x + 1 / x = x - 1. Statement-only (not a
132+// pre/post-increment EXPRESSION with a value of its own), matching kiln's
133+// own incdec_statement exactly.
134+static void incdec_statement(int slot, TokenType op) {
135+ advance_token(); // consume ++ or --
136+ expect(TOKEN_SEMICOLON, "expected ';' after statement");
137+ emit2(OP_GET_LOCAL, (uint8_t)slot);
138+ emit_constant(vm_num(1));
139+ emit_op(op == TOKEN_PLUS_PLUS ? OP_ADD : OP_SUB);
140+ emit2(OP_SET_LOCAL, (uint8_t)slot);
141+ emit_op(OP_POP);
142+}
143+
144+// A bare call (`foo();`), an index assignment (`foo[i] = value;`), or a
145+// compound-assignment/increment-decrement on an already-declared local --
146+// the statement shapes that start with a bare identifier.
93147 static void identifier_statement(void) {
94148 Token id = current;
95149 advance_token();
150+ // A namespaced call used as its OWN statement (`json.save(...);`,
151+ // discarding the result) rather than nested in an expression
152+ // (`local x = json.load(...);`, where expr.c's primary() already
153+ // handles this same `ident.member(...)` shape) -- found missing
154+ // while writing the first real forgepack module, whose whole point
155+ // (`json.save(path, value);`) is exactly this shape.
156+ if (current.type == TOKEN_DOT) {
157+ advance_token();
158+ expect(TOKEN_IDENTIFIER, "expected name after '.'");
159+ Token member = previous;
160+ char combined[128];
161+ int combined_len = snprintf(combined, sizeof(combined), "%.*s.%.*s",
162+ id.length, id.start, member.length, member.start);
163+ expect(TOKEN_LPAREN, "expected '(' after namespaced function name");
164+ emit_call_named(combined, combined_len);
165+ expect(TOKEN_SEMICOLON, "expected ';' after call");
166+ emit_op(OP_POP);
167+ return;
168+ }
96169 if (current.type == TOKEN_LPAREN) {
97170 advance_token();
98171 emit_call(id);
@@ -101,13 +174,7 @@ static void identifier_statement(void) {
101174 return;
102175 }
103176 if (current.type == TOKEN_LBRACKET) {
104- int slot = resolve_local(id.start, id.length);
105- if (slot == -1) {
106- char msg[128];
107- snprintf(msg, sizeof(msg), "undefined variable: %.*s", id.length, id.start);
108- diagnostics_report("error", msg, id.start, id.length);
109- exit(1);
110- }
177+ int slot = resolve_local_or_error(id);
111178 emit2(OP_GET_LOCAL, (uint8_t)slot);
112179 advance_token();
113180 expression();
@@ -118,41 +185,108 @@ static void identifier_statement(void) {
118185 emit_op(OP_INDEX_SET);
119186 return;
120187 }
188+ if (current.type == TOKEN_PLUS_EQUAL || current.type == TOKEN_MINUS_EQUAL ||
189+ current.type == TOKEN_STAR_EQUAL || current.type == TOKEN_SLASH_EQUAL) {
190+ compound_assignment_statement(resolve_local_or_error(id), current.type);
191+ return;
192+ }
193+ if (current.type == TOKEN_PLUS_PLUS || current.type == TOKEN_MINUS_MINUS) {
194+ incdec_statement(resolve_local_or_error(id), current.type);
195+ return;
196+ }
197+ // Bare reassignment (`x = expr;`, no `local`) on an already-declared
198+ // variable -- found missing while writing the first real forgepack
199+ // module (a JSON codec, which reassigns a loop position variable
200+ // throughout). kiln already supports this (its local_statement()
201+ // reassigns an existing slot in place rather than requiring `local`
202+ // again); ashvm didn't, which is exactly the kind of "both engines
203+ // run the exact same code" gap this project's cross-engine audits
204+ // exist to catch. Plain SET_LOCAL, same as local_statement()'s own
205+ // `slot != -1` path -- the RHS here is a full expression, so this
206+ // doesn't try the OP_ACC_LOCAL fusion local_statement() attempts
207+ // first (that fusion only fires for a number-or-local RHS operand).
208+ if (current.type == TOKEN_EQUAL) {
209+ int slot = resolve_local_or_error(id);
210+ advance_token();
211+ expression();
212+ expect(TOKEN_SEMICOLON, "expected ';' after assignment");
213+ emit2(OP_SET_LOCAL, (uint8_t)slot);
214+ emit_op(OP_POP);
215+ return;
216+ }
121217 diagnostics_report("error", "unexpected identifier statement", current.start, current.length);
122218 exit(1);
123219 }
124220
221+// stop; -- jumps past the innermost loop; the jump is recorded, not
222+// resolved here, since the innermost loop's own codegen
223+// (compile_during_stmt/compile_each_stmt in stmt_control.c) hasn't emitted
224+// the "after the loop" landing point yet.
225+static void stop_statement(void) {
226+ Token keyword = current;
227+ advance_token();
228+ expect(TOKEN_SEMICOLON, "expected ';' after 'stop'");
229+ loop_record_stop(keyword, emit_jump(OP_JUMP));
230+}
231+
232+// next; -- jumps to the innermost loop's per-iteration advance step (an
233+// each-loop's index increment, or straight back to the condition for a
234+// during loop) -- also recorded, not resolved here, for the same reason
235+// as stop above.
236+static void next_statement(void) {
237+ Token keyword = current;
238+ advance_token();
239+ expect(TOKEN_SEMICOLON, "expected ';' after 'next'");
240+ loop_record_next(keyword, emit_jump(OP_JUMP));
241+}
242+
243+// raise expr; -- expr must evaluate to a string at runtime (checked by
244+// the VM's do_RAISE, not here, since the raised value is a general
245+// expression that could be anything). Hands off to vm_raise_value, which
246+// performs the exact same attempt/handle unwind every other runtime error
247+// already goes through (vm/C/errors.c).
248+static void raise_statement(void) {
249+ advance_token();
250+ expression();
251+ expect(TOKEN_SEMICOLON, "expected ';' after 'raise'");
252+ emit_op(OP_RAISE);
253+}
254+
125255 void statement(void) {
126256 if (current.type == TOKEN_IMPORT) { advance_token(); compile_import_stmt(); return; }
127257 imports_close_block();
128258
129259 const char *ns; int ns_len;
130260 get_import_namespace(&ns, &ns_len);
131- if (ns_len > 0 && block_depth == 0 && current.type != TOKEN_FN && current.type != TOKEN_LET) {
132- diagnostics_report("error", "only 'fn' and constant 'let' declarations are allowed at an imported file's top level", current.start, current.length);
261+ if (ns_len > 0 && block_depth == 0 && current.type != TOKEN_FORGE && current.type != TOKEN_LOCAL) {
262+ diagnostics_report("error", "only 'forge' and constant 'local' declarations are allowed at an imported file's top level", current.start, current.length);
133263 exit(1);
134264 }
135265
136- if (current.type == TOKEN_PRINT) {
266+ if (current.type == TOKEN_SAY) {
137267 advance_token();
138268 expression();
139269 expect(TOKEN_SEMICOLON, "expected ';' after statement");
140270 emit_op(OP_PRINT);
141271 return;
142272 }
143- if (current.type == TOKEN_LET) { let_statement(); return; }
144- if (current.type == TOKEN_RETURN) {
273+ if (current.type == TOKEN_LOCAL) { local_statement(); return; }
274+ if (current.type == TOKEN_YIELD) {
145275 advance_token();
146276 if (current.type != TOKEN_SEMICOLON) expression();
147277 else emit_constant(vm_num(0));
148- expect(TOKEN_SEMICOLON, "expected ';' after return");
278+ expect(TOKEN_SEMICOLON, "expected ';' after yield");
149279 emit_op(OP_RETURN);
150280 return;
151281 }
152- if (current.type == TOKEN_FN) { advance_token(); compile_fn_decl(); return; }
153- if (current.type == TOKEN_IF) { advance_token(); compile_if_stmt(); return; }
154- if (current.type == TOKEN_TRY) { advance_token(); compile_try_stmt(); return; }
155- if (current.type == TOKEN_WHILE) { advance_token(); compile_while_stmt(); return; }
282+ if (current.type == TOKEN_FORGE) { advance_token(); compile_forge_decl(); return; }
283+ if (current.type == TOKEN_GIVEN) { advance_token(); compile_given_stmt(); return; }
284+ if (current.type == TOKEN_ATTEMPT) { advance_token(); compile_attempt_stmt(); return; }
285+ if (current.type == TOKEN_DURING) { advance_token(); compile_during_stmt(); return; }
286+ if (current.type == TOKEN_EACH) { advance_token(); compile_each_stmt(); return; }
287+ if (current.type == TOKEN_STOP) { stop_statement(); return; }
288+ if (current.type == TOKEN_NEXT) { next_statement(); return; }
289+ if (current.type == TOKEN_RAISE) { raise_statement(); return; }
156290 if (current.type == TOKEN_IDENTIFIER) { identifier_statement(); return; }
157291
158292 diagnostics_report("error", "unexpected token", current.start, current.length);

ashvm/src/compiler/C/stmt_control.cmodified

+173−31
@@ -2,12 +2,14 @@
22 #include <string.h>
33 #include "compiler/H/internal.h"
44 #include "diagnostics/H/diagnostics.h"
5+#include "builtins/H/builtins.h"
6+#include "compiler/H/loop_stack.h"
57
68 // Each function here assumes statement() already consumed the keyword token
7-// (TOKEN_FN / TOKEN_IF / TOKEN_TRY / TOKEN_WHILE) that identified it.
9+// (TOKEN_FORGE / TOKEN_GIVEN / TOKEN_ATTEMPT / TOKEN_DURING) that identified it.
810
9-void compile_fn_decl(void) {
10- expect(TOKEN_IDENTIFIER, "expected function name after 'fn'");
11+void compile_forge_decl(void) {
12+ expect(TOKEN_IDENTIFIER, "expected function name after 'forge'");
1113 const char *name = previous.start;
1214 int name_len = previous.length;
1315 expect(TOKEN_LPAREN, "expected '(' after function name");
@@ -39,6 +41,9 @@ void compile_fn_decl(void) {
3941 char outer_locals[MAX_VM_LOCALS][64];
4042 int outer_local_count = local_count;
4143 memcpy(outer_locals, local_names, sizeof(local_names));
44+ int outer_loop_depth;
45+ loop_depth_save(&outer_loop_depth);
46+ loop_depth_reset();
4247
4348 chunk = &vm_functions[fn_idx].chunk;
4449 local_count = 0;
@@ -51,79 +56,216 @@ void compile_fn_decl(void) {
5156 chunk = outer_chunk;
5257 local_count = outer_local_count;
5358 memcpy(local_names, outer_locals, sizeof(local_names));
59+ loop_depth_restore(outer_loop_depth);
5460 }
5561
56-void compile_if_stmt(void) {
57- expect(TOKEN_LPAREN, "expected '(' after 'if'");
58- int patch_loc = try_fuse_condition();
59- int else_jump;
62+// Conditions may be parenthesized or not (`given (x < n) {` and
63+// `given x < n {` both work) -- try_fuse_condition() needs to know which,
64+// since the fused shape's lookahead has to stop at whatever actually
65+// follows the comparison: `)`, or straight on to the block's opening `{`.
66+static int consume_optional_lparen(void) {
67+ if (current.type != TOKEN_LPAREN) return 0;
68+ advance_token();
69+ return 1;
70+}
71+
72+// After compiling a block that may be re-entered (a loop body) or whose
73+// sibling branch may take a different path (given/otherwise), resets the
74+// runtime stack back to exactly saved_local_count slots if the block
75+// declared any locals of its own, and restores compile-time bookkeeping
76+// to match. This is an ABSOLUTE reset (OP_TRUNC_LOCALS sets
77+// stack_top = frame->slots + n), not a fixed number of OP_POPs -- a jump
78+// that exits the block early (`next`, or one branch of a nested given)
79+// may have declared fewer locals than the block's "straight-line" path
80+// does, so only the known target depth is safe to assume. Without this,
81+// a fresh `local` declared inside a during/each body (or inside a given
82+// nested in one) would push one more stack slot every time the block
83+// re-runs, permanently stuck at slot `saved_local_count` from the first
84+// pass -- found via a cross-engine audit against kiln, which doesn't
85+// have this bug (its locals are fixed rbp-relative memory, not stack
86+// positions, so re-declaring one just overwrites the same address).
87+static void truncate_block_locals(int saved_local_count) {
88+ if (local_count != saved_local_count) {
89+ emit2(OP_TRUNC_LOCALS, (uint8_t)saved_local_count);
90+ }
91+ local_count = saved_local_count;
92+}
93+
94+void compile_given_stmt(void) {
95+ int parenthesized = consume_optional_lparen();
96+ int patch_loc = try_fuse_condition(parenthesized ? TOKEN_RPAREN : TOKEN_LBRACE);
97+ int otherwise_jump;
6098 if (patch_loc != -1) {
61- expect(TOKEN_RPAREN, "expected ')' after condition");
62- else_jump = patch_loc;
99+ if (parenthesized) expect(TOKEN_RPAREN, "expected ')' after condition");
100+ otherwise_jump = patch_loc;
63101 } else {
64102 expression();
65- expect(TOKEN_RPAREN, "expected ')' after condition");
66- else_jump = emit_jump(OP_JUMP_IF_FALSE);
103+ if (parenthesized) expect(TOKEN_RPAREN, "expected ')' after condition");
104+ otherwise_jump = emit_jump(OP_JUMP_IF_FALSE);
67105 }
106+ int saved_local_count = local_count;
68107 block();
69- if (current.type == TOKEN_ELSE) {
108+ truncate_block_locals(saved_local_count);
109+ if (current.type == TOKEN_OTHERWISE) {
70110 int end_jump = emit_jump(OP_JUMP);
71- patch_jump(else_jump);
111+ patch_jump(otherwise_jump);
72112 advance_token();
73113 block();
114+ truncate_block_locals(saved_local_count);
74115 patch_jump(end_jump);
75116 } else {
76- patch_jump(else_jump);
117+ patch_jump(otherwise_jump);
77118 }
78119 }
79120
80-void compile_try_stmt(void) {
121+void compile_attempt_stmt(void) {
81122 int saved_local_count = local_count;
82123
83- int try_jump = emit_jump(OP_TRY_PUSH);
124+ int attempt_jump = emit_jump(OP_TRY_PUSH);
84125
85126 block();
86127
87128 emit_op(OP_TRY_POP);
88- int skip_catch = emit_jump(OP_JUMP);
129+ int skip_handle = emit_jump(OP_JUMP);
89130
90- patch_jump(try_jump);
131+ patch_jump(attempt_jump);
91132
92- expect(TOKEN_CATCH, "expected 'catch' after try block");
93- expect(TOKEN_LPAREN, "expected '(' after 'catch'");
133+ expect(TOKEN_HANDLE, "expected 'handle' after attempt block");
134+ expect(TOKEN_LPAREN, "expected '(' after 'handle'");
94135 expect(TOKEN_IDENTIFIER, "expected error variable name");
95136 // 'e' must land at EXACTLY saved_local_count, not wherever local_count
96- // ended up after compiling the try block -- since the try block may have
97- // declared its own locals before erroring, but the runtime error-value
98- // push always lands at the try-entry stack position, regardless of what
99- // the try block attempted (all of that gets discarded on the error path).
137+ // ended up after compiling the attempt block -- since the attempt block
138+ // may have declared its own locals before erroring, but the runtime
139+ // error-value push always lands at the attempt-entry stack position,
140+ // regardless of what the attempt block attempted (all of that gets
141+ // discarded on the error path).
100142 memcpy(local_names[saved_local_count], previous.start, previous.length);
101143 local_names[saved_local_count][previous.length] = '\0';
102144 local_count = saved_local_count + 1;
103- expect(TOKEN_RPAREN, "expected ')' after catch variable");
145+ expect(TOKEN_RPAREN, "expected ')' after handle variable");
104146
105147 block();
106148 emit_op(OP_POP);
107149
108- patch_jump(skip_catch);
150+ patch_jump(skip_handle);
109151
110152 local_count = saved_local_count;
111153 }
112154
113-void compile_while_stmt(void) {
114- expect(TOKEN_LPAREN, "expected '(' after 'while'");
155+void compile_during_stmt(void) {
115156 int loop_start = chunk->count;
116- int patch_loc = try_fuse_condition();
157+ int parenthesized = consume_optional_lparen();
158+ int patch_loc = try_fuse_condition(parenthesized ? TOKEN_RPAREN : TOKEN_LBRACE);
117159 int exit_jump;
118160 if (patch_loc != -1) {
119- expect(TOKEN_RPAREN, "expected ')' after condition");
161+ if (parenthesized) expect(TOKEN_RPAREN, "expected ')' after condition");
120162 exit_jump = patch_loc;
121163 } else {
122164 expression();
123- expect(TOKEN_RPAREN, "expected ')' after condition");
165+ if (parenthesized) expect(TOKEN_RPAREN, "expected ')' after condition");
124166 exit_jump = emit_jump(OP_JUMP_IF_FALSE);
125167 }
168+ int saved_local_count = local_count;
169+ loop_push();
170+ block();
171+ loop_patch_nexts(); // next lands here, right before the loop-back
172+ truncate_block_locals(saved_local_count);
173+ emit_loop(loop_start);
174+ patch_jump(exit_jump);
175+ loop_pop_and_patch_stops(); // stop lands here too -- same position as exit_jump
176+}
177+
178+// each (x in arr) { body } -- iterates an array by index, compiling down
179+// to exactly the bytecode a hand-written counting during-loop would produce
180+// (len(), OP_INDEX_GET, and OP_ACC_LOCAL for the index bump, wired together
181+// directly instead of going through expression()/statement() parsing of
182+// synthesized source text). Arrays only, matching map()/filter()/reduce()'s
183+// own array-only scope -- iterate a map via `each (k in keys(m))`.
184+//
185+// The three slots below (the array, the index, and the loop variable) are
186+// declared ONCE, before loop_start, and only ever updated via
187+// OP_SET_LOCAL/OP_ACC_LOCAL inside the repeated region -- never re-declared
188+// per iteration -- so the stack-slot layout stays stable across however
189+// many times the loop body actually runs (compiling a fresh declare_local()
190+// call into code that runs every iteration would desync the compile-time
191+// slot index from the runtime stack position after the first iteration).
192+// local_count is restored afterward (like compile_attempt_stmt does for its
193+// handle-variable) so repeated or nested `each` loops can't run the
194+// 128-slot MAX_VM_LOCALS table dry. The body's OWN locals (declared fresh
195+// inside `block()`, unlike these three setup slots) are handled separately
196+// by truncate_block_locals() every iteration -- see its comment.
197+void compile_each_stmt(void) {
198+ int saved_local_count = local_count;
199+ int parenthesized = consume_optional_lparen();
200+
201+ expect(TOKEN_IDENTIFIER, "expected loop variable name after 'each'");
202+ const char *var_name = previous.start;
203+ int var_len = previous.length;
204+ expect(TOKEN_IN, "expected 'in' after loop variable name");
205+
206+ expression(); // the collection expression; its value becomes the next local slot
207+ int arr_slot = declare_local("__each_arr", 10);
208+
209+ emit_constant(vm_num(0));
210+ int idx_slot = declare_local("__each_idx", 10);
211+
212+ emit_constant(vm_num(0)); // placeholder, overwritten before the body ever reads it
213+ int var_slot = declare_local(var_name, var_len);
214+
215+ if (parenthesized) expect(TOKEN_RPAREN, "expected ')' after 'each' header");
216+
217+ int len_id = vm_builtin_lookup("len", 3);
218+
219+ int loop_start = chunk->count;
220+ emit2(OP_GET_LOCAL, (uint8_t)idx_slot);
221+ emit2(OP_GET_LOCAL, (uint8_t)arr_slot);
222+ emit_op(OP_CALL_BUILTIN);
223+ emit((uint8_t)len_id);
224+ emit((uint8_t)1);
225+ emit_op(OP_LT);
226+ int exit_jump = emit_jump(OP_JUMP_IF_FALSE);
227+
228+ emit2(OP_GET_LOCAL, (uint8_t)arr_slot);
229+ emit2(OP_GET_LOCAL, (uint8_t)idx_slot);
230+ emit_op(OP_INDEX_GET);
231+ emit2(OP_SET_LOCAL, (uint8_t)var_slot);
232+ emit_op(OP_POP);
233+
234+ int body_local_count = local_count; // past arr/idx/var, before the body's own locals
235+ loop_push();
126236 block();
237+ loop_patch_nexts(); // next lands here, right before the index bump
238+ truncate_block_locals(body_local_count);
239+
240+ emit_op(OP_ACC_LOCAL);
241+ emit((uint8_t)idx_slot);
242+ emit((uint8_t)0x80); // op 0 ('+'), operand is the constant below
243+ emit((uint8_t)chunk_add_constant(chunk, vm_num(1)));
244+
127245 emit_loop(loop_start);
128246 patch_jump(exit_jump);
247+ // stop must land at this EXACT bytecode position too (not after the
248+ // cleanup pops below) -- patch_jump() backfills an offset into
249+ // already-written bytes without advancing chunk->count, so this call
250+ // doesn't move "the current position" at all. Patching stop jumps to
251+ // land here means execution falls through into the same cleanup pops
252+ // below regardless of whether the loop ended via its own condition
253+ // going false or via an explicit `stop`.
254+ loop_pop_and_patch_stops();
255+
256+ // local_count rolling back is compile-time bookkeeping only -- it does
257+ // NOT shrink the runtime stack, so the three slots pushed during setup
258+ // (arr, idx, var) are still physically sitting there when the loop
259+ // exits. Without these, a second `each` after this one would compile
260+ // its own slots to the same (now compile-time-reused) indices while
261+ // the runtime stack is still three deeper than that, desyncing every
262+ // GET_LOCAL/SET_LOCAL in it from where its values actually live.
263+ // compile_attempt_stmt's `emit_op(OP_POP);` after its handle-block
264+ // pops exactly this same kind of leftover for its one handle-variable
265+ // slot; this is the three-slot version of the same fix.
266+ emit_op(OP_POP);
267+ emit_op(OP_POP);
268+ emit_op(OP_POP);
269+
270+ local_count = saved_local_count;
129271 }

ashvm/src/compiler/D/calls.dadded

+15−0
@@ -0,0 +1,15 @@
1+src/compiler/O/calls.o: src/compiler/C/calls.c src/compiler/H/internal.h \
2+ src/compiler/H/state.h src/compiler/H/compiler.h src/vm/H/chunk.h \
3+ src/value/H/value.h src/lexer/H/lexer.h src/compiler/H/constants.h \
4+ src/builtins/H/builtins.h src/diagnostics/H/diagnostics.h \
5+ src/compiler/H/loop_stack.h
6+src/compiler/H/internal.h:
7+src/compiler/H/state.h:
8+src/compiler/H/compiler.h:
9+src/vm/H/chunk.h:
10+src/value/H/value.h:
11+src/lexer/H/lexer.h:
12+src/compiler/H/constants.h:
13+src/builtins/H/builtins.h:
14+src/diagnostics/H/diagnostics.h:
15+src/compiler/H/loop_stack.h:

ashvm/src/compiler/D/constants.dadded

+12−0
@@ -0,0 +1,12 @@
1+src/compiler/O/constants.o: src/compiler/C/constants.c \
2+ src/compiler/H/constants.h src/value/H/value.h src/compiler/H/internal.h \
3+ src/compiler/H/state.h src/compiler/H/compiler.h src/vm/H/chunk.h \
4+ src/lexer/H/lexer.h src/diagnostics/H/diagnostics.h
5+src/compiler/H/constants.h:
6+src/value/H/value.h:
7+src/compiler/H/internal.h:
8+src/compiler/H/state.h:
9+src/compiler/H/compiler.h:
10+src/vm/H/chunk.h:
11+src/lexer/H/lexer.h:
12+src/diagnostics/H/diagnostics.h:

ashvm/src/compiler/D/expr.dadded

+12−0
@@ -0,0 +1,12 @@
1+src/compiler/O/expr.o: src/compiler/C/expr.c src/compiler/H/internal.h \
2+ src/compiler/H/state.h src/compiler/H/compiler.h src/vm/H/chunk.h \
3+ src/value/H/value.h src/lexer/H/lexer.h src/compiler/H/constants.h \
4+ src/diagnostics/H/diagnostics.h
5+src/compiler/H/internal.h:
6+src/compiler/H/state.h:
7+src/compiler/H/compiler.h:
8+src/vm/H/chunk.h:
9+src/value/H/value.h:
10+src/lexer/H/lexer.h:
11+src/compiler/H/constants.h:
12+src/diagnostics/H/diagnostics.h:

ashvm/src/compiler/D/import_paths.dadded

+3−0
@@ -0,0 +1,3 @@
1+src/compiler/O/import_paths.o: src/compiler/C/import_paths.c \
2+ src/compiler/H/import_paths.h
3+src/compiler/H/import_paths.h:

ashvm/src/compiler/D/loop_stack.dadded

+11−0
@@ -0,0 +1,11 @@
1+src/compiler/O/loop_stack.o: src/compiler/C/loop_stack.c \
2+ src/compiler/H/loop_stack.h src/lexer/H/lexer.h src/compiler/H/state.h \
3+ src/compiler/H/compiler.h src/vm/H/chunk.h src/value/H/value.h \
4+ src/diagnostics/H/diagnostics.h
5+src/compiler/H/loop_stack.h:
6+src/lexer/H/lexer.h:
7+src/compiler/H/state.h:
8+src/compiler/H/compiler.h:
9+src/vm/H/chunk.h:
10+src/value/H/value.h:
11+src/diagnostics/H/diagnostics.h:

ashvm/src/compiler/D/state.dadded

+9−0
@@ -0,0 +1,9 @@
1+src/compiler/O/state.o: src/compiler/C/state.c src/compiler/H/state.h \
2+ src/compiler/H/compiler.h src/vm/H/chunk.h src/value/H/value.h \
3+ src/lexer/H/lexer.h src/diagnostics/H/diagnostics.h
4+src/compiler/H/state.h:
5+src/compiler/H/compiler.h:
6+src/vm/H/chunk.h:
7+src/value/H/value.h:
8+src/lexer/H/lexer.h:
9+src/diagnostics/H/diagnostics.h:

ashvm/src/compiler/D/stmt.dadded

+13−0
@@ -0,0 +1,13 @@
1+src/compiler/O/stmt.o: src/compiler/C/stmt.c src/compiler/H/internal.h \
2+ src/compiler/H/state.h src/compiler/H/compiler.h src/vm/H/chunk.h \
3+ src/value/H/value.h src/lexer/H/lexer.h src/compiler/H/constants.h \
4+ src/diagnostics/H/diagnostics.h src/compiler/H/loop_stack.h
5+src/compiler/H/internal.h:
6+src/compiler/H/state.h:
7+src/compiler/H/compiler.h:
8+src/vm/H/chunk.h:
9+src/value/H/value.h:
10+src/lexer/H/lexer.h:
11+src/compiler/H/constants.h:
12+src/diagnostics/H/diagnostics.h:
13+src/compiler/H/loop_stack.h:

ashvm/src/compiler/D/stmt_control.dadded

+16−0
@@ -0,0 +1,16 @@
1+src/compiler/O/stmt_control.o: src/compiler/C/stmt_control.c \
2+ src/compiler/H/internal.h src/compiler/H/state.h \
3+ src/compiler/H/compiler.h src/vm/H/chunk.h src/value/H/value.h \
4+ src/lexer/H/lexer.h src/compiler/H/constants.h \
5+ src/diagnostics/H/diagnostics.h src/builtins/H/builtins.h \
6+ src/compiler/H/loop_stack.h
7+src/compiler/H/internal.h:
8+src/compiler/H/state.h:
9+src/compiler/H/compiler.h:
10+src/vm/H/chunk.h:
11+src/value/H/value.h:
12+src/lexer/H/lexer.h:
13+src/compiler/H/constants.h:
14+src/diagnostics/H/diagnostics.h:
15+src/builtins/H/builtins.h:
16+src/compiler/H/loop_stack.h:

ashvm/src/compiler/D/stmt_import.dadded

+14−0
@@ -0,0 +1,14 @@
1+src/compiler/O/stmt_import.o: src/compiler/C/stmt_import.c \
2+ src/compiler/H/internal.h src/compiler/H/state.h \
3+ src/compiler/H/compiler.h src/vm/H/chunk.h src/value/H/value.h \
4+ src/lexer/H/lexer.h src/compiler/H/constants.h \
5+ src/compiler/H/import_paths.h src/diagnostics/H/diagnostics.h
6+src/compiler/H/internal.h:
7+src/compiler/H/state.h:
8+src/compiler/H/compiler.h:
9+src/vm/H/chunk.h:
10+src/value/H/value.h:
11+src/lexer/H/lexer.h:
12+src/compiler/H/constants.h:
13+src/compiler/H/import_paths.h:
14+src/diagnostics/H/diagnostics.h:

ashvm/src/compiler/H/constants.hmodified

+1−1
@@ -18,7 +18,7 @@ VMConstant *resolve_constant(const char *name, int len);
1818 // would have -- used at every `ns.CONST` use site.
1919 void codegen_constant_value(VMConstant *k);
2020
21-// Called at the top of let_statement(), right after the variable name and
21+// Called at the top of local_statement(), right after the variable name and
2222 // '=' have been consumed: if an import namespace is currently active,
2323 // consumes the RHS (must be a literal number or string), registers it as
2424 // a namespaced constant, and consumes the trailing ';'. Returns 1 if it

ashvm/src/compiler/H/internal.hmodified

+15−10
@@ -14,11 +14,15 @@ void emit_call(Token id);
1414 void emit_call_named(const char *name, int len);
1515 void lambda_literal(void);
1616 // Tries to compile `identifier CMP (number|identifier)` directly into a
17-// fused OP_CMP_JUMP, used by if/while conditions. Returns the bytecode
18-// offset to patch_jump() once the branch target is known, or -1 if the
19-// condition didn't match the fusable shape (caller falls back to a plain
20-// expression() + OP_JUMP_IF_FALSE).
21-int try_fuse_condition(void);
17+// fused OP_CMP_JUMP, used by if/while conditions. `terminator` is whatever
18+// token must immediately follow the comparison for the fusion to apply --
19+// TOKEN_RPAREN when the condition was parenthesized, or TOKEN_LBRACE when
20+// it wasn't (conditions may optionally drop the parens, but the block's
21+// opening '{' is always there) -- so fusion keeps firing either way.
22+// Returns the bytecode offset to patch_jump() once the branch target is
23+// known, or -1 if the condition didn't match the fusable shape (caller
24+// falls back to a plain expression() + OP_JUMP_IF_FALSE).
25+int try_fuse_condition(TokenType terminator);
2226
2327 // compiler_stmt.c
2428 void block(void);
@@ -29,13 +33,14 @@ void block(void);
2933 // rather than staying static to compiler_stmt.c.
3034 void statement(void);
3135
32-// compiler_stmt_control.c -- the four statement kinds involved enough to
36+// compiler_stmt_control.c -- the statement kinds involved enough to
3337 // warrant their own function (each assumes its keyword token has already
3438 // been consumed by statement()'s dispatch in compiler_stmt.c)
35-void compile_fn_decl(void);
36-void compile_if_stmt(void);
37-void compile_try_stmt(void);
38-void compile_while_stmt(void);
39+void compile_forge_decl(void);
40+void compile_given_stmt(void);
41+void compile_attempt_stmt(void);
42+void compile_during_stmt(void);
43+void compile_each_stmt(void);
3944
4045 // compiler_stmt_import.c
4146 void compile_import_stmt(void);

ashvm/src/compiler/H/loop_stack.hadded

+50−0
@@ -0,0 +1,50 @@
1+#ifndef ASH_VM_COMPILER_LOOP_STACK_H
2+#define ASH_VM_COMPILER_LOOP_STACK_H
3+#include "lexer/H/lexer.h"
4+
5+// stop/next need to know which loop they belong to, and loops nest --
6+// they're pure "emit now, patch later" jump bookkeeping (same primitive
7+// OP_JUMP/OP_JUMP_IF_FALSE already use elsewhere in this compiler), just
8+// tracked per-nesting-level at compile time instead of resolved
9+// immediately. Mirrors kiln's own parser/C/statements/loop_stack.c.
10+//
11+// next can't simply backward-jump to a known offset the way a plain loop
12+// back-edge does: `next`'s target (the per-iteration advance step -- an
13+// each-loop's index increment, or straight back to the condition for a
14+// during loop) is only emitted AFTER the body compiles, i.e. after the
15+// next statement itself. So next jumps are forward-patched too, resolved
16+// by the loop's own codegen right after block() returns, before whatever
17+// per-iteration bookkeeping runs next.
18+
19+// Pushes a fresh loop context (call right before compiling the body).
20+void loop_push(void);
21+
22+// Called from stmt.c's stop_statement()/next_statement() -- records a
23+// forward jump's patch offset into the innermost loop's list. `keyword`
24+// anchors the "outside a loop" diagnostic at the stop/next token itself
25+// rather than wherever parsing happens to be next. Exits via
26+// diagnostics_report + exit(1) if called outside any loop.
27+void loop_record_stop(Token keyword, int patch_offset);
28+void loop_record_next(Token keyword, int patch_offset);
29+
30+// Patches every recorded next jump to the CURRENT code position -- call
31+// right after block() returns, before emitting the per-iteration advance
32+// step.
33+void loop_patch_nexts(void);
34+
35+// Patches every recorded stop jump to the CURRENT code position, then
36+// pops the loop context -- call once the loop is fully compiled.
37+void loop_pop_and_patch_stops(void);
38+
39+// Save/reset/restore the loop-nesting depth, for compile_forge_decl and
40+// lambda_literal to wrap around a called body's compilation (same spirit
41+// as local_count's own save-recurse-restore): a function or lambda body
42+// gets its own Chunk at runtime, so a stop/next inside one must never
43+// reach past it into an ENCLOSING loop it happens to be textually written
44+// inside -- jumps can't cross Chunks anyway, so this is what turns that
45+// into a clean compile error instead of nonsense bytecode.
46+void loop_depth_save(int *out);
47+void loop_depth_reset(void);
48+void loop_depth_restore(int saved);
49+
50+#endif

ashvm/src/compiler/H/state.hmodified

+2−2
@@ -36,8 +36,8 @@ void get_import_namespace(const char **out_ns, int *out_len);
3636
3737 // Declares a NEW vm_functions[] entry under "ns.NAME" if a namespace is
3838 // currently set, else under the bare NAME. Returns the new entry's index
39-// (mirrors compile_fn_decl's own prior inline logic, now centralized so
40-// imports.c-equivalent code and compile_fn_decl share it). Exits via
39+// (mirrors compile_forge_decl's own prior inline logic, now centralized so
40+// imports.c-equivalent code and compile_forge_decl share it). Exits via
4141 // diagnostics_report + exit(1) on table-full or name-too-long, matching
4242 // every other error path in this file.
4343 int declare_function_in_context(const char *name, int len);

ashvm/src/diagnostics/C/diagnostics.cmodified

+1−1
@@ -62,7 +62,7 @@ static int has_prefix(const char *message, const char *prefix) {
6262 }
6363
6464 static const char *lookup_hint(const char *message) {
65- if (has_prefix(message, "undefined variable")) return "did you forget to declare it with `let`?";
65+ if (has_prefix(message, "undefined variable")) return "did you forget to declare it with `local`?";
6666 if (has_prefix(message, "undefined function")) return "check the function name is spelled correctly";
6767 if (has_prefix(message, "index out of bounds")) return "check the array's length before indexing";
6868 if (has_prefix(message, "key not found")) return "use `has(map, key)` to check before accessing";

ashvm/src/diagnostics/D/diagnostics.dadded

+3−0
@@ -0,0 +1,3 @@
1+src/diagnostics/O/diagnostics.o: src/diagnostics/C/diagnostics.c \
2+ src/diagnostics/H/diagnostics.h
3+src/diagnostics/H/diagnostics.h:

ashvm/src/lexer/C/lexer.cmodified

+46−22
@@ -107,17 +107,29 @@ static Token identifier(void) {
107107 while (isalnum(peek()) || peek() == '_') advance();
108108
109109 int length = (int)(lex_current - lex_start);
110- if (length == 5 && strncmp(lex_start, "print", 5) == 0) return make_token(TOKEN_PRINT);
111- if (length == 3 && strncmp(lex_start, "let", 3) == 0) return make_token(TOKEN_LET);
112- if (length == 2 && strncmp(lex_start, "if", 2) == 0) return make_token(TOKEN_IF);
113- if (length == 4 && strncmp(lex_start, "else", 4) == 0) return make_token(TOKEN_ELSE);
114- if (length == 5 && strncmp(lex_start, "while", 5) == 0) return make_token(TOKEN_WHILE);
115- if (length == 3 && strncmp(lex_start, "for", 3) == 0) return make_token(TOKEN_FOR);
110+ if (length == 3 && strncmp(lex_start, "say", 3) == 0) return make_token(TOKEN_SAY);
111+ if (length == 5 && strncmp(lex_start, "local", 5) == 0) return make_token(TOKEN_LOCAL);
112+ if (length == 5 && strncmp(lex_start, "given", 5) == 0) return make_token(TOKEN_GIVEN);
113+ if (length == 9 && strncmp(lex_start, "otherwise", 9) == 0) return make_token(TOKEN_OTHERWISE);
114+ if (length == 6 && strncmp(lex_start, "during", 6) == 0) return make_token(TOKEN_DURING);
115+ if (length == 4 && strncmp(lex_start, "each", 4) == 0) return make_token(TOKEN_EACH);
116116 if (length == 2 && strncmp(lex_start, "in", 2) == 0) return make_token(TOKEN_IN);
117- if (length == 2 && strncmp(lex_start, "fn", 2) == 0) return make_token(TOKEN_FN);
118- if (length == 6 && strncmp(lex_start, "return", 6) == 0) return make_token(TOKEN_RETURN);
119- if (length == 3 && strncmp(lex_start, "try", 3) == 0) return make_token(TOKEN_TRY);
120- if (length == 5 && strncmp(lex_start, "catch", 5) == 0) return make_token(TOKEN_CATCH);
117+ if (length == 5 && strncmp(lex_start, "forge", 5) == 0) return make_token(TOKEN_FORGE);
118+ if (length == 5 && strncmp(lex_start, "yield", 5) == 0) return make_token(TOKEN_YIELD);
119+ if (length == 7 && strncmp(lex_start, "attempt", 7) == 0) return make_token(TOKEN_ATTEMPT);
120+ if (length == 6 && strncmp(lex_start, "handle", 6) == 0) return make_token(TOKEN_HANDLE);
121+ if (length == 5 && strncmp(lex_start, "raise", 5) == 0) return make_token(TOKEN_RAISE);
122+ if (length == 4 && strncmp(lex_start, "stop", 4) == 0) return make_token(TOKEN_STOP);
123+ if (length == 4 && strncmp(lex_start, "next", 4) == 0) return make_token(TOKEN_NEXT);
124+ if (length == 3 && strncmp(lex_start, "yes", 3) == 0) return make_token(TOKEN_YES);
125+ if (length == 2 && strncmp(lex_start, "no", 2) == 0) return make_token(TOKEN_NO);
126+ if (length == 4 && strncmp(lex_start, "none", 4) == 0) return make_token(TOKEN_NONE);
127+ if (length == 3 && strncmp(lex_start, "and", 3) == 0) return make_token(TOKEN_AND);
128+ if (length == 2 && strncmp(lex_start, "or", 2) == 0) return make_token(TOKEN_OR);
129+ // "not" reuses TOKEN_BANG (unary logical negation) -- same meaning as
130+ // the symbol it replaces, so unary()'s existing TOKEN_BANG handling in
131+ // expr.c needs no change at all.
132+ if (length == 3 && strncmp(lex_start, "not", 3) == 0) return make_token(TOKEN_BANG);
121133
122134 return make_token(TOKEN_IDENTIFIER);
123135 }
@@ -145,27 +157,39 @@ Token lexer_next_token(void) {
145157 case ';': return make_token(TOKEN_SEMICOLON);
146158 case ',': return make_token(TOKEN_COMMA);
147159 case ':': return make_token(TOKEN_COLON);
148- case '+': return make_token(TOKEN_PLUS);
149- case '-': return make_token(TOKEN_MINUS);
150- case '*': return make_token(TOKEN_STAR);
151- case '/': return make_token(TOKEN_SLASH);
160+ case '?': return make_token(TOKEN_QUESTION);
161+ case '+':
162+ if (match('+')) return make_token(TOKEN_PLUS_PLUS);
163+ return make_token(match('=') ? TOKEN_PLUS_EQUAL : TOKEN_PLUS);
164+ case '-':
165+ if (match('-')) return make_token(TOKEN_MINUS_MINUS);
166+ return make_token(match('=') ? TOKEN_MINUS_EQUAL : TOKEN_MINUS);
167+ case '*': return make_token(match('=') ? TOKEN_STAR_EQUAL : TOKEN_STAR);
168+ case '/': return make_token(match('=') ? TOKEN_SLASH_EQUAL : TOKEN_SLASH);
152169 case '%': return make_token(TOKEN_PERCENT);
170+ case '&': return make_token(TOKEN_AMP);
171+ case '|': return make_token(TOKEN_PIPE);
172+ case '^': return make_token(TOKEN_CARET);
173+ case '~': return make_token(TOKEN_TILDE);
153174 case '(': return make_token(TOKEN_LPAREN);
154175 case ')': return make_token(TOKEN_RPAREN);
155176 case '{': return make_token(TOKEN_LBRACE);
156177 case '}': return make_token(TOKEN_RBRACE);
157178 case '[': return make_token(TOKEN_LBRACKET);
158179 case ']': return make_token(TOKEN_RBRACKET);
159180 case '=': return make_token(match('=') ? TOKEN_EQUAL_EQUAL : TOKEN_EQUAL);
160- case '!': return make_token(match('=') ? TOKEN_BANG_EQUAL : TOKEN_BANG);
161- case '<': return make_token(match('=') ? TOKEN_LESS_EQUAL : TOKEN_LESS);
162- case '>': return make_token(match('=') ? TOKEN_GREATER_EQUAL : TOKEN_GREATER);
163- case '&':
164- if (match('&')) return make_token(TOKEN_AND);
165- return error_token("unexpected character");
166- case '|':
167- if (match('|')) return make_token(TOKEN_OR);
181+ // Bare '!' is no longer valid on its own -- logical negation is the
182+ // 'not' keyword now (see identifier()). '!=' (inequality) stays
183+ // symbolic either way.
184+ case '!':
185+ if (match('=')) return make_token(TOKEN_BANG_EQUAL);
168186 return error_token("unexpected character");
187+ case '<':
188+ if (match('<')) return make_token(TOKEN_SHL);
189+ return make_token(match('=') ? TOKEN_LESS_EQUAL : TOKEN_LESS);
190+ case '>':
191+ if (match('>')) return make_token(TOKEN_SHR);
192+ return make_token(match('=') ? TOKEN_GREATER_EQUAL : TOKEN_GREATER);
169193 }
170194
171195 return error_token("unexpected character");

ashvm/src/lexer/D/lexer.dadded

+4−0
@@ -0,0 +1,4 @@
1+src/lexer/O/lexer.o: src/lexer/C/lexer.c src/lexer/H/lexer.h \
2+ src/lexer/H/lexer_import.h
3+src/lexer/H/lexer.h:
4+src/lexer/H/lexer_import.h:

ashvm/src/lexer/D/lexer_import.dadded

+4−0
@@ -0,0 +1,4 @@
1+src/lexer/O/lexer_import.o: src/lexer/C/lexer_import.c \
2+ src/lexer/H/lexer_import.h src/lexer/H/lexer.h
3+src/lexer/H/lexer_import.h:
4+src/lexer/H/lexer.h:

ashvm/src/lexer/H/lexer.hmodified

+29−10
@@ -2,18 +2,24 @@
22 #define LEXER_H
33
44 typedef enum {
5- TOKEN_PRINT,
6- TOKEN_LET,
7- TOKEN_IF,
8- TOKEN_ELSE,
9- TOKEN_WHILE,
10- TOKEN_FOR,
5+ TOKEN_SAY,
6+ TOKEN_LOCAL,
7+ TOKEN_GIVEN,
8+ TOKEN_OTHERWISE,
9+ TOKEN_DURING,
10+ TOKEN_EACH,
1111 TOKEN_IN,
1212 TOKEN_IMPORT,
13- TOKEN_FN,
14- TOKEN_RETURN,
15- TOKEN_TRY,
16- TOKEN_CATCH,
13+ TOKEN_FORGE,
14+ TOKEN_YIELD,
15+ TOKEN_ATTEMPT,
16+ TOKEN_HANDLE,
17+ TOKEN_RAISE,
18+ TOKEN_STOP,
19+ TOKEN_NEXT,
20+ TOKEN_YES,
21+ TOKEN_NO,
22+ TOKEN_NONE,
1723 TOKEN_IDENTIFIER,
1824 TOKEN_STRING,
1925 TOKEN_IMPORT_PATH,
@@ -41,6 +47,19 @@ typedef enum {
4147 TOKEN_COMMA,
4248 TOKEN_COLON,
4349 TOKEN_DOT,
50+ TOKEN_QUESTION,
51+ TOKEN_AMP,
52+ TOKEN_PIPE,
53+ TOKEN_CARET,
54+ TOKEN_TILDE,
55+ TOKEN_SHL,
56+ TOKEN_SHR,
57+ TOKEN_PLUS_EQUAL,
58+ TOKEN_MINUS_EQUAL,
59+ TOKEN_STAR_EQUAL,
60+ TOKEN_SLASH_EQUAL,
61+ TOKEN_PLUS_PLUS,
62+ TOKEN_MINUS_MINUS,
4463 TOKEN_NUMBER,
4564 TOKEN_SEMICOLON,
4665 TOKEN_EOF,

ashvm/src/value/C/value.cmodified

+5−0
@@ -7,6 +7,9 @@
77 VMValue vm_num(double n) {
88 VMValue v; v.type = VM_NUM; v.number = n; v.str = NULL; v.array = NULL; v.map = NULL; v.closure = NULL; return v;
99 }
10+VMValue vm_nil(void) {
11+ VMValue v; v.type = VM_NIL; v.number = 0; v.str = NULL; v.array = NULL; v.map = NULL; v.closure = NULL; return v;
12+}
1013 VMValue vm_str(char *s) {
1114 VMValue v; v.type = VM_STR; v.number = 0; v.str = s; v.array = NULL; v.map = NULL; v.closure = NULL; return v;
1215 }
@@ -80,6 +83,8 @@ void vm_print_value(VMValue v) {
8083 else printf("%g", v.number);
8184 } else if (v.type == VM_STR) {
8285 printf("%s", v.str);
86+ } else if (v.type == VM_NIL) {
87+ printf("none");
8388 } else if (v.type == VM_FUNCTION) {
8489 printf("<function>");
8590 } else if (v.type == VM_CLOSURE) {

ashvm/src/value/D/hashmap.dadded

+4−0
@@ -0,0 +1,4 @@
1+src/value/O/hashmap.o: src/value/C/hashmap.c src/value/H/hashmap.h \
2+ src/value/H/value.h
3+src/value/H/hashmap.h:
4+src/value/H/value.h:

ashvm/src/value/D/value.dadded

+4−0
@@ -0,0 +1,4 @@
1+src/value/O/value.o: src/value/C/value.c src/value/H/value.h \
2+ src/value/H/hashmap.h
3+src/value/H/value.h:
4+src/value/H/hashmap.h:

ashvm/src/value/H/value.hmodified

+2−1
@@ -2,7 +2,7 @@
22 #define ASH_VM_VALUE_H
33 #include <stdint.h>
44
5-typedef enum { VM_NUM, VM_STR, VM_ARRAY, VM_MAP, VM_FUNCTION, VM_CLOSURE } VMType;
5+typedef enum { VM_NUM, VM_STR, VM_ARRAY, VM_MAP, VM_FUNCTION, VM_CLOSURE, VM_NIL } VMType;
66
77 typedef struct VMArray {
88 struct VMValue *items;
@@ -28,6 +28,7 @@ typedef struct VMValue {
2828 } VMValue;
2929
3030 VMValue vm_num(double n);
31+VMValue vm_nil(void);
3132 VMValue vm_str(char *s);
3233 VMValue vm_array_val(VMArray *a);
3334 VMValue vm_map_val(VMMap *m);

ashvm/src/vm/C/dispatch.cmodified

+38−4
@@ -31,7 +31,10 @@ void vm_execute(int stop_at_frame_count) {
3131 &&do_ARRAY, &&do_INDEX_GET, &&do_INDEX_SET,
3232 &&do_MAP, &&do_CALL_VALUE, &&do_CALL_BUILTIN,
3333 &&do_MAKE_CLOSURE,
34- &&do_TRY_PUSH, &&do_TRY_POP
34+ &&do_TRY_PUSH, &&do_TRY_POP,
35+ &&do_RAISE,
36+ &&do_BAND, &&do_BOR, &&do_BXOR, &&do_BNOT, &&do_SHL, &&do_SHR,
37+ &&do_TRUNC_LOCALS
3538 };
3639
3740 #define DISPATCH() \
@@ -64,6 +67,7 @@ void vm_execute(int stop_at_frame_count) {
6467 int eq;
6568 if (a->type == VM_STR && b.type == VM_STR) eq = strcmp(a->str, b.str) == 0;
6669 else if (a->type == VM_NUM && b.type == VM_NUM) eq = a->number == b.number;
70+ else if (a->type == VM_NIL && b.type == VM_NIL) eq = 1;
6771 else eq = 0;
6872 *a = vm_num(eq);
6973 DISPATCH();
@@ -73,6 +77,7 @@ void vm_execute(int stop_at_frame_count) {
7377 int eq;
7478 if (a->type == VM_STR && b.type == VM_STR) eq = strcmp(a->str, b.str) == 0;
7579 else if (a->type == VM_NUM && b.type == VM_NUM) eq = a->number == b.number;
80+ else if (a->type == VM_NIL && b.type == VM_NIL) eq = 1;
7681 else eq = 0;
7782 *a = vm_num(!eq);
7883 DISPATCH();
@@ -183,6 +188,7 @@ void vm_execute(int stop_at_frame_count) {
183188 int eq;
184189 if (a.type == VM_STR && b.type == VM_STR) eq = strcmp(a.str, b.str) == 0;
185190 else if (a.type == VM_NUM && b.type == VM_NUM) eq = a.number == b.number;
191+ else if (a.type == VM_NIL && b.type == VM_NIL) eq = 1;
186192 else eq = 0;
187193 taken = (cmp_code == 4) ? eq : !eq;
188194 } else {
@@ -282,14 +288,42 @@ void vm_execute(int stop_at_frame_count) {
282288 stack_top = h->saved_stack_top;
283289 frame = h->target_frame;
284290 frame->ip = h->target_ip;
285- char *msg_copy = malloc(strlen(error_message) + 1);
286- strcpy(msg_copy, error_message);
287- *stack_top++ = vm_str(msg_copy);
291+ if (has_raised_value) {
292+ *stack_top++ = raised_value;
293+ has_raised_value = 0;
294+ } else {
295+ char *msg_copy = malloc(strlen(error_message) + 1);
296+ strcpy(msg_copy, error_message);
297+ *stack_top++ = vm_str(msg_copy);
298+ }
288299 DISPATCH();
289300 }
290301 }
291302 do_TRY_POP: {
292303 try_depth--;
293304 DISPATCH();
294305 }
306+ do_RAISE: {
307+ VMValue msg = *--stack_top;
308+ vm_raise_value(msg); // noreturn -- longjmps to a handler, or exits
309+ }
310+ // Bitwise ops truncate to int64 (matching kiln's cvttsd2si approach)
311+ // then convert back -- same "assume NUMBER" scope limit as -, *, /,
312+ // and the comparison operators.
313+ do_BAND: { VMValue b = *--stack_top; VMValue *a = stack_top - 1; *a = vm_num((double)((int64_t)require_num(*a, "'&'") & (int64_t)require_num(b, "'&'"))); DISPATCH(); }
314+ do_BOR: { VMValue b = *--stack_top; VMValue *a = stack_top - 1; *a = vm_num((double)((int64_t)require_num(*a, "'|'") | (int64_t)require_num(b, "'|'"))); DISPATCH(); }
315+ do_BXOR: { VMValue b = *--stack_top; VMValue *a = stack_top - 1; *a = vm_num((double)((int64_t)require_num(*a, "'^'") ^ (int64_t)require_num(b, "'^'"))); DISPATCH(); }
316+ do_BNOT: { stack_top[-1] = vm_num((double)(~(int64_t)require_num(stack_top[-1], "'~'"))); DISPATCH(); }
317+ do_SHL: { VMValue b = *--stack_top; VMValue *a = stack_top - 1; *a = vm_num((double)((int64_t)require_num(*a, "'<<'") << (int64_t)require_num(b, "'<<'"))); DISPATCH(); }
318+ do_SHR: { VMValue b = *--stack_top; VMValue *a = stack_top - 1; *a = vm_num((double)((int64_t)require_num(*a, "'>>'") >> (int64_t)require_num(b, "'>>'"))); DISPATCH(); }
319+ // Resets the stack to exactly `frame->slots + n` -- an absolute reset,
320+ // not N pops. A re-executed block (loop body, given/otherwise branch)
321+ // may declare a variable number of fresh locals depending on which
322+ // internal path it took (early `next`, nested given, ...), so "pop
323+ // however many were declared on a typical pass" can under- or
324+ // over-pop. Since every local is a stack slot and slot layout is
325+ // static per chunk, "what the stack should look like once this block
326+ // is done" is just "n slots past frame->slots" -- always correct
327+ // regardless of the path taken through the block this time.
328+ do_TRUNC_LOCALS: { uint8_t n = *frame->ip++; stack_top = frame->slots + n; DISPATCH(); }
295329 }

ashvm/src/vm/C/errors.cmodified

+32−2
@@ -7,9 +7,10 @@
77
88 // Every genuinely-runtime error (as opposed to a compile-time/structural one,
99 // which stays a hard crash since ashvm resolves names at compile time) funnels
10-// through here. If a try block is active, longjmp back to its handler instead
11-// of crashing the whole program.
10+// through here. If an attempt block is active, longjmp back to its handler
11+// instead of crashing the whole program.
1212 void vm_runtime_error(const char *fmt, ...) {
13+ has_raised_value = 0; // this error carries a formatted C string, not a raised VMValue
1314 va_list args;
1415 va_start(args, fmt);
1516 vsnprintf(error_message, sizeof(error_message), fmt, args);
@@ -28,6 +29,31 @@ void vm_runtime_error(const char *fmt, ...) {
2829 }
2930 }
3031
32+// `raise expr;` -- expr must already be a VM_STR (checked here, not at
33+// compile time, since the raised value is a general expression that could
34+// be anything at runtime). Mirrors vm_runtime_error's three-way dispatch
35+// (attempt handler / REPL recovery / hard exit) exactly, but hands back
36+// the exact raised VMValue via `raised_value` instead of funneling it
37+// through the fixed-size error_message buffer -- see state.h.
38+void vm_raise_value(VMValue msg) {
39+ if (msg.type != VM_STR) type_error("'raise'");
40+
41+ raised_value = msg;
42+ has_raised_value = 1;
43+
44+ if (try_depth > 0) {
45+ longjmp(try_handlers[try_depth - 1].jmp, 1);
46+ } else if (repl_recovery_jmp != NULL) {
47+ diagnostics_report("error", msg.str, current_pos, current_poslen);
48+ frame_count = repl_saved_frame_count;
49+ stack_top = repl_saved_stack_top;
50+ longjmp(*repl_recovery_jmp, 1);
51+ } else {
52+ diagnostics_report("error", msg.str, current_pos, current_poslen);
53+ exit(1);
54+ }
55+}
56+
3157 void type_error(const char *context) {
3258 vm_runtime_error("type error: invalid operand type in %s", context);
3359 }
@@ -38,5 +64,9 @@ double require_num(VMValue v, const char *context) {
3864 }
3965
4066 int truthy(VMValue v, const char *context) {
67+ // none is always falsy -- checked here rather than erroring like every
68+ // other non-number would, matching kiln's own nil-is-always-falsy rule
69+ // (its payload is always exactly 0).
70+ if (v.type == VM_NIL) return 0;
4171 return require_num(v, context) != 0;
4272 }

ashvm/src/vm/C/state.cmodified

+3−0
@@ -10,6 +10,9 @@ TryHandler try_handlers[MAX_TRY_DEPTH];
1010 int try_depth = 0;
1111 char error_message[512];
1212
13+VMValue raised_value;
14+int has_raised_value = 0;
15+
1316 jmp_buf *repl_recovery_jmp = NULL;
1417 int repl_saved_frame_count;
1518 VMValue *repl_saved_stack_top;

ashvm/src/vm/D/api.dadded

+7−0
@@ -0,0 +1,7 @@
1+src/vm/O/api.o: src/vm/C/api.c src/vm/H/vm.h src/vm/H/chunk.h \
2+ src/value/H/value.h src/vm/H/state.h src/compiler/H/compiler.h
3+src/vm/H/vm.h:
4+src/vm/H/chunk.h:
5+src/value/H/value.h:
6+src/vm/H/state.h:
7+src/compiler/H/compiler.h:

ashvm/src/vm/D/chunk.dadded

+3−0
@@ -0,0 +1,3 @@
1+src/vm/O/chunk.o: src/vm/C/chunk.c src/vm/H/chunk.h src/value/H/value.h
2+src/vm/H/chunk.h:
3+src/value/H/value.h:

ashvm/src/vm/D/dispatch.dadded

+10−0
@@ -0,0 +1,10 @@
1+src/vm/O/dispatch.o: src/vm/C/dispatch.c src/vm/H/vm.h src/vm/H/chunk.h \
2+ src/value/H/value.h src/vm/H/state.h src/compiler/H/compiler.h \
3+ src/builtins/H/builtins.h src/value/H/hashmap.h
4+src/vm/H/vm.h:
5+src/vm/H/chunk.h:
6+src/value/H/value.h:
7+src/vm/H/state.h:
8+src/compiler/H/compiler.h:
9+src/builtins/H/builtins.h:
10+src/value/H/hashmap.h:

ashvm/src/vm/D/errors.dadded

+7−0
@@ -0,0 +1,7 @@
1+src/vm/O/errors.o: src/vm/C/errors.c src/vm/H/vm.h src/vm/H/chunk.h \
2+ src/value/H/value.h src/vm/H/state.h src/diagnostics/H/diagnostics.h
3+src/vm/H/vm.h:
4+src/vm/H/chunk.h:
5+src/value/H/value.h:
6+src/vm/H/state.h:
7+src/diagnostics/H/diagnostics.h:

ashvm/src/vm/D/state.dadded

+5−0
@@ -0,0 +1,5 @@
1+src/vm/O/state.o: src/vm/C/state.c src/vm/H/state.h src/vm/H/chunk.h \
2+ src/value/H/value.h
3+src/vm/H/state.h:
4+src/vm/H/chunk.h:
5+src/value/H/value.h:

ashvm/src/vm/H/chunk.hmodified

+4−1
@@ -22,7 +22,10 @@ typedef enum {
2222 OP_CALL_BUILTIN,
2323 OP_MAKE_CLOSURE,
2424 OP_TRY_PUSH,
25- OP_TRY_POP
25+ OP_TRY_POP,
26+ OP_RAISE,
27+ OP_BAND, OP_BOR, OP_BXOR, OP_BNOT, OP_SHL, OP_SHR,
28+ OP_TRUNC_LOCALS
2629 } OpCode;
2730
2831 typedef struct {

ashvm/src/vm/H/state.hmodified

+8−0
@@ -35,6 +35,14 @@ extern TryHandler try_handlers[MAX_TRY_DEPTH];
3535 extern int try_depth;
3636 extern char error_message[512];
3737
38+// Set by vm_raise_value() right before it longjmps, consumed (and cleared)
39+// by do_TRY_PUSH's recovery branch in dispatch.c -- lets a caught `raise`
40+// hand back the exact VMValue the program raised instead of being forced
41+// through the fixed-size error_message C-string buffer every other runtime
42+// error uses.
43+extern VMValue raised_value;
44+extern int has_raised_value;
45+
3846 extern jmp_buf *repl_recovery_jmp;
3947 extern int repl_saved_frame_count;
4048 extern VMValue *repl_saved_stack_top;

ashvm/src/vm/H/vm.hmodified

+6−0
@@ -12,5 +12,11 @@ VMValue vm_call_function_sync(int func_idx, VMValue *args, int argc);
1212 VMValue vm_call_closure_sync(VMClosureObj *c, VMValue *args, int argc);
1313 VMValue vm_call_callable_sync(VMValue callable, VMValue *args, int argc);
1414 void vm_runtime_error(const char *fmt, ...) __attribute__((noreturn, format(printf, 1, 2)));
15+// Like vm_runtime_error, but for a RUNTIME string value (`raise expr;`)
16+// instead of a compile-time format string -- msg must be VM_STR. Unlike
17+// vm_runtime_error, the caught value is the exact string the program
18+// raised, not a copy funneled through the fixed-size error_message buffer,
19+// so an arbitrarily long raised message survives intact.
20+void vm_raise_value(VMValue msg) __attribute__((noreturn));
1521
1622 #endif

ashvm/tests/cases/basics/bare_reassign_test.ashadded

+10−0
@@ -0,0 +1,10 @@
1+forge f(i) {
2+ i = i + 1;
3+ i = i * 2;
4+ yield i;
5+}
6+say f(5);
7+
8+local x = 1;
9+x = x + 10;
10+say x;

ashvm/tests/cases/basics/literals_test.ashadded

+29−0
@@ -0,0 +1,29 @@
1+say yes;
2+say no;
3+say none;
4+
5+say yes == 1;
6+say no == 0;
7+say none == none;
8+say none == 0;
9+say none == no;
10+
11+given yes {
12+ say "yes is truthy";
13+}
14+given no {
15+ say "unreachable";
16+} otherwise {
17+ say "no is falsy";
18+}
19+local x = none;
20+given x {
21+ say "unreachable";
22+} otherwise {
23+ say "none is falsy";
24+}
25+
26+local arr = [1, none, yes, no];
27+say arr;
28+local m = {"a": none, "b": yes};
29+say m;

ashvm/tests/cases/basics/modulo.ashadded

+7−0
@@ -0,0 +1,7 @@
1+say 7 % 3;
2+say -7 % 3;
3+say 7 % -3;
4+say -7 % -3;
5+say 7.5 % 2;
6+say 10 % 2 == 0;
7+say 10 % 3 == 1;

ashvm/tests/cases/basics/operators_test.ashadded

+31−0
@@ -0,0 +1,31 @@
1+local x = 10;
2+x += 5;
3+say x;
4+x -= 3;
5+say x;
6+x *= 2;
7+say x;
8+x /= 4;
9+say x;
10+
11+local s = "hello";
12+s += " world";
13+say s;
14+
15+local i = 0;
16+i++;
17+i++;
18+i--;
19+say i;
20+
21+local n = 7;
22+say n > 5 ? "big" : "small";
23+say n < 5 ? "big" : n < 10 ? "medium" : "huge";
24+
25+say 5 & 3;
26+say 5 | 2;
27+say 5 ^ 1;
28+say ~5;
29+say 1 << 4;
30+say 256 >> 4;
31+say (5 & 3) == 1;

ashvm/tests/cases/basics/type_test.ashadded

+8−0
@@ -0,0 +1,8 @@
1+say type(5);
2+say type("hi");
3+say type([1, 2]);
4+say type({"a": 1});
5+say type(none);
6+say type(yes);
7+forge f() { yield 1; }
8+say type(f);

ashvm/tests/cases/collections/maps_test.ashmodified

+14−14
@@ -1,22 +1,22 @@
1-let person = {"name": "Lennox", "age": 20};
2-print person;
3-print person["name"];
4-print person["age"];
1+local person = {"name": "Lennox", "age": 20};
2+say person;
3+say person["name"];
4+say person["age"];
55
66 person["age"] = 21;
7-print person["age"];
7+say person["age"];
88
99 person["city"] = "Luebeck";
10-print person;
10+say person;
1111
12-print keys(person);
13-print values(person);
14-print has(person, "name");
15-print has(person, "zip");
12+say keys(person);
13+say values(person);
14+say has(person, "name");
15+say has(person, "zip");
1616
1717 delete(person, "city");
18-print person;
18+say person;
1919
20-let empty = {};
21-print empty;
22-print len(person);
20+local empty = {};
21+say empty;
22+say len(person);

ashvm/tests/cases/collections/vm_arrays_test.ashmodified

+16−16
@@ -1,23 +1,23 @@
1-let nums = [1, 2, 3, 4, 5];
2-print nums;
3-print nums[0];
4-print nums[4];
1+local nums = [1, 2, 3, 4, 5];
2+say nums;
3+say nums[0];
4+say nums[4];
55
66 nums[0] = 99;
7-print nums;
7+say nums;
88
9-let sum = 0;
10-let i = 0;
11-while (i < 5) {
12- let sum = sum + nums[i];
13- let i = i + 1;
9+local sum = 0;
10+local i = 0;
11+during (i < 5) {
12+ local sum = sum + nums[i];
13+ local i = i + 1;
1414 }
15-print sum;
15+say sum;
1616
17-fn first(arr) {
18- return arr[0];
17+forge first(arr) {
18+ yield arr[0];
1919 }
20-print first(nums);
20+say first(nums);
2121
22-let names = ["Ash", "Sonnet"];
23-print names[1];
22+local names = ["Ash", "Sonnet"];
23+say names[1];

ashvm/tests/cases/control_flow/each_test.ashadded

+45−0
@@ -0,0 +1,45 @@
1+local nums = [10, 20, 30, 40, 50];
2+
3+each x in nums {
4+ say x;
5+}
6+
7+each (y in nums) {
8+ say y * 2;
9+}
10+
11+local m = {"a": 1, "b": 2, "c": 3};
12+each k in keys(m) {
13+ say k + " = " + str(m[k]);
14+}
15+
16+local total = 0;
17+each n in nums {
18+ local total = total + n;
19+}
20+say total;
21+
22+local empty = [];
23+local empty_count = 0;
24+each e in empty {
25+ local empty_count = empty_count + 1;
26+}
27+say empty_count;
28+
29+local rows = [[1, 2], [3, 4], [5, 6]];
30+local line = "";
31+each row in rows {
32+ local line = "";
33+ each cell in row {
34+ local line = line + str(cell) + " ";
35+ }
36+ say line;
37+}
38+
39+local outer_total = 0;
40+each a in [1, 2, 3] {
41+ each b in [10, 20] {
42+ local outer_total = outer_total + a * b;
43+ }
44+}
45+say outer_total;

ashvm/tests/cases/control_flow/loop_body_locals_test.ashadded

+42−0
@@ -0,0 +1,42 @@
1+// A fresh `local` declared inside a re-executed block must not leak across
2+// iterations/branches -- found via a cross-engine audit against kiln.
3+
4+local i = 0;
5+during i < 3 {
6+ local x = i * 10;
7+ say x;
8+ i += 1;
9+}
10+
11+local arr = [1, 2, 3];
12+each n in arr {
13+ local y = n * 10;
14+ say y;
15+}
16+
17+local j = 0;
18+during j < 6 {
19+ given j % 2 == 0 {
20+ local a = j * 100;
21+ say a;
22+ } otherwise {
23+ j += 1;
24+ next;
25+ }
26+ j += 1;
27+}
28+
29+each x in arr {
30+ given x == 2 { next; }
31+ local z = x * 1000;
32+ say z;
33+}
34+
35+each outer in [1, 2] {
36+ local tmp = outer;
37+ each inner in [10, 20] {
38+ local tmp2 = outer + inner;
39+ say tmp2;
40+ }
41+ say tmp;
42+}

ashvm/tests/cases/control_flow/loop_control_test.ashadded

+41−0
@@ -0,0 +1,41 @@
1+local total = 0;
2+local j = 0;
3+during j < 10 {
4+ j += 1;
5+ given j == 3 {
6+ next;
7+ }
8+ given j == 7 {
9+ stop;
10+ }
11+ total += j;
12+}
13+say total;
14+say j;
15+
16+local sum = 0;
17+each v in [1, 2, 3, 4, 5, 6, 7, 8] {
18+ given v == 2 {
19+ next;
20+ }
21+ given v == 6 {
22+ stop;
23+ }
24+ sum += v;
25+}
26+say sum;
27+
28+// stop only breaks the innermost loop
29+local results = [];
30+each a in [1, 2, 3] {
31+ each b in [10, 20, 30] {
32+ given b == 20 {
33+ next;
34+ }
35+ given a == 3 {
36+ stop;
37+ }
38+ local results = push(results, a * b);
39+ }
40+}
41+say results;

ashvm/tests/cases/core/vm_full_test.ashmodified

+30−30
@@ -1,39 +1,39 @@
1-let person = {"name": "Ash", "version": 2};
2-print person;
3-print person["name"];
1+local person = {"name": "Ash", "version": 2};
2+say person;
3+say person["name"];
44 person["version"] = 3;
5-print person;
6-print keys(person);
7-print values(person);
8-print has(person, "name");
5+say person;
6+say keys(person);
7+say values(person);
8+say has(person, "name");
99 delete(person, "version");
10-print person;
10+say person;
1111
12-fn double(x) { return x * 2; }
13-let f = double;
14-print f(21);
12+forge double(x) { yield x * 2; }
13+local f = double;
14+say f(21);
1515
16-let nums = [1, 2, 3, 4, 5];
17-let doubled = map(nums, double);
18-print doubled;
16+local nums = [1, 2, 3, 4, 5];
17+local doubled = map(nums, double);
18+say doubled;
1919
20-fn isEven(x) { return x % 2 == 0; }
21-let evens = filter(nums, isEven);
22-print evens;
20+forge isEven(x) { yield x % 2 == 0; }
21+local evens = filter(nums, isEven);
22+say evens;
2323
24-fn add(a, b) { return a + b; }
25-let total = reduce(nums, add, 0);
26-print total;
24+forge add(a, b) { yield a + b; }
25+local total = reduce(nums, add, 0);
26+say total;
2727
28-print upper("hello");
29-print lower("WORLD");
30-print trim(" spaced ");
31-print split("a,b,c", ",");
32-print join(["x", "y", "z"], "-");
33-print substring("hello world", 0, 5);
34-print indexOf("hello world", "world");
35-print replace("foo bar foo", "foo", "baz");
28+say upper("hello");
29+say lower("WORLD");
30+say trim(" spaced ");
31+say split("a,b,c", ",");
32+say join(["x", "y", "z"], "-");
33+say substring("hello world", 0, 5);
34+say indexOf("hello world", "world");
35+say replace("foo bar foo", "foo", "baz");
3636
3737 write_file("/tmp/ashvm_test.txt", "vm file test\n");
38-print file_exists("/tmp/ashvm_test.txt");
39-print read_file("/tmp/ashvm_test.txt");
38+say file_exists("/tmp/ashvm_test.txt");
39+say read_file("/tmp/ashvm_test.txt");

ashvm/tests/cases/errors/error_handling_test.ashmodified

+41−41
@@ -1,57 +1,57 @@
1-fn risky(n) {
2- if (n > 3) {
3- let undefined_thing = does_not_exist + 1;
1+forge risky(n) {
2+ given (n > 3) {
3+ local undefined_thing = does_not_exist + 1;
44 }
5- return n;
5+ yield n;
66 }
77
8-let result = 0;
9-try {
10- let result = risky(5);
11- print "should not reach here";
12-} catch (e) {
13- print "caught: " + e;
8+local result = 0;
9+attempt {
10+ local result = risky(5);
11+ say "should not reach here";
12+} handle (e) {
13+ say "caught: " + e;
1414 }
15-print "after try 1, result still: " + str(result);
15+say "after attempt 1, result still: " + str(result);
1616
17-let arr = [1, 2, 3];
18-try {
19- print arr[10];
20-} catch (e) {
21- print "caught: " + e;
17+local arr = [1, 2, 3];
18+attempt {
19+ say arr[10];
20+} handle (e) {
21+ say "caught: " + e;
2222 }
2323
24-let m = {"a": 1};
25-try {
26- print m["missing_key"];
27-} catch (e) {
28- print "caught: " + e;
24+local m = {"a": 1};
25+attempt {
26+ say m["missing_key"];
27+} handle (e) {
28+ say "caught: " + e;
2929 }
3030
31-fn needs_two(a, b) { return a + b; }
32-try {
33- print needs_two(1);
34-} catch (e) {
35- print "caught: " + e;
31+forge needs_two(a, b) { yield a + b; }
32+attempt {
33+ say needs_two(1);
34+} handle (e) {
35+ say "caught: " + e;
3636 }
3737
38-try {
39- let content = read_file("/tmp/does_not_exist_xyz_ash.txt");
40-} catch (e) {
41- print "caught: " + e;
38+attempt {
39+ local content = read_file("/tmp/does_not_exist_xyz_ash.txt");
40+} handle (e) {
41+ say "caught: " + e;
4242 }
4343
44-try {
45- try {
46- let x = totally_undefined_var;
47- } catch (inner) {
48- print "inner caught: " + inner;
44+attempt {
45+ attempt {
46+ local x = totally_undefined_var;
47+ } handle (inner) {
48+ say "inner caught: " + inner;
4949 }
50- print "inner try/catch resolved, continuing outer try";
51-} catch (outer) {
52- print "outer should not see this: " + outer;
50+ say "inner attempt/handle resolved, continuing outer attempt";
51+} handle (outer) {
52+ say "outer should not see this: " + outer;
5353 }
5454
55-print "program completed normally";
56-let final_check = 42;
57-print final_check;
55+say "program completed normally";
56+local final_check = 42;
57+say final_check;

ashvm/tests/cases/errors/raise_test.ashadded

+22−0
@@ -0,0 +1,22 @@
1+attempt {
2+ raise "custom error: " + str(42);
3+} handle (e) {
4+ say "caught: " + e;
5+}
6+say "after";
7+
8+forge validate(x) {
9+ given x < 0 {
10+ raise "negative value: " + str(x);
11+ }
12+ yield x;
13+}
14+
15+attempt {
16+ say validate(5);
17+ say validate(-3);
18+ say "unreachable";
19+} handle (e) {
20+ say "caught: " + e;
21+}
22+say "done";

ashvm/tests/cases/errors/vm_trycatch_test.ashmodified

+38−38
@@ -1,52 +1,52 @@
1-let arr = [1, 2, 3];
2-try {
3- print arr[10];
4-} catch (e) {
5- print "caught: " + e;
1+local arr = [1, 2, 3];
2+attempt {
3+ say arr[10];
4+} handle (e) {
5+ say "caught: " + e;
66 }
7-print "after array test";
7+say "after array test";
88
9-let m = {"a": 1};
10-try {
11- print m["missing"];
12-} catch (e) {
13- print "caught: " + e;
9+local m = {"a": 1};
10+attempt {
11+ say m["missing"];
12+} handle (e) {
13+ say "caught: " + e;
1414 }
1515
16-try {
17- let content = read_file("/tmp/nonexistent_ashvm_xyz.txt");
18-} catch (e) {
19- print "caught: " + e;
16+attempt {
17+ local content = read_file("/tmp/nonexistent_ashvm_xyz.txt");
18+} handle (e) {
19+ say "caught: " + e;
2020 }
2121
22-fn risky(n) {
23- if (n > 3) {
24- let arr2 = [1, 2];
25- print arr2[99];
22+forge risky(n) {
23+ given (n > 3) {
24+ local arr2 = [1, 2];
25+ say arr2[99];
2626 }
27- return n;
27+ yield n;
2828 }
2929
30-try {
31- let result = risky(5);
32- print "should not print";
33-} catch (e) {
34- print "caught in function: " + e;
30+attempt {
31+ local result = risky(5);
32+ say "should not say";
33+} handle (e) {
34+ say "caught in function: " + e;
3535 }
36-print "still running after function error";
36+say "still running after function error";
3737
38-try {
39- try {
40- let arr3 = [1];
41- print arr3[5];
42- } catch (inner) {
43- print "inner caught: " + inner;
38+attempt {
39+ attempt {
40+ local arr3 = [1];
41+ say arr3[5];
42+ } handle (inner) {
43+ say "inner caught: " + inner;
4444 }
45- print "inner resolved";
46-} catch (outer) {
47- print "outer should not fire";
45+ say "inner resolved";
46+} handle (outer) {
47+ say "outer should not fire";
4848 }
4949
50-print "program completed";
51-let final = 42;
52-print final;
50+say "program completed";
51+local final = 42;
52+say final;

ashvm/tests/cases/functions/closures_test.ashmodified

+16−16
@@ -1,21 +1,21 @@
1-fn make_adder(x) {
2- return fn(y) { return x + y; };
1+forge make_adder(x) {
2+ yield forge(y) { yield x + y; };
33 }
44
5-let add5 = make_adder(5);
6-let add10 = make_adder(10);
7-print add5(3);
8-print add10(3);
5+local add5 = make_adder(5);
6+local add10 = make_adder(10);
7+say add5(3);
8+say add10(3);
99
10-let nums = [1, 2, 3];
11-let shift100 = make_adder(100);
12-let shifted = map(nums, shift100);
13-print shifted;
10+local nums = [1, 2, 3];
11+local shift100 = make_adder(100);
12+local shifted = map(nums, shift100);
13+say shifted;
1414
15-fn compose(f, g) {
16- return fn(x) { return f(g(x)); };
15+forge compose(f, g) {
16+ yield forge(x) { yield f(g(x)); };
1717 }
18-fn double(x) { return x * 2; }
19-fn inc(x) { return x + 1; }
20-let doubleThenInc = compose(inc, double);
21-print doubleThenInc(5);
18+forge double(x) { yield x * 2; }
19+forge inc(x) { yield x + 1; }
20+local doubleThenInc = compose(inc, double);
21+say doubleThenInc(5);

ashvm/tests/cases/functions/functional_test.ashmodified

+14−14
@@ -1,20 +1,20 @@
1-fn double(x) { return x * 2; }
2-fn is_even(x) { return x % 2 == 0; }
1+forge double(x) { yield x * 2; }
2+forge is_even(x) { yield x % 2 == 0; }
33
4-let nums = [1, 2, 3, 4, 5, 6];
4+local nums = [1, 2, 3, 4, 5, 6];
55
6-let doubled = map(nums, double);
7-print doubled;
6+local doubled = map(nums, double);
7+say doubled;
88
9-let evens = filter(nums, is_even);
10-print evens;
9+local evens = filter(nums, is_even);
10+say evens;
1111
12-let total = reduce(nums, fn(acc, x) { return acc + x; }, 0);
13-print total;
12+local total = reduce(nums, forge(acc, x) { yield acc + x; }, 0);
13+say total;
1414
15-let squareLambda = fn(x) { return x * x; };
16-let squares = map(nums, squareLambda);
17-print squares;
15+local squareLambda = forge(x) { yield x * x; };
16+local squares = map(nums, squareLambda);
17+say squares;
1818
19-let f = double;
20-print f(21);
19+local f = double;
20+say f(21);

ashvm/tests/cases/functions/vm_closures_test.ashmodified

+18−18
@@ -1,24 +1,24 @@
1-fn make_adder(x) {
2- return fn(y) { return x + y; };
1+forge make_adder(x) {
2+ yield forge(y) { yield x + y; };
33 }
44
5-let add5 = make_adder(5);
6-let add10 = make_adder(10);
7-print add5(3);
8-print add10(3);
5+local add5 = make_adder(5);
6+local add10 = make_adder(10);
7+say add5(3);
8+say add10(3);
99
10-let nums = [1, 2, 3];
11-let shift100 = make_adder(100);
12-let shifted = map(nums, shift100);
13-print shifted;
10+local nums = [1, 2, 3];
11+local shift100 = make_adder(100);
12+local shifted = map(nums, shift100);
13+say shifted;
1414
15-fn compose(f, g) {
16- return fn(x) { return f(g(x)); };
15+forge compose(f, g) {
16+ yield forge(x) { yield f(g(x)); };
1717 }
18-fn double(x) { return x * 2; }
19-fn inc(x) { return x + 1; }
20-let doubleThenInc = compose(inc, double);
21-print doubleThenInc(5);
18+forge double(x) { yield x * 2; }
19+forge inc(x) { yield x + 1; }
20+local doubleThenInc = compose(inc, double);
21+say doubleThenInc(5);
2222
23-let total = reduce(nums, fn(acc, n) { return acc + n; }, 0);
24-print total;
23+local total = reduce(nums, forge(acc, n) { yield acc + n; }, 0);
24+say total;

ashvm/tests/cases/imports/ash.libs/testlib/testlib.ashmodified

+3−3
@@ -1,5 +1,5 @@
1-let VERSION = "1.0";
1+local VERSION = "1.0";
22
3-fn square(x) {
4- return x * x;
3+forge square(x) {
4+ yield x * x;
55 }

ashvm/tests/cases/imports/fixtures/collision_dir_one/shared.ashmodified

+2−2
@@ -1,3 +1,3 @@
1-fn fromOne() {
2- return 1;
1+forge fromOne() {
2+ yield 1;
33 }

ashvm/tests/cases/imports/fixtures/collision_dir_two/shared.ashmodified

+2−2
@@ -1,3 +1,3 @@
1-fn fromTwo() {
2- return 2;
1+forge fromTwo() {
2+ yield 2;
33 }

ashvm/tests/cases/imports/fixtures/imports_basic_lib.ashmodified

+4−4
@@ -1,7 +1,7 @@
1-fn helper(x) {
2- return x * 2;
1+forge helper(x) {
2+ yield x * 2;
33 }
44
5-fn add(a, b) {
6- return a + b + helper(1);
5+forge add(a, b) {
6+ yield a + b + helper(1);
77 }

ashvm/tests/cases/imports/fixtures/imports_circular_a.ashmodified

+2−2
@@ -1,5 +1,5 @@
11 @import <./imports_circular_b.ash>;
22
3-fn fromA() {
4- return 1;
3+forge fromA() {
4+ yield 1;
55 }

ashvm/tests/cases/imports/fixtures/imports_circular_b.ashmodified

+2−2
@@ -1,5 +1,5 @@
11 @import <./imports_circular_a.ash>;
22
3-fn fromB() {
4- return 2;
3+forge fromB() {
4+ yield 2;
55 }

ashvm/tests/cases/imports/fixtures/imports_collision_main.ashmodified

+1−1
@@ -1,4 +1,4 @@
11 @import <./collision_dir_one/shared.ash>;
22 @import <./collision_dir_two/shared.ash>;
33
4-print shared.fromOne();
4+say shared.fromOne();

ashvm/tests/cases/imports/fixtures/imports_const_lib.ashmodified

+4−4
@@ -1,6 +1,6 @@
1-let PI = 3.14159;
2-let GREETING = "hello from lib";
1+local PI = 3.14159;
2+local GREETING = "hello from lib";
33
4-fn circleArea(r) {
5- return PI * r * r;
4+forge circleArea(r) {
5+ yield PI * r * r;
66 }

ashvm/tests/cases/imports/fixtures/imports_diamond_b.ashmodified

+2−2
@@ -1,5 +1,5 @@
11 @import <./imports_diamond_d.ash>;
22
3-fn fromB() {
4- return imports_diamond_d.base() + 1;
3+forge fromB() {
4+ yield imports_diamond_d.base() + 1;
55 }

ashvm/tests/cases/imports/fixtures/imports_diamond_c.ashmodified

+2−2
@@ -1,5 +1,5 @@
11 @import <./imports_diamond_d.ash>;
22
3-fn fromC() {
4- return imports_diamond_d.base() + 2;
3+forge fromC() {
4+ yield imports_diamond_d.base() + 2;
55 }

ashvm/tests/cases/imports/fixtures/imports_diamond_d.ashmodified

+2−2
@@ -1,3 +1,3 @@
1-fn base() {
2- return 100;
1+forge base() {
2+ yield 100;
33 }

ashvm/tests/cases/imports/fixtures/imports_statement_lib.ashadded

+19−0
@@ -0,0 +1,19 @@
1+// Covers two bugs found writing the first real forgepack module (see
2+// CHANGELOG.md): a `local` inside a loop in an imported function used to
3+// be misdiagnosed as a failed top-level namespaced-constant declaration,
4+// and a namespaced call used as its OWN statement (not nested in an
5+// expression) used to fail to parse entirely.
6+forge sum_doubled(arr) {
7+ local total = 0;
8+ local i = 0;
9+ during i < len(arr) {
10+ local doubled = arr[i] * 2;
11+ total += doubled;
12+ i += 1;
13+ }
14+ yield total;
15+}
16+
17+forge announce(label) {
18+ say "announced: " + label;
19+}

ashvm/tests/cases/imports/imports_basic_test.ashmodified

+2−2
@@ -1,4 +1,4 @@
11 @import <./fixtures/imports_basic_lib.ash>;
22
3-print imports_basic_lib.add(3, 4);
4-print imports_basic_lib.helper(10);
3+say imports_basic_lib.add(3, 4);
4+say imports_basic_lib.helper(10);

ashvm/tests/cases/imports/imports_const_test.ashmodified

+3−3
@@ -1,5 +1,5 @@
11 @import <./fixtures/imports_const_lib.ash>;
22
3-print imports_const_lib.PI;
4-print imports_const_lib.GREETING;
5-print imports_const_lib.circleArea(2);
3+say imports_const_lib.PI;
4+say imports_const_lib.GREETING;
5+say imports_const_lib.circleArea(2);

ashvm/tests/cases/imports/imports_diamond_test.ashmodified

+3−3
@@ -1,6 +1,6 @@
11 @import <./fixtures/imports_diamond_b.ash>;
22 @import <./fixtures/imports_diamond_c.ash>;
33
4-print imports_diamond_b.fromB();
5-print imports_diamond_c.fromC();
6-print imports_diamond_d.base();
4+say imports_diamond_b.fromB();
5+say imports_diamond_c.fromC();
6+say imports_diamond_d.base();

ashvm/tests/cases/imports/imports_lib_test.ashmodified

+2−2
@@ -1,4 +1,4 @@
11 @import <testlib>;
22
3-print testlib.VERSION;
4-print testlib.square(6);
3+say testlib.VERSION;
4+say testlib.square(6);

ashvm/tests/cases/imports/imports_statement_test.ashadded

+4−0
@@ -0,0 +1,4 @@
1+@import <./fixtures/imports_statement_lib.ash>;
2+
3+say imports_statement_lib.sum_doubled([1, 2, 3]);
4+imports_statement_lib.announce("done");

ashvm/tests/cases/strings/escapes_test.ashmodified

+7−7
@@ -1,9 +1,9 @@
1-print "line one\nline two";
2-print "tab\there";
3-print "quote: \"hello\"";
4-print "backslash: \\";
1+say "line one\nline two";
2+say "tab\there";
3+say "quote: \"hello\"";
4+say "backslash: \\";
55 write_file("/tmp/ash_escape_test.txt", "Hello from Ash!\n");
6-let content = read_file("/tmp/ash_escape_test.txt");
7-print content;
6+local content = read_file("/tmp/ash_escape_test.txt");
7+say content;
88 append_file("/tmp/ash_escape_test.txt", "Second line\n");
9-print read_file("/tmp/ash_escape_test.txt");
9+say read_file("/tmp/ash_escape_test.txt");

ashvm/tests/cases/strings/strings_files_test.ashmodified

+16−16
@@ -1,19 +1,19 @@
1-let parts = split("a,b,c", ",");
2-print parts;
3-print join(parts, "-");
4-print substring("hello world", 0, 5);
5-print indexOf("hello world", "world");
6-print indexOf("hello world", "xyz");
7-print replace("foo bar foo", "foo", "baz");
8-print upper("hello");
9-print lower("WORLD");
10-print trim(" spaced ");
1+local parts = split("a,b,c", ",");
2+say parts;
3+say join(parts, "-");
4+say substring("hello world", 0, 5);
5+say indexOf("hello world", "world");
6+say indexOf("hello world", "xyz");
7+say replace("foo bar foo", "foo", "baz");
8+say upper("hello");
9+say lower("WORLD");
10+say trim(" spaced ");
1111
1212 write_file("/tmp/ash_test.txt", "Hello from Ash!\n");
13-print file_exists("/tmp/ash_test.txt");
14-let content = read_file("/tmp/ash_test.txt");
15-print content;
13+say file_exists("/tmp/ash_test.txt");
14+local content = read_file("/tmp/ash_test.txt");
15+say content;
1616 append_file("/tmp/ash_test.txt", "Second line\n");
17-let content2 = read_file("/tmp/ash_test.txt");
18-print content2;
19-print file_exists("/tmp/nonexistent_file_xyz.txt");
17+local content2 = read_file("/tmp/ash_test.txt");
18+say content2;
19+say file_exists("/tmp/nonexistent_file_xyz.txt");

ashvm/tests/cases/strings/vm_strings_test.ashmodified

+9−9
@@ -1,12 +1,12 @@
1-let name = "Ash";
2-print "Hello, " + name + "!";
1+local name = "Ash";
2+say "Hello, " + name + "!";
33
4-let a = "abc";
5-let b = "abc";
6-print a == b;
7-print a == "xyz";
4+local a = "abc";
5+local b = "abc";
6+say a == b;
7+say a == "xyz";
88
9-fn greet(who) {
10- return "Hi " + who;
9+forge greet(who) {
10+ yield "Hi " + who;
1111 }
12-print greet("world");
12+say greet("world");

forgepack/Makefileadded

+46−0
@@ -0,0 +1,46 @@
1+CC = gcc
2+.DEFAULT_GOAL := all
3+CFLAGS = -Wall -Wextra -std=c11 -O2 -Isrc
4+
5+FORGEPACK_SRC = \
6+ src/C/main.c \
7+ src/C/manifest.c \
8+ src/C/fsutil.c \
9+ src/C/git_fetch.c \
10+ src/C/install.c \
11+ src/C/cmd.c \
12+ src/C/ui.c
13+
14+# Objects and dependency files live in trees parallel to C/:
15+# src/C/x.c -> src/O/x.o, src/D/x.d
16+obj_of = $(patsubst %.c,%.o,$(subst /C/,/O/,$(1)))
17+dep_of = $(patsubst %.c,%.d,$(subst /C/,/D/,$(1)))
18+FORGEPACK_OBJ = $(foreach s,$(FORGEPACK_SRC),$(call obj_of,$(s)))
19+FORGEPACK_DEP = $(foreach s,$(FORGEPACK_SRC),$(call dep_of,$(s)))
20+
21+# -MMD -MP: see ashvm/Makefile's and kiln/Makefile's comment -- a shared
22+# header edit must rebuild every .o that includes it, not just the .c
23+# whose own timestamp changed.
24+define compile_rule
25+$(call obj_of,$(1)): $(1)
26+ @mkdir -p $$(dir $$@) $$(dir $(call dep_of,$(1)))
27+ $$(CC) $$(CFLAGS) -MMD -MP -MF $(call dep_of,$(1)) -MT $$@ -c $$< -o $$@
28+endef
29+$(foreach s,$(FORGEPACK_SRC),$(eval $(call compile_rule,$(s))))
30+
31+BIN = ../bin/forgepack
32+
33+all: $(BIN)
34+
35+$(BIN): $(FORGEPACK_OBJ)
36+ mkdir -p ../bin
37+ $(CC) $(CFLAGS) -o $(BIN) $(FORGEPACK_OBJ)
38+
39+-include $(FORGEPACK_DEP)
40+
41+clean:
42+ find src -name '*.o' -delete
43+ find src -name '*.d' -delete
44+ rm -f $(BIN)
45+
46+.PHONY: all clean

forgepack/src/C/cmd.cadded

+137−0
@@ -0,0 +1,137 @@
1+#define _XOPEN_SOURCE 700 // exposes PATH_MAX under -std=c11 (see ashvm/kiln's import_paths.c)
2+#include <stdio.h>
3+#include <string.h>
4+#include <unistd.h>
5+#include <limits.h>
6+#include "H/cmd.h"
7+#include "H/manifest.h"
8+#include "H/install.h"
9+#include "H/fsutil.h"
10+#include "H/git_fetch.h"
11+#include "H/ui.h"
12+
13+static void infer_project_name(char *out, int out_size) {
14+ char cwd[PATH_MAX];
15+ if (getcwd(cwd, sizeof(cwd)) == NULL) { snprintf(out, (size_t)out_size, "my-project"); return; }
16+ char *slash = strrchr(cwd, '/');
17+ const char *base = slash ? slash + 1 : cwd;
18+ int len = (int)strlen(base);
19+ if (len >= out_size) len = out_size - 1;
20+ memcpy(out, base, (size_t)len);
21+ out[len] = '\0';
22+}
23+
24+int cmd_init(const char *project_dir, int argc, char **argv) {
25+ (void)argc; (void)argv;
26+ Manifest m;
27+ if (manifest_load(project_dir, &m)) {
28+ ui_err("%s already exists", MANIFEST_FILE);
29+ return 1;
30+ }
31+
32+ char name[MANIFEST_NAME_LEN];
33+ infer_project_name(name, sizeof(name));
34+ manifest_init_default(&m, name);
35+ if (!manifest_save(project_dir, &m)) return 1;
36+
37+ ui_ok("created %s (%s@%s)", MANIFEST_FILE, m.name, m.version);
38+ return 0;
39+}
40+
41+// forgepack add github:user/repo[@ref] [--as <name>]
42+//
43+// GitHub-only for now -- forgepack has no registry/index yet (it will,
44+// see ideas/ash_modules.md's phased plan; ash-project.org is reserved
45+// for that once there's a real flat index worth serving from it), so a
46+// git source is the only dependency kind that's actually installable
47+// without a server.
48+int cmd_add(const char *project_dir, int argc, char **argv) {
49+ if (argc < 1 || !git_fetch_is_github_spec(argv[0])) {
50+ ui_err("usage: forgepack add github:<user>/<repo>[@ref] [--as <name>]");
51+ return 1;
52+ }
53+ const char *source = argv[0];
54+ const char *explicit_name = NULL;
55+ for (int i = 1; i < argc - 1; i++) {
56+ if (strcmp(argv[i], "--as") == 0) explicit_name = argv[i + 1];
57+ }
58+
59+ char name[MANIFEST_NAME_LEN];
60+ if (explicit_name) {
61+ snprintf(name, sizeof(name), "%s", explicit_name);
62+ } else {
63+ git_fetch_infer_name(source, name, sizeof(name));
64+ }
65+
66+ Manifest m;
67+ if (!manifest_load(project_dir, &m)) {
68+ ui_err("no %s here -- run 'forgepack init' first", MANIFEST_FILE);
69+ return 1;
70+ }
71+ if (!manifest_set_dependency(&m, name, source)) {
72+ ui_err("dependency table full (max %d)", MANIFEST_MAX_DEPS);
73+ return 1;
74+ }
75+ if (!manifest_save(project_dir, &m)) return 1;
76+
77+ char ash_modules[1024];
78+ snprintf(ash_modules, sizeof(ash_modules), "%s/@ash-modules", project_dir);
79+ fsutil_mkdir_p(ash_modules);
80+
81+ Dependency *dep = manifest_find_dependency(&m, name);
82+ if (!install_dependency(project_dir, dep)) {
83+ ui_err("failed to install '%s'", name);
84+ return 1;
85+ }
86+ ui_ok("added %s (%s)", name, source);
87+ return 0;
88+}
89+
90+int cmd_install(const char *project_dir, int argc, char **argv) {
91+ (void)argc; (void)argv;
92+ Manifest m;
93+ if (!manifest_load(project_dir, &m)) {
94+ ui_err("no %s here -- run 'forgepack init' first", MANIFEST_FILE);
95+ return 1;
96+ }
97+
98+ char ash_modules[1024];
99+ snprintf(ash_modules, sizeof(ash_modules), "%s/@ash-modules", project_dir);
100+ fsutil_mkdir_p(ash_modules);
101+
102+ if (m.dep_count == 0) {
103+ ui_info("%s has no dependencies", m.name);
104+ return 0;
105+ }
106+
107+ ui_header("%s@%s", m.name, m.version);
108+ int ok = 1;
109+ for (int i = 0; i < m.dep_count; i++) {
110+ if (!install_dependency(project_dir, &m.deps[i])) ok = 0;
111+ }
112+ if (ok) ui_ok("done");
113+ else ui_err("one or more dependencies failed to install");
114+ return ok ? 0 : 1;
115+}
116+
117+int cmd_list(const char *project_dir, int argc, char **argv) {
118+ (void)argc; (void)argv;
119+ Manifest m;
120+ if (!manifest_load(project_dir, &m)) {
121+ ui_err("no %s here -- run 'forgepack init' first", MANIFEST_FILE);
122+ return 1;
123+ }
124+
125+ ui_header("%s@%s", m.name, m.version);
126+ if (m.dep_count == 0) {
127+ ui_bullet("(no dependencies)");
128+ return 0;
129+ }
130+ for (int i = 0; i < m.dep_count; i++) {
131+ char dest[1024];
132+ snprintf(dest, sizeof(dest), "%s/@ash-modules/%s", project_dir, m.deps[i].name);
133+ if (fsutil_dir_exists(dest)) ui_bullet("%s: %s", m.deps[i].name, m.deps[i].value);
134+ else ui_bullet("%s: %s (not installed)", m.deps[i].name, m.deps[i].value);
135+ }
136+ return 0;
137+}

forgepack/src/C/fsutil.cadded

+73−0
@@ -0,0 +1,73 @@
1+#include <stdio.h>
2+#include <stdlib.h>
3+#include <string.h>
4+#include <sys/stat.h>
5+#include <dirent.h>
6+#include <unistd.h>
7+#include "H/fsutil.h"
8+
9+int fsutil_dir_exists(const char *path) {
10+ struct stat st;
11+ return stat(path, &st) == 0 && S_ISDIR(st.st_mode);
12+}
13+
14+int fsutil_mkdir_p(const char *path) {
15+ char buf[1024];
16+ snprintf(buf, sizeof(buf), "%s", path);
17+ size_t len = strlen(buf);
18+ for (size_t i = 1; i <= len; i++) {
19+ if (buf[i] == '/' || i == len) {
20+ char save = buf[i];
21+ buf[i] = '\0';
22+ if (buf[0] != '\0' && mkdir(buf, 0755) != 0) {
23+ // EEXIST is fine -- anything else means a real failure.
24+ struct stat st;
25+ if (stat(buf, &st) != 0 || !S_ISDIR(st.st_mode)) return 0;
26+ }
27+ buf[i] = save;
28+ }
29+ }
30+ return 1;
31+}
32+
33+static int copy_file(const char *src, const char *dest) {
34+ FILE *in = fopen(src, "rb");
35+ if (!in) return 0;
36+ FILE *out = fopen(dest, "wb");
37+ if (!out) { fclose(in); return 0; }
38+ char buf[8192];
39+ size_t n;
40+ while ((n = fread(buf, 1, sizeof(buf), in)) > 0) fwrite(buf, 1, n, out);
41+ fclose(in);
42+ fclose(out);
43+ return 1;
44+}
45+
46+int fsutil_copy_dir(const char *src_dir, const char *dest_dir) {
47+ if (!fsutil_mkdir_p(dest_dir)) return 0;
48+
49+ DIR *d = opendir(src_dir);
50+ if (!d) return 0;
51+
52+ struct dirent *entry;
53+ int ok = 1;
54+ while (ok && (entry = readdir(d)) != NULL) {
55+ if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue;
56+ // Never copy a nested @ash-modules/ or .git -- a "path:" dependency
57+ // installs its OWN source, not whatever it happened to have
58+ // installed/cloned locally when you ran `forgepack add`.
59+ if (strcmp(entry->d_name, "@ash-modules") == 0 || strcmp(entry->d_name, ".git") == 0) continue;
60+
61+ char src_path[1024], dest_path[1024];
62+ snprintf(src_path, sizeof(src_path), "%s/%s", src_dir, entry->d_name);
63+ snprintf(dest_path, sizeof(dest_path), "%s/%s", dest_dir, entry->d_name);
64+
65+ if (fsutil_dir_exists(src_path)) {
66+ ok = fsutil_copy_dir(src_path, dest_path);
67+ } else {
68+ ok = copy_file(src_path, dest_path);
69+ }
70+ }
71+ closedir(d);
72+ return ok;
73+}

forgepack/src/C/git_fetch.cadded

+83−0
@@ -0,0 +1,83 @@
1+#include <stdio.h>
2+#include <stdlib.h>
3+#include <string.h>
4+#include <ctype.h>
5+#include "H/git_fetch.h"
6+
7+int git_fetch_is_github_spec(const char *value) {
8+ return strncmp(value, "github:", 7) == 0;
9+}
10+
11+// user/repo/ref only ever contain characters that are safe to drop
12+// straight into a shell command -- anything else is rejected outright
13+// rather than escaped, since a dependency spec is attacker-controlled
14+// the moment it comes from someone else's published ash.pkg.
15+static int is_safe_component(const char *s, int len) {
16+ if (len <= 0) return 0;
17+ for (int i = 0; i < len; i++) {
18+ char c = s[i];
19+ if (!(isalnum((unsigned char)c) || c == '-' || c == '_' || c == '.')) return 0;
20+ }
21+ return 1;
22+}
23+
24+static int parse_github_spec(const char *spec, char *user, int user_size, char *repo, int repo_size, char *ref, int ref_size) {
25+ const char *rest = spec + 7; // skip "github:"
26+ const char *slash = strchr(rest, '/');
27+ if (!slash) return 0;
28+
29+ int user_len = (int)(slash - rest);
30+ if (!is_safe_component(rest, user_len) || user_len >= user_size) return 0;
31+ memcpy(user, rest, (size_t)user_len);
32+ user[user_len] = '\0';
33+
34+ const char *repo_start = slash + 1;
35+ const char *at = strchr(repo_start, '@');
36+ int repo_len = at ? (int)(at - repo_start) : (int)strlen(repo_start);
37+ if (!is_safe_component(repo_start, repo_len) || repo_len >= repo_size) return 0;
38+ memcpy(repo, repo_start, (size_t)repo_len);
39+ repo[repo_len] = '\0';
40+
41+ if (at) {
42+ int ref_len = (int)strlen(at + 1);
43+ if (!is_safe_component(at + 1, ref_len) || ref_len >= ref_size) return 0;
44+ memcpy(ref, at + 1, (size_t)ref_len);
45+ ref[ref_len] = '\0';
46+ } else {
47+ ref[0] = '\0';
48+ }
49+ return 1;
50+}
51+
52+void git_fetch_infer_name(const char *spec, char *out, int out_size) {
53+ char user[128], repo[128], ref[128];
54+ if (git_fetch_is_github_spec(spec) && parse_github_spec(spec, user, sizeof(user), repo, sizeof(repo), ref, sizeof(ref))) {
55+ snprintf(out, (size_t)out_size, "%s", repo);
56+ } else {
57+ snprintf(out, (size_t)out_size, "%s", spec);
58+ }
59+}
60+
61+int git_fetch_github(const char *spec, const char *dest_dir) {
62+ char user[128], repo[128], ref[128];
63+ if (!parse_github_spec(spec, user, sizeof(user), repo, sizeof(repo), ref, sizeof(ref))) {
64+ fprintf(stderr, "forgepack: malformed github spec '%s' (expected github:user/repo or github:user/repo@ref)\n", spec);
65+ return 0;
66+ }
67+
68+ char cmd[1024];
69+ if (ref[0] != '\0') {
70+ snprintf(cmd, sizeof(cmd), "git clone --quiet --depth 1 --branch %s https://github.com/%s/%s.git %s",
71+ ref, user, repo, dest_dir);
72+ } else {
73+ snprintf(cmd, sizeof(cmd), "git clone --quiet --depth 1 https://github.com/%s/%s.git %s",
74+ user, repo, dest_dir);
75+ }
76+
77+ int rc = system(cmd);
78+ if (rc != 0) {
79+ fprintf(stderr, "forgepack: 'git clone' failed for %s\n", spec);
80+ return 0;
81+ }
82+ return 1;
83+}

forgepack/src/C/install.cadded

+30−0
@@ -0,0 +1,30 @@
1+#include <stdio.h>
2+#include "H/install.h"
3+#include "H/fsutil.h"
4+#include "H/git_fetch.h"
5+#include "H/ui.h"
6+
7+// GitHub-only for now, deliberately -- no registry/index yet (see
8+// ideas/ash_modules.md's phased plan), so a git source is the only thing
9+// forgepack can actually resolve without a server. path:/plain-version
10+// dependencies are a later addition, not scoped in yet.
11+int install_dependency(const char *project_dir, const Dependency *dep) {
12+ char dest[1024];
13+ snprintf(dest, sizeof(dest), "%s/@ash-modules/%s", project_dir, dep->name);
14+
15+ if (fsutil_dir_exists(dest)) {
16+ ui_bullet("%s already installed", dep->name);
17+ return 1;
18+ }
19+
20+ if (!git_fetch_is_github_spec(dep->value)) {
21+ ui_err("'%s' is not a github: dependency ('%s') -- only github:user/repo[@ref] is supported right now",
22+ dep->name, dep->value);
23+ return 0;
24+ }
25+
26+ ui_info("fetching %s (%s)", dep->name, dep->value);
27+ if (!git_fetch_github(dep->value, dest)) return 0;
28+ ui_ok("%s installed", dep->name);
29+ return 1;
30+}

forgepack/src/C/main.cadded

+31−0
@@ -0,0 +1,31 @@
1+#include <stdio.h>
2+#include <string.h>
3+#include "H/cmd.h"
4+#include "H/ui.h"
5+
6+static void print_usage(void) {
7+ printf("forgepack -- package manager for Ash (github: sources only, for now)\n\n");
8+ printf("usage:\n");
9+ printf(" forgepack init\n");
10+ printf(" forgepack add github:<user>/<repo>[@ref] [--as <name>]\n");
11+ printf(" forgepack install\n");
12+ printf(" forgepack list\n");
13+}
14+
15+int main(int argc, char **argv) {
16+ if (argc < 2) { print_usage(); return 1; }
17+
18+ const char *command = argv[1];
19+ int rest_argc = argc - 2;
20+ char **rest_argv = argv + 2;
21+ const char *project_dir = ".";
22+
23+ if (strcmp(command, "init") == 0) return cmd_init(project_dir, rest_argc, rest_argv);
24+ if (strcmp(command, "add") == 0) return cmd_add(project_dir, rest_argc, rest_argv);
25+ if (strcmp(command, "install") == 0) return cmd_install(project_dir, rest_argc, rest_argv);
26+ if (strcmp(command, "list") == 0) return cmd_list(project_dir, rest_argc, rest_argv);
27+
28+ ui_err("unknown command '%s'", command);
29+ print_usage();
30+ return 1;
31+}

forgepack/src/C/manifest.cadded

+98−0
@@ -0,0 +1,98 @@
1+#include <stdio.h>
2+#include <stdlib.h>
3+#include <string.h>
4+#include "H/manifest.h"
5+
6+void manifest_init_default(Manifest *m, const char *project_name) {
7+ memset(m, 0, sizeof(*m));
8+ snprintf(m->name, sizeof(m->name), "%s", project_name);
9+ snprintf(m->version, sizeof(m->version), "0.1.0");
10+}
11+
12+// Trims leading/trailing whitespace in place, returns the trimmed start.
13+static char *trim(char *s) {
14+ while (*s == ' ' || *s == '\t') s++;
15+ size_t len = strlen(s);
16+ while (len > 0 && (s[len - 1] == '\n' || s[len - 1] == '\r' || s[len - 1] == ' ' || s[len - 1] == '\t')) {
17+ s[--len] = '\0';
18+ }
19+ return s;
20+}
21+
22+int manifest_load(const char *dir, Manifest *out) {
23+ char path[1024];
24+ snprintf(path, sizeof(path), "%s/%s", dir, MANIFEST_FILE);
25+ FILE *f = fopen(path, "r");
26+ if (!f) return 0;
27+
28+ memset(out, 0, sizeof(*out));
29+ int in_deps = 0;
30+ char line[512];
31+ while (fgets(line, sizeof(line), f)) {
32+ char *l = trim(line);
33+ if (l[0] == '\0' || l[0] == '#') continue;
34+ if (strcmp(l, "[dependencies]") == 0) { in_deps = 1; continue; }
35+
36+ char *colon = strchr(l, ':');
37+ if (!colon) continue; // ignore malformed lines rather than aborting the whole load
38+ *colon = '\0';
39+ char *key = trim(l);
40+ char *value = trim(colon + 1);
41+
42+ if (!in_deps) {
43+ if (strcmp(key, "name") == 0) snprintf(out->name, sizeof(out->name), "%s", value);
44+ else if (strcmp(key, "version") == 0) snprintf(out->version, sizeof(out->version), "%s", value);
45+ } else {
46+ if (out->dep_count >= MANIFEST_MAX_DEPS) {
47+ fprintf(stderr, "forgepack: too many dependencies (max %d), ignoring '%s'\n", MANIFEST_MAX_DEPS, key);
48+ continue;
49+ }
50+ Dependency *d = &out->deps[out->dep_count++];
51+ snprintf(d->name, sizeof(d->name), "%s", key);
52+ snprintf(d->value, sizeof(d->value), "%s", value);
53+ }
54+ }
55+ fclose(f);
56+ return 1;
57+}
58+
59+int manifest_save(const char *dir, const Manifest *m) {
60+ char path[1024];
61+ snprintf(path, sizeof(path), "%s/%s", dir, MANIFEST_FILE);
62+ FILE *f = fopen(path, "w");
63+ if (!f) {
64+ fprintf(stderr, "forgepack: could not write %s\n", path);
65+ return 0;
66+ }
67+
68+ fprintf(f, "name: %s\n", m->name);
69+ fprintf(f, "version: %s\n", m->version);
70+ if (m->dep_count > 0) {
71+ fprintf(f, "\n[dependencies]\n");
72+ for (int i = 0; i < m->dep_count; i++) {
73+ fprintf(f, "%s: %s\n", m->deps[i].name, m->deps[i].value);
74+ }
75+ }
76+ fclose(f);
77+ return 1;
78+}
79+
80+int manifest_set_dependency(Manifest *m, const char *name, const char *value) {
81+ Dependency *existing = manifest_find_dependency(m, name);
82+ if (existing) {
83+ snprintf(existing->value, sizeof(existing->value), "%s", value);
84+ return 1;
85+ }
86+ if (m->dep_count >= MANIFEST_MAX_DEPS) return 0;
87+ Dependency *d = &m->deps[m->dep_count++];
88+ snprintf(d->name, sizeof(d->name), "%s", name);
89+ snprintf(d->value, sizeof(d->value), "%s", value);
90+ return 1;
91+}
92+
93+Dependency *manifest_find_dependency(Manifest *m, const char *name) {
94+ for (int i = 0; i < m->dep_count; i++) {
95+ if (strcmp(m->deps[i].name, name) == 0) return &m->deps[i];
96+ }
97+ return NULL;
98+}

forgepack/src/C/ui.cadded

+67−0
@@ -0,0 +1,67 @@
1+#define _POSIX_C_SOURCE 200809L // exposes fileno() under -std=c11
2+#include <stdio.h>
3+#include <stdarg.h>
4+#include <stdlib.h>
5+#include <unistd.h>
6+#include "H/ui.h"
7+
8+#define GREEN "\x1b[32m"
9+#define RED "\x1b[31m"
10+#define CYAN "\x1b[36m"
11+#define DIM "\x1b[2m"
12+#define BOLD "\x1b[1m"
13+#define RESET "\x1b[0m"
14+
15+static int color_enabled(FILE *stream) {
16+ static int checked = 0, out_tty = -1, err_tty = -1, no_color = -1;
17+ if (!checked) {
18+ out_tty = isatty(fileno(stdout));
19+ err_tty = isatty(fileno(stderr));
20+ no_color = getenv("NO_COLOR") != NULL;
21+ checked = 1;
22+ }
23+ if (no_color) return 0;
24+ return stream == stdout ? out_tty : err_tty;
25+}
26+
27+static void emit(FILE *stream, const char *color, const char *symbol, const char *fmt, va_list args) {
28+ int c = color_enabled(stream);
29+ if (c) fprintf(stream, "%s%s%s ", color, symbol, RESET);
30+ else fprintf(stream, "%s ", symbol);
31+ vfprintf(stream, fmt, args);
32+ fprintf(stream, "\n");
33+}
34+
35+void ui_ok(const char *fmt, ...) {
36+ va_list args; va_start(args, fmt);
37+ emit(stdout, GREEN, "\xe2\x9c\x93", fmt, args); // U+2713 CHECK MARK
38+ va_end(args);
39+}
40+
41+void ui_err(const char *fmt, ...) {
42+ va_list args; va_start(args, fmt);
43+ emit(stderr, RED, "\xe2\x9c\x97", fmt, args); // U+2717 BALLOT X
44+ va_end(args);
45+}
46+
47+void ui_info(const char *fmt, ...) {
48+ va_list args; va_start(args, fmt);
49+ emit(stdout, CYAN, "\xe2\x86\x92", fmt, args); // U+2192 RIGHTWARDS ARROW
50+ va_end(args);
51+}
52+
53+void ui_bullet(const char *fmt, ...) {
54+ va_list args; va_start(args, fmt);
55+ emit(stdout, DIM, "\xe2\x80\xa2", fmt, args); // U+2022 BULLET
56+ va_end(args);
57+}
58+
59+void ui_header(const char *fmt, ...) {
60+ va_list args; va_start(args, fmt);
61+ int c = color_enabled(stdout);
62+ if (c) printf("%s", BOLD);
63+ vprintf(fmt, args);
64+ if (c) printf("%s", RESET);
65+ printf("\n");
66+ va_end(args);
67+}

forgepack/src/D/cmd.dadded

+8−0
@@ -0,0 +1,8 @@
1+src/O/cmd.o: src/C/cmd.c src/H/cmd.h src/H/manifest.h src/H/install.h \
2+ src/H/fsutil.h src/H/git_fetch.h src/H/ui.h
3+src/H/cmd.h:
4+src/H/manifest.h:
5+src/H/install.h:
6+src/H/fsutil.h:
7+src/H/git_fetch.h:
8+src/H/ui.h:

forgepack/src/D/fsutil.dadded

+2−0
@@ -0,0 +1,2 @@
1+src/O/fsutil.o: src/C/fsutil.c src/H/fsutil.h
2+src/H/fsutil.h:

forgepack/src/D/git_fetch.dadded

+2−0
@@ -0,0 +1,2 @@
1+src/O/git_fetch.o: src/C/git_fetch.c src/H/git_fetch.h
2+src/H/git_fetch.h:

forgepack/src/D/install.dadded

+7−0
@@ -0,0 +1,7 @@
1+src/O/install.o: src/C/install.c src/H/install.h src/H/manifest.h \
2+ src/H/fsutil.h src/H/git_fetch.h src/H/ui.h
3+src/H/install.h:
4+src/H/manifest.h:
5+src/H/fsutil.h:
6+src/H/git_fetch.h:
7+src/H/ui.h:

forgepack/src/D/main.dadded

+3−0
@@ -0,0 +1,3 @@
1+src/O/main.o: src/C/main.c src/H/cmd.h src/H/ui.h
2+src/H/cmd.h:
3+src/H/ui.h:

forgepack/src/D/manifest.dadded

+2−0
@@ -0,0 +1,2 @@
1+src/O/manifest.o: src/C/manifest.c src/H/manifest.h
2+src/H/manifest.h:

forgepack/src/D/ui.dadded

+2−0
@@ -0,0 +1,2 @@
1+src/O/ui.o: src/C/ui.c src/H/ui.h
2+src/H/ui.h:

forgepack/src/H/cmd.hadded

+12−0
@@ -0,0 +1,12 @@
1+#ifndef FORGEPACK_CMD_H
2+#define FORGEPACK_CMD_H
3+
4+// Each command takes the project directory ("." almost always, since
5+// forgepack is always run from a project's root) and the remaining argv
6+// past the subcommand name. Returns an exit code.
7+int cmd_init(const char *project_dir, int argc, char **argv);
8+int cmd_add(const char *project_dir, int argc, char **argv);
9+int cmd_install(const char *project_dir, int argc, char **argv);
10+int cmd_list(const char *project_dir, int argc, char **argv);
11+
12+#endif

forgepack/src/H/fsutil.hadded

+16−0
@@ -0,0 +1,16 @@
1+#ifndef FORGEPACK_FSUTIL_H
2+#define FORGEPACK_FSUTIL_H
3+
4+int fsutil_dir_exists(const char *path);
5+
6+// mkdir -p equivalent -- creates every missing component of `path`.
7+// Returns 1 on success (including "already exists"), 0 on failure.
8+int fsutil_mkdir_p(const char *path);
9+
10+// Recursively copies `src_dir` into `dest_dir` (dest_dir is created).
11+// Used for "path:" dependencies -- deliberately a copy, not a symlink,
12+// so an installed @ash-modules/ entry never changes out from under a
13+// build just because the original source directory did.
14+int fsutil_copy_dir(const char *src_dir, const char *dest_dir);
15+
16+#endif

forgepack/src/H/git_fetch.hadded

+19−0
@@ -0,0 +1,19 @@
1+#ifndef FORGEPACK_GIT_FETCH_H
2+#define FORGEPACK_GIT_FETCH_H
3+
4+// Recognizes "github:user/repo" and "github:user/repo@ref".
5+int git_fetch_is_github_spec(const char *value);
6+
7+// Clones the given "github:..." spec into dest_dir (shells out to the
8+// system `git` binary -- forgepack has no git implementation of its own,
9+// same as every other package manager that supports a git source).
10+// user/repo/ref are validated against a strict charset before ever
11+// touching a shell command string, so a crafted dependency spec can't
12+// inject anything. Returns 1 on success.
13+int git_fetch_github(const char *spec, const char *dest_dir);
14+
15+// Derives the package name forgepack should use when none was given
16+// explicitly ("add github:user/repo" -> "repo").
17+void git_fetch_infer_name(const char *spec, char *out, int out_size);
18+
19+#endif

forgepack/src/H/install.hadded

+15−0
@@ -0,0 +1,15 @@
1+#ifndef FORGEPACK_INSTALL_H
2+#define FORGEPACK_INSTALL_H
3+#include "H/manifest.h"
4+
5+// Installs one dependency into <project_dir>/@ash-modules/<dep->name>/ if
6+// it isn't already there. Dispatches on the dependency's value: "github:"
7+// -> git clone, "path:" -> recursive copy, anything else (a plain
8+// version/range) -> prints a note and is skipped, since there's no
9+// registry yet to resolve a bare version against (see
10+// ideas/ash_modules.md's phased plan -- that's phase 3, not phase 1).
11+// Returns 1 if the dependency ended up installed (or already was), 0 on
12+// a real failure.
13+int install_dependency(const char *project_dir, const Dependency *dep);
14+
15+#endif

forgepack/src/H/manifest.hadded

+42−0
@@ -0,0 +1,42 @@
1+#ifndef FORGEPACK_MANIFEST_H
2+#define FORGEPACK_MANIFEST_H
3+
4+#define MANIFEST_MAX_DEPS 64
5+#define MANIFEST_NAME_LEN 128
6+#define MANIFEST_VALUE_LEN 256
7+#define MANIFEST_FILE "ash.pkg"
8+
9+// A dependency's value is a raw spec string. GitHub-only for now --
10+// "github:user/repo" or "github:user/repo@ref" (ref = tag or branch).
11+// No registry yet (see ideas/ash_modules.md), so a plain version/range
12+// or a local "path:" dependency aren't supported yet either.
13+typedef struct {
14+ char name[MANIFEST_NAME_LEN];
15+ char value[MANIFEST_VALUE_LEN];
16+} Dependency;
17+
18+typedef struct {
19+ char name[MANIFEST_NAME_LEN];
20+ char version[32];
21+ Dependency deps[MANIFEST_MAX_DEPS];
22+ int dep_count;
23+} Manifest;
24+
25+void manifest_init_default(Manifest *m, const char *project_name);
26+
27+// Reads ash.pkg from `dir` (a directory path, not the file itself).
28+// Returns 1 on success, 0 if the file doesn't exist or is malformed
29+// (a message is printed to stderr either way).
30+int manifest_load(const char *dir, Manifest *out);
31+
32+// Writes ash.pkg into `dir`, overwriting any existing one.
33+int manifest_save(const char *dir, const Manifest *m);
34+
35+// Adds a new dependency or overwrites an existing one with the same name.
36+// Returns 0 if the table is full.
37+int manifest_set_dependency(Manifest *m, const char *name, const char *value);
38+
39+// Returns NULL if no dependency with that name exists.
40+Dependency *manifest_find_dependency(Manifest *m, const char *name);
41+
42+#endif

forgepack/src/H/ui.hadded

+14−0
@@ -0,0 +1,14 @@
1+#ifndef FORGEPACK_UI_H
2+#define FORGEPACK_UI_H
3+
4+// Plain Unicode symbols (box-drawing/dingbats block, not emoji -- no
5+// color-font fallback needed, renders in any monospace terminal font) and
6+// ANSI color, auto-disabled when stdout isn't a TTY or NO_COLOR is set
7+// (piping to a file or another program should never see escape codes).
8+void ui_ok(const char *fmt, ...); // green check -- succeeded
9+void ui_err(const char *fmt, ...); // red cross, to stderr -- failed
10+void ui_info(const char *fmt, ...); // cyan arrow -- in progress / notice
11+void ui_bullet(const char *fmt, ...); // dim bullet -- a sub-item (dependency list, etc.)
12+void ui_header(const char *fmt, ...); // bold -- a section/project name header
13+
14+#endif

forgepack/src/O/.gitkeepadded

+0−0

No content changes. View the file on GitHub

forgepack/tests/scripts/smoke_test.shadded

+39−0
@@ -0,0 +1,39 @@
1+#!/usr/bin/env bash
2+# forgepack/tests/scripts/smoke_test.sh -- exercises the CLI end to end
3+# (init/add/install/list) against a throwaway scratch directory. Not a
4+# .ash regression suite like ashvm/kiln have (forgepack doesn't run Ash
5+# programs, it manages their dependencies) -- this is the equivalent
6+# check for this project: does the real CLI, talking to the real
7+# filesystem and the real `git`, do what it says.
8+set -euo pipefail
9+cd "$(dirname "$0")/../.." # -> forgepack/
10+
11+make -s
12+
13+BIN="$(cd ../bin && pwd)/forgepack" # absolute -- the script cd's into a scratch dir below
14+SCRATCH=$(mktemp -d)
15+trap 'rm -rf "$SCRATCH"' EXIT
16+
17+fail() { echo "FAIL: $1"; exit 1; }
18+
19+cd "$SCRATCH"
20+
21+"$BIN" init > /dev/null || fail "init"
22+[ -f ash.pkg ] || fail "ash.pkg not created"
23+grep -q "^name:" ash.pkg || fail "ash.pkg missing name"
24+
25+"$BIN" add github:octocat/Hello-World > /dev/null || fail "add"
26+grep -q "Hello-World: github:octocat/Hello-World" ash.pkg || fail "dependency not recorded"
27+[ -d @ash-modules/Hello-World ] || fail "dependency not installed"
28+
29+rm -rf @ash-modules
30+"$BIN" install > /dev/null || fail "install"
31+[ -d @ash-modules/Hello-World ] || fail "install did not restore the dependency"
32+
33+"$BIN" install > /dev/null || fail "re-install (idempotency)"
34+
35+"$BIN" list | grep -q "Hello-World" || fail "list didn't show the dependency"
36+
37+if "$BIN" bogus-command > /dev/null 2>&1; then fail "unknown command should exit non-zero"; fi
38+
39+echo "forgepack smoke test: OK"

ideas/ash_modules.mdadded

+208−0
@@ -0,0 +1,208 @@
1+# Ash Modules — a package manager for Ash
2+
3+> **TL;DR:** An npm-like package manager for Ash. Packages get fetched into a project-local `@ash-modules/` folder (same idea as `node_modules`). A manifest file (`ash.pkg`) declares dependencies and versions. `@import <name>;` — which already works for local `ash.libs/` folders — gets extended to also search `@ash-modules/`, so existing code and syntax don't change.
4+
5+---
6+
7+## Table of Contents
8+
9+1. [Goals & Non-Goals](#goals--non-goals)
10+2. [What Already Exists](#what-already-exists)
11+3. [The Manifest: `ash.pkg`](#the-manifest-ashpkg)
12+4. [`@ash-modules/` Layout](#ash-modules-layout)
13+5. [Resolution Order](#resolution-order)
14+6. [Versioning](#versioning)
15+7. [The Registry](#the-registry)
16+8. [CLI: `forgepack`](#cli-forgepack)
17+9. [Publishing a Package](#publishing-a-package)
18+10. [Development Phases](#development-phases)
19+11. [Design Principles](#design-principles)
20+12. [Risks & Open Questions](#risks--open-questions)
21+
22+---
23+
24+## Goals & Non-Goals
25+
26+**Goals**
27+
28+- `@import <name>;` keeps working exactly as it does today — a package is just something else `<name>` can resolve to.
29+- A manifest file per project declares what it depends on and at what version.
30+- Installed dependencies live in a project-local folder (`@ash-modules/`), never globally — two projects can depend on different versions of the same package with zero conflict.
31+- Works identically for `ashvm` and `kiln` — a package is plain `.ash` source, and both engines already share one `@import` syntax.
32+- Lockfile for reproducible installs.
33+
34+**Non-Goals**
35+
36+- A general-purpose build system (Ash programs are single-entry-point scripts; this is dependency resolution, not a `Makefile` replacement).
37+- Native/binary dependencies (a package is Ash source only — no compiled artifacts, no FFI, at least for v1).
38+- A hosted registry on day one (see [The Registry](#the-registry) — start file/git-based, add a real registry later).
39+- Semver solving across a huge transitive graph with conflicting ranges — start with a simple model (see [Versioning](#versioning)) and only grow it if real packages need it.
40+
41+---
42+
43+## What Already Exists
44+
45+Don't design this from scratch — `@import` already has most of the shape a package system needs:
46+
47+```ash
48+@import <./fixtures/helper.ash>; // relative path, must end in .ash
49+@import <testlib>; // bare name, no '.' or '/' allowed
50+```
51+
52+A bare name already resolves (`compiler/C/import_paths.c`, mirrored in `kiln`) to:
53+
54+```text
55+<project_root>/ash.libs/<name>/<name>.ash
56+```
57+
58+That's a real, working, local "library folder" convention — it's just not fetchable. Ash Modules reuses the exact same bare-name syntax and the exact same "no `.` or `/`" rule, and adds a *second* place that name is allowed to resolve to: `@ash-modules/<name>/`. `ash.libs/` keeps working unchanged for hand-written local libraries that never need a version or a registry.
59+
60+---
61+
62+## The Manifest: `ash.pkg`
63+
64+One file per project, at the project root (same level as `ash.libs/`), plain key-value text — not JSON, to match Ash's own "no external dependencies" ethos (no need to pull in a JSON parser just to read a manifest):
65+
66+```text
67+name: my-project
68+version: 0.1.0
69+
70+[dependencies]
71+http: 2.1.0
72+json: ^1.3.0
73+left-pad: 1.0.0
74+```
75+
76+- `[dependencies]` lists direct dependencies only — transitive ones are resolved and recorded in the lockfile, not hand-written here.
77+- A package itself has the same file at its own root (so a dependency can declare its *own* dependencies) — a package and a project are the same kind of thing, the only difference is whether anything depends on it.
78+- `name`/`version` double as what gets published to the registry (see below).
79+
80+---
81+
82+## `@ash-modules/` Layout
83+
84+```text
85+my-project/
86+├── ash.pkg
87+├── ash.lock
88+├── main.ash
89+├── ash.libs/ # unchanged — hand-written local libs, never fetched
90+│ └── mathutils/
91+│ └── mathutils.ash
92+└── @ash-modules/ # generated, gitignored, never hand-edited
93+ ├── http/
94+ │ ├── ash.pkg
95+ │ └── http.ash
96+ ├── json/
97+ │ ├── ash.pkg
98+ │ └── json.ash
99+ └── left-pad/
100+ ├── ash.pkg
101+ └── left-pad.ash
102+```
103+
104+- Flat, not nested — same lesson npm learned the hard way (npm 2's deeply nested `node_modules` was replaced by npm 3's flat layout). A dependency's *own* dependencies also land flat in the top-level `@ash-modules/`, deduped by version where possible.
105+- A version conflict (two dependencies need incompatible versions of the same package) gets a single nested fallback: `@ash-modules/<name>/@ash-modules/<name>@<version>/` — resolution always checks the nearest `@ash-modules/` first, same lookup order Node uses for `node_modules`.
106+- `@ash-modules/` is machine-generated and gitignored, like `O/`/`D/` already are for build output — `forgepack install` regenerates it from `ash.lock`.
107+
108+---
109+
110+## Resolution Order
111+
112+`@import <name>;` tries, in order, from the importing file's project root:
113+
114+1. `ash.libs/<name>/<name>.ash` — unchanged, hand-written local libraries win first (a project can always shadow a package name with its own local copy).
115+2. `@ash-modules/<name>/<name>.ash` — the installed package's entry file, named the same as the package itself (matches the existing `ash.libs` convention exactly, so the resolver code barely changes).
116+3. Walk up: if the current directory has no `@ash-modules/<name>/`, check the parent's, same as Node's `node_modules` walk — lets a package's own `@import`s resolve against the *project's* installed copies instead of needing its own nested set for every shared dependency.
117+
118+No new import syntax. The only change is `import_resolve_path()` gaining a second candidate path to try before giving up.
119+
120+---
121+
122+## Versioning
123+
124+Semver (`MAJOR.MINOR.PATCH`), kept deliberately simple for v1:
125+
126+- An exact version (`2.1.0`) always means exactly that version.
127+- A caret range (`^1.3.0`) means "`>=1.3.0`, `<2.0.0`" — the usual meaning, allows patch/minor updates, blocks breaking ones.
128+- No `~`, no `>=<`, no OR-ranges in v1 — if a real package needs more than caret ranges, add it then, not speculatively now.
129+- `ash.lock` pins every resolved package (direct and transitive) to one exact version and a content hash, so `forgepack install` is always reproducible — the manifest describes *intent*, the lockfile describes *what actually got installed*.
130+
131+---
132+
133+## The Registry
134+
135+Start without a hosted server at all — two sources, both file-based:
136+
137+1. **Git.** `forgepack add github:someone/some-package` clones a tag/commit straight into `@ash-modules/`. Zero infrastructure, works day one, same escape hatch cargo/npm/go modules all still support.
138+2. **A flat static index**, once there's more than a handful of packages: a single JSON (or plain-text) file mapping `name -> [versions] -> tarball URL`, served from anywhere static (GitHub Pages, a S3 bucket) — no backend service, no database, no auth to build first. `forgepack publish` is just "upload a tarball + update the index."
139+
140+A real registry with search, download counts, a web UI — all later, once there's something worth searching. Don't build npm's infrastructure for zero packages.
141+
142+---
143+
144+## CLI: `forgepack`
145+
146+```text
147+forgepack init # creates ash.pkg in the current directory
148+forgepack install # reads ash.pkg + ash.lock, populates @ash-modules/
149+forgepack add <name>[@version] # adds to ash.pkg, resolves, installs, updates ash.lock
150+forgepack add github:user/repo # git-sourced dependency
151+forgepack remove <name> # removes from ash.pkg, re-resolves, prunes @ash-modules/
152+forgepack update [<name>] # re-resolves within manifest ranges, rewrites ash.lock
153+forgepack publish # packages the current directory, pushes to the index
154+forgepack list # prints the resolved dependency tree
155+```
156+
157+Sample `list` output:
158+
159+```text
160+my-project@0.1.0
161+├── http@2.1.0
162+│ └── json@1.4.2
163+├── json@1.3.0 (direct — different version than http's own json dependency)
164+└── left-pad@1.0.0
165+```
166+
167+---
168+
169+## Publishing a Package
170+
171+A package is just a project with no `main` entry point expectation — `ash.pkg`'s `name`/`version`, a `<name>.ash` entry file at the root, optionally its own `ash.libs/`/dependencies. `forgepack publish`:
172+
173+1. Validates `ash.pkg` (name/version present, version not already published).
174+2. Tars up the directory (minus `@ash-modules/`, minus anything in a `.ashignore`).
175+3. Uploads the tarball and updates the index (or, for the git-only path, publishing *is* just tagging a commit — nothing to run).
176+
177+---
178+
179+## Development Phases
180+
181+| Phase | Goal | Details |
182+|-------|------|---------|
183+| **1. Manifest + local install** | Prove the shape works | `ash.pkg` parsing, `@ash-modules/` resolution added to `import_resolve_path()`, `forgepack install` reading only git/local-path sources (no index yet). |
184+| **2. Lockfile** | Reproducible installs | `ash.lock` generation, content hashing, `forgepack install` becoming deterministic from the lockfile alone. |
185+| **3. Static index** | Discoverability without a server | Flat JSON index, `forgepack publish`/`add <name>` against it, version resolution against caret ranges. |
186+| **4. Version conflict handling** | Real-world dependency graphs | Nested fallback resolution, `forgepack list` tree view, dedup pass. |
187+| **5. Registry service** *(maybe, later)* | Search, stats, web UI | Only once there's enough real packages to justify it. |
188+
189+---
190+
191+## Design Principles
192+
193+1. **`@import` doesn't change.** A package is just another thing a bare name can resolve to — no new syntax, no special "package import" keyword.
194+2. **Project-local, never global.** No version conflicts between unrelated projects, no `sudo` to install a package, matches how `@ash-modules/` already sits next to `ash.libs/`.
195+3. **The manifest describes intent; the lockfile describes reality.** Never let `install` silently drift from what got reviewed/committed.
196+4. **No hosted infrastructure until it's earned.** Git and a static file are a real registry for a first ecosystem; a server is a later problem.
197+5. **Packages are plain Ash.** No native code, no FFI, no build step inside a package — anything that can `@import` a package can also just read it.
198+
199+---
200+
201+## Risks & Open Questions
202+
203+- **Name squatting / collisions** once there's a shared static index — needs *some* claiming rule before the index is writable by anyone.
204+- **Git dependency pinning:** a tag can move, a branch definitely does. `ash.lock` should probably pin a commit hash for git sources, not just a tag name.
205+- **Circular package dependencies:** `ashvm`'s `@import` already handles circular *file* imports (see `imports_circular_*` tests) — need to confirm that still holds once "file" becomes "a file inside someone else's installed package."
206+- **Namespace clashes with `ash.libs/`:** a project with a local `ash.libs/http/` and a dependency also named `http` — local wins per the resolution order above, but should probably at least warn.
207+- **Removing packages:** `forgepack remove` pruning `@ash-modules/` safely needs to know what's still a transitive dependency of something else first.
208+- **No native deps today — forever?** If a package ever genuinely needs to call into C (e.g. for something `kiln` can't do in pure Ash), there's no story for that yet. Worth deciding explicitly rather than backing into it.

ideas/gpu_acceleration.mdadded

+447−0
@@ -0,0 +1,447 @@
1+# Ash GPU Acceleration
2+
3+> **TL;DR:** Ash stays a simple language. Kiln stays the native compiler. A **separate project (`ash-gpu`)** handles GPU work and picks CPU, SIMD, or GPU automatically. No GPU? Program still runs on CPU.
4+
5+---
6+
7+## Table of Contents
8+
9+1. [Goals & Non-Goals](#goals--non-goals)
10+2. [Why GPUs](#why-gpus)
11+3. [Architecture](#architecture)
12+4. [Components](#components)
13+5. [Dispatch: When to Use the GPU](#dispatch-when-to-use-the-gpu)
14+6. [Memory & Kernel Fusion](#memory--kernel-fusion)
15+7. [Language Surface](#language-surface)
16+8. [Standard Library Acceleration](#standard-library-acceleration)
17+9. [Tooling](#tooling)
18+10. [Development Phases](#development-phases)
19+11. [Design Principles](#design-principles)
20+12. [Risks & Open Questions](#risks--open-questions)
21+
22+---
23+
24+## Goals & Non-Goals
25+
26+**Goals**
27+
28+- Ordinary Ash code benefits from GPU hardware without the programmer managing kernels.
29+- Works on AMD, NVIDIA, and Intel. No vendor lock-in.
30+- Every program runs without a GPU (CPU fallback).
31+- Core language and compiler stay small.
32+
33+**Non-Goals**
34+
35+- Turning `ashvm` or Kiln into a GPU framework.
36+- Making Ash a "GPU language" with GPU syntax in everyday code.
37+- Moving individual scalar math to the GPU (see [below](#do-not-gpu-every-operation)).
38+- Multi-GPU support in the first versions.
39+
40+---
41+
42+## Why GPUs
43+
44+CPUs (e.g. Ryzen 7 7800X3D) are great at branching, sequential work, and low latency.
45+GPUs (e.g. RX 9070 XT) are built to run thousands of similar operations at once.
46+
47+```text
48+CPU: work 1 → work 2 → work 3 → work 4 → ...
49+
50+GPU: work 1 ─┐
51+ work 2 ─┤
52+ work 3 ─┼──> thousands in parallel
53+ work 4 ─┤
54+ work 5 ─┘
55+```
56+
57+**Good fits:** array/matrix ops, image processing, simulations, signal processing, physics, procedural generation, fractals, particle systems, large sorts/reductions/searches, ML workloads, parallel data processing.
58+
59+**Catch:** launching GPU work and moving data CPU ↔ GPU both cost time. So the system must *decide* whether the GPU is worth it, not assume it.
60+
61+---
62+
63+## Architecture
64+
65+```text
66+ Ash Program
67+ │
68+ ▼
69+ Kiln / Ash IR
70+ │
71+ ▼
72+ Optimization Layer
73+ │
74+ ┌──────────┴──────────┐
75+ ▼ ▼
76+ CPU path GPU path
77+ (native / SIMD) (ash-gpu runtime)
78+ │
79+ ┌────────────┼────────────┐
80+ ▼ ▼ ▼
81+ Vulkan HIP CUDA
82+ (all GPUs) (AMD) (NVIDIA)
83+```
84+
85+**Responsibilities**
86+
87+| Layer | Job |
88+|-------|-----|
89+| **Ash** | Describes *what* the programmer wants |
90+| **Kiln** | Turns it into efficient native code |
91+| **ash-gpu** | Hardware-aware acceleration for heavy compute |
92+
93+Kiln talks to `ash-gpu` through a **small C ABI** and never needs to know backend internals.
94+
95+---
96+
97+## Components
98+
99+Proposed layout:
100+
101+```text
102+ash-gpu/
103+├── include/
104+│ └── ash_gpu.h # public C ABI
105+├── runtime/ # device, memory, dispatch, scheduler (C++)
106+├── backends/ # vulkan, hip, cuda, cpu
107+├── kernels/ # vector, matrix, reduction, sorting, image
108+├── bindings/ash/ # Ash-facing API
109+├── tools/ # info, benchmark, profile, doctor
110+├── benchmarks/
111+└── tests/
112+```
113+
114+### 1. Hardware Detection
115+
116+Detect what's available and report it:
117+
118+```text
119+CPU: AMD Ryzen 7 7800X3D
120+GPU: AMD Radeon RX 9070 XT
121+VRAM: 16 GB
122+Compute: available
123+```
124+
125+Collected info: vendor, model, VRAM, compute units, supported APIs, precision formats, features, driver capabilities. No suitable GPU → everything falls back to CPU.
126+
127+### 2. Backends
128+
129+Never tie Ash itself to one API.
130+
131+| Backend | Notes |
132+|---------|-------|
133+| **Vulkan Compute** | Cross-vendor (AMD/NVIDIA/Intel), mature, explicit control. Best default. |
134+| **HIP** | CUDA-like model, strong on AMD |
135+| **CUDA** | Highly optimized NVIDIA path |
136+| **DirectCompute** | Possible Windows-specific option |
137+| **CPU** | Always-present fallback |
138+
139+### 3. Optimized Kernels
140+
141+```text
142+kernels/
143+├── arithmetic/ vector_add, vector_mul, vector_fma
144+├── vector/ vector_sin, vector_cos
145+├── matrix/ matrix_mul
146+├── reduction/ reduce_sum, reduce_min, reduce_max
147+├── sorting/ sort, prefix_sum
148+├── image/
149+├── signal/
150+└── simulation/
151+```
152+
153+Written in whatever gives the best performance: C++, HIP, CUDA, Vulkan/SPIR-V shaders. The goal is speed, not writing everything in Ash.
154+
155+### 4. Why C++ for the Runtime
156+
157+- RAII for GPU resources
158+- Cleaner device/backend abstractions
159+- Better ecosystem support
160+- Easier async management
161+
162+Kiln stays in C. The boundary is a plain C ABI:
163+
164+```c
165+bool ash_gpu_available(void);
166+void *ash_gpu_alloc(size_t bytes);
167+void ash_gpu_free(void *buf);
168+int ash_gpu_upload(void *dst, const void *src, size_t bytes);
169+int ash_gpu_download(void *dst, const void *src, size_t bytes);
170+int ash_gpu_dispatch(/* kernel, args, size */);
171+```
172+
173+```text
174+Ash → C ABI → C++ runtime → Vulkan / HIP / CUDA / DirectX
175+```
176+
177+### 5. Async Execution
178+
179+GPUs are async by nature. Don't block unless you must:
180+
181+```text
182+CPU ──► launch GPU work
183+ ──► keep doing CPU work
184+ ──► launch more GPU work
185+ ──► sync only when the result is needed
186+```
187+
188+### 6. Multiple GPUs *(future)*
189+
190+Split big workloads across devices, e.g. 100M elements → 60M on GPU 0, 40M on GPU 1. Not an initial requirement.
191+
192+---
193+
194+## Dispatch: When to Use the GPU
195+
196+The runtime picks the best implementation per operation:
197+
198+```text
199+operation
200+ │
201+ ├─ small workload? → CPU
202+ ├─ medium workload? → SIMD CPU
203+ └─ huge workload? → GPU
204+```
205+
206+| Example | Elements | Choice |
207+|---------|----------|--------|
208+| vector multiply | 10 | CPU |
209+| vector multiply | 1,000 | CPU / SIMD |
210+| vector multiply | 128 | CPU SIMD |
211+| vector multiply | 10,000,000+ | GPU |
212+
213+Thresholds depend on: operation type, element count, memory size, GPU characteristics, transfer cost, CPU SIMD capability, and **previous benchmark data**.
214+
215+### Do Not GPU Every Operation
216+
217+```ash
218+// BAD: overhead dwarfs the work
219+let x = gpu 10 * 20;
220+
221+// GOOD: big batch, GPU can shine
222+for i in 0..100000000 {
223+ output[i] = input[i] * 20;
224+}
225+```
226+
227+Optimize **workloads**, not individual instructions.
228+
229+---
230+
231+## Memory & Kernel Fusion
232+
233+### Keep data on the GPU
234+
235+Bad (ping-pong):
236+
237+```text
238+CPU → GPU → CPU → GPU → CPU
239+```
240+
241+Good (upload once, download once):
242+
243+```text
244+CPU ─upload─► GPU memory ─► multiply ─► add ─► sin ─► normalize ─download─► CPU
245+```
246+
247+### Kernel fusion
248+
249+Instead of three kernels:
250+
251+```ash
252+a = b * 2
253+c = a + 10
254+d = sin(c)
255+```
256+
257+generate one:
258+
259+```text
260+d[i] = sin(b[i] * 2 + 10)
261+```
262+
263+Saves: kernel launch overhead, intermediate writes, intermediate reads, synchronization.
264+
265+---
266+
267+## Language Surface
268+
269+**Primary goal: automatic.** Plain Ash code gets optimized.
270+
271+```ash
272+let a = huge_array();
273+let b = huge_array();
274+
275+for i in 0..a.length {
276+ a[i] = a[i] * b[i] + 42;
277+}
278+```
279+
280+The toolchain recognizes this as a highly parallel map.
281+
282+### Vectorized operations
283+
284+High-level ops that are easy to analyze:
285+
286+```ash
287+a = a * 2
288+c = a + b
289+result = sin(values)
290+```
291+
292+Internally: `parallel map → parallel map → parallel map` → fusable into one pipeline.
293+
294+### Explicit control *(advanced users, later)*
295+
296+Syntax TBD. Possibilities:
297+
298+```ash
299+parallel {
300+ for i in 0..values.length {
301+ values[i] = values[i] * 2;
302+ }
303+}
304+
305+gpu {
306+ for i in 0..values.length {
307+ output[i] = input[i] * input[i];
308+ }
309+}
310+```
311+
312+| Mode | Behavior |
313+|------|----------|
314+| Normal Ash | Automatic optimization |
315+| `parallel` / `gpu` blocks | Programmer explicitly requests acceleration |
316+
317+---
318+
319+## Standard Library Acceleration
320+
321+Likely the biggest practical win. Users call normal APIs; the implementation picks CPU / SIMD / GPU.
322+
323+```text
324+array.sort() array.sum() array.min() array.max()
325+array.map() array.filter()
326+matrix.multiply()
327+image.resize() image.blur()
328+```
329+
330+### Example
331+
332+```ash
333+let pixels = image.load("image.png");
334+
335+pixels = pixels.blur();
336+pixels = pixels.color_correct();
337+pixels = pixels.resize(3840, 2160);
338+
339+image.save(pixels, "output.png");
340+```
341+
342+| Step | Runs on |
343+|------|---------|
344+| `image.load` | CPU |
345+| `blur` | GPU |
346+| `color_correct` | GPU |
347+| `resize` | GPU |
348+| `image.save` | CPU |
349+
350+The whole middle pipeline stays resident in GPU memory: one upload, one download.
351+
352+---
353+
354+## Tooling
355+
356+```text
357+ash-gpu info # detected devices, backend, VRAM, status
358+ash-gpu benchmark # measure CPU vs SIMD vs GPU
359+ash-gpu kernels # inspect available kernels
360+ash-gpu doctor # diagnose driver/setup problems
361+ash-gpu compile # compile GPU kernels
362+ash-gpu profile program.ash
363+```
364+
365+Sample `info` output:
366+
367+```text
368+Ash GPU Runtime
369+
370+Backend: Vulkan
371+Device: AMD Radeon RX 9070 XT
372+VRAM: 16 GB
373+Status: Available
374+```
375+
376+Sample `profile` output:
377+
378+```text
379+Ash GPU Profile
380+
381+CPU time: 421 ms
382+GPU time: 83 ms
383+GPU transfer time: 19 ms
384+Kernel launch time: 3 ms
385+GPU utilization: 91%
386+
387+Top kernels:
388+ vector_mul 31 ms
389+ matrix_mul 24 ms
390+ sin_kernel 17 ms
391+ reduction 8 ms
392+```
393+
394+The profiler answers the key question: **is the GPU actually helping?**
395+
396+---
397+
398+## Development Phases
399+
400+| Phase | Goal | Details |
401+|-------|------|---------|
402+| **1. Standalone runtime** | Prove the GPU path works | Detection, one backend, alloc, upload/download, kernel execution, CPU fallback, benchmarks. **Don't touch Ash yet.** |
403+| **2. Primitive library** | Fast building blocks | Vector math, matrix math, reductions, sorting, image processing. Benchmark-driven. |
404+| **3. Ash bindings** | Let users experiment | Small API like `gpu.available()`, `gpu.array(...)`. No compiler changes. |
405+| **4. Kiln integration** | Compiler awareness | Only after the runtime is solid. Kiln emits calls to `ash-gpu` for suitable workloads. |
406+| **5. Automatic acceleration** | The real payoff | Plain `for` loops over large arrays become GPU workloads automatically. |
407+
408+---
409+
410+## Design Principles
411+
412+1. **GPU is optional.** No GPU means "use the best CPU implementation", never "can't run".
413+2. **Never assume the GPU is faster.** Only use it when the workload justifies it.
414+3. **Minimize transfers.** Keep data on the GPU across chained operations.
415+4. **Optimize whole workloads**, not single arithmetic instructions.
416+5. **Keep Ash simple.** GPU complexity lives outside the language.
417+6. **Keep Kiln maintainable.** No giant GPU framework inside the compiler.
418+7. **Benchmark everything.** Measurements over assumptions.
419+
420+---
421+
422+## Risks & Open Questions
423+
424+- **Floating-point consistency:** GPU and CPU results may differ slightly. Define what Ash guarantees.
425+- **Detecting parallel-safe loops:** Aliasing and loop-carried dependencies make auto-parallelization hard. Start with a restricted, provably safe subset.
426+- **Shipping binaries:** A program compiled on a GPU machine runs on a laptop. Needs runtime detection (not compile-time), plus bundled or JIT-compiled kernels.
427+- **Driver/backend variance:** Vulkan compute behavior differs across vendors. Needs a solid test matrix.
428+- **Dispatch accuracy:** Heuristics may misjudge. Benchmark data / calibration at install time could help.
429+- **Explicit syntax:** `parallel {}` vs `gpu {}` vs a library API. Decide later.
430+- **Error handling:** What happens on GPU out-of-memory or device loss mid-run? Should fall back to CPU transparently.
431+
432+---
433+
434+## The Big Idea
435+
436+```text
437+Ash → simple high-level programs
438+Kiln → optimized native execution
439+Ash GPU → hardware-aware acceleration
440+ │
441+ ▼
442+ CPU / SIMD / GPU
443+```
444+
445+Ash doesn't become a GPU language. Kiln doesn't become a GPU compiler framework. A normal Ash program can get dramatically faster on machines with capable GPUs and still run fine on CPU-only machines.
446+
447+GPU acceleration becomes an **implementation detail of the ecosystem**, not something every Ash programmer has to think about.
\ No newline at end of file

kiln/Makefilemodified

+6−3
@@ -14,13 +14,14 @@ KILN_SRC = \
1414 src/parser/C/declarations/vars.c \
1515 src/parser/C/imports/import_paths.c \
1616 src/parser/C/imports/imports.c \
17- src/parser/C/statements/for_loop.c \
17+ src/parser/C/statements/each_loop.c \
1818 src/parser/C/statements/loop_stack.c \
1919 src/parser/C/statements/parser_control.c \
20- src/parser/C/statements/try_catch.c \
20+ src/parser/C/statements/attempt_handle.c \
2121 src/codegen/C/builtins/builtins.c \
2222 src/codegen/C/builtins/filter_builtin.c \
2323 src/codegen/C/builtins/map_builtin.c \
24+ src/codegen/C/builtins/type_builtin.c \
2425 src/codegen/C/builtins/math_builtins.c \
2526 src/codegen/C/builtins/num_builtin.c \
2627 src/codegen/C/builtins/reduce_builtin.c \
@@ -132,4 +133,6 @@ $(KILNRT_DLL): $(BIN)
132133
133134 runtime-dll: $(KILNRT_DLL)
134135
135-.PHONY: all clean install-runtime runtime-dll
136+.PHONY: all clean rebuild install-runtime runtime-dll
137+
138+rebuild: clean all
\ No newline at end of file

kiln/README.mdmodified

+3−3
@@ -13,7 +13,7 @@ directly, the same "every line hand-written" rule that built `ashc` and
1313
1414 ## Status: milestone 1 done (Linux only)
1515
16-`kiln file.ash -o out` compiles `print <expr>;` programs (integer literals,
16+`kiln file.ash -o out` compiles `say <expr>;` programs (integer literals,
1717 `+ - * /`, unary `-`, parens, multiple statements) straight to a real,
1818 standalone, freestanding ELF64 executable - no libc, raw syscalls only.
1919
@@ -25,7 +25,7 @@ cd kiln && make # builds ../bin/kiln
2525
2626 ## What's deliberately not built yet
2727
28-- Everything beyond numeric `print` statements - variables, functions,
28+- Everything beyond numeric `say` statements - variables, functions,
2929 strings, arrays, control flow, closures
3030 - Multi-object linking (the whole program is one self-contained object
3131 right now; there's no symbol-resolution step yet)
@@ -46,7 +46,7 @@ kiln/
4646 lexer/ tokenizer (own copy, same shape as ashvm's)
4747 parser/C/ H/ D/ O/
4848 core/ parser entry, shared parser state
49- statements/ control flow, for loops, try/catch, loop stack
49+ statements/ control flow, each loops, attempt/handle, loop stack
5050 declarations/ variables, functions, constants
5151 imports/ import resolution
5252 codegen/C/ H/ D/ O/ (same feature folders in each tree)

kiln/src/codegen/C/builtins/builtins.cmodified

+7−0
@@ -11,6 +11,7 @@
1111 #include "codegen/H/io/file_builtins.h"
1212 #include "codegen/H/io/input_builtin.h"
1313 #include "codegen/H/io/argv_builtin.h"
14+#include "codegen/H/builtins/type_builtin.h"
1415 #include "parser/H/core/parser.h"
1516
1617 int codegen_try_builtin_call(const char *name, int len) {
@@ -166,5 +167,11 @@ int codegen_try_builtin_call(const char *name, int len) {
166167 codegen_builtin_delete();
167168 return 1;
168169 }
170+ if (len == 4 && strncmp(name, "type", 4) == 0) {
171+ codegen_expression();
172+ expect(TOKEN_RPAREN, "expected ')' after argument");
173+ codegen_builtin_type();
174+ return 1;
175+ }
169176 return 0;
170177 }

kiln/src/codegen/C/builtins/type_builtin.cadded

+57−0
@@ -0,0 +1,57 @@
1+#include "codegen/H/builtins/type_builtin.h"
2+#include "codegen/H/strings/strings.h"
3+#include "codegen/H/emit/emit_internal.h"
4+#include "codegen/H/emit/value.h"
5+#include "parser/H/core/parser.h"
6+
7+// Pops (tag, payload), pushes one of six string literals depending on
8+// the tag -- TAG_FUNCTION covers both a plain named function and a
9+// closure, matching value.h's documented "capture_count=0 is just a
10+// zero-capture closure" shape (kiln has no separate closure tag the way
11+// ashvm's VM_CLOSURE vs. VM_FUNCTION does, so there's only one string to
12+// return either way). The argument itself is already parsed and pushed
13+// by the caller (codegen_try_builtin_call), same convention every other
14+// single-argument builtin here uses (upper/lower/trim/str/num/...).
15+void codegen_builtin_type(void) {
16+ emit_pop_reg(code, REG_RAX); // payload (unused -- only the tag matters)
17+ emit_pop_reg(code, REG_RBX); // tag
18+
19+ int jumps[5];
20+ int n = 0;
21+
22+ emit_cmp_reg_imm32(code, REG_RBX, TAG_NUMBER);
23+ int not_number = emit_jcc_rel32(code, COND_NE);
24+ codegen_string_literal_bytes("number", 6);
25+ jumps[n++] = emit_jmp_rel32(code);
26+ emit_patch_jump(code, not_number);
27+
28+ emit_cmp_reg_imm32(code, REG_RBX, TAG_STRING);
29+ int not_string = emit_jcc_rel32(code, COND_NE);
30+ codegen_string_literal_bytes("string", 6);
31+ jumps[n++] = emit_jmp_rel32(code);
32+ emit_patch_jump(code, not_string);
33+
34+ emit_cmp_reg_imm32(code, REG_RBX, TAG_ARRAY);
35+ int not_array = emit_jcc_rel32(code, COND_NE);
36+ codegen_string_literal_bytes("array", 5);
37+ jumps[n++] = emit_jmp_rel32(code);
38+ emit_patch_jump(code, not_array);
39+
40+ emit_cmp_reg_imm32(code, REG_RBX, TAG_MAP);
41+ int not_map = emit_jcc_rel32(code, COND_NE);
42+ codegen_string_literal_bytes("map", 3);
43+ jumps[n++] = emit_jmp_rel32(code);
44+ emit_patch_jump(code, not_map);
45+
46+ emit_cmp_reg_imm32(code, REG_RBX, TAG_FUNCTION);
47+ int not_function = emit_jcc_rel32(code, COND_NE);
48+ codegen_string_literal_bytes("function", 8);
49+ jumps[n++] = emit_jmp_rel32(code);
50+ emit_patch_jump(code, not_function);
51+
52+ // TAG_NIL, or anything else -- "none" is the only tag left in
53+ // practice, so this is both the nil case and the safety net.
54+ codegen_string_literal_bytes("none", 4);
55+
56+ for (int i = 0; i < n; i++) emit_patch_jump(code, jumps[i]);
57+}

kiln/src/codegen/C/collections/arrays.cmodified

+16−3
@@ -189,10 +189,23 @@ void codegen_builtin_push(void) {
189189 emit_push_reg(code, REG_RSI); // push() returns the array itself, matching ashvm
190190 }
191191
192+// Bug fix (found by a cross-engine ashvm/kiln parity audit): this used to
193+// unconditionally read offset +8 as "count", which is correct for ARRAY
194+// and MAP (both share the same capacity/count/data-or-entries-ptr object
195+// shape, see emit/value.h) but wrong for STRING -- a string's length lives
196+// at offset -8 from its payload (the length prefix), not +8. Calling
197+// len() on a string used to read whatever garbage happened to sit 8 bytes
198+// into the string's own byte content.
192199 void codegen_builtin_len(void) {
193- emit_pop_reg(code, REG_RAX); // payload (array object address)
194- emit_pop_reg(code, REG_RBX); // tag (ignored, assumed ARRAY)
195- emit_load_mem_disp32(code, REG_RAX, REG_RAX, 8); // count
200+ emit_pop_reg(code, REG_RAX); // payload
201+ emit_pop_reg(code, REG_RBX); // tag
202+ emit_cmp_reg_imm32(code, REG_RBX, TAG_STRING);
203+ int not_string = emit_jcc_rel32(code, COND_NE);
204+ emit_load_mem_disp32(code, REG_RAX, REG_RAX, -8); // string length prefix
205+ int done = emit_jmp_rel32(code);
206+ emit_patch_jump(code, not_string);
207+ emit_load_mem_disp32(code, REG_RAX, REG_RAX, 8); // array/map count
208+ emit_patch_jump(code, done);
196209 emit_cvtsi2sd(code, XMM0, REG_RAX);
197210 emit_movq_reg_from_xmm(code, REG_RAX, XMM0);
198211 emit_mov_reg_imm64(code, REG_RBX, TAG_NUMBER);

kiln/src/codegen/C/expressions/expr.cmodified

+23−8
@@ -68,12 +68,12 @@ static void primary(void) {
6868 codegen_string_literal();
6969 return;
7070 }
71- if (current.type == TOKEN_FN) {
71+ if (current.type == TOKEN_FORGE) {
7272 codegen_lambda_expr();
7373 return;
7474 }
75- if (current.type == TOKEN_TRUE || current.type == TOKEN_FALSE) {
76- double v = current.type == TOKEN_TRUE ? 1.0 : 0.0;
75+ if (current.type == TOKEN_YES || current.type == TOKEN_NO) {
76+ double v = current.type == TOKEN_YES ? 1.0 : 0.0;
7777 advance_token();
7878 uint64_t bits;
7979 memcpy(&bits, &v, sizeof(bits));
@@ -83,7 +83,7 @@ static void primary(void) {
8383 emit_push_reg(code, REG_RAX);
8484 return;
8585 }
86- if (current.type == TOKEN_NIL) {
86+ if (current.type == TOKEN_NONE) {
8787 advance_token();
8888 emit_mov_reg_imm64(code, REG_RBX, TAG_NIL);
8989 emit_push_reg(code, REG_RBX);
@@ -139,7 +139,7 @@ static void primary(void) {
139139 }
140140 // Not a function either -- if we're compiling inside an imported
141141 // file's own namespace context, a bare reference may be that same
142- // file's own top-level constant: let_statement always registers
142+ // file's own top-level constant: local_statement always registers
143143 // imported `let`s under their namespaced name (e.g. "lib.PI"),
144144 // even for references from within lib.ash itself, so a bare "PI"
145145 // used inside lib.ash needs this same "in context" namespacing
@@ -353,7 +353,7 @@ static void power(void) {
353353
354354 static void term(void) {
355355 unary();
356- while (current.type == TOKEN_STAR || current.type == TOKEN_SLASH) {
356+ while (current.type == TOKEN_STAR || current.type == TOKEN_SLASH || current.type == TOKEN_PERCENT) {
357357 TokenType op = current.type;
358358 advance_token();
359359 unary();
@@ -363,8 +363,23 @@ static void term(void) {
363363 emit_pop_reg(code, REG_RBX); // a tag (assumed NUMBER)
364364 emit_movq_xmm_from_reg(code, XMM0, REG_RAX);
365365 emit_movq_xmm_from_reg(code, XMM1, REG_RCX);
366- if (op == TOKEN_STAR) emit_mulsd(code, XMM0, XMM1);
367- else emit_divsd(code, XMM0, XMM1);
366+ if (op == TOKEN_STAR) {
367+ emit_mulsd(code, XMM0, XMM1);
368+ } else if (op == TOKEN_SLASH) {
369+ emit_divsd(code, XMM0, XMM1);
370+ } else {
371+ // a % b = a - trunc(a / b) * b (matches ashvm's C fmod(): result
372+ // takes the dividend's sign, truncating toward zero rather than
373+ // flooring -- e.g. -7 % 3 == -1, not 2).
374+ emit_movsd_xmm_xmm(code, XMM2, XMM0); // XMM2 = a
375+ emit_divsd(code, XMM0, XMM1); // XMM0 = a / b
376+ emit_cvttsd2si(code, REG_RAX, XMM0); // truncate toward zero
377+ emit_cvtsi2sd(code, XMM0, REG_RAX); // XMM0 = trunc(a / b)
378+ emit_mulsd(code, XMM0, XMM1); // XMM0 = trunc(a / b) * b
379+ emit_movsd_xmm_xmm(code, XMM1, XMM0); // XMM1 = trunc(a / b) * b
380+ emit_movsd_xmm_xmm(code, XMM0, XMM2); // XMM0 = a
381+ emit_subsd(code, XMM0, XMM1); // XMM0 = a - trunc(a / b) * b
382+ }
368383 emit_movq_reg_from_xmm(code, REG_RAX, XMM0);
369384 emit_mov_reg_imm64(code, REG_RBX, TAG_NUMBER);
370385 emit_push_reg(code, REG_RBX);

kiln/src/codegen/C/functions/lambda.cmodified

+9−9
@@ -9,19 +9,19 @@
99 #include "parser/H/core/parser_internal.h"
1010 #include "parser/H/declarations/vars.h"
1111 #include "parser/H/declarations/functions.h"
12-#include "parser/H/statements/for_loop.h"
12+#include "parser/H/statements/each_loop.h"
1313 #include "parser/H/statements/loop_stack.h"
1414
15-// `fn (params) { body }` as an expression. The closure-object creation
15+// `forge (params) { body }` as an expression. The closure-object creation
1616 // code (which runs in the OUTER frame, snapshotting captures by value)
1717 // is emitted first, then a jump-over, then the body -- same shape as
18-// parser_control.c's fn_statement, generalized with a capture region
18+// parser_control.c's forge_statement, generalized with a capture region
1919 // ahead of the parameter region. The object's code address isn't known
2020 // until after the body compiles, so it's written via a patchable imm64
2121 // (see emit_mov_reg_imm64_patchable) and patched once the body's end is
2222 // reached.
2323 void codegen_lambda_expr(void) {
24- advance_token(); // consume 'fn'
24+ advance_token(); // consume 'forge'
2525
2626 VarScope outer_vars;
2727 vars_save(&outer_vars);
@@ -43,17 +43,17 @@ void codegen_lambda_expr(void) {
4343 int lambda_code_offset = code->count;
4444
4545 vars_clear();
46- int outer_for_depth;
47- for_depth_save(&outer_for_depth);
48- for_depth_reset();
46+ int outer_each_depth;
47+ each_depth_save(&outer_each_depth);
48+ each_depth_reset();
4949 int outer_loop_depth;
5050 loop_depth_save(&outer_loop_depth);
5151 loop_depth_reset();
5252 for (int i = 0; i < capture_count; i++) {
5353 declare_var(outer_vars.names[i], (int)strlen(outer_vars.names[i]));
5454 }
5555
56- expect(TOKEN_LPAREN, "expected '(' after 'fn'");
56+ expect(TOKEN_LPAREN, "expected '(' after 'forge'");
5757 char param_names[MAX_KILN_PARAMS][64];
5858 int param_lens[MAX_KILN_PARAMS];
5959 int argc = 0;
@@ -102,7 +102,7 @@ void codegen_lambda_expr(void) {
102102 emit_ret(code);
103103
104104 vars_restore(&outer_vars);
105- for_depth_restore(outer_for_depth);
105+ each_depth_restore(outer_each_depth);
106106 loop_depth_restore(outer_loop_depth);
107107 emit_patch_jump(code, skip_jump);
108108

kiln/src/codegen/C/runtime/errors.cmodified

+5−5
@@ -36,12 +36,12 @@ static void embed_message(CodeBuf *code, const char *msg, uint64_t *out_addr, in
3636 *out_len = len;
3737 }
3838
39-// Raise routine -- inputs RSI=message addr, RDX=message len. If a `try`
39+// Raise routine -- inputs RSI=message addr, RDX=message len. If an `attempt`
4040 // is active (kiln_try_depth_addr() > 0), pops the innermost handler,
4141 // restores its saved RSP/RBP (kiln's hand-rolled longjmp -- no libc
4242 // setjmp in a freestanding binary), stores the message as a TAG_STRING
4343 // value into the handler's recorded error-variable slot, and jumps to
44-// its catch block. Otherwise falls through to an inline fatal path
44+// its handle block. Otherwise falls through to an inline fatal path
4545 // (write to stderr, exit 1).
4646 static void emit_raise_routine(CodeBuf *code) {
4747 int skip = emit_jmp_rel32(code);
@@ -63,7 +63,7 @@ static void emit_raise_routine(CodeBuf *code) {
6363
6464 emit_load_mem_disp32(code, REG_RCX, REG_RAX, 0); // saved rsp
6565 emit_load_mem_disp32(code, REG_RDI, REG_RAX, 8); // saved rbp
66- emit_load_mem_disp32(code, REG_RDX, REG_RAX, 16); // catch target (clobbers RDX -- fine, msg len only needed on the fatal branch we didn't take)
66+ emit_load_mem_disp32(code, REG_RDX, REG_RAX, 16); // handle target (clobbers RDX -- fine, msg len only needed on the fatal branch we didn't take)
6767 emit_load_mem_disp32(code, REG_RBX, REG_RAX, 24); // error variable's rbp-relative tag offset
6868
6969 emit_mov_reg_reg(code, REG_RSP, REG_RCX);
@@ -80,7 +80,7 @@ static void emit_raise_routine(CodeBuf *code) {
8080 emit_patch_jump(code, go_fatal);
8181 // RSI/RDX still hold this call's original message addr/len -- nothing
8282 // between the raise routine's entry and here touches them on the path
83- // that skips straight past the try-active handling.
83+ // that skips straight past the attempt-active handling.
8484 platform_emit_write_stderr(code);
8585 emit_sub_reg_imm8(code, REG_RSP, 8);
8686 emit_mov_reg_reg(code, REG_RCX, REG_RSP);
@@ -131,7 +131,7 @@ void errors_emit_die(CodeBuf *code, const char *msg) {
131131 // The ONE case that must never be "catchable" (exceeding
132132 // MAX_KILN_TRY_DEPTH itself -- silently continuing there would mean
133133 // pushing a handler past the fixed table, corrupting the adjacent code
134-// segment). Only ever called from one site (parser/C/statements/try_catch.c), so
134+// segment). Only ever called from one site (parser/C/statements/attempt_handle.c), so
135135 // there's no deduplication payoff in routing it through the shared
136136 // runtime at all -- it just emits its own small inline write+exit
137137 // sequence directly, in every link mode, decoupled from wherever RAISE

kiln/src/codegen/C/runtime/print_int.cmodified

+357−93
@@ -20,91 +20,77 @@ static void load_double_const(CodeBuf *code, XReg dst, double v) {
2020 emit_movq_xmm_from_reg(code, dst, REG_RAX);
2121 }
2222
23-// Scratch layout, all within one `sub rsp, 40` region:
24-// [rsp, rsp+32) integer digits (+ optional '-'), built BACKWARD --
25-// same technique milestone 1 used, RCX decrements
26-// before each store so no reversal step is needed.
27-// rsp+32 '.' (only meaningful if frac_len > 0)
28-// [rsp+33, rsp+39) up to 6 fraction digits, built FORWARD
29-// dynamic '\n', placed right after whichever of the above
30-// actually got used, so the whole thing writes in ONE
31-// contiguous write(2) call regardless of whether this
32-// number turned out to be whole or fractional.
33-// RBX holds the sign flag through phase 3, then gets reused as frac_len
34-// (how many of the 6 fraction digits are significant, i.e. up to the
35-// last nonzero one) -- safe, sign has already been written to the
36-// buffer by the time phase 4 starts.
37-// Dispatches on the popped value's tag: a STRING just gets written
38-// directly (it's already bytes -- no digit conversion needed at all,
39-// unlike a number); everything below this point is the NUMBER path,
40-// unchanged in shape from milestone 4 except that it now starts from an
41-// already-popped payload (in RAX) instead of popping XMM0 itself.
42-//
43-// Plan B, phase B1: emitted once (jumped over), called via emit_call_back
44-// from codegen_print_top below -- this is the single biggest deduplication
45-// win of B1, since this routine used to be re-emitted in full at EVERY
46-// print statement in the source (dwarfing heap_emit_alloc/bytes_emit_copy,
47-// which are individually smaller). Takes its value in RAX=payload,
48-// RBX=tag (NOT by popping them itself): a real CALL pushes a return
49-// address on top of whatever the caller already pushed, so a `pop` here
50-// would grab the return address instead of the caller's value -- same
51-// register-based-argument reasoning codegen/C/runtime/heap.c's alloc routine
52-// already uses, not something print specifically needed before this
53-// became a real subroutine. Each of its three paths (string/nil/number)
54-// still balances its own RSP usage back to the entry value, which a real
55-// subroutine does need.
56-static int print_routine_offset = -1;
57-
58-static void emit_print_routine(CodeBuf *code) {
59- int routine_skip = emit_jmp_rel32(code);
60- print_routine_offset = code->count;
61- emit_cmp_reg_imm32(code, REG_RBX, TAG_STRING);
62- int is_number = emit_jcc_rel32(code, COND_NE);
23+// Embeds a fixed byte sequence directly in the code stream (jumped over
24+// at runtime, never executed) and writes it to stdout -- used for the
25+// small fixed strings ("none", "<function>") that don't need per-call
26+// digit/content computation. Position-(in)dependent addressing matches
27+// the technique the NIL case already used before this file grew a
28+// second caller for it (see the KILN_TARGET_LINUX/so comment below).
29+static int emit_literal(CodeBuf *code, const char *s, int len) {
30+ int skip = emit_jmp_rel32(code);
31+ int data_offset = code->count;
32+ for (int i = 0; i < len; i++) emit_byte(code, (uint8_t)s[i]);
33+ emit_patch_jump(code, skip);
34+ return data_offset;
35+}
6336
64- // ---- STRING path: [payload-8] = length, [payload..] = bytes ----
65- emit_load_mem_disp32(code, REG_RDX, REG_RAX, -8); // length
66- emit_mov_reg_reg(code, REG_RSI, REG_RAX); // buf
37+static void write_literal(CodeBuf *code, int data_offset, int len) {
38+ if (kiln_is_compiling_so() && kiln_get_target() == KILN_TARGET_LINUX) {
39+ emit_lea_rip_back(code, REG_RSI, data_offset);
40+ } else {
41+ uint64_t addr = kiln_code_base() + (uint64_t)data_offset;
42+ emit_mov_reg_imm64(code, REG_RSI, addr);
43+ }
44+ emit_mov_reg_imm64(code, REG_RDX, (uint64_t)len);
6745 platform_emit_write_stdout(code);
68- // newline, as its own tiny write (the string's bytes aren't
69- // guaranteed to be followed by one in memory)
46+}
47+
48+// Writes a single literal byte via a throwaway one-byte stack buffer --
49+// the same shape the original NIL/newline code used, generalized into a
50+// helper since the array/map printer below needs it for every bracket,
51+// comma, colon and quote. Clobbers RAX/RCX/RDX/RSI (and RBX/RDI on the
52+// Windows win_call path) like any call to platform_emit_write_stdout --
53+// callers that need something to survive across this keep it in memory
54+// ([RBP-disp], never a register), never in a register.
55+static void write_one_char(CodeBuf *code, char ch) {
7056 emit_sub_reg_imm8(code, REG_RSP, 8);
7157 emit_mov_reg_reg(code, REG_RCX, REG_RSP);
72- emit_store_byte_imm(code, REG_RCX, '\n');
58+ emit_store_byte_imm(code, REG_RCX, (uint8_t)ch);
7359 emit_mov_reg_reg(code, REG_RSI, REG_RCX);
7460 emit_mov_reg_imm64(code, REG_RDX, 1);
7561 platform_emit_write_stdout(code);
7662 emit_add_reg_imm8(code, REG_RSP, 8);
77- int done = emit_jmp_rel32(code);
78- emit_patch_jump(code, is_number);
63+}
7964
80- // ---- NIL path ----
81- emit_cmp_reg_imm32(code, REG_RBX, TAG_NIL);
82- int not_nil = emit_jcc_rel32(code, COND_NE);
83- {
84- int skip = emit_jmp_rel32(code);
85- int data_offset = code->count;
86- emit_byte(code, 'n'); emit_byte(code, 'i'); emit_byte(code, 'l'); emit_byte(code, '\n');
87- emit_patch_jump(code, skip);
88- // Position-independent path: needed only for libkilnrt.so, whose
89- // load address ld.so chooses at runtime. libkilnrt.dll (Windows)
90- // is fixed-base like everything else kiln emits (see
91- // pe/H/pe_dll_writer.h), so kiln_code_base() already resolves
92- // correctly there without RIP-relative addressing -- it now
93- // knows about DLL compilation specifically (see codegen/C/emit/layout.c).
94- if (kiln_is_compiling_so() && kiln_get_target() == KILN_TARGET_LINUX) {
95- emit_lea_rip_back(code, REG_RSI, data_offset);
96- } else {
97- uint64_t addr = kiln_code_base() + (uint64_t)data_offset;
98- emit_mov_reg_imm64(code, REG_RSI, addr);
99- }
100- emit_mov_reg_imm64(code, REG_RDX, 4);
101- platform_emit_write_stdout(code);
102- }
103- int done_nil = emit_jmp_rel32(code);
104- emit_patch_jump(code, not_nil);
65+// ---- number formatting, extracted into its own callable routine ----
66+//
67+// Entry: RAX = a double's raw bit pattern. Writes just the digits (and
68+// sign, and '.' + fraction digits if any) to stdout -- deliberately NO
69+// trailing newline, so both the top-level print routine (which adds
70+// exactly one newline after whichever path it took) and the array/map
71+// printer below (which never wants a newline mid-structure) can share
72+// it. This used to be inlined directly in the top-level routine's NUMBER
73+// path, newline and all; splitting it out is what let the array/map
74+// path below reuse it instead of duplicating ~90 lines of digit-building
75+// asm.
76+//
77+// Scratch layout, all within one `sub rsp, 40` region:
78+// [rsp, rsp+32) integer digits (+ optional '-'), built BACKWARD --
79+// RCX decrements before each store so no reversal
80+// step is needed.
81+// rsp+32 '.' (only meaningful if frac_len > 0)
82+// [rsp+33, rsp+39) up to 6 fraction digits, built FORWARD
83+// RBX holds the sign flag through the integer-digit phase, then gets
84+// reused as frac_len (how many of the 6 fraction digits are significant,
85+// i.e. up to the last nonzero one) -- safe, sign has already been
86+// written to the buffer by the time that phase starts.
87+static int number_routine_offset = -1;
10588
106- // ---- NUMBER path ----
107- emit_movq_xmm_from_reg(code, XMM0, REG_RAX); // payload bits -> double
89+static void emit_number_routine(CodeBuf *code) {
90+ int routine_skip = emit_jmp_rel32(code);
91+ number_routine_offset = code->count;
92+
93+ emit_movq_xmm_from_reg(code, XMM0, REG_RAX);
10894 emit_sub_reg_imm8(code, REG_RSP, 40);
10995
11096 // ---- sign ----
@@ -123,7 +109,7 @@ static void emit_print_routine(CodeBuf *code) {
123109 emit_cvtsi2sd(code, XMM1, REG_RAX);
124110 emit_subsd(code, XMM0, XMM1);
125111
126- // ---- integer digits, backward, same loop shape as milestone 1 ----
112+ // ---- integer digits, backward ----
127113 emit_mov_reg_reg(code, REG_RCX, REG_RSP);
128114 emit_add_reg_imm8(code, REG_RCX, 32);
129115 int loop_start = code->count;
@@ -143,16 +129,8 @@ static void emit_print_routine(CodeBuf *code) {
143129 emit_store_byte_imm(code, REG_RCX, '-');
144130 emit_patch_jump(code, skip_sign);
145131
146- // ---- fraction digits, forward, 6 unrolled steps (a fixed, small,
147- // compile-time-known count -- no runtime loop needed) ----
148- // Round-half-up at the 6th decimal place before extracting digits:
149- // repeated float arithmetic (e.g. adding 0.1 a few times) routinely
150- // lands a hair below the "true" decimal value (0.7999999999999999
151- // instead of 0.8) due to ordinary double rounding, and truncating
152- // that directly would print "0.799999" instead of "0.8". Adding half
153- // a unit-in-the-last-place first is the standard fix; it's too small
154- // to introduce a spurious digit for values that are already exact
155- // (see the loop comment below).
132+ // ---- fraction digits, forward, 6 unrolled steps -- see print_int.c's
133+ // history for why the 0.0000005 round-half-up nudge is needed ----
156134 load_double_const(code, XMM1, 0.0000005);
157135 emit_addsd(code, XMM0, XMM1);
158136 emit_mov_reg_reg(code, REG_RSI, REG_RSP);
@@ -173,7 +151,7 @@ static void emit_print_routine(CodeBuf *code) {
173151 emit_add_reg_imm8(code, REG_RSI, 1);
174152 }
175153
176- // ---- decide '.'/newline placement, then a single write(2) ----
154+ // ---- decide '.' placement, then a single write(2) -- no newline ----
177155 emit_mov_reg_reg(code, REG_RDX, REG_RSP);
178156 emit_add_reg_imm8(code, REG_RDX, 32);
179157 emit_cmp_reg_imm32(code, REG_RBX, 0);
@@ -184,23 +162,305 @@ static void emit_print_routine(CodeBuf *code) {
184162 emit_add_reg_reg(code, REG_RDX, REG_RBX); // + frac_len
185163 int after_dot = emit_jmp_rel32(code);
186164 emit_patch_jump(code, whole_number);
187- // RDX already points at rsp+32, the correct newline spot when there's no fraction
165+ // RDX already points one past the last int digit -- the correct
166+ // end-of-content position when there's no fraction.
188167 emit_patch_jump(code, after_dot);
189- emit_store_byte_imm(code, REG_RDX, '\n');
190168
191169 emit_mov_reg_reg(code, REG_RSI, REG_RCX); // buf start
192- emit_add_reg_imm8(code, REG_RDX, 1);
193- emit_sub_reg_reg(code, REG_RDX, REG_RCX); // length = (newline_pos + 1) - buf_start
170+ emit_sub_reg_reg(code, REG_RDX, REG_RCX); // length = content_end - buf_start
171+ platform_emit_write_stdout(code);
172+
173+ emit_add_reg_imm8(code, REG_RSP, 40);
174+ emit_ret(code);
175+ emit_patch_jump(code, routine_skip);
176+}
177+
178+// ---- recursive array/map printer ----
179+//
180+// Entry: RAX = payload, RBX = tag -- same convention as the top-level
181+// print routine, so array elements and map values can be fed straight
182+// into it without re-tagging. Unlike the top-level routine, strings ARE
183+// quoted here (matching ashvm's vm_print_value: an element/value that's
184+// a string always prints with quotes, even though a bare top-level `say
185+// "hi";` doesn't) -- see value.c's vm_print_value for the shape this
186+// mirrors. Never writes a trailing newline; the top-level routine adds
187+// exactly one, once, after the whole structure has printed.
188+//
189+// Own stack frame (own RBP, not the enclosing ash function's -- pushed
190+// and restored like any ordinary call) holds the three things that must
191+// survive across both the write(2) calls AND the recursive self-calls
192+// below, since those clobber every GP register this module uses:
193+// [RBP-8] data_ptr (array) / entries_ptr (map)
194+// [RBP-16] count
195+// [RBP-24] index
196+// [RBP+disp] addressing (not [RSP+disp], which this codegen layer can't
197+// even encode -- RSP as a base needs a SIB byte modrm_mem_disp32()
198+// doesn't emit, see emit.c) survives any balanced call uneventfully: a
199+// call's net effect on RSP is always zero once it returns (push
200+// return-addr, callee's own matched sub/add rsp, ret pops the
201+// return-addr), so RBP itself -- untouched by any callee here, each of
202+// which is careful to push/pop its OWN RBP if it uses one at all -- keeps
203+// addressing the exact same bytes before and after.
204+static int print_recursive_offset = -1;
205+
206+static void emit_print_recursive_routine(CodeBuf *code) {
207+ int routine_skip = emit_jmp_rel32(code);
208+ print_recursive_offset = code->count;
209+
210+ emit_push_reg(code, REG_RBP);
211+ emit_mov_reg_reg(code, REG_RBP, REG_RSP);
212+ emit_sub_reg_imm8(code, REG_RSP, 32); // [RBP-8/-16/-24] loop state, [RBP-32] map's current entry addr
213+
214+ int to_epilogue[5];
215+ int n_epilogue = 0;
216+
217+ emit_cmp_reg_imm32(code, REG_RBX, TAG_STRING);
218+ int not_string = emit_jcc_rel32(code, COND_NE);
219+ {
220+ // write_one_char() clobbers RAX/RCX/RDX/RSI (it's its own call to
221+ // platform_emit_write_stdout), so length/buf must be saved via the
222+ // real stack -- NOT held in a register -- across the opening quote.
223+ emit_load_mem_disp32(code, REG_RDX, REG_RAX, -8); // length
224+ emit_mov_reg_reg(code, REG_RSI, REG_RAX); // buf
225+ emit_push_reg(code, REG_RSI);
226+ emit_push_reg(code, REG_RDX);
227+ write_one_char(code, '"');
228+ emit_pop_reg(code, REG_RDX);
229+ emit_pop_reg(code, REG_RSI);
230+ platform_emit_write_stdout(code);
231+ write_one_char(code, '"');
232+ to_epilogue[n_epilogue++] = emit_jmp_rel32(code);
233+ }
234+ emit_patch_jump(code, not_string);
235+
236+ emit_cmp_reg_imm32(code, REG_RBX, TAG_NIL);
237+ int not_nil = emit_jcc_rel32(code, COND_NE);
238+ {
239+ static int none_offset = -1;
240+ if (none_offset == -1) none_offset = emit_literal(code, "none", 4);
241+ write_literal(code, none_offset, 4);
242+ to_epilogue[n_epilogue++] = emit_jmp_rel32(code);
243+ }
244+ emit_patch_jump(code, not_nil);
245+
246+ emit_cmp_reg_imm32(code, REG_RBX, TAG_NUMBER);
247+ int not_number = emit_jcc_rel32(code, COND_NE);
248+ {
249+ emit_call_back(code, number_routine_offset); // RAX already = payload bits
250+ to_epilogue[n_epilogue++] = emit_jmp_rel32(code);
251+ }
252+ emit_patch_jump(code, not_number);
253+
254+ emit_cmp_reg_imm32(code, REG_RBX, TAG_ARRAY);
255+ int not_array = emit_jcc_rel32(code, COND_NE);
256+ {
257+ emit_mov_reg_reg(code, REG_RCX, REG_RAX); // array object addr
258+ emit_load_mem_disp32(code, REG_RDX, REG_RCX, 8); // count
259+ emit_load_mem_disp32(code, REG_RSI, REG_RCX, 16); // data_ptr
260+ emit_store_mem_disp32(code, REG_RBP, -16, REG_RDX);
261+ emit_store_mem_disp32(code, REG_RBP, -8, REG_RSI);
262+ emit_mov_reg_imm64(code, REG_RDX, 0);
263+ emit_store_mem_disp32(code, REG_RBP, -24, REG_RDX); // index = 0
264+
265+ write_one_char(code, '[');
266+
267+ int loop_top = code->count;
268+ emit_load_mem_disp32(code, REG_RCX, REG_RBP, -24); // index
269+ emit_load_mem_disp32(code, REG_RDX, REG_RBP, -16); // count
270+ emit_cmp_reg_reg(code, REG_RCX, REG_RDX);
271+ int loop_end = emit_jcc_rel32(code, COND_GE);
272+
273+ emit_load_mem_disp32(code, REG_RSI, REG_RBP, -8); // data_ptr
274+ emit_mov_reg_reg(code, REG_RDX, REG_RCX);
275+ emit_mov_reg_imm64(code, REG_RAX, 16);
276+ emit_imul_reg_reg(code, REG_RDX, REG_RAX); // index * 16
277+ emit_add_reg_reg(code, REG_RDX, REG_RSI); // element addr
278+ emit_load_mem_disp32(code, REG_RBX, REG_RDX, 0); // elem tag
279+ emit_load_mem_disp32(code, REG_RAX, REG_RDX, 8); // elem payload
280+ emit_call_back(code, print_recursive_offset); // recursive -- quotes strings
281+
282+ // index++ is stored BEFORE deciding whether to print a comma --
283+ // not held in a register across write_one_char(','), which
284+ // clobbers RCX (see write_one_char's own comment). The decision
285+ // itself re-reads both values fresh from the frame for the same
286+ // reason: nothing here may assume a register survives a call.
287+ emit_load_mem_disp32(code, REG_RCX, REG_RBP, -24); // index
288+ emit_add_reg_imm8(code, REG_RCX, 1);
289+ emit_store_mem_disp32(code, REG_RBP, -24, REG_RCX); // index++
290+ emit_load_mem_disp32(code, REG_RCX, REG_RBP, -24);
291+ emit_load_mem_disp32(code, REG_RDX, REG_RBP, -16); // count
292+ emit_cmp_reg_reg(code, REG_RCX, REG_RDX);
293+ int skip_comma = emit_jcc_rel32(code, COND_GE);
294+ write_one_char(code, ',');
295+ write_one_char(code, ' ');
296+ emit_patch_jump(code, skip_comma);
297+ emit_jmp_back(code, loop_top);
298+
299+ emit_patch_jump(code, loop_end);
300+ write_one_char(code, ']');
301+ to_epilogue[n_epilogue++] = emit_jmp_rel32(code);
302+ }
303+ emit_patch_jump(code, not_array);
304+
305+ emit_cmp_reg_imm32(code, REG_RBX, TAG_MAP);
306+ int not_map = emit_jcc_rel32(code, COND_NE);
307+ {
308+ emit_mov_reg_reg(code, REG_RCX, REG_RAX); // map object addr
309+ emit_load_mem_disp32(code, REG_RDX, REG_RCX, 8); // count
310+ emit_load_mem_disp32(code, REG_RSI, REG_RCX, 16); // entries_ptr
311+ emit_store_mem_disp32(code, REG_RBP, -16, REG_RDX);
312+ emit_store_mem_disp32(code, REG_RBP, -8, REG_RSI);
313+ emit_mov_reg_imm64(code, REG_RDX, 0);
314+ emit_store_mem_disp32(code, REG_RBP, -24, REG_RDX); // index = 0
315+
316+ write_one_char(code, '{');
317+
318+ int loop_top = code->count;
319+ emit_load_mem_disp32(code, REG_RCX, REG_RBP, -24); // index
320+ emit_load_mem_disp32(code, REG_RDX, REG_RBP, -16); // count
321+ emit_cmp_reg_reg(code, REG_RCX, REG_RDX);
322+ int loop_end = emit_jcc_rel32(code, COND_GE);
323+
324+ emit_load_mem_disp32(code, REG_RSI, REG_RBP, -8); // entries_ptr
325+ emit_mov_reg_reg(code, REG_RDX, REG_RCX);
326+ emit_mov_reg_imm64(code, REG_RAX, 32);
327+ emit_imul_reg_reg(code, REG_RDX, REG_RAX); // index * 32
328+ emit_add_reg_reg(code, REG_RDX, REG_RSI); // entry addr
329+ // Persisted in the frame, not a register -- it has to survive the
330+ // key write, the value's tag/payload load, AND the recursive
331+ // call below, and every one of those clobbers RAX/RCX/RDX/RSI.
332+ emit_store_mem_disp32(code, REG_RBP, -32, REG_RDX);
333+
334+ // key: always a string (value.h) -- quoted, written directly
335+ // rather than through the recursive call (no tag dispatch needed).
336+ write_one_char(code, '"');
337+ emit_load_mem_disp32(code, REG_RCX, REG_RBP, -32); // entry addr
338+ emit_load_mem_disp32(code, REG_RAX, REG_RCX, 8); // key payload
339+ emit_load_mem_disp32(code, REG_RDX, REG_RAX, -8); // key length
340+ emit_mov_reg_reg(code, REG_RSI, REG_RAX); // key buf
341+ platform_emit_write_stdout(code);
342+ write_one_char(code, '"');
343+ write_one_char(code, ':');
344+ write_one_char(code, ' ');
345+
346+ emit_load_mem_disp32(code, REG_RCX, REG_RBP, -32); // entry addr
347+ emit_load_mem_disp32(code, REG_RBX, REG_RCX, 16); // value tag
348+ emit_load_mem_disp32(code, REG_RAX, REG_RCX, 24); // value payload
349+ emit_call_back(code, print_recursive_offset); // recursive -- quotes strings
350+
351+ // See the array loop's matching comment: store index++ before
352+ // deciding on a comma, then re-read both values fresh.
353+ emit_load_mem_disp32(code, REG_RCX, REG_RBP, -24); // index
354+ emit_add_reg_imm8(code, REG_RCX, 1);
355+ emit_store_mem_disp32(code, REG_RBP, -24, REG_RCX); // index++
356+ emit_load_mem_disp32(code, REG_RCX, REG_RBP, -24);
357+ emit_load_mem_disp32(code, REG_RDX, REG_RBP, -16); // count
358+ emit_cmp_reg_reg(code, REG_RCX, REG_RDX);
359+ int skip_comma = emit_jcc_rel32(code, COND_GE);
360+ write_one_char(code, ',');
361+ write_one_char(code, ' ');
362+ emit_patch_jump(code, skip_comma);
363+ emit_jmp_back(code, loop_top);
364+
365+ emit_patch_jump(code, loop_end);
366+ write_one_char(code, '}');
367+ to_epilogue[n_epilogue++] = emit_jmp_rel32(code);
368+ }
369+ emit_patch_jump(code, not_map);
370+
371+ // Fallback (TAG_FUNCTION, or anything else): matches ashvm's
372+ // "<function>"/"<closure>" rather than silently misprinting the
373+ // function object's code address as a number.
374+ {
375+ static int function_offset = -1;
376+ if (function_offset == -1) function_offset = emit_literal(code, "<function>", 10);
377+ write_literal(code, function_offset, 10);
378+ // falls straight through to the epilogue below
379+ }
380+
381+ for (int i = 0; i < n_epilogue; i++) emit_patch_jump(code, to_epilogue[i]);
382+ emit_mov_reg_reg(code, REG_RSP, REG_RBP);
383+ emit_pop_reg(code, REG_RBP);
384+ emit_ret(code);
385+ emit_patch_jump(code, routine_skip);
386+}
387+
388+// Dispatches on the popped value's tag: a STRING just gets written
389+// directly (it's already bytes -- no digit conversion needed at all,
390+// unlike a number, and unquoted here -- only array/map elements get
391+// quoted, see emit_print_recursive_routine above); NUMBER goes to
392+// number_routine; everything else (ARRAY, MAP, FUNCTION) delegates to
393+// the recursive printer. Each path ends with exactly one trailing
394+// newline, written once after the value's own content, then control
395+// converges on a single `ret`.
396+//
397+// Plan B, phase B1: emitted once (jumped over), called via emit_call_back
398+// from codegen_print_top below -- this is the single biggest deduplication
399+// win of B1, since this routine used to be re-emitted in full at EVERY
400+// print statement in the source (dwarfing heap_emit_alloc/bytes_emit_copy,
401+// which are individually smaller). Takes its value in RAX=payload,
402+// RBX=tag (NOT by popping them itself): a real CALL pushes a return
403+// address on top of whatever the caller already pushed, so a `pop` here
404+// would grab the return address instead of the caller's value -- same
405+// register-based-argument reasoning codegen/C/runtime/heap.c's alloc routine
406+// already uses, not something print specifically needed before this
407+// became a real subroutine.
408+static int print_routine_offset = -1;
409+
410+static void emit_print_routine(CodeBuf *code) {
411+ int routine_skip = emit_jmp_rel32(code);
412+ print_routine_offset = code->count;
413+ emit_cmp_reg_imm32(code, REG_RBX, TAG_STRING);
414+ int is_number = emit_jcc_rel32(code, COND_NE);
415+
416+ // ---- STRING path: [payload-8] = length, [payload..] = bytes ----
417+ emit_load_mem_disp32(code, REG_RDX, REG_RAX, -8); // length
418+ emit_mov_reg_reg(code, REG_RSI, REG_RAX); // buf
194419 platform_emit_write_stdout(code);
420+ write_one_char(code, '\n');
421+ int done = emit_jmp_rel32(code);
422+ emit_patch_jump(code, is_number);
423+
424+ // ---- NIL path ----
425+ emit_cmp_reg_imm32(code, REG_RBX, TAG_NIL);
426+ int not_nil = emit_jcc_rel32(code, COND_NE);
427+ {
428+ int skip = emit_jmp_rel32(code);
429+ int data_offset = code->count;
430+ emit_byte(code, 'n'); emit_byte(code, 'o'); emit_byte(code, 'n'); emit_byte(code, 'e');
431+ emit_patch_jump(code, skip);
432+ write_literal(code, data_offset, 4);
433+ write_one_char(code, '\n');
434+ }
435+ int done_nil = emit_jmp_rel32(code);
436+ emit_patch_jump(code, not_nil);
437+
438+ // ---- NUMBER path ----
439+ emit_cmp_reg_imm32(code, REG_RBX, TAG_NUMBER);
440+ int not_number = emit_jcc_rel32(code, COND_NE);
441+ {
442+ emit_call_back(code, number_routine_offset); // RAX already = payload bits
443+ write_one_char(code, '\n');
444+ }
445+ int done_number = emit_jmp_rel32(code);
446+ emit_patch_jump(code, not_number);
447+
448+ // ---- everything else (ARRAY, MAP, FUNCTION) -- delegate to the
449+ // recursive printer, which already has a case for each (and a
450+ // "<function>" fallback for anything further afield than that) ----
451+ emit_call_back(code, print_recursive_offset);
452+ write_one_char(code, '\n');
195453
196- emit_add_reg_imm8(code, REG_RSP, 40); // restore stack for the next statement
197454 emit_patch_jump(code, done);
198455 emit_patch_jump(code, done_nil);
456+ emit_patch_jump(code, done_number);
199457 emit_ret(code);
200458 emit_patch_jump(code, routine_skip);
201459 }
202460
203461 void print_emit_top_routine_only(CodeBuf *code) {
462+ emit_number_routine(code);
463+ emit_print_recursive_routine(code);
204464 emit_print_routine(code);
205465 }
206466
@@ -211,7 +471,11 @@ static int is_shared(void) {
211471 }
212472
213473 void print_emit_startup(CodeBuf *code) {
214- if (!is_shared()) emit_print_routine(code);
474+ if (!is_shared()) {
475+ emit_number_routine(code);
476+ emit_print_recursive_routine(code);
477+ emit_print_routine(code);
478+ }
215479 }
216480
217481 void codegen_print_top(CodeBuf *code) {

kiln/src/codegen/D/builtins/builtins.dmodified

+3−1
@@ -7,7 +7,8 @@ src/codegen/O/builtins/builtins.o: src/codegen/C/builtins/builtins.c \
77 src/codegen/H/collections/keys_values.h \
88 src/codegen/H/strings/string_builtins.h src/codegen/H/io/file_builtins.h \
99 src/codegen/H/io/input_builtin.h src/codegen/H/io/argv_builtin.h \
10- src/parser/H/core/parser.h src/codegen/H/emit/emit.h
10+ src/codegen/H/builtins/type_builtin.h src/parser/H/core/parser.h \
11+ src/codegen/H/emit/emit.h
1112 src/codegen/H/builtins/builtins.h:
1213 src/codegen/H/expressions/expr.h:
1314 src/lexer/H/lexer.h:
@@ -21,5 +22,6 @@ src/codegen/H/strings/string_builtins.h:
2122 src/codegen/H/io/file_builtins.h:
2223 src/codegen/H/io/input_builtin.h:
2324 src/codegen/H/io/argv_builtin.h:
25+src/codegen/H/builtins/type_builtin.h:
2426 src/parser/H/core/parser.h:
2527 src/codegen/H/emit/emit.h:

kiln/src/codegen/D/builtins/type_builtin.dadded

+13−0
@@ -0,0 +1,13 @@
1+src/codegen/O/builtins/type_builtin.o: \
2+ src/codegen/C/builtins/type_builtin.c \
3+ src/codegen/H/builtins/type_builtin.h src/codegen/H/strings/strings.h \
4+ src/codegen/H/emit/emit_internal.h src/codegen/H/emit/emit.h \
5+ src/codegen/H/emit/value.h src/parser/H/core/parser.h \
6+ src/lexer/H/lexer.h
7+src/codegen/H/builtins/type_builtin.h:
8+src/codegen/H/strings/strings.h:
9+src/codegen/H/emit/emit_internal.h:
10+src/codegen/H/emit/emit.h:
11+src/codegen/H/emit/value.h:
12+src/parser/H/core/parser.h:
13+src/lexer/H/lexer.h:

kiln/src/codegen/D/functions/lambda.dmodified

+2−2
@@ -4,7 +4,7 @@ src/codegen/O/functions/lambda.o: src/codegen/C/functions/lambda.c \
44 src/codegen/H/emit/value.h src/codegen/H/expressions/expr.h \
55 src/lexer/H/lexer.h src/codegen/H/emit/layout.h \
66 src/parser/H/core/parser.h src/parser/H/core/parser_internal.h \
7- src/parser/H/declarations/vars.h src/parser/H/statements/for_loop.h \
7+ src/parser/H/declarations/vars.h src/parser/H/statements/each_loop.h \
88 src/parser/H/statements/loop_stack.h
99 src/codegen/H/functions/closures.h:
1010 src/parser/H/declarations/functions.h:
@@ -17,5 +17,5 @@ src/codegen/H/emit/layout.h:
1717 src/parser/H/core/parser.h:
1818 src/parser/H/core/parser_internal.h:
1919 src/parser/H/declarations/vars.h:
20-src/parser/H/statements/for_loop.h:
20+src/parser/H/statements/each_loop.h:
2121 src/parser/H/statements/loop_stack.h:

kiln/src/codegen/H/builtins/type_builtin.hadded

+11−0
@@ -0,0 +1,11 @@
1+#ifndef KILN_TYPE_BUILTIN_H
2+#define KILN_TYPE_BUILTIN_H
3+
4+// type(value) -- returns "number"/"string"/"array"/"map"/"function"/"none".
5+// Needed for anything that has to branch on a value's own type at
6+// runtime (the motivating case: a JSON stringify can't decide "format
7+// this as an array vs. a map vs. a string" from pure Ash without some
8+// way to ask a value what it is -- see ideas/ash_modules.md).
9+void codegen_builtin_type(void);
10+
11+#endif

kiln/src/codegen/H/emit/emit.hmodified

+2−2
@@ -109,8 +109,8 @@ void emit_call_indirect(CodeBuf *buf, Reg target);
109109
110110 // JMP r/m64 (opcode FF /4) -- indirect jump through a register holding an
111111 // absolute runtime address, no return address pushed (unlike
112-// emit_call_indirect). Used by try/catch's raise routine to resume at a
113-// catch block once RSP/RBP have already been unwound to the handler's
112+// emit_call_indirect). Used by attempt/handle's raise routine to resume at a
113+// handle block once RSP/RBP have already been unwound to the handler's
114114 // saved values -- this is a resumption, not a call, so nothing should be
115115 // pushed.
116116 void emit_jmp_indirect(CodeBuf *buf, Reg target);

kiln/src/codegen/H/emit/runtime_layout.hmodified

+2−2
@@ -1,8 +1,8 @@
11 #ifndef KILN_RUNTIME_LAYOUT_H
22 #define KILN_RUNTIME_LAYOUT_H
33
4-// OS-agnostic sizes for kiln's own hand-rolled try/catch handler stack
5-// (see codegen/C/runtime/errors.c's raise routine, parser/C/statements/try_catch.c) -- the byte
4+// OS-agnostic sizes for kiln's own hand-rolled attempt/handle handler stack
5+// (see codegen/C/runtime/errors.c's raise routine, parser/C/statements/attempt_handle.c) -- the byte
66 // layout is a kiln-internal invention, not tied to ELF or PE, so both
77 // elf/H/elf_writer.h and pe/H/pe_writer.h include this rather than each
88 // re-declaring the same numbers.

kiln/src/codegen/H/runtime/errors.hmodified

+6−6
@@ -7,7 +7,7 @@
77 // Emitted once, at the very start of the generated program (right after
88 // codegen/C/runtime/heap.c's heap_emit_startup): a single reusable "raise" routine
99 // that every runtime error jumps to (never called -- there's nothing to
10-// return to, control either resumes at a catch block or the process
10+// return to, control either resumes at a handle block or the process
1111 // exits).
1212 void errors_emit_startup(CodeBuf *code);
1313
@@ -18,23 +18,23 @@ int errors_raise_routine_offset(void);
1818
1919 // Embeds `msg` (compile-time-known, jumped over so it's never executed
2020 // as instructions -- same technique codegen/C/strings/strings.c uses for string
21-// literals) and raises it: if a `try` is currently active (see
22-// parser/C/statements/try_catch.c), unwinds to its `catch` block with `msg` bound as
21+// literals) and raises it: if an `attempt` is currently active (see
22+// parser/C/statements/attempt_handle.c), unwinds to its `handle` block with `msg` bound as
2323 // a TAG_STRING value; otherwise writes it to stderr and exits with
2424 // status 1, same as before milestone 9.
2525 void errors_emit_die(CodeBuf *code, const char *msg);
2626
27-// Like errors_emit_die, but ALWAYS fatal -- bypasses the try/catch check
27+// Like errors_emit_die, but ALWAYS fatal -- bypasses the attempt/handle check
2828 // entirely. Used only for the one internal case that must never be
2929 // "catchable": exceeding MAX_KILN_TRY_DEPTH itself (silently continuing
3030 // there would mean pushing a handler past the fixed table, corrupting
3131 // the adjacent code segment).
3232 void errors_emit_fatal(CodeBuf *code, const char *msg);
3333
3434 // Like errors_emit_die, but for a RUNTIME string value (parser.c's
35-// `throw expr;`) instead of a compile-time-constant C string -- the
35+// `raise expr;`) instead of a compile-time-constant C string -- the
3636 // caller must already have RSI=message payload and RDX=message length
37-// loaded (a throw expression's already-popped (tag,payload), payload is
37+// loaded (a raise expression's already-popped (tag,payload), payload is
3838 // already a valid length-prefixed TAG_STRING pointer, so there's nothing
3939 // to embed, just the same jump to the raise routine errors_emit_die uses.
4040 void errors_emit_die_dynamic(CodeBuf *code);

kiln/src/diagnostics/C/diagnostics.cmodified

+1−1
@@ -62,7 +62,7 @@ static int has_prefix(const char *message, const char *prefix) {
6262 }
6363
6464 static const char *lookup_hint(const char *message) {
65- if (has_prefix(message, "undefined variable")) return "did you forget to declare it with `let`?";
65+ if (has_prefix(message, "undefined variable")) return "did you forget to declare it with `local`?";
6666 if (has_prefix(message, "undefined function")) return "check the function name is spelled correctly";
6767 if (has_prefix(message, "index out of bounds")) return "check the array's length before indexing";
6868 if (has_prefix(message, "key not found")) return "use `has(map, key)` to check before accessing";

kiln/src/elf/H/elf_writer.hmodified

+2−2
@@ -15,8 +15,8 @@
1515 #define KILN_PHDR_SIZE 56
1616
1717 // Milestone 9: the globals region also holds kiln's own hand-rolled
18-// setjmp/longjmp equivalent for try/catch (see codegen/C/runtime/errors.c's raise
19-// routine and parser/C/statements/try_catch.c) -- a fixed-depth stack of "handler"
18+// setjmp/longjmp equivalent for attempt/handle (see codegen/C/runtime/errors.c's raise
19+// routine and parser/C/statements/attempt_handle.c) -- a fixed-depth stack of "handler"
2020 // records, since this is a freestanding binary with no libc setjmp to
2121 // call. Each handler is {saved_rsp, saved_rbp, target_addr,
2222 // error_slot_rbp_offset}, 4 x int64. Bounded the same way every other

kiln/src/lexer/C/lexer.cmodified

+36−23
@@ -140,23 +140,29 @@ static Token identifier(void) {
140140 while (isalnum(peek()) || peek() == '_') advance();
141141
142142 int length = (int)(lex_current - lex_start);
143- if (length == 5 && strncmp(lex_start, "print", 5) == 0) return make_token(TOKEN_PRINT);
144- if (length == 3 && strncmp(lex_start, "let", 3) == 0) return make_token(TOKEN_LET);
145- if (length == 2 && strncmp(lex_start, "if", 2) == 0) return make_token(TOKEN_IF);
146- if (length == 4 && strncmp(lex_start, "else", 4) == 0) return make_token(TOKEN_ELSE);
147- if (length == 5 && strncmp(lex_start, "while", 5) == 0) return make_token(TOKEN_WHILE);
148- if (length == 3 && strncmp(lex_start, "for", 3) == 0) return make_token(TOKEN_FOR);
143+ if (length == 3 && strncmp(lex_start, "say", 3) == 0) return make_token(TOKEN_SAY);
144+ if (length == 5 && strncmp(lex_start, "local", 5) == 0) return make_token(TOKEN_LOCAL);
145+ if (length == 5 && strncmp(lex_start, "given", 5) == 0) return make_token(TOKEN_GIVEN);
146+ if (length == 9 && strncmp(lex_start, "otherwise", 9) == 0) return make_token(TOKEN_OTHERWISE);
147+ if (length == 6 && strncmp(lex_start, "during", 6) == 0) return make_token(TOKEN_DURING);
148+ if (length == 4 && strncmp(lex_start, "each", 4) == 0) return make_token(TOKEN_EACH);
149149 if (length == 2 && strncmp(lex_start, "in", 2) == 0) return make_token(TOKEN_IN);
150- if (length == 2 && strncmp(lex_start, "fn", 2) == 0) return make_token(TOKEN_FN);
151- if (length == 6 && strncmp(lex_start, "return", 6) == 0) return make_token(TOKEN_RETURN);
152- if (length == 3 && strncmp(lex_start, "try", 3) == 0) return make_token(TOKEN_TRY);
153- if (length == 5 && strncmp(lex_start, "catch", 5) == 0) return make_token(TOKEN_CATCH);
154- if (length == 5 && strncmp(lex_start, "throw", 5) == 0) return make_token(TOKEN_THROW);
155- if (length == 5 && strncmp(lex_start, "break", 5) == 0) return make_token(TOKEN_BREAK);
156- if (length == 8 && strncmp(lex_start, "continue", 8) == 0) return make_token(TOKEN_CONTINUE);
157- if (length == 4 && strncmp(lex_start, "true", 4) == 0) return make_token(TOKEN_TRUE);
158- if (length == 5 && strncmp(lex_start, "false", 5) == 0) return make_token(TOKEN_FALSE);
159- if (length == 3 && strncmp(lex_start, "nil", 3) == 0) return make_token(TOKEN_NIL);
150+ if (length == 5 && strncmp(lex_start, "forge", 5) == 0) return make_token(TOKEN_FORGE);
151+ if (length == 5 && strncmp(lex_start, "yield", 5) == 0) return make_token(TOKEN_YIELD);
152+ if (length == 7 && strncmp(lex_start, "attempt", 7) == 0) return make_token(TOKEN_ATTEMPT);
153+ if (length == 6 && strncmp(lex_start, "handle", 6) == 0) return make_token(TOKEN_HANDLE);
154+ if (length == 5 && strncmp(lex_start, "raise", 5) == 0) return make_token(TOKEN_RAISE);
155+ if (length == 4 && strncmp(lex_start, "stop", 4) == 0) return make_token(TOKEN_STOP);
156+ if (length == 4 && strncmp(lex_start, "next", 4) == 0) return make_token(TOKEN_NEXT);
157+ if (length == 3 && strncmp(lex_start, "yes", 3) == 0) return make_token(TOKEN_YES);
158+ if (length == 2 && strncmp(lex_start, "no", 2) == 0) return make_token(TOKEN_NO);
159+ if (length == 4 && strncmp(lex_start, "none", 4) == 0) return make_token(TOKEN_NONE);
160+ if (length == 3 && strncmp(lex_start, "and", 3) == 0) return make_token(TOKEN_AND);
161+ if (length == 2 && strncmp(lex_start, "or", 2) == 0) return make_token(TOKEN_OR);
162+ // "not" reuses TOKEN_BANG (unary logical negation) -- same meaning as
163+ // the symbol it replaces, so unary()'s existing TOKEN_BANG handling in
164+ // codegen/C/expressions/expr.c needs no change at all.
165+ if (length == 3 && strncmp(lex_start, "not", 3) == 0) return make_token(TOKEN_BANG);
160166
161167 return make_token(TOKEN_IDENTIFIER);
162168 }
@@ -196,19 +202,26 @@ Token lexer_next_token(void) {
196202 case '[': return make_token(TOKEN_LBRACKET);
197203 case ']': return make_token(TOKEN_RBRACKET);
198204 case '=': return make_token(match('=') ? TOKEN_EQUAL_EQUAL : TOKEN_EQUAL);
199- case '!': return make_token(match('=') ? TOKEN_BANG_EQUAL : TOKEN_BANG);
205+ // Bare '!' is no longer valid on its own -- logical negation is the
206+ // 'not' keyword now (see identifier()). '!=' (inequality) stays
207+ // symbolic either way.
208+ case '!':
209+ if (match('=')) return make_token(TOKEN_BANG_EQUAL);
210+ return error_token("unexpected character");
200211 case '<':
201212 if (match('<')) return make_token(TOKEN_SHL);
202213 return make_token(match('=') ? TOKEN_LESS_EQUAL : TOKEN_LESS);
203214 case '>':
204215 if (match('>')) return make_token(TOKEN_SHR);
205216 return make_token(match('=') ? TOKEN_GREATER_EQUAL : TOKEN_GREATER);
206- case '&':
207- if (match('&')) return make_token(TOKEN_AND);
208- return make_token(TOKEN_AMP);
209- case '|':
210- if (match('|')) return make_token(TOKEN_OR);
211- return make_token(TOKEN_PIPE);
217+ // '&'/'|' are always the bitwise operators now -- 'and'/'or' are
218+ // the only way to write the logical versions (see identifier()).
219+ // This also means a literal '&&'/'||' in old-syntax source now
220+ // lexes as two separate AMP/PIPE tokens rather than one fused one,
221+ // consistent with the rest of today's renames replacing their old
222+ // symbol outright rather than keeping it as an alias.
223+ case '&': return make_token(TOKEN_AMP);
224+ case '|': return make_token(TOKEN_PIPE);
212225 case '^': return make_token(TOKEN_CARET);
213226 case '~': return make_token(TOKEN_TILDE);
214227 case '@':

kiln/src/lexer/H/lexer.hmodified

+16−16
@@ -2,24 +2,24 @@
22 #define LEXER_H
33
44 typedef enum {
5- TOKEN_PRINT,
6- TOKEN_LET,
7- TOKEN_IF,
8- TOKEN_ELSE,
9- TOKEN_WHILE,
10- TOKEN_FOR,
5+ TOKEN_SAY,
6+ TOKEN_LOCAL,
7+ TOKEN_GIVEN,
8+ TOKEN_OTHERWISE,
9+ TOKEN_DURING,
10+ TOKEN_EACH,
1111 TOKEN_IN,
1212 TOKEN_IMPORT,
13- TOKEN_FN,
14- TOKEN_RETURN,
15- TOKEN_TRY,
16- TOKEN_CATCH,
17- TOKEN_THROW,
18- TOKEN_BREAK,
19- TOKEN_CONTINUE,
20- TOKEN_TRUE,
21- TOKEN_FALSE,
22- TOKEN_NIL,
13+ TOKEN_FORGE,
14+ TOKEN_YIELD,
15+ TOKEN_ATTEMPT,
16+ TOKEN_HANDLE,
17+ TOKEN_RAISE,
18+ TOKEN_STOP,
19+ TOKEN_NEXT,
20+ TOKEN_YES,
21+ TOKEN_NO,
22+ TOKEN_NONE,
2323 TOKEN_IDENTIFIER,
2424 TOKEN_STRING,
2525 TOKEN_IMPORT_PATH,

kiln/src/parser/C/core/parser.cmodified

+90−59
@@ -40,10 +40,11 @@ void expect(TokenType type, const char *message) {
4040
4141 // statement() is shared between top-level parsing (compile_program's own
4242 // loop, and imports.c's parse_imported_file_body()) and here, inside a
43-// fn/if/while/for/try body -- block_depth distinguishes the two so the
44-// "only fn/let at an imported file's top level" guard in statement()
45-// only fires for genuinely top-level statements, never for an ordinary
46-// `return`/`if`/etc. nested inside an imported function's own body.
43+// forge/given/during/each/attempt body -- block_depth distinguishes the
44+// two so the "only forge/local at an imported file's top level" guard in
45+// statement() only fires for genuinely top-level statements, never for an
46+// ordinary `yield`/`given`/etc. nested inside an imported function's own
47+// body.
4748 static int block_depth = 0;
4849
4950 void block(void) {
@@ -56,19 +57,26 @@ void block(void) {
5657 vars_scope_end(saved_scope);
5758 }
5859
59-// let IDENT = expr ; -- reassigns the existing slot if `name` is already
60-// declared instead of erroring, matching ashvm's own let-statement
61-// behavior exactly (ashvm/src/compiler/stmt.c's let_statement).
62-static void let_statement(void) {
60+// local IDENT = expr ; -- reassigns the existing slot if `name` is already
61+// declared instead of erroring, matching ashvm's own local-statement
62+// behavior exactly (ashvm/src/compiler/stmt.c's local_statement).
63+static void local_statement(void) {
6364 advance_token();
64- expect(TOKEN_IDENTIFIER, "expected variable name after 'let'");
65+ expect(TOKEN_IDENTIFIER, "expected variable name after 'local'");
6566 const char *name = previous.start;
6667 int len = previous.length;
6768 expect(TOKEN_EQUAL, "expected '=' after variable name");
6869
6970 const char *ns; int ns_len;
7071 get_import_namespace(&ns, &ns_len);
71- if (ns_len > 0) {
72+ // Bug (found while writing the first real forgepack module, a JSON
73+ // codec -- any imported function with a `local` inside a loop failed
74+ // to compile, mirrored from the identical ashvm bug): this only
75+ // makes sense at block_depth == 0 (a genuinely top-level `local` in
76+ // the imported file), not for an ordinary local deep inside one of
77+ // that file's own functions -- the sibling guard further down in
78+ // statement() already gets this right, this one didn't.
79+ if (ns_len > 0 && block_depth == 0) {
7280 char combined[96];
7381 int combined_len = snprintf(combined, sizeof(combined), "%.*s.%.*s", ns_len, ns, len, name);
7482 if (current.type == TOKEN_NUMBER) {
@@ -86,7 +94,7 @@ static void let_statement(void) {
8694 declare_string_constant(combined, combined_len, current.start, current.length);
8795 advance_token();
8896 } else {
89- parse_error("imported top-level 'let' must be a literal number or string constant");
97+ parse_error("imported top-level 'local' must be a literal number or string constant");
9098 }
9199 expect(TOKEN_SEMICOLON, "expected ';' after statement");
92100 return;
@@ -241,6 +249,26 @@ static void incdec_statement(Token id, TokenType op) {
241249 static void assignment_statement(void) {
242250 Token id = current;
243251 advance_token();
252+ // A namespaced call used as its OWN statement (`json.save(...);`,
253+ // discarding the result) rather than nested in an expression
254+ // (`local x = json.load(...);`, where codegen/C/expressions/expr.c's
255+ // primary() already handles this same `ident.member(...)` shape) --
256+ // mirrors the identical ashvm fix, found missing the same way (the
257+ // first real forgepack module's whole point is exactly this shape).
258+ if (current.type == TOKEN_DOT) {
259+ advance_token();
260+ expect(TOKEN_IDENTIFIER, "expected name after '.'");
261+ Token member = previous;
262+ char combined[128];
263+ int combined_len = snprintf(combined, sizeof(combined), "%.*s.%.*s",
264+ id.length, id.start, member.length, member.start);
265+ expect(TOKEN_LPAREN, "expected '(' after namespaced function name");
266+ codegen_call_named(combined, combined_len);
267+ expect(TOKEN_SEMICOLON, "expected ';' after call");
268+ emit_pop_reg(code, REG_RAX); // discard the (unused) return value
269+ emit_pop_reg(code, REG_RBX);
270+ return;
271+ }
244272 if (current.type == TOKEN_LBRACKET) { index_assignment_statement(id); return; }
245273 if (current.type == TOKEN_LPAREN) { call_statement(id); return; }
246274 if (current.type == TOKEN_PLUS_EQUAL || current.type == TOKEN_MINUS_EQUAL ||
@@ -263,14 +291,14 @@ static void assignment_statement(void) {
263291 emit_store_mem_disp32(code, REG_RBP, var_slot_payload_offset(slot), REG_RAX);
264292 }
265293
266-// return [expr] ; -- bare `return;` returns 0, matching ashc/ashvm.
294+// yield [expr] ; -- bare `yield;` returns 0, matching ashc/ashvm.
267295 // Return value comes back as RBX=tag, RAX=payload (see codegen/C/expressions/expr.c's
268296 // call-site handling).
269-static void return_statement(void) {
297+static void yield_statement(void) {
270298 advance_token();
271299 if (current.type != TOKEN_SEMICOLON) {
272300 codegen_expression();
273- expect(TOKEN_SEMICOLON, "expected ';' after return");
301+ expect(TOKEN_SEMICOLON, "expected ';' after yield");
274302 emit_pop_reg(code, REG_RAX); // payload
275303 emit_pop_reg(code, REG_RBX); // tag
276304 } else {
@@ -283,81 +311,84 @@ static void return_statement(void) {
283311 emit_ret(code);
284312 }
285313
286-// throw expr ; -- expr must be a STRING (matches try/catch's own
287-// convention: every caught error, built-in or user-thrown, is a plain
288-// string). Reuses codegen/C/runtime/errors.c's raise routine directly -- the exact
289-// same catch/unwind mechanism M9's built-in errors (array bounds, missing
290-// map key) already go through, just with a runtime message instead of a
291-// compile-time-constant one.
292-static void throw_statement(void) {
314+// raise expr ; -- expr must be a STRING (matches attempt/handle's own
315+// convention: every caught error, built-in or user-raised, is a plain
316+// string). Reuses codegen/C/runtime/errors.c's raise routine directly --
317+// the exact same handle/unwind mechanism M9's built-in errors (array
318+// bounds, missing map key) already go through, just with a runtime
319+// message instead of a compile-time-constant one. (The runtime "raise
320+// routine" this calls into was already named that before the `throw`
321+// keyword became `raise` -- a coincidental match, not a rename.)
322+static void raise_statement(void) {
293323 advance_token();
294324 codegen_expression();
295- expect(TOKEN_SEMICOLON, "expected ';' after 'throw'");
325+ expect(TOKEN_SEMICOLON, "expected ';' after 'raise'");
296326
297327 emit_pop_reg(code, REG_RSI); // message payload (assumed STRING)
298328 emit_pop_reg(code, REG_RBX); // message tag (ignored)
299329 emit_load_mem_disp32(code, REG_RDX, REG_RSI, -8); // length
300330 errors_emit_die_dynamic(code);
301331 }
302332
303-// break ; -- jumps past the innermost loop; the jump is recorded, not
333+// stop ; -- jumps past the innermost loop; the jump is recorded, not
304334 // resolved here, since the innermost loop's own codegen (parser_control.c's
305-// while_statement / parser/C/statements/for_loop.c's for_statement) hasn't emitted the
335+// during_statement / parser/C/statements/each_loop.c's each_statement) hasn't emitted the
306336 // "after the loop" landing point yet.
307-static void break_statement(void) {
337+static void stop_statement(void) {
308338 advance_token();
309- expect(TOKEN_SEMICOLON, "expected ';' after 'break'");
310- loop_record_break(emit_jmp_rel32(code));
339+ expect(TOKEN_SEMICOLON, "expected ';' after 'stop'");
340+ loop_record_stop(emit_jmp_rel32(code));
311341 }
312342
313-// continue ; -- jumps to the innermost loop's per-iteration advance
314-// step (a for-loop's index increment, or straight back to the condition
315-// for a while loop) -- also recorded, not resolved here, for the same
316-// reason as break above.
317-static void continue_statement(void) {
343+// next ; -- jumps to the innermost loop's per-iteration advance
344+// step (an each-loop's index increment, or straight back to the condition
345+// for a during loop) -- also recorded, not resolved here, for the same
346+// reason as stop above.
347+static void next_statement(void) {
318348 advance_token();
319- expect(TOKEN_SEMICOLON, "expected ';' after 'continue'");
320- loop_record_continue(emit_jmp_rel32(code));
349+ expect(TOKEN_SEMICOLON, "expected ';' after 'next'");
350+ loop_record_next(emit_jmp_rel32(code));
321351 }
322352
323-// statement := "print" expr ";"
324-// | "let" IDENT "=" expr ";"
353+// statement := "say" expr ";"
354+// | "local" IDENT "=" expr ";"
325355 // | IDENT "=" expr ";"
326-// | "if" "(" expr ")" block ["else" block]
327-// | "while" "(" expr ")" block
328-// | "fn" IDENT "(" params ")" block
329-// | "return" [expr] ";"
330-// (milestone 5's entire statement grammar -- anything else is a parse
331-// error, per the project rule that malformed input always fails cleanly)
356+// | "given" ["("] expr [")"] block ["otherwise" block]
357+// | "during" ["("] expr [")"] block
358+// | "forge" IDENT "(" params ")" block
359+// | "yield" [expr] ";"
360+// (milestone 5's entire statement grammar, since renamed and given optional
361+// condition parens -- anything else is a parse error, per the project rule
362+// that malformed input always fails cleanly)
332363 void statement(void) {
333364 if (current.type == TOKEN_IMPORT) { import_statement(); return; }
334365 imports_close_block();
335366
336367 const char *ns; int ns_len;
337368 get_import_namespace(&ns, &ns_len);
338- if (ns_len > 0 && block_depth == 0 && current.type != TOKEN_FN && current.type != TOKEN_LET) {
339- parse_error("only 'fn' and constant 'let' declarations are allowed at an imported file's top level");
369+ if (ns_len > 0 && block_depth == 0 && current.type != TOKEN_FORGE && current.type != TOKEN_LOCAL) {
370+ parse_error("only 'forge' and constant 'local' declarations are allowed at an imported file's top level");
340371 }
341372
342- if (current.type == TOKEN_PRINT) {
373+ if (current.type == TOKEN_SAY) {
343374 advance_token();
344375 codegen_expression();
345- expect(TOKEN_SEMICOLON, "expected ';' after print statement");
376+ expect(TOKEN_SEMICOLON, "expected ';' after say statement");
346377 codegen_print_top(code);
347378 return;
348379 }
349- if (current.type == TOKEN_LET) { let_statement(); return; }
350- if (current.type == TOKEN_IF) { if_statement(); return; }
351- if (current.type == TOKEN_WHILE) { while_statement(); return; }
352- if (current.type == TOKEN_FN) { fn_statement(); return; }
353- if (current.type == TOKEN_RETURN) { return_statement(); return; }
354- if (current.type == TOKEN_TRY) { try_statement(); return; }
355- if (current.type == TOKEN_FOR) { for_statement(); return; }
356- if (current.type == TOKEN_BREAK) { break_statement(); return; }
357- if (current.type == TOKEN_CONTINUE) { continue_statement(); return; }
358- if (current.type == TOKEN_THROW) { throw_statement(); return; }
380+ if (current.type == TOKEN_LOCAL) { local_statement(); return; }
381+ if (current.type == TOKEN_GIVEN) { given_statement(); return; }
382+ if (current.type == TOKEN_DURING) { during_statement(); return; }
383+ if (current.type == TOKEN_FORGE) { forge_statement(); return; }
384+ if (current.type == TOKEN_YIELD) { yield_statement(); return; }
385+ if (current.type == TOKEN_ATTEMPT) { attempt_statement(); return; }
386+ if (current.type == TOKEN_EACH) { each_statement(); return; }
387+ if (current.type == TOKEN_STOP) { stop_statement(); return; }
388+ if (current.type == TOKEN_NEXT) { next_statement(); return; }
389+ if (current.type == TOKEN_RAISE) { raise_statement(); return; }
359390 if (current.type == TOKEN_IDENTIFIER) { assignment_statement(); return; }
360- parse_error("expected 'print', 'let', 'if', 'while', 'fn', 'return', 'try', 'for', 'break', 'continue', 'throw', '@import', or an assignment");
391+ parse_error("expected 'say', 'local', 'given', 'during', 'forge', 'yield', 'attempt', 'each', 'stop', 'next', 'raise', '@import', or an assignment");
361392 }
362393
363394 void compile_program(const char *source, const char *source_path, CodeBuf *out) {
@@ -384,8 +415,8 @@ void compile_program(const char *source, const char *source_path, CodeBuf *out)
384415 // Frame prologue: RBP anchors the top-level program's own variable
385416 // slots. RSP drifts during expression push/pop; RBP never does. Each
386417 // function call below establishes and tears down its own separate
387- // frame (see parser_control.c's fn_statement/parser.c's
388- // return_statement) without disturbing this one -- the callee always
418+ // frame (see parser_control.c's forge_statement/parser.c's
419+ // yield_statement) without disturbing this one -- the callee always
389420 // restores RBP before returning. Milestone 5: each slot is now 16
390421 // bytes (tag + payload, see codegen/H/emit/value.h), not 8.
391422 emit_mov_reg_reg(code, REG_RBP, REG_RSP);

kiln/src/parser/C/declarations/vars.cmodified

+12−12
@@ -7,16 +7,16 @@ static int var_count = 0;
77 static int scope_start = 0;
88
99 // Scans newest-first (most-recently-declared wins), standard shadowing
10-// semantics -- required for try/catch: parser/C/statements/try_catch.c reserves each
11-// catch variable's slot with a synthetic placeholder name and renames it
10+// semantics -- required for attempt/handle: parser/C/statements/attempt_handle.c reserves each
11+// handle variable's slot with a synthetic placeholder name and renames it
1212 // to the real name only after the fact (the slot's rbp-offset has to be
13-// known before the try body compiles, before the real name is even
14-// parsed -- see try_catch.c's header comment), so two SEQUENTIAL (not
15-// nested) `catch (e)` blocks end up with two separate table entries both
16-// named "e". A first-match-wins scan would bind the second catch body's
17-// references to the FIRST catch's stale slot; newest-first fixes that
18-// while leaving `let`'s own reuse-if-already-declared behavior unchanged
19-// (there's normally only one live entry for a given `let` name anyway).
13+// known before the attempt body compiles, before the real name is even
14+// parsed -- see attempt_handle.c's header comment), so two SEQUENTIAL (not
15+// nested) `handle (e)` blocks end up with two separate table entries both
16+// named "e". A first-match-wins scan would bind the second handle body's
17+// references to the FIRST handle's stale slot; newest-first fixes that
18+// while leaving `local`'s own reuse-if-already-declared behavior unchanged
19+// (there's normally only one live entry for a given `local` name anyway).
2020 int resolve_var(const char *name, int len) {
2121 for (int i = var_count - 1; i >= 0; i--) {
2222 if ((int)strlen(var_names[i]) == len && strncmp(var_names[i], name, len) == 0) return i;
@@ -70,12 +70,12 @@ int32_t call_scratch_offset(void) { return var_slot_tag_offset(MAX_KILN_VARS); }
7070
7171 int32_t higher_order_scratch_offset(void) { return call_scratch_offset() - KILN_HIGHER_ORDER_SCRATCH_SIZE; }
7272
73-int32_t for_level_offset(int depth) {
74- return higher_order_scratch_offset() - KILN_FOR_STACK_SIZE + depth * KILN_FOR_LEVEL_SIZE;
73+int32_t each_level_offset(int depth) {
74+ return higher_order_scratch_offset() - KILN_EACH_STACK_SIZE + depth * KILN_EACH_LEVEL_SIZE;
7575 }
7676
7777 int32_t win_call_scratch_offset(void) {
78- return higher_order_scratch_offset() - KILN_FOR_STACK_SIZE - KILN_WIN_CALL_SCRATCH_SIZE;
78+ return higher_order_scratch_offset() - KILN_EACH_STACK_SIZE - KILN_WIN_CALL_SCRATCH_SIZE;
7979 }
8080
8181 int32_t win_outparam_offset(void) {

kiln/src/parser/C/imports/import_paths.cmodified

+6−0
@@ -50,8 +50,14 @@ int import_resolve_path(const char *raw_path, int raw_len,
5050 return 0;
5151 }
5252 }
53+ // ash.libs/ (hand-written local libraries) wins first; a package
54+ // installed by forgepack (see ideas/ash_modules.md) only gets
55+ // tried if there's no local library shadowing the same name.
5356 snprintf(candidate, sizeof(candidate), "%s/ash.libs/%.*s/%.*s.ash",
5457 project_root_dir, raw_len, raw_path, raw_len, raw_path);
58+ if (realpath(candidate, canonical_out) != NULL) return 1;
59+ snprintf(candidate, sizeof(candidate), "%s/@ash-modules/%.*s/%.*s.ash",
60+ project_root_dir, raw_len, raw_path, raw_len, raw_path);
5561 }
5662
5763 if (realpath(candidate, canonical_out) == NULL) {

kiln/src/parser/C/imports/imports.cmodified

+1−1
@@ -177,7 +177,7 @@ void import_statement(void) {
177177 resolving_depth++;
178178
179179 // --- Save everything the recursive parse of the imported file will
180- // clobber, exactly mirroring vars_save/for_depth_save/loop_depth_save's
180+ // clobber, exactly mirroring vars_save/each_depth_save/loop_depth_save's
181181 // existing save-recurse-restore pattern (the C call stack gives
182182 // correct nesting for free, same as those). ---
183183 DiagnosticsSource saved_diag = diagnostics_save_source();

kiln/src/parser/C/statements/each_loop.cmodified

+2−2
@@ -70,14 +70,14 @@ void each_statement(void) {
7070
7171 loop_push();
7272 block();
73- loop_patch_continues(); // continue lands here, right before i++
73+ loop_patch_nexts(); // next lands here, right before i++
7474
7575 emit_load_mem_disp32(code, REG_RCX, REG_RBP, level + 16);
7676 emit_add_reg_imm8(code, REG_RCX, 1);
7777 emit_store_mem_disp32(code, REG_RBP, level + 16, REG_RCX); // i++
7878 emit_jmp_back(code, loop_start);
7979 emit_patch_jump(code, loop_exit);
80- loop_pop_and_patch_breaks(); // break lands here, after the loop
80+ loop_pop_and_patch_stops(); // stop lands here, after the loop
8181
8282 each_nesting_depth--;
8383 vars_scope_end(saved_scope);

kiln/src/parser/C/statements/for_loop.cremoved

+0−82
@@ -1,82 +0,0 @@
1-#include "parser/H/statements/for_loop.h"
2-#include "parser/H/core/parser.h"
3-#include "parser/H/core/parser_internal.h"
4-#include "parser/H/declarations/vars.h"
5-#include "parser/H/statements/loop_stack.h"
6-#include "codegen/H/expressions/expr.h"
7-#include "codegen/H/emit/emit.h"
8-
9-static int for_nesting_depth = 0;
10-
11-void for_depth_save(int *out) { *out = for_nesting_depth; }
12-void for_depth_reset(void) { for_nesting_depth = 0; }
13-void for_depth_restore(int saved) { for_nesting_depth = saved; }
14-
15-// for (x in array) { block }
16-//
17-// The array expression is evaluated once, up front; x is bound to each
18-// element in turn via the same var-slot mechanism `let` uses, so it's a
19-// perfectly ordinary variable inside the body (readable, reassignable,
20-// though reassigning it doesn't affect iteration -- the next iteration
21-// overwrites it again from the array, matching a plain for-each's usual
22-// semantics). data_ptr/count/i live in this nesting level's fixed
23-// RBP-relative slot (see vars.h's for_level_offset) so they survive
24-// whatever the body's own codegen does, including calls.
25-void for_statement(void) {
26- VarBlockScope saved_scope = vars_scope_begin();
27- advance_token(); // consume 'for'
28- expect(TOKEN_LPAREN, "expected '(' after 'for'");
29- expect(TOKEN_IDENTIFIER, "expected loop variable name");
30- const char *var_name = previous.start;
31- int var_len = previous.length;
32- expect(TOKEN_IN, "expected 'in' after loop variable");
33- codegen_expression(); // pushes the array (tag, payload)
34- expect(TOKEN_RPAREN, "expected ')' after loop expression");
35-
36- if (for_nesting_depth >= MAX_KILN_FOR_DEPTH) parse_error("too many nested for loops");
37- int32_t level = for_level_offset(for_nesting_depth);
38- for_nesting_depth++;
39-
40- emit_pop_reg(code, REG_RAX); // array payload (object address)
41- emit_pop_reg(code, REG_RBX); // array tag (ignored, assumed ARRAY)
42- emit_load_mem_disp32(code, REG_RCX, REG_RAX, 8); // count
43- emit_load_mem_disp32(code, REG_RDX, REG_RAX, 16); // data_ptr
44- emit_store_mem_disp32(code, REG_RBP, level + 0, REG_RDX);
45- emit_store_mem_disp32(code, REG_RBP, level + 8, REG_RCX);
46- emit_mov_reg_imm64(code, REG_RAX, 0);
47- emit_store_mem_disp32(code, REG_RBP, level + 16, REG_RAX); // i = 0
48-
49- int slot = resolve_var_in_current_scope(var_name, var_len);
50- if (slot == -1) slot = declare_var(var_name, var_len);
51-
52- int loop_start = code->count;
53- emit_load_mem_disp32(code, REG_RCX, REG_RBP, level + 16); // i
54- emit_load_mem_disp32(code, REG_RDX, REG_RBP, level + 8); // count
55- emit_cmp_reg_reg(code, REG_RCX, REG_RDX);
56- int loop_exit = emit_jcc_rel32(code, COND_GE);
57-
58- emit_load_mem_disp32(code, REG_RSI, REG_RBP, level + 0); // data_ptr
59- emit_mov_reg_reg(code, REG_RAX, REG_RCX);
60- emit_mov_reg_imm64(code, REG_RDX, 16);
61- emit_imul_reg_reg(code, REG_RAX, REG_RDX);
62- emit_add_reg_reg(code, REG_RSI, REG_RAX); // element address
63-
64- emit_load_mem_disp32(code, REG_RBX, REG_RSI, 0); // element tag
65- emit_load_mem_disp32(code, REG_RAX, REG_RSI, 8); // element payload
66- emit_store_mem_disp32(code, REG_RBP, var_slot_tag_offset(slot), REG_RBX);
67- emit_store_mem_disp32(code, REG_RBP, var_slot_payload_offset(slot), REG_RAX);
68-
69- loop_push();
70- block();
71- loop_patch_continues(); // continue lands here, right before i++
72-
73- emit_load_mem_disp32(code, REG_RCX, REG_RBP, level + 16);
74- emit_add_reg_imm8(code, REG_RCX, 1);
75- emit_store_mem_disp32(code, REG_RBP, level + 16, REG_RCX); // i++
76- emit_jmp_back(code, loop_start);
77- emit_patch_jump(code, loop_exit);
78- loop_pop_and_patch_breaks(); // break lands here, after the loop
79-
80- for_nesting_depth--;
81- vars_scope_end(saved_scope);
82-}

kiln/src/parser/C/statements/loop_stack.cmodified

+18−18
@@ -5,44 +5,44 @@
55 #define MAX_LOOP_JUMPS 64
66
77 typedef struct {
8- int break_jumps[MAX_LOOP_JUMPS];
9- int break_count;
10- int continue_jumps[MAX_LOOP_JUMPS];
11- int continue_count;
8+ int stop_jumps[MAX_LOOP_JUMPS];
9+ int stop_count;
10+ int next_jumps[MAX_LOOP_JUMPS];
11+ int next_count;
1212 } LoopContext;
1313
1414 static LoopContext loop_stack[MAX_LOOP_NESTING];
1515 static int loop_depth = 0;
1616
1717 void loop_push(void) {
1818 if (loop_depth >= MAX_LOOP_NESTING) parse_error("too many nested loops");
19- loop_stack[loop_depth].break_count = 0;
20- loop_stack[loop_depth].continue_count = 0;
19+ loop_stack[loop_depth].stop_count = 0;
20+ loop_stack[loop_depth].next_count = 0;
2121 loop_depth++;
2222 }
2323
24-void loop_record_break(int patch_offset) {
25- if (loop_depth == 0) parse_error("'break' outside a loop");
24+void loop_record_stop(int patch_offset) {
25+ if (loop_depth == 0) parse_error("'stop' outside a loop");
2626 LoopContext *ctx = &loop_stack[loop_depth - 1];
27- if (ctx->break_count >= MAX_LOOP_JUMPS) parse_error("too many break statements in one loop");
28- ctx->break_jumps[ctx->break_count++] = patch_offset;
27+ if (ctx->stop_count >= MAX_LOOP_JUMPS) parse_error("too many stop statements in one loop");
28+ ctx->stop_jumps[ctx->stop_count++] = patch_offset;
2929 }
3030
31-void loop_record_continue(int patch_offset) {
32- if (loop_depth == 0) parse_error("'continue' outside a loop");
31+void loop_record_next(int patch_offset) {
32+ if (loop_depth == 0) parse_error("'next' outside a loop");
3333 LoopContext *ctx = &loop_stack[loop_depth - 1];
34- if (ctx->continue_count >= MAX_LOOP_JUMPS) parse_error("too many continue statements in one loop");
35- ctx->continue_jumps[ctx->continue_count++] = patch_offset;
34+ if (ctx->next_count >= MAX_LOOP_JUMPS) parse_error("too many next statements in one loop");
35+ ctx->next_jumps[ctx->next_count++] = patch_offset;
3636 }
3737
38-void loop_patch_continues(void) {
38+void loop_patch_nexts(void) {
3939 LoopContext *ctx = &loop_stack[loop_depth - 1];
40- for (int i = 0; i < ctx->continue_count; i++) emit_patch_jump(code, ctx->continue_jumps[i]);
40+ for (int i = 0; i < ctx->next_count; i++) emit_patch_jump(code, ctx->next_jumps[i]);
4141 }
4242
43-void loop_pop_and_patch_breaks(void) {
43+void loop_pop_and_patch_stops(void) {
4444 LoopContext *ctx = &loop_stack[loop_depth - 1];
45- for (int i = 0; i < ctx->break_count; i++) emit_patch_jump(code, ctx->break_jumps[i]);
45+ for (int i = 0; i < ctx->stop_count; i++) emit_patch_jump(code, ctx->stop_jumps[i]);
4646 loop_depth--;
4747 }
4848

kiln/src/parser/C/statements/parser_control.cmodified

+39−28
@@ -3,16 +3,17 @@
33 #include "parser/H/core/parser_internal.h"
44 #include "parser/H/declarations/vars.h"
55 #include "parser/H/declarations/functions.h"
6-#include "parser/H/statements/for_loop.h"
6+#include "parser/H/statements/each_loop.h"
77 #include "parser/H/statements/loop_stack.h"
88 #include "codegen/H/expressions/expr.h"
99 #include "codegen/H/emit/emit_sse.h"
1010 #include "codegen/H/emit/value.h"
1111
12-// Truthiness test for if/while conditions: pop the value (tag discarded,
13-// assumed NUMBER -- matches ashvm's own truthy(), which requires a
14-// number too) and compare its payload against 0.0 via ucomisd (not GP
15-// cmp -- see emit_sse.h). COND_E afterward means "was zero, i.e. falsy".
12+// Truthiness test for given/during conditions: pop the value (tag
13+// discarded, assumed NUMBER -- matches ashvm's own truthy(), which
14+// requires a number too) and compare its payload against 0.0 via ucomisd
15+// (not GP cmp -- see emit_sse.h). COND_E afterward means "was zero, i.e.
16+// falsy".
1617 static void pop_and_test_truthy(void) {
1718 emit_pop_reg(code, REG_RAX); // payload
1819 emit_pop_reg(code, REG_RBX); // tag (assumed NUMBER)
@@ -21,49 +22,59 @@ static void pop_and_test_truthy(void) {
2122 emit_ucomisd(code, XMM0, XMM1);
2223 }
2324
25+// Conditions may be parenthesized or not (`given (x < n) {` and
26+// `given x < n {` both work) -- unlike ashvm, there's no superinstruction
27+// fusion here to keep firing either way, so this is just a plain optional
28+// token, no caller-side terminator-passing needed.
29+static int consume_optional_lparen(void) {
30+ if (current.type != TOKEN_LPAREN) return 0;
31+ advance_token();
32+ return 1;
33+}
34+
2435 // The statement kinds involved enough to warrant their own file (mirrors
2536 // ashvm/src/compiler/stmt_control.c's split from stmt.c for the same
2637 // reason). Each function assumes statement()'s dispatch in parser.c
2738 // already consumed the keyword token that identified it.
2839
29-void if_statement(void) {
40+void given_statement(void) {
3041 advance_token();
31- expect(TOKEN_LPAREN, "expected '(' after 'if'");
42+ int parenthesized = consume_optional_lparen();
3243 codegen_expression();
33- expect(TOKEN_RPAREN, "expected ')' after condition");
44+ if (parenthesized) expect(TOKEN_RPAREN, "expected ')' after condition");
3445 pop_and_test_truthy();
35- int else_jump = emit_jcc_rel32(code, COND_E);
46+ int otherwise_jump = emit_jcc_rel32(code, COND_E);
3647
3748 block();
38- if (current.type == TOKEN_ELSE) {
49+ if (current.type == TOKEN_OTHERWISE) {
3950 int end_jump = emit_jmp_rel32(code);
40- emit_patch_jump(code, else_jump);
51+ emit_patch_jump(code, otherwise_jump);
4152 advance_token();
4253 block();
4354 emit_patch_jump(code, end_jump);
4455 } else {
45- emit_patch_jump(code, else_jump);
56+ emit_patch_jump(code, otherwise_jump);
4657 }
4758 }
4859
49-void while_statement(void) {
60+void during_statement(void) {
5061 advance_token();
51- expect(TOKEN_LPAREN, "expected '(' after 'while'");
5262 int loop_start = code->count;
63+ int parenthesized = consume_optional_lparen();
5364 codegen_expression();
54- expect(TOKEN_RPAREN, "expected ')' after condition");
65+ if (parenthesized) expect(TOKEN_RPAREN, "expected ')' after condition");
5566 pop_and_test_truthy();
5667 int exit_jump = emit_jcc_rel32(code, COND_E);
5768
5869 loop_push();
5970 block();
60- loop_patch_continues(); // continue lands here, right before the loop-back
71+ loop_patch_nexts(); // next lands here, right before the loop-back
6172 emit_jmp_back(code, loop_start);
6273 emit_patch_jump(code, exit_jump);
63- loop_pop_and_patch_breaks(); // break lands here, after the loop
74+ loop_pop_and_patch_stops(); // stop lands here, after the loop
6475 }
6576
66-// fn NAME ( [IDENT (, IDENT)*] ) block
77+// forge NAME ( [IDENT (, IDENT)*] ) block
6778 //
6879 // Function bodies are compiled inline into the SAME flat CodeBuf as
6980 // top-level code (unlike ashvm, which gives each function its own Chunk)
@@ -83,15 +94,15 @@ void while_statement(void) {
8394 // establishes a fresh frame; the FIRST thing the body does is copy each
8495 // argument's tag AND payload off the caller's pushed stack values into
8596 // its own local slot, so afterward every variable access -- parameter or
86-// `let` -- goes through the exact same var_slot_*_offset() formulas.
97+// `local` -- goes through the exact same var_slot_*_offset() formulas.
8798 // Because the last argument is the last one pushed (closest to the
8899 // return address), and the first argument is deepest, parameter i's tag
89100 // sits at [rbp + 16 + 16*(argc-1-i)] and its payload right after that
90101 // (+16 skips the saved rbp and return address). Return value comes back
91-// as RBX=tag, RAX=payload (see parser.c's return_statement).
92-void fn_statement(void) {
102+// as RBX=tag, RAX=payload (see parser.c's yield_statement).
103+void forge_statement(void) {
93104 advance_token();
94- expect(TOKEN_IDENTIFIER, "expected function name after 'fn'");
105+ expect(TOKEN_IDENTIFIER, "expected function name after 'forge'");
95106 const char *name = previous.start;
96107 int name_len = previous.length;
97108 KilnFunction *fn = declare_function_in_context(name, name_len);
@@ -120,9 +131,9 @@ void fn_statement(void) {
120131 VarScope outer_vars;
121132 vars_save(&outer_vars);
122133 vars_clear();
123- int outer_for_depth;
124- for_depth_save(&outer_for_depth);
125- for_depth_reset();
134+ int outer_each_depth;
135+ each_depth_save(&outer_each_depth);
136+ each_depth_reset();
126137 int outer_loop_depth;
127138 loop_depth_save(&outer_loop_depth);
128139 loop_depth_reset();
@@ -150,9 +161,9 @@ void fn_statement(void) {
150161
151162 block();
152163
153- // Implicit `return 0;` if the body falls through without one --
164+ // Implicit `yield 0;` if the body falls through without one --
154165 // dead code on any path that already returned explicitly, harmless
155- // (matches ashvm's compile_fn_decl, which does the same
166+ // (matches ashvm's compile_forge_decl, which does the same
156167 // unconditionally after compiling the body).
157168 emit_mov_reg_imm64(code, REG_RAX, 0); // 0.0's bits are all zero
158169 emit_mov_reg_imm64(code, REG_RBX, TAG_NUMBER);
@@ -161,7 +172,7 @@ void fn_statement(void) {
161172 emit_ret(code);
162173
163174 vars_restore(&outer_vars);
164- for_depth_restore(outer_for_depth);
175+ each_depth_restore(outer_each_depth);
165176 loop_depth_restore(outer_loop_depth);
166177 emit_patch_jump(code, skip_jump);
167178 }

kiln/src/parser/C/statements/try_catch.cremoved

+0−86
@@ -1,86 +0,0 @@
1-#include "parser/H/core/parser.h"
2-#include "parser/H/core/parser_internal.h"
3-#include "parser/H/declarations/vars.h"
4-#include "codegen/H/emit/emit.h"
5-#include "codegen/H/runtime/errors.h"
6-#include "codegen/H/emit/layout.h"
7-#include "codegen/H/emit/runtime_layout.h"
8-
9-// try { A } catch (e) { B }
10-//
11-// A handler stack living in the fixed globals region (see
12-// elf/H/elf_writer.h) holds, per active try, the (rsp, rbp) to restore and
13-// the (target address, error-variable rbp-offset) to resume at -- kiln's
14-// own setjmp/longjmp equivalent, hand-rolled since this is a freestanding
15-// binary with no libc to call into (see codegen/C/runtime/errors.c's raise routine
16-// for the runtime half of this).
17-//
18-// The catch variable's slot has to be reserved BEFORE compiling A: its
19-// rbp-relative offset must be embedded into the handler struct at
20-// try-entry, which runs before A's body -- and therefore before `e`'s
21-// real name, which only appears after A -- is even parsed. A placeholder
22-// slot is declared up front and retroactively renamed once `catch (e)`
23-// is actually seen. try/catch is inline (like if/while), not a new call
24-// frame, so this works the same way if/while's own bodies share the
25-// enclosing function's variable table.
26-void try_statement(void) {
27- VarBlockScope saved_scope = vars_scope_begin();
28- advance_token(); // consume 'try'
29-
30- int catch_slot = declare_var("$catch", 6);
31-
32- // --- push handler (bounds-checked -- overflowing here would
33- // silently corrupt the code segment right after the handler table) ---
34- emit_mov_reg_imm64(code, REG_RAX, kiln_try_depth_addr());
35- emit_load_mem_disp32(code, REG_RAX, REG_RAX, 0);
36- emit_cmp_reg_imm32(code, REG_RAX, MAX_KILN_TRY_DEPTH);
37- int overflow = emit_jcc_rel32(code, COND_GE);
38-
39- emit_mov_reg_imm64(code, REG_RCX, KILN_TRY_HANDLER_SIZE);
40- emit_imul_reg_reg(code, REG_RAX, REG_RCX); // RAX = index * handler size
41- emit_mov_reg_imm64(code, REG_RDI, kiln_try_handlers_addr());
42- emit_add_reg_reg(code, REG_RDI, REG_RAX); // RDI = this handler's slot address
43-
44- emit_store_mem_disp32(code, REG_RDI, 0, REG_RSP);
45- emit_store_mem_disp32(code, REG_RDI, 8, REG_RBP);
46- int patch_offset = emit_mov_reg_imm64_patchable(code, REG_RCX); // catch target, patched once known
47- emit_store_mem_disp32(code, REG_RDI, 16, REG_RCX);
48- emit_mov_reg_imm64(code, REG_RCX, (uint64_t)(int64_t)var_slot_tag_offset(catch_slot));
49- emit_store_mem_disp32(code, REG_RDI, 24, REG_RCX);
50-
51- emit_mov_reg_imm64(code, REG_RAX, kiln_try_depth_addr());
52- emit_load_mem_disp32(code, REG_RCX, REG_RAX, 0);
53- emit_add_reg_imm8(code, REG_RCX, 1);
54- emit_store_mem_disp32(code, REG_RAX, 0, REG_RCX);
55-
56- int skip_overflow_die = emit_jmp_rel32(code);
57- emit_patch_jump(code, overflow);
58- errors_emit_fatal(code, "runtime error: too many nested try blocks");
59- emit_patch_jump(code, skip_overflow_die);
60-
61- block(); // compiles A
62-
63- // Normal completion (no error was raised): pop the handler -- past
64- // this point, an error inside the enclosing scope should NOT be
65- // caught by this try anymore.
66- emit_mov_reg_imm64(code, REG_RAX, kiln_try_depth_addr());
67- emit_load_mem_disp32(code, REG_RCX, REG_RAX, 0);
68- emit_dec_reg(code, REG_RCX);
69- emit_store_mem_disp32(code, REG_RAX, 0, REG_RCX);
70-
71- int skip_catch = emit_jmp_rel32(code);
72- int catch_code_offset = code->count;
73- uint64_t catch_addr = kiln_code_base() + (uint64_t)catch_code_offset;
74- emit_patch_imm64(code, patch_offset, catch_addr);
75-
76- expect(TOKEN_CATCH, "expected 'catch' after try block");
77- expect(TOKEN_LPAREN, "expected '(' after 'catch'");
78- expect(TOKEN_IDENTIFIER, "expected error variable name");
79- rename_var(catch_slot, previous.start, previous.length);
80- expect(TOKEN_RPAREN, "expected ')' after catch variable");
81-
82- block(); // compiles B -- `e` already declared at catch_slot above
83-
84- emit_patch_jump(code, skip_catch);
85- vars_scope_end(saved_scope);
86-}

kiln/src/parser/D/statements/try_catch.d → kiln/src/parser/D/statements/attempt_handle.drenamed

+3−2
@@ -1,5 +1,6 @@
1-src/parser/O/statements/try_catch.o: src/parser/C/statements/try_catch.c \
2- src/parser/H/core/parser.h src/lexer/H/lexer.h src/codegen/H/emit/emit.h \
1+src/parser/O/statements/attempt_handle.o: \
2+ src/parser/C/statements/attempt_handle.c src/parser/H/core/parser.h \
3+ src/lexer/H/lexer.h src/codegen/H/emit/emit.h \
34 src/parser/H/core/parser_internal.h src/parser/H/declarations/vars.h \
45 src/codegen/H/runtime/errors.h src/codegen/H/emit/layout.h \
56 src/codegen/H/emit/runtime_layout.h

kiln/src/parser/D/statements/for_loop.d → kiln/src/parser/D/statements/each_loop.drenamed

+3−3
@@ -1,9 +1,9 @@
1-src/parser/O/statements/for_loop.o: src/parser/C/statements/for_loop.c \
2- src/parser/H/statements/for_loop.h src/parser/H/core/parser.h \
1+src/parser/O/statements/each_loop.o: src/parser/C/statements/each_loop.c \
2+ src/parser/H/statements/each_loop.h src/parser/H/core/parser.h \
33 src/lexer/H/lexer.h src/codegen/H/emit/emit.h \
44 src/parser/H/core/parser_internal.h src/parser/H/declarations/vars.h \
55 src/parser/H/statements/loop_stack.h src/codegen/H/expressions/expr.h
6-src/parser/H/statements/for_loop.h:
6+src/parser/H/statements/each_loop.h:
77 src/parser/H/core/parser.h:
88 src/lexer/H/lexer.h:
99 src/codegen/H/emit/emit.h:

kiln/src/parser/D/statements/parser_control.dmodified

+5−4
@@ -2,16 +2,17 @@ src/parser/O/statements/parser_control.o: \
22 src/parser/C/statements/parser_control.c src/parser/H/core/parser.h \
33 src/lexer/H/lexer.h src/codegen/H/emit/emit.h \
44 src/parser/H/core/parser_internal.h src/parser/H/declarations/vars.h \
5- src/parser/H/declarations/functions.h src/parser/H/statements/for_loop.h \
6- src/parser/H/statements/loop_stack.h src/codegen/H/expressions/expr.h \
7- src/codegen/H/emit/emit_sse.h src/codegen/H/emit/value.h
5+ src/parser/H/declarations/functions.h \
6+ src/parser/H/statements/each_loop.h src/parser/H/statements/loop_stack.h \
7+ src/codegen/H/expressions/expr.h src/codegen/H/emit/emit_sse.h \
8+ src/codegen/H/emit/value.h
89 src/parser/H/core/parser.h:
910 src/lexer/H/lexer.h:
1011 src/codegen/H/emit/emit.h:
1112 src/parser/H/core/parser_internal.h:
1213 src/parser/H/declarations/vars.h:
1314 src/parser/H/declarations/functions.h:
14-src/parser/H/statements/for_loop.h:
15+src/parser/H/statements/each_loop.h:
1516 src/parser/H/statements/loop_stack.h:
1617 src/codegen/H/expressions/expr.h:
1718 src/codegen/H/emit/emit_sse.h:

kiln/src/parser/H/core/parser_internal.hmodified

+6−6
@@ -2,15 +2,15 @@
22 #define KILN_PARSER_INTERNAL_H
33
44 // Cross-file link between parser.c (state, simple statements, the
5-// statement() dispatcher) and parser_control.c (if/while/fn -- the
5+// statement() dispatcher) and parser_control.c (given/during/forge -- the
66 // statement kinds involved enough to warrant their own file), not part of
77 // the public API (parser.h).
88 void block(void);
9-void if_statement(void);
10-void while_statement(void);
11-void fn_statement(void);
12-void try_statement(void);
13-void for_statement(void);
9+void given_statement(void);
10+void during_statement(void);
11+void forge_statement(void);
12+void attempt_statement(void);
13+void each_statement(void);
1414
1515 // statement() itself (parser.c's dispatcher) also needs to be callable
1616 // from imports.c's parse_imported_file_body(), which drives the same

kiln/src/parser/H/declarations/constants.hmodified

+1−1
@@ -26,7 +26,7 @@ KilnConstant *declare_number_constant(const char *name, int len, double value);
2626
2727 // Same, for a string constant. str_bytes/str_len are the RAW (unescaped)
2828 // source token bytes -- constants.c does not own or copy this pointer
29-// beyond storing it; the caller (parser.c's let_statement) must pass
29+// beyond storing it; the caller (parser.c's local_statement) must pass
3030 // bytes from a buffer that outlives the whole compile (the imported
3131 // file's source buffer, which imports.c already never frees -- see
3232 // imports.c's read_import_file comment).

kiln/src/parser/H/declarations/functions.hmodified

+2−2
@@ -32,7 +32,7 @@ KilnFunction *resolve_function(const char *name, int len);
3232 KilnFunction *declare_function(const char *name, int len);
3333
3434 // --- Import namespace context (parser/C/imports/imports.c sets/restores this around
35-// each recursively-parsed imported file; parser_control.c's fn_statement
35+// each recursively-parsed imported file; parser_control.c's forge_statement
3636 // and codegen/C/expressions/expr.c + expr_call.c's call-site resolution consult it) ---
3737
3838 // Sets the namespace prefix every subsequent declare_function_in_context/
@@ -45,7 +45,7 @@ void set_import_namespace(const char *ns, int len);
4545 void get_import_namespace(const char **out_ns, int *out_len);
4646
4747 // Declares NAME under "ns.NAME" if a namespace is currently set, else
48-// under the bare NAME -- the single call site fn_statement() uses,
48+// under the bare NAME -- the single call site forge_statement() uses,
4949 // replacing a direct declare_function() call.
5050 KilnFunction *declare_function_in_context(const char *name, int len);
5151

kiln/src/parser/H/declarations/vars.hmodified

+21−20
@@ -16,10 +16,10 @@ int resolve_var(const char *name, int len);
1616 int declare_var(const char *name, int len);
1717
1818 // Milestone 9: overwrites an already-declared slot's name in place (same
19-// slot index, new name) -- parser/C/statements/try_catch.c needs a catch variable's
20-// slot reserved BEFORE its real name (`catch (e)`) is parsed, since the
19+// slot index, new name) -- parser/C/statements/attempt_handle.c needs a handle variable's
20+// slot reserved BEFORE its real name (`handle (e)`) is parsed, since the
2121 // slot's rbp-relative offset must be embedded into the handler struct at
22-// try-entry, ahead of the try block's body.
22+// attempt-entry, ahead of the attempt block's body.
2323 void rename_var(int slot, const char *name, int len);
2424
2525 // Milestone 5: each variable now holds a (tag, payload) pair (16 bytes,
@@ -49,19 +49,20 @@ void vars_restore(const VarScope *saved);
4949
5050 // Block scoping: `scope_start` marks the slot index where the innermost
5151 // active block began. vars_scope_begin/end bracket a single block() call
52-// (or, for for/try statements whose own loop/catch variable is declared
53-// outside their nested block() call -- see for_loop.c/try_catch.c -- the
54-// whole statement). Same save-an-int-on-the-C-call-stack pattern as
55-// for_depth_save/restore and loop_depth_save/restore above: block() nests
56-// via ordinary recursive descent, so the C call stack gives correct
57-// nesting for free, no explicit stack structure needed.
52+// (or, for each/attempt statements whose own loop/handle variable is
53+// declared outside their nested block() call -- see
54+// each_loop.c/attempt_handle.c -- the whole statement). Same
55+// save-an-int-on-the-C-call-stack pattern as each_depth_save/restore and
56+// loop_depth_save/restore above: block() nests via ordinary recursive
57+// descent, so the C call stack gives correct nesting for free, no explicit
58+// stack structure needed.
5859 typedef struct { int count_mark; int prev_scope_start; } VarBlockScope;
5960
6061 VarBlockScope vars_scope_begin(void);
6162 void vars_scope_end(VarBlockScope saved);
6263
6364 // Like resolve_var, but only considers slots >= scope_start (the CURRENT
64-// block's own declarations). let_statement/for_statement use this for
65+// block's own declarations). local_statement/each_statement use this for
6566 // their "reuse this slot or declare fresh" decision: a name that only
6667 // exists in an ENCLOSING scope must shadow (fresh slot), not mutate.
6768 int resolve_var_in_current_scope(const char *name, int len);
@@ -91,21 +92,21 @@ int32_t call_scratch_offset(void);
9192 #define KILN_HIGHER_ORDER_SCRATCH_SIZE 128
9293 int32_t higher_order_scratch_offset(void);
9394
94-// Milestone 11: `for (x in array)` loop state (data_ptr, count, running
95+// Milestone 11: `each (x in array)` loop state (data_ptr, count, running
9596 // index) also needs to survive across the loop body's own codegen -- but
96-// unlike try/catch's runtime recursion, for-loop NESTING is fully known
97-// at COMPILE TIME (nested `for` statements are literally nested calls in
97+// unlike attempt/handle's runtime recursion, each-loop NESTING is fully known
98+// at COMPILE TIME (nested `each` statements are literally nested calls in
9899 // this single-pass compiler), so each nesting level just gets its own
99100 // fixed offset rather than a runtime-tracked stack. Bounded the same way
100-// every other table here is; parser/C/statements/for_loop.c resets its nesting
101+// every other table here is; parser/C/statements/each_loop.c resets its nesting
101102 // counter to 0 when entering a function or lambda body (mirroring
102103 // vars_save/vars_clear/vars_restore), since that body gets its own RBP
103104 // at runtime and can safely reuse the same offsets independently of how
104-// deep the enclosing code's own `for` nesting was.
105-#define MAX_KILN_FOR_DEPTH 8
106-#define KILN_FOR_LEVEL_SIZE 24
107-#define KILN_FOR_STACK_SIZE (MAX_KILN_FOR_DEPTH * KILN_FOR_LEVEL_SIZE)
108-int32_t for_level_offset(int depth);
105+// deep the enclosing code's own `each` nesting was.
106+#define MAX_KILN_EACH_DEPTH 8
107+#define KILN_EACH_LEVEL_SIZE 24
108+#define KILN_EACH_STACK_SIZE (MAX_KILN_EACH_DEPTH * KILN_EACH_LEVEL_SIZE)
109+int32_t each_level_offset(int depth);
109110
110111 // Plan A, phase A3: one 8-byte RBP-relative slot for codegen/C/platform/win_call.c to
111112 // stash the original RSP across a Windows API call (dynamic 16-byte align
@@ -131,7 +132,7 @@ int32_t win_call_scratch_offset(void);
131132 int32_t win_outparam_offset(void);
132133
133134 #define KILN_FRAME_RESERVE \
134- ((MAX_KILN_VARS + 1) * 16 + KILN_HIGHER_ORDER_SCRATCH_SIZE + KILN_FOR_STACK_SIZE + \
135+ ((MAX_KILN_VARS + 1) * 16 + KILN_HIGHER_ORDER_SCRATCH_SIZE + KILN_EACH_STACK_SIZE + \
135136 KILN_WIN_CALL_SCRATCH_SIZE + KILN_WIN_OUTPARAM_SCRATCH_SIZE)
136137
137138 #endif

kiln/src/parser/H/imports/imports.hmodified

+1−1
@@ -9,7 +9,7 @@ void imports_init(const char *entry_file_path);
99
1010 // Parses one `@import <path>;` statement. Assumes statement()'s
1111 // dispatcher has NOT yet consumed the TOKEN_IMPORT (this function does
12-// that itself, matching let_statement()'s own convention).
12+// that itself, matching local_statement()'s own convention).
1313 void import_statement(void);
1414
1515 // Called by statement() on every non-import statement dispatch. No-op

kiln/src/parser/H/statements/for_loop.hremoved

+0−19
@@ -1,19 +0,0 @@
1-#ifndef KILN_FOR_LOOP_H
2-#define KILN_FOR_LOOP_H
3-
4-// for_statement itself is declared in parser_internal.h alongside
5-// if_statement/while_statement/fn_statement/try_statement (the other
6-// statement-compiler cross-file links) -- this header exists only for
7-// the depth counter below, which fn_statement/lambda.c also need.
8-//
9-// The compile-time for-nesting-depth counter's save/reset/restore, for
10-// fn_statement/lambda.c to wrap around a called body's compilation (same
11-// spirit as parser/H/declarations/vars.h's vars_save/vars_clear/vars_restore): a
12-// function or lambda body gets its own RBP at runtime, so its own `for`
13-// loops can safely reuse the same offsets independently of how deep the
14-// enclosing code's nesting was -- see vars.h's for_level_offset comment.
15-void for_depth_save(int *out);
16-void for_depth_reset(void);
17-void for_depth_restore(int saved);
18-
19-#endif

kiln/src/parser/H/statements/loop_stack.hmodified

+17−17
@@ -1,43 +1,43 @@
11 #ifndef KILN_LOOP_STACK_H
22 #define KILN_LOOP_STACK_H
33
4-// break/continue need to know which loop they belong to, and loops
5-// nest -- but unlike for_loop.h's runtime scratch state, break/continue
4+// stop/next need to know which loop they belong to, and loops
5+// nest -- but unlike each_loop.h's runtime scratch state, stop/next
66 // need NO runtime state at all: they're pure "emit now, patch later"
77 // jump bookkeeping (same primitive every other forward jump in this
88 // project uses), just tracked per-nesting-level at compile time instead
99 // of resolved immediately.
1010 //
11-// continue can't simply backward-jump to a known offset the way a plain
12-// loop back-edge does: `continue`'s target (the per-iteration advance
13-// step -- a for-loop's index increment, or nothing for a while loop) is
14-// only emitted AFTER the body compiles, i.e. after the continue
15-// statement itself. So continue jumps are forward-patched too, resolved
11+// next can't simply backward-jump to a known offset the way a plain
12+// loop back-edge does: `next`'s target (the per-iteration advance
13+// step -- an each-loop's index increment, or nothing for a during loop)
14+// is only emitted AFTER the body compiles, i.e. after the next
15+// statement itself. So next jumps are forward-patched too, resolved
1616 // by the loop's own codegen right after block() returns, before
1717 // whatever per-iteration bookkeeping runs next.
1818
1919 // Pushes a fresh loop context (call right before compiling the body).
2020 void loop_push(void);
2121
22-// Called from parser.c's break_statement()/continue_statement() --
22+// Called from parser.c's stop_statement()/next_statement() --
2323 // records a forward jump's patch offset into the innermost loop's list.
2424 // parse_error()s (noreturn) if called outside any loop.
25-void loop_record_break(int patch_offset);
26-void loop_record_continue(int patch_offset);
25+void loop_record_stop(int patch_offset);
26+void loop_record_next(int patch_offset);
2727
28-// Patches every recorded continue jump to the CURRENT code position --
28+// Patches every recorded next jump to the CURRENT code position --
2929 // call right after block() returns, before emitting the per-iteration
3030 // advance step.
31-void loop_patch_continues(void);
31+void loop_patch_nexts(void);
3232
33-// Patches every recorded break jump to the CURRENT code position, then
33+// Patches every recorded stop jump to the CURRENT code position, then
3434 // pops the loop context -- call once the loop is fully compiled.
35-void loop_pop_and_patch_breaks(void);
35+void loop_pop_and_patch_stops(void);
3636
37-// Save/reset/restore the loop-nesting depth, for fn_statement/lambda.c
37+// Save/reset/restore the loop-nesting depth, for forge_statement/lambda.c
3838 // to wrap around a called body's compilation (same spirit as
39-// parser/H/declarations/vars.h's vars_save/vars_clear/vars_restore and for_loop.h's
40-// for_depth_save/reset/restore): a break/continue inside a function or
39+// parser/H/declarations/vars.h's vars_save/vars_clear/vars_restore and each_loop.h's
40+// each_depth_save/reset/restore): a stop/next inside a function or
4141 // lambda body must never reach past that body into an ENCLOSING loop it
4242 // happens to be textually written inside.
4343 void loop_depth_save(int *out);

kiln/tests/cases/basics/bare_reassign.ashadded

+10−0
@@ -0,0 +1,10 @@
1+forge f(i) {
2+ i = i + 1;
3+ i = i * 2;
4+ yield i;
5+}
6+say f(5);
7+
8+local x = 1;
9+x = x + 10;
10+say x;

kiln/tests/cases/basics/bitwise.ashmodified

+9−9
@@ -1,9 +1,9 @@
1-print 5 & 3;
2-print 5 | 2;
3-print 5 ^ 1;
4-print 1 << 4;
5-print 256 >> 4;
6-print ~0;
7-print ~5;
8-print -8 >> 1;
9-print (5 & 3) == 1;
1+say 5 & 3;
2+say 5 | 2;
3+say 5 ^ 1;
4+say 1 << 4;
5+say 256 >> 4;
6+say ~0;
7+say ~5;
8+say -8 >> 1;
9+say (5 & 3) == 1;

kiln/tests/cases/basics/block_comments.ashmodified

+5−5
@@ -1,10 +1,10 @@
11 /* this is a block comment */
2-print 1;
2+say 1;
33 /* multi
44 line
55 comment */
6-print 2;
7-print /* inline */ 3;
8-let x = 5; /* trailing */ print x;
6+say 2;
7+say /* inline */ 3;
8+local x = 5; /* trailing */ say x;
99 // still works
10-print 4;
10+say 4;

kiln/tests/cases/basics/compound_assignment.ashmodified

+16−16
@@ -1,32 +1,32 @@
1-let x = 5;
1+local x = 5;
22 x += 3;
3-print x;
3+say x;
44 x -= 2;
5-print x;
5+say x;
66 x *= 4;
7-print x;
7+say x;
88 x /= 3;
9-print x;
9+say x;
1010
11-let s = "hello";
11+local s = "hello";
1212 s += " world";
13-print s;
13+say s;
1414
15-let i = 0;
15+local i = 0;
1616 i++;
1717 i++;
18-print i;
18+say i;
1919 i--;
20-print i;
20+say i;
2121
22-let total = 0;
23-for (n in [1,2,3,4,5]) {
22+local total = 0;
23+each (n in [1,2,3,4,5]) {
2424 total += n;
2525 }
26-print total;
26+say total;
2727
28-let j = 0;
29-while (j < 5) {
28+local j = 0;
29+during (j < 5) {
3030 j++;
3131 }
32-print j;
32+say j;

kiln/tests/cases/basics/exponent.ashmodified

+9−9
@@ -1,9 +1,9 @@
1-print 2 ** 10;
2-print 2 ** 0;
3-print 5 ** 1;
4-print 2 ** -2;
5-print -2 ** 2;
6-print 3 ** 3;
7-print 2 ** 3 ** 2;
8-print 2 ** 3 * 4;
9-print (2 ** 3) * 4;
1+say 2 ** 10;
2+say 2 ** 0;
3+say 5 ** 1;
4+say 2 ** -2;
5+say -2 ** 2;
6+say 3 ** 3;
7+say 2 ** 3 ** 2;
8+say 2 ** 3 * 4;
9+say (2 ** 3) * 4;

kiln/tests/cases/basics/floats.ashmodified

+16−16
@@ -1,22 +1,22 @@
1-print 10 / 4;
2-print 0.5;
3-print -3.25;
4-print 1.5 + 2.25;
5-print 0.1 + 0.2;
1+say 10 / 4;
2+say 0.5;
3+say -3.25;
4+say 1.5 + 2.25;
5+say 0.1 + 0.2;
66
7-let x = 2.5;
8-print x < 3;
9-print x > 3;
7+local x = 2.5;
8+say x < 3;
9+say x > 3;
1010
11-fn avg(a, b) {
12- return (a + b) / 2;
11+forge avg(a, b) {
12+ yield (a + b) / 2;
1313 }
14-print avg(1, 2);
14+say avg(1, 2);
1515
16-fn half(n) {
17- if (n < 1) {
18- return n;
16+forge half(n) {
17+ given (n < 1) {
18+ yield n;
1919 }
20- return half(n / 2) + 0.1;
20+ yield half(n / 2) + 0.1;
2121 }
22-print half(4);
22+say half(4);

kiln/tests/cases/basics/hello.ashmodified

+1−1
@@ -1 +1 @@
1-print 1 + 2 * 3;
1+say 1 + 2 * 3;

kiln/tests/cases/basics/math_builtins.ashmodified

+8−8
@@ -1,8 +1,8 @@
1-print sqrt(16);
2-print sqrt(2);
3-print abs(-5.5);
4-print abs(5.5);
5-print floor(3.7);
6-print floor(-3.7);
7-print floor(3.0);
8-print floor(-3.0);
1+say sqrt(16);
2+say sqrt(2);
3+say abs(-5.5);
4+say abs(5.5);
5+say floor(3.7);
6+say floor(-3.7);
7+say floor(3.0);
8+say floor(-3.0);

kiln/tests/cases/basics/modulo.ashadded

+7−0
@@ -0,0 +1,7 @@
1+say 7 % 3;
2+say -7 % 3;
3+say 7 % -3;
4+say -7 % -3;
5+say 7.5 % 2;
6+say 10 % 2 == 0;
7+say 10 % 3 == 1;

kiln/tests/cases/basics/numeric_literals.ashmodified

+10−10
@@ -1,10 +1,10 @@
1-print 0xF;
2-print 0x1A;
3-print 0xFF;
4-print 1e3;
5-print 1.5e2;
6-print 2e-2;
7-print 1_000_000;
8-print 1_000.5;
9-print 0x10 + 1;
10-print 3.14159;
1+say 0xF;
2+say 0x1A;
3+say 0xFF;
4+say 1e3;
5+say 1.5e2;
6+say 2e-2;
7+say 1_000_000;
8+say 1_000.5;
9+say 0x10 + 1;
10+say 3.14159;

kiln/tests/cases/basics/ternary.ashmodified

+10−10
@@ -1,10 +1,10 @@
1-print true ? "yes" : "no";
2-print false ? "yes" : "no";
3-let x = 5;
4-print x > 3 ? "big" : "small";
5-print x < 3 ? "big" : x < 10 ? "medium" : "huge";
6-let m = { "a": true, "b": false };
7-print m["a"] ? "a is true" : "a is false";
8-fn max(a, b) { return a > b ? a : b; }
9-print max(3, 7);
10-print max(10, 2);
1+say yes ? "yes" : "no";
2+say no ? "yes" : "no";
3+local x = 5;
4+say x > 3 ? "big" : "small";
5+say x < 3 ? "big" : x < 10 ? "medium" : "huge";
6+local m = { "a": yes, "b": no };
7+say m["a"] ? "a is true" : "a is false";
8+forge max(a, b) { yield a > b ? a : b; }
9+say max(3, 7);
10+say max(10, 2);

kiln/tests/cases/basics/true_false_nil.ashmodified

+14−14
@@ -1,14 +1,14 @@
1-print true;
2-print false;
3-print nil;
4-print true == 1;
5-print false == 0;
6-print nil == nil;
7-print nil == 0;
8-print nil == false;
9-if (true) { print "yes"; }
10-if (false) { print "no"; } else { print "else"; }
11-let x = nil;
12-if (x) { print "truthy"; } else { print "falsy nil"; }
13-let y = true;
14-print y;
1+say yes;
2+say no;
3+say none;
4+say yes == 1;
5+say no == 0;
6+say none == none;
7+say none == 0;
8+say none == no;
9+given (yes) { say "yes"; }
10+given (no) { say "no"; } otherwise { say "else"; }
11+local x = none;
12+given (x) { say "truthy"; } otherwise { say "falsy nil"; }
13+local y = yes;
14+say y;

kiln/tests/cases/basics/type.ashadded

+8−0
@@ -0,0 +1,8 @@
1+say type(5);
2+say type("hi");
3+say type([1, 2]);
4+say type({"a": 1});
5+say type(none);
6+say type(yes);
7+forge f() { yield 1; }
8+say type(f);

kiln/tests/cases/basics/unary_not.ashmodified

+7−7
@@ -1,7 +1,7 @@
1-print !false;
2-print !true;
3-print !0;
4-print !5;
5-print !nil;
6-print !!5;
7-if (!false) { print "works"; }
1+say not no;
2+say not yes;
3+say not 0;
4+say not 5;
5+say not none;
6+say not not 5;
7+given (not no) { say "works"; }

kiln/tests/cases/collections/arrays.ashmodified

+18−18
@@ -1,28 +1,28 @@
1-let a = [1, 2, 3];
2-print a[0];
3-print a[1];
4-print a[2];
5-print len(a);
1+local a = [1, 2, 3];
2+say a[0];
3+say a[1];
4+say a[2];
5+say len(a);
66
77 a[0] = 99;
8-print a[0];
8+say a[0];
99
10-let b = ["x", "y", "z"];
11-print b[1];
10+local b = ["x", "y", "z"];
11+say b[1];
1212
13-let c = [1];
13+local c = [1];
1414 push(c, 2);
1515 push(c, 3);
16-print len(c);
17-print c[0];
18-print c[1];
19-print c[2];
16+say len(c);
17+say c[0];
18+say c[1];
19+say c[2];
2020
21-let d = [];
22-let i = 0;
23-while (i < 10) {
21+local d = [];
22+local i = 0;
23+during (i < 10) {
2424 push(d, i);
2525 i = i + 1;
2626 }
27-print len(d);
28-print d[9];
27+say len(d);
28+say d[9];

kiln/tests/cases/collections/keys_values.ashmodified

+14−14
@@ -1,15 +1,15 @@
1-let m = {"a": 1, "b": 2, "c": 3};
2-let ks = keys(m);
3-let vs = values(m);
4-print len(ks);
5-print len(vs);
6-print ks[0];
7-print ks[1];
8-print ks[2];
9-print vs[0];
10-print vs[1];
11-print vs[2];
1+local m = {"a": 1, "b": 2, "c": 3};
2+local ks = keys(m);
3+local vs = values(m);
4+say len(ks);
5+say len(vs);
6+say ks[0];
7+say ks[1];
8+say ks[2];
9+say vs[0];
10+say vs[1];
11+say vs[2];
1212
13-let empty_m = {};
14-print len(keys(empty_m));
15-print len(values(empty_m));
13+local empty_m = {};
14+say len(keys(empty_m));
15+say len(values(empty_m));

kiln/tests/cases/collections/maps.ashmodified

+13−13
@@ -1,21 +1,21 @@
1-let m = {"a": 1, "b": 2};
2-print m["a"];
3-print m["b"];
1+local m = {"a": 1, "b": 2};
2+say m["a"];
3+say m["b"];
44
55 m["c"] = 3;
6-print m["c"];
6+say m["c"];
77
88 m["a"] = 99;
9-print m["a"];
9+say m["a"];
1010
11-print has(m, "b");
12-print has(m, "z");
11+say has(m, "b");
12+say has(m, "z");
1313
1414 delete(m, "b");
15-print has(m, "b");
16-print m["c"];
15+say has(m, "b");
16+say m["c"];
1717
18-let g = {};
18+local g = {};
1919 g["a"] = 1;
2020 g["b"] = 2;
2121 g["c"] = 3;
@@ -26,6 +26,6 @@ g["g"] = 7;
2626 g["h"] = 8;
2727 g["i"] = 9;
2828 g["j"] = 10;
29-print g["a"];
30-print g["e"];
31-print g["j"];
29+say g["a"];
30+say g["e"];
31+say g["j"];

kiln/tests/cases/control_flow/block_scoping.ashmodified

+33−33
@@ -1,41 +1,41 @@
1-let x = 1;
2-if (true) {
3- let x = 2;
4- print x;
1+local x = 1;
2+given (yes) {
3+ local x = 2;
4+ say x;
55 }
6-print x;
6+say x;
77
8-let y = 10;
9-while (y < 13) {
10- let z = y * 2;
11- print z;
8+local y = 10;
9+during (y < 13) {
10+ local z = y * 2;
11+ say z;
1212 y = y + 1;
1313 }
1414
15-// 20 if-blocks, each declaring 4 UNIQUE-named variables (80 total) --
15+// 20 given-blocks, each declaring 4 UNIQUE-named variables (80 total) --
1616 // leaking would exceed MAX_KILN_VARS (64) and fail to compile; scoped
1717 // slot reuse keeps this well under the cap. Verified during planning:
18-// this exact file (with the shadowing let above already showing the
18+// this exact file (with the shadowing local above already showing the
1919 // bug) fails pre-fix with "error: too many variables" at block index 15.
20-let count = 0;
21-if (true) { let a0=1; let b0=2; let c0=3; let d0=4; count = count + 1; }
22-if (true) { let a1=1; let b1=2; let c1=3; let d1=4; count = count + 1; }
23-if (true) { let a2=1; let b2=2; let c2=3; let d2=4; count = count + 1; }
24-if (true) { let a3=1; let b3=2; let c3=3; let d3=4; count = count + 1; }
25-if (true) { let a4=1; let b4=2; let c4=3; let d4=4; count = count + 1; }
26-if (true) { let a5=1; let b5=2; let c5=3; let d5=4; count = count + 1; }
27-if (true) { let a6=1; let b6=2; let c6=3; let d6=4; count = count + 1; }
28-if (true) { let a7=1; let b7=2; let c7=3; let d7=4; count = count + 1; }
29-if (true) { let a8=1; let b8=2; let c8=3; let d8=4; count = count + 1; }
30-if (true) { let a9=1; let b9=2; let c9=3; let d9=4; count = count + 1; }
31-if (true) { let a10=1; let b10=2; let c10=3; let d10=4; count = count + 1; }
32-if (true) { let a11=1; let b11=2; let c11=3; let d11=4; count = count + 1; }
33-if (true) { let a12=1; let b12=2; let c12=3; let d12=4; count = count + 1; }
34-if (true) { let a13=1; let b13=2; let c13=3; let d13=4; count = count + 1; }
35-if (true) { let a14=1; let b14=2; let c14=3; let d14=4; count = count + 1; }
36-if (true) { let a15=1; let b15=2; let c15=3; let d15=4; count = count + 1; }
37-if (true) { let a16=1; let b16=2; let c16=3; let d16=4; count = count + 1; }
38-if (true) { let a17=1; let b17=2; let c17=3; let d17=4; count = count + 1; }
39-if (true) { let a18=1; let b18=2; let c18=3; let d18=4; count = count + 1; }
40-if (true) { let a19=1; let b19=2; let c19=3; let d19=4; count = count + 1; }
41-print count;
20+local count = 0;
21+given (yes) { local a0=1; local b0=2; local c0=3; local d0=4; count = count + 1; }
22+given (yes) { local a1=1; local b1=2; local c1=3; local d1=4; count = count + 1; }
23+given (yes) { local a2=1; local b2=2; local c2=3; local d2=4; count = count + 1; }
24+given (yes) { local a3=1; local b3=2; local c3=3; local d3=4; count = count + 1; }
25+given (yes) { local a4=1; local b4=2; local c4=3; local d4=4; count = count + 1; }
26+given (yes) { local a5=1; local b5=2; local c5=3; local d5=4; count = count + 1; }
27+given (yes) { local a6=1; local b6=2; local c6=3; local d6=4; count = count + 1; }
28+given (yes) { local a7=1; local b7=2; local c7=3; local d7=4; count = count + 1; }
29+given (yes) { local a8=1; local b8=2; local c8=3; local d8=4; count = count + 1; }
30+given (yes) { local a9=1; local b9=2; local c9=3; local d9=4; count = count + 1; }
31+given (yes) { local a10=1; local b10=2; local c10=3; local d10=4; count = count + 1; }
32+given (yes) { local a11=1; local b11=2; local c11=3; local d11=4; count = count + 1; }
33+given (yes) { local a12=1; local b12=2; local c12=3; local d12=4; count = count + 1; }
34+given (yes) { local a13=1; local b13=2; local c13=3; local d13=4; count = count + 1; }
35+given (yes) { local a14=1; local b14=2; local c14=3; local d14=4; count = count + 1; }
36+given (yes) { local a15=1; local b15=2; local c15=3; local d15=4; count = count + 1; }
37+given (yes) { local a16=1; local b16=2; local c16=3; local d16=4; count = count + 1; }
38+given (yes) { local a17=1; local b17=2; local c17=3; local d17=4; count = count + 1; }
39+given (yes) { local a18=1; local b18=2; local c18=3; local d18=4; count = count + 1; }
40+given (yes) { local a19=1; local b19=2; local c19=3; local d19=4; count = count + 1; }
41+say count;

kiln/tests/cases/control_flow/block_scoping_closures.ashmodified

+35−35
@@ -1,50 +1,50 @@
1-// closure created and returned from inside an if-block, capturing a
1+// closure created and returned from inside an given-block, capturing a
22 // block-scoped variable; call it after the block, once its slot has
33 // been reused by a later declaration
4-fn make_from_if() {
5- let f = 0;
6- if (true) {
7- let x = 42;
8- f = fn() { return x; };
4+forge make_from_if() {
5+ local f = 0;
6+ given (yes) {
7+ local x = 42;
8+ f = forge() { yield x; };
99 }
10- let after = 999; // reuses x's old slot
11- return f;
10+ local after = 999; // reuses x's old slot
11+ yield f;
1212 }
13-let cf = make_from_if();
14-print cf();
13+local cf = make_from_if();
14+say cf();
1515
1616 // closure capturing a variable that shadows an outer same-named one --
1717 // the closure must see the INNER value; the outer variable must be
1818 // untouched afterward
19-fn make_shadow() {
20- let x = 1;
21- let f = 0;
22- if (true) {
23- let x = 2;
24- f = fn() { return x; };
19+forge make_shadow() {
20+ local x = 1;
21+ local f = 0;
22+ given (yes) {
23+ local x = 2;
24+ f = forge() { yield x; };
2525 }
26- return [x, f()];
26+ yield [x, f()];
2727 }
28-let r = make_shadow();
29-print r[0];
30-print r[1];
28+local r = make_shadow();
29+say r[0];
30+say r[1];
3131
32-// closures created inside a while body (compiled once, run 3 times),
32+// closures created inside a during body (compiled once, run 3 times),
3333 // each capturing that iteration's own block-scoped value
34-fn make_list() {
35- let fns = [];
36- let i = 0;
37- while (i < 3) {
38- let captured = i;
39- push(fns, fn() { return captured; });
34+forge make_list() {
35+ local fns = [];
36+ local i = 0;
37+ during (i < 3) {
38+ local captured = i;
39+ push(fns, forge() { yield captured; });
4040 i = i + 1;
4141 }
42- return fns;
42+ yield fns;
4343 }
44-let list = make_list();
45-let l0 = list[0];
46-let l1 = list[1];
47-let l2 = list[2];
48-print l0();
49-print l1();
50-print l2();
44+local list = make_list();
45+local l0 = list[0];
46+local l1 = list[1];
47+local l2 = list[2];
48+say l0();
49+say l1();
50+say l2();

kiln/tests/cases/control_flow/break_continue.ashmodified

+31−31
@@ -1,47 +1,47 @@
1-let i = 0;
2-while (i < 10) {
1+local i = 0;
2+during (i < 10) {
33 i = i + 1;
4- if (i == 5) { break; }
5- print i;
4+ given (i == 5) { stop; }
5+ say i;
66 }
7-print "---";
7+say "---";
88
9-let j = 0;
10-while (j < 5) {
9+local j = 0;
10+during (j < 5) {
1111 j = j + 1;
12- if (j == 3) { continue; }
13- print j;
12+ given (j == 3) { next; }
13+ say j;
1414 }
15-print "---";
15+say "---";
1616
17-for (x in [1,2,3,4,5]) {
18- if (x == 3) { break; }
19- print x;
17+each (x in [1,2,3,4,5]) {
18+ given (x == 3) { stop; }
19+ say x;
2020 }
21-print "---";
21+say "---";
2222
23-for (x in [1,2,3,4,5]) {
24- if (x == 3) { continue; }
25- print x;
23+each (x in [1,2,3,4,5]) {
24+ given (x == 3) { next; }
25+ say x;
2626 }
27-print "---";
27+say "---";
2828
29-// nested loops: break only exits innermost
30-for (i in [1,2]) {
31- for (j in [1,2,3]) {
32- if (j == 2) { break; }
33- print i * 10 + j;
29+// nested loops: stop only exits innermost
30+each (i in [1,2]) {
31+ each (j in [1,2,3]) {
32+ given (j == 2) { stop; }
33+ say i * 10 + j;
3434 }
3535 }
36-print "---";
36+say "---";
3737
38-// break/continue inside function's own loop, not affecting caller
39-fn find_first_even(arr) {
40- for (x in arr) {
41- if (x - floor(x/2)*2 == 0) {
42- return x;
38+// stop/next inside function's own loop, not affecting caller
39+forge find_first_even(arr) {
40+ each (x in arr) {
41+ given (x - floor(x/2)*2 == 0) {
42+ yield x;
4343 }
4444 }
45- return -1;
45+ yield -1;
4646 }
47-print find_first_even([1, 3, 5, 4, 7]);
47+say find_first_even([1, 3, 5, 4, 7]);

kiln/tests/cases/control_flow/control_flow.ashmodified

+13−13
@@ -1,20 +1,20 @@
1-let x = 5;
1+local x = 5;
22 x = x + 1;
3-print x;
3+say x;
44
5-if (x > 5) {
6- print 1;
7-} else {
8- print 0;
5+given (x > 5) {
6+ say 1;
7+} otherwise {
8+ say 0;
99 }
1010
11-let i = 0;
12-while (i < 3) {
13- print i;
11+local i = 0;
12+during (i < 3) {
13+ say i;
1414 i = i + 1;
1515 }
1616
17-print 1 == 1;
18-print 1 != 1;
19-print (1 < 2) && (3 > 2);
20-print (1 > 2) || (3 < 2);
17+say 1 == 1;
18+say 1 != 1;
19+say (1 < 2) and (3 > 2);
20+say (1 > 2) or (3 < 2);

kiln/tests/cases/control_flow/for_loop_scoping.ashmodified

+9−9
@@ -1,15 +1,15 @@
11 // loop variable no longer leaks: outer x, same name, must be untouched
2-let x = 9;
3-for (x in [1, 2, 3]) {
4- print x;
2+local x = 9;
3+each (x in [1, 2, 3]) {
4+ say x;
55 }
6-print x;
6+say x;
77
8-// nested for loops with the same variable name at each level -- each
8+// nested each loops with the same variable name at each level -- each
99 // level's binding is independent
10-for (n in [1, 2]) {
11- for (n in [10, 20]) {
12- print n;
10+each (n in [1, 2]) {
11+ each (n in [10, 20]) {
12+ say n;
1313 }
14- print n;
14+ say n;
1515 }

kiln/tests/cases/control_flow/for_loops.ashmodified

+27−27
@@ -1,44 +1,44 @@
1-let nums = [10, 20, 30];
2-for (x in nums) {
3- print x;
1+local nums = [10, 20, 30];
2+each (x in nums) {
3+ say x;
44 }
55
6-// nested for loops
7-let a = [1, 2];
8-let b = [10, 20];
9-for (i in a) {
10- for (j in b) {
11- print i * j;
6+// nested each loops
7+local a = [1, 2];
8+local b = [10, 20];
9+each (i in a) {
10+ each (j in b) {
11+ say i * j;
1212 }
1313 }
1414
15-// for loop calling a higher-order builtin in its body
16-fn double(x) { return x * 2; }
17-for (x in nums) {
18- let d = map([x], double);
19- print d[0];
15+// each loop calling a higher-order builtin in its body
16+forge double(x) { yield x * 2; }
17+each (x in nums) {
18+ local d = map([x], double);
19+ say d[0];
2020 }
2121
2222 // empty array
23-let empty = [];
24-for (x in empty) {
25- print "should not print";
23+local empty = [];
24+each (x in empty) {
25+ say "should not print";
2626 }
27-print "after empty loop";
27+say "after empty loop";
2828
29-// for loop inside a function, recursion-safe check
30-fn sum_array(arr) {
31- let total = 0;
32- for (v in arr) {
29+// each loop inside a function, recursion-safe check
30+forge sum_array(arr) {
31+ local total = 0;
32+ each (v in arr) {
3333 total = total + v;
3434 }
35- return total;
35+ yield total;
3636 }
37-print sum_array(nums);
38-print sum_array([1, 2, 3, 4, 5]);
37+say sum_array(nums);
38+say sum_array([1, 2, 3, 4, 5]);
3939
4040 // loop variable reused/reassignable, doesn't affect iteration
41-for (x in nums) {
41+each (x in nums) {
4242 x = x + 1000;
43- print x;
43+ say x;
4444 }

kiln/tests/cases/control_flow/loop_body_locals.ashadded

+42−0
@@ -0,0 +1,42 @@
1+// A fresh `local` declared inside a re-executed block must not leak across
2+// iterations/branches -- found via a cross-engine audit against kiln.
3+
4+local i = 0;
5+during i < 3 {
6+ local x = i * 10;
7+ say x;
8+ i += 1;
9+}
10+
11+local arr = [1, 2, 3];
12+each n in arr {
13+ local y = n * 10;
14+ say y;
15+}
16+
17+local j = 0;
18+during j < 6 {
19+ given j % 2 == 0 {
20+ local a = j * 100;
21+ say a;
22+ } otherwise {
23+ j += 1;
24+ next;
25+ }
26+ j += 1;
27+}
28+
29+each x in arr {
30+ given x == 2 { next; }
31+ local z = x * 1000;
32+ say z;
33+}
34+
35+each outer in [1, 2] {
36+ local tmp = outer;
37+ each inner in [10, 20] {
38+ local tmp2 = outer + inner;
39+ say tmp2;
40+ }
41+ say tmp;
42+}

kiln/tests/cases/errors/throw.ashmodified

+16−16
@@ -1,22 +1,22 @@
1-try {
2- throw "custom error!";
3-} catch (e) {
4- print "caught: " + e;
1+attempt {
2+ raise "custom error!";
3+} handle (e) {
4+ say "caught: " + e;
55 }
6-print "after";
6+say "after";
77
8-fn validate(x) {
9- if (x < 0) {
10- throw "negative value: " + str(x);
8+forge validate(x) {
9+ given (x < 0) {
10+ raise "negative value: " + str(x);
1111 }
12- return x;
12+ yield x;
1313 }
1414
15-try {
16- print validate(5);
17- print validate(-3);
18- print "unreachable";
19-} catch (e) {
20- print "caught: " + e;
15+attempt {
16+ say validate(5);
17+ say validate(-3);
18+ say "unreachable";
19+} handle (e) {
20+ say "caught: " + e;
2121 }
22-print "done";
22+say "done";

kiln/tests/cases/errors/try_catch.ashmodified

+36−36
@@ -1,49 +1,49 @@
1-// basic catch of an out-of-bounds array error, execution continues after
2-let a = [1, 2, 3];
3-try {
4- print a[10];
5-} catch (e) {
6- print "caught 1";
1+// basic handle of an out-of-bounds array error, execution continues after
2+local a = [1, 2, 3];
3+attempt {
4+ say a[10];
5+} handle (e) {
6+ say "caught 1";
77 }
8-print "after 1";
8+say "after 1";
99
10-// catch of a missing-map-key error
11-let m = {"x": 1};
12-try {
13- print m["missing"];
14-} catch (e) {
15- print "caught 2";
10+// handle of a missing-map-key error
11+local m = {"x": 1};
12+attempt {
13+ say m["missing"];
14+} handle (e) {
15+ say "caught 2";
1616 }
17-print "after 2";
17+say "after 2";
1818
19-// no error raised -- the try body's own value still prints, catch is skipped
20-try {
21- print 42;
22-} catch (e) {
23- print "should not print";
19+// no error raised -- the attempt body's own value still prints, handle is skipped
20+attempt {
21+ say 42;
22+} handle (e) {
23+ say "should not print";
2424 }
2525
26-// nested try/catch: inner catches its own error, outer never sees it
27-try {
28- try {
29- print a[99];
30- } catch (e) {
31- print "inner caught";
26+// nested attempt/handle: inner catches its own error, outer never sees it
27+attempt {
28+ attempt {
29+ say a[99];
30+ } handle (e) {
31+ say "inner caught";
3232 }
33- print "after inner";
34-} catch (e) {
35- print "outer should not run";
33+ say "after inner";
34+} handle (e) {
35+ say "outer should not run";
3636 }
3737
38-// error raised inside a function called from within a try -- unwinds
38+// error raised inside a function called from within a attempt -- unwinds
3939 // through the call
40-fn boom() {
41- let z = [1];
42- return z[5];
40+forge boom() {
41+ local z = [1];
42+ yield z[5];
4343 }
44-try {
44+attempt {
4545 boom();
46-} catch (e) {
47- print "caught from function";
46+} handle (e) {
47+ say "caught from function";
4848 }
49-print "done";
49+say "done";

kiln/tests/cases/errors/try_catch_scoping.ashmodified

+21−21
@@ -1,27 +1,27 @@
1-let e = "outer";
2-try {
3- throw "inner";
4-} catch (e) {
5- print e;
1+local e = "outer";
2+attempt {
3+ raise "inner";
4+} handle (e) {
5+ say e;
66 }
7-print e;
7+say e;
88
9-try {
10- print "no error";
11-} catch (e) {
12- print "should not run";
9+attempt {
10+ say "no error";
11+} handle (e) {
12+ say "should not run";
1313 }
14-print e;
14+say e;
1515
16-// nested try/catch, inner catch variable shadows outer's
17-try {
18- try {
19- throw "deep";
20- } catch (e) {
21- print e;
16+// nested attempt/handle, inner handle variable shadows outer's
17+attempt {
18+ attempt {
19+ raise "deep";
20+ } handle (e) {
21+ say e;
2222 }
23- print e;
24-} catch (e) {
25- print "outer should not run";
23+ say e;
24+} handle (e) {
25+ say "outer should not run";
2626 }
27-print e;
27+say e;

kiln/tests/cases/functions/closures.ashmodified

+19−19
@@ -1,31 +1,31 @@
11 // M8: lambda assigned to a variable and called
2-let square = fn(x) { return x * x; };
3-print square(5);
2+local square = forge(x) { yield x * x; };
3+say square(5);
44
55 // named function referenced as a value and called indirectly
6-fn add(a, b) { return a + b; }
7-let op = add;
8-print op(3, 4);
6+forge add(a, b) { yield a + b; }
7+local op = add;
8+say op(3, 4);
99
1010 // genuine capture: adder-generator (partial application / currying)
11-fn make_adder(n) {
12- return fn(x) { return x + n; };
11+forge make_adder(n) {
12+ yield forge(x) { yield x + n; };
1313 }
14-let add5 = make_adder(5);
15-let add10 = make_adder(10);
16-print add5(1);
17-print add10(1);
18-print add5(2);
14+local add5 = make_adder(5);
15+local add10 = make_adder(10);
16+say add5(1);
17+say add10(1);
18+say add5(2);
1919
2020 // higher-order function: pass a closure into a plain function
21-fn apply_twice(f, x) {
22- return f(f(x));
21+forge apply_twice(f, x) {
22+ yield f(f(x));
2323 }
24-print apply_twice(square, 3);
24+say apply_twice(square, 3);
2525
2626 // closure capturing multiple outer variables
27-fn make_scaler(mul, offset) {
28- return fn(x) { return x * mul + offset; };
27+forge make_scaler(mul, offset) {
28+ yield forge(x) { yield x * mul + offset; };
2929 }
30-let scale = make_scaler(2, 1);
31-print scale(10);
30+local scale = make_scaler(2, 1);
31+say scale(10);

kiln/tests/cases/functions/functions.ashmodified

+19−19
@@ -1,30 +1,30 @@
1-fn add(a, b) {
2- return a + b;
1+forge add(a, b) {
2+ yield a + b;
33 }
4-print add(2, 3);
4+say add(2, 3);
55
6-fn fact(n) {
7- if (n <= 1) {
8- return 1;
6+forge fact(n) {
7+ given (n <= 1) {
8+ yield 1;
99 }
10- return n * fact(n - 1);
10+ yield n * fact(n - 1);
1111 }
12-print fact(5);
12+say fact(5);
1313
14-fn fib(n) {
15- if (n < 2) {
16- return n;
14+forge fib(n) {
15+ given (n < 2) {
16+ yield n;
1717 }
18- return fib(n - 1) + fib(n - 2);
18+ yield fib(n - 1) + fib(n - 2);
1919 }
20-print fib(10);
20+say fib(10);
2121
22-fn noop() {
22+forge noop() {
2323 }
24-print noop();
24+say noop();
2525
26-fn square(x) {
27- return x * x;
26+forge square(x) {
27+ yield x * x;
2828 }
29-let y = 5;
30-print square(y) + square(y + 1);
29+local y = 5;
30+say square(y) + square(y + 1);

kiln/tests/cases/functions/higher_order.ashmodified

+21−21
@@ -1,26 +1,26 @@
1-let nums = [1, 2, 3, 4, 5];
1+local nums = [1, 2, 3, 4, 5];
22
3-fn double(x) { return x * 2; }
4-let doubled = map(nums, double);
5-print doubled[0];
6-print doubled[1];
7-print doubled[4];
8-print len(doubled);
3+forge double(x) { yield x * 2; }
4+local doubled = map(nums, double);
5+say doubled[0];
6+say doubled[1];
7+say doubled[4];
8+say len(doubled);
99
10-fn is_even(x) { return x - floor(x / 2) * 2 == 0; }
11-let evens = filter(nums, is_even);
12-print len(evens);
13-print evens[0];
14-print evens[1];
10+forge is_even(x) { yield x - floor(x / 2) * 2 == 0; }
11+local evens = filter(nums, is_even);
12+say len(evens);
13+say evens[0];
14+say evens[1];
1515
16-fn add(a, b) { return a + b; }
17-print reduce(nums, add, 0);
18-print reduce(nums, add, 100);
16+forge add(a, b) { yield a + b; }
17+say reduce(nums, add, 0);
18+say reduce(nums, add, 100);
1919
20-let squares = map(nums, fn(x) { return x * x; });
21-print squares[2];
22-print squares[4];
20+local squares = map(nums, forge(x) { yield x * x; });
21+say squares[2];
22+say squares[4];
2323
24-let empty = [];
25-let mapped_empty = map(empty, double);
26-print len(mapped_empty);
24+local empty = [];
25+local mapped_empty = map(empty, double);
26+say len(mapped_empty);

kiln/tests/cases/imports/ash.libs/testlib/testlib.ashmodified

+3−3
@@ -1,5 +1,5 @@
1-let VERSION = "1.0";
1+local VERSION = "1.0";
22
3-fn square(x) {
4- return x * x;
3+forge square(x) {
4+ yield x * x;
55 }

kiln/tests/cases/imports/fixtures/collision_dir_one/shared.ashmodified

+2−2
@@ -1,3 +1,3 @@
1-fn fromOne() {
2- return 1;
1+forge fromOne() {
2+ yield 1;
33 }

kiln/tests/cases/imports/fixtures/collision_dir_two/shared.ashmodified

+2−2
@@ -1,3 +1,3 @@
1-fn fromTwo() {
2- return 2;
1+forge fromTwo() {
2+ yield 2;
33 }

kiln/tests/cases/imports/fixtures/imports_basic_lib.ashmodified

+4−4
@@ -1,7 +1,7 @@
1-fn helper(x) {
2- return x * 2;
1+forge helper(x) {
2+ yield x * 2;
33 }
44
5-fn add(a, b) {
6- return a + b + helper(1);
5+forge add(a, b) {
6+ yield a + b + helper(1);
77 }

kiln/tests/cases/imports/fixtures/imports_circular_a.ashmodified

+2−2
@@ -1,5 +1,5 @@
11 @import <./imports_circular_b.ash>;
22
3-fn fromA() {
4- return 1;
3+forge fromA() {
4+ yield 1;
55 }

kiln/tests/cases/imports/fixtures/imports_circular_b.ashmodified

+2−2
@@ -1,5 +1,5 @@
11 @import <./imports_circular_a.ash>;
22
3-fn fromB() {
4- return 2;
3+forge fromB() {
4+ yield 2;
55 }

kiln/tests/cases/imports/fixtures/imports_collision_main.ashmodified

+1−1
@@ -1,4 +1,4 @@
11 @import <./collision_dir_one/shared.ash>;
22 @import <./collision_dir_two/shared.ash>;
33
4-print shared.fromOne();
4+say shared.fromOne();

kiln/tests/cases/imports/fixtures/imports_const_lib.ashmodified

+4−4
@@ -1,6 +1,6 @@
1-let PI = 3.14159;
2-let GREETING = "hello from lib";
1+local PI = 3.14159;
2+local GREETING = "hello from lib";
33
4-fn circleArea(r) {
5- return PI * r * r;
4+forge circleArea(r) {
5+ yield PI * r * r;
66 }

kiln/tests/cases/imports/fixtures/imports_diamond_b.ashmodified

+2−2
@@ -1,5 +1,5 @@
11 @import <./imports_diamond_d.ash>;
22
3-fn fromB() {
4- return imports_diamond_d.base() + 1;
3+forge fromB() {
4+ yield imports_diamond_d.base() + 1;
55 }

kiln/tests/cases/imports/fixtures/imports_diamond_c.ashmodified

+2−2
@@ -1,5 +1,5 @@
11 @import <./imports_diamond_d.ash>;
22
3-fn fromC() {
4- return imports_diamond_d.base() + 2;
3+forge fromC() {
4+ yield imports_diamond_d.base() + 2;
55 }

kiln/tests/cases/imports/fixtures/imports_diamond_d.ashmodified

+2−2
@@ -1,3 +1,3 @@
1-fn base() {
2- return 100;
1+forge base() {
2+ yield 100;
33 }

kiln/tests/cases/imports/fixtures/imports_statement_lib.ashadded

+19−0
@@ -0,0 +1,19 @@
1+// Covers two bugs found writing the first real forgepack module (see
2+// CHANGELOG.md): a `local` inside a loop in an imported function used to
3+// be misdiagnosed as a failed top-level namespaced-constant declaration,
4+// and a namespaced call used as its OWN statement (not nested in an
5+// expression) used to fail to parse entirely.
6+forge sum_doubled(arr) {
7+ local total = 0;
8+ local i = 0;
9+ during i < len(arr) {
10+ local doubled = arr[i] * 2;
11+ total += doubled;
12+ i += 1;
13+ }
14+ yield total;
15+}
16+
17+forge announce(label) {
18+ say "announced: " + label;
19+}

kiln/tests/cases/imports/imports_basic.ashmodified

+2−2
@@ -1,4 +1,4 @@
11 @import <./fixtures/imports_basic_lib.ash>;
22
3-print imports_basic_lib.add(3, 4);
4-print imports_basic_lib.helper(10);
3+say imports_basic_lib.add(3, 4);
4+say imports_basic_lib.helper(10);

kiln/tests/cases/imports/imports_const.ashmodified

+3−3
@@ -1,5 +1,5 @@
11 @import <./fixtures/imports_const_lib.ash>;
22
3-print imports_const_lib.PI;
4-print imports_const_lib.GREETING;
5-print imports_const_lib.circleArea(2);
3+say imports_const_lib.PI;
4+say imports_const_lib.GREETING;
5+say imports_const_lib.circleArea(2);

kiln/tests/cases/imports/imports_diamond.ashmodified

+3−3
@@ -1,6 +1,6 @@
11 @import <./fixtures/imports_diamond_b.ash>;
22 @import <./fixtures/imports_diamond_c.ash>;
33
4-print imports_diamond_b.fromB();
5-print imports_diamond_c.fromC();
6-print imports_diamond_d.base();
4+say imports_diamond_b.fromB();
5+say imports_diamond_c.fromC();
6+say imports_diamond_d.base();

kiln/tests/cases/imports/imports_lib.ashmodified

+2−2
@@ -1,4 +1,4 @@
11 @import <testlib>;
22
3-print testlib.VERSION;
4-print testlib.square(6);
3+say testlib.VERSION;
4+say testlib.square(6);

kiln/tests/cases/imports/imports_statement.ashadded

+4−0
@@ -0,0 +1,4 @@
1+@import <./fixtures/imports_statement_lib.ash>;
2+
3+say imports_statement_lib.sum_doubled([1, 2, 3]);
4+imports_statement_lib.announce("done");

kiln/tests/cases/io/argv.ashmodified

+5−5
@@ -1,6 +1,6 @@
1-let args = argv();
2-print len(args);
3-print args[0];
4-for (a in args) {
5- print a;
1+local args = argv();
2+say len(args);
3+say args[0];
4+each (a in args) {
5+ say a;
66 }

kiln/tests/cases/io/file_io.ashmodified

+7−7
@@ -1,7 +1,7 @@
1-let path = "/tmp/kiln_file_test.txt";
2-print file_exists(path);
3-print write_file(path, "hello, file!\nsecond line");
4-print file_exists(path);
5-print read_file(path);
6-print append_file(path, "\nappended");
7-print read_file(path);
1+local path = "/tmp/kiln_file_test.txt";
2+say file_exists(path);
3+say write_file(path, "hello, file!\nsecond line");
4+say file_exists(path);
5+say read_file(path);
6+say append_file(path, "\nappended");
7+say read_file(path);

kiln/tests/cases/io/input_builtin.ashmodified

+4−4
@@ -1,4 +1,4 @@
1-let name = input();
2-print "hello, " + name + "!";
3-let x = num(input());
4-print x + 1;
1+local name = input();
2+say "hello, " + name + "!";
3+local x = num(input());
4+say x + 1;

kiln/tests/cases/strings/convert_builtins.ashmodified

+10−10
@@ -1,10 +1,10 @@
1-print str(42);
2-print str(3.5);
3-print str(-7.25);
4-print str(0);
5-print num("42");
6-print num("3.5");
7-print num("-7.25");
8-print num(" 12.5abc");
9-print num("abc");
10-print num(str(123.456));
1+say str(42);
2+say str(3.5);
3+say str(-7.25);
4+say str(0);
5+say num("42");
6+say num("3.5");
7+say num("-7.25");
8+say num(" 12.5abc");
9+say num("abc");
10+say num(str(123.456));

kiln/tests/cases/strings/string_builtins2.ashmodified

+14−14
@@ -1,14 +1,14 @@
1-print upper("hello world");
2-print lower("HELLO WORLD");
3-print upper("Already Mixed 123!");
4-print trim(" hi there ");
5-print trim("no spaces");
6-print trim(" ");
7-print substring("hello world", 0, 5);
8-print substring("hello world", 6, 11);
9-print substring("hello world", -3, 100);
10-print substring("hello world", 8, 3);
11-print indexOf("hello world", "world");
12-print indexOf("hello world", "xyz");
13-print indexOf("hello world", "");
14-print indexOf("hello world", "hello world");
1+say upper("hello world");
2+say lower("HELLO WORLD");
3+say upper("Already Mixed 123!");
4+say trim(" hi there ");
5+say trim("no spaces");
6+say trim(" ");
7+say substring("hello world", 0, 5);
8+say substring("hello world", 6, 11);
9+say substring("hello world", -3, 100);
10+say substring("hello world", 8, 3);
11+say indexOf("hello world", "world");
12+say indexOf("hello world", "xyz");
13+say indexOf("hello world", "");
14+say indexOf("hello world", "hello world");

kiln/tests/cases/strings/string_builtins3.ashmodified

+22−22
@@ -1,27 +1,27 @@
1-let parts = split("a,b,c", ",");
2-print len(parts);
3-print parts[0];
4-print parts[1];
5-print parts[2];
1+local parts = split("a,b,c", ",");
2+say len(parts);
3+say parts[0];
4+say parts[1];
5+say parts[2];
66
7-let chars = split("abc", "");
8-print len(chars);
9-print chars[0];
10-print chars[2];
7+local chars = split("abc", "");
8+say len(chars);
9+say chars[0];
10+say chars[2];
1111
12-let empty1 = split("", ",");
13-print len(empty1);
14-print empty1[0];
12+local empty1 = split("", ",");
13+say len(empty1);
14+say empty1[0];
1515
16-let empty2 = split("", "");
17-print len(empty2);
16+local empty2 = split("", "");
17+say len(empty2);
1818
19-print join(["a", "b", "c"], "-");
20-print join(["x"], ",");
21-print join([], ",");
19+say join(["a", "b", "c"], "-");
20+say join(["x"], ",");
21+say join([], ",");
2222
23-print replace("hello world", "world", "there");
24-print replace("aaa", "a", "bb");
25-print replace("hello", "xyz", "abc");
26-print replace("hello", "", "X");
27-print replace("banana", "a", "o");
23+say replace("hello world", "world", "there");
24+say replace("aaa", "a", "bb");
25+say replace("hello", "xyz", "abc");
26+say replace("hello", "", "X");
27+say replace("banana", "a", "o");

kiln/tests/cases/strings/string_index.ashmodified

+12−12
@@ -1,17 +1,17 @@
1-let s = "hello";
2-print s[0];
3-print s[4];
4-print s[1] + s[2];
1+local s = "hello";
2+say s[0];
3+say s[4];
4+say s[1] + s[2];
55
6-try {
7- print s[10];
8-} catch (e) {
9- print "caught: " + e;
6+attempt {
7+ say s[10];
8+} handle (e) {
9+ say "caught: " + e;
1010 }
1111
12-try {
12+attempt {
1313 s[0] = "H";
14-} catch (e) {
15- print "caught: " + e;
14+} handle (e) {
15+ say "caught: " + e;
1616 }
17-print "done";
17+say "done";

kiln/tests/cases/strings/strings.ashmodified

+25−25
@@ -1,37 +1,37 @@
1-print "hello";
2-print "hello, " + "ash!";
1+say "hello";
2+say "hello, " + "ash!";
33
4-let s = "x";
5-print s == "x";
6-print s == "y";
7-print s != "x";
4+local s = "x";
5+say s == "x";
6+say s == "y";
7+say s != "x";
88
9-let a = "foo";
10-let b = "bar";
11-print a + b;
9+local a = "foo";
10+local b = "bar";
11+say a + b;
1212
13-print "line1\nline2";
14-print "quote: \"hi\"";
13+say "line1\nline2";
14+say "quote: \"hi\"";
1515
16-fn greet(name) {
17- return "hello, " + name;
16+forge greet(name) {
17+ yield "hello, " + name;
1818 }
19-print greet("world");
19+say greet("world");
2020
21-let x = "a";
21+local x = "a";
2222 x = x + "b";
2323 x = x + "c";
24-print x;
24+say x;
2525
26-fn repeat(s, n) {
27- if (n <= 0) {
28- return "";
26+forge repeat(s, n) {
27+ given (n <= 0) {
28+ yield "";
2929 }
30- return s + repeat(s, n - 1);
30+ yield s + repeat(s, n - 1);
3131 }
32-print repeat("ab", 5);
32+say repeat("ab", 5);
3333
34-let n = 5;
35-print n;
36-print s + "test";
37-print n + 1;
34+local n = 5;
35+say n;
36+say s + "test";
37+say n + 1;

plans.mdremoved

+0−128
@@ -1,128 +0,0 @@
1-# Ash: Plans
2-
3-Status: written 2026-10-07, right after the source/test restructure (see `CHANGELOG.md`).
4-Items marked **(proposed)** are my recommendations, not decisions. Items marked **(open)** still need an answer.
5-
6-## Why
7-
8-Ash is in a good position today: a working lexer, single-pass compiler and bytecode VM with closures, maps, `try`/`catch`, real doubles, imports and good diagnostics. But I don't like the general design anymore. Ash currently looks like C/JavaScript: braces, semicolons, parentheses around every condition. I love Lua, and I want Ash to read like Lua: lightweight, high level, little punctuation noise, with blocks that read as structure.
9-
10-The goal is **a higher-level, Lua-looking language with the same performance**. The look changes. The engine's speed does not.
11-
12-## Goals
13-
14-1. **Lua-like surface syntax.** Blocks are keyword-delimited instead of brace-delimited, and the syntax carries less punctuation overall.
15-2. **Same performance.** The VM must not get slower. See the performance rules below. This is the hard constraint.
16-3. **Same behavior.** Existing language semantics stay unless a change is deliberate and written down.
17-4. **Keep the code organized.** Every change follows `AGENTS.md` (mirrored C/H/D/O trees, small feature folders, tests in `tests/cases/<feature>/`).
18-
19-## Non-goals (for this phase)
20-
21-- No new VM instructions or new runtime features just because the syntax changed. The syntax change is a **frontend** change.
22-- No type system, no classes/structs, no new standard library. If "high level" later means more language features (classes, varargs, default parameters), that is a separate plan.
23-- No JIT or backend work (the x86-64 JIT experiment in `ashvm/benchmarks/native_jit.c` stays an experiment).
24-- `kiln` is not blocked on this (see "kiln" below).
25-
26-## What "Lua-like" means here
27-
28-Lua is not indentation-sensitive. Its blocks are delimited by keywords (`then`/`do`/`end`), and whitespace does not matter. That is what "the same indentation as Lua" means in this plan: **keyword-delimited blocks, written with Lua's usual indentation style**. If I ever want Python-style significant indentation instead, that is a different and harder lexer change (INDENT/DEDENT tokens, awkward with multi-line lambdas and the REPL) and needs a new decision.
29-
30-Sketch of the direction **(proposed, not final)**:
31-
32-```text
33--- today -- Lua-like Ash
34-fn make_adder(x) { fn make_adder(x)
35- return fn(y) { return x + y; }; return fn(y) return x + y end
36-} end
37-
38-if (n % 2 == 0) { if n % 2 == 0 then
39- print "even"; print "even"
40-} else { else
41- print "odd"; print "odd"
42-} end
43-
44-while (i < n) { i = i + 1; } while i < n do i = i + 1 end
45-
46-for (x in nums) { print x; } for x in nums do print x end
47-
48-try { ... } catch (e) { ... } try ... catch e ... end
49-```
50-
51-Likely rules: statements end at a newline or `;` (semicolons optional), conditions need no parentheses, `end` closes every block, comments stay as they are or move to `--` **(open: keep `//` or switch to `--`?)**.
52-
53-## What must stay (do not change)
54-
55-These are the things that make Ash good and fast. The syntax work must leave them alone.
56-
57-**Runtime and performance**
58-- The bytecode `Chunk` format, the stack VM and the computed-goto dispatch loop (`ashvm/src/vm/`).
59-- The hand-fused superinstructions (`x = x + y`, `while (i < n)` and similar). They are why loops are fast.
60-- Value representation (`VMValue` tagged type, real `double` numbers), the hash map, and builtins' behavior.
61-- Closures with **by-value capture**, first-class functions, `map`/`filter`/`reduce`, `try`/`catch`, `for (x in array)`.
62-
63-**Behavior users see**
64-- Rust-style diagnostics: `file:line:column`, a source snippet, a caret and a contextual hint, for both compile-time and runtime errors. New syntax must produce equally good errors. For example, a missing `end` must point at the block that was never closed, not at end-of-file.
65-- Import semantics: `@import <./relative.ash>` and `@import <lib>` (resolved through `ash.libs/<lib>/<lib>.ash`), relative to the **entry file's directory**, with namespaced access and circular/collision errors.
66-- The REPL, including persistent variables across lines.
67-
68-**Project structure**
69-- `AGENTS.md` layout and rules. Split new code into feature folders, mirrored across `C/H/D/O`.
70-- The regression harnesses (`tests/scripts/run_regression.sh`) as the safety net.
71-
72-## Performance rules (non-negotiable)
73-
74-Today's baseline, measured 2026-10-07 on my laptop (median of 5 runs; the laptop is slow, so only **relative** comparisons on the same machine matter):
75-
76-| Benchmark | `ashvm` |
77-|---|---|
78-| `fib(30)` | 0.178s |
79-| sum 0..20M (`loop`) | 1.36s |
80-| `loop_varbound` | 1.37s |
81-| `countdown` | 1.36s |
82-| `hashmap` (50k inserts) | 0.081s |
83-
84-The README's older numbers (0.06s / 0.12s) do not reproduce here. Whether that is machine difference, staleness or a fusion that is not firing is **(open)**; see "Investigate first".
85-
86-Rules:
87-1. **Measure before and after**, on the same machine, with the same method (median of at least 5 runs, `ashvm/benchmarks/*.ash`). A change that makes any benchmark more than ~5% slower does not land.
88-2. **The syntax change must not touch the VM.** Work is limited to the lexer and compiler (`ashvm/src/lexer`, `ashvm/src/compiler`). New syntax compiles to the **same bytecode** as the equivalent old-syntax program. The same bytecode means the same speed.
89-3. **Superinstruction fusion must keep firing.** The compiler's fusion peeks at tokens after `(` in conditions (`try_fuse_condition`). When parentheses become optional, `while i < n do` and `x = x + y` must still match those patterns. This is the single most likely way to lose speed by accident, so test it explicitly.
90-4. **No per-instruction cost.** No extra checks, indirection or bookkeeping in `vm/dispatch.c` for the sake of syntax.
91-5. Benchmarks are converted to the new syntax too, and compared against their old-syntax timings.
92-
93-## How (proposed phases)
94-
95-0. **Investigate first (optional, small).** Find out why `loop` takes ~1.36s and whether the `while (i < n)` fusion is firing. Fixing that first gives the rework a better baseline, and it settles the README numbers.
96-1. **Freeze the safety net.** Capture regression baselines (`run_regression.sh baseline`) and benchmark timings. If there is no bytecode dump, consider adding a small one so "same bytecode" can be checked directly rather than inferred from timings.
97-2. **Lexer.** Add the new keyword tokens (`then`, `do`, `end` or whatever is chosen). Make `;` optional if decided.
98-3. **Compiler.** `block()` is the single choke point for blocks. Rework it to stop at `end`/`else` instead of `}`, then update `if`, `while`, `for`, `try`/`catch`, `fn` and lambda emission (`stmt.c`, `stmt_control.c`, `calls.c`, `expr.c`). Make parentheses around conditions optional without breaking fusion.
99-4. **Diagnostics.** Update messages and hints for the new tokens ("expected `end` to close this `if`"), keeping the Rust-style output.
100-5. **Migrate** all `.ash` files: `tests/cases/**`, `benchmarks/*.ash`, README examples. This is mechanical. Run the regression suite after: output must match except where a diagnostic prints new syntax.
101-6. **Compare** benchmarks against the baseline above. If anything regressed, fix before moving on.
102-7. **Docs/tooling:** update `README.md`, `CHANGELOG.md`, and the VS Code syntax extension (separate repo, `ash-syntax`).
103-
104-Each phase keeps the build and tests green and is verified before the next one starts.
105-
106-## kiln
107-
108-`kiln` (the native `.ash` → ELF/PE compiler) has its **own** lexer and parser, so it does not get the new syntax for free. **(open)** Port it too (about the same amount of frontend work again), or leave kiln on the old syntax for now. If it stays behind, the two will accept different languages until it is ported, so this must be a conscious choice and recorded here.
109-
110-## Old syntax: keep or drop? (open)
111-
112-**Proposed:** replace it outright at the switch. Supporting both doubles lexer/compiler complexity and costs roughly 50% more effort, and the old brace syntax has no reason to survive a rework whose point is to leave it. The cost is that every `.ash` file in the repo (and anyone else's scripts) has to be migrated at once, which the phases above already plan for.
113-
114-## Definition of done
115-
116-- Programs are written in the Lua-like syntax and read like Lua.
117-- Full regression suite passes for the migrated tests (identical output, apart from diagnostics that now print new syntax).
118-- Benchmarks are within ~5% of the baseline (or better, if phase 0 improved it).
119-- Diagnostics for syntax errors are as good as today's.
120-- `AGENTS.md` structure rules still hold; docs, changelog and editor extension are updated.
121-
122-## Open questions
123-
124-1. Keyword set and comment style (`//` vs `--`)?
125-2. Semicolons optional, or removed entirely?
126-3. Old syntax: replace at once (recommended) or support both for a while?
127-4. Port `kiln` now or later?
128-5. Does "high level" also mean new language features (classes, varargs, default parameters, a larger standard library) after the syntax change? If so, that gets its own plan.