The Phero Lang expression language (Tier 2)
Phero Lang’s expression sublanguage is the deliberate replacement for Eclipse
Xbase inside .mox bodies and derived-feature expressions. It is deliberately
small: literals (including date("YYYY-MM-DD") date constants), field/feature
access, method calls on model-declared operations, comparison and arithmetic,
if/let, a fixed collection algebra (first, filter, map, any,
size, sum), and a small typed calendar algebra on dates (plus_days,
plus_months, diff_days). There are no arbitrary host-library calls —
that is the discipline that keeps expressions portable across backends — and
no clock: expressions stay pure, and “as at date D” queries take date
values as operation parameters or attributes. datetime does not exist in
this milestone; date is timezone-less and time-less by construction.
The pipeline is: parse (phero-expr) → type-check once in Rust against the Core
IR (phero-expr, over a phero_ir::Model) → each backend lowers the typed
expression tree to idiomatic target code. Because backends see typed trees,
semantics must be pinned down explicitly where target languages disagree —
integer overflow, string equality, string concatenation, null/Option,
division by zero, and the calendar rules for dates are the disagreeing
corners. Those are rules R1–R9 below; they are numbered so tests and
backends can cite them.
Status: this document is the normative spec for the expression surface and typing (the front half). Backend lowering rules land with each backend; the Rust lowering is pinned below — each rule carries a “Rust lowering” subsection, and the exact Rust shapes for names, feature access, and the algebra are in “Rust lowering”.
Lexical rules
Section titled “Lexical rules”- Comments:
//to end of line,/* ... */block comments. - Strings: double-quoted with
\"and\\escapes (same as.mox). - Integers: decimal digits, non-negative in the token stream; a leading
-is the unary minus operator, never part of the literal. A literal that does not fit ini64is a lex error. - Floats:
digits.digits— one or more digits, a., one or more digits, non-negative in the token stream (a leading-is, again, the unary operator). Digits are required on both sides of the dot, so member access never lexes as a float. No exponent notation. - Identifiers:
[A-Za-z_][A-Za-z0-9_]*.if else let true false nullare reserved keywords.first filter map any size sumare contextual: they name the collection algebra only inname("...")position after a.(or?.), so a model feature may freely be calledsizeorfirst. The worddateis likewise contextual: it names the date constructor only in thedate ( STRING )primary form, and a baredateis still an ordinary name. - Operators:
== = != < <= > >= + - * / && || !and the navigation operators.?.?:, plus=>(lambda arrow) and;(let separator).
Grammar (informal EBNF)
Section titled “Grammar (informal EBNF)”expr := let_expr | lambda_expr | coalescelet_expr := "let" NAME "=" expr ";" exprlambda_expr := NAME "=>" exprcoalesce := logic_or ("?:" logic_or)* -- left-associativelogic_or := logic_and ("||" logic_and)*logic_and := comparison ("&&" comparison)*comparison := additive (cmp_op additive)* -- left-associative chaincmp_op := "==" | "=" | "!=" | "<" | "<=" | ">" | ">="additive := multiplicative (("+" | "-") multiplicative)*multiplicative := unary (("*" | "/") unary)*unary := ("!" | "-") unary | postfixpostfix := primary ((("." | "?.") member))*member := NAME ("(" args? ")")?args := expr ("," expr)*primary := INT | FLOAT | STRING | "true" | "false" | "null" | NAME | "date" "(" STRING ")" | NAME "(" args ")" | "(" expr ")" | "[" (expr ("," expr)*)? "]" | "if" expr "{" expr "}" "else" "{" expr "}"Notes:
-
The
dateconstructor’s argument must be a string literal — any other expression in the parentheses is a parse error (rule R6’s syntactic half). The calendar validity of the text is checked later, by the type-checker. -
let, lambdas, and?:chains appear at the top level of an expression or inside parentheses/brackets/argument lists. A lambda body extends as far right as possible; parenthesize to use a lambda as a binary operand. -
==is the canonical equality operator. A single=is accepted where an operator is expected and means exactly the same thing (see R2); it cannot be confused with the=oflet, which follows thelet NAMEprefix. -
NAME ("(" args? ")")after.is a call. IfNAMEis one of the six algebra names, the argument shape is validated syntactically (see the algebra table):firsttakes an optional lambda,filter/map/anyrequire exactly one lambda,size/sumtake none. Any otherNAMEwith parentheses is an operation call on a model-declaredop. -
A bare
NAMEis a variable reference — aletbinding or a lambda parameter — except in the bare-call formNAME "(" args ")", which is a package-function call (see “Function calls” under the type system). A bareNAMEwith no parentheses is never a call. Feature access always has an explicit receiver; the one receiver that is not a variable is the enum-literal namespace (rule L3):EnumName.MemberwhereEnumNameis not a scope binding is a typed member literal.
Precedence (loosest to tightest)
Section titled “Precedence (loosest to tightest)”| Level | Operators | Associativity |
|---|---|---|
let / lambda |
prefix forms | body maximal |
| coalesce | ?: |
left |
| logic or | ` | |
| logic and | && |
left |
| comparison | == = != < <= > >= |
left |
| additive | + - |
left |
| multiplicative | * / |
left |
| unary | ! - |
prefix |
| postfix | . ?. calls, algebra |
left |
if is a primary: its condition and both branche bodies are full
expressions.
Type system
Section titled “Type system”Types (Ty):
- Primitives:
string int long short float double boolean byte char date(mirroringphero_ir::PrimitiveType). Adateis a timezone-less calendar day (YYYY-MM-DD); it has no time-of-day component and no timezone. - Named types: class, enum, datatype, interface, vocabulary — always
package-qualified in the checker (
Named { kind, package, name }), because they come from a resolvedphero_ir::Model. List<T>— a to-many feature value or algebra result.Option<T>— an optional (0..1) feature value; also the result of?.chains andfirst.- Null — the type of the
nullliteral. It is notOption<T>; it exists sonullcan participate in equality (R2) and?:(R3) and nowhere else.
Booleans are boolean (true/false literals). Comparisons yield
boolean.
L1 (literal polymorphism). An integer literal is typed int by
default. In a context whose expected type is long — the other operand of an
arithmetic/comparison operator, an operation argument, or a list element base
— an integer literal is typed long instead (its value always fits i64).
A float literal (digits.digits) is the float analogue: typed double by
default, adapting to float in a float context — the other operand of an
arithmetic/comparison operator, an operation argument, or a list element base
— in either operand order (the feature’s float type wins; see the float
special cases of L2). Everywhere else, and for everything that is not a
numeric literal, there are no implicit conversions: in particular there
is no implicit int → long or float ↔ double (see U1). A literal typed
int whose value does not fit in 32 bits is an R1 constant-overflow error;
float literals have no compile-time range rule.
L2 (operand rule). + - * / require both operands to have the same
numeric type and produce it — except +, which additionally admits two
string operands and concatenates them (rule R9). < <= > >= require the
same numeric type — or two date values (rule R7) — and produce boolean.
&& || ! operate on boolean only; unary - takes a numeric. There is no
ordering on string; strings support equality (R2) and + concatenation
(R9). Dates take no arithmetic operators at all: calendar arithmetic is the
typed method algebra described under R8, never +/-.
L3 (enum-literal namespace and closed membership). Enums carry a closed
literal set, and expressions can name members as typed values: a dotted
name EnumName.Member whose receiver is not a scope binding resolves
through the enum-member registry (DomainTypes::insert_enum, fed from the
model’s EnumDef literals), and the member types as the enum — so
status == PayRunStatus.Draft is the typed spelling of an enum comparison,
in either operand order, == or !=. Scope wins: a bound name shadowing
an enum name takes the ordinary feature-access path. A bare enum name
without .Member stays an ordinary name (a type, not a value); a member
chain past the literal (EnumName.Member.x) is an unknown-feature error.
A bare name declared by several packages is ambiguous — a type error naming
every declaring package (the package-function precedent). The .ddd
machine-state binding (docs/DDD.md “Condition binding”) predates and
composes with this rule: bound state names are scope bindings, so they keep
winning over the namespace.
The same closed set backs two membership checks, both compile-time:
- A dotted access naming an undeclared member is a type error
(
`Member` is not a literal of enum `EnumName`, with a did-you-mean hint when exactly one declared member is within edit distance 2). - A string literal compared (
==/!=) against a plain enum-typed operand where the literal names no declared member is the same closed-set error — never the generic R2 mismatch. A declared member still fails R2’s same-type rule: the anti-magic-strings stance is deliberate, and the typed spelling is the dotted form, never a bare string (docs/DDD.md).Option-/List-wrapped enums and enums registered without a member set (the kind-onlyinsert_namedhost seam) defer to the ordinary R2 rule.
Vocabularies are deliberately outside the registry: their entries are lockfile-backed facets with different member semantics (entry keys, not nominal literals), and an entry set can be legitimately empty before a snapshot is vendored — a closed-set check over an absent snapshot would false-positive. Vocabulary members stay unchecked: no dotted namespace, no membership check.
C1 (.ddd service-guard condition scope). A .ddd service
operation’s when guard (issue pherolang#180) types under a scope the
driver assembles, not the checker alone; the rule is test-citable so
crates/phero-driver/tests/ddd_conditions.rs can cite it. The scope’s
bindings, in shadowing order (later wins):
- the implicit-
selffeatures — the module’s unique machine-bearing class, or the class a resolvedtransitionbinding pins (issue pherolang#176); absent in a multi-machine module without a pin; - in a multi-machine module, each machine’s bound field as an
enum-typed name — by its authored field name when that name is unique
across the module’s machines, and always by the enum-derived spelling
(lowerCamel of the machine’s enum type name:
machine status: PayRunStatusbindspayRunStatus); - the machine states as enum-typed names (the machine-bound rule; a state two machines declare never binds);
- the service’s injected repositories as class-typed roots (
inject TimecardRepository;bindsTimecardRepositoryto the repository design’s class), soTimecardRepository.statusnavigates as ordinary feature access.
An unresolvable reference is the driver’s E0353/E0354 (the
driver.ddd band), not a checker error; a resolved path that mistypes is
the ordinary L2/R2 rules with the condition span. The driver lowers the
resolution into the artifact as conditionPaths — see docs/DDD.md
“Condition scoping” for the row discipline.
Guard lint. A string literal compared (==/!=) against a
String-typed operand inside a guard expression type-checks (it is a
string equality), but records a lint:
TypeChecker::guard_string_lints returns it, and every guard-hosting
surface (.ddd conditions, .actor when, .evt filters, .cmmn
criteria, .uc step guards) emits it as a WARNING suggesting the field be
declared as an enum so the compared literals are checked against its
members — the use-based companion of the name-based bare-String
declaration rule. The empty string is excepted: field != "" is a
sentinel/emptiness check, and an empty string can never be a declared
member, so empty-literal comparisons stay silent. The lint is name-blind
and type-driven: it fires only where a guard actually compares, and defers
silently whenever a type cannot be resolved (no type context, unbound
names — the domains: None contract).
U1 (if unification). if requires a boolean condition. Both branches
must have the same type — compared exactly, with no implicit int → long
and no literal adaptation. The if expression’s type is that type.
Feature typing (mirrors the resolver’s IR):
attribute/derived: to-many (is_many) →List(T); lower bound 0 →Option(T); otherwiseT(the declared type).contains/refers: to-many →List(Named); single-valued →Named(neverOption— a single containment/reference is always present).container: alwaysOption(Named), whatever the declared multiplicity.opcall: the declared return type lifted by the operation’sreturn_multiplicity(0..*→List(T),0..1→Option(T), unset → the plain type — the same mapping as a bare function call), after argument checking (arity and per-parameter types; a class-typed parameter expects thatNamedtype). L1 applies to arguments. Domain operations are single-valued and never set the multiplicity; the.dddbody checker’s synthetic repository operations use it forfindById/findAll.
Function calls. The bare form name(args) (no receiver) resolves
against the sigil-lowered package functions of the domain union
(phero_ir::Package::functions), indexed by bare name across every package.
A name declared by several packages is a type error naming them all; an
unknown name is a type error at the name span (with a did-you-mean hint when
exactly one indexed function is within edit distance 2). Each parameter
expects its declared type mapped by multiplicity — is_many → List(T),
lower bound 0 → Option(T), else T — with L1 literal adaptation and the
R1–R4 argument rules unchanged; there is no R3 auto-propagation, so an
Option argument does not satisfy a plain parameter. The call’s type is
the declared return type lifted by the function’s return multiplicity,
the same mapping as the parameter rule above: 0..* → List(T), 0..1 →
Option(T), the single-valued convention (unset, or a required (1..1))
→ T (a sigil func output’s cardinality lowers into the multiplicity;
wire contract rule 15, and the Rust backend’s emitted signature mirrors
it). There is no ?. form (a function has no receiver to be optional).
Receiver calls (x.name(...)) never consult the function index — they
resolve class operations only.
Unknown features, unknown operations, and unknown names are type errors whose diagnostic span points at the offending name token.
Navigation. Accessing a feature or operation on a receiver whose type is
Option(T) without ?. is a type error (R3). ?. unwraps an optional
receiver for the access and makes the whole access yield Option of what
the access would have yielded, wrapping only if the result is not already
Option. Collection algebra requires a List receiver (List is never
optional in this typing: a to-many feature is List(T) even when its lower
bound is 0).
The semantic rules
Section titled “The semantic rules”These nine are where target languages disagree. They are normative:
every backend lowering a typed expression tree must preserve them, and the
type-checker enforces their compile-time halves. Each rule is cited by tests
as R1–R9. R1–R4 predate the date primitive; R5–R8 pin the calendar
semantics that came with it (issue #9); R9 adds string concatenation to
+.
R1 — INTEGER OVERFLOW
Section titled “R1 — INTEGER OVERFLOW”Arithmetic on int/long is checked: overflow is a runtime panic
contract. The lowered Rust backend uses plain +/-/* (Rust panics on
overflow in debug builds); every other target must trap equivalently —
never wrap, never saturate.
Compile-time half: constants that overflow are a type-check error. Two
integer constants combined by +/-/* are folded with checked arithmetic
and must fit the result type (int = 32-bit signed, long = 64-bit signed);
an int-typed literal whose value does not fit 32 bits is likewise rejected.
Unary minus folds too (-i64::MIN overflows). Non-constant overflow is a
runtime panic per the contract above; long constant pairs never fold in
practice because a literal only becomes long in a non-constant context (L1),
so long overflow remains purely a runtime contract.
Rust lowering. Plain +/-/* on the literal-suffixed operands
(2i32/2i64 per L1); Rust’s debug-build overflow panic is the contract:
(self.pages * 2i32) // `pages * 2`; panics on overflow in debug buildsR2 — STRING EQUALITY
Section titled “R2 — STRING EQUALITY”== (and its alias =) on string operands is value equality.
Equality is null-safe: null == x is false unless x is null (and
symmetrically); null compares with a value of any type.
== on Option<T> is element-wise: Some(a) == Some(b) iff a == b
(recursively, so nested options and strings compare by value);
None == None is true; None == Some(_) is false. ==/!= yield
boolean.
Typing: both operands must have the same type (L1 literal adaptation
applies), or one of them must be the null literal. Comparing values of two
different named/primitive types is a type error.
Rust lowering. Plain ==/!= (Rust String equality is value equality;
Option<T>’s PartialEq is exactly the element-wise rule; == borrows both
sides, so nothing moves). Against null: is_none() when the other operand
is optional, otherwise the constant answer (false/true — a required value
can never be absent). A string literal operand emits a bare &str, since
String == &str is value equality:
self.title.clone() == "Dune" // value equality, no allocationbook.library.is_none() // `book.library == null`R3 — OPTION/NULL PROPAGATION
Section titled “R3 — OPTION/NULL PROPAGATION”Navigating an optional feature (multiplicity 0..1, or a container)
yields Option<T>.
- Calling a feature or operation on an optional receiver without
?.is a type error. ?.propagatesNone:a?.bevaluates toNonewhenaisNone, otherwise to the access result, wrapped inOption(unless it already is one, e.g. another?.hop or acontainerfeature).?: defaultcoalesces:a ?: bevaluates toaunlessaisNone, in which case it evaluates tob. Its type is the inner typeTofOption<T>(or the right-hand type when the left is thenullliteral).- A to-many
contains/refersfeature yieldsList<T>— the empty list is a valid value; there is noOptionaround a collection.
Rust lowering. ?. over an optional receiver chains and_then (the
lookup) with map (or a second and_then when the feature itself is
optional-valued); ?. over a non-optional receiver yields Some(..) unless
the result already is an Option. ?: lowers to unwrap_or — the default
is evaluated eagerly in Rust (expressions are pure, so the only visible
difference would be a panicking default):
lib_id.and_then(|id| res.library(id)).map(|o| o.name.clone()) // `lib_id?.name`lib_id.and_then(|id| res.library(id)).map(|o| o.name.clone()) .unwrap_or("anon".to_string()) // `... ?: "anon"`R4 — DIVISION BY ZERO
Section titled “R4 — DIVISION BY ZERO”Integer division by zero is a runtime panic contract — the same behavior
as Rust: / on int/long with a zero divisor panics; other targets must
trap equivalently. (Floating-point / follows IEEE semantics and never
panics.)
/ on integers is truncating (rounds toward zero, the Rust/C family
behavior), for both int and long.
Compile-time half: a constant zero divisor (any expression that folds to
the integer constant 0, including -0) is a type-check error. The
constant-folding half of R1/R4 is integer-only: const_of never folds float
literals, so float division is never compile-checked (IEEE semantics, per
the parenthetical above).
Float literal lowering. A float literal lowers with its L1-adapted type
as the suffix — 1.5f64 by default, 1.5f32 in a float context (mirroring
2i32/2i64).
Rust lowering. Plain / — Rust integer division is already truncating
toward zero and panics on a zero divisor, which is exactly the contract:
(self.pages / 2i32) // truncating; panics when the divisor is zeroR5 — DATE ARITHMETIC OVERFLOW
Section titled “R5 — DATE ARITHMETIC OVERFLOW”A date is an i32 count of days since the epoch 1970-01-01 (negative
before it) in the proleptic Gregorian civil calendar. plus_days(n) and
diff_days(d) are checked i32 arithmetic on that day number: overflow is a
runtime panic contract — never wrap, never saturate. plus_months
obeys the same contract at its bounds (R8).
Compile-time half: a date("…") literal whose day number does not fit the
i32 scale is rejected at compile time under R6, so a checked literal can
never fail to construct downstream.
Rust lowering. The runtime type phero_runtime::Date implements the
arithmetic with checked_add/checked_sub and panics with an R5-citing
message on overflow:
(self.effective_date.plus_days(30i32)) // checked; panics past the i32 scaleR6 — INVALID DATE LITERAL
Section titled “R6 — INVALID DATE LITERAL”date("YYYY-MM-DD") is a constant, not a constructor call: there is no
runtime constructor in the language (generated code may build its target
date type directly). Two halves, both compile-time:
- Parse-time (syntactic). The argument must be a string literal; any other expression in the parentheses is a parse error.
- Check-time (semantic). The text must be a strict ISO-8601 calendar
date: a year of at least four digits (a leading
-admits extended negative years), two-digit month01–12, two-digit day, and a day that exists in the proleptic Gregorian calendar (leap years included). Anything else —2026-9-17,2026-13-01,2026-02-30,2023-02-29— is a type-check error whose span is the string literal, plus the R5 day-number bound. A valid literal types asdate.
Rust lowering. The literal lowers to the runtime’s strict parser with the
validity already proven, so the expect documents an invariant:
phero_runtime::Date::from_str("2026-09-17").expect("R6-checked date literal")R7 — DATE ORDERING AND EQUALITY
Section titled “R7 — DATE ORDERING AND EQUALITY”The six comparison operators are total on date:
< <= > >=order two dates by the total civil-calendar order — exactly the day-number order of R5 (there is no separate “granularity”: dates are days).==/!=are value equality (an R2 extension), null-safe like every equality (an optional date compares againstnullper R2/R3).
A date and a non-date are a type error for all six, per L2/L1 (no implicit
conversions, no literal adaptation — the date("…") form is its own type).
Rust lowering. Plain operators: phero_runtime::Date derives PartialEq
and Ord over the day number, so ==/</… are integer comparisons:
(self.effective_date == self.due_date)R8 — MONTH-ADD CLAMPING
Section titled “R8 — MONTH-ADD CLAMPING”plus_months(n) shifts the calendar month and clamps the day to the
target month’s length: Jan 31 + 1 month = Feb 28 (Feb 29 in a leap year),
Aug 31 + 1 month = Sep 30, Dec 31 + 2 months = Feb 28/29. The clamp never
carries into the next month, and it is not sticky: each step clamps
against its own target month, so (Jan 31 + 1 month) + 1 month = Feb 28 + 1
month = Mar 28. plus_days and diff_days are plain day arithmetic and
never clamp. R5’s panic contract applies when the result leaves the i32
scale.
Rust lowering. Implemented once, in phero_runtime::Date::plus_months
(month arithmetic in i64, day clamped with min(days_in_month), R5 panic
on overflow); lowered calls are plain method calls:
(self.effective_date.plus_months(6i32)) // 2026-01-31 + 6 months → 2026-07-31R9 — STRING CONCATENATION
Section titled “R9 — STRING CONCATENATION”+ admits two string operands, in which case it concatenates them:
a + b is the string whose characters are a’s followed by b’s. Chains
are left-associative (the additive level), so title + " - " + synopsis
is (title + " - ") + synopsis.
Optionality follows R3: an absent operand yields an absent result —
Option<string> + string, string + Option<string>, and
Option<string> + Option<string> all type as Option<string>, and the
concatenation happens only when every operand is present. The null literal
is not a string operand: it behaves exactly as on the numeric path,
which rejects it, so null + s and s + null are type errors (null
exists only for equality, R2, and ?:, R3).
There are no implicit conversions between string and any numeric,
boolean, or date type (L2): when either operand of + is a string (or
Option<string>), the other must be too, and anything else — an integer,
boolean, date, enum, class value, list — is a type error naming the
mismatch. When neither operand is a string, + is entirely the numeric
operator: L1 literal adaptation, the L2 same-type rule, and the R1/R4
compile-time halves apply unchanged.
Rust lowering. format!("{}{}", lhs, rhs) on the owned String
operands — Rust’s consuming Add is not implemented for
String + String, so plain + cannot carry the rule. Every binary node
lowers to one format!, so left-associative chains nest safely:
format!("{}{}", format!("{}{}", self.title.clone(), " - ".to_string()), self.synopsis.clone())An operand that may be absent propagates through Option::zip (the present
side rides in Some), per R3:
self.citation(res).zip(Some(self.title.clone())) .map(|(concat_lhs, concat_rhs)| format!("{concat_lhs}{concat_rhs}"))Rune statement bodies
Section titled “Rune statement bodies”A sigil-lowered Rune function body (the "rune" target, wire contract
rule 15) is a statement layer above this expression language — not a second
semantics: every expression slot in it (guards, alias bindings,
set/add steps) is a phero-expr program, checked and lowered by the same
checker and lowerings as any expr body, so the rules above apply inside
those slots unchanged. Nothing Rune-specific re-derives overflow, equality,
Option, or division semantics — the carry-over is a pinned contract, not
a lowering accident. The statement layer itself (guard phases, binding
scoping, accumulation order, the driver’s typechecking and deferral
contract) is specified in
docs/RUNE-BODIES.md; the Rust lowering of a checked
body is pinned in docs/BACKENDS.md.
Collection algebra
Section titled “Collection algebra”The algebra is fixed — these six, and nothing else. Lowerings must emit the target language’s idiomatic equivalent; no other collection operations exist in the language (no host-library calls, per the mandate).
| # | Form | Receiver | Lambda | Result | Notes |
|---|---|---|---|---|---|
| A1 | x.first() / x.first(p => e) |
List(T) |
optional; T → boolean |
Option(T) |
first matching / first element |
| A2 | x.filter(p => e) |
List(T) |
required; T → boolean |
List(T) |
order preserved |
| A3 | x.map(p => e) |
List(T) |
required; T → U |
List(U) |
any U, including boolean |
| A4 | x.any(p => e) |
List(T) |
required; T → boolean |
boolean |
false when empty |
| A5 | x.size() |
List(T) |
none | int |
element count |
| A6 | x.sum() |
List(T), T numeric |
none | T |
0 when empty |
Lambda parameters are single, untyped, and inferred: the parameter of a
lambda passed to an algebra call has the receiver’s element type T. A lambda
outside an algebra call (where its parameter type cannot be inferred) is a
type error. Inside the lambda body the parameter shadows outer bindings of the
same name (shadowing is always allowed).
The algebra is generic over the element type wherever the table says so, which
includes date elements: first/filter/map/any/size work on
List<date> unchanged (and the calendar-algebra methods may appear inside
their lambdas), while sum remains numeric-only — summing dates is a type
error.
Rust lowering
Section titled “Rust lowering”The Rust backend (phero-backend-rust) lowers the typed tree with
expr_lower::lower_expr. The shapes below are normative for the Rust target
and pinned by crates/phero-backend-rust/tests/expr_lower.rs.
Values, names, and self. Class-typed values are their typed ids
(BookId, always Copy); every other type is its Rust value. The implicit
self (the class owning the body) puts each of its features in scope as a
bare name, read directly off the struct (books → self.books.clone();
derived features call the generated accessor, citation →
self.citation(res)). Operation parameters and let bindings are locals:
Copy values are used plainly, everything else clones on use — generated
code is correct in every context and never moves out of self. Inside an
emitted closure the same rule holds (.clone() compiles whether the capture
was by reference or by move). if lowers to a Rust if expression, let to
a scoped block { let x = ..; .. }, list literals to vec![..], and every
binary node is parenthesized, so precedence is safe by construction.
Feature access through res. Reading a feature of a class-typed receiver
goes through the arena: res.book(RECV).map(|o| FIELD).expect("dangling Book id"). The expect asserts the arena invariant that stored ids resolve
(generated mutators never delete objects); it is the one failure mode beyond
R1/R4/Option. Field shapes on the resolved object o: &Book: value
features clone when non-Copy (o.title.clone()) and read plainly when
Copy (o.pages); reference/container features are the id or Option<id>
field (o.library); to-many features clone the vector (o.authors.clone()).
Safe navigation (a?.b) propagates None with and_then/map as described
under R3. Operation calls become method calls on the resolved object:
res.book(b).expect("dangling Book id").find_book(title.clone()).
A1–A6 in Rust. The receiver expression X lowers independently and the
closure parameter is the element reference (one reference layer for
map/any items, two for the find/filter predicates’ &Self::Item):
| # | Form | Rust shape (result) |
|---|---|---|
| A1 | X.first(p => PRED) |
X.iter().find(|b| PRED).copied() / .cloned() → Option<T> |
| A1 | X.first() |
X.first().copied() / .cloned() → Option<T> |
| A2 | X.filter(p => PRED) |
X.iter().filter(|b| PRED).copied()/.cloned().collect::<Vec<_>>() |
| A3 | X.map(p => BODY) |
X.iter().map(|b| BODY).collect::<Vec<_>>() |
| A4 | X.any(p => PRED) |
X.iter().any(|b| PRED) → bool |
| A5 | X.size() |
X.len() as i32 → int |
| A6 | X.sum() |
X.iter().sum::<i32/i64/…>() (annotated element width; 0 when empty) |
.copied() adapts Copy element types; .cloned() clones only the elements
actually selected (for A2, only the matches). A6 sums by shared borrow (iter
Sum<&T>), so it works on stored fields and temporaries alike; the sum is plain+, keeping the R1 contract. An A1 result is naturallyOption; the remaining forms yield plain values, so their?.forms wrap inSome(..).
Dates in Rust. The date primitive maps to phero_runtime::Date, the
runtime support crate’s Copy newtype over the R5 day number. A literal
lowers to the strict-parse-plus-expect shape of R6; the calendar algebra
lowers to plain method calls (plus_days/plus_months/diff_days), and ?.
over an optional date propagates with map:
(self.effective_date.plus_months(6i32))(self.maturity_date.map(|__phero_date| __phero_date.plus_days(1i32)))Scoping and evaluation shape
Section titled “Scoping and evaluation shape”let x = e1; e2:xis bound to the value ofe1while checking/evaluatinge2. Shadowing an existing binding (including lambda parameters) is allowed, including shadowing with a different type.- Expressions are total in the checker’s model: every well-typed expression
yields a value of its type or panics per R1/R4/R5 — there is no third
failure mode other than
Option(R3).
Errors
Section titled “Errors”phero_expr::parse never panics and performs best-effort recovery like
phero-syntax: it returns the recovered expression (if any) plus every error
with byte spans. TypeChecker::type_of collects all type errors in one
pass (subexpression errors do not cascade into spurious operator errors).
The guard lint of rule L3 is a separate opt-in pass
(TypeChecker::guard_string_lints), not part of type_of: it changes no
typing and only guard-hosting drivers run it.