Lexical structure
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…
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.
There is no escaped-identifier form: a reserved word cannot be spelled as a name by quoting, prefixing or backslashing it. The only fix for a collision is a different name.
A reserved word written where a name belongs — a params field, a
local, a fn / method / parameter, a struct field, a locus or type
name — is a parse error reported once, at the word, naming the
word and the declaration it was written in:
work.hl:8:9: parse error: `epoch` is a reserved word and cannot namea params field; rename it (Hale has no escaped-identifier form —spec/tokens.md lists every reserved word)The parser then resumes as if the declaration had been named, so the
rest of the file still parses and the one error is not followed by the
consequences of the abandoned declaration (a later } reported as a
stray token, a literal of the type reported as a block, the same word
reported again at every use site). Every use of the SAME word as a
name after the first is part of that one mistake and is not reported
again. This section is the canonical list the diagnostic points at.
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_onresets_per_epochapprox 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. Time and Duration
are declared by the stdlib’s seed (type Duration = quantity Int in ns;, type Time = point Duration;, spec/units.md § The stdlib’s
time catalogue); a type position reads either name as that
declaration, whose values keep the primitive’s representation.
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. A scalar declaration (a quantity, a point, an
identity, a range) or a unit of such a name is an error
(spec/units.md § The laws, law 12).
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.
Unit dialect words (GH #1076)
Section titled “Unit dialect words (GH #1076)”unit quantity point distinctAll four are contextual keywords (spec/units.md
§ Declarations), each recognized in one position and an ordinary
Ident everywhere else, so a local, field, parameter, fn or
method may still be named by any of them:
unit— at top level, when a name and then=or;follow it:unit ms = 1_000 us;,unit tick;.quantity,point,distinct— right after atypedeclaration’s=, when a type expression follows:type Money = quantity Int in cent;.type P = point;still aliases a type namedpoint.
range, round and origin name the clauses of a scalar type’s
{ } block (type Byte = Int { range: 0..256; }) and are not
words anywhere else. in stays a hard keyword; after . it is a
member name, so d.in(s) is an ordinary method call.
Surface words (GH #1417)
Section titled “Surface words (GH #1417)”api rpc requiresAll three are contextual keywords (spec/api.md § Surfaces and
their rows), each recognized in its positions and an ordinary Ident
everywhere else:
api— at top level, when a name and then{follow it:api Public { … }declares a surface. It stays an ordinary identifier elsewhere, as theapi::servepath head (thebindings { api: … }entry head of the retired structural path is refused, spec/semantics.md § The api binding).rpc— at the head of a row inside anapiblock (rpc Orders::place;), and as the annotation@rpcon a locus fn.requires— after a row’s handler, inside@rpc( … ), and among a hub binding’s clauses:requires: [trader].
api::serve( … ) is a call whose arguments after the surface and the
transport are named (as:, receivers:, bound:, on_full:); no
other call takes named arguments (spec/api.md § Serving).
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. - There is no type suffix. A decimal integer written against a name
is a decimal literal (
d) or a quantity literal (any other name, the time units among them), below; a radix integer followed by a name is a parse error.
Float literals
Section titled “Float literals”- Decimal:
3.14,1.0e-3,2.5E+10. AneorEis an exponent only when a digit, or a sign and a digit, follows it. - There is no type suffix: but for the decimal literal’s
d(below), a float written against a name is a parse error.
Quantity literals (GH #1076)
Section titled “Quantity literals (GH #1076)”- A decimal integer immediately followed, with no space, by a name:
500ms,3bp,5kg,2min,1_250_000USD,2EUR. One token, carrying the magnitude and the unit’s name as written. Integer magnitudes only. The lexer knows no unit: which one the name is, a unit of the stdlib’s time catalogue or one a program declares, is the catalogue’s to say where the literal is checked (U4). - The unit is named in the units’ own namespace, never a local’s:
a quantity literal is its unit’s component’s quantity, at the
quantity’s denomination when it is a whole count of it (
1000mgof a quantity in grams is 1) and else at its unit (5mg), converted at compile time into the denomination it flows into (spec/units.md § Literals). A unit nounitdeclares is refused at the literal.
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”- A duration literal is a quantity literal of a unit of the stdlib’s
time catalogue (
std::time, spec/units.md § The stdlib’s time catalogue):ns,us,ms,s,min,h,day. Examples:100ms,5s,2min,1day. It is aDuration, its value its length in nanoseconds (100msis 100,000,000), which the catalogue computes; a program’s own unit of the time component (unit tick = 10 ms;) writes5tickthe same way. One magnitude, one unit: a span of mixed units is a sum (1h + 30min). dis the Decimal literal’s anddayis the day:3dis the Decimal3, as it always was, and no unit may be namedd(spec/units.md, law 11).mis no unit:5mis refused as a unit no catalogue declares, the message saying minutes aremin.- A compound such as
1h30mis one magnitude and the unith30m, which no catalogue declares: the check refuses it as a literal of several units and says the sum to write (1h + 30min). - A unit declaration’s factor and a denomination read the magnitude
and the unit as written (spec/units.md § Declarations):
unit tick = 100ms;parses asunit tick = 100 ms;. - Time literals: ISO-8601 UTC between backticks:
`2026-05-08T12:00:00Z`,`2026-09-14T08:30:15.25Z`(a fraction of one to nine digits; theZis optional; an offset is a compile error). Parsed at check time into an instant (GH #607).
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"...") and multi-line strings ("""...""") are NOT implemented — the lexer rejects both (verified 2026-07-22, GH #228 docs-drift audit; both were previously listed here aspirationally). Byte literals + f-strings cover the practical cases; add these forms to the lexer before re-documenting them. -
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.Whitespace immediately inside the braces is trimmed, so
f"{ x }"andf"{x}"are the same. An empty interpolation (f"{}") is a lex error: there is no positional-argument form for it to refer to.A string literal may appear inside an interpolation (
f"{lower("ADA")}") — the lexer tracks quote state while scanning for the closing brace, so braces inside that string do not close the interpolation.Format specs (GH #469): an interpolation may end with
:spec, givingspec := [[fill]align] [width] ["." precision] [kind]align := "<" | "^" | ">"kind := "x" | "X"The
:that opens a spec is the last top-level one that is not part of a::path separator and not inside brackets or a string — sof"{std::str::lower(s):>8}"splits once, correctly.widthpads to at least that many bytes and never truncates. Absent an explicit alignment, numbers pad on the left and everything else pads on the right.fillis only recognised when an alignment follows it, which is what keeps{x:8}a width rather than a fill of8.precisionapplies toFloatandDecimalonly;Decimalis rendered through the exact fixed-point path, not viaf64. Maximum 17.kindrenders anIntin hexadecimal, from the unsigned bit pattern (so-1isffffffffffffffff).
A spec that is ungrammatical is a parse error; a spec that is grammatical but does not apply to the value’s type (hex of a
String, a precision on anInt) is a type error.Lint — a plain string that looks interpolated. Because a plain
"..."keeps{and}as ordinary characters, writingprintln("x={x}")compiles, runs, and prints the braces. The checker emits a warning naming the f-string form when a plain string literal passed toprint/printlncontains{name}andnameresolves to a binding in scope.The in-scope condition is what makes it a lint rather than a heuristic:
println("{}"),println("{\"a\": 1}")and prose about a template placeholder name nothing and stay quiet. It is a warning, never an error — printing braces around a word that happens to also be a local is unusual, not illegal.{{anywhere in the literal suppresses it, since that spelling signals the author knows the syntax.
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 println eprint eprintlnto_string len absstarts_with contains check_closuresInt 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. Float(x) is its widening twin — Int → Float via sitofp,
Float arg the identity, other types reject. These two are the only
casts; the remaining primitive-type names are types and nothing
else. to_string(x), len(x), abs(x), min(a, b), max(a, b),
starts_with(s, p) and contains(s, p) are similarly bare-name
builtins, as is check_closures() at statement position.
A bare builtin call is the same call in either position. In
statement position the expression is evaluated and its value
discarded, so len(s);, Int(3); and min(1, 2); are legal (if
pointless) statements rather than a narrower vocabulary: a name the
compiler answers in expression position it answers here too. The
four printers and check_closures() return no value and so are
statement-position-only; the bounded[T; N] intrinsics keep their
own statement spellings, with push / at / set carrying an
or disposition because they are fallible.
Names a free fn may not take (GH #863, GH #880)
Section titled “Names a free fn may not take (GH #863, GH #880)”These names are claimed at the call site, ahead of any user declaration:
sum prod min maxabs to_string Int Floatlen starts_with containsprint println eprint eprintlncheck_closures __fmtsum( and prod( have their own production at expression head
(the closure-assertion accumulators — see
semantics.md § Closure-test evaluation).
min, max, abs, to_string, Int, Float, len,
starts_with and contains are answered by a call-expression
arm that matches the callee name before it consults the
program’s free fns. check_closures is answered the same way at
statement position. __fmt is what an f-string interpolation
desugars into.
A declaration of one of those names could therefore never be
reached, so a free fn may not take it — the parser refuses
the declaration, at the name:
`abs` is a built-in call form and cannot name a fn; rename it(every `abs(...)` call site lowers to the builtin, so thedeclaration could never be reached …)The four printers are claimed for the opposite reason, and it is
the stronger one: they resolve through the program’s fns first,
and the Hale-source standard library is merged into the same
global fn namespace. A fn print(a: Int) therefore captures
the library’s own print("…") calls, and a program that never
mentions print again fails to build with fn `print` arg 0 type mismatch: expected Int, got String. Whether a given
printer is reachable that way is an accident of what the library
happens to call today, so all four are claimed.
The rule is on the declaration, not the name. A locus
method, an interface method and a perspective contract fn
may all still be called sum, len or contains — they are
reached through a receiver (self.sum(), b.len()), which no
builtin claims, and the standard library’s own mirror_ring.hl
and bytes_builder.hl declare fn len(). A module { } item
is a free fn (its items resolve into the same global fn
namespace) and is refused.
Everything else in this section’s table stays available to a
free fn. B, c, sigma, phi, k_max, span_max,
length and empty are conventionally reserved but no call
site dispatches on them. The bounded[T; N] intrinsics
(count, clear, truncate, push, at, set) and the
accumulator vocabulary (count, mean) are recognized only
when the argument IS a bounded receiver, or inside a closure
assertion. The rest of the element-chain vocabulary (map,
filter, into, any, all, first, find, each, take,
skip, enumerate, sort_into, reverse_into,
group_count_into — semantics.md § Element
chains) is recognized only after a ..
A declaration shadows a bounded intrinsic (GH #892)
Section titled “A declaration shadows a bounded intrinsic (GH #892)”The six bounded[T; N] intrinsics dispatch on the type of
their first argument, so where a program declares one of
those names the two readings can be told apart — and the
program’s own declaration wins:
A free
fnwhose first parameter is abounded[T; N]answers every call of that name whose first argument is a receiver of that samebounded[T; N], at any arity the declaration accepts. The intrinsic answers every other call.
type Window { id: String; samples: bounded[Int; 8]; }
fn count(xs: bounded[Int; 8]) -> Int { return 8801; }
fn main() { let mut w = Window { id: "w1" }; push(w.samples, 1) or raise; println(count(w.samples)); // 8801 — the declaration}The rule is held by the typechecker and by codegen alike, so
hale check and hale build resolve such a call to the same
fn. It is a shadow, not a refusal: no declaration is
rejected, and a program that declares none of the names is
unaffected.
Element type and capacity are part of the match, and that is
load-bearing rather than fussy. The Hale-source standard
library is merged into the same global fn namespace and calls
these intrinsics on buffers of its own, so a shadow keyed on
the name alone would retarget the library’s calls at the
user’s declaration — the capture that makes the four printers
claimed names. A declaration over some other bounded is
simply not the receiver’s, and the intrinsic answers.
Method position is untouched: f.get(i) and the element-chain
terminals (f.filter(…).count()) are reached through a
receiver, which no free fn claims.
Until GH #863 / GH #880 the declaration was accepted and the
divergence appeared later, in one of two shapes. hale check
passed and hale build refused the program with an unlocated
unsupported in codegen v0 (len, starts_with, contains,
print, eprintln, check_closures, __fmt); or the program
BUILT and silently ran the builtin instead of the body — a wrong
answer with no diagnostic anywhere (abs, to_string, Int,
Float, and a two-arg min / max).
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.)
Annotation inventory
Section titled “Annotation inventory”Annotations are @-prefixed contextual idents (never reserved
words), so a program may still use these names as identifiers.
They attach to the declaration that follows.
| annotation | attaches to | meaning |
|---|---|---|
@ffi("c") |
free fn | external C-ABI binding |
@export |
fn / locus | WASM module export; persistent singleton |
@form(kind) |
locus | storage form (vec / hashmap / ring_buffer / lru_cache) |
@locality(tier) |
locus | placement locality tier |
@bounded |
locus | opt into the memory-bound proof |
@unbounded |
fn | acknowledge intentional unbounded allocation |
@hot |
fn | certify a hot path (promotes advisories to errors) |
@budget(alloc_per_call = N, stack_bytes = N, block_points = N, publish = N, fanout = N) |
fn | quantitative budgets, comma-separated |
@effects(none: {…}) |
fn | forbid effect classes, checked transitively |
@phase_effects(phase: {…}, …) |
locus | per-lifecycle-phase effect contract |
@effects(causes: {…}) |
fn | effect classes this fn may cause via bus edges |
@supervised |
locus | every locus in the subtree has a failure policy |
@sealed |
locus | this locus’s params are readable only from inside it |
@secret |
fn param | lint: flags a value reaching a publish or log/file sink in the same body |
@effects(publish: {…}) |
fn | the allowed publish set |
@effects(depends: {…}) |
locus | complete set of subjects that may transitively reach any handler |
@effects(is: {…}) |
fn | classify this fn as a source of the named effect classes |
@effects(only: {…}) |
fn | CLOSED contract — the inferred set must be a subset of these |
@no_syscall @no_block @no_ffi @no_publish @no_spawn @no_recursion @deterministic |
fn | sugar for the @effects(none: …) forms |
@no_panic |
fn | no reachable trap (disposition coverage — a different analysis) |
@rpc / @rpc(requires: [ROLE, …]) |
locus fn | a row of the seed’s default surface (spec/api.md) |
The fn CONTRACT annotations — @unbounded, @hot, @budget(...) and
the effect assertions — stack on one fn in any order; @ffi and
@export say what a declaration IS and take the slot alone. A stack
that contradicts itself (the same annotation twice, or @unbounded
with a contract that forbids allocation) is a check-time error at the
annotation. See spec/verification.md § Decorator stacks.
effect NAME = { A, B }; optionally DEFINES a class as the union of
others. A composed class owns no bit of its own, so forbidding it
forbids every member, and a fn reaching a member carries it. Members
may be built-ins or other declared classes; a definition cycle is
rejected.
The built-in classes are syscall, block, time, entropy,
env, ffi, publish, spawn, recursion, alloc, and
secret_use (GH #436 — a privileged operation over confined secret
material, carried by std::secret’s methods). Every built-in name
is reserved: effect secret_use; — or effect syscall; — is an
error, because the built-in wins at every use site and the
declaration would be a silent no-op.
effect NAME; is a top-level declaration, not an annotation:
it introduces a user effect class that is: / none: / causes:
then name like a built-in. effect is a contextual keyword —
recognized only at the start of a top-level item followed by an
identifier and ;, so an existing effect binding is unaffected.
Declared classes are single-seed (see spec/verification.md).
Effect annotations stack with each other and with @hot /
@budget(...); see spec/verification.md for what each class
means and where its truth comes from.
group NAME = { … } [may_be_empty]; is a top-level declaration
of claim vocabulary, domain NAME = { … }; declares a closed index
domain for effect families (effect knowledge(wing);), and
claims { … } is a main-locus member holding named bundle-level
claims (GH #382). The claim verbs are forbid reaches (with via
/ during / avoiding modifiers), only edges (grant
enumeration), bound (path budgets over user classes, plus the
built-in secret_use — the one counted built-in with no @budget
spelling), require subscribes|publishes (existence),
require sealed(all G) and require attributed(all C) (GH #436 —
universals over the closed world rather than over a path), cover,
and count. All introducers — group, claims,
domain, forbid, reaches, via, may_be_empty, edges,
bound, require, sealed, attributed, all, cover, count,
during, avoiding, seed
— are contextual keywords, recognized in their positions only,
so existing identifiers with those names are unaffected.
@budget(<user class> = N) bounds calls to declared carriers of
the class. See spec/verification.md § Claims and
spec/grammar.ebnf.