Skip to content

Operator precedence and associativity

Reference material, synced from the compiler repo’s spec/. The guide is the gentler path in.

Operators are listed from highest precedence (binds tightest) to lowest (binds loosest). Operators on the same row have the same precedence; their associativity is given in the right column.

LevelOperatorsAssociativityNotes
14() [] . ::leftCall, index, field access, path
13unary - ! ~rightUnary minus, logical not, bitwise not
12* / %leftMultiplicative
11+ -leftAdditive
10<< >>leftBit shifts
9&leftBitwise and
8^leftBitwise xor
7|leftBitwise or
6< > <= >=non-assocOrdering comparison
5== !=non-assocEquality
4~~non-assocApproximate equality (closure context only)
3&&leftLogical and
2||leftLogical or
1.. ..=non-assocRange (parsed everywhere; use restricted to for x in lo..hi at typecheck)
1orrightFallible disposition (v1.x-FORM-1; contextual). RHS is raise / discard / fail <expr> / another expression.
0= += -= *= /= %= &= |= ^=rightAssignment
-1<-non-assocBus send (statement-shape only)

Comparison and equality are non-associative

Section titled “Comparison and equality are non-associative”

a < b < c is a parse error. Use a < b && b < c. This avoids the chained-comparison surprises common in C and matches Rust’s choice.

The ~~ operator is permitted only inside a closure block’s assertion clause. Elsewhere it is a parse error. It is non-associative; a ~~ b ~~ c is invalid.

A closure assertion has the form:

expression ~~ expression within expression

The within clause is part of the assertion syntax, not an operator at this precedence level. See grammar.ebnf for the production.

The lexer emits the same tokens for < > regardless of context. The parser disambiguates: when a < follows an identifier in a type-expression context (after :, ->, in a generic-args position), it begins a generic-args list. Otherwise it is a comparison.

This is the same approach Rust takes (with the famous turbofish disambiguator ::<> reserved for ambiguous expression contexts). v0 does not provide turbofish; we’ll add it if needed.

  • . accesses a field or method of a runtime value: foo.bar, book.bid_side().
  • :: accesses a name through a path: module, type, perspective: messages::Book, Strategy::default().

Fallible disposition (or) binds looser than everything except assignment

Section titled “Fallible disposition (or) binds looser than everything except assignment”

The or keyword is the fallible-disposition postfix (v1.x- FORM-1). It is contextual — recognized only when it appears as a postfix on an expression that the typechecker has marked Ty::Fallible. Outside that position, or is an ordinary identifier (so let or = 5; stays admissible).

It is right-associative so a chain reduces step by step:

a() or b() or raise
// parses as: a() or (b() or raise)

The RHS of or is one of:

  • the contextual keyword raise (diverges by re-entering the enclosing fallible(E) fn’s error-return path — the value-error channel, orthogonal to closure tests; past every fallible frame it root-panics via lotus_root_panic), or
  • discard (swallow the error, substitute Unit; rejected at typecheck when the success type is non-Unit), or
  • fail <expr> (diverge like raise, but with a fresh payload of the enclosing fallible fn’s declared error type), or
  • any expression of the success type (the substitute path; err is implicitly bound to the payload in this scope).

Bus send is a statement, not an expression

Section titled “Bus send is a statement, not an expression”

<- does not nest in expressions. It appears only at statement position:

"subject" <- value;

The left side is a string-literal subject (or an identifier bound to a publish handle in a future revision); the right side is any expression. The construct produces no value — there is no x = ("s" <- v). The level −1 row in the table is bookkeeping: <- binds looser than every expression operator, so the parser recognizes the leading expression in full before deciding the statement is a send.

Recovery primitives are statements, not operators

Section titled “Recovery primitives are statements, not operators”

restart, quarantine, bubble, violate, etc. are statement-level keywords; they do not participate in the precedence table. They appear in the form:

restart(child);
quarantine(child) for 30s;
bubble(err);
violate fatal_io; // F.27, v1.x-VIOLATE
violate fatal_io with detail; // optional payload

violate is divergent (same type-level shape as fail / bubble); the typechecker treats it as Never. Its closure- name argument is a bare identifier (not a parenthesized argument list), and the optional with <expr> trailer carries a user-shaped payload onto the synthesized ClosureViolation.

Lifecycle and locus member declarations are not expressions

Section titled “Lifecycle and locus member declarations are not expressions”

birth, accept, run, drain, dissolve, on_failure, mode, closure, contract, params, bus introduce declaration-level constructs inside a locus body and never appear in expression position.

a + b * c // a + (b * c) -- level 12 binds tighter than 11
a == b && c == d // (a == b) && (c == d) -- level 5 over level 3
a < b == c // parse error: < is non-assoc with ==
-a.field // -(a.field) -- access binds tighter than unary minus
foo<T>(x) // generic instantiation, not (foo < T) > (x)
  • .. / ..= (range) — level 1, non-assoc. These parse in any expression position; v1 restricts their use to for x in lo..hi, so a range elsewhere is a typecheck error, not a parse error. .. is exclusive, ..= inclusive.

Future operators expected to land at specific levels:

  • ?? (nil-coalesce) — level 2, right-assoc

?? is a reserved token; using it in v0 is a parse error.

Note: ? (try-propagation) was previously reserved at level 13 but has been cut from the roadmap. The fallible- disposition operator or covers the same use case at the expression level without re-introducing a parallel upward-propagation mechanism at the value layer. See spec/design-rationale.md.