Skip to content

About

The compiler for the WAS language

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

was-compiler

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.

Workspace

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 ..

Build and run

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.

Compile in the browser

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.

Current compilation support

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.

Typed arrays (#37; implemented, release gates passed)

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.

Local callables and shared captures (WP11; release gates passed)

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.

Named-only pipelines (WP10 implemented; release gates passed)

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.

Statement and value loops

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.

Block values and if expressions

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.

Ranges, numeric membership, and range/array iteration (WP5)

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.

Match statements and values (WP9)

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.

Optimization levels

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.

Tests, Bacon, and benchmarks

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.

About

The compiler for the WAS language

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages