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, andfirst/lastare recognized in specific positions but are not reserved. All remain usable as ordinary identifiers, which is why adding them broke nothing. Contrastswitchandunreachable, 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:
| Was | Now |
|---|---|
.wast source files | .reed |
the wastc binary | reedc |
catalog wastc-core-gc-eh-0 | reedc-core-gc-eh-0, since bumped to reedc-core-gc-eh-simd-threads-1 |
```wast doc fences | ```reed |
tree-sitter-wast | tree-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 throughwasmtimeand checks the WAT output's structure. It only proves what it covers.tests/lsp_integration.py, a committed scripted JSON-RPC client that spawnsreedc lspand 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 theelsebranch. struct.newreordering field-initializer side effects instead of preserving source order.- Multi-parameter
block/loopbinding 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 parse — parse 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-1 — try/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
Nbits of whatever it is given, in an initializer and in an assignment alike. This matches packedi8/i16storage 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
asand shifts. A field's type may name a sizedenum, which contributes a width and a signedness -- that is a spelling for the bits, not nesting. - An i31-represented value converts to
&i31/&eq/&anyand 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 : u4range-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.
u4rounds to ani8field,u12toi16, so widening the enum later changes the emitted field type from an edit that looks source-compatible. Identical exposure to declaringi8directly, but the enum spelling hides it one level further away. - The rounding ladder stops at
i16. WebAssembly has noi24, so au20enum 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 andi64'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. boolis not a valid representation. It is already a transparent alias ofi32, so an enum over it could hold nothingu1cannot.
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};becomesuse util.{add};. No quotes, no extension.- A path is dotted:
use net.http.{get};is the filenet/http.reed. The last segment is the file; earlier segments are directories. import "std:math".*;becomesuse std.math.*;. Thestd:scheme is gone;stdis a reserved path root.pub import ...becomespub 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. Writeuse util.{add};even for one name. useis 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 onimport, 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.reedorcore.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 thatuse 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 thethrowis 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
trythat 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 athrowsclause 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 throwA. Catching one tag and propagating the rest requires thetry/catch as erethrow 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: acatch Tag(...) as econtributes exactly that tag, and acatch as econtributes 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. throwsis not part of a function type's identity for&references beyond its declaration. Atype 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.throwsandboolare 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 aCirclecallsShape.describe, notCircle.describe. There is no virtual dispatch, no vtable, and nodynamic/overridekeyword. If you need runtime behavior selection, test the type withisand 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?Pis an error even whenP.getexists; narrow first, or callP.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 asclz,div_u,sqrt,copysign, andmul_wide_uare ordinary methods from the default core library: you cannot redeclare the same qualified core name (i32.clzorf64.sqrt), but another receiver such asPoint.clzorCounter.sqrtis allowed. @inlinemethods are still thin functions. They inline at bothx.m(...)andT.m(x, ...)call sites, but they obey the ordinary@inlinerestrictions: no export,start, function reference, table entry, orelementry, because no callable function is emitted.- A method's name is not private, and not namespaced.
Point.normis one entry in the one function namespace. Two types may each declarenorm; one type may not declare it twice. A plainfunc norm(...)can coexist withfunc 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 rawwasm.*spellings specifically, but all four are reachable through dedicated surface syntax:new Name[count] data<D>(offset)/elem<E>(offset)for the first two, andMem.init<SomeData>(...)/Table.init<Handlers>(...)plusSomeData.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_refor 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)narrowsxonly whenxis a bare immutable local (letor a parameter). On avar, 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 theif, 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-patternswitchhas the identical restriction and now warns the same way, at its scrutinee. - A statically impossible
istest still folds to a constant-false condition and the true arm is still dead code, but compiling now emits a warning at theifpointing 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 (selectwas already the one exception that errored) now rejects a stray one as a compile error instead. - A stale output file. A failed
reedc buildused to write nothing, leaving a previous run's.watin place with no way to tell it apart from current output except the exit code.reedc buildnow 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 foriN.constspecifically — seetests/cases/raw_ops/negative_const*— but the unsigned spelling still works identically and is still what every other opcode'sImmediate::Intexpects;-is not a general immediate-grammar feature.ref.test/ref.castonly downcast. The immediate must be a subtype of the operand's type.ref.i31produces a non-null&i31, buti31.get_s/i31.get_utake a nullable?i31. Theas &i31/as i32.s/as i32.ucast-expression sugar over these three carries the same asymmetry; unboxing a nullable?i31viaasadditionally triggers thenullable-i31-unboxlint.table.growis(value, delta), not(delta, value).call_indirecttakes 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
importof 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 aparam's bare name, or a constant-eligiblewasmop (which now includes GC allocation, extended-consti32/i64arithmetic, and0 as &i31) — the dedicatednew 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 theextended-constproposal enabled. Most modern runtimes already support it — including thewasmtimeversion 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_caseresolves as a local,PascalCaseresolves as a global. Get the case wrong and you get a different binding, not a lint warning. switchis now a reserved word. It used to be a legal identifier, sofunc switch(...)orlet 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 namedswitchstart identically, and telling them apart would need a balanced-paren lookahead for the following{, which this parser does nowhere else.caseanddefaultare not reserved — they only appear inside aswitchbody, so they remain usable as ordinary names.unreachableis now a reserved word. It used to be a legalsnake_caseidentifier, sofunc unreachable()orlet unreachable: i32 = 1;compiled. Both are now syntax errors (expected identifier, found keyword 'unreachable'). Rename any such binding. Unlikeswitch, 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 whatunreachable;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
identcapture, 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 usecomptime forinstead. - 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:exprwith nothing after it consumes the rest of the argument list, commas included, so($x:expr)happily matchesm!(1, 2)— binding1 , 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 anexprscan 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 thanmacro. - 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 inreedc 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_IntVeccannot be derived fromIntVec. 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&Foohas no default value, so a vector of structs is spelled?Fooand narrowed withis. 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-reedgrammar 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
stdmodule 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, andreedc docoutput 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: rewritingas plusand its uses there is a complete edit. Note the two spellings sit on the same line, and only the cursor position distinguishes them — renamingaddthere is declined, renamingplusis 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/executeCommandresult — 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 sendwindow/showDocument, falling back towindow/showMessageonly 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.showDocumentcapability, 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/functionWatslices 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 acomptime functemplate body — that function is never compiled as written, so it says so and points at the generated functions instead. A function generated by acomptime fordoes 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_boxcomes frompub func [<make_ $prefix>], so that spelling appears nowhere in the file. You get the instantiation for acomptime func, or the loop body for acomptime for— both places you can act on, but each is one jump short of the generator itself. -
A
paramwhose 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 acomptime functemplate, and a chainedouter->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 forunused-local/unused-param/unnecessary-var. unused-declis 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_functionscollects 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-callis all-or-nothing per compilation.--allow deprecated-callsilences 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
@deprecateduse 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
funccan carry it. A deprecatedstruct,global, ortypeis 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.wasmcannot see it. Emitting a custom section would be the way to change that.
Generated documentation (reedc doc)
- No cross-references. A signature naming
&Pointis plain text, not a link toPoint'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
@paramnaming a parameter that does not exist, or a missing@paramfor one that does, is rendered as written and never reported. There is no-Wmissing-docseither: 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 noimportnaming it compiled, and a name reached through a chain of imports did too.pubcaught 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 explicitimport, or apub importin 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 noasaliasing 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. Apub importwhose 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-declcovers declarations, not import edges. unused-declis unit-wide, and countspubtoo. Apubdeclaration 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-declwhile 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
importtyped 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 ownIntListwhile importingstd.listis 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.
powexists oni32andi64, 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
Strcannot be handed to a WASI or C host function expecting a pointer and a length. A module that needs that declares its ownmemoryand copies — the library cannot, since amemorywould be imposed on every importer. - A string literal holds text, not bytes.
data D = "\u{80}";encodes as UTF-8 (two bytes), so adatasegment cannot express an arbitrary byte sequence. Building deliberately malformed UTF-8 means writing the bytes into aStrone 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.randis seeded explicitly and is not cryptographic. - Everything allocates. Each
Strand list operation returns a fresh GC object. Usestr_concat_allrather than repeatedconcatin a loop, which is quadratic in bytes moved. IntSet/IntMapiteration 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
fmtwill 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-widthis 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, soreedc-binaryencannot be a subcommand ofreedcwithout putting that on the critical path for every build. Cargo also forbids the dependency edge outright:reedc-binaryendeclares 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/binaryenand the build says so rather than failing insidecmake. Rungit 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_tablewhose body is unreachable, which is ordinary output for any Reedtry/catch— their dead-code pass has noTryTablecase while claiming to handle one. Anabort()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
addFunctioncallsFatal()on a duplicate, so merging is checked up front and reports every colliding name. Rename withModule::rename_function, which follows every reference. - The playground cannot reach any of this.
reedc-binaryenlinks 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, runrustfmtover it or keep it out of the check. --emit watgives Binaryen's text, not reedc's. The comments that make reedc's output readable are not in the binary and cannot come back; usereedc buildfor 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