Skip to main content

Tooling and generated output

Reed treats generated WAT as something you should be able to read, diff, and debug. The tools are built around that idea.

Readable WAT output

A conforming implementation emits readable WebAssembly Text. The output includes comments that identify the Reed declaration, statement, or expression that caused an instruction sequence.

;; return v + v;
(return (i32.add (local.get $v_0) (local.get $v_0)))

An implementation may also provide .wasm binary output. Optimization is optional and must be an explicit flag; it must preserve observable behavior and traps.

Command-line interface

The reference compiler, reedc, has five subcommands: build, check, fmt, doc, and lsp. There are intentionally few flags. There is no verbosity switch, catalog selection flag, or color setting.

A sixth command lives in a separate binary, reedc-binaryen, because it links Binaryen and so needs a C++ toolchain that ordinary compilation does not.

reedc build [<INPUT>] [-o <OUTPUT>] [--tail-calls] [--allow <LINT>]... [--define NAME=VALUE]...
# an <OUTPUT> ending in .wasm is assembled to a binary; anything else is WAT text
reedc check [<INPUT>] [--tail-calls] [--allow <LINT>]... [--define NAME=VALUE]...
reedc fmt [<PATH>...] [--check] [--stdout] [--indent <N>] [--max-width <N>]
reedc doc [<INPUT>] [-o <OUTPUT>] [--title <TITLE>] [--format html|markdown] [--include-core] [--allow <LINT>]... [--define NAME=VALUE]...
reedc lsp
reedc-binaryen <INPUT> [-o <OUTPUT>] [--emit wasm|wat|rust] [-O <LEVEL>] [--pass <NAME>]...
# [--check] [--module <NAME>] [--sys-path <PATH>] [--list-passes]
# [--tail-calls] [--allow <LINT>]... [--define NAME=VALUE]...

lib.reed is the default input

build, check, and doc each take one file: the root of a compilation unit, which pulls in everything it imports (see modules). Omit it and they use lib.reed in the current directory.

reedc build # same as: reedc build lib.reed -> lib.wat
reedc check # same as: reedc check lib.reed
reedc doc # same as: reedc doc lib.reed

The output path is still derived from the input, so a bare reedc build writes lib.wat. There is no manifest file and no project-wide configuration: the convention is only this filename. If you omit the argument and there is no lib.reed, the error says so and names it, rather than reporting a missing argument or a bare "no such file".

fmt is deliberately excluded. Running it with no arguments already means something else (read source from stdin, write the formatted result to stdout), and it takes any number of paths rather than a single unit root.

Output format follows the output extension

reedc build writes WAT text. Naming the output file .wasm assembles that text to a binary module as well:

reedc build # -> lib.wat, from lib.reed
reedc build hello.reed # -> hello.wat
reedc build hello.reed -o hello.wat # -> hello.wat
reedc build hello.reed -o hello.wasm # -> hello.wasm, a real binary module

The extension is the only thing that switches the format — there is no --emit flag, and an unrecognized extension still gets text. Readable WAT remains the compiler's output format (section 15); the binary is assembled from exactly the text reedc just produced, so the two cannot describe different modules. That saves needing wasm-tools on your PATH for the common case.

stdout is for formatted source, and nothing else

Only fmt writes to stdout. For build, check, doc, and lsp, success banners, diagnostics, and errors all go to stderr. (reedc-binaryen writes generated Rust or WAT to stdout when given -o -, an explicit opt-in rather than the default; it refuses to write a binary there at all.) That means reedc build x.reed > log.txt captures an empty file; redirect with 2> instead. fmt writes formatted source to stdout in stdin filter mode and under --stdout, while its diagnostics still go to stderr, so reedc fmt < a.reed > b.reed is a safe pipeline.

Exit codes

CodeMeaning
0clean build (file written), clean check, generated docs, a clean fmt, or a clean LSP shutdown
1any diagnostics, a read error, a write error, an LSP error, or fmt --check finding an unformatted file
2argument/usage error (missing subcommand, unknown flag) — from clap

There is no distinct code separating compile errors from I/O errors.

build

Compiles a module to WAT. The input is the root of a compilation unit: every Reed file it imports, transitively, is compiled with it into the same single .wat. A diagnostic from an imported file is prefixed with that file's path; one from the root file is not, so single-file output is unchanged. If -o/--output is omitted, the output path is the input path with its final extension replaced by .wata.b.reed becomes a.b.wat, and an extensionless noext becomes noext.wat. Output lands next to the input, not in the working directory.

$ reedc build examples/double.reed
type warning at 1:10: function 'double' is never used [unused-decl]
reedc: catalog reedc-core-gc-eh-simd-threads-1 -> examples/double.wat

(examples/double.reed never exports double, so this particular run also prints an unused-decl lint — a warning never blocks the write, per Lints.)

A failing module reports diagnostics and exits 1. No output file is written — a stale .wat from a previous successful build is left in place, not truncated. Check the exit code; don't infer success from the file existing.

I/O failures report as error: could not read '<path>': <reason> or error: could not write '<path>': <reason>.

check

check runs the same analysis as build over the same whole compilation unit, but never writes a file. Use it for CI and editor workflows that need diagnostics without touching output.

$ reedc check examples/double.reed
type warning at 1:10: function 'double' is never used [unused-decl]

That's a clean check (exit 0) with a warning attached — the reedc: <path> -- no errors success banner only prints when the diagnostics list is completely empty, not merely when there's no error. A module with only warnings, like this one, still exits 0 but skips the banner.

check and build share one code path, so they always agree on whether a module is valid; a module that fails check also fails build, and vice versa. Diagnostics are printed one per line as <phase> error at <line>:<col>: <message>:

$ reedc check broken.reed
type error at 2:3: 'return;' is only valid for a function with result '()'

Only the earliest non-empty diagnostic phase is ever reported — see Diagnostics for what that hides and why.

doc

doc generates an API reference from a compilation unit. It analyzes the same whole unit as build, so signatures are resolved types, imported files are included, and a compile error stops generation rather than producing a reference to a program that does not exist.

$ reedc doc src/main.reed
reedc doc: wrote 3 file(s) to doc/index.html

That writes a standalone website into doc/: an index with a module list and an alphabetical index, one page per source file, and a stylesheet. It has no external dependencies and no build step — opening doc/index.html from the filesystem works. Use -o to write somewhere else.

Each module page lists declarations by kind (functions, methods, types, globals, tables, memories, tags, exports). Every kind gets a summary table of names and first sentences, followed by full entries with the signature, the documentation, and a source line.

Three things make the page usable rather than just complete:

  • Syntax highlighting, driven by the compiler's own lexer rather than a second grammar. A keyword added to the language is highlighted the day it is added, with no separate list to update.
  • View source. Every entry with a body carries a collapsed Source disclosure holding the declaration's text, sliced verbatim out of the file — comments and all, exactly as written. It is plain <details>, so it works with JavaScript off and a browser's own find-in-page can open it.
  • Search. A box in the sidebar, / to focus, matching names first and then documentation text. The index is inlined into each page rather than fetched, so it works from a file:// URL where fetch is blocked.

Everything is listed, documented or not

A declaration with no /// comment still appears, with its signature and an explicit "No description available for this declaration." marker.

This is deliberate and is the main behavioral difference from a generator that lists only documented items. Omitting an undocumented function makes the reference actively misleading: a reader cannot tell "this module has no such function" from "nobody wrote a comment", and the missing half is invisible precisely to the person least able to notice it. Listing it also makes the gaps greppable, which is what turns "we should document this" into a list.

Documentation comments

A /// block attaches to the declaration below it. Any declaration that introduces a name can carry one, not just a func:

/// A point in two dimensions.
struct Point {
  x: mut i32,
  y: mut i32,
}

/// Adds two numbers.
///
/// The body is Markdown: `code`, **emphasis**, lists, and fenced blocks all render.
///
/// @param a the left operand
/// @param b the right operand
/// @return their sum
/// @see `sub`
func add(a: i32, b: i32) -> i32 { return a + b; }

Three tags are recognized, each on its own line: @param <name> <description>, @return <description>, and @see <reference>. A tag's description continues onto following lines until the next tag or a blank line. A line that does not begin with a recognized tag is ordinary body text, so a @typo is rendered as prose rather than silently swallowing the paragraph after it.

Only a function's documentation is also shown by the language server (in hover, completion, and signature help); the rest is used by reedc doc.

Deprecation

A function marked @deprecated is rendered with a badge and a banner carrying its migration note, in both output formats. It is still listed: a reference that hides deprecated entries is useless to exactly the reader who has found one in existing code and needs to know what replaced it.

Documenting Reed's own libraries

--include-core additionally documents Reed's default core library — the i32.clz/f64.sqrt methods every unit gets for free — and any std: module your program imports, each as its own page. It is off by default because core is in every compilation unit and a std: module is a dependency rather than the subject, so including either unasked would bury your own module.

This site's core library reference is generated that way, and is served exactly as reedc doc writes it — a working sample of the output.

Core has no path on disk, so it cannot be the <INPUT>: it is injected into every unit, and compiling it as a user file reports every declaration as a duplicate of itself. Point the command at any module and add the flag. The same goes for a std: module — it is documented as a dependency of whatever you pointed at, never as the input itself.

Markdown output

--format markdown writes a single Docusaurus-compatible page instead, for feeding an existing docs site:

$ reedc doc src/main.reed --format markdown -o docs/content/reference/api.md --title "My API"

There the output path is a file, defaulting to the input path with a .md extension. Both formats render from the same model, so they always describe the same declarations.

--tail-calls

Accepted by both build and check. It lowers an eligible return of a direct call to return_call (and an indirect one to return_call_ref). It is an output mode only: it changes how a valid program lowers, never which programs are valid.

On check it is therefore a no-op for the pass/fail result, since check discards the WAT. Its help text is also blank — that entry is missing a doc comment, not a hidden feature.

--allow

Accepted by both build and check, repeatable (--allow foo --allow bar). Each occurrence takes one lint name and suppresses that lint for the whole file — see Diagnostics' "Warnings (lints)" for the current list of names and the messages they produce. An unrecognized name is a hard error, exit 1, not silently ignored — the message lists every valid name.

$ reedc build foo.reed --allow unused-local --allow self-assignment

fmt

Formats Reed source. The CLI, language server, and playground use the same formatter, so a file should not bounce between different house styles depending on where you save it.

$ reedc fmt examples/ # rewrite every .reed file under a directory, in place
$ reedc fmt a.reed b.reed # or specific files
$ reedc fmt --check . # CI mode: name the files that need formatting, exit 1
$ reedc fmt --stdout a.reed # print the result, leave the file alone
$ reedc fmt < a.reed > b.reed # with no paths at all: stdin to stdout

A path that is a directory is searched recursively for .reed files, in a deterministic order. --check prints one path per line (on stdout, so it pipes) and exits 1 if any file would change, without modifying anything.

What it normalizes:

  • Spacing between tokens, in full. v:i32 becomes v: i32, a+b becomes a + b, and -1 - -2 keeps unary minus tight against its operand while spacing the subtraction. Immediates stay tight (wasm.i32.const<3>()), and so do a->field, a[i], f(x), "env"."log", a macro capture's :, and token pasting.
  • Indentation, two spaces per level by default (--indent N to change it).
  • Blank lines, to at most one consecutive, and none directly inside a brace's edges.
  • Trailing whitespace, and the file's final newline.

What it deliberately leaves alone:

  • A brace group's line layout. func f() -> i32 { return 1; } stays on one line, and the same function written across three lines stays across three. Both are idiomatic Reed and both appear throughout these docs, so the formatter normalizes within the layout you chose rather than imposing one.
  • A trailing comma is kept where you wrote one and never added where you did not. Comma-less struct fields are legal, so adding a comma there would change the tokens. For the same reason, a comma-less field list keeps your line breaks: with no commas to separate the fields, those breaks are the only thing marking where one ends and the next begins, and the formatter cannot supply a separator to replace them.
  • Long expressions with no brackets to break inside. Only (...) and [...] groups are split to respect --max-width (100 by default); a long binary-operator chain is left over-long rather than broken at an operator.
  • Anything that does not parse. A file with a lexical or syntax error is reported and skipped, not partially reflowed — reflowing text the parser cannot make sense of risks changing what it means once the error is fixed. Formatting a directory continues past such a file rather than stopping.

Formatting a program never changes what it compiles to. That is not an aspiration: the formatter re-lexes its own output and refuses to write a file whose token stream differs from the input's, and the repo's test suite checks that the emitted WAT is byte-identical before and after formatting for every .reed file it contains.

reedc-binaryen (separate binary)

Compiles a unit into a Binaryen module, so Reed can be used as a front end for a wasm generation or transform tool instead of building an expression tree by hand. Reed's GC structs and arrays are represented natively; nothing is lowered.

--emit chooses the output: wasm (the default) writes a binary, rust writes a module an embedder drops into their crate, and wat writes Binaryen's own text rendering. -O0 through -O3, -Os, and -Oz run Binaryen's optimizer with wasm-opt's meanings, defaulting to no optimization so what you get is what the compiler emitted; --pass runs individual passes, and --list-passes prints every name it accepts. --check reports the unit's exports, pub functions, and compile-time values without writing anything.

It is a separate binary because linking Binaryen means compiling its C++ through cmake, which reedc build has no reason to require. Build it with mise run binaryen:build.

See Embedding Reed in Rust with Binaryen for the whole surface.

lsp

Starts a Language Server Protocol server on stdio for editor integration. It supports:

  • Diagnostics — published on didOpen/didChange/didClose, using the same phase-ordered diagnostics build/check report.

  • Hover — signatures and shapes for functions, globals, structs, arrays, function types, tables, memories, and tags, and the declared type of a parameter or let/var/for-loop local at the cursor's position. Every hover body is a Reed declaration sent as a fenced code block, so an editor syntax-highlights it; a documented function additionally gets its doc comment below the signature.

  • Document symbols — an outline of every declaration in the module, not just functions and globals.

  • Completion — names and signatures for every declaration kind in the module, every parameter and local in scope at the cursor's position, and every opcode in the wasm. escape hatch's instruction catalog while typing one, with per-opcode documentation and a stack signature sourced from vendor/wasm-ops/data. Inside a macro template or a comptime func body it also offers that construct's own compile-time names, and a name that came from another file is labelled with where it came from — see inside a template and where a name came from. Typing . or > requests a list, so a struct's fields appear as soon as you finish the -> rather than after a first character — and a chain resolves hop by hop, so outer->middle-> offers Middle's fields, not Outer's. A hop through something that is not a struct (an i32 field, an array, a name that does not exist) offers nothing rather than falling back to the base type's fields, which would be confidently wrong. Inside an import, the quotes offer importable paths — every std: module always, plus any file the unit already loads — and the item list offers that module's pub names, withholding private ones so a suggestion is never an import the compiler then rejects.

  • Go to definition — jumps from a use site to the declaration, including into an imported file, and following a use x.{a as b} alias to the original declaration. On a use path segment it opens the imported file itself. A name from a std module or the core library is the exception: those are compiled into the binary with no file on disk to open. For a tag this works from every position its name occupies: the tag declaration, a throws (...) clause, a throw, a catch, and an export.

  • Document highlight — highlights every occurrence of the identifier under the cursor in the current file.

  • Find references and rename — for a parameter or local, scoped to exactly that binding (so a same-named local in another function or a sibling block isn't included or renamed); for a top-level declaration, every textual occurrence in the file. A tag renames completely from any of its five positions, including the throw and catch sites, which look like calls but are not.

    Rename declines a declaration that lives in another file. It can only edit the open document, so renaming an imported name would rewrite the import and every local use while leaving the declaration untouched — a change that stops compiling. A use x.{a as b} alias is renamable, because b belongs to this file alone.

  • Signature help — shows a function's signature and the active parameter while typing a call's arguments.

  • Semantic tokens — classifies every identifier occurrence as a function, parameter, variable, type, or field, so an editor can tell them apart even where the grammar alone can't (tree-sitter already handles keyword/string/comment highlighting; this covers what's left).

  • Formatting — whole-document textDocument/formatting, using the same formatter as reedc fmt, so an editor's "format document" and the CLI can never disagree. An editor's configured tab size is honoured. A document that fails to parse, or one already formatted, produces no edits rather than an error — format-on-save fires on files that are mid-edit too.

  • Compiled-output and macro-expansion views — three read-only text views answering "what does this compile to?" and "what did this macro expand to?" (see below).

Diagnostics, hover, document symbols, completion, definition, references, and rename all keep working on the rest of a file even while one declaration has a syntax error — the compiler's lexer, parser, and resolver each retain their best-effort partial result instead of discarding the whole file over one incomplete statement. Only the declaration actively being edited (and anything textually swallowed by its unclosed braces) goes dark; everything else in the file stays fully usable. Signature help and document highlight are plain text-based operations and work even on a file that doesn't parse at all. Local/parameter completion, hover, references, and rename read directly off the parsed (not typechecked) function body, with an extra token-based pass for in-progress local declarations, so they keep working on a function's parameters and already-declared locals even while a later statement in that same function has a type error or a just-typed let is still missing its trailing ;. Semantic tokens re-tokenizes the raw text directly, so it degrades to "no token" for whatever it can't classify rather than going blank for the whole file.

A macro or comptime func whose closing } has not been typed yet keeps its own outline entry and its own compile-time names, rather than disappearing until the brace is closed. That is the state an editor asks the most questions in, and it used to be the one state where the construct under the cursor was the only thing unavailable.

Inside a template

A macro arm's template and a comptime func body are never compiled as written, so nothing the compiler produced describes the text you are looking at. Both are nevertheless code you read and edit, and completion treats them as such:

  • A macro arm's captures ($value:expr) are offered at their declaration and at every use in that arm's template.
  • A generator's parameters (T: type, Name: ident) are offered throughout its body, splices included.
  • Whatever the template itself declares — a function's parameters and locals, and the fields of a struct reached through one — resolves like ordinary code, because inside the template it is ordinary code.
  • Module-scope names stay reachable, since a template still sits in the module.

Both are closed scopes, matching the language rather than merely the implementation. One generator's parameters are never offered inside another's body, and one macro arm's captures are never offered in a sibling arm's template: arms are alternatives, so suggesting a sibling's $name would suggest a template that cannot expand.

Where a name came from

A compilation unit is one flat namespace — names are unique across every file and nothing is mangled — so an imported declaration is completed under exactly the same bare name as a local one. Each item therefore carries the file it came from, rendered beside the name by editors that support labelDetails:

OriginShown as
the file being editednothing
another Reed file in the unitits base name, e.g. util.reed
a standard library moduleits import path, e.g. std.math
the default core librarycore

Unmarked means yours. A type a comptime func instantiation declared in this file counts as yours, even when the generator itself was imported, because the declaration really is here.

{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"capabilities": {}}}

Point your editor's LSP client at reedc lsp (no flags); it communicates over stdin/stdout using standard Content-Length-framed JSON-RPC, like any other language server.

Compiled-output and macro-expansion views

Three custom requests expose read-only views the standard protocol has no request for. Each takes ordinary TextDocumentPositionParams and answers with {"title": string, "text": string, "error": string | null} — put text in a scratch buffer named title, or show error when there is nothing to show.

MethodShows
reed/moduleWatthe WAT the whole document compiles to, identical to what reedc build writes
reed/functionWatjust the function containing the position, sliced out of that same module output (so type indices and local numbering match what the compiler really emits), with its source-attribution comment kept
reed/macroExpansionthe expansion of the name!(...) invocation at the position, one section per expansion step when a macro expands to another macro

A document with errors produces no WAT at all, so the two WAT views set error to the reason (the same phase-ordered errors the editor already shows squiggles for) rather than returning an empty buffer. A macro expansion, by contrast, still works on a document that fails to typecheck — expansion runs before type checking, and "what did this expand to" is exactly the question being asked when the expansion is what broke the later phase.

All three are also offered as ordinary code actions (textDocument/codeAction), each carrying a Command whose name is the matching method and whose arguments are the document URI and the position, so an editor with no Reed-specific configuration can still reach them from its usual code-action menu. Selecting one sends workspace/executeCommand, which the server advertises all three under executeCommandProvider.

A language server has no standard way to open a buffer, so the command shows its result rather than only returning it:

  • The text is written to a temporary file and the server sends window/showDocument (LSP 3.16) to open it in a buffer. The filename carries a .wat or .reed extension, so the buffer gets syntax highlighting.
  • The request is sent whether or not the client advertised window.showDocument. Some editors honour it without announcing it — Zed is one (zed-industries/zed#61572) — so treating the missing capability as "cannot open a buffer" would send those users a whole module as a popup. The advertised capability is a positive signal only.
  • If the client then answers that it did not open the document (success: false, or a JSON-RPC error because it does not implement the request), the text arrives as a window/showMessage instead. Cramped for a whole module, but every client implements it, and it is the difference between seeing the output and seeing nothing.
  • A document that produces no output (it does not compile, the cursor is not in a function, there is no macro invocation here) gets a one-line explanation as a warning message, never an empty buffer.

The command still returns the same {title, text, error} object as its result, for a client that prefers to read it. Note that returning it is not what makes the feature work: no stock editor displays an executeCommand result, so a server that only returned it would offer a menu entry that silently did nothing.

{"jsonrpc": "2.0", "id": 2, "method": "reed/functionWat",
"params": {"textDocument": {"uri": "file:///foo.reed"}, "position": {"line": 4, "character": 2}}}

Black-box compiler tests

The repository test suite is designed for any compiler binary through an adapter contract. It does not link to a compiler implementation.

REED_TEST_ADAPTER="/path/to/compiler-adapter" python3 tests/run.py

The adapter produces WAT, advertises optional binary/optimization support, and forwards diagnostics. The suite executes exported check() -> i32 functions with Wasmtime, checks compile failures, and structurally inspects WAT comments and lowering patterns.

Read the repository's tests/README.md for the adapter protocol and fixture format.

Tree-sitter

tree-sitter-reed/ provides syntax parsing, editor highlighting, scopes, symbols, and folds. It intentionally does not implement semantic resolution or type checking.

The Docusaurus site uses that same grammar and highlights.scm during its build. Every fenced reed block is parsed at build time and receives CSS classes from Tree-sitter captures instead of generic Prism tokenization.

cd tree-sitter-reed
npm install
npm test

The generated parser is committed. Update grammar.js, corpus tests, generated parser files, and queries together when the syntax evolves.

Editors

editors/ packages the grammar for Helix and Zed, each with install steps in editors/README.md. Both pick up highlighting, indentation, bracket matching, text objects, and the outline, and both wire up reedc lsp for diagnostics, hover, completion, go-to-definition, and rename. Neither ships a compiler: they locate reedc on your $PATH, so build it first and make it reachable. For Zed that lookup is what editors/zed/src/reed_extension.rs does -- Zed requires a small WebAssembly extension to resolve a language server, even when the answer is just "find it on $PATH".

tree-sitter-reed/queries/ stays the single source of truth. Zed needs real copies of the query files inside its extension directory, so those are generated and drift-checked:

mise run editors:sync # refresh the Zed copies after editing a query
mise run editors:verify # drift, captures, LSP wiring, real-editor scopes, ext build

One wrinkle is worth knowing before editing highlights.scm: Helix and Zed use different names for the same highlight scopes, and an unrecognized capture name is not an error in either editor -- it silently renders as unstyled text. Several patterns therefore carry two capture names ((integer_literal) @number @constant.numeric), ordered so that each consumer picks the one it knows. mise run editors:test drives the real hx binary and asserts on the colors it actually paints, which is the only way to catch this class of mistake; it skips cleanly when Helix is not installed.

Two related failure modes have their own static guards, because that skip covers most CI runs. A pattern that matches nothing leaves its token looking exactly like a comment, and a token matched by two patterns is one color in the browser and another in an editor, since the three consumers break a tie differently -- the docs highlighter takes the first capture, Helix the last, and Zed tries rightmost-first. mise run editors:check-capture-correctness asserts, per token in a sample module that actually compiles and runs, that it carries exactly the capture it should: not the wrong one, not none, and never two competing ones.

What each construct is colored as:

ConstructScope
func f, a call f(...), a method x.m(...)function
struct Point, &Point, let p: Point, new Point, throw Failure, Color.Greentype
i32, &any, u4builtin type
global Counter, const Limit, param N, and every read of oneglobal variable
a local or parameterunstyled, so it reads as ordinary text beside a global
x in p->x, new P { x: 1 }, and its declarationproperty
@inline, @deprecated, @custom, @likely, @unlikelyattribute, deliberately distinct from a keyword

A local is deliberately left uncolored rather than given its own scope: unstyled text already reads as "an ordinary value", and every capture emitted is one more span for the three consumers to disagree about.

For normative output requirements, read Lowering and validation.