Grammar
This page is the normative syntax of Flux: every statement form, the full expression grammar,
the single arrow -> and its five contextual readings, the precedence ladder, the
disambiguation decisions that keep parsing deterministic, and the six machine-verified formal
properties the grammar is frozen against. Tokens (IDENT, NUMBER, STRING, TERM, …) are
defined in lexical structure; what programs mean is the
business of kinds, inference and time and
state.
There is exactly one grammar, and it is stated once: the normative EBNF below is expressed as a Lezer LR grammar — the same artifact drives the compiler, the editor and the documentation tooling, so no second, drifting description of the syntax exists. The grammar build accepting with zero unresolved conflicts is the machine check that the language is unambiguous (see formal properties).
New here? Start with Guide §8 — The four planes → — it teaches the statement families below by writing them, and shows how one file mixes analysis, canvas and app without a mode switch. This page is the exact syntax underneath that chapter.
Notation. { x } repeats zero or more times, [ x ] is optional, | separates
alternatives, "x" is a literal keyword or punctuation token, and UPPERCASE names are lexical
tokens from the token catalogue.
One grammar, all planes
Flux programs live on four planes — ANALYSIS, CANVAS, TRANSITION and APP — but the syntax is a
single grammar. The plane is inferred from the constructs used, never annotated: there is no
plane pragma, no file-level mode switch. plot and alert are ANALYSIS sinks; on, the shape
primitives and spawn are CANVAS; morph, focus and replay are TRANSITION; an app
descriptor is APP. A single file freely mixes them — an indicator and its presentation are one
program — and the firewall between planes (presentation may read analysis, never the reverse)
is enforced by the dependency analysis on the parsed tree, not by the grammar. See the four
planes.
plot close // ANALYSIS — the smallest program
on click -> burst(40) dot { at: (bar.i, close), life: 2s } // CANVAS — same file, same grammar
morph chart over 600ms // TRANSITIONPrograms and statement separation
program = { TERM } [ stmt { TERMSEP stmt } { TERM } ]
TERMSEP = ( TERM | ";" ) { TERM | ";" }
sep = ( TERM | "," | ";" ) { TERM | "," | ";" }A program is a sequence of statements separated by significant newlines (TERM) and/or
optional semicolons; blank lines are free. Inside brace bodies, list items are separated by
sep — a newline, comma or semicolon, interchangeably — which is why a multi-line record at the
top level needs no trailing commas and a one-line block can use ;. The newline only counts as
a separator at parenthesis depth zero: a brace body written inside a call is still inside the
call’s parentheses, so its items need an explicit , or ;. The complete newline policy (when a
newline terminates and when it continues) is specified in lexical
structure.
Flux has no expression-statements: a bare expression at statement level is a syntax error, which is what makes newline-terminated statements unambiguous.
rsi(close, 14) // ✗ syntax error — an expression is not a statement; write `plot rsi(close, 14)`Statement forms
stmt = declStmt | plotStmt | markStmt | fillStmt | colorBarsStmt | alertStmt | assertStmt
| onStmt | groupStmt | repeatStmt | forStmt | uiElement | primStmt | spawnStmt
| tweenStmt | effectStmt | setStmt | morphStmt | focusStmt | replayStmt
| appStmt | importStmtNearly every statement is committed by its first token — the keyword-head idiom: at statement
level, only assertStmt can begin with assert, only variantDecl with variant, and so on.
The parser never guesses; each head owns a distinct LR state. The two statements that begin with
an IDENT (a binding, a view container) and the one that begins with { (a destructuring
binding) are resolved by one token of lookahead, catalogued
below.
Declarations
declStmt = [ visMod ] ( bindStmt | defStmt | typeDecl | variantDecl | recordDecl
| reprStmt | toolStmt )
visMod = "pub" | "private" | "package"
bindStmt = letPat "=" expr
letPat = recordPat | IDENT
defStmt = [ DOC ] "def" IDENT "(" [ params ] ")" "=" expr
params = param { "," param }
param = IDENT [ "=" literal ]A binding names a value for the rest of the program; its left-hand side is a single
identifier or an irrefutable record pattern that destructures on the spot. Bindings are
immutable — there is no assignment statement, and let exists only inside expressions.
n = input(14, 2..200)
{upper, lower} = bollinger(close, 20) // destructuring bind — no `:` after `{ IDENT`
let n = 20 // ✗ syntax error — `let` is expression-only; top-level binds are bareA function definition binds a name to a parameterized expression. Parameters may carry
literal defaults; a /// doc-comment attaches to the definition. Recursion is rejected
([ErrTotalRec]) — totality by construction.
/// z-score of a series over n bars
def zscore(x, n=20) = (x - sma(x, n)) / stdev(x, n)
plot zscore(close) as zThe optional visibility modifier scopes a declaration for the module system: pub crosses
an import, package spans the source files of one script, private (the default) stays in its
file. The modifier is contextual — decided by the token after it — so pub, private and
package remain usable as ordinary names in binding slots.
pub def helper(x) = x * 2
private record Internal { a: num }
pub {upper, lower} = bollinger(close, 20) // a modifier also prefixes a (destructuring) bindNamed types — variant, record, alias
variantDecl = "variant" IDENT "{" { TERM } ctorDecl { "|" ctorDecl } { sep } "}"
ctorDecl = IDENT [ "(" [ field { "," field } ] ")" ]
field = [ IDENT ":" ] kindExpr
recordDecl = "record" IDENT "{" { TERM } fieldDecl { sep fieldDecl } { sep } "}"
fieldDecl = IDENT ":" kindExpr [ "=" ( literal | "na" ) ]
typeDecl = "type" IDENT [ "(" IDENT { "," IDENT } ")" ] "=" kindExpr
kindExpr = "vec" "(" kindExpr "," constLen ")"
| "variant" "{" { TERM } ctorDecl { "|" ctorDecl } { sep } "}"
| "record" "{" { TERM } fieldDecl { sep fieldDecl } { sep } "}"
| IDENT [ "(" [ argList ] ")" ]
constLen = NUMBER | IDENTvariant declares a named sum type (constructors separated by |, payloads positional with
optional documentary field names); record declares a named product type (every field named
and kinded, with optional constant defaults); type declares a transparent alias, possibly
parameterized — substitution, not a new type. A kindExpr names a kind of the lattice
(price, osc(0,100)), a declared type, a vec(element, constLength), or an inline
structural variant{…} / record{…}. Angle brackets are never type delimiters — < and >
are exclusively comparison operators — so every parameterized form uses parentheses.
variant Phase { ask | suspense | revealed }
variant Tool { Select | Event(kind: num) | At(price) } // named or anonymous payloads
record Level { price: price; kind: num = 3; label: string }
record Inline {
state: variant { Connecting | Ready } // inline structural kinds
slots: vec(Level, 100)
}
type long = decimal(18, 0)
type Series(T) = vec(T, 500) // transparent, parameterized aliasThe reference graph between named types must be acyclic — record Node { next: Node } is
rejected with [ErrTotalType] at name resolution, keeping every type finite.
ANALYSIS sinks and parameters
plotStmt = "plot" exprList [ "as" IDENT ] [ block ]
markStmt = "mark" condExpr [ strLit ] [ block ]
fillStmt = "fill" addExpr ".." addExpr [ block ]
colorBarsStmt = "color" "bars" ":" condExpr
alertStmt = "alert" condExpr [ strLit ]
assertStmt = "assert" condExpr [ strLit ] [ "at" addExpr ]
strLit = STRING | interpStr
exprList = expr { "," expr }The sinks publish analysis values to the host: plot traces series (optionally named with
as, optionally styled with a trailing block), mark drops labeled markers on a condition,
fill shades between two series, color bars: colors the bars themselves, alert raises a
message on a condition, and assert states an invariant that must hold on every bar — or, with
at, on one given bar. An assertion whose condition is na (warm-up) passes; it fires only
on a definitely-false signal.
bb = bollinger(close, 20)
equity = cum(close - open)
fill bb.upper..bb.lower
color bars: if close > ema(close, 200)@"1d" then up else down
alert close cross_up open "crossed"
assert rsi(close, 14) <= 100 "rsi bounded" // na on bars 0–13 — vacuously satisfied
assert equity > 0 "positive" at 500
mark rsi(close, 14) > 70 "overbought" { size: 8 }A script parameter is the expression form input(…) (a primary, usable anywhere an
expression is):
inputExpr = "input" "(" inputDefault { "," inputArg } ")"
inputDefault = literal | IDENT | listLit
inputArg = metaArg | inputExtra
metaArg = IDENT ":" strLit
inputExtra = addExpr [ ".." addExpr ]The default must be constant — a literal, a source identifier, or a list of string labels (the
enumerated form); its kind fixes the parameter’s kind. After the default come an optional range
(2..200) or bound and named meta-UI arguments (title:, group:, tooltip:, inline:),
distinguished from a range by the IDENT : lookahead:
len = input(14, 2..200)
src = input(close)
name = input("fast", title: "Label", group: "Style")
mode = input(["ema", "sma"], tooltip: "kind") // enumerated — a variant of the labels
dec = input(1.50d) // decimal parameterCANVAS statements
onStmt = "on" eventExpr ARROW action
eventExpr = "hover" | "click" | "drag" | "enter" | "exit" | "move" | "wheel"
| everyExpr | condExpr
everyExpr = "every" "(" [ "~" ] ( DUR | SPAN | addExpr ) ")"
action = stmt | block
groupStmt = "group" [ block ]
repeatStmt = "repeat" addExpr "as" IDENT [ block ]
forStmt = "for" IDENT "in" condExpr ARROW uiChild
primStmt = primitive [ "when" condExpr ]
primitive = shapePrim [ block ] | contentPrim [ condExpr ] [ block ]
shapePrim = "dot" | "circle" | "ring" | "rect" | "square" | "triangle" | "poly"
| "line" | "path" | "backdrop" | "column"
contentPrim = "text" | "image" | "svg" | "sparkline"
spawnStmt = ( "spawn" | "burst" "(" addExpr ")" | "emit" "rate" "(" addExpr ")" ) primitive
tweenStmt = "tween" propPath arrowPair [ overClause ]
setStmt = "set" propPath "=" expr
effectStmt = ( "flash" | "bounce" | "pulse" | "shake" ) [ postfix ] [ block ]
propPath = IDENT { "." IDENT }on wires an event — an interaction verb, a cadence every(…), or any boolean stream — to an
action. A primitive draws one element, with a property block and an optional trailing when
guard. spawn / burst(n) / emit rate(r) create pooled short-lived elements; tween, set
and the effect words animate properties (their propPath targets the presentation state
self.*). The semantics of all of these live on the CANVAS page;
grammatically they are keyword-headed statements sharing the one block form.
on click -> group { dot { at:(bar.i, close); r: 4 } }
on every(1 bar) -> spawn ring { at: (bar.i, close); r: 6->24; life: 200 bars }
on every(~ 500ms) -> pulse // `~` — approximate cadence
on close > highest(close, 250)[1] -> burst(40) dot { at: (bar.i, close), life: 2s }
emit rate(norm(volume) * 40) dot { at: (bar.i, close), size: 2 }
tween self.glow 6->24 over 300ms
circle { at:(spring(close)); trail: 24 } when close > openView containers and comprehension. A view container is an identifier head (optionally
called) followed by a mandatory brace body of children; the comprehension for … in … -> is a
statement/child form only — for never begins an expression (pure data mapping is
vec.map):
uiElement = IDENT [ callTail ] uiBlock
uiBlock = "{" { TERM } [ uiChild { sep uiChild } { sep } ] "}"
uiChild = uiElement | forStmt | whenChild | primStmt | expr
whenChild = "when" condExpr ":" uiChildslots = window(close, 5)
row { text "spread" { at: (screen.cx, screen.cy) }; button("reset") }
panel { col { for s in slots -> renderSlot(s) } }
grid(cols: 2) { when close > open: button("up"); col { button("deep") } }A childless container (button(reset), panel(slot: x)) is not a uiElement — it is an
ordinary call expression of kind ui; the brace body is what makes the container form.
TRANSITION statements
morphStmt = "morph" ( "chart" | IDENT ) [ overClause ] [ block ]
overClause = "over" condExpr
focusStmt = "focus" "(" "view" { "," arg } ")"
replayStmt = "replay" "from" condExpr overClauseanchors = window(close, 10)
morph chart over 600ms
morph pnf { keep: anchors }
focus(view, over: 600ms, pad: 5%) // `over` here is an argument label, not the clause keyword
replay from close > 100 over 2sRepresentations and drawing tools
reprStmt = "representation" IDENT "(" [ params ] ")" reprBlock
reprBlock = "{" { TERM } [ reprHook { sep reprHook } { sep } ] "}"
reprHook = reprKey ":" reprVal
reprKey = "transform" | "render" | "reduce" | "liveReduce" | "updateLastUnit" | "persistKey"
reprVal = primStmt | block | expr
toolStmt = "tool" IDENT "(" [ params ] ")" toolBlock
toolBlock = "{" { TERM } [ toolHook { sep toolHook } { sep } ] "}"
toolHook = toolKey ":" toolVal
toolKey = "barExtent" | "priceExtent" | "render"
toolVal = primStmt | block | exprBoth are keyword-headed declaration forms whose bodies are a closed enumeration of hooks — the
script authors the pure functions (geometry, rendering, reduction), the host supplies
everything else (placement, hit-testing, persistence; hence hitTest and lod are not
keywords and remain free names). See host integration.
representation pnf(box, rev) {
transform: rebin(close, box, rev)
render: column { at: (bar.i, hi), h: hi - lo, w: 1 }
reduce: decimate(cols, k)
liveReduce: mutateHead(cols)
updateLastUnit: patch(cols)
persistKey: "pnf-v1"
}A drawing tool’s hooks read the anchors the host hands it (a, b below are host-placed
anchor points), so its body only means something with the host in the loop:
tool fib(a, b) {
barExtent: (a.bar, b.bar)
priceExtent: (a.price, b.price)
render: line { at: (a.bar, a.price), w: b.bar - a.bar, h: b.price - a.price }
}The APP descriptor
appStmt = "app" IDENT appBody
appBody = "{" { TERM } [ appMember { sep appMember } { sep } ] "}"
appMember = capEntry | memberDef
capEntry = "capabilities" ":" capList
capList = "[" { TERM } [ capRef { "," capRef } { sep } ] "]"
capRef = CAPREF | IDENT
memberDef = ( "init" | "update" | "view" | "subs" | "contributes" )
"(" [ params ] ")" "=" memberBody
memberBody = uiElement | exprAn app is a named descriptor with one capabilities: entry and the five fixed members of the
TEA harness (TEA — The Elm Architecture). The member heads are keywords, not identifiers — the
form is deliberately def-less because these five roles are fixed. memberBody is the one
place in the grammar where a statement-level view container is the right-hand side of =
(a view returns a view); everywhere else the RHS of = is an expression.
app structureGame {
capabilities: [chart:read, storage:own, levels:write, sfx]
init(p) = {score: 0, phase: ask}
update(m, msg) = match msg {
Tick(dt) -> m with {score: m.score + dt}
_ -> m
}
view(m) = panel(slot: side) {
row { text "score {m.score}"; when m.done: button(reset) }
for t in TOOLS -> button(t)
}
subs(m) = [OnTick(Tick)]
}The APP plane’s semantics — Model, Msg, commands as inert data, subscriptions — are specified on the APP plane page.
Imports and visibility
importStmt = "import" pkgRef [ "as" IDENT ]
pkgRef = IDENT "/" IDENTimport author/package binds a package under its name or an as alias; only the package’s
pub declarations are reachable, as qualified names mod.f. The / of the coordinate is
never confused with division — it is only reachable after import IDENT, a state where no
expression exists.
import acme/wyckoff as wk
pub def helper(x) = wk.zone(x) * 2The import mechanism, content-addressed resolution and lockfile semantics are fully specified (packages); deploying the public registry follows v1.
Expressions
The expression grammar is a strict stratification: each level refers only to the next tighter level, so precedence is built into the shape of the grammar itself (no precedence declarations are needed for the arithmetic chain, and the parse is LR(1) by construction).
expr = arrowExpr
arrowExpr = condExpr [ ARROW expr ] (right-assoc; lambda or pair — see below)
condExpr = ifExpr | letExpr | ternExpr
ifExpr = "if" expr "then" expr "else" expr (else mandatory)
letExpr = "let" letPat "=" expr "in" expr
ternExpr = coalExpr [ "?" expr ":" expr ] (≡ if/then/else)
coalExpr = orExpr [ "??" coalExpr ] (right-assoc; ≡ nz)
orExpr = andExpr { "or" andExpr }
andExpr = notExpr { "and" notExpr }
notExpr = "not" notExpr | cmpExpr
cmpExpr = addExpr [ cmpOp addExpr ] (NON-associative)
addExpr = mulExpr { addOp mulExpr }
mulExpr = unary { mulOp unary }
unary = "-" unary | postfix
postfix = primary { callTail | indexTail | clockTail | memberTail | safeNavTail | withTail }
primary = NUMBER | DEC | DUR | PCT | PX | SPAN | RATE | STRING | interpStr | BOOL | "na"
| IDENT | inputExpr | tweenSig | matchExpr | listLit | recordLit | blockExpr
| sceneExpr | parenForm
tweenSig = "tween" "(" arrowPair [ "," argList ] ")"
arrowPair = condExpr ARROW condExpr
Figure — the stratified expression spine: each level calls only the next tighter one.
Conditionals: if, let, ternary, coalescing
if always carries an else — there is no dangling-else problem because the incomplete form
does not exist. let … in scopes a binding (or an irrefutable destructuring) over one body
expression. The ternary is the same tree as if/then/else, and ?? is sugar for nz
(replace na by a default):
plot if close > open then 1 else 0
r = let x = close - open in x * x
d = let { upper, lower } = bollinger(close, 20) in upper - lower // destructuring let
t = close > open ? 1 : 0 // ≡ if close > open then 1 else 0
z = close[1] ?? 0 // ≡ nz(close[1], 0)
x = if a then 1 // ✗ syntax error — else is mandatorymatch
matchExpr = "match" condExpr "{" { TERM } matchArm { sep matchArm } { sep } "}"
matchArm = pattern ARROW expr
pattern = "_" | "na" | ctorPat | recordPat | IDENT
ctorPat = IDENT [ "(" [ IDENT { "," IDENT } ] ")" ]
recordPat = "{" [ IDENT { "," IDENT } ] "}"match is the eliminator of variant values (and of na), usable in any expression position.
The scrutinee is arrow-free (condExpr), so every -> inside the braces belongs to an arm.
Patterns are flat in v1: wildcard, na, a constructor with bound payload names, a record
destructure, or a binding identifier — no deep nesting. Exhaustiveness is checked statically;
a non-exhaustive match is rejected with [ErrTotalMatch].
variant Phase { ask | suspense | revealed }
variant Tool { Select | Event(kind: num) | At(price) }
m = { phase: ask }
t = Event(3)
next = match m.phase {
ask -> suspense
suspense -> revealed
_ -> ask
}
which = match t { Event(k) -> k; At(p) -> 0; _ -> na }
q = SaveState.Saved // a qualified constructor disambiguates cross-variant homonymsA nullary tag and a binding identifier are the same parse tree (an IDENT pattern); whether
ask names a known constructor or binds a fresh variable is resolved semantically, exactly
like function resolution — never a parse fork.
Record, list and block literals; scene
recordLit = "{" { TERM } fieldAssign { sep fieldAssign } { sep } "}" (≥ 1 field)
fieldAssign = IDENT ":" expr
listLit = "[" { TERM } [ expr { "," expr } { sep } ] "]"
blockExpr = "{" { TERM } { blockBind sep } expr { TERM } "}"
blockBind = IDENT "=" expr
sceneExpr = "scene" uiBlock
parenForm = "(" [ parenItem { "," parenItem } ] ")"
parenItem = expr [ ".." expr ]A record literal builds the first record ({a: 1, b: 2}; an empty {} is not a record). A
list literal builds a bounded vec ([1, 2, 3], [] — the spine of every APP command list).
A block expression sequences immutable bindings before a final expression and desugars into
nested let … in — the multi-line body form:
m = {a: 1, b: 2}
xs = [1, 2, 3]
empty = []
v = { x = 1; y = x * 2; y + x } // blockExpr — desugars to let x = 1 in let y = … in y + x
w = { close } // a one-expression block
bad = { a, b } // ✗ syntax error — neither a record (no `:`) nor a block (no `=`)scene { … } packages a multi-element CANVAS scene as a value of kind ui — the only
expression form of a scene, which otherwise lives at statement level. It is how a def returns
an overlay:
def overlayOf(d) = scene {
line { a: d.a; b: d.b }
for it in items(d) -> dot { at:(it.bar, it.price) }
}The paren form unifies grouping, coordinates and lambda heads: (x) grouping, (x, y) a
coordinate/argument pair (at:(bar.i, close)), (2..200) a range operand, (p) -> … a
lambda head.
The postfix chain
callTail = "(" [ argList ] ")"
indexTail = "[" expr "]"
memberTail = "." IDENT
safeNavTail = "?." IDENT
clockTail = "@" clockOperand
clockOperand = STRING | IDENT [ callTail ] | "(" expr ")"
withTail = "with" recordUpdateBody
recordUpdateBody = "{" { TERM } [ fieldAssign { sep fieldAssign } { sep } ] "}"
argList = arg { "," arg }
arg = [ IDENT ":" ] expr
arrowPair = condExpr ARROW condExprAll six suffixes bind at the same (tightest) level and associate left, in lexical order:
slots = window(close, 20)
bb = bollinger(close, 20)
m = { a: 1 }
i = input(0, 0..19)
chain = bollinger(close, 20).upper[1]@"1d"
// ((( bollinger(close,20) ).upper )[1] )@"1d"
prev = close[1] // [Delay] — scalar stream, constant index
s = slots[i] // [Index] — vec element, runtime index, out-of-bounds → na
htf = ema(close, 50)@"1d" // clock suffix — see time-and-state
safe = bb?.upper // na-propagating navigation
y = m with {a: 3} // functional record update — shape-preservingThe @ operand is deliberately restricted (a string, an identifier or call, or a
parenthesized expression) so that x@"1d" + 1 parses as (x@"1d") + 1 with no precedence
subtleties. with { … } is a postfix keyword, not a brace-led expression — the update body is
only reachable after the word with, which is what keeps it distinct from every other brace.
Arguments may be labeled (focus(view, over: 600ms)); the label is decided by the IDENT :
lookahead. Member access doubles as UFCS — close.ema(20).rsi(14) is
rsi(ema(close, 20), 14); the . resolves to field, function call or qualified module name at
compilation with no grammar impact (see Operators).
Lambdas
A lambda is an arrow whose left side is a paren form of bare identifiers, in a position that
expects a function (the higher-order arguments of fold, map, scan, loop, …):
def ema0(s, n) = let a = 2/(n+1) in scan(s, (p) -> a*s + (1-a)*p)
r = window(close / close[1], 20) // a vector of ratios
sq = vec.map(r, (x) -> x * x)
inc = vec.map(r, _ + 1) // placeholder sugar — exactly one `_`, mono-argumentThe parens are part of the form — a bare x -> … in a value position is a tween pair, not a
lambda (see the next section); the single-argument shorthand is the _ placeholder. A
multi-statement body is a block expression: (x) -> { d = x - open; d * d }.
Blocks and properties
block = "{" { TERM } [ item { sep item } { sep } ] "}"
item = propEntry | stmt
propEntry = IDENT [ ":" propValue ]
propValue = arrowPair | addExpr ".." addExpr | condExpr
literal = NUMBER | DEC | DUR | PCT | PX | SPAN | STRING | BOOLThere is one block form. Its items are properties (key: value, or a bare flag like
stagger) and/or statements; which of the two a given head permits (a dot block takes
properties, a group block takes statements plus the flags) is a semantic restriction, not a
separate grammar. The same division of labor governs the value shapes: the grammar admits a
plain value, a range lo..hi, or a tween pair a->b in the propValue slot, and each head
then accepts the shapes that mean something on it — a v1 shape property takes a value or an
animable pair a->b, and rejects the range shape with a pointed diagnostic (the ranges you
write in practice live in their dedicated slots: fill a..b, input(n, lo..hi)):
group { stagger; dot { at:(bar.i, close); r: 6->24; glow: 16 } }The single arrow
-> is one token and one grammar production — Arrow { lhs, rhs } — with five readings
selected entirely by context. There is no second arrow symbol anywhere in the language.
Figure — one arrow token, five readings, each selected by its guarding context.
| # | Reading | Example | What selects it |
|---|---|---|---|
| 1 | lambda | scan(s, (p) -> a*s + (1-a)*p) |
left side is a paren form of bare identifiers, in a position expecting a function |
| 2 | event → action | on click -> pulse |
the on head; the event operand is arrow-free |
| 3 | tween pair | r: 6->24, tween(0->1, ease: out) |
a value position (property, tween); both sides are values |
| 4 | match arm | Event(k) -> k |
the match head owns the braces; every arm arrow is claimed by it |
| 5 | view comprehension | for t in TOOLS -> button(t) |
the for … in head; the collection is arrow-free |
Deterministic by construction, in two steps:
- Guarded arrows never reach the expression grammar. In
on EVENT -> ACTION,match e { pattern -> expr }andfor x in coll -> child, the operand before the arrow is acondExpr— a stratum that excludesarrowExpr— so the event, scrutinee and collection can never absorb the->. The arrow is claimed by the head’s own production, with zero choice for the parser. - Unguarded arrows build one tree; the role is decided at inference.
a -> bin an expression parses as the singleArrownode whether it will act as a lambda or as a tween pair. Kind inference reads it as a lambda exactly when the left side is a paren form of bare identifiers and the position expects a function; otherwise it is a pair of values (6->24). The same tree also serves the dedicated pair productionarrowPairused bytweenand property values.
plot -> 3 // ✗ syntax error — an arrow needs a left side; no production begins with ->Why one arrow. Five separately spelled arrows would demand five tokens, five precedence entries and a reader’s mental table mapping spelling to role. One token with head-guarded readings costs the grammar nothing (each guard is an LR state the head already owns), keeps every program visually consistent, and moves the only genuine ambiguity — lambda versus pair — to the kind checker, which must inspect that position anyway. The parser never forks.
Precedence and associativity
From loosest to tightest; each level is a stratum of the grammar above:
| Level | Operators | Associativity |
|---|---|---|
| 0 | -> (lambda / pair) |
right |
| 1 | ? : ternary · if/then/else · let/in |
right; else mandatory |
| 2 | ?? |
right |
| 3 | or |
left |
| 4 | and |
left |
| 5 | not |
prefix |
| 6 | < > <= >= == != cross_up cross_down |
non-associative |
| 7 | + - |
left |
| 8 | * / % |
left |
| 9 | unary - |
prefix — tighter than *// |
| 10 | postfix f(…) [i] @c .m ?.m with {…} |
left, same level, lexical order |
| 11 | primary | — |
Figure — the ladder from loosest to tightest, and how one realistic expression parses along it.
Consequences worth spelling out:
m = -close[1] * 2 // (-(close[1])) * 2 — unary minus tighter than `*`, postfix tighter still
p = close > open // signal
q = volume > sma(volume, 20) // signal
r = not p and q or p // ((not p) and q) or p
s = m ?? 0.0 // m ?? 0.0 — null-coalescing
t = close > open ? high : low // (close > open) ? high : low
e = macd(close).hist[1]@"1d" // (((macd(close)).hist)[1])@"1d" — postfix chain, lexical order
bad = a < b < c // ✗ syntax error — comparisons do not chain (non-associative)Outside the ladder. Three token families take no precedence level at all. The range .. is
non-associative and appears only in its dedicated slots (fills, spans, input ranges, paren
items, property values) — never inside the operator cascade, so bb.upper..bb.lower needs no
parentheses. The clock suffix @ restricts its right operand to a clockOperand, so
x@"1d" + 1 is (x@"1d") + 1 by construction. And : and , are pure separators.
Why stratification instead of precedence annotations. Each stratum refers only to the next tighter one — there is no cross-recursion anywhere in the expression grammar — so the LR automaton is the precedence table. Nothing needs to be declared, so nothing can be declared inconsistently; the non-associative comparison level is a production that does not repeat. The comparison ban on chaining exists because
a < b < chas no boolean reading in a dimensional language (the first comparison yields asignal, which is not ordered againstc— the grammar rejects the shape before the kind checker would).
Newlines. The statement separator TERM and its continuation rules interact with this
ladder (an infix operator at a line start continues the previous line). The policy is lexical
and specified in lexical structure.
Disambiguation catalogue
Every place where two constructs could compete for the same token is closed by one of five devices: a tokenizer rule, the stratification, a guarding head keyword, distinct LR states, or one token of bounded lookahead. The design plan enumerates each potential conflict and its resolution; the grammar build re-proves the absence of conflicts mechanically on every change. The cases a reader actually meets:
The brace. All { readings are separated by where the brace is reached:
| You see | It is | Decided by |
|---|---|---|
{f: v, …} in expression position |
record literal | lookahead after { IDENT is : |
{x = 1; …; e} in expression position |
block expression | lookahead after { IDENT is = / ; / } |
{a, b} = e at statement level |
destructuring bind | no : after { IDENT; statements never start with a brace-led expression |
match e { … } |
match arms | the match head owns this brace |
e with { … } |
record update body | reachable only after the with token |
row { … }, panel(x) { … } |
view container | brace after an IDENT/call head in statement or child position |
plot … { … }, dot { … }, group { … } |
the one block (style/props/stmts) |
trailing block guarded by its statement head |
variant T { … }, record T { … }, app N { … } |
declaration body | keyword head |
Two brace-led forms — and only two — can begin an expression (record literal, block
expression), and one token after { IDENT separates them; a bare { a, b } in expression
position is neither, and is rejected. Every other brace is reached after a guarding token, in
an LR state where no expression can start — so the parser never forks on {.
The bracket. [ at the start of an expression opens a list literal; [ glued after a
postfix is an index. The two sit in different automaton states (expecting-a-value versus
holding-a-value), so no input reaches both. The single index form then carries two typing
roles, chosen by the receiver’s kind, not by grammar: on a scalar stream with a constant index
it is the causal delay close[1]; on a vec it is element access slots[i] (runtime index,
out-of-bounds yields na).
Items after an identifier. Inside a block, the token after a leading IDENT dispatches:
: → property, = → binding, { → view container, ( → call (then a following { makes it
a container; otherwise it stays a call — same Call tree either way, the container question is
semantic), separator/} → bare flag or expression child.
when, twice. when c: child (a conditional view child, with :) versus
dot { … } when c (a trailing guard on a primitive, without :) — the colon decides.
Contextual at. In assert cond "msg" at 500, at is a keyword only in that clause
position (after the condition and optional message). It never collides with the property key
at: of a canvas block, which is an item-level IDENT :.
The capability colon. chart:read is a single CAPREF token recognized only inside a
capability list; the [ of capabilities: [ … ] is likewise reached only after
capabilities :, never in expression position. No other colon in the language can occur in
that state (details).
Contextual color. color is a keyword only as the head of the two-token sequence
color bars; followed by anything else it is an ordinary identifier — which is what lets
color name both a field and the color kind (record Stop { color: color }).
The ? family. The lexer’s maximal munch orders ?? > ?. > ?; the productions then
sit at three different strata (coalescing, postfix, ternary). The ternary’s : is reachable
only after ? expr, an LR state disjoint from every other colon.
Visibility prefixes. pub / private / package are modifiers exactly when followed by
a declaration head or a binding; followed by =, :, . or ( they are plain identifiers.
Since two adjacent identifiers are never a valid parse, pub def … commits deterministically.
Formal properties
The grammar is frozen against six criteria, each with a machine verification — designing the bad states out, then checking by machine, rather than being careful:
| Property | Meaning | Guaranteed by | Verified by |
|---|---|---|---|
| Complete | every intended program parses; every construct has a surface form | the grammar is corpus-driven: each catalogued construct contributed a form | the full example corpus parses to valid ASTs |
| Correct | exactly the intended language; trees mirror structure (a-b-c = (a-b)-c) |
one normative grammar; total precedence & associativity | conformance suite: positives with expected tree, negatives with expected diagnostic; round-trip parse → canonical format → re-parse yields the identical AST |
| Consistent | no contradictory or dead rules | a single grammar artifact; every non-terminal reachable | the grammar generator’s linter reports zero warnings |
| Unambiguous | every valid input has exactly one tree | an LR grammar class whose build fails on any conflict; each potential conflict resolved by a named device | the build completes with zero unresolved conflicts; fuzzing finds no input with two trees |
| Decidable parsing | the parser always terminates; linear and incremental | LR(1) by stratification; no unbounded lookahead anywhere | complexity profile; the incremental parser serves live preview within its frame budget |
| Semantically coherent | every parsed program gets a defined meaning or a precise error — no gaps | decidable analyses on the tree: kind inference on a finite-height lattice, clock-calculus causality, the plane firewall, totality by construction | the typed corpus asserts expected kinds; negatives assert the exact diagnostic ([ErrDim], repaint attempts, …) |
Three of these verifications deserve a sentence each:
- The build is the ambiguity proof. The normative grammar is expressed once, as a Lezer LR grammar; an LR generator reports every conflict at build time, so “the build is green” is a machine check of non-ambiguity — not a review claim. (An ordered-choice formalism was rejected for exactly this reason: it does not detect ambiguity, it silently hides it.) The same artifact drives the compiler, the editor’s syntax services and the documentation tooling, so there is nothing to drift.
- The corpus round-trips. Every example in the corpus is parsed, printed by the canonical formatter, and re-parsed; the two trees must be identical. A grammar bug and a formatter bug break the same gate. (The corpus is also the golden suite, so an example cannot rot without a test going red.)
- The parser is total. Fuzzing asserts that any byte sequence either parses to a unique tree or is rejected with clean diagnostics — never a crash, never a hang. Malformed input is part of the language’s domain.
Semantic coherence extends beyond parsing: for every kind, the admissibility of each operator,
comparison, fill and plot is enumerated over the finite-by-family kind set, so no
kind/construct pair is left without either a meaning or a named error. The verification harness
that runs all six of these checks is owned and specified by Verification & reproducible
builds; the guarantees themselves are gathered on the
Guarantees page.
Additivity and versioning
The grammar evolves under a strict additivity policy:
- A valid script stays valid indefinitely. The semantics of an existing construct is never altered; the surface only grows. Every extension must itself prove zero conflicts at the grammar build before it lands — additivity is a build gate, not a promise.
- Language versions are declared, optionally. Each source file may carry a language
version marker; a file without one means “current”. (An app’s data schema — its
Msgvariant and model — is versioned separately by the app itself; see the APP plane.) - Keywords are reserved ahead of need. Words destined for future surface are reserved
before their productions ship, so no program written today can shadow tomorrow’s syntax —
testis the standing example (keyword model). Combined with contextual reservation, this makes future growth collision-free by construction. - The codemod promise. If a deprecation ever became unavoidable, an automatic codemod would rewrite affected sources at load — mechanically, not by asking authors to migrate by hand. Nothing in v1 is deprecated.
Why this rule exists. A total, deterministic language is a promise about the future of a program, not just its present: a script that replays byte-identically today must still parse — and mean the same thing — under every later compiler. Strict additivity is the syntactic half of that promise; the pinned-routine and byte-identity invariants are the semantic half (compiler and runtime).
See also
- Lexical structure — tokens, the significant newline, the keyword tiers.
- Kinds — the dimensional lattice the parsed tree is checked against.
- Operators — the per-operator dimensional algebra, UFCS,
with, the?family. - Inference — how kinds (and the lambda-versus-pair arrow) are decided on the tree.
- The CANVAS plane · The APP plane — semantics of the statement families above.
- Guarantees — the trust page: every machine-verified property in one place.