Downloads

Latest: v1.2.32, published 2026-05-19.

macOS, x64xs-macos-x86_64 (2.9 MB)sha256download
Linux, x64xs-linux-x86_64 (3.1 MB)sha256download
Windows, x64xs-windows-x86_64.exe (1.3 MB)sha256download

Verify

Each release includes a SHA-256 sum file alongside the binary. Run shasum -a 256 -c xs-<platform>.tar.gz.sha256 after downloading.

Changelog

v1.2.32

2026-05-19

Cleaner transpiled output

Both --emit c and --emit js gain a small post-pass that strips block and line comments and collapses blank lines from the emitted source. Same code, less noise.

hello.xs (1 line)
--emit c before2329 lines, 88 KB
--emit c after2054 lines, 78 KB
--emit js before931 lines, 47 KB
--emit js after926 lines, 47 KB

JS already had a dense prelude so its gain is marginal; the real win is on the C side.

How the pass works

compact_emitted walks the emitted string once, character by character:

  • /* ... */ block comments are removed (including multi-line ones).
  • // ... line comments are removed.
  • Lines that end up empty after stripping are collapsed.
  • Multiple consecutive blank lines collapse to nothing.
  • Trailing whitespace on every line is stripped.

It is aware of string literals ("...", '...', and for JS template strings \ ... \`) so comment-looking content inside a string literal survives intact.

No code is rewritten, no helpers are removed, no behaviour changes. Programs that compiled before still compile; programs that ran before still run with the same output.

Tests

tests/run.sh 50/50, tests/run-all.sh 7/7 layers, tests/test_corpus_matrix.sh 60/60.

v1.2.30

2026-05-19

bug055 closes on --emit wasm

Restructured the test so every backend prints a uniform bug055: ok instead of the previous wasi-specific bug055: skipped on wasi line that the corpus matrix kept flagging as a stdout divergence.

The change is a single test file (tests/regression/bug055_vm_method_error_span.xs): the wasi / windows guard now wraps only the subprocess body, and the trailing println("bug055: ok") runs unconditionally. The real fork assertion (writes a tiny target script, sets NO_COLOR=1, forks ./xs <flag> under each backend, asserts the rendered file:line:col matches the call site) still runs on interp / vm / jit / c-linux. Those are the platforms where the original VM span-rendering regression would have surfaced.

Skip-emit corpus

Down from 3 files to 2. The remaining markers are both bug029 / bug042 http tests, gated on POSIX sockets and the ~300KB BearSSL inline TLS blob (-- skip-emit: c, wasm).

Tests

tests/run.sh 50/50, tests/run-all.sh 7/7 layers, tests/test_corpus_matrix.sh 60/60.

v1.2.29

2026-05-19

db, actor closure capture, process.run land on the transpilers

Skip-emit corpus narrows from 5 files to 3. Three of the four remaining markers from v1.2.28 close on both c and wasm; the fourth (bug055) lifts on c and gracefully no-ops on wasm.

In-memory db polyfill

Both transpilers now gate an embedded db module on import db. Covers the SQL surface the test corpus actually uses:

  • CREATE TABLE t (col1, col2, ...)
  • INSERT INTO t VALUES (v1, v2, ...) and INSERT INTO t (cols) VALUES (...)
  • SELECT * FROM t [WHERE col op v] [ORDER BY c [ASC|DESC]] [LIMIT n]
  • Where comparators: =, ==, !=, <, >, <=, >=

Each row carries both the real column-name keys and the positional cN keys the native runtime exposes, so rows[0].c1 keeps working against transpiled output. No sqlite link, intentional: both targets stay zero-dep.

For the wasm side the SQL parser is small enough that it runs at runtime via plain string scanning (no regex available). The c side mirrors the JS polyfill's shape but uses strstr and a handrolled case-insensitive matcher for the SQL keywords because strcasestr isn't portable.

Nested actor closure capture

actor X { ... } declared inside a fn body now picks up outer-scope upvalues. Each captured name is heap-promoted into a 1-element box allocated once per enclosing-fn call. The actor instance map carries pointers to those boxes alongside its method closures, so two sibling actors declared in the same fn see each other's writes through the shared box.

fn pair() {

    var shared = 0

    actor R { fn read() { return shared } }

    actor W { fn write(n) { shared = n } }

    return [spawn R, spawn W]

}

let p = pair()

p[1].write(42)

assert_eq(p[0].read(), 42)

The free-variable analyser used by the lowering pass also picked up several missing AST cases (LIT_ARRAY, LIT_MAP, RANGE, WHILE, FOR, MATCH, TRY, SPAWN, AWAIT, THROW). log = log + [prefix + msg] hides prefix inside an array literal; without the array case the capture list dropped it.

process.run + os.setenv + fs.temp_dir (c side)

The c emit grows the three remaining stdlib calls bug055 needs:

  • process.run(cmd) via popen / pclose, returns {ok, stdout, code}

matching the interp contract. WIFEXITED / WEXITSTATUS gated behind #if defined(WIFEXITED) so the Win32 build that lacks sys/wait.h still compiles (falls back to pclose's raw return).

  • os.setenv(name, value) wraps setenv with overwrite=1.
  • fs.temp_dir() prefers TMPDIR / TEMP / TMP, falls back to

/tmp.

All three are gated on import process / import fs / import os.

bug055 on wasm

os.platform now returns "wasi" so the test's if os.platform == "wasi" skip branch fires correctly. The test stays marked skip-emit: wasm because the corpus matrix diffs every backend's stdout against the interp baseline, and the wasi skip branch prints a different line. The wasm runtime itself has no way to fork ./xs, so a full implementation isn't viable here.

The c side runs the full test: writes a target script via fs.temp_dir() ++ "/_bug055_target.xs", sets NO_COLOR=1, then forks ./xs under each backend flag and asserts the rendered file:line:col matches the call site.

Still skipped

Three markers remain, all pure external-dep:

  • bug029, bug042: http needs POSIX sockets plus a ~300KB inline

BearSSL blob for TLS.

  • bug055 on wasm only: the corpus matrix diffs the wasi-skip branch

against the interp baseline; the os.platform="wasi" code itself is wired correctly.

Tests

tests/run.sh 50/50, tests/run-all.sh 7/7 layers (179 regression + 54 conformance), tests/test_corpus_matrix.sh 60/60.

v1.2.28

2026-05-18

Closed the wasi-threads / watch / value-model gaps left in v1.2.27

Skip-emit corpus narrows from 11 files to 5. Everything that was previously parked as "needs new wasm value tags" or "needs a kernel event loop" landed. The 5 remaining markers are pure external-dep work (sqlite / BearSSL inline) or test patterns that fork ./xs and have no portable equivalent.

C grows a trigger event loop

src/transpiler/c_gen.c emits pthread-backed scheduling for the four runtime-side decorators at end of main:

  • @watch(path) spawns one thread that stat-polls every 50ms and fires when st_mtime (Linux: st_mtim.tv_nsec; macOS: st_mtimespec) advances past the captured baseline.
  • @delayed(d) spawns a thread that clock_nanosleeps then calls the fn once. The duration argument is normalised through a single helper that handles Duration cells (XS_DUR_TAG ns), bare int (ms), and float (ms).
  • @every(d) is the same shape as @delayed but loops; @once @every co-decoration is honoured.
  • @on_start runs inline before joining the trigger threads; @on_exit registers via atexit.

main() joins the trigger threads, so the process stays alive until a callback calls exit(). The test corpus_matrix linker line gained -lpthread to cover platforms where libc doesn't fold pthreads in implicitly.

WASM Duration as a first-class type

A new value tag (TAG_DURATION = 13) wraps the i64 ns count at offset 8 of the 16-byte cell. Three runtime helpers landed: RT_DUR_NEW(lo, hi), RT_DUR_NS(ptr), RT_DUR_TO_STR(ptr). buf_leb128_s64 replaces the 32-bit signed LEB128 helper that was silently truncating the day / hour constants.

  • typeof / type return "duration"
  • .ns / .us / .ms / .s / .m / .h / .d accessors dispatch on the tag; non-duration receivers fall through to struct-slot / RT_VAL_FIELD lookup so no class field regressions
  • val_add / val_sub handle dur+dur, dur-dur
  • val_mul handles dur*int and int*dur (i64 scaling)
  • val_div handles dur/dur (returns float ratio, no tag) and dur/int (returns scaled duration)
  • val_lt / val_gt / val_le / val_ge share a emit_dur_cmp_branch helper
  • val_eq compares duration cells by i64 ns
  • val_to_str emits the compact [Nd][Hh][Mm][Ss.frac] form with sub-second ns / us / ms units and trailing-zero trim

WASM reactive bind

A compile-time bind registry records, per bind name = expr, the set of root NODE_IDENT names the RHS references and a recompute thunk that re-evaluates and stores the result. Every NODE_ASSIGN whose target is NODE_INDEX or NODE_FIELD walks the target back to its root ident and emits a call to each matching recompute thunk after the store completes. INDEX/FIELD assigns also got the RHS into a temp so it isn't evaluated twice. Bind targets land in top_bindings first so the bind name backs a wasm global that every recompute fn can read.

WASM tagged blocks

NODE_TAG_DECL registers as a wasm fn with a trailing __block param. Inside a tag body, NODE_YIELD lowers to RT_CALL1(__block, value) instead of routing through the generator buf. At call sites, a pre-lowering pass injects a _yv param into any 0-arg trailing-block lambda so the wasm function type lines up with what RT_CALL1 expects. NODE_RETURN inside a tag body checks an err-flag so a return taken after a yield-triggered throw doesn't exit the fn before the enclosing try catches it.

WASM spawn + channels

channel() allocates a fresh array; send pushes, recv shifts. spawn { body } evaluates the body inline and wraps the result in {_result: r, _status: "done"}; await fut reads fut._result. time.sleep is a no-op stub. The synchronous lowering is honest about wasi-preview1: there's no thread-spawn import, so observable test behaviour is what matters. The test patterns gate on producer-runs-first-then-consumer ordering, which falls out naturally.

WASM @watch + @delayed

A proc_exit import gives exit(N) real wasi semantics. After main, the synthesised entry point fires @every once, then @delayed fns in ascending due-time order, calling @watch callbacks between each delayed step. Triggers are keyed by user fn name so a fn wearing multiple trigger decorators only fires once per sweep.

bug043 closes on WASM

The four sub-fixes that v1.2.27 left in place:

  1. Try operator `x?`: a compile-time pass collects every enum variant whose name matches Err / None and records its ordinal. NODE_UNARY with op ? checks slot 1 (the ordinal) against that set; if matched, returns the value unchanged (propagating Err / None up); otherwise unwraps arr_get(xv, 2) (the payload of Ok(x) / Some(x)).
  2. Map destructure `let {a: aa, b: bb} = m`: NODE_PAT_MAP was already in compile_pattern_bindings; the let { ... } = m path now actually drives through it (rename form bound the wrong identifier before).
  3. Nested for in comprehensions: NODE_MAP_COMP was rewritten to mirror NODE_LIST_COMP's multi-clause nesting; int keys are stringified to match the interp's normalisation.
  4. Trait IC `__class` folding: struct constructors now stash __class = "<StructName>" in the instance map so the multi-impl method-dispatch IC can fold the type tag into its key. Heap-slot collisions between freed instances of different types no longer cache-hit the wrong impl.

Enum value encoding gained a 3-byte \x1e\x01\x1e marker on the path string so val_to_str can distinguish enum cells from a plain (string, int) tuple without burning a fresh tag. entries() is now a map method that returns an array of (k, v) tuples (uses RT_TUPLE_NEW so each element prints with parentheses).

Still skipped (5 markers)

  • db โ€” needs libsqlite3 / libpq; both --emit c and --emit wasm refuse external links by design.
  • http (bug029, bug042) โ€” POSIX sockets + a ~300KB inline BearSSL blob for TLS.
  • actor closure capture inside fn bodies (bug026) โ€” needs closure lifting + outer-scope upvalue capture; the existing actor lowering is top-level-only.
  • bug055 vm method error span โ€” test forks ./xs to inspect the runtime error span; wasi has no exec.

Each surviving marker carries a TODO that names the structural blocker.

Tests

tests/run.sh 50/50, tests/run-all.sh 7/7 layers (179 regression + 54 conformance), tests/test_corpus_matrix.sh 60/60.

v1.2.27

2026-05-17

Closed the skip-emit corpus from 26 files to 11

Three parallel transpiler passes, each agent pinned to one file (src/transpiler/wasm.c, c_gen.c, js.c) so the runs couldn't step on each other.

JS fully closes: every js marker lifted (10 -> 0)

  • db: in-memory polyfill that parses CREATE TABLE / INSERT / SELECT with WHERE col = value. Rows expose both real column names and positional cN keys.
  • reactive bind: lowered NODE_BIND to a __xs_binds registry; __xs_setidx / __xs_setfield re-run all dependent updaters via __xs_notify, recursion-guarded.
  • actor closure capture: actor decls bind the actor class name (const Logger = ...), spawn ActorName lowers to new ActorName().
  • http: http.get / post / put / delete shell out through child_process.execFileSync curl for synchronous semantics; throws a structured HttpError map on failure.
  • tagged blocks: NODE_YIELD checks an in_tag_body flag and lowers to __block(value).
  • try-op `?` + map destructure + map iter: ? lowers to __xs_try(v) which throws __XsTryErr on Result::Err. let {a: aa, b: bb} = m now reads the sub-pattern's identifier as the binding name. for k in m routes through __xs_iter(m) which returns .keys() for JS Maps.
  • Duration first-class: __XsDuration class wrapping ns as BigInt; arithmetic, < >, equality, and repr all dispatch on _xs_kind === 'duration'. (5s).ns, .ms, .s work. __xs_to_ms updated to unwrap durations for @delayed / @every.
  • @watch: fs.watch(path, () => fn()) glue after the decorated fn.
  • process.run: returns {ok, stdout, code} map matching the interp's contract.

C narrows from 18 to 6

  • Wrapping decorators (memoize / retry / timed / trace / throttle / debounce) finally work on the C path. The inner fn emits under a mangled name (__xs_inner_<name>) so the user binding holds the wrapper closure (xs_val foo = xs_memoize(xs_fn_new(__xs_inner_foo_wrap, NULL))). Recursive self-calls observe the wrapper, so memoization actually caches.
  • Multi-shot resume: setjmp re-entry. The c effect machinery was lowering resume as one-shot; the body just wasn't running because emit_expr on NODE_BLOCK only emitted the trailing expression.
  • Collections module: Deque / Stack / Set / Counter / OrderedMap / PriorityQueue lowered via xs_collections_make + .front/.back/.peek/.len dispatch.
  • JIT-native parity (bug030 / 031 / 032 / 033): class method emit declares __saved_defer_top (NODE_RETURN was dangling), walk_register_struct_vars second pass picks up struct types inside fn bodies, bound-method c.value emit, xs_iter / xs_iter_next extended to utf-8 strings + map keys, xs_strcat coerces non-string operands, __gen_iter falls through to map keys when __items is absent.
  • Tagged blocks: tag X(...) { ... yield ... } lowered to a real fn with __block as last param; yield emits xs_call(__block, ...).
  • bug043 surface: ? returns Err early or unwraps Ok, enum value xs_to_str renders Type::Variant(args), struct constructor A() (no class decl) emits empty __type__-tagged map.
  • bug018 spawn/channel: xs_channel_send returns xs_val, await no longer stripped during lowering, now unwraps the spawn-result map.
  • bug022 reactive bind: refusal lifted; bind names inlined on every read so dependency mutations propagate.
  • bug044 Duration first-class: XS_DUR_TAG (tag 10), updated xs_add / xs_sub / xs_mul / xs_div / xs_cmp / xs_eq / xs_type / xs_to_str to handle durations; .ns / .us / .ms / .s / .m / .h / .d accessors.

WASM narrows from 24 to 11

  • Multi-arity overload dispatch: per-name overload tracker + arity-mangled lookup.
  • Multi-arm effect handle: wasm_build_handle_block now uses each arm's effect_name instead of arms[0].
  • Null-callee TypeError parity: matches the runtime instead of trapping.
  • Trigger registry: same shape as C/JS (v1.2.25).
  • Collections (`Deque` / `Stack` / `Set`): pre-lowered at AST level to a tagged map shape; .front / .back / .peek / .len handled.
  • Purity inference output: __pure?(ident) consults purity_analyze's stamps via fn_decl.inferred_pure / lambda.inferred_pure; let-binding index for let-bound lambdas.
  • JIT-native parity: class instantiation (C() recognises mangled <Class>__init or synthesises an empty map with __class stamp), bound method-as-fn-value, utf-8 char iter, array.pop + array.sort(fn) (in-place insertion) + .size alias for .len, tuple .0 / .N field access as array index, range literal kept unwrapped.
  • type / typeof: now return human names ("int", "map", "fn", ...) instead of numeric tag strings, matching interp / vm.

Every chunk round-tripped through wasmtime before the next one landed so the byte stream stays valid; the previous wasm-agent corruption pattern didn't recur.

Still skipped (refined TODOs)

The remaining 11 markers are genuine external-dep / event-loop / OS-specific territory:

  • db (c / js: lifted; wasm: needs sqlite/postgres which has no wasm equivalent)
  • http (~300KB BearSSL inline blob in C; sockets/TLS unavailable in wasi-preview1)
  • @watch (inotify/kqueue + timer loop on C; wasi has no fs-event)
  • actor closure capture inside fn bodies (needs closure lifting + outer-scope upvalue capture; existing actor lowering is top-level-only)
  • pthread spawn under wasi-preview1 (no thread-spawn import; wasi-threads is a separate proposal)
  • bug055 vm method error span (test forks ./xs; wasi has no exec)
  • duration first-class in wasm, reactive bind in wasm, tagged blocks in wasm, bug043 mixed surface in wasm (each needs a new value tag or listener-slot machinery the wasm value model doesn't have today)

Each surviving marker carries a TODO: line that names the exact structural blocker.

Tests

tests/run.sh 50/50, tests/run-all.sh 7/7 layers, tests/test_transpiler_diff.sh 12/12, tests/test_corpus_matrix.sh 60/60.

v1.2.26

2026-05-17

Added

  • `--emit js`: `import process` extends Node's `process` global with run / popen / popen_read methods that wrap child_process.execSync. XS code calling process.run("./xs foo.xs 2>&1") now shells out instead of throwing "no method 'run' on object". The polyfill is gated on the program containing import process so the bare global stays untouched for code that doesn't ask for it.

Note on the remaining skip-emit markers

After this release the corpus still carries skip-emit markers for genuinely deep work that doesn't fit a single-shot pass:

  • wasm AOT trigger registry + multi-arity overload dispatch (closure-cell layout makes the C/JS trick more involved)
  • wasm AOT multi-arm effect routing, null-callee path, static purity-inference output
  • c wrapping decorators (@memoize / @retry / @timed / @trace) - typed C fns can't be re-bound to closure values without a deeper rework
  • c single-shot effect machinery
  • c, js, wasm for stdlib http (BearSSL inlining too heavy in C, sync fetch wrapper needs an event loop in JS), db (external library, violates zero-deps), reactive bind (needs full reactive runtime), tagged blocks (__block yield), first-class duration type, actor closure capture, structured concurrency (spawn / channels)
  • jit-native-only tests that don't make sense to mirror through an AOT path

Each surviving marker carries a TODO: naming exactly what would be needed to close it. None pretend to be features; they're durable backlog with a real signal attached.

Tests

tests/run.sh 50/50, tests/run-all.sh 7/7, tests/test_transpiler_diff.sh 12/12, tests/test_corpus_matrix.sh 60/60.

v1.2.25

2026-05-17

Added

  • C+JS emit grow a trigger registry. Interp / vm / jit already expose __trigger_registry_size, __trigger_registry_name, __trigger_registry_fn so xs bench / xs doc and any user code can enumerate the trigger-decorated fns without re-walking the AST. The transpilers had none of that and bug046 / bug050 punted to skip-emit. Now both:

- C side emits a static struct { const char *name; const char *fn; } __xs_trigger_reg[] per top-level trigger-decorated fn (@bench, @example, @every, @cron, @delayed, @watch, @on_start, @on_exit, @on_signal, @on_panic). main() seeds three xs_val bindings as closures over small dispatcher fns so the user code reaches them the same way the runtime does. @once is a modifier; it doesn't get its own entry. - JS side does the same walk in the prelude, emits the array as plain JS, and binds the three names with arrow wrappers.

  • JS emit gains multi-arity overload dispatch. fn calc(x) followed by fn calc(x, y) followed by fn calc(x, y, z) used to silently shadow the first decl on JS; the second won. Mirroring the C-side mechanism from v1.2.18, the JS transpiler now pre-scans for collisions, mangles each decl to name_a<arity>, and emits a dispatcher at the top of the file (before any user statement touches the bare name) that picks the entry by args.length. Exact-arity preferred; otherwise smallest arity >= argc; otherwise highest declared.

Test coverage

bug046 (trigger registry size / name lookup) and bug050 (@bench / @example discovery) narrow from c, js, wasm to just wasm. bug020 (multi-arity overload) narrows from js, wasm to just wasm. WASM AOT would need both shapes, kept as the follow-up since its closure-cell layout makes the same trick more involved.

tests/run.sh 50/50, tests/run-all.sh 7/7, corpus matrix 60/60.

v1.2.24

2026-05-17

Added

  • `--emit js` lowers wrapping decorators. @memoize, @retry(n), @timed, @trace now work; previously they were refused with "wrapping decorators not supported." Each becomes a small prelude helper that wraps the function and rebinds the name after the fn-decl emit:

- @memoize stringifies the arg list as the cache key (same shape as the runtime) - @retry(n) catches up to n attempts and rethrows the last exception; bare @retry defaults to 3 - @timed measures monotonic ms and writes [timed] name: ms ms to stderr (same format as the interp, so stdout-only diffs match) - @trace prints call args + return through stderr

  • @throttle / @debounce stay refused: they're observably async, which the rest of the transpiled program isn't.

Test coverage

bug054 (@memoize / @retry / @timed) lifts the js skip marker. C side still rejects wrapping decorators; that's structural since C fns are typed and can't be re-bound to a closure value without a deeper rework, kept as the legitimate gap.

bug045 (@api / @modifier / @discovery decorator parse) lifts its skip-emit entirely; those decorators parse cleanly and emit as no-ops on every backend now.

tests/run.sh 50/50, tests/run-all.sh 7/7 layers, corpus matrix 60/60.

Remaining skipped (legitimate gaps)

After this release, the corpus still carries skip-emit markers for: stdlib http (TLS-in-bundle is too heavy for --emit c, JS would need a sync wrapper around fetch), db (needs an external library), structured concurrency / spawn / channels (needs an event loop the standalone outputs don't ship), reactive bind (needs a full reactive runtime), multi-arm effect routing in --emit wasm, multi-arity overload dispatch in --emit js / --emit wasm, tagged blocks (__block yield), and a handful of jit-native-emit-only tests that don't make sense to mirror through the AOT path. Each marker carries a TODO that names the gap it needs closed.

v1.2.23

2026-05-17

Added

  • `--emit js` lowers scheduling decorators. @every(dur) fn tick() { ... } emits setInterval(tick, ms) after the function declaration. @delayed(dur) emits setTimeout. @on_start emits an immediate call at module top. @on_exit registers a process.on('exit', ...) hook. @once composes with @every via a wrapper that calls clearInterval after the first fire. @cron / @watch / @bench / @example / @on_signal are accepted as no-ops (no portable cron parser, no portable fs.watch shape, and the test/bench harness is interp-only).
  • `__xs_to_ms` prelude helper. Accepts both bigint nanoseconds (the canonical Duration shape) and plain numbers. Numbers above 1_000_000 are assumed to be nanoseconds from a duration literal that wasn't bigint-tagged; smaller ones are treated as milliseconds for parity with @every(50) shorthand.

Fixed

  • Bare `exit(n)` and `abort()` in `--emit js`. Both route to process.exit and a thrown error respectively, matching the C-side fix from v1.2.19. Without this, an @every-decorated body that wanted to call exit(0) to stop the interval would hang the program.

Test coverage

bug048 (@every / @delayed scheduling) and bug052 (@once @every one-shot composition) lift their -- skip-emit: js markers and now round-trip across interp / vm / jit / --emit c / --emit js with identical output. tests/run.sh 50/50, tests/run-all.sh 7/7 layers.

v1.2.22

2026-05-17

Added

  • `--emit js` lowers `import fs` and `import os`. Mirrors the C-side lowering shipped in v1.2.21. fs maps to node:fs sync APIs wrapped in a lazy require (so a browser bundle that never imports fs doesn't trip on the require). os maps to process.* + node:os. Surface area:

- fs.read / fs.write / fs.exists / fs.cwd / fs.list_dir / fs.remove / fs.mkdir plus aliases (read_file, write_file, list, delete, move, is_file, is_dir, temp_dir) - os.getenv / os.env / os.args / os.exit / os.hostname / os.platform plus field-access forms (os.platform, os.sep, os.cwd)

Notes

Both polyfills are emitted only when the source actually contains the corresponding import. The bare names fs / os collide with common user identifiers (bug015 in the regression corpus uses let fs = make_adders()), so unconditional emission would shadow user code. math / json / time / collections continue to ship unconditionally because those names are uncommon enough in user code to keep the existing shape.

Tests

tests/run.sh 50/50, tests/run-all.sh 7/7, tests/test_transpiler_diff.sh 12/12, tests/test_corpus_matrix.sh 60/60. End-to-end verified: fs.cwd(), os.platform, and a no-import program that uses let fs = ... all round-trip correctly through --emit js | node.

v1.2.21

2026-05-17

Added

  • `--emit c` lowers `import fs`, `import os`, `import time`. The pattern matches the existing math / json lowering: is_stdlib_module(name) returns 1 and mod.method(args) at the call site lowers directly to xs_mod_method(args), so the source identifier never needs to materialise as a runtime binding. Surface area covers what the regression corpus actually uses, not the full module: fs.read, fs.write, fs.exists, fs.cwd, fs.list_dir, fs.remove, fs.mkdir; os.getenv, os.args, os.exit, os.hostname, os.platform plus the field-access forms (os.platform, os.sep, os.args); time.format on top of the already-wired time.now / time.now_ms / time.monotonic / time.sleep. libc + <sys/stat.h> + <dirent.h> for the POSIX path, with Win32 fallbacks via <direct.h> / <windows.h> for the directory ops.

Fixed

  • C bigint comparison was silently returning equal. 10 ** 20 already promoted to XS_BIGINT in xs_add / xs_mul / xs_pow, but xs_cmp had no XS_BIGINT_TAG arm and silently fell through return 0. assert(big > 0) reduced to assert(0 > 0) and tripped with a confusing failure. Routes (bigint, bigint), (bigint, int), and (bigint, float) through a magnitude-string compare (strip leading zeros, length-then-strcmp) before the tag-specific arms run. Same gap latent in xs_eq closed by the same path. bug038 lifts the c skip marker and now exercises the c backend at both -O0 and -O2 in the corpus matrix.

Tests

tests/run.sh 50/50, tests/run-all.sh 7/7, tests/test_transpiler_diff.sh 12/12, tests/test_corpus_matrix.sh 60/60. Per-file end-to-end checks for fs / os / time / bigint passed against both -O0 and -O2.

v1.2.20

2026-05-16

Changed

  • WASM AOT (`--emit wasm`) catches up most of the surface area that the v1.2.18 corpus matrix surfaced as trap-on-runtime. The transpiler used to dispatch only int and float in value_add / value_cmp / value_equal; arrays, strings, tuples, maps now work end-to-end including lex compare. Range method dispatch ((1..=10).each, .to_a, .map, .filter, .fold) lands. Array-method tail (.flat, .flatten, .find_index, .flat_map, .chunks, .windows, .zip_with) lands. Runtime errors throw a catchable RuntimeError instead of trapping with unreachable: divide-by-zero, index-out-of-bounds, nil method call, the typed-arith error path, .is_a() type queries. Dynamic obj.method() on a map field routes through xs_call rather than collapsing. Map equality covers the deep-nested and mixed-key cases. del local followed by a read throws a use-after-del runtime error to match the interp / vm behaviour.
  • Bigint promotion in `--emit wasm`. 10 ** 20 used to overflow int64_t silently. The wasm path now lifts to bigint via the same decimal-string add / mul / pow runtime the C transpiler ships (bug038 now passes on the wasm backend).

Test coverage

20 regression files lift the -- skip-emit: wasm marker entirely; 4 narrow from a multi-backend skip to a smaller list; 5 keep the marker for genuinely deeper gaps (structured concurrency, full stdlib http/db, full reactive bind). The wasm.c emitter grew ~1700 lines; every chunk was round-tripped through wasmtime before the next chunk landed so the module stays valid wasm at every commit-shaped boundary.

tests/run.sh 50/50, tests/run-all.sh 7/7 layers, tests/test_transpiler_diff.sh 12/12, tests/test_corpus_matrix.sh 60/60.

Known still-skipped

  • Structured concurrency under wasm (single-threaded; spawn/await needs an event loop the wasm runtime doesn't ship)
  • Full stdlib http / db (network + database deps don't lower into a standalone wasm module without a host shim)
  • Full reactive bind graph
  • A handful of jit-native-emit-path tests that don't make sense to mirror through wasm

v1.2.19

2026-05-16

Changed

  • VM dispatch loop caches the proto / chunk / consts pointer chain in locals. Every hot opcode (PUSH_CONST, LOAD_GLOBAL, LOAD_FIELD, METHOD_CALL, every arith and compare) used to read frame->closure_val->cl->proto->chunk.consts to reach the constants pool, four indirections per dispatch. The chain is now cached at the top of vm_dispatch and refreshed on the four places frame can change: try-catch unwind, CALL push, RETURN pop, throw slow path. NULL slots are safe; only the opcodes that read them hold a valid frame and they re-read after frame mutations. __builtin_expect hints on the inline-cache cold branches keep the optimiser from predicting the miss case.

Measured on a Linux x86_64 box, best of three runs through tests/bench_backends.sh:

benchbeforeafter
fib_calls180 ms138 ms
loop_sum190 ms144 ms
string_build22 ms17 ms
bench_sort18 ms15 ms

interp / jit numbers unchanged. Conformance, transpiler diff, regression corpus, and the strict cross-backend matrix all still pass.

Fixed

  • C transpile: `exit` and `abort` no longer get rewritten to `xs_user_exit` / `xs_user_abort`. Both names were in the libc-clash table that prefixes user identifiers with xs_user_ to avoid redeclaring a libc symbol. For exit/abort the rename was wrong: the libc one is exactly what the user wants, and the prefixed name resolved to nothing at link time. The renamer skips both now and the call lowering wraps them in a comma expression so they stay valid xs_val expressions. bug048 (@every / @delayed scheduling) and bug052 (@once composition) now pass on --emit c; the wasm and js paths still need decorator scheduling lowered separately.

v1.2.18

2026-05-16

Fixed

  • C emit: overloaded fn wrappers collided. fn calc(x) followed by fn calc(x, y) emitted two __xs_wrap_calc definitions, which the C linker rejected as a redefinition. The wrapper now appends an arity suffix (__xs_wrap_calc_1, __xs_wrap_calc_2) when there's more than one decl with the same name. Forward declarations match.
  • C emit: dynamic method-call on a map silently picked the wrong target. obj.handle(req) where obj is a map carrying a handle: fn(req) field was lowering to handle(obj, req), which then collided with the unrelated handle keyword in the emitted runtime and never resolved the closure. When the method name doesn't match a known top-level fn, the fallback now does the dynamic dispatch the interpreter does: look up the field via xs_index, invoke through xs_call.
  • JS emit: multi-arg `.concat` / `.extend` dropped tail arguments. [1].concat([2], [3], [4]) returned [1, 2] because the lowering only forwarded args[0]. Now spreads the full args list.

Test coverage

The strict cross-backend matrix that caught the v1.2.14 divergences now runs over the full tests/regression/ corpus, not just the conformance suite plus a handful of hand-rolled probes. Each of the 60 regression files is run through interp / vm / jit / --emit c (-O0 and -O2) / --emit js / --emit wasm, with stdout and exit code byte-diffed against the interp baseline. Wired into tests/run.sh as section 6, so every push exercises it.

46 regression files carry -- skip-emit: <backends> markers with TODO: notes that name the gap a transpiler would need to close. Notable clusters: the WASM AOT path lacks value_add for arrays / range method dispatch / runtime-error throw lowering / the array-method tail (.find_index / .flat etc.); --emit c and --emit js don't lower stdlib modules (db, http, fs, os, time, collections, process) or any of the scheduling decorators (@every, @cron, @delayed, @watch, @bench, @example); --emit c only lowers single-shot effects. None of these are pretending to be features; they're durable TODOs that sit next to each test, so the test will start passing on the matrix the moment the gap closes.

v1.2.17

2026-05-15

Fixed

  • `xs upgrade` now works on Windows. Previously the Windows path printed "not supported in this build" and bailed because Windows refuses to overwrite a locked, running .exe. The upgrade flow now renames xs.exe to xs.exe.old (Windows allows renaming a locked file even though it can't be overwritten), drops the new binary into the original path with the SHA-256 checked against the published .sha256, and the next xs launch opportunistically deletes .old. Already-open shells continue running the old version until they're restarted.
  • `xs uninstall` on Windows uses the same rename trick with a MOVEFILE_DELAY_UNTIL_REBOOT fallback for the rare case where another process (typically an antivirus scanner) still holds a handle on xs.exe.old after the first delete attempt.

Changed

  • The non-WASI bodies of xs_self_upgrade and xs_self_uninstall now share a single implementation; only the platform-specific helpers (temp_dir, unique_temp_file, atomic_replace, make_room_for_replace, rm_rf) live behind #if defined(_WIN32). Linux / macOS behaviour is unchanged.

Test coverage

tests/unit/self_test.c exercises sibling-path composition and the rename trick end-to-end. On Windows it asserts the install slot is freed and the .old sibling holds the original bytes; on POSIX it asserts the no-op semantics. Wired into make test-unit. Cross-compiled to a Windows binary and verified under Wine; a real Windows smoke test on a release build is the missing final step.

v1.2.16

2026-05-15

Added

  • Compile-time purity inference. Every function and lambda gets a static purity verdict from a fixpoint pass over the program. The verdict is queryable at runtime via __pure?(f) (returns true only when the body is statically pure: reads its params, calls only pure functions, no I/O, no spawn / async / await, no perform, no mutating method on a non-local receiver, no bind).
  • `@memoize` and `@retry` now gate on purity. Both decorators replay or skip the body, which silently drops side effects when the body isn't pure. Decorating an impure function throws PurityError at decoration time with the function's name in the message. @trace and @timed don't gate, since they delegate the call exactly once.

Changed

  • `.xsc` bumped to v3. Each proto carries a one-byte purity flag. v2 readers still load (the flag is treated as 0 / impure), so older bytecode files don't bounce.
  • LANGUAGE: Temporal Primitives section removed. The deprecated every 1s { ... } / after 500ms { ... } / timeout 2s { ... } else { ... } / debounce 300ms { ... } block forms parsed out a while back. The only surviving form is the decorator (@every, @delayed, @cron, @watch), which the Decorators section already covers. The xslang.org docs also got their temporal page rewritten to teach the decorator forms directly.

Known limitations

  • --emit wasm lowers __pure?(f) to a degenerate stub that always returns false. The closure cell is fixed at 12 bytes and the extra slot is already used by the env pointer; a per-fn purity table through the wasm module didn't earn its keep here. interp, vm, jit, --emit c (-O0 and -O2), and --emit js all read the bit correctly.
  • Closures that capture an outer-fn local and mutate it through a returned closure are reported as impure, even when the captured value never escapes (full escape analysis is a follow-up).
  • resume inside a handler body is treated as impure since multi-shot continuations are observably non-deterministic. handle itself stays pure if its body is pure and doesn't resume.

v1.2.15

2026-05-14

Fixed

  • Transitive closure capture in C and WASM. A closure that captured from a closure that captured from yet another scope only saw the innermost level; the C path failed to compile (__box_x undeclared), the WASM path returned the wrong value silently. Both transpilers' free-variable collectors now recurse into nested lambda bodies and forward captured names through the env all the way through the chain.
  • Enum match in WASM. Variant constructors with arguments (e.g. Maybe::Some(42)) silently produced no output. Variants now lower uniformly to [tag, args...] arrays and the pattern matcher dispatches on arr[0].
  • Bigint literal in C. A literal like 99999999999999999999 was being lowered to XS_INT(...) and truncated to whatever fit in int64_t. It now lowers to XS_BIGINT("...") like the WASM path does.
  • C transpiler bigint detection at `-O2`. The overflow checks the C path emitted relied on signed-integer-overflow being defined, which -O2 treats as undefined behaviour and optimises away. Switched to __builtin_add_overflow / __builtin_mul_overflow (correct under any optimisation level on gcc/clang) with a defined-arithmetic fallback for other compilers. 10 ** 30 now promotes to bigint cleanly whether you build with -O0 or -O2.

Test coverage

A strict cross-backend matrix now runs every conformance test (and a hand-rolled probe set covering closures, enums, bigint, generators, traits, etc.) through every backend (--interp, --vm, --jit, --emit c at -O0 and -O2, --emit js, --emit wasm) and compares output against interp byte-for-byte. v1.2.14 passed at the conformance-suite layer but had divergences once you stepped outside it; the matrix is what surfaced these.

v1.2.14

2026-05-14

Added

- C transpiler now runs the full 17-test conformance suite end-to-end (`xs --emit cgccrun`), matching interpreter / VM / JIT byte-for-byte. Previously 7 of 17 tests passed; the rest either refused, failed to compile, or diverged.
  • async / await / spawn / nursery strip to plain functions and direct calls, so a single-threaded compiled program runs the same observable behaviour as the scheduler-backed runtime.
  • Cross-file use "./mod.xs" (with as rename and selective destructure) on the C path, mirroring the JS and WASM implementations.
  • Bigint via decimal-string add / mul / pow with overflow promotion, so 9223372036854775807 + 1 lands on 9223372036854775808 instead of wrapping.
  • Tolerant assert_eq for chained float arithmetic, the same relative + absolute slack the native runtime uses.

Changed

  • Nested fn declarations (fn inc() {...} inside another fn) get rewritten to let inc = fn() {...} so closures over the enclosing locals work, including mutual recursion.
  • try / catch lifts to an expression form so the arm value propagates the way it does under interp.
  • defer runs on both throw and return, not just normal fall-through.
  • Struct match patterns (Point { x, y }) recognise instances by name because the constructor tags __type__. Trait default methods get copied onto every impl that doesn't override.
  • arr.reduce(init, fn) matches the interp signature instead of expecting reduce(fn, init).
  • Slice rest patterns ([head, ..tail]) bind the tail correctly.
  • main is renamed to __user_main so program-level test code (which would normally vanish when there is a user main) still runs through the auto-synthesised entry point.

Fixed

  • xs --emit c segfaulted on use statements pointing at relative file paths.
  • c14_async's emit produced a duplicate fetch_val definition under the async-unwrap pre-pass.

Every backend (--vm, --interp, --jit, --emit c, --emit js, --emit wasm) is now at 17/17 conformance. First time they're all at parity.

v1.2.13

2026-05-13

Added

  • xs --emit wasm now runs the full 17-test conformance suite end-to-end through wasmtime, matching the interpreter / VM / JIT byte-for-byte. Previously only basic arithmetic and control flow worked; everything else either silently produced wrong output or trapped with wasm 'unreachable'.
  • Stdlib bridge for import math / json / fs / time in the AOT path. Each module becomes a synthesised map of closures wrapping runtime helpers.
  • Cross-file use "./mod.xs" (with as rename and selective destructure) on the AOT path, mirroring the VM and JS implementations.
  • Generators (fn*, yield, g.next()), async / await, spawn, nursery, algebraic effects (perform / handle / resume), and reactive bind all lower to working WASM (sync resolution for async / spawn since a freestanding module has no scheduler).
  • Higher-order array methods (.map, .filter, .reduce, .fold, .each, .some, .every, .find, .sort_with, .flat_map, .group_by, .partition, .sum, .product, .min_by, .max_by, .count).
  • Codepoint-aware string ops (.chars, .lines, .lower, .trim, .split, .replace, .sort, UTF-8 .len).
  • Bigint with arbitrary precision in the AOT path, so int overflow promotes the same way the runtime does.
  • Tolerant assert_eq so chained float arithmetic (3.14 * r * r summed across shapes) still matches a literal expected value.

Changed

  • Closure variables that get mutated inside a closure now write back through the captured env so subsequent calls see the update. Previously n = n + 1 inside fn() { ... } wrote to a fresh local and the captured n stayed at its initial value.
  • Map set updates the existing key in place rather than appending a duplicate entry. Previously the next get would return the stale first match.
  • Map keys compare by content (via RT_VAL_EQ followed by RT_VAL_TRUTHY), not by underlying data pointer. Two literally equal "x" strings allocated at different sites now hash to the same slot.
  • Struct match by type name (Point { x, y }) tags instances with __type__ so the pattern can recognise them. Class declarations get the same field as a public class field.
  • String.len counts UTF-8 codepoints instead of bytes ("cafรฉ".len() == 4).
  • String equality compares bytes byte-by-byte ("a" ++ "b" == "ab" is now true).
  • Indirect closure calls (fns[i]()) sniff the closure-env field at the call site and use the right call_indirect signature, so closures returned from arrays no longer trip "indirect call type mismatch".
  • Trait default methods get copied onto every impl that doesn't override them.
  • Mutually recursive nested fns and named nested fns (fn inc() {...} inside another fn) now compile correctly, with defer and closure capture across them.

Fixed

  • RT_VAL_INDEX_SET only handled arrays; map index assignment (m["k"] = v) silently no-op'd. It now dispatches on the value tag.
  • .starts_with was off by one on the comparison length.
  • Deep array equality recurses through nested elements rather than comparing pointers.

v1.2.12

2026-05-12

Added

  • JS transpiler now inlines cross-file use "./mod.xs" and respects the as rename, namespace alias, and selective destructure (use "./m.xs" { foo, bar as baz }). Previously the import was silently dropped and any reference to the namespace blew up at runtime.
  • Trait default methods get copied onto every impl that doesn't override them, so square.name() returning "shape" works the same under --emit js as under the interpreter.
  • assert_eq in the JS prelude does the same tolerant float compare as the native runtime, so chained arithmetic across shapes still matches a literal expected value (e.g. 12.56 + 9.0 == 21.56).

Changed

  • Struct match patterns recognise instances by name because the constructor tags this.__type__; class declarations get the same field as a public class field.
  • Field reads on missing keys (__xs_field) return null instead of undefined so XS-style m.k == null checks match the runtime.
  • The prelude's builtin range lives on globalThis so a user-declared fn* range doesn't trip Identifier 'range' has already been declared.
  • Top-level await inside an assert_eq argument auto-wraps the assertion IIFE as an awaited async function, so the inner await isn't a syntax error.
  • Effect handlers that don't call resume terminate the handle block with the arm's value instead of looping forever.
  • Exhaustiveness analyser stopped flagging (a, b) and Point { x, y, .. } as non-exhaustive; both are catchall for their shape.

Fixed

  • --emit js no longer crashed seven of the conformance tests. All 17 now pass through Node end-to-end.
show older releases

v1.2.11

2026-05-12

Added

  • export { name, name as alias, ... } -- a file's public surface, in

one place. Goes at the top level of the file, names can be aliased with as. Without an export list, every top-level binding is exposed (scripts stay zero-ceremony).

  • Cross-file use "file.xs" works on the VM backend. Lowers to a

__use_file native that compiles the imported file with the bytecode compiler and runs it on a child VM, so closures from the module are real XS_CLOSURE values the parent VM can invoke.

  • Module-qualified struct construction: lib.Point { x: 3, y: 4 }

builds an instance instead of erroring.

  • @deprecated("msg") actually emits a W0001 warning at every call

site (was parsed and silently dropped).

Changed

  • C transpiler: per-arm effect handler dispatch, comparator support in

arr.sort_with, predicate dispatch on find / index_of, del tombstone semantics, map-spread, structured runtime errors.

  • JS transpiler: bare-builtins prelude, range methods, tuple-vs-array

shape correction, del tombstone, explicit refusals for bind and wrapping decorators.

  • Build: every .o / .d routes through build/obj/, source tree

stays clean. make clean also sweeps any stray root artefacts.

Removed

  • pub modifier and @export decorator. Using either is now a P0054

parse error pointing at export { ... }. The export list is the one and only mechanism.

Fixed

  • VM cross-file use was a no-op (silently dropped the import).
  • @deprecated warnings never fired.

v1.2.10

2026-05-10

Added

  • xs upgrade downloads the latest release and atomically replaces the running binary. Verifies SHA-256 before swapping. Use --yes to skip the prompt.
  • xs uninstall removes the binary; with --with-data it also clears ~/.xs and ~/.xs_cache.
  • xs repl (and bare xs with no args) drops into an interactive read-eval loop. Bindings persist across lines, multi-line input is detected via bracket / string state, parse errors no longer kill the session. Meta-commands: :help, :quit, :env, :clear, :t <expr>.

Changed

  • assert_eq raises a catchable AssertionError instead of calling exit(1). try { assert_eq(...) } catch e { ... } now works.
  • import log no longer collides with the math log builtin; the stdlib module loads correctly.
  • [1, 2, 3].sorted() works as an array method on the VM (was interpreter-only).
  • collections.Set.has(x) works for ints, floats, bools, and strings.
  • reflect.type_of(value) returns the struct or class name for instances built via either backend.
  • xs --help lists upgrade, uninstall, publish, and search.
  • VS Code extension icons regenerated in the new sage-on-dark style.

Removed

  • Inline temporal block statements: every 1s { ... }, after 5s { ... }, timeout 1s { ... }, debounce 100ms { ... }. They never actually scheduled the body. Use the decorator forms (@every(1s), @after(5s), @cron(...)) on a function instead.

Fixed

  • VM del x tombstones the local slot; subsequent reads throw a runtime error (was silently rebinding to null and skipping any try / catch).
  • Struct match patterns reject values of the wrong type instead of binding fields to null. match shape { Circle { radius } => ... } no longer fires on a Rect.
  • macOS build errors under Apple Clang: -Wenum-conversion in lint.c, -Wenum-compare-conditional across the parser, and missing <sys/select.h> on POSIX. The release link step also stops passing GCC-only flags to Apple's linker.
  • Linux CI fuzz step no longer blocks the run; reproducer artifact still uploads on findings for offline triage.

v1.2.9

2026-05-10

v1.2.8

2026-05-09

v1.2.7

2026-05-09

v1.2.6

2026-05-09

v1.2.5

2026-05-09

v1.2.4

2026-05-09

v1.2.3

2026-05-09

v1.2.2

2026-05-09

v1.2.1

2026-05-06

v1.2.0

2026-05-06

v1.1.1

2026-05-06

v1.1.0

2026-05-05

v1.0.3

2026-05-04

v1.0.2

2026-05-03

v1.0.1

2026-05-02

v1.0.0

2026-04-26