A typed language compiler targeting WebAssembly with wasm-encoder.
See the compilation roadmap for the architecture, host ABI, milestones, and acceptance criteria. The memory ABI documents string/array layouts, allocation, ownership, and host examples. Its revision 1 baseline records memory-contract coverage and validation; resizing and reclamation remain future roadmap work.
The virtual workspace separates the portable compiler from its adapters:
| Package | Location | Responsibility |
|---|---|---|
was-compiler (was_compiler) |
crates/compiler/ |
Lexer, parser, semantic analysis, checked Wasm lowering, public Rust API |
was-compiler-cli (binary was-compiler) |
crates/cli/ |
Native arguments, filesystem I/O, WAT/binary output |
was-compiler-browser |
crates/browser/ |
wasm-bindgen JavaScript adapter |
Compiler stages remain internal modules. Root Cargo commands select compiler and
CLI by default; browser validation is explicit. Shared fixtures and subprocess
support remain in tests/, while integration tests live with their owning package.
Rust consumers keep using was_compiler; local path dependencies now point to
crates/compiler. Install the CLI with
cargo install --offline --path crates/cli, rather than --path ..
cargo run --offline -- examples/array-sum.was -o target/array-sum.wasm --export main
node runtime/run.cjs target/array-sum.wasm
The runner prints 65. A source file is required; no arguments produce a usage
error (exit 2). --help exits 0 and lists source-file, output-format, export,
and host options. - is a literal source filename, not stdin.
The Node harness uses built-in modules only and requires no npm installation.
- Runtime integration: CLI usage, host providers, export selection, raw Wasm calls, explicit marshaling, and diagnostic stages.
- Limitations and alternatives: current restrictions, available workarounds, and possible future solutions with tradeoffs.
The dedicated crates/browser/ workspace crate (was-compiler-browser) builds the
compiler library as WebAssembly. Compile and execute source locally in the
dependency-free browser playground:
rustup target add wasm32-unknown-unknown
wasm-pack build crates/browser --target web --out-dir ../../pkg --out-name was_compiler --mode no-install --no-opt --offline
Run the command from the project root; ../../pkg is relative to crates/browser/,
and --out-name was_compiler preserves the demo's generated module paths.
There is no root browser feature; compiler and CLI are the default workspace
members. Install wasm-pack if needed (cargo install wasm-pack --locked).
The build uses release mode by default; offline builds require cached dependencies
and available tooling. Serve the project root over HTTP (for example,
python -m http.server 8000) and open
http://localhost:8000/examples/browser/ rather than a file: URL.
The demo compiles and runs in a terminable worker, displays diagnostics and plain
WAT, and downloads raw Wasm. See the browser guide for the
JavaScript Compiler API, host imports, configuration, memory boundaries, and
MIME/CSP requirements. Browser use does not depend on runtime/*.cjs and does not
support cwasm artifacts.
The compiler emits valid modules containing registered function imports,
parameters and locals of every nonvoid language type, variable reads and
assignments (including numeric compound updates), expression statements, returns,
user/host function calls, if/else, and while / range/array for with
break; / continue;. Strings, number[]/bool[]/string[], checked array access/assignment, and built-in
len are supported. Empty void functions and bare return; statements work too.
Calls can be forward, nested, self-recursive, or mutually recursive; arguments
are evaluated left-to-right in written order. User functions also accept named
arguments, including reordering and an initial positional prefix:
fn subtract(a: number, b: number) -> number { return a - b; }
fn main() -> number { return subtract(b: 2, a: 9); }
After the first named argument, all remaining arguments must be named. Every required parameter must be supplied exactly once, with its exact type. User parameters may have trailing defaults resolved against earlier formals:
fn total(base: number, extra: number = base + 1) -> number {
return base + extra;
}
fn main() -> number { return total(3); }
Explicit arguments are captured once in written order; omitted defaults run
afterward in formal order on each invocation. Defaults allow scalar literals,
earlier parameter reads, existing unary/arithmetic/comparison/equality/logical
operators (including string/array equality), and positional builtin len(array).
Indexing, array/range construction, membership, blocks/if/loops/match, writes, and
user/host calls are forbidden, including in skipped logical operands. Every
declaration is checked even when unused or explicitly supplied. Pure defaults
perform no writes or runtime allocation but may read shared arrays or trap.
Raw exports still require every machine argument; source defaults do not add
host-side argument generation.
Named host/builtin arguments remain unsupported. WP6 named/default parameters are implemented, and
#13 release validation passed.
The development handoff records historical worktree
evidence; see the
current named/default interface and AST migration.
Issues #11/#14 implement function-result inference, including acyclic chains and
diamonds, after the WP6 gate:
fn square(x: number) { return x * x; } // inferred number
fn main() -> number { return square(4); }
Reachable explicit returns must agree exactly. Procedures without value-return
syntax infer void; Never-only value-return syntax needs an annotation. Recursive
components require explicit results on every member; annotated barriers do not
hide recursive omitted results. Acyclic inferred-to-inferred calls resolve in
stable dependency order, choosing source order among currently ready functions.
Owned typed signatures stay concrete and function/layout order stays source-ordered.
There are no implicit function tails.
See the #14 inference contract.
#15 combined-feature acceptance
covers named/default calls through inferred chains and diamonds, including effects,
transfers, and checked typed input. WP7's native/browser release gates passed;
see #16 release evidence.
The same inference rules apply through the CLI and freshly rebuilt browser adapter.
The runnable named/default diamond returns 584:
cargo run --offline -- examples/inferred-defaults.was -o target/inferred-defaults.wasm --export main
node runtime/run.cjs target/inferred-defaults.wasm
Inferred results retain the explicit ABI: numbers are f64, bools are i32 with
normalized host boundaries, and strings/arrays use existing descriptor handles.
Raw exports retain full arity; private range signatures cannot be host imports or
selected exports. Raw Rust AST consumers wrap explicit Function.return_type
annotations in Some(type); None requests inference. Typed results remain concrete.
WP8 function preconditions are implemented:
fn divide(a: number, b: number = 1) -> number
requires b != 0
{
return a / b;
}
fn main() { return divide(a: 12, b: 3); }
Each requires expression must be bool and uses the same narrow pure-expression
whitelist as defaults, but may read all formal parameters. Every declaration and
skipped logical operand is checked. After explicit arguments and omitted defaults
run, the callee validates all parameters, then checks contracts in declaration
order before its body, including internal, recursive, and full-arity raw export
calls. A false contract causes a standard Wasm unreachable trap; there is no
custom failure metadata, check elimination, or effect on result inference/missing
return checks. requires is reserved for source and host-language names, not host
module/field names. See the contract specification
and WP8 status and suites.
WP8 release gates passed,
including freshly rebuilt browser bindings and the full ignored runtime suite.
WP9 match statements/values are implemented with frontend/backend integrated;
release gates passed.
WP10 user-function pipeline stages support positional/named arguments, omitted trailing
defaults, and final Void stages with discarded results (#27). Implementation is
complete; release gates passed
on 2026-10-14, including current-source rebuilt-browser and full ignored runtime coverage.
WP11 local callables are implemented: capture-free declarations/calls
(#29), immediate-creator shared parameter/let captures (#30), and captured numeric
compounds (#31). #32 implements nested original-binding capture forwarding and
earlier context-requiring callable dependencies in the frontend/backend;
#32 validation passed.
#33 implements read-only range/array iteration captures;
validation passed.
Nested read-only range/array captures and transitive live callable contexts compose
with shared mutation and captured compounds. #34 adds recursion/reentry/trap lifetime
coverage. WP11 is implemented; full native/browser
release gates passed on 2026-10-15
under #35. Earlier subset evidence retains its historical scope.
#30 subset validation passed,
including rebuilt-browser and full ignored runtime coverage for #30, before #31.
#31 validation passed. WP12 placeholders and
WP13 optimization are separately scoped.
Issue #44 adds known lexical callable pipeline stages: a visible local callable receives the input as formal zero followed by positional written arguments. These stages keep the existing direct-helper/shared-capture model and add no named/default closure parameters. Implementation is committed; integrated release validation, including rebuilt-browser parity, remains pending.
Issue #45 adds directly constrained placeholder local callables:
let add = (_ + _); // two independent number parameters
let negate = !_; // one bool parameter
let same = _ == "ready"; // one string parameter
let result = 10 |> add(5);
Issue #46 propagates exact constraints through ordinary locals, annotations, assignments, array elements and basic value-if joins:
let increment = { let x = _; x += 1; x }; // number parameter
let first_plus_one = { let items = _; items[0] + 1 }; // number[] parameter
let flip_first = { let flags = _; !flags[0] }; // bool[] parameter
Inside an enclosing placeholder region, a bare _ initializer belongs to that
region; compound placeholder initializers still declare independent callables.
Concrete formal argument types also supply constraints. These declarations reuse
direct helpers and shared captures; bodies run only when invoked. _ == _ and
len(_) remain ambiguous, and callers do not infer parameter types. This is a
partial WP12 milestone, not release readiness. Issue #47 adds control-flow and
nested-region composition:
let choose = match _ { true => 1, false => 2 };
let outer = { let inner = _ + 1; loop { break inner(_); } };
Parameters are counted structurally, but only reachable constraint sites select types. Nested callables have independent return/loop contexts, and unresolved circular captures do not infer signatures. See the implemented boundary. Issue #48 adds immediate unary typed-lambda and placeholder stages:
10 |> ((x: number) => x * 2); // 20
10 |> (_ * 2); // 20
Immediate stages take exactly one parameter and no written arguments. Placeholder
parameter types must be uniquely inferred inside the stage, never from its input;
10 |> (_) is ambiguous and 10 |> (_ + _) has the wrong arity. Input runs before
the stage environment is created; original captures still use eager shared-cell
promotion. These are direct helpers, not first-class callable values or general
postfix invocation. See #48 contracts and migration.
Validation is ongoing; integrated WP12 release evidence remains pending.
The compiler core supports fixed-length mutable number[], bool[] and
string[], including typed indexing/replacement, len, content equality,
iteration, calls/results and shared captures. Each literal allocates fresh storage;
assignment and passing share handles. String-array equality compares string
contents, not handles. Compounds remain numeric-only.
fn empty() -> string[] { return []; }
fn main() -> bool {
let flags: bool[] = [];
flags = [true, false];
flags[1] = true;
let texts = ["hello", "world"];
for text in texts { if text == "world" { return flags[1]; } }
return false;
}
Annotations, destinations, mapped call formals and explicit named-function results
can type empty literals through block/if/match/loop values. Unconstrained []
remains number[]; branches, other returns and callers do not reinterpret it.
Omitted/anonymous results remain independently inferred, and pipeline inputs are
not typed backward from stages. Mixed/nested arrays, range[] and void[] are
unsupported. Defaults/contracts keep their whitelist (no literals or indexing).
#37 contracts, Rust migration and release evidence describe
Type::Array(ArrayElementType::{Number,Bool,String}), typed-tree changes and lazy
consumed-element validation. Core, CLI/browser adapters and Node marshaling are
implemented; final release gates passed
after the accessor-snapshot fix: 1,251 workspace tests passed with none ignored,
25 browser smoke groups passed, and native/browser Clippy, portable core and
rebuilt-browser checks passed. M4 comparison tables
and optimization handoff retain their
historical pre-fix runtime hash; final hashes are recorded separately. Earlier
extension release evidence remains historical.
fn main() -> number {
let twice = ((x: number) => x * 2);
let answer = () => { let nested = () => twice(21); nested() };
return answer(); // 42
}
Parameters require explicit existing concrete types. Results infer from reachable explicit returns and a completing expression/block tail, with exact joins. A no-tail block may complete only as Void; Never-only expression/tail results cannot infer a signature. Closures have their own return and loop-control contexts. Earlier visible local callables can be invoked, including through nested helpers; new declarations have no special self or forward visibility. Calls are positional, once-only and left-to-right. Ordinary named/default calls and contracts inside closure bodies retain their existing behavior.
Closures may share ancestor parameters and ordinary lets of type
number, bool, string, number[], bool[], string[] or range. Reassignment from either side is
visible to the other; ordinary indexed array writes retain alias semantics. #32
forwards each original binding through every intermediate helper, without copying
its value or allocating another cell. Earlier context-requiring callable dependencies
forward their original live environments, not snapshots. #33 allows read-only
captures of the numeric binding in range/array for loops; ordinary and all numeric
compound writes remain rejected, even in skipped/unreachable code. Each yielded
captured binding gets a fresh eight-byte cell before the body, even if the closure
declaration is skipped; empty/exhausted/failed yields allocate no cell. Cells are
monotonic, not reused or reclaimed; environments allocate only at executed
declarations. Nested helpers forward the same original read-only iteration cell;
transitive callable dependencies retain that iteration's live environment. Mutable
shadowing lets remain distinct. #31 supports +=, -=,
*=, /=, and %= on captured numbers and captured numeric-array elements,
including creator-side promoted bindings. Updates preserve the original checked
place and old value across RHS effects; ordinary indexed assignment also retains
its pre-RHS checked address.
Callables cannot be aliased, reassigned, annotated with a value type, passed, returned or used as values. General postfix invocation, closure defaults/contracts/result annotations are not supported. Issue #44 separately adds known lexical callable pipeline stages with positional-only arguments; integrated release validation remains pending. #45–#47 add placeholder locals, collections, control flow and nested regions as described above. #48 implements immediate unary pipeline stages, separately from WP11's release; integrated WP12 release validation remains pending. WP13 optimization requires separate authorization. WP11's release gates passed on 2026-10-15, including current-source rebuilt-browser and full ignored runtime coverage.
Generated helpers use direct calls, are never exported, and follow existing runtime
helpers. Context-free callables add no environments, hidden arguments or memory
solely for callable support; ordinary strings/arrays/ranges may still need memory.
Captured bindings use eager shared cells and declaration-time environments with
private uniform eight-byte storage: canonical capture slots precede slots for
context-requiring dependencies; context-free dependencies occupy no slots.
A helper needing either kind of context receives a hidden first i32 argument.
Allocation may grow memory or trap, is monotonic,
and does not roll back earlier effects. Hosts must refresh views after possible
memory growth. See the private storage contract
and the public AST migration and capture contracts,
including the new owner-qualified TypedExprKind::Captured variant.
#34 lifetime coverage
exercises distinct recursive/reentrant contexts, post-trap calls and persistent
array/string handles through actual memory growth; no reclamation or rollback is added.
fn add(a: number, b: number = a) { return a + b; }
fn double(x: number) { return x * 2; }
fn main() { return 10 |> add(b: 5) |> double; } // 30
fn defaults() { return 10 |> add; } // 20
fn sink(x: number) -> void {}
fn discarded() { 10 |> add |> sink; } // final Void result is discarded
|> associates left and binds below logical OR. Named user-function stages retain
WP10 binding:
input binds to formal 0 even when it has a default; naming that formal is a duplicate.
Written arguments start at formal 1 and support a positional prefix followed by
named arguments, with exact types. Omitted trailing defaults run in formal order
after all written arguments. Bare value |> add and value |> add() are equivalent.
Input, written arguments, defaults, and stages execute once; existing parameter
validation and ordered contracts run before the body. All concrete types work,
including internal ranges under compatible explicit export selection.
A final declared-Void stage is allowed only when its result is discarded: directly
in an expression statement, or through discarded block tails, if branches, and
match arms. Parentheses are transparent. An outer discard does not permit Void
pipelines in operands, call arguments, pipeline inputs, initializers, returns, or
value breaks. Nonfinal declared-Void stages are rejected even after Never.
Expression-form if/match retain their existing structural result joins; discarding
them does not allow mixed Void/value branches. Ordinary calls and the ABI remain unchanged. Host functions, builtin len, zero-parameter
functions and callable values are ineligible. #44 also permits visible local callable
stages with positional-only written arguments; #48 permits parenthesized immediate
unary typed lambdas and placeholder stages, without a written argument suffix.
Arbitrary expression callees and general postfix invocation remain unsupported.
Use user-function wrappers for host effects. Parenthesize whole bare if/match/loop
constructs to continue with |> across statement/tail classification boundaries.
Pipelines are forbidden in defaults/contracts, including skipped operands.
See public migration and checked normalization
and normative WP10 rules.
#25
and #26
validation records remain historical subset evidence, not full-release evidence;
#27 release validation
covers complete WP10 support.
Local declarations support both let name = initializer; and the existing
let name: type = initializer;. Without an annotation, the initializer determines
the local's concrete type: number, bool, string, number[], bool[],
string[], or range. This works for value-producing expressions, including
calls, blocks, and if; unconstrained [] retains the number[] fallback.
An explicit array annotation supplies literal context but still requires exact
types, without coercions. See contextual typing.
fn main() -> number {
let values = [10, 20];
let index = 1;
let result: number = values[index] * 2;
return result;
}
The initializer is analyzed before the new binding is introduced. In a nested
scope, let value = value + 1; can read an outer value and then shadow it; without
an outer binding, it is an unknown-variable error. The local's type is fixed after
initialization, so later assignments must match it. A void-returning call cannot
initialize a local. Every declaration still needs an initializer, and parameter
types remain mandatory. Function results follow the incremental rules above;
there is no parameter inference, whole-program type unification, promotion, or
implicit coercion. number remains
IEEE-754 f64, even for let index = 1;; there is no source i32 type.
// line comments run to LF, CR, or end of file. /* ... */ block comments can
span lines and end at the first */; they do not nest. Both are skipped between
tokens anywhere whitespace can appear. Comment delimiters inside string literals
remain text. Comments separate tokens rather than joining identifier fragments or
operator bytes: foo/* note */bar is two identifiers, not foobar.
fn main() -> number {
// A line comment
return 6 /* A block comment
spanning lines */ * 7;
}
The lexer skips comments directly, preserving original UTF-8 byte spans without
source preprocessing. An unterminated block comment produces an Invalid token
from the opening slash to EOF, followed by Eof, and is reported through existing
parser errors; it does not introduce a new error stage.
fn factorial(value: number) -> number {
if value <= 1 {
return 1;
}
return value * factorial(value - 1);
}
fn sum(limit: number) -> number {
let total: number = 0;
let current: number = 1;
while current <= limit {
total = total + current;
current = current + 1;
}
return total;
}
Numeric variables and numeric-array elements support statement-only +=, -=,
*=, /=, and %=. The target is evaluated and bounds-checked once, its old value
is read before the RHS, and the result is written to the original location. RHS
array mutations do not change that saved old value; target failures trap before
RHS effects. Both operands must be number; strings, booleans, and whole arrays
cannot be compound-assigned. %= uses the same exact remainder semantics as %.
fn main() -> number {
let values = [10, 20];
values[0] += 2;
let total = values[0];
total *= 3;
total %= 10;
return total;
}
It supports +, -, *, /, %, unary negation, and nested/parenthesized expressions.
Resolved variable IDs map to per-function Wasm slots, with parameters first and
locals in declaration order. Reads use local.get; initialization and assignment
use local.set. Scalar expression statements end in drop to keep the stack
balanced. Boolean literals use i32.const 0 or i32.const 1.
At the default None level, numeric literals become f64.const; arithmetic uses
f64.add, f64.sub, f64.mul, f64.div, and f64.neg. Integers and decimals use the same f64
representation. Invalid and overflowing literals return explicit errors;
arithmetic follows IEEE-754 behavior, including infinity, NaN, and signed zero.
number % number produces truncating floating remainder, matching JavaScript
%/fmod, not mathematical modulo or nearest-quotient IEEE remainder. Operands
are evaluated exactly once, left-to-right. A NaN operand, infinite dividend, or
zero divisor produces NaN; a finite dividend with an infinite divisor is returned
unchanged. Otherwise the result has the dividend's sign, including signed zero,
and its magnitude is less than the finite nonzero divisor's magnitude.
Wasm has no f64.rem, so % calls an optional guest (f64, f64) -> f64 helper.
It uses exact binary scaled subtraction rather than a - trunc(a / b) * b,
requires no implicit host import or Memory/Global/Data sections, and is appended
after all user functions and any required memory helpers. It is included only when
% or %= occurs anywhere in the typed AST, including unexported functions,
unreachable statements, and runtime-skipped code. Programs without either retain the same
output. The browser compiler uses the same pipeline and remainder semantics.
Numeric comparisons support ==, !=, <, <=, >, and >=; booleans support
==, !=, !, &&, and ||. Logical operators short-circuit, so skipped operands
never execute host calls or other side effects. Boolean operations treat any
nonzero i32 as true and produce canonical 0/1 results; numeric comparisons
follow IEEE-754 behavior, including NaN and signed zero.
break; exits the nearest active while, for, or statement loop; continue;
skips the rest of the body and starts the next iteration (reevaluating a while
condition or advancing a for). Transfers work beneath nested branches and expression blocks, but
cannot cross function boundaries. An active loop is required even in unreachable
source. break, continue, and loop are reserved source/host-language names;
rename existing bindings/functions using them. Host import module/field names
retain their unrestricted nonempty contract. for is also reserved for range/array
iteration; labels remain future work.
fn main() -> number {
let i = 0;
let total = 0;
while i < 10 {
i += 1;
if i == 3 { continue; }
if i == 7 { break; }
total += i;
}
return total;
}
This returns 18. Transfers skip later effects and runtime traps, but skipped
statements still receive static and backend checks. They do not count as function
returns. The typed API adds per-function LoopIds to While and resolved targets
to Break / Continue; public statement matches/construction must account for
these additions. IDs may be sparse, but must be unique within a function, and
transfers must refer to its innermost active loop.
loop repeats until a break exits it. In statement position, loop { ... }
(optionally followed by ;) accepts only bare break;. In value position, its
result comes from break expression;, not a body tail:
fn first_positive(values: number[]) -> number {
let index = 0;
let result = loop {
if index >= len(values) { break 0; }
let value = values[index];
index += 1;
if value <= 0 { continue; }
break value;
};
return result;
}
Value loops support numbers, bools, strings, and numeric-array handles. Reachable breaks targeting that loop must agree exactly; nested loops do not affect its result. Unreachable breaks do not constrain the result but still receive type, name, and context checks. A bare break in a value loop, a value break in a statement loop/while/for, or a Void break value is a semantic error. A divergent break RHS supplies no value and does not reach its break.
Loop bodies contain strict statements, not implicit tails. Bare loops in function
or loop bodies are statements, even with ;; use parentheses to discard a value
loop, as in (loop { break 1; });. In an expression block, a final bare loop
without ; is its value tail; a nonfinal or semicolon-terminated bare loop is a
statement. Bare statement loops end at the closing brace, regardless of trivia.
Parenthesize a whole loop for arithmetic continuation in these positions.
A loop with no reachable exit is Never. let x = loop {}; cannot infer a storage
type; let x: number = loop {}; is accepted, without a reachable store. Explicit
noncompletion is valid in a nonvoid function: fn forever() -> number { loop {} }
compiles, although it never returns. Later unreachable code is still checked.
There is no compiler-provided fuel or cancellation; avoid calling an unbounded
loop unless the embedding supplies its own resource controls.
AST additions (WP4): raw/typed expressions and statements add Loop { body }
(with id: LoopId in typed nodes). Existing bare Break variants are retained;
raw statements add BreakValue(Expr) and typed statements add
BreakValue { target, value }. Update exhaustive matches to handle these additions.
Loop IDs are unique per function across while, for, statement, and value loops.
An expression block's final expression supplies its value; a trailing semicolon
discards it. Value-context if requires else (including else if chains), and
normally completing branches must have exactly the same type. Numbers, booleans,
strings, and numeric-array handles are supported without changing their ABI.
fn choose(ready: bool) -> number {
let result = if ready {
let base = 20;
base * 2
} else {
return 0;
};
return { let extra = 2; result + extra };
}
Block-local bindings do not escape. Both branches and unreachable statements are
still checked, although only the selected branch executes. Two normally completing
branches without values produce void, which can be discarded but cannot initialize
a local. A path that unconditionally returns, breaks, or continues is internally
Never: it has no normal result, storage, or host ABI type. It can join another
branch's concrete result. let x = { return 1; }; cannot infer a storage type;
let x: number = { return 1; }; is accepted without storing a nonexistent value.
A possible runtime trap alone does not infer Never.
Named functions still require explicit return for results;
fn f() -> number { 42 } is not an implicit return. Function and while bodies require semicolons
for ordinary expression statements. A standalone block expression also requires
;. Bare statement-if may omit else and discards branch tails. Within an
expression block, a final bare if is a tail, while a nonfinal bare if is a
statement. A bare statement-if ends at its final arm: if true {} -1; is two
statements regardless of whitespace. Parenthesize the whole if to continue it
arithmetically in these positions: (if true { 2 } else { 3 }) - 1;.
Branches and loops use structured Wasm control flow and preserve shadowed bindings.
Transfers also work through expression blocks, including in their own while's
condition. Continue reevaluates that condition. Nonvoid functions must return or
explicitly diverge on every path; while true { return 1; } alone remains
conservative and is not a guaranteed return. An
unconditional explicit return in the condition, as in
while { return 1; } {}, does guarantee return before the body. Skipped operands,
unreachable statements, and forged scope escapes still receive backend checks.
Typed-AST migration (WP3): TypedExpr.ty is now ExprResult::Value(Type),
ExprResult::Void, or ExprResult::Never, rather than Type. Use ty.into()
for existing expression constructors (Type::Void converts to Void), and
value_type() to extract a concrete value type. Never is not a source Type;
Value(Type::Void) is malformed. Bindings and function signatures retain Type.
Handle the new raw/typed Block and If expression variants. ValueBlock and
TypedValueBlock hold statement vectors, an optional boxed tail, and a span;
existing function/statement-block vector aliases are unchanged.
Strings support literals, parameters/locals/returns, and content-based ==/!=.
Numeric arrays support literals (including []), reference assignment, parameters,
returns, element reads/writes, content-based ==/!=, and len(array) returning
a number. Equality requires matching lengths and exact elementwise f64 equality:
signed zeros compare equal, while NaN makes equality false even for an array
compared to itself. Array assignment still aliases storage. Array indices
must be finite, integral, nonnegative, and below the length; invalid indices trap
rather than being truncated or reading unrelated memory. len does not accept
strings, matching the existing language rules.
fn sum(values: number[]) -> number {
let total: number = 0;
let index: number = 0;
while index < len(values) {
total = total + values[index];
index = index + 1;
}
return total;
}
fn main() -> number {
let values: number[] = [10, 20, 30];
values[1] = 25;
return sum(values);
}
Memory-backed modules export memory and __alloc. Strings and arrays cross
function boundaries as i32 descriptor handles, not JavaScript strings/arrays.
Strings are immutable UTF-8 values; array assignments alias mutable storage.
Allocation is an aligned, growable bump allocator with no freeing or garbage
collection yet. Scalar-only modules do not include the memory runtime.
All user functions are exported by default. ExportOptions or CLI --export /
--no-exports select the public user-function surface without removing bodies or
imports. Required memory ABI exports remain. M0–M9 are implemented; those milestones
added no optimization passes. See the experimental #39/#40/#41 subset below.
use was_compiler::{Compiler, Type};
let mut compiler = Compiler::new();
compiler.register_host_function(
"print",
"env",
"log_number",
vec![Type::Number],
Type::Void,
)?;
let typed = compiler.analyze("fn main() -> number { print(1 + 2 * 3); return 7; }")?;
let wasm = compiler.compile(&typed)?;The embedding runtime must provide env.log_number. Registration declares its
signature and import names; it does not register a runtime callback. Function
references resolve through the precomputed module layout, independently of names
or deduplicated type indices. All nonvoid call results used as statements are
dropped; void calls leave no stack value and receive no drop.
Host number values use f64, including IEEE-754 NaN and infinities. Boolean
arguments sent to hosts and boolean results received from hosts are normalized to
0 or 1, treating any nonzero i32 as true. Hosts must obey their registered
signatures. Host string/array arguments and results have their descriptor bounds
validated; hosts construct return values using the module's allocator and memory.
See the memory ABI for the required host contract.
Every registered import must be provided, including unused ones.
Use Compiler::compile_source for the full pipeline, or Compiler::analyze and
Compiler::compile separately to inspect the typed AST. The typed program owns
its host-import snapshot, so later registry changes do not alter its compilation.
use was_compiler::{Compiler, ExportOptions};
let mut compiler = Compiler::new();
compiler.set_exports(ExportOptions::Only(vec!["main".into()]));Library and CLI binary output are the same raw Wasm bytes for the same source,
host declarations, and compiler/export options. The Node runtime's
instantiate(bytes, imports) returns { instance, call }; call passes raw
argument values and returns raw results. Numbers use f64; booleans and
string/array descriptor handles use i32. Native strings/arrays are not marshaled
automatically. Use createAbi from runtime/abi.cjs for explicit conversion, or
access memory directly when reference mutation is required.
range values support start..end and start..=end, with an optional contextual
by step; the default step is +1. Range operators bind below arithmetic and
above comparisons and are non-associative. Endpoints and steps must be number.
fn identity(value: range) -> range { return value; }
fn main() -> number {
let value: range = identity(10..=0 by -2);
return 7;
}
Compile this example with --export main or
ExportOptions::Only(vec!["main".into()]). Range parameters/results use private
i32 handles internally; every range host-import signature and every selected
range user-export signature is rejected. Default export-all therefore rejects
identity; Only or None permits internal range functions without pruning or
skipping checks. There is no public range marshaling API.
Construction evaluates start, end, and explicit step once, left-to-right, all
before validation, even when an earlier operand is invalid or the range is empty
or discarded. Executed construction traps unless all inputs are finite integers
within -(2^53 - 1)..=(2^53 - 1) and the step is nonzero (both signed zeros are
invalid). Negative-zero endpoints become integer zero. Each construction allocates
an immutable private record; assignment and internal calls share its handle.
See the private layout and validation contract.
Range locals, assignments, parameters/results, blocks, if values, and value loops
use exact type joins with Never compatibility. Range ==/!= are prohibited.
number in range returns bool: 6 in (0..10 by 2) is true, while
5 in (0..10 by 2) is false. in is a reserved source/host-language name, not a
restriction on host module/field names. It binds at comparison precedence with
existing left associativity. Both operands evaluate once, left-to-right, with
exact number/range types and Never compatibility; all children remain checked,
including unreachable or runtime-skipped code. Membership validates the consumed
record before checking the candidate, so malformed records trap even for invalid
candidates. Nonfinite, fractional, or out-of-safe-domain candidates return false;
both signed zeros are accepted. Interval and congruence checks use exact signed
i64 arithmetic, not floating remainder.
Range and numeric-array statement for are implemented:
fn main() -> number {
let total = 0;
for value in 0..=6 by 2 {
if value == 2 { continue; }
total += value;
}
return total;
}
This yields 0, 2, 4, 6 and returns 10. for name in iterable { body }
accepts range, number[], bool[] or string[] with Never compatibility.
The iterable evaluates once in the enclosing context before activating the
new loop or introducing its read-only scalar element binding (number for ranges).
Break/continue inside that expression targets the outer nearest loop,
or is invalid without one; return/divergence is preserved. The body may introduce
an inner shadowing let that is mutable; assigning or compound-updating the
iteration binding itself is rejected.
Bare break; exits the for; continue; advances once before retesting; return
exits the function. Value breaks are rejected even when unreachable. For is a void
statement with a strict statement body, not a value-producing expression. An empty
range or array can skip the body, so a returning body alone does not guarantee a
function return. For ranges, cached validated fields use exact i64 advancement and signed endpoint
checks before f64 conversion/yield; yielded integers are exact and zero is
+0. Advancing past the endpoint, including outside the safe-integer domain,
exhausts normally without a trap or out-of-range yield. The bounds proof is in the
memory ABI.
For numeric arrays (issue #5), the handle and unsigned u32 length are captured
once after full array validation, even for an empty array. An unsigned index test
precedes each checked ArrayAddress call and f64 element load. Elements are
loaded on their iteration, not snapshotted: mutations through aliases to unvisited
elements are visible, while reassigning the source variable does not replace the
captured handle. Continue reaches index advancement through the body latch.
fn main() -> number {
let values = [1, 2, 3];
let alias = values;
let total = 0;
for value in values {
if value == 1 { alias[1] = 20; values = [100]; }
total += value;
}
return total;
}
This returns 24, observing the changed second element of the original array.
AST addition (issue #4): typed statements add
For { id, variable, name, iterable: Box<TypedExpr>, body }; public consumers must
update constructors and exhaustive matches. Read-only checks derive from For
provenance, without new public mutable-binding metadata. Issue #5 changes no
parser or raw/typed AST shape. Subsequently #37 adds element_type: Type to
this typed For; see the Rust migration.
WP5 implementation is complete and all release gates passed. The CLI
and browser support construction, internal range calls, membership, and both
iterable types through the shared core pipeline. Issue #6 adds adapter and
cross-feature coverage in crates/cli/tests/cli.rs and tests/browser.cjs, including export
selection, actionable private-range host errors, effects, and optimization-level
parity. Cross-feature compatibility coverage is detailed in the
WP5 checkpoint.
See the
exact release evidence (§5),
including the full ignored-test run; ordinary default Cargo tests still skip Node cases.
crates/compiler/tests/array_for_loops.rs is the array-for suite. crates/compiler/tests/for_frontend.rs covers syntax, types,
scopes, read-only bindings, and transfers; crates/compiler/tests/for_lowering.rs covers forged
input, scratch layout, cached fields, and decoded conversion/latch ordering.
crates/compiler/tests/for_loops.rs covers deterministic output, import snapshots/export selection,
and bounded execution against an independent BigInt progression reference at all
optimization levels. Node execution cases retain the ignored-test policy: default
Cargo success alone does not establish runtime coverage.
No optimization passes, ABI changes, or helper-order changes were added. For adds
no range helpers; array-only iteration requires none.
The optional Contains helper follows Construct and Validate only when membership exists;
construction-only programs retain their bytes, as do programs without ranges.
Membership alone needs no floating remainder helper or host callback.
Implemented, frontend/backend integrated, with release gates passed.
The parent-run evidence includes current-source rebuilt-browser and full ignored
runtime validation. See the approved WP9 rules and suites.
crates/compiler/tests/match_frontend.rs and crates/compiler/tests/match_lowering.rs cover frontend/inference
and forged-tree/decoded lowering contracts; crates/compiler/tests/match_runtime.rs and
crates/cli/tests/match_adapters.rs, crates/browser/tests/match_adapters.rs cover bounded effects and native/browser compatibility.
Node/browser cases retain their default ignored policy.
fn classify(value: number) -> string {
return match value {
< -2.5 => "low",
-2.5..=2.5 => { let label = "middle"; label },
_ => "other",
};
}
fn choose(ready: bool) -> number {
return match ready { true => 10, false => 0 };
}
Each arm is pattern => expression, with commas required between all arms
(including block arms) and an optional trailing comma. At least one arm is
required. Each arm has its own lexical scope; declarations do not leak to other
arms or outside the match. Use a block RHS for return, break, or continue;
these retain their enclosing function/nearest-loop targets.
Patterns support _, exact number/string/bool literals, numeric comparisons
<, <=, >, >=, and numeric intervals a..b / a..=b. Numeric constants
are decimal literals with optional unary minus, not names, calls, arithmetic,
or unary plus. They must be finitely representable; underflow is allowed.
Interval bounds may be fractional and need not be safe integers. An interval
uses bound comparisons, not stepped range membership: 1..=9 includes 1.5.
by is rejected. Reversed intervals and equal-endpoint exclusive intervals
are accepted but never match. Signed zeros compare equal; NaN reaches _.
String patterns compare UTF-8 byte contents.
Normally completing scrutinees must be number, bool, or string; arrays,
ranges and Void are rejected. Both statement and value matches are exhaustive:
number/string require _; bool requires both literals, either literal then _,
or _ alone. Wildcard occurs at most once and only last. Duplicate non-wildcard
patterns and overlaps are accepted without new warnings. The scrutinee runs
exactly once, and only the first matching arm executes.
Every arm is conservatively checked, including duplicates, covered arms and constant/skipped/unreachable cases. Value results join exact concrete types or all Void with Never compatibility; all-Never arms yield Never. Statement matches discard results and allow heterogeneous tails. A Never scrutinee still requires valid patterns, coverage and structural value-arm joins: non-wildcard patterns establish one domain; wildcard-only needs none. Its arms contribute no reachable flow and the whole match is Never. Completing scrutinees conservatively include all arms in result joins, return inference and loop-transfer summaries.
Bare match follows bare-if statement/tail classification: statement-body matches
discard results; a final bare match without ; in a value block is its tail;
nonfinal or semicolon-terminated bare matches are statements. Parenthesize the
whole match for arithmetic continuation in these classification positions.
Parenthesized matches remain expressions even when discarded. There are no
newline heuristics or implicit function returns.
match and standalone _ are reserved for source bindings/functions and host
source-language names; _temp and arbitrary nonempty host module/field names
remain valid. This intentionally breaks prior uses of these exact source names.
WP9 adds no ignored bindings, placeholder expressions, guards, binding patterns,
destructuring or or-patterns. Match remains excluded from the defaults/contracts
pure-expression whitelist. Ordered comparison-chain lowering adds no ABI,
helper kind or optimization pass; all optimization levels retain parity.
The compiler supports configuring OptimizationLevel::None (the default),
Basic, Standard, or Aggressive, corresponding to the intended O0–O3 presets.
Basic, Standard, and Aggressive share experimental #39/#40/#41 integer
specialization: exact parsed signed-i32 literals, proven add/sub subtrees and
eligible never-reassigned ordinary numeric locals use private i32 lowering/storage.
#40 propagates initializer facts through eligible local reads, converting back to
f64 at unspecialized consumers. Every ordinary/compound write, even skipped or
unreachable, excludes its binding. Parameters, iteration and normalized-call synthetic
bindings, captured/promoted bindings, calls and memory loads supply no local proofs.
Ordinary loop-body lets may qualify. User-authorized #41 reuses the existing facts
for all six numeric comparisons (==, !=, <, <=, >, >=) when both operands
are proven signed-i32 integers, using signed ordering, and for safe unary negation
when operand bounds exclude both zero and i32::MIN. Multiplication, division,
remainder and unknown f64 fallbacks remain unchanged. No branch/contract/caller
refinement is added. Public AST, source language, ABI and captures are unchanged.
None/default bytes are preserved; enabled presets must match each other's
bytes, with semantic/diagnostic parity across levels and adapter byte parity within
each level.
Contracts and status distinguish this subset from
remaining #38 work. Authorization through #41 covers implementation, not release
acceptance. #41 measurements
show cumulative current None → Basic size across 27 cases of 13,414 → 9,538 bytes
(−28.90%). Against historical #40 Basic (9,701 bytes), the net reduction is
163 bytes (−1.68%): compare_32 saves 192 bytes, but negate_32 grows 32 bytes.
An independent expression-only supplement demonstrates comparison/negation
specialization without local storage. Mixed guest timings and compilation
regressions keep the subset experimental; these cumulative measurements are not
isolated #41 timing benefit or regression acceptance.
Historical #40 measurements and #39 evidence retain their original scope. Final #40 correctness gates passed: 1,276 tests across 81 targets, zero failed/ignored, and 25 current-source browser groups. #40 review is complete; neither validates #41. Separate #41 correctness gates passed: 1,287 workspace tests across 81 targets, zero failed/ignored, and 25 rebuilt-browser groups. Two-axis #41 review is complete with zero hard standards/spec findings and one deferred low-priority test-data suggestion. #42 integrated validation passed 1,296 workspace tests across 81 targets, none failed/ignored, plus 25 rebuilt-browser groups. Repeated matched measurements confirm the same size benefit, but typed compilation medians rise in 25/27 cases in both repetitions; guest timing remains mixed. Material-regression and release acceptance remain pending: the decision is keep experimental, with no release or default change.
use was_compiler::{Compiler, CompilerOptions, OptimizationLevel};
let mut compiler = Compiler::with_options(CompilerOptions {
optimization_level: OptimizationLevel::Standard,
});
compiler.set_optimization_level(OptimizationLevel::None);Both compile and compile_source go through an optimization stage between the
typed AST and code generation. analyze remains independent of the selected
level. The stage currently borrows the input without cloning or changing it;
the orchestrator separately selects the numeric policy used by checked emission's
private per-body facts cache. Future AST transformations can return an owned tree.
Calling lower-level codegen::compile or codegen::compile_with_exports directly
bypasses compiler options and retains baseline lowering.
Run cargo run --offline -- examples/array-sum.was to print plain WAT with
wasmprinter.
Run native compiler/CLI tests with cargo test --offline. The full suite includes optional Node.js
runtime tests and compiler-in-Wasm tests; build pkg/ with the browser command
above and use Node.js 24+ before including ignored tests:
cargo test --offline --workspace -- --include-ignored
Use the root Bacon configuration for interactive feedback and manual validation chains:
bacon
bacon fast
bacon full
bacon bench
See validation for finite direct commands, prerequisites, package ownership, and browser rebuild ordering. Native compiler benchmarks cover lexing, parsing, semantics, typed compilation, and source-to-Wasm compilation:
cargo bench --offline -p was-compiler --bench compiler
See benchmark boundaries and baseline. This refactor changes package ownership, not language behavior or optimization support.