◆ Flux

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.

FLUX
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                            // TRANSITION

Programs 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.

FLUX
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 | importStmt

Nearly 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.

FLUX
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 bare

A 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.

FLUX
/// z-score of a series over n bars
def zscore(x, n=20) = (x - sma(x, n)) / stdev(x, n)
plot zscore(close) as z

The 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.

FLUX
pub def helper(x) = x * 2
private record Internal { a: num }
pub {upper, lower} = bollinger(close, 20)    // a modifier also prefixes a (destructuring) bind

Named 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 | IDENT

variant 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.

FLUX
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 alias

The 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.

FLUX
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:

FLUX
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 parameter

CANVAS 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.

FLUX
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 > open

View 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 ":" uiChild
FLUX
slots = 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 overClause
FLUX
anchors = 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 2s

Representations 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 | expr

Both 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.

FLUX
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:

FLUX
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 | expr

An 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.

FLUX
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 "/" IDENT

import 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.

FLUX
import acme/wyckoff as wk
pub def helper(x) = wk.zone(x) * 2

The 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

Railroad view of the expression spine, from expr down to primary 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):

FLUX
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 mandatory

match

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].

FLUX
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 homonyms

A 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:

FLUX
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:

FLUX
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 condExpr

All six suffixes bind at the same (tightest) level and associate left, in lexical order:

FLUX
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-preserving

The @ 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, …):

FLUX
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-argument

The 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 | BOOL

There 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)):

FLUX
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.

The five contextual readings of the single arrow token 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:

FLUX
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 —

The precedence ladder with a worked example expression Figure — the ladder from loosest to tightest, and how one realistic expression parses along it.

Consequences worth spelling out:

FLUX
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 < c has no boolean reading in a dimensional language (the first comparison yields a signal, which is not ordered against c — 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:

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:

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