Reference
This guide is the tour. The canonical contract — what the compiler actually enforces — lives in the spec/ directory at the repository root. When the guide…
This guide is the tour. The canonical contract — what the
compiler actually enforces — lives in the spec/ directory at
the repository root. When the guide and the spec disagree, the
spec wins; when you need the exact rule, an edge case, or a
diagnostic’s meaning, go there.
The spec, by topic
Section titled “The spec, by topic”| You want | Read |
|---|---|
| The formal grammar | spec/grammar.ebnf |
| Lexical structure, literals, operators | spec/tokens.md |
| Operator precedence & associativity | spec/precedence.md |
| Operational semantics (lifecycle, bus, recovery, fallible) | spec/semantics.md |
| The type system | spec/types.md |
| Memory: regions, capacity slots, projection classes | spec/memory.md |
The form library (vec / hashmap / ring_buffer) |
spec/forms.md |
| The always-loaded runtime | spec/runtime.md |
| The standard library surface | spec/stdlib.md |
| Idiomatic patterns, the seven shapes, correctness + speed rules | spec/styleguide.md |
The FFI contract — C (@ffi("c")) and the WASM host interface (@ffi("js") / @export) |
spec/ffi.md |
| Dependencies & vendoring | spec/packages.md |
| Project layout & imports | spec/projects.md |
| How tests are written and run | spec/testing.md |
| Why the current surface is shaped this way | spec/design-rationale.md |
| The design-decision log (the F-series: commitments, rejects, sketches) | spec/decisions.md |
| Internal codename legend (milestone / workstream tags) | spec/glossary.md |
Two more anchors
Section titled “Two more anchors”AGENTS.md— the load-bearing prompt for agents writing.hl. It condenses the seven idiomatic shapes, the “what’s not in the language” reflexes, and the formal design model into one file. Excellent for a human, too.- Working programs —
crates/hale-codegen/tests/fixtures/examples/holds about ninety small per-feature programs, numbered. Reading a few near your target shape is the fastest way to see real, compiling Hale.
Toolchain commands
Section titled “Toolchain commands”| Command | Does |
|---|---|
hale init [dir] |
bootstrap a project: hale.toml, a hello-world seed, a first test, .gitignore — non-destructive, fills in only what’s missing |
hale run <file/dir> |
compile + run (fast feedback) |
hale build <file/dir> |
compile to a native binary |
hale check |
parse + typecheck only |
hale test |
run *_test.hl |
hale fetch |
clone & pin git dependencies |
hale fmt |
canonical formatter |
hale verify |
check, but any advisory fails too (the CI gate) |
hale bench |
run *_bench.hl (ns/op, allocs/op) |
hale doc |
API reference from /// comments |
hale fleet |
compose topology artifacts across binaries: check / dump / sign / attest / keygen |
hale node <name> |
express a fleet plan’s instances on one machine, from the record |
hale replay <rec> <file> |
re-run a recording; --diff reports the first divergence and per-category coverage — see Record & replay |
hale iris [port] |
the embedded observer; hale run --observe starts a session beside a run — see Iris |
hale dna <verb> |
attach and operate an organization: init, new, run, task create, status, ledger, body and the rest — see the DNA reference |
hale inputs <seed> |
every file a build of the seed reads, imports followed |
hale targets |
the targets this compiler can name, and which of them it can build |
hale lsp |
the language server, in the compiler binary |
hale mcp [--app <endpoint>] |
the MCP server, in the compiler binary; --app serves a running program’s exposure as tools (the members its description lists for the caller) |
hale describe <endpoint | file> |
a served exposure’s description (members, streams, schemas, the caller and its roles), or from a program hale check --api |
hale call <endpoint> <member> [json] |
call one member of a served exposure naming the digest read, print the response — see Build an API |
hale watch <ws://hub> <topic> |
subscribe to a hub’s stream, one JSON line per frame |
hale admin <endpoint> |
a local page over an exposure’s description |
hale api export --surface <S> [--out <dir> | --check <dir>] |
a surface’s bundle from its rows: description, OpenAPI, JSON Schema, MCP and the digest; --check refuses a stale copy |
hale api client --surface <S> --lang hale|ts [--out <file> | --check <file>] |
a typed client of a surface (the five outcomes, the digest sent, a subscription per stream), generated from its rows |
Every one of them answers --help (or -h) as its first argument with
its own flags, its input shape and where its output goes. hale build --help is where the built binary’s path is written down: a file target
becomes ./app and a directory target becomes myapp/myapp, inside the
directory — unless -o <path> (--out) says where. hale build myapp/ -o target/seeds/myapp/myapp writes exactly that path, making its
directories, and nothing beside the source; a wasm build takes the same
flag for its .wasm and puts the .mjs loader next to it. Build
somewhere else, not in the tree, whenever the seed is not yours to
litter: it is the one way to keep a checkout free of binaries.
Flags may stand on either side of the target. The first argument that
is not a flag is the target, so hale build --dev app.hl and hale build app.hl --dev are the same command, as they already were for
check. run follows the same rule up to the target and then stops:
everything after it is the program’s own argv, which is why hale run --observe app.hl puts the flag in front.
run takes build’s options as well — --dev, --target-cpu,
--link, --csrc — and builds what build would build with them, so
hale run --csrc glue.c app.hl runs an @ffi program. They are part
of the execution identity a recording carries, and hale replay takes
the same set: a run recorded under --dev replays under hale replay --dev, and a default replay refuses it (--env likewise). A binary
from hale build stamps the identity hale run would, so its
recordings replay too — a directory build’s against the directory’s
entry file. The flags that report on a
build rather than change it (--locality-report, --target-cache,
--strict, --wrap-main, -o) belong to build alone and are refused by
name.
check and verify follow every import, so a parse failure anywhere
in the import graph — a library you import, or one it imports — fails
them both, reported at that file’s own line and column. Neither ever
reports success on a tree hale build would refuse.
Every command that follows imports reports it the same way. build,
run, test, bench and replay print the same
path:line:col: kind: message, with the offending line and a caret
under it, positioned in the file that holds the mistake — so the
line number you read is the line number you open, whichever command
found it.
The path is the file’s canonical one: absolute, symlinks resolved,
never a ../ form, however you reached the file or spelled the
target. One file has one name, so a script can compare what two
commands said about it.
A refusal that comes from the backend rather than the front end
reads the same way. Some things hale check accepts cannot be
lowered — an array or a tuple as a generic argument, a toolchain
piece the program needs and the build cannot find — and
each of those is path:line:col: codegen error: message, with the
line and the caret, from build, run, test, bench and
replay alike. When such a refusal has no particular place in your
source to point at, every one of them prints the message on its own,
with no position invented for it.
With --json a parse error is reported exactly like a type error: one
NDJSON record on stdout carrying the file, line, column and message,
with "kind":"parse error". Whatever fails, the machine-readable
stream says why — so for a gate reading it, an empty stream means the
seed is clean and nothing else does.
That holds for the inputs themselves, not just for what is in them: a
target that does not exist, or a file check cannot open — one of the
seed’s own, or one reached through an import — is a record with
"kind":"io error", the path in "file" and the operating system’s
error in "message". It carries "line":0 and "col":0, since a
file that never opened has no text to point into. In text mode you
read the same sentence you always did, on stderr.
Some findings point into the standard library rather than into
your source. When a fn that declares @effects(none: {alloc})
reaches an allocation, the
allocation is often several calls deep inside a stdlib locus — and
the stdlib is Hale source the compiler reads on its own terms, not a
file of your project. Those locations are named, never placed:
main.hl:2:4: type error: effect assertion violated: `ship` must not reach `alloc`, but reaches ship -> std::io::tcp::Stream::recv [an allocation (struct `IoError`)]. … fn ship(s: std::io::tcp::Stream) { ^^^^ note: the `alloc` effect happens here (in the standard library, io_tcp.hl:98:18)The finding itself sits on the line you have to change; the note says
where in the library the effect actually happens, and --json emits
that note as a record of its own, the library location in its
message, with an empty file and line and col 0.
Your editor gets the finding and nothing else — there is no range in
the file you have open for a position in another one.