Lexical structure
Reference material, synced from the compiler repo’s
spec/. The guide is the gentler path in.
This document specifies the lexical layer of Hale: the set of
tokens the lexer produces and feeds to the parser. The formal
grammar in grammar.ebnf is defined over these tokens.
Source encoding
Section titled “Source encoding”Source files are UTF-8 encoded. File extension: .hl.
Outside string literals and comments, only ASCII characters are permitted. The framework’s mathematical primitives are spelled out as ASCII names; the renderer can produce Unicode for human display, but the source is ASCII-only. This commitment serves the agent-first authorship principle (no symbol-input friction) and simplifies tooling.
Whitespace
Section titled “Whitespace”- Spaces, tabs, carriage returns, and newlines are whitespace.
- Whitespace separates tokens but is otherwise insignificant.
- No off-side rule (no Python-style indentation).
- Newlines are not statement terminators; semicolons are required.
Comments
Section titled “Comments”- Line comment:
// ...to end of line. - Block comment:
/* ... */. Block comments do not nest in v0. - Doc comment:
///lines directly above a declaration attach to it (decorator lines like@hotmay sit between). Rendered byhale docinto the seed’s API reference (spec/testing.md). Tooling recovers doc text positionally from the source — the lexer itself skips all comments./** */stays reserved for a future block form;hale docv1 reads///only.
Identifiers
Section titled “Identifiers”- Match:
[a-zA-Z_][a-zA-Z0-9_]*. - Case conventions are enforced by lints, not the lexer:
PascalCasefor type names, locus names, perspective names.snake_casefor variables, fields, function names.SCREAMING_SNAKE_CASEfor constants.
- Reserved words (see below) are not legal identifiers.
Reserved words (keywords)
Section titled “Reserved words (keywords)”Declaration keywords
Section titled “Declaration keywords”locus perspective type constfn import export moduleinterfaceinterface (F.20) declares a structural interface — a named set
of method signatures. Any locus whose method set is a superset
structurally satisfies the interface (no impl I for L
declaration). Phase A (typecheck) and Phase B (codegen vtable
dispatch) both shipped 2026-05-11; interface values are usable as
fn params, fn returns, locus param/field values, and
@form(vec) cell elements. Interface elements in tuples / fixed
arrays remain gated on the broader composite-construction
coercion design (same gap as tuple-of-LocusRef escape).
Locus member keywords
Section titled “Locus member keywords”params contract bus capacityas_parent_for indexed_bycapacity introduces an F.22 capacity { ... } block carrying
zero or more pool X of T; / heap Y of T; slot declarations.
as_parent_for and indexed_by are hard keywords used only
in slot-clause position inside a capacity block: pool P of T as_parent_for ChildL; (v1.x-4 — share the slot’s allocator with
a child locus’s same-named slot at accept time) and pool P of T indexed_by field; (v1.x-FORM-4 — names the hashmap key field on
a @form(hashmap) cell type).
The slot-kind words pool and heap are contextual idents —
they lex as ordinary Idents and the parser recognizes them only
in slot-decl head position inside a capacity block. So
fn pool_alloc(...), let heap = ..., and type Heap { ... }
all stay admissible outside capacity blocks. Same F.10-style
narrowing the closure-keyword family uses for approx / within.
Lifecycle keywords
Section titled “Lifecycle keywords”birth accept release rundrain dissolve on_failureMode keywords
Section titled “Mode keywords”bulk harmonic resolutionmode is a contextual keyword: recognized only
at locus-member position (as the leading token of a mode_decl
production in grammar.ebnf § 11). Outside that position it
lexes as an ordinary Ident, so cam.mode: Int and similar
param/field uses are admissible — relevant for raylib-style
bindings where mode is a natural API name. Same F.10-style
narrowing as bindings, birth_check, pool, heap.
bulk / harmonic / resolution remain reserved (they sit in
member-name position via try_keyword_as_name).
Projection-class keywords
Section titled “Projection-class keywords”projection rich chunked recognitionPlacement keywords (F.31)
Section titled “Placement keywords (F.31)”placement cooperative pinned pool core corestopology node l3 reserve replicasplacement introduces the placement { } block on main locus (F.31); topology introduces the topology { } block
(topology Phase 1b). cooperative / pinned are
placement-spec keywords inside placement { }. pool, core,
and cores are contextual idents — recognized only as
kwarg names inside cooperative(pool = X) /
pinned(core = N) / pinned(cores = A..B | A..=B | {a, b, c})
placement specs (topology Phase 1a). Phase 1b adds
pinned(node = N) / pinned(l3 = name), plus the topology,
node, l3, and reserve block keywords. Phase 1c adds
replicas (a second pinned(...) kwarg, e.g. pinned(cores = A..B, replicas = K)). All are contextual idents — outside their
positions they lex as ordinary Idents
and can name fns / vars / fields (same F.10-style narrowing the
closure / mode keyword families use). Note L3-domain names go
through the identifier rule, so a hard keyword (e.g. bulk)
can’t name a domain — use a plain identifier. cores range
bounds and set elements are integer literals; the range reuses
the expression tokens .. (exclusive) / ..= (inclusive).
The pre-F.31 schedule keyword is gone — the : schedule cooperative | pinned per-locus annotation no longer exists.
Placement is a main-only deployment seam (bindings’s
sibling), not a per-locus annotation.
Closure keywords
Section titled “Closure keywords”closure epoch persists_through resets_onapprox and within are contextual keywords, recognized
only inside a closure { ... } block body (and only in the
specific positions the closure-assertion grammar admits them).
They lex as ordinary Idents elsewhere, so fn approx(...) and
let within = ... are admissible outside closure bodies. Same
F.10-style narrowing the mode-keyword family uses
post-dot. Shipped 2026-05-11; resolves
notes/hale-friction.md 2026-05-10
closure-keyword-shadows-helper-ident.
captures and inline (v1.x-VIOLATE, F.27) are
contextual keywords. captures is recognized only as a
clause keyword inside closure { ... } body, in the
captures: f1, f2 ... ; production. inline is recognized
only as an epoch_spec variant (immediately after epoch in
a closure clause). Both lex as ordinary Idents elsewhere — so
let captures = ... and fn inline(...) stay admissible
outside closure bodies. Same F.10-style narrowing.
Recovery primitives
Section titled “Recovery primitives”restart restart_in_place quarantine reorganizebubbleviolate (v1.x-VIOLATE, F.27) is a contextual keyword:
recognized only as the leading token of a statement inside a
locus method body (the violate_stmt production in
grammar.ebnf § 14). Outside that position it lexes as an
ordinary Ident, so let violate = ... and fn violate(...)
stay admissible. The optional trailing with in
violate NAME with EXPR; is the previously-reserved keyword
with (no longer reserved-for-future-use); recognized only as
the separator before the payload expression in this production.
Contract keywords
Section titled “Contract keywords”expose consume inferredBus keywords
Section titled “Bus keywords”subscribe publish on ofPerspective keywords
Section titled “Perspective keywords”stable_when serialize_as serves reperspectiveserves (Phase 2a) is a contextual keyword —
recognized only in a locus header’s post-: list, as the
serves P conformance clause (locus RouterV1 : serves Router).
reperspective (Phase 2b) is a contextual keyword
recognized only as a statement head followed by self
(reperspective self.<field> as <Impl>;). Outside those positions
both lex as ordinary Idents, so fn serves(...) /
let reperspective = ... stay admissible. Same F.10-style
narrowing the placement / closure keyword families use.
perspective (a hard declaration keyword) doubles as a type
constructor: perspective(P) in type position is a handle to the
contract P. The parser recognizes it by the perspective
token followed by (.
Statement / expression keywords
Section titled “Statement / expression keywords”let mut if elsematch for in whilereturn break continue truefalse nil tier selfBindings are immutable by default. let mut x = ... declares a
mutable binding; reassignment via x = ... is permitted. Without
mut, reassignment is a compile-time error.
self is meaningful only inside a lifecycle block, mode block,
or closure block. It refers to the enclosing locus’s own params
and contract-exposed state. Outside such a block, self is a
parse error.
Predefined type names (NOT keywords)
Section titled “Predefined type names (NOT keywords)”Int Uint Float DecimalString Bool Time DurationBytes BytesView StringView BytesMutBytesView / StringView (F.30) are non-owning views over a
BytesBuilder’s buffer; BytesMut (#3) is a raw {ptr, len}
writable/readable window (a Topic.write ring slot or a
MirrorRing window). See spec/types.md.
PascalCase per the type-name convention. The lexer emits these
as Ident tokens; the parser recognizes them by name in type
position only. In expression / namespace position, these names
are unreserved — time::sleep is a regular path because time
(lowercase) is an ordinary identifier. This eliminates the
lexical collision between primitive type names and stdlib
namespace names that would otherwise occur.
Shadowing a predefined type name with a user-defined type
(type Int = ...) is permitted by the grammar but produces a
compiler warning.
Reserved for future use (not yet legal)
Section titled “Reserved for future use (not yet legal)”trait impl async awaitmacro(with is no longer in this list — v1.x-VIOLATE recognizes it
as a contextual keyword inside the violate_stmt production.
where is no longer in this list either — Form K
recognizes it as the suffix keyword on binding_entry carrying
operational constraints.)
yield is a real statement keyword (m26b) — explicit
cooperative yield point; lowers to a bus-queue drain in
codegen. Listed under cooperative-scheduler keywords.
terminate is a statement keyword — ends the
current locus’s lifecycle from inside one of its own methods
(the locus analogue of return). Only valid inside a locus
method body. See spec/semantics.md § “terminate”.
Cooperative-scheduler keywords
Section titled “Cooperative-scheduler keywords”yieldterminateFallible / error-addressing keywords (v1.x-FORM-1)
Section titled “Fallible / error-addressing keywords (v1.x-FORM-1)”fallible fail or raiseAll four are contextual keywords, recognized only in the
positions described below. Outside those positions they lex as
ordinary Ident tokens, so existing code that uses
let fail = ..., fn or_else(...), or let raise = ... stays
admissible.
fallible— recognized only immediately after a function declaration’s return type and before its body, in thefallible_marker = "fallible" "(" type_expr ")"production. Marks the function as one whose call sites MUST address the error. Permitted at declaration sites: free fns + stdlib- synthesized methods over@form(...)containers; rejected at typecheck on user-declared locus methods per the two-channel rule (seespec/semantics.md§ “Fallible call semantics” § “Where each channel lives”).fail— recognized only as the leading token of a statement inside a fallible-fn body. Exits via the error path, attaching the expression as the typed payload. Symmetric toreturn.or— recognized as a postfix on any expression of fallible type (theor_clauseproduction). Four RHS forms:or raise(propagate one frame up the call stack),or discard(swallow the error and substitute Unit — rejected at typecheck on calls whose success type is non-Unit; sugar for the oldor noop(err)),or fail <expr>(B3 / G6 — diverge likeraisebut with a fresh payload of the enclosing fallible fn’s declared error type),or <expression>(substitute),or <handler-call>(hand off).raise— recognized only as the immediate RHS ofor. Diverges the expression by re-entering the fallible-return shape of the enclosingfallible(E)fn, with the payload carried into the enclosing fn’s error sret slot. Past every enclosing fallible frame — at the implicit main locus’s root boundary — the runtime panics vialotus_root_panic. The value-error channel is orthogonal to the closure- violation channel;or raisedoes not enter thebubble/on_failuremachinery by default. Seespec/semantics.md§ “Fallible call semantics” for the full two-channel semantics.
The implicit binding err is in scope on the RHS of an or
clause and resolves to the fallible call’s payload value
(typed as the fn’s fallible(T) marker). This is a typecheck
rule, not a lexer rule — err lexes as an ordinary identifier
everywhere; the typechecker just introduces it as a binding in
the or RHS scope.
Rule of thumb for use:
- A stdlib fn whose failure carries useful diagnostic context
returns
fallible(T)with a named payload type T. - A stdlib fn whose “failure” is a benign not-found case (and
whose successful value has a natural default like 0 or "")
uses the sentinel-with-discriminator idiom instead
(
parse_int/can_parse_int). Not every stdlib fn is fallible — the marker is reserved for true error paths.
Operators
Section titled “Operators”Arithmetic
Section titled “Arithmetic”+ - * / %Comparison
Section titled “Comparison”== != < > <= >=Logical
Section titled “Logical”&& || !Bitwise
Section titled “Bitwise”& | ^ << >> ~Assignment
Section titled “Assignment”= += -= *= /= %= &= |= ^=Closure / approximation
Section titled “Closure / approximation”~~ equivalent to `approx`; tests value approximate-equal within a stated tolerance band. Used in closure tests.Bus send
Section titled “Bus send”<- Send a typed message on a declared bus subject: `"subject" <- value;`. The left side names a subject declared in the locus's `bus { publish ... }`; the right side is the typed payload. Same Erlang-shape as `Pid ! Msg`; one-direction (subscribe is declarative, not an operator).Member access / call / index
Section titled “Member access / call / index”. :: ( ) [ ]Type / generic
Section titled “Type / generic”< > -> => : ::(Note: < and > are overloaded between comparison and generic
arguments. The parser disambiguates contextually.)
Punctuation
Section titled “Punctuation”; , { }Annotation prefix (v1.x-FORM-1)
Section titled “Annotation prefix (v1.x-FORM-1)”@@ introduces a decorator-shaped annotation that decorates the
declaration immediately following it. v1 recognizes one such
annotation: @form(<name>, <args>...), which sits above a
locus declaration and picks an efficient lowering (see
spec/forms.md).
Lexically, @ is a single-character punctuation token. The
parser routes the following form ident as a contextual
keyword in this position only.
Reserved for future annotation surfaces; user-defined annotations are not in v1.
Reserved (no v0 meaning)
Section titled “Reserved (no v0 meaning)”# $ ? ?? ?:Literals
Section titled “Literals”Integer literals
Section titled “Integer literals”- Decimal:
0,42,1_000_000(underscores permitted as digit separators). - Hexadecimal:
0xFF,0x1A_2B. - Octal:
0o755. - Binary:
0b1010_1010. - Optional type suffix:
42i32,0xFFu64. Default:int.
Float literals
Section titled “Float literals”- Decimal:
3.14,1.0e-3,2.5E+10. - Optional type suffix:
3.14f32,2.5f64. Default:float.
Decimal literals
Section titled “Decimal literals”- Suffix
d:1.50d,0.05d. Used for the built-indecimaltype (fixed-precision, no float artifacts; same semantics as theshopspring/decimalGo library).
Time / duration literals
Section titled “Time / duration literals”- Duration suffixes:
ns,us,ms,s,m,h,d. Examples:100ms,5s,1h30m. Compound forms permitted. - Time literals: ISO-8601 between backticks:
`2026-05-08T12:00:00Z`.
String literals
Section titled “String literals”- Double-quoted:
"hello". Standard escape sequences:\n,\t,\r,\\,\",\',\0, and\xNNfor ASCII bytes 0x00..=0x7f (added 2026-05-16; useful for separators like"a\x01b"). High bytes (\x80+) are rejected with a message pointing atstd::bytes::*— the Rust String invariant would UTF-8-encode them as two bytes and surprise the caller. No\u{NNNN}Unicode escape at v1; agents needing non-ASCII drop to byte literals. - Raw strings:
r"..."— no escape processing. - Multi-line strings:
"""...""". - F-strings (v1.x-10):
f"hello {name}"— interpolates Hale expressions inside{...}, each rendered via the same formatterprintlnuses.{{and}}are literal braces. Plain"..."strings keep{and}as ordinary characters for back-compat (no breaking change). Interpolation accepts any expression; types are converted withto_string.
Boolean literals
Section titled “Boolean literals”true,false.
Nil literal
Section titled “Nil literal”nil. Represents the absent value of an option type. Distinct from numeric zero or empty string.
Bytes literals
Section titled “Bytes literals”-
b"..."— byte-string literal. Same escape table asSTRING_LIT(\n,\t,\r,\\,\",\0,\xNN), but the body is raw bytes — no UTF-8 promotion, so\xNNaccepts the full0x00..0xFFrange (unlike the string literal which clamps at\x7f). Embedded NULs are preserved; the value’s length is carried alongside the byte buffer, solen(b)andstd::bytes::at(b, i)work over the entire literal. Codegen lowers vialotus_bytes_from_buf(arena, src, len), which copies the source bytes into the caller’s arena.Use cases: binary protocol headers / framing, magic bytes, embedded fixtures. Earlier code reached for
std::bytes::from_string("..."), which only worked for ASCII; theb"..."form replaces that workaround end-to-end.
Built-in identifiers (not keywords)
Section titled “Built-in identifiers (not keywords)”These identifiers have semantic meaning in the standard library and are conventionally reserved, but are not parser-reserved keywords:
B c sigma phik_max span_maxsum prod min maxlength emptyprint printlnto_string len absInt FloatInt(x) (v1.x-11) is a built-in cast — explicit Float → Int
narrowing via fptosi (truncate toward zero). Int arg is the
identity; other types reject. There is no implicit Float → Int
conversion; the user must commit via this constructor-shaped
call. to_string(x), len(x), abs(x), min(a, b), max(a, b)
are similarly bare-name builtins.
print and println are built-in functions, always in scope
without an import. They write to stdout. print does not
emit a trailing newline; println does. They accept any number
of arguments of any displayable type and concatenate.
publish is a built-in function in scope inside any locus
that declares matching bus { publish SUBJECT of type T; }.
The compiler verifies the subject and type at each call site.
Out of scope in loci with no publish declaration.
The framework names (B, c, σ, φ) use ASCII spellings in source:
B, c, sigma, phi. The framework’s k_max is k_max or
its named alias span_max.
Tokenization rules
Section titled “Tokenization rules”- Longest match:
==is one token, not=followed by=. - Keywords win over identifiers:
runis the lifecycle keyword, not an identifier. - Numeric literals are recognized greedily up to the first
non-numeric character (after handling
0x,0o,0bprefixes, decimal point, exponent, and type suffix). - Comments are stripped before parsing (except doc comments, which are attached to the following declaration as metadata).
Reserved character classes
Section titled “Reserved character classes”The following characters are not part of any token in v0 and are lexer errors if encountered outside a string or comment:
` (outside time literal) \ (vertical bar mid-token, not || or |=)(Backticks are reserved for time literals only; bare backslash outside a string literal is illegal.)