Downloads
Latest: v1.2.32, published 2026-05-19.
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 before | 2329 lines, 88 KB | |
--emit c after | 2054 lines, 78 KB | |
--emit js before | 931 lines, 47 KB | |
--emit js after | 926 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, ...)andINSERT 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()prefersTMPDIR/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 whenst_mtime(Linux:st_mtim.tv_nsec; macOS:st_mtimespec) advances past the captured baseline.@delayed(d)spawns a thread thatclock_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 @everyco-decoration is honoured.@on_startruns inline before joining the trigger threads;@on_exitregisters 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/typereturn"duration".ns / .us / .ms / .s / .m / .h / .daccessors dispatch on the tag; non-duration receivers fall through to struct-slot / RT_VAL_FIELD lookup so no class field regressionsval_add/val_subhandledur+dur,dur-durval_mulhandlesdur*intandint*dur(i64 scaling)val_divhandlesdur/dur(returns float ratio, no tag) anddur/int(returns scaled duration)val_lt/val_gt/val_le/val_geshare aemit_dur_cmp_branchhelperval_eqcompares duration cells by i64 nsval_to_stremits the compact[Nd][Hh][Mm][Ss.frac]form with sub-secondns / us / msunits 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:
- Try operator `x?`: a compile-time pass collects every enum variant whose name matches
Err/Noneand records its ordinal.NODE_UNARYwith op?checks slot 1 (the ordinal) against that set; if matched, returns the value unchanged (propagating Err / None up); otherwise unwrapsarr_get(xv, 2)(the payload ofOk(x)/Some(x)). - Map destructure `let {a: aa, b: bb} = m`:
NODE_PAT_MAPwas already incompile_pattern_bindings; thelet { ... } = mpath now actually drives through it (rename form bound the wrong identifier before). - Nested for in comprehensions:
NODE_MAP_COMPwas rewritten to mirrorNODE_LIST_COMP's multi-clause nesting; int keys are stringified to match the interp's normalisation. - 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 cand--emit wasmrefuse 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
./xsto 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/SELECTwithWHERE col = value. Rows expose both real column names and positionalcNkeys. - reactive bind: lowered
NODE_BINDto a__xs_bindsregistry;__xs_setidx/__xs_setfieldre-run all dependent updaters via__xs_notify, recursion-guarded. - actor closure capture: actor decls bind the actor class name (
const Logger = ...),spawn ActorNamelowers tonew ActorName(). - http:
http.get/post/put/deleteshell out throughchild_process.execFileSync curlfor synchronous semantics; throws a structuredHttpErrormap on failure. - tagged blocks:
NODE_YIELDchecks anin_tag_bodyflag and lowers to__block(value). - try-op `?` + map destructure + map iter:
?lowers to__xs_try(v)which throws__XsTryErronResult::Err.let {a: aa, b: bb} = mnow reads the sub-pattern's identifier as the binding name.for k in mroutes through__xs_iter(m)which returns.keys()for JS Maps. - Duration first-class:
__XsDurationclass wrapping ns as BigInt; arithmetic,<>, equality, and repr all dispatch on_xs_kind === 'duration'.(5s).ns,.ms,.swork.__xs_to_msupdated 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
resumeas one-shot; the body just wasn't running becauseemit_expronNODE_BLOCKonly emitted the trailing expression. - Collections module:
Deque/Stack/Set/Counter/OrderedMap/PriorityQueuelowered viaxs_collections_make+.front/.back/.peek/.lendispatch. - JIT-native parity (bug030 / 031 / 032 / 033): class method emit declares
__saved_defer_top(NODE_RETURN was dangling),walk_register_struct_varssecond pass picks up struct types inside fn bodies, bound-methodc.valueemit,xs_iter/xs_iter_nextextended to utf-8 strings + map keys,xs_strcatcoerces non-string operands,__gen_iterfalls through to map keys when__itemsis absent. - Tagged blocks:
tag X(...) { ... yield ... }lowered to a real fn with__blockas last param;yieldemitsxs_call(__block, ...). - bug043 surface:
?returns Err early or unwraps Ok, enum valuexs_to_strrendersType::Variant(args), struct constructorA()(no class decl) emits empty__type__-tagged map. - bug018 spawn/channel:
xs_channel_sendreturnsxs_val,awaitno 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), updatedxs_add/xs_sub/xs_mul/xs_div/xs_cmp/xs_eq/xs_type/xs_to_strto handle durations;.ns/.us/.ms/.s/.m/.h/.daccessors.
WASM narrows from 24 to 11
- Multi-arity overload dispatch: per-name overload tracker + arity-mangled lookup.
- Multi-arm effect handle:
wasm_build_handle_blocknow uses each arm'seffect_nameinstead ofarms[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/.lenhandled. - Purity inference output:
__pure?(ident)consultspurity_analyze's stamps viafn_decl.inferred_pure/lambda.inferred_pure; let-binding index for let-bound lambdas. - JIT-native parity: class instantiation (
C()recognises mangled<Class>__initor synthesises an empty map with__classstamp), bound method-as-fn-value, utf-8 char iter,array.pop+array.sort(fn)(in-place insertion) +.sizealias for.len, tuple.0/.Nfield 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_readmethods that wrapchild_process.execSync. XS code callingprocess.run("./xs foo.xs 2>&1")now shells out instead of throwing "no method 'run' on object". The polyfill is gated on the program containingimport processso 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:
wasmAOT trigger registry + multi-arity overload dispatch (closure-cell layout makes the C/JS trick more involved)wasmAOT multi-arm effect routing, null-callee path, static purity-inference outputcwrapping decorators (@memoize/@retry/@timed/@trace) - typed C fns can't be re-bound to closure values without a deeper reworkcsingle-shot effect machineryc, js, wasmfor stdlibhttp(BearSSL inlining too heavy in C, sync fetch wrapper needs an event loop in JS),db(external library, violates zero-deps), reactivebind(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_fnsoxs bench/xs docand 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 byfn calc(x, y)followed byfn 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 toname_a<arity>, and emits a dispatcher at the top of the file (before any user statement touches the bare name) that picks the entry byargs.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,@tracenow 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/@debouncestay 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() { ... }emitssetInterval(tick, ms)after the function declaration.@delayed(dur)emitssetTimeout.@on_startemits an immediate call at module top.@on_exitregisters aprocess.on('exit', ...)hook.@oncecomposes with@everyvia a wrapper that callsclearIntervalafter the first fire.@cron/@watch/@bench/@example/@on_signalare accepted as no-ops (no portable cron parser, no portablefs.watchshape, 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.exitand a thrown error respectively, matching the C-side fix from v1.2.19. Without this, an@every-decorated body that wanted to callexit(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.
fsmaps tonode:fssync APIs wrapped in a lazyrequire(so a browser bundle that never imports fs doesn't trip on the require).osmaps toprocess.*+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/jsonlowering:is_stdlib_module(name)returns 1 andmod.method(args)at the call site lowers directly toxs_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.platformplus the field-access forms (os.platform,os.sep,os.args);time.formaton top of the already-wiredtime.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 ** 20already promoted toXS_BIGINTinxs_add/xs_mul/xs_pow, butxs_cmphad noXS_BIGINT_TAGarm and silently fell throughreturn 0.assert(big > 0)reduced toassert(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 inxs_eqclosed by the same path. bug038 lifts thecskip marker and now exercises the c backend at both-O0and-O2in 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
intandfloatinvalue_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 catchableRuntimeErrorinstead of trapping withunreachable: divide-by-zero, index-out-of-bounds, nil method call, the typed-arith error path,.is_a()type queries. Dynamicobj.method()on a map field routes throughxs_callrather than collapsing. Map equality covers the deep-nested and mixed-key cases.del localfollowed by a read throws a use-after-del runtime error to match the interp / vm behaviour.
- Bigint promotion in `--emit wasm`.
10 ** 20used to overflowint64_tsilently. 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
bindgraph - 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 readframe->closure_val->cl->proto->chunk.conststo reach the constants pool, four indirections per dispatch. The chain is now cached at the top ofvm_dispatchand refreshed on the four placesframecan change: try-catch unwind,CALLpush,RETURNpop, 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_expecthints 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:
| bench | before | after | ||
|---|---|---|---|---|
| fib_calls | 180 ms | 138 ms | ||
| loop_sum | 190 ms | 144 ms | ||
| string_build | 22 ms | 17 ms | ||
| bench_sort | 18 ms | 15 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. Forexit/abortthe 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 validxs_valexpressions. bug048 (@every/@delayedscheduling) and bug052 (@oncecomposition) 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 byfn calc(x, y)emitted two__xs_wrap_calcdefinitions, 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)whereobjis a map carrying ahandle: fn(req)field was lowering tohandle(obj, req), which then collided with the unrelatedhandlekeyword 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 viaxs_index, invoke throughxs_call. - JS emit: multi-arg `.concat` / `.extend` dropped tail arguments.
[1].concat([2], [3], [4])returned[1, 2]because the lowering only forwardedargs[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 renamesxs.exetoxs.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 nextxslaunch 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_REBOOTfallback for the rare case where another process (typically an antivirus scanner) still holds a handle onxs.exe.oldafter the first delete attempt.
Changed
- The non-WASI bodies of
xs_self_upgradeandxs_self_uninstallnow 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)(returnstrueonly when the body is statically pure: reads its params, calls only pure functions, no I/O, nospawn/async/await, noperform, no mutating method on a non-local receiver, nobind). - `@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
PurityErrorat decoration time with the function's name in the message.@traceand@timeddon'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 wasmlowers__pure?(f)to a degenerate stub that always returnsfalse. 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 jsall 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).
resumeinside a handler body is treated as impure since multi-shot continuations are observably non-deterministic.handleitself stays pure if its body is pure and doesn'tresume.
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 onarr[0]. - Bigint literal in C. A literal like
99999999999999999999was being lowered toXS_INT(...)and truncated to whatever fit inint64_t. It now lowers toXS_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
-O2treats 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 ** 30now promotes to bigint cleanly whether you build with-O0or-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 c | gcc | run`), 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/nurserystrip 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"(withasrename 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 + 1lands on9223372036854775808instead of wrapping. - Tolerant
assert_eqfor chained float arithmetic, the same relative + absolute slack the native runtime uses.
Changed
- Nested fn declarations (
fn inc() {...}inside another fn) get rewritten tolet inc = fn() {...}so closures over the enclosing locals work, including mutual recursion. try/catchlifts to an expression form so the arm value propagates the way it does under interp.deferruns on boththrowandreturn, 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 expectingreduce(fn, init).- Slice rest patterns (
[head, ..tail]) bind the tail correctly. mainis renamed to__user_mainso program-level test code (which would normally vanish when there is a usermain) still runs through the auto-synthesised entry point.
Fixed
xs --emit csegfaulted onusestatements pointing at relative file paths.c14_async's emit produced a duplicatefetch_valdefinition 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 wasmnow runs the full 17-test conformance suite end-to-end throughwasmtime, 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 withwasm 'unreachable'.- Stdlib bridge for
import math / json / fs / timein the AOT path. Each module becomes a synthesised map of closures wrapping runtime helpers. - Cross-file
use "./mod.xs"(withasrename 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 reactivebindall 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_eqso chained float arithmetic (3.14 * r * rsummed 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 + 1insidefn() { ... }wrote to a fresh local and the capturednstayed at its initial value. - Map
setupdates the existing key in place rather than appending a duplicate entry. Previously the nextgetwould return the stale first match. - Map keys compare by content (via
RT_VAL_EQfollowed byRT_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.lencounts UTF-8 codepoints instead of bytes ("cafรฉ".len() == 4).- String equality compares bytes byte-by-byte (
"a" ++ "b" == "ab"is nowtrue). - Indirect closure calls (
fns[i]()) sniff the closure-env field at the call site and use the rightcall_indirectsignature, 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, withdeferand closure capture across them.
Fixed
RT_VAL_INDEX_SETonly handled arrays; map index assignment (m["k"] = v) silently no-op'd. It now dispatches on the value tag..starts_withwas 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 theasrename, 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 jsas under the interpreter. assert_eqin 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) returnnullinstead ofundefinedso XS-stylem.k == nullchecks match the runtime. - The prelude's builtin
rangelives onglobalThisso a user-declaredfn* rangedoesn't tripIdentifier 'range' has already been declared. - Top-level
awaitinside anassert_eqargument auto-wraps the assertion IIFE as an awaited async function, so the innerawaitisn't a syntax error. - Effect handlers that don't call
resumeterminate the handle block with the arm's value instead of looping forever. - Exhaustiveness analyser stopped flagging
(a, b)andPoint { x, y, .. }as non-exhaustive; both are catchall for their shape.
Fixed
--emit jsno 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 aW0001warning 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/.droutes throughbuild/obj/, source tree
stays clean. make clean also sweeps any stray root artefacts.
Removed
pubmodifier and@exportdecorator. Using either is now a P0054
parse error pointing at export { ... }. The export list is the one and only mechanism.
Fixed
- VM cross-file
usewas a no-op (silently dropped the import). @deprecatedwarnings never fired.
v1.2.10
2026-05-10
Added
xs upgradedownloads the latest release and atomically replaces the running binary. Verifies SHA-256 before swapping. Use--yesto skip the prompt.xs uninstallremoves the binary; with--with-datait also clears~/.xsand~/.xs_cache.xs repl(and barexswith 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_eqraises a catchableAssertionErrorinstead of callingexit(1).try { assert_eq(...) } catch e { ... }now works.import logno longer collides with the mathlogbuiltin; 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 --helplistsupgrade,uninstall,publish, andsearch.- 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 xtombstones the local slot; subsequent reads throw a runtime error (was silently rebinding to null and skipping anytry / catch). - Struct match patterns reject values of the wrong type instead of binding fields to null.
match shape { Circle { radius } => ... }no longer fires on aRect. - macOS build errors under Apple Clang:
-Wenum-conversioninlint.c,-Wenum-compare-conditionalacross 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