Skip to main content

Control Flow

Functions

A function can be defined in two forms, both lowering to the same shape: ["Assign", name, ["Function", body, …params]].

The math style is a single expression:

f(x) = x + 1
["Assign", "f", ["Function", ["Add", "x", 1], "x"]]
f(x, y) = x + y
["Assign", "f", ["Function", ["Add", "x", "y"], "x", "y"]]

The block style wraps the body in a statement block, whose value is its last expression:

function f(x) { x + 1 }
["Assign", "f", ["Function", ["Block", ["Add", "x", 1]], "x"]]

Parameters can carry a type annotation (f(x: real) = …), and the block form accepts a return-type annotation in the unambiguous post-parameter-list position (function f(x) -> real { … }). Parameter types are enforced when the function is called. Return types are retained in the function signature; the current runtime does not validate the inferred type of every returned value against that annotation.

f(x: real) = x + 1
["Assign", "f",
["Function", ["Add", "x", 1], ["Typed", "x", {"str": "real"}]]]

Anonymous functions

An anonymous function uses the ASCII mapsto arrow |-> (the engine's ); -> itself is taken by KeyValuePair, so this is a collision-free choice:

x |-> x + 1
["Function", ["Add", "x", 1], "x"]
(x, y) |-> x + y
["Function", ["Add", "x", "y"], "x", "y"]

A mapsto binds loosely enough to sit on the right-hand side of an assignment:

f = x |-> x + 1
["Assign", "f", ["Function", ["Add", "x", 1], "x"]]

A lambda can take no parameters — an empty parameter list () before the arrow:

() |-> 42
["Function", 42]

if / else

if/else is an expression, not a statement — it evaluates to a value:

if x > 0 { 1 } else { 2 }
["If", ["Greater", "x", 0], ["Block", 1], ["Block", 2]]

The else branch is optional:

if x > 0 { 1 }
["If", ["Greater", "x", 0], ["Block", 1]]

else if chains nest into an If in else position:

if x > 0 { 1 } else if x < 0 { 2 } else { 3 }
[
"If",
["Greater", "x", 0],
["Block", 1],
["If", ["Less", "x", 0], ["Block", 2], ["Block", 3]]
]

A { } block's value is its last expression — the same Block semantics as a multi-statement program (see Blocks below).

match

match is an expression that inspects the structure of a subject against a sequence of pattern => body cases and evaluates to the body of the first matching case:

match x {
0 => "zero"
_ => "other"
}
[
"Match",
"x",
["MatchCase", 0, {"str": "zero"}],
["MatchCase", "_", {"str": "other"}]
]

Unlike if/Which, match is structural and total: it always selects a case, it never stays inert. A literal pattern (0) matches structurally, and _ is the anonymous wildcard, matching anything — with a symbolic (unbound) x as the subject above, match selects the _ case: x is structurally not 0, even though it could be zero semantically. Use if/Which when you want that kind of semantic case-split instead.

Bindings

A bare identifier in pattern position binds a new variable to the value at that position — for any name, including ones that happen to name an engine constant (e, i, Pi). A pattern is parsed as an ordinary expression first, so this applies inside nested patterns too:

match p {
(x, e) => x + e
}
["Match", "p", ["MatchCase", ["Tuple", "_x", "_e"], ["Add", "x", "e"]]]

Matching (2, 7) against this case binds x to 2 and e to 7 — the body's e is the captured value, not ExponentialE. Because a bare binding matches unconditionally, a non-final case consisting of just a binding (or _) makes every case after it unreachable; this is flagged as a match-irrefutable-case diagnostic (a final catch-all is expected and not flagged):

match x {
Pi => 1
0 => 2
}

This does not match the constant π — Pi in pattern position binds a new variable named Pi, shadowing the constant, and the diagnostic is the safety net for that: it fires because the Pi => 1 case is non-final and matches anything, not because Pi is a reserved name. To test against the value of the constant, use a pin.

Pins

== expr matches the subject against the value of expr, evaluated in the enclosing scope — this is how to test a symbolic constant or a runtime variable, since a bare identifier always binds instead:

match x {
== Pi => "is-pi"
_ => "no"
}
[
"Match",
"x",
["MatchCase", ["Pin", "Pi"], {"str": "is-pi"}],
["MatchCase", "_", {"str": "no"}]
]
match x {
== limit => 1
_ => 0
}
["Match", "x", ["MatchCase", ["Pin", "limit"], 1], ["MatchCase", "_", 0]]

The Cortex parser lowers every non-literal pinned expression to Pin, whether it names a constant or a runtime variable — it cannot tell the two apart lexically, and only Pin resolution looks up the value at match time. A pin of a literal (== 5) drops the Pin head and matches structurally, same as writing the literal directly; Infinity/NaN are numeric literals in Cortex, so == Infinity is a literal pin too, with no binding trap to avoid.

Or-alternatives

p₁ | p₂ | … at the top level of a case pattern matches if any alternative matches; a guard, if present, applies after whichever alternative matched:

match x {
1 | 2 | == Pi => "small"
_ => "big"
}
[
"Match",
"x",
["MatchCase", ["Alternatives", 1, 2, ["Pin", "Pi"]], {"str": "small"}],
["MatchCase", "_", {"str": "big"}]
]

Alternatives must be binding-free_ is fine ([0, _] | [_, 0]), but a named binding inside an alternative (a | 2 => …) is a match-alternative-binding diagnostic, since there is no single value for the body to bind a to when the alternatives disagree on shape.

Guards

pattern if guard => body adds a boolean condition, checked after the pattern matches and after its bindings are in scope:

match n {
n if n > 3 => "big"
_ => "small"
}
[
"Match",
"n",
["MatchCase", "_n", ["Greater", "n", 3], {"str": "big"}],
["MatchCase", "_", {"str": "small"}]
]

If the guard is undecidable for a symbolic subject, the case falls through to the next one — consistent with match's totality, a guard never leaves the whole expression inert.

Destructuring

List, tuple, and dictionary patterns decompose the subject and bind their elements:

match xs {
[first, ...rest] => first
}
["Match", "xs", ["MatchCase", ["List", "_first", "___rest"], "first"]]
match p {
(x, y) => x
}
["Match", "p", ["MatchCase", ["Tuple", "_x", "_y"], "x"]]
match p {
{x -> px, y -> py} => px + py
}
[
"Match",
"p",
[
"MatchCase",
[
"Dictionary",
["KeyValuePair", {"str": "x"}, "_px"],
["KeyValuePair", {"str": "y"}, "_py"]
],
["Add", "px", "py"]
]
]

...rest (or bare ...) captures the remaining elements of a list pattern; at most one rest is allowed per pattern — a second one is a match-multiple-rest diagnostic.

Dictionary pattern keys are literal (not patternized); the values are full patterns — bindings, literals, pins, or nested shapes. Dictionary matching is open: a case matches when the subject is a dictionary that has at least the named keys, each with a matching value; extra subject keys are ignored. A subject missing any named key falls through to the next case. So

match {x -> 3, y -> 4, z -> 5} {
{x -> px, y -> py} => px + py
_ => 0
}

binds px = 3 and py = 4 (the extra z key is ignored) and evaluates to 7.

Typed bindings

name: type binds like a bare identifier, plus an implicit type guard, conjoined with any explicit guard:

match n {
n: integer if n > 0 => "positive integer"
_ => "other"
}
[
"Match",
"n",
[
"MatchCase",
"_n",
["And", ["Element", "n", "integer"], ["Greater", "n", 0]],
{"str": "positive integer"}
],
["MatchCase", "_", {"str": "other"}]
]

Algebraic patterns

Because a pattern is parsed as an ordinary expression, matching on operator structure comes for free — a pattern like a + b dispatches on the Add operator and captures its operands, with the same commutative matching the rule system already uses for Add/Multiply:

match z {
a + b if a > 0 => a
_ => 0
}
[
"Match",
"z",
["MatchCase", ["Add", "_a", "_b"], ["Greater", "a", 0], "a"],
["MatchCase", "_", 0]
]

This is symbolic destructuring, evaluated by the engine's general pattern matcher — it works when evaluating a match expression, but such patterns are not supported by compile(); compiling a match with an operator pattern fails closed, naming the offending pattern in the error.

No match

If no case matches, match evaluates to an Error value tagged 'match-no-case' carrying the subject, rather than throwing or silently producing Nothing — errors are ordinary values in Cortex (see Evaluation):

match 3 {
0 => "zero"
}
["Match", 3, ["MatchCase", 0, {"str": "zero"}]]

Evaluating this expression yields Error("match-no-case", 3).

Loops

There is one loop keyword form for each of the two common shapes, and both lower to the engine's imperative Loop — evaluated for effect, not for its value (a Loop's value is Nothing). Value-producing iteration over a collection belongs to the library functions Map/Filter/Reduce, not to a loop statement.

while cond { … } lowers to a Loop over a Block whose first statement breaks out when the condition becomes false:

while x > 0 { x }
[
"Loop",
["Block", ["If", ["Not", ["Greater", "x", 0]], ["Break"]], ["Block", "x"]]
]

for x in xs { … } lowers to ["Loop", body, ["Element", "x", "xs"]] — the loop variable's in is the engine's Element operator, doubling as the iterator clause:

for x in xs { x }
["Loop", ["Block", "x"], ["Element", "x", "xs"]]

in is contextual: only the loop-variable in introduces the iterator clause. A second, later in in the collection expression is still the ordinary Element infix operator:

for x in a in b { x }
["Loop", ["Block", "x"], ["Element", "x", ["Element", "a", "b"]]]

Blocks

A { … } that immediately follows a keyword (function/if/else/ while/for) is a statement block — the engine's Block — and is distinct from the { … } collection grammar (set/dictionary literals). A bare { … } with no introducing keyword is always the collection grammar:

{ 1, 2 }
["Set", 1, 2]

Each block pushes its own lexical scope. A block's value is its last expression; an empty block's value is Nothing:

if a { }
["If", "a", ["Block"]]

Statements inside a block are separated the same way as top-level statements — a linebreak or a ;:

if a { 1; 2; 3 }
["If", "a", ["Block", 1, 2, 3]]

Blocks nest freely:

if a { if b { 1 } }
["If", "a", ["Block", ["If", "b", ["Block", 1]]]]

do { … } block expressions

To use a statement block in expression position — where a bare { … } would be the collection grammar — prefix it with do. do { … } opens a statement block usable anywhere an expression can appear: a lambda body, an assignment right-hand side, a function argument. Its value is its last statement, and it pushes its own lexical scope, exactly like a keyword-led block:

let y = do { let t = 3; t + 1 }
["Declare", "y", ["Dictionary", ["KeyValuePair", "value",
["Block", ["Declare", "t", ["Dictionary", ["KeyValuePair", "value", 3]]],
["Add", "t", 1]]]]]

Because a lambda body is an ordinary expression, x |-> do { … } produces the same Function(Block(…), x) shape a named function body does — so a closure whose body runs several statements is written with do:

counter |-> do { counter = counter + 1; counter }

A do not followed by { is an opening-bracket-expected diagnostic.

return / break / continue

These three words are reserved but not implemented: Cortex's expression-oriented style (an if is a value, a block's value is its last expression) doesn't need an explicit return yet, and loops are for-effect only. Using them produces a reserved-word diagnostic rather than the control-transfer behavior their names suggest.