Skip to main content

Limitations and rough edges

This page is the project status page. Read it before using Reed for anything where a wrong module would matter.

Status

Version 0.1.0. There are no tags, no changelog, and no compatibility guarantee yet. Syntax and semantics can change between commits. If you write something today, check it again after you pull.

The catalog ID changed with the new proposals

The instruction catalog now includes SIMD, atomics, 64-bit memories, exception catching, and wide arithmetic. That changes the language target, not just the implementation detail, so the catalog ID changed from reedc-core-gc-eh-0 to reedc-core-gc-eh-simd-threads-1. The ID appears in the build banner and at the top of every emitted module.

Existing Reed source does not break because of this ID change. Tooling that matched the old ID exactly might: a build script grepping the banner, or a check pinning the leading WAT comment.

@inline is the one annotation that is a requirement rather than information: it changes what the emitted module contains (the function is not emitted at all) and restricts what you may do with the function (no export, no &name, no table entry, no recursion). What it cannot do is change what an accepted program computes. There is no @noinline, and whether an un-annotated function gets inlined is up to the engine — this compiler performs no inlining of its own.

Two smaller source-visible changes came with them:

  • @ is now a token. It was previously an invalid character, so it could appear nowhere at all; it is now the annotation marker. A file that used @ inside a string or a comment is unaffected.
  • try, catch, shared, custom, likely, unlikely, and first/last are recognized in specific positions but are not reserved. All remain usable as ordinary identifiers, which is why adding them broke nothing. Contrast switch and unreachable, which had to be reserved.

The language was renamed from WAST to Reed

It used to be called WAST, which collided with the pre-existing .wast WebAssembly spec-test format — a different, unrelated file format that is also WAT-adjacent. Everything user-facing changed name at once:

WasNow
.wast source files.reed
the wastc binaryreedc
catalog wastc-core-gc-eh-0reedc-core-gc-eh-0, since bumped to reedc-core-gc-eh-simd-threads-1
```wast doc fences```reed
tree-sitter-wasttree-sitter-reed

No syntax or semantics changed with it. If you have existing files, renaming them is enough — reedc never checked the extension in the first place, so even a .wast file still compiles. What will break is anything that shelled out to wastc or read the old catalog ID out of the build banner, and, if you drive the test harness yourself, the adapter env vars: WASTC_BIN and WAST_TEST_ADAPTER are now REEDC_BIN and REED_TEST_ADAPTER.

Testing is thin

Correctness mostly rests on two feedback loops:

  • tests/run.py, a black-box suite that runs fixtures through wasmtime and checks the WAT output's structure. It only proves what it covers.
  • tests/lsp_integration.py, a committed scripted JSON-RPC client that spawns reedc lsp and asserts on real responses (mise run lsp:test).

There are Rust #[test]s too, but they are deliberately narrow: pure algorithms with no WAT to run, integer-literal parsing, and a few same-process diagnostic checks for fast feedback while working. They help, but they do not replace executable fixtures. If a behavior depends on generated WAT being correct, it belongs in tests/cases/ and should be run.

A module that passes reedc check and wasm-tools validate is not proven correct. Validation means the WAT is well formed. It does not say whether the output does what the source says. Three real bugs shipped and validated cleanly before being caught by running the output:

  • An is-narrowing test whose success path fell through into the else branch.
  • struct.new reordering field-initializer side effects instead of preserving source order.
  • Multi-parameter block/loop binding parameters in reversed stack order.

All three are fixed. The pattern isn't — see known compiler bugs below for two more of exactly this shape, found the same way — by running documentation examples rather than reading the spec — and fixed only after they had been shipping silently.

Anything touching control flow, side-effect order, or stack layout is where bugs like this hide, and the test suite's coverage of that space is whatever fixtures happen to exist, not exhaustive. If you're relying on evaluation order or exact stack shape, compile it, run it with wasmtime --invoke, and check the actual result. Use wasm-tools validate, not wasm-tools parseparse only converts text to binary and will happily accept a module the validator rejects.

Recently fixed compiler bugs

Both were found by writing and running the documentation examples, not by reading the spec. Both are now fixed and covered by conformance fixtures, but they are worth knowing about: they show the failure mode this compiler is prone to, and any .wat you generated before the fix still has them.

1. A diverging if/else as the last statement emitted invalid WebAssembly

func broken(x: i32) -> i32 {
  if (x == 0) { return 1; } else { return 2; }
}

reedc check reported no errors and the emitted module failed validation with type mismatch: expected i32 but nothing on stack. WebAssembly validates each arm independently against the block type, so the point after the if stays reachable even when both arms return; the function then fell off its end owing an i32.

Fixed by emitting an explicit unreachable after a control statement whose arms all diverge. The same gap applied to a loop with no reachable break, which is fixed too. Audit C7.

Note that wasm-tools parse accepted the broken output — it only converts text to binary. Only wasm-tools validate caught it.

2. Structurally identical sibling structs shared one runtime type

struct Node { tag: i32, }
struct Leaf : Node { value: i32, }
struct Twig : Node { count: i32, }

Leaf and Twig flatten to identical field lists under the same parent. Each was emitted as its own singleton recursive group, and WebAssembly canonicalizes groups structurally, so they became one runtime type — value is &Leaf succeeded on a Twig. The module validated, ran, and returned the wrong answer.

Fixed by emitting declarations that would canonicalize together in a single recursive group, making their identity positional. This required amending specification section 6, which had mandated the emission strategy that caused the bug while also requiring nominality. Audit C8.

This was the most dangerous bug found, because nothing reported it.

What the language does not have

Scope is core WebAssembly plus reference types, sign extension, GC, exception handling, fixed-width and relaxed SIMD, atomics with shared memory, 64-bit memories, saturating conversions, and wide arithmetic. Everything below is explicitly not implemented at all, not planned in the near term:

  • Runtime strings (stringref)
  • Stack switching
  • Half-precision floats (f16x8.*, f32.load_f16)
  • Any GC-draft-2022 or custom-descriptors extensions

Half-precision is worth singling out, because the reason is not scope: the wasm-tools release this compiler emits text for cannot parse those instructions at all. Emitting them would trade a clear "not in the catalog" feature error for an opaque failure inside a downstream tool, so they wait until the toolchain supports them.

Exception handling is complete as of catalog reedc-core-gc-eh-simd-threads-1try/catch (with or without a tag), payload bindings, and rethrow via throw e; all exist (see Exceptions). What does not exist is delegate, the legacy proposal's forwarding form, which the standardized try_table encoding dropped in favor of catching and rethrowing explicitly. There is also no finally: it is not a WebAssembly construct, and simulating it would mean duplicating cleanup code into every handler plus the fallthrough path.

Tail calls are partial too, and narrower than it sounds: --tail-calls is a compiler output flag that may turn your own return into return_call. There is no source-level return_call syntax and no way to ask for one directly — you write an ordinary return, and whether it tail-calls is the compiler's choice, not yours.

There are also no type parameters, and there will not be: a compilation unit becomes one WebAssembly GC module with nominal, source-derived type names and no mangling, which leaves nothing for a type variable to erase to. What exists instead is compile-time monomorphization — comptime func — with the limits in its own section below.

Packed structs, enums, and const

Three constructs added together, each with edges worth knowing.

A packed struct is a value, not a reference, and that shapes what you can do to one. p->field = x is a read-modify-write of the place p names, so it needs a place that can be written back: a var local or a mut global. Through a GC struct field, an array element, or a parameter there is nowhere to write, and the compiler says so rather than computing a new value and discarding it. Rebuild with new instead.

Its other limits:

  • A global cannot be initialized with new. Building a packed value needs shifts and masks, which a WebAssembly constant expression cannot contain. Write the bits directly (global Current: mut Flags = 0 as Flags;) and build the real value in a function.
  • Truncation is silent. A field takes the low N bits of whatever it is given, in an initializer and in an assignment alike. This matches packed i8/i16 storage fields and WebAssembly's own narrow stores; if out of range should be an error, check first.
  • No subtyping, no inheritance, no nesting. A packed struct's fields are bit ranges, so a field cannot itself be a packed struct. Compose by hand with as and shifts. A field's type may name a sized enum, which contributes a width and a signedness -- that is a spelling for the bits, not nesting.
  • An i31-represented value converts to &i31/&eq/&any and never back implicitly. Two i31-represented packed structs are the same thing at runtime, so a downcast could not be checked; the round trip is written out in both directions.
  • The Binaryen backend handles every representation natively. The i32/i64 ones are an i32/i64 in the emitted module, and an i31-represented one is a real GC reference, which Binaryen represents as one. Nothing is lowered.

An enum is C-style, and deliberately so: Color is a transparent alias for its representation, an arbitrary integer is assignable to it, and a switch over its members still needs its default. There is no exhaustiveness checking and no payload — a nominal or algebraic enumeration would be a separate construct. A member's bare name works only inside its own enum's initializers; everywhere else it is Color.Red, and Color alone is not a value.

Its representation is a bit width (u4, i12, i64), and the edges of that are worth knowing:

  • A width constrains storage, not values. enum Color : u4 range-checks its members at 0..15, but a field of that type still accepts any integer — the field is an ordinary narrow integer, and a store truncates. If out of range should be an error, check first. This is the same tradeoff packed fields make, for the same reason.
  • Widening an enum changes a normal struct's ABI. u4 rounds to an i8 field, u12 to i16, so widening the enum later changes the emitted field type from an edit that looks source-compatible. Identical exposure to declaring i8 directly, but the enum spelling hides it one level further away.
  • The rounding ladder stops at i16. WebAssembly has no i24, so a u20 enum field is stored at its value type rather than at anything narrower.
  • Two representations cannot state their full range in source. A compile-time integer is a signed 64-bit value, so u64's upper half is unreachable and i64's most negative value has no literal spelling (the lexer sees a negation of an out-of-range magnitude). A pre-existing property of compile-time evaluation, not of enums.
  • bool is not a valid representation. It is already a transparent alias of i32, so an enum over it could hold nothing u1 cannot.

A const differs from a param in exactly two ways: its value is an expression, and --define cannot change it. Because expansion is a single forward pass, a const must be declared above anything that reads it at compile time — a function may read one declared below it, but another const may not.

Compile-time arithmetic is exact, not wrapping: overflow, division by zero, and a shift count outside 0..63 are all errors, where the runtime operators would wrap or mask. One case is rejected outright rather than answered: >>> on a negative value, whose result depends on a width a compile-time integer does not have (as an i64, -8 >>> 28 is 68719476735; as an i32 it is 15). Mask to a width first, or use >>.

File imports moved to use, which is a breaking change

A Reed file import used to be import "util.reed".{add}; -- a quoted path, with .reed written out, sharing the import keyword with WebAssembly host imports. It is now use util.{add};, and code written against the old spelling no longer compiles. The old form produces an error naming the replacement rather than a generic parse failure.

What changed, concretely:

  • import "util.reed".{add}; becomes use util.{add};. No quotes, no extension.
  • A path is dotted: use net.http.{get}; is the file net/http.reed. The last segment is the file; earlier segments are directories.
  • import "std:math".*; becomes use std.math.*;. The std: scheme is gone; std is a reserved path root.
  • pub import ... becomes pub use ....
  • The brace-less single-name form is removed. use util.add; is an error, because it cannot be told apart from a path naming one more directory level. Write use util.{add}; even for one name.
  • use is now a reserved word, so a program using it as an identifier stops compiling. That was a deliberate choice rather than a forced one: the alternative was keeping file imports on import, which is exactly what made an unquoted path ambiguous with a host import's module name.
  • A user source file may no longer be named std.reed or core.reed. Both are reserved path roots, so such a file was unreachable anyway -- the difference is that naming one is now an error instead of silently loading a file that use std.math.*; would never have reached.

import still exists, unchanged, for WebAssembly host imports: import "env"."log" as func log(i32); and the unquoted import env."log" as ....

New in the same change: use util.{add as plus}; imports under a different name. An alias is a per-file synonym and not a rename -- the declaration keeps its own name, the emitted WAT still names it, and another file may import the same declaration under its real name simultaneously. An alias also does not shadow a local: inside a function body, a name that resolves as a local still does, so let helper: i32 = 5; return helper(2, 3) * 10 + helper; calls the aliased function and reads the local, in that one expression.

Checked exceptions are a breaking change

Throwing used to be unchecked: any function could throw and any caller could ignore it, with an uncaught throw becoming a runtime trap. It is now statically checked, so code written against the older behavior no longer compiles:

  • A function that throws must declare throws (Tag, ...), or the throw is an error (throw 'T' is not caught here and is not declared by the enclosing function).
  • A call to such a function must either sit inside a try that catches the tag, or be marked ? to propagate it. An unmarked call is an error naming both options.
  • ? on a call with nothing left to propagate is also an error. That keeps the operator meaning "this can fail" everywhere it appears, rather than becoming noise that gets sprinkled on defensively, but it does mean widening a throws clause can require adding ? at call sites, and narrowing one can require removing them.

The fix is mechanical and the diagnostics name it, but it is not automatic: there is no codemod, and no flag to restore unchecked throwing.

Known edges within the checked model:

  • Effect sets are per function, not per call. A throws (A, B) function propagated with ? contributes both tags to the caller, even when the particular call could only ever throw A. Catching one tag and propagating the rest requires the try/catch as e rethrow form, which does compute the exact residual set.
  • A rethrow needs a statically known set. throw e; on an exception reference works when the compiler knows which tags reach that clause: a catch Tag(...) as e contributes exactly that tag, and a catch as e contributes the tags that actually reach it, with anything an earlier clause already handled removed. Those tags must then be declared by the enclosing function like any other throw. An exception reference obtained some other way cannot be rethrown, because there would be no sound set to attribute.
  • throws is not part of a function type's identity for & references beyond its declaration. A type F = func(i32) -> i32 throws (T); records the clause, and calls through it are checked, but the tags are erased in the emitted WebAssembly — as they are for direct calls, since WebAssembly itself has no notion of a declared effect. The checking is entirely a source-level guarantee.
  • throws and bool are not reserved words. Both are recognized positionally, so existing code using either as an identifier still compiles. This was deliberate: reserving them would have made the change breaking in a second, unrelated way.

Methods are static, and deliberately thin

Methods are new, and what they are not matters as much as what they are. A method is an ordinary function with a dotted name — that is the whole design, and everything below follows from it rather than being a gap waiting to be filled.

  • Dispatch is static. It resolves against the receiver expression's declared type, so a &Shape-typed binding holding a Circle calls Shape.describe, not Circle.describe. There is no virtual dispatch, no vtable, and no dynamic/override keyword. If you need runtime behavior selection, test the type with is and narrow (see GC and narrowing) or hold a function reference in a field.
  • There are no interfaces, traits, or generic methods. A method is declared on exactly one concrete receiver type. Struct inheritance is the only sharing mechanism.
  • A nullable receiver has no methods. p.get() on a ?P is an error even when P.get exists; narrow first, or call P.get(p) by name inside the narrowed branch.
  • Some method names are reserved, and core names already exist. Array methods (copy, fill) and memory/table/data/elem forms (grow, size, init, ...) are resolved before declared methods are consulted, so a method with one of those names is rejected rather than silently dead. Numeric operations such as clz, div_u, sqrt, copysign, and mul_wide_u are ordinary methods from the default core library: you cannot redeclare the same qualified core name (i32.clz or f64.sqrt), but another receiver such as Point.clz or Counter.sqrt is allowed.
  • @inline methods are still thin functions. They inline at both x.m(...) and T.m(x, ...) call sites, but they obey the ordinary @inline restrictions: no export, start, function reference, table entry, or elem entry, because no callable function is emitted.
  • A method's name is not private, and not namespaced. Point.norm is one entry in the one function namespace. Two types may each declare norm; one type may not declare it twice. A plain func norm(...) can coexist with func Point.norm(...) because the names differ, and only the qualified one participates in .norm() dispatch.

One tooling consequence worth knowing: hover, go-to-definition, and rename resolve a bare .name(...) call by name alone, since that path has no type information. When two receiver types declare the same method name, the editor answers nothing rather than guessing; put the cursor on the qualified spelling (Point.norm) to get an unambiguous answer. Likewise, unused-decl marks every same-named method used when any one of them is called, so an unused Point.norm can hide behind a used Vec3.norm.

The wasm.* escape hatch is not "any instruction"

It only covers what's hand-coded in the compiler's opcode catalog. Notably absent:

  • Branch-carrying instructions — br, br_if, br_table, block, loop, if, return, br_on_null, br_on_cast. The language's own structured control flow is the only way to branch; there is no raw escape hatch for it.
  • array.new_data, array.new_elem, memory.init, data.drop — absent under those raw wasm.* spellings specifically, but all four are reachable through dedicated surface syntax: new Name[count] data<D>(offset) / elem<E>(offset) for the first two, and Mem.init<SomeData>(...) / Table.init<Handlers>(...) plus SomeData.drop() / Handlers.drop() for the latter two. See Raw WebAssembly operations for the working forms — Reed can do bulk-copy-from-segment, just not by spelling the instruction directly.
  • A raw call_ref or any other tail-call opcode.

If an opcode isn't in the catalog, using it is a feature error, not a silent fallback to raw WAT. There is no way to hand-write WAT and splice it in.

Things that fail silently

Nothing currently known to this project fails completely silently — the four entries that used to live on this list are now either a compiler warning or a hard error. They're kept here, because "used to be silent" is exactly the kind of thing worth knowing if you're debugging output built before the fix, and because the fix for each one is a targeted narrowing of what counts as "silent," not a guarantee there's nothing left to find. If you find a new one, it belongs here until it's fixed.

  • A narrow that doesn't apply. if (x is &T) narrows x only when x is a bare immutable local (let or a parameter). On a var, a field read, a call result, or any compound expression, the test still runs and the type does not change — but the compiler now says so, as a warning at the if, instead of leaving you to hit a confusing type error at the use site with no idea why. The narrowing rule itself is unchanged; only the silence is fixed. A type-pattern switch has the identical restriction and now warns the same way, at its scrutinee.
  • A statically impossible is test still folds to a constant-false condition and the true arm is still dead code, but compiling now emits a warning at the if pointing at exactly that.
  • Ignored opcode immediates. Numeric ops used to never inspect their immediates, so wasm.i32.add<Junk>(a, b) type-checked and silently discarded <Junk>. Every opcode that structurally takes no immediate (select was already the one exception that errored) now rejects a stray one as a compile error instead.
  • A stale output file. A failed reedc build used to write nothing, leaving a previous run's .wat in place with no way to tell it apart from current output except the exit code. reedc build now removes an existing output file on failure instead of leaving it looking current.

None of the four above block a program that was already relying on the old (silent) behavior — a warning never fails the build, and the immediate check only rejects programs that were passing a meaningless immediate to begin with. The main alternative considered for the first two was making them hard errors instead of warnings: rejected because both describe code that is valid and already runs correctly (the narrow "doesn't apply" — it doesn't make the program wrong to write it that way; a dead arm is exactly as unreachable whether or not it's flagged), so failing the build over it would be disproportionate. See CLAUDE.md's "Diagnostic severity" section for the mechanism (Severity::Error vs Severity::Warning) if you're adding another one.

Two more entries were removed from this list once fixed, both worth knowing if you are on an older build. A failed initializer used to skip declaring its binding, so the next use reported unknown local 'x' at the resolution phase and suppressed the initializer's real error (audit P16). And operator pivot inference omitted memory.size, memory.grow, array.get_s/_u, struct.get_s/_u, and call_indirect, so wasm.memory.size<M>() != 1 was rejected outright while wasm.table.size<T>() != 4 compiled (audit P15).

Sharp edges in the escape hatch

  • wasm.i32.const<-1> used to not parse at all (the immediate grammar had no -), forcing the unsigned bit-pattern spelling (4294967295). A narrow -<digits> form is now accepted for iN.const specifically — see tests/cases/raw_ops/negative_const* — but the unsigned spelling still works identically and is still what every other opcode's Immediate::Int expects; - is not a general immediate-grammar feature.
  • ref.test/ref.cast only downcast. The immediate must be a subtype of the operand's type.
  • ref.i31 produces a non-null &i31, but i31.get_s/i31.get_u take a nullable ?i31. The as &i31/as i32.s/as i32.u cast-expression sugar over these three carries the same asymmetry; unboxing a nullable ?i31 via as additionally triggers the nullable-i31-unbox lint.
  • table.grow is (value, delta), not (delta, value).
  • call_indirect takes the table index as its last argument, after the call arguments.
  • array.copy's immediate is <Destination, Source> — destination first.
  • Bare heap keywords aren't type immediates: <&struct>, never <struct>.
  • align= is validated against the opcode's natural alignment, which is derived by substring-matching the opcode name. Unusual but consistent.

Full detail in the opcode catalog.

Structural constraints, not bugs

These are deliberate, but easy to trip over if you assume otherwise:

  • Many files, one module. An import of another Reed file merges it into a single compilation unit that emits one WebAssembly module (see Modules for what that costs). There is no linking of separately compiled modules — composing two WebAssembly modules remains the embedding host's job, through imports and exports.
  • Global initializers are heavily restricted. A contextually-typed literal, null, a read of an imported immutable global, a read of a param's bare name, or a constant-eligible wasm op (which now includes GC allocation, extended-const i32/i64 arithmetic, and 0 as &i31) — the dedicated new Name {...}/new Name[count]{} syntax works too, when it lowers to one of those same allocation opcodes. No calls, no reading a local, and no reading a mutable global, still.
  • Extended-const arithmetic needs a runtime that supports it. A global initializer using i32.add/i32.sub/i32.mul/i64.add/i64.sub/ i64.mul (section 10) only validates under a WebAssembly runtime/validator with the extended-const proposal enabled. Most modern runtimes already support it — including the wasmtime version this repo's own tests run against — but it's worth knowing this compiler now emits output that depends on a proposal beyond plain core WebAssembly.
  • ASCII only. Identifiers and keywords are ASCII in this version. No block comments — // line comments only.
  • Naming case is load-bearing, not style. snake_case resolves as a local, PascalCase resolves as a global. Get the case wrong and you get a different binding, not a lint warning.
  • switch is now a reserved word. It used to be a legal identifier, so func switch(...) or let switch: i32 = 1; compiled. Both are now syntax errors (expected identifier, found keyword 'switch'). Rename any such binding. This was unavoidable rather than chosen: switch (x) { ... } and a call to a function named switch start identically, and telling them apart would need a balanced-paren lookahead for the following {, which this parser does nowhere else. case and default are not reserved — they only appear inside a switch body, so they remain usable as ordinary names.
  • unreachable is now a reserved word. It used to be a legal snake_case identifier, so func unreachable() or let unreachable: i32 = 1; compiled. Both are now syntax errors (expected identifier, found keyword 'unreachable'). Rename any such binding. Unlike switch, this one was a choice rather than a grammatical necessity — the word is now the surface spelling of a diverging expression (see control flow), and leaving it a usable identifier would have meant a program could shadow it with a function of the same name and silently change what unreachable; means.

Macros

Macros are token-level and deliberately minimal. Four limits are worth knowing before you reach for one:

  • No hygiene. A name a template introduces is an ordinary name, resolved ordinarily, so it can collide with a name at the invocation site. There is no gensym and no scope isolation. Take the name as an ident capture, or prefix generated names distinctively.
  • No nested-repetition zipping across depths. $(...)* works, including nested, but one template group iterates exactly one depth: a body mentioning two repeated names of different lengths is an error naming both, rather than being zipped or truncated.
  • No statement-position invocation. Declaration and expression position only. A template that produces bare statements (x = 1; y = 2;) has nowhere to go — wrap it in a function, or use comptime for instead.
  • No expansion preview. There is no flag that prints what a macro expanded to, and adding one would need per-token provenance the compiler does not keep. What you can rely on: a diagnostic caused by template text is reported at the invocation, and one caused by an argument you passed is reported at that argument's own position.

Two more sharp edges, both consequences of how matching works rather than oversights:

  • Arity is not enforced by the matcher. A trailing $x:expr with nothing after it consumes the rest of the argument list, commas included, so ($x:expr) happily matches m!(1, 2) — binding 1 , 2, which then splices as the tuple (1, 2). If you want two arguments, put the separator in the pattern. This matches Rust.
  • Two adjacent captures are rejected outright. ($a:expr $b:expr) is an error when the macro is defined, because an expr scan has no token to stop at and any split would be arbitrary. Rejecting it up front beats silently mis-splitting at a call site.

One behavior that is not a limitation, and is easy to assume is: a spliced multi-token expr fragment is parenthesized, so square!(2 + 3) with ($x:expr) => { $x * $x } means (2 + 3) * (2 + 3) (25), not 2 + 3 * 2 + 3 (11). The C preprocessor's most famous footgun does not apply here.

Generics

Generics are compile-time monomorphization, not type parameters, and the difference shows up in four places:

  • A generic type must be instantiated before it is used, textually. Expansion is a single forward pass, so comptime vec_of(...) has to precede both the generator's definition being read and any code naming what it produced. There is no forward reference, unlike every declaration kind other than macro.
  • A generator cannot recurse. Not directly and not through a chain of others: a self-referential data structure has to be written by hand, or built from a fixed number of instantiations. The limit is 1,000 instantiations per module.
  • A /// comment inside a generator body does not reach the declaration it produced. Comments are trivia the expansion pass does not carry, so generated declarations show as undocumented in reedc doc. Document the generator; its own doc comment reaches hover on both the definition and every instantiation.
  • The language server refuses to rename a generator. By the time renaming could run, the declarations an instantiation produced are indistinguishable from hand-written ones, so a rename could not tell what it should touch. Refusing beats corrupting the file. This matches the macro rule above and has the same cause.

Two things that look like limitations but are load-bearing choices:

  • A generator declaring both a type and functions needs two name arguments. A type must be PascalCase and a function snake_case, and a PascalCase name may not contain an underscore — so new_IntVec cannot be derived from IntVec. That is the naming rule doing its job, not a gap.
  • std.vec's element type must be defaultable. Growth allocates a default-initialized array, and a non-null &Foo has no default value, so a vector of structs is spelled ?Foo and narrowed with is. This is WebAssembly's constraint, not the generator's.

Editor tooling (LSP)

  • Full-document sync only — no incremental didChange. Fine for the files this language is meant for; don't expect it to be pleasant on something huge.

  • The tree-sitter-reed grammar used for syntax highlighting is deliberately more permissive than the compiler: it doesn't check naming case, namespace resolution, mutability, feature gates, or which opcodes exist in the active catalog. Clean highlighting is not proof the compiler will accept the file — the grammar and the compiler are also allowed to drift out of sync with each other during active development, so a mismatch between them isn't automatically a bug in either one.

  • Go-to-definition works across files, except into Reed's own libraries. It used to search only the open buffer and return nothing whenever the declaration lived in an imported file — which, in a multi-file project, is most names. It now resolves into the declaring file, and follows a use util.{add as plus} alias to the original declaration, because a symbol's span carries the file it came from and the analysis already holds that file's path and text.

    What still answers nothing is a name from a std module or the injected core library: those are compiled into the binary and have no file on disk to open, so their recorded paths (std:math, <reed:core>) are not somewhere an editor can be pointed. Hover still describes them, and reedc doc output is the browsable substitute.

  • Rename cannot rename a declaration in another file, and now says so. It only ever edits the open document, so renaming an imported name used to rewrite the import line and every local use while leaving the declaration where it was — handing the editor a change that makes the file stop compiling ('total' is not declared in 'util.reed'). It now declines, which an editor shows as "cannot rename". Doing it properly means editing every file in the unit, since a name is unique unit-wide and any file may use it.

    A use util.{add as plus} alias is renamable, because the alias belongs to the importing file alone: rewriting as plus and its uses there is a complete edit. Note the two spellings sit on the same line, and only the cursor position distinguishes them — renaming add there is declined, renaming plus is not.

  • Hover/rename/reference ranges are inferred heuristically (from "the next thing that starts"), not computed from exact source positions, because the compiler's diagnostic spans are single points with no end offset. Usually right, occasionally off by a token at a boundary.

  • The compiled-output views used to run and show nothing. They answered correctly the whole time; what was missing was the last hop. A language server has no standard way to open a buffer, so the code action handed its text back as the workspace/executeCommand result — which is well-formed LSP that no stock client displays. Helix and Zed both discard it, so the menu entry produced no buffer, no message, and no error. They now send window/showDocument, falling back to window/showMessage only when the client answers that it could not open the document. Worth knowing because the shape recurs: a server can answer a request perfectly and still be invisible if nothing asks the client to display the answer.

    The fallback was originally chosen by the client's advertised window.showDocument capability, which is the obvious reading of the spec and recreated the same bug one level down: Zed honours the request without announcing it (zed-industries/zed#61572), so every Zed user got a whole module as a popup instead of a buffer. The request is now always attempted and the capability treated as a positive signal only. The general lesson is that an absent client capability does not mean the client will refuse — only a refusal does.

  • The compiled-output views (reed/moduleWat, reed/functionWat) need a module that compiles. A document with any error produces no WAT at all, so they report that instead of showing partial output — there is no "compile what you can" mode.

  • reed/functionWat slices the module's WAT by function name rather than re-lowering one function, so it shows exactly what the compiler emits, but it can't show anything for an imported function (an import has no body in the output) and it depends on the surrounding module still compiling. It also has nothing to show for a function inside a comptime func template body — that function is never compiled as written, so it says so and points at the generated functions instead. A function generated by a comptime for does work: the loop body is real code, so the cursor there resolves to whichever generated function it produced.

  • A macro expansion is displayed as re-rendered tokens, not as original source text: whitespace, line breaks, and redundant parentheses are reconstructed heuristically, because a template's tokens all carry the invocation's span and no layout of their own. A macro that expands to another macro shows one section per step; since every step is attributed to the same invocation span, they cannot be told apart by position.

  • Both kinds of view are returned as text for the client to place. A language server has no standard way to open a buffer, so how (or whether) the text is shown is entirely up to your editor.

  • Go-to-definition on a generated declaration lands on what produced it, not on a declaration of its own. There is no declaration of its own to land on: make_int_box comes from pub func [<make_ $prefix>], so that spelling appears nowhere in the file. You get the instantiation for a comptime func, or the loop body for a comptime for — both places you can act on, but each is one jump short of the generator itself.

  • A param whose value the compiler could not compute has no hover. A default that is out of range for its declared type is an expansion error, so there is no value to describe; the declaration is reported as an error instead. Every other position in the file keeps working.

  • Field completion resolves a chain, but only the base may be a binding. b->cells, self-> inside a comptime func template, and a chained outer->middle->inner-> all work: the base resolves through the binding and global tables, and each later hop through the previous type's field list. What a hop cannot be is anything other than a field of a struct or packed struct, so a chain through a call result (get()->) or an array element (items[0]->) still offers nothing. It offers nothing rather than guessing, since -> admits exactly one correct set and a wrong suggestion is one you accept and then debug. Bind the intermediate to a local and the completion works from there.

The playground

The in-browser playground runs the real compiler compiled to wasm32. It's checked to be byte-identical to the native compiler's output only against a curated set of examples (mise run docs:verify-wasm) — not against arbitrary input. Treat it as a convenience for trying syntax, not as the reference implementation. It has its own checkbox list for toggling individual lints, the browser equivalent of the CLI's --allow — unlike --allow, an unrecognized name reaching the compiler here is silently ignored rather than rejected, since the checkbox list itself is generated from the same name list the compiler validates against, so there's no independent source of a typo to catch.

Diagnostics only show one phase at a time

Errors are bucketed into syntax > resolution > feature > type > lowering, and only the earliest non-empty bucket is reported. Fixing every syntax error can therefore reveal a brand-new set of type errors — the error count going up after a fix is normal. This applies to errors only: a warning (see Lints below) never blocks compilation and is never filtered by this rule, since it's only ever produced by a phase that ran to completion. See Diagnostics.

Lints

Every warning is a named lint, individually suppressible with a repeatable reedc build/check --allow <name> — see Diagnostics' "Warnings (lints)" for the current list and messages. A couple of things worth knowing that aren't obvious from the table:

  • No source-level suppression. There is no #[allow(...)]-style annotation — suppression is CLI-only, applies to the whole file, and there is no way to elevate a lint to a hard error. A leading _ on a local or parameter name is the one source-level opt-out, and only for unused-local/unused-param/unnecessary-var.
  • unused-decl is shallow, not a reachability graph. A struct/function/etc. referenced only by another declaration that is itself unused still counts as "used" and won't be flagged. Catching that would need a full reachability analysis from exports/start, which this lint deliberately doesn't attempt.
  • A lint never surfaces next to an error. check_all_functions collects warnings only once every function has typechecked with zero errors — a module with any error reports errors only, never a mix of severities. Don't expect a lint to explain itself on a file that doesn't fully compile yet.
  • deprecated-call is all-or-nothing per compilation. --allow deprecated-call silences every deprecation in the unit, not the one call you have decided to keep. There is no per-call opt-out, for the same reason there is no #[allow(...)] above.
  • A @deprecated use inside a deprecated function is not reported. That is deliberate — there is no migration to perform inside code already marked as going away — but it does mean a deprecated function's own body is unchecked territory during a migration. Once you undeprecate it, every deferred warning appears at once.

@deprecated

  • Only a func can carry it. A deprecated struct, global, or type is rejected as a syntax error rather than accepted-and-ignored. The spec permits an implementation to widen this; the compiler has not.
  • It is not machine-readable in the output. Deprecation reaches the diagnostic stream, the language server, and reedc doc. Nothing about it is emitted into the WebAssembly module, so a consumer reading only the .wasm cannot see it. Emitting a custom section would be the way to change that.

Generated documentation (reedc doc)

  • No cross-references. A signature naming &Point is plain text, not a link to Point's own entry. Names are unique across a compilation unit, so resolving them would be unambiguous — it is simply not implemented.
  • No syntax highlighting in the generated site. Signatures and fenced code are monospaced and unstyled. The site is dependency-free by design (it opens from a file:// URL with no build step), and a highlighter would mean shipping either JavaScript or a second copy of the tree-sitter grammar.
  • Search is your browser's find. There is an alphabetical index of every name on the front page, but no search box.
  • Documentation is not checked against the signature. A @param naming a parameter that does not exist, or a missing @param for one that does, is rendered as written and never reported. There is no -Wmissing-docs either: undocumented entries are listed as undocumented, which makes the gaps visible without making them an error.

Modules

  • One flat namespace across the whole compilation unit. A name must be unique across every file, not per file: two files cannot both define add. Reed emits one WebAssembly module whose WAT names come straight from source names, and mangling them would defeat the readable-output guarantee. There is no aliasing form (as) yet, so a collision is resolved by renaming one of them.
  • No separate compilation and no caching. Every file in the unit is re-parsed on every build. Fine at present scale; it is not an incremental compiler.
  • No package or dependency management. An import is a filesystem path (or a std: module), and that is the whole build graph. There is no manifest, no version resolution, and no notion of a third-party package: the standard library is the only non-filesystem source of code, and it ships with the compiler.
  • Visibility is now enforced at the use, and that is a breaking change. Previously a unit was merged into one module with one flat namespace before resolution ran, so any name in the unit resolved from any file: writing secret_helper() with no import naming it compiled, and a name reached through a chain of imports did too. pub caught the mistake only at the import line. Both now fail, naming the file that declares the name. A program relying on either — most likely on an import chain, since that reads as working code rather than as a mistake — needs an explicit import, or a pub import in the intermediate file. The language server no longer offers an invisible name in completion either, since accepting the suggestion would write code the compiler rejects.
  • A re-export cannot rename or narrow. pub import (see modules) forwards exactly the names it imports. There is no as aliasing form, so a facade cannot present a name under a different spelling, and it cannot re-export a subset of a glob — list the names instead.
  • A macro cannot cross an import cycle. Cycles themselves are fine now, but expansion is a single forward pass, so a macro declared in one file of a cycle is not visible in another -- there is no ordering that satisfies both directions. The diagnostic says so rather than suggesting you move the definition. Move the macro to a file outside the cycle.
  • Nothing warns about an unnecessary pub import. A pub import whose re-exported names nobody imports is indistinguishable from a plain import in the emitted module (both emit nothing), and no lint reports the difference. unused-decl covers declarations, not import edges.
  • unused-decl is unit-wide, and counts pub too. A pub declaration that nothing in the compilation unit uses is still reported as unused, because a unit is closed: it is rooted at the file you asked to build, so there is no "someone else might import this later". Suppress it with --allow unused-decl while a library file is still being written against.
  • The playground's editor is single-buffer. The wasm compiler behind it does accept a set of files and produces byte-identical output to the CLI, but the page has one editor pane and no way to add a second file, so an import typed there reports "no such file". Multi-file programs need the CLI for now.

Standard library

  • It shares your namespace. std: modules are merged into the same flat namespace as your code (there is no mangling — see Modules above), so declaring your own IntList while importing std.list is a duplicate declaration. Import only the names you need if that becomes awkward.
  • A literal receiver needs a type where two types share a method name. pow exists on i32 and i64, so (2).pow(10) cannot pick one and is an error; bind the receiver first. This is spec section 7.1 declining to guess, not a library defect, but the library is where most people will first meet it.
  • Strings are GC byte arrays, not linear memory. A Str cannot be handed to a WASI or C host function expecting a pointer and a length. A module that needs that declares its own memory and copies — the library cannot, since a memory would be imposed on every importer.
  • A string literal holds text, not bytes. data D = "\u{80}"; encodes as UTF-8 (two bytes), so a data segment cannot express an arbitrary byte sequence. Building deliberately malformed UTF-8 means writing the bytes into a Str one at a time.
  • No I/O, no time, no entropy. All three need a host import, and the shape of that import is the program's decision (WASI, a browser, a custom embedder). std.rand is seeded explicitly and is not cryptographic.
  • Everything allocates. Each Str and list operation returns a fresh GC object. Use str_concat_all rather than repeated concat in a loop, which is quadratic in bytes moved.
  • IntSet/IntMap iteration order is unspecified and changes as the table grows. Sort the result if you need a stable order.

Formatting

reedc fmt normalizes layout, but it is deliberately not a full pretty-printer, and a few of its non-behaviours are easy to mistake for bugs:

  • It does not choose your brace layout. A body written on one line stays on one line; the same body written across lines stays across lines. Only the spacing and indentation within your chosen layout are normalized. Running fmt will therefore never collapse or expand a function for you, and two files in the same project can legitimately disagree about which they use.
  • It does not break long lines that contain no brackets. --max-width is respected by splitting (...)/[...] groups only. A long chain of binary operators, or a very long single identifier chain, is left over-long — there is no operator-level line breaking.
  • It never adds a trailing comma, only preserves one. Comma-less struct fields are legal, so synthesizing a comma would add a token that was not there.
  • It refuses a file that does not parse, rather than partially reflowing it. Formatting a directory reports such a file, skips it, and continues with the rest; the exit code is still 1.
  • It formats comments' position, not their content. A comment keeps its own line, or stays trailing the code it followed, and gets re-indented — but its text (including any hand-drawn alignment inside it) is never rewrapped or touched.

If fmt ever produces output whose tokens differ from the input's, it reports an internal error and leaves the file unchanged rather than writing it. That check is the one thing about the formatter that is not best-effort.

The Binaryen backend (reedc-binaryen)

reedc-binaryen compiles a unit into a Binaryen module for a Rust embedder. Binaryen accepts everything reedc emits, so there is no representability limit here at all — GC structs stay GC structs. What remains is about the build and about names:

  • It is a separate binary, and it needs a C++ toolchain. Linking Binaryen means compiling its C++ through cmake, so reedc-binaryen cannot be a subcommand of reedc without putting that on the critical path for every build. Cargo also forbids the dependency edge outright: reedc-binaryen declares its own workspace, and a path dependency on such a crate is rejected with "multiple workspace roots found".
  • The vendored Binaryen is a git submodule, so a fresh clone or a new worktree has an empty vendor/binaryen and the build says so rather than failing inside cmake. Run git submodule update --init vendor/binaryen.
  • Binaryen 131 or newer is required, and this is enforced at build time. Older versions abort the process on a try_table whose body is unreachable, which is ordinary output for any Reed try/catch — their dead-code pass has no TryTable case while claiming to handle one. An abort() is not something a Rust caller can recover from, which is why the check refuses rather than warns.
  • A name collision with the host module is refused, not resolved. Binaryen identifies entities by name, and its addFunction calls Fatal() on a duplicate, so merging is checked up front and reports every colliding name. Rename with Module::rename_function, which follows every reference.
  • The playground cannot reach any of this. reedc-binaryen links a native library; the playground is a wasm32 build.
  • The generated Rust is not rustfmt-clean by construction. It compiles and is readable, but if it lands in a repo that checks formatting, run rustfmt over it or keep it out of the check.
  • --emit wat gives Binaryen's text, not reedc's. The comments that make reedc's output readable are not in the binary and cannot come back; use reedc build for those.

Where the actual rules live

This page is "what to watch out for." It is not the grammar, the type rules, or the lowering requirements — for those, read the authoritative specification. When anything here, the guide, or the compiler disagrees with it, the specification wins.

Faster lookups: syntax cheat sheet · opcode catalog · diagnostics