Operators reference
coil expressions use a Pratt parser with prefix, infix, and postfix operators. Higher rows in the precedence table bind tighter.
Associativity:
| Class | Operators | Associativity |
|---|---|---|
| Binary arithmetic, shift, bitwise, comparison, equality | * / % + - << >> & ^ | < <= > >= == != | Left (a - b - c is (a - b) - c, 1 << 2 << 3 is 32) |
Exponentiation ** | Right (2 ** 3 ** 2 is 512) | |
Logical && || | Right (same result either way) | |
Range .. ..= | Non-associative | |
Coalesce ?? | Right | |
Assignment = += … | Right | |
Postfix ++ -- . ?. [] ? | N/A (postfix) | |
Prefix - + ~ ! | N/A (prefix) |
Shift and bitwise operators follow Rust: 1 << 2 | 1 is (1 << 2) | 1,
x & 1 << n is x & (1 << n), and a ^ b == c is (a ^ b) == c.
Precedence table (high to low)#
| Precedence | Operators / forms | Notes |
|---|---|---|
| Primary (postfix) | expr++, expr--, expr.field, expr?.field, expr[index], expr? | Tightest — postfix on atoms |
| Call (postfix) | f(args) | Function / method call |
| Prefix unary | -expr, +expr, ~expr, !expr, readonly expr, typeof expr | Numeric negation, no-op plus, bitwise NOT, logical NOT, read-only view, compile-time type name |
| Cast (postfix) | expr as T | Below unary (-1 as byte is (-1) as byte), above ** / * / + and assignment (c = m as byte → RHS cast) |
| Exponentiation | ** | Right-associative |
| Multiplicative | *, /, % | Left-associative |
| Additive | +, - | Left-associative; operands must unify to same type |
| Bit shift | <<, >> | Left-associative |
| Bitwise AND | & | Left-associative |
| Bitwise XOR | ^ | Left-associative; bitwise, not logical |
| Bitwise OR | | | Left-associative |
| Comparison | <, <=, >, >= | Left-associative; operands same type → bool |
| Range | .., ..= | Non-associative; bounds unify to T: Ord → lazy Range<T> / RangeInclusive<T>; numeric .to_vec() |
| Equality | ==, != | Left-associative; operands same type → bool |
| Logical AND | && | Both operands bool → bool |
| Logical OR | || | Both operands bool → bool |
| Coalesce | ?? | Right-associative; Option / Result only (see below) |
| Assignment | =, +=, -=, … | Lowest — right-associative |
Forms not in the Pratt table but still tight-binding:
| Form | Binding |
|---|---|
Qualified construct E::V(...) | Atom |
Grouping (expr) | Atom |
match, new, literals | Atoms |
Arithmetic#
| Operator | Types | Result | VM op (int / float) |
|---|---|---|---|
+ | int / float / byte (both same), string + string, or a type with an Add instance | same | ADD / ADDF; strings lower through FORMAT "%s%s"; an instance is a CALL |
- | int / float / byte, or a type with a Sub instance | same | SUB / SUBF |
* | int / float / byte, or a type with a Mul instance | same | MUL / MULF |
/ | int / float / byte, or a type with a Div instance | same | DIV / DIVF |
% | int / float / byte, or a type with a Rem instance | same | MOD / MODF |
** | int / float / byte, or a type with a Pow instance | same | Pow / PowF |
Mixed int and float operands → type error at compile time. Any other
operand type is a type error too ("a" - "b", true * false, a class with no
Sub instance): the VM never operates on a non-numeric word.
int arithmetic is exact or it panics, in every build (debug and release):
+, -, *, unary -, ++ / -- and compound forms panic with
integer overflow when the result does not fit; / and % by zero panic with
division by zero (MIN / -1 is an overflow); ** panics on overflow or a
negative exponent. A shift count must be in 0..=63: x << n and x >> n
panic with shift count out of range otherwise, and a constant count outside
that range is a compile error (E0131). Within range a shift does not trap
(bits shifted out are dropped, and >> is arithmetic), bitwise operators never
trap, and floats keep IEEE behaviour (inf, NaN). Checked, wrapping, saturating and overflowing
arithmetic are userland traits in coil-stdlib’s arith module (Checked,
Wrapping, Saturating, Overflowing).
For a shift count that may be out of range, those traits have shift methods
that take any int count:
| Method | Count outside 0..=63 |
|---|---|
x.checked_shl(n) / x.checked_shr(n) | None (otherwise Some(x << n) / Some(x >> n)) |
x.wrapping_shl(n) / x.wrapping_shr(n) | Shifts by n & 63, as Rust does (1.wrapping_shl(65) is 2) |
x.overflowing_shl(n) / x.overflowing_shr(n) | (x << (n & 63), true); an in-range count gives (x << n, false) |
byte arithmetic wraps modulo 256 instead: +, -, *, **, <<, unary
- and their compound forms keep the low 8 bits of the result, so a byte
is always in 0..=255 (200 + 100 is 44, 0 - 1 is 255). Each of these
operators on a byte warns E0130; convert to int first (x as int) when
you want the exact result. / and % by zero still panic.
When the factor is a compile-time power of two (2, 4, 8, …), byte
multiplication lowers to a left shift (SHL / <<) instead of MUL. int
* stays a MUL (a shift would not trap on overflow); trait/Mul dictionary
dispatch and float * are never rewritten either.
Aggregate (vector) arithmetic#
Homogeneous numeric tuples and arrays support the same operators element-wise, plus scalar broadcast:
| Left | Right | Result |
|---|---|---|
(T,…,T) | (T,…,T) (same arity) | zip |
[T; N] | [T; N] | zip |
| aggregate | scalar T (or reverse) | broadcast |
T must be numeric (int / float / byte, or a Num-bounded type
parameter). A Vec<T> supports unary - and scalar broadcast (k * v),
returning a new Vec<T>; zipping two Vecs is a type error (lengths are not
known), so use fixed [T; N] for that.
* and ** are element-wise (not dot product / matrix power). Unary -
negates each element. Compound assign (+=, **=, …) follows the same
rules with the LHS shape fixed.
Static-length zip/broadcast uses a packed HostInvoke SIMD kernel when the
fixed length is ≥ 8 (packed_vec_arith); smaller shapes still unroll to
scalar opcodes (bytecode size scales with N in that case).
For dot product, cross product, and bare-array matrix multiply,
use the named helpers dot, cross, and matmul. For matmul via *
(Mul), wrap rows with matrix(...) to get a nominal Matrix — see
Built-ins.
(1, 1) + (1, 1); // (2, 2)
[1, 2] + 3; // [4, 5]
-(1, 2); // (-1, -2)
dot((1, 2), (3, 4)); // 11
let a = matrix([[1, 2], [3, 4]]);
let b = matrix([[5, 6], [7, 8]]);
a * b; // matmul (Matrix)
a + a; // element-wise
a == b; // byte mask, 1 where cells match
a & b; // bitwise and (int or byte cells)
intersect(a, b); // byte mask, 1 where both cells are non-zero
See examples/vec_tuple.hy, examples/vec_array.hy,
examples/vec_generic.hy, examples/vec_packed_mul.hy, examples/vec_dot.hy,
examples/vec_matmul.hy, and examples/matrix_mul.hy.
String concatenation uses +:
let s = "hello" + " " + "world";
s += "!";
Mixing string with a non-string operand is a compile-time type error.
Bitwise#
The operands of &, | and ^ are inferred together and must be int or
byte, or a type with the operator’s trait instance (float and bool are
type errors; && / || are the boolean connectives). In a shift, x << n /
x >> n, the count n is always an int, whatever the type of x, and the
result has the type of x ((7 as byte) << 6 is a byte):
| Operator | Meaning | Trait |
|---|---|---|
& | Bitwise AND | BitAnd |
| | Bitwise OR | BitOr |
^ | Bitwise XOR | BitXor |
<< | Shift left | Shl |
>> | Shift right | Shr |
~ | Bitwise NOT (prefix) | BitNot (int only) |
On a type parameter these need the trait bound, or Integral, which implies
Num and all six:
fn low_bits<T: Integral>(T x, T mask) -> T {
return x & mask;
}
fn rotate_in<T: Shl>(T x, int n) -> T {
return x << n;
}
A user type’s Shl / Shr instance takes the count as an int too:
pub fn shl(Flags a, int n) -> Flags.
Logical#
| Operator | Operands | Result |
|---|---|---|
&& | bool, bool | bool |
|| | bool, bool | bool |
&& and || short-circuit: the right operand runs only when the left does not decide the result. The compiler evaluates the right operand eagerly (one AND / OR opcode) only when it is a side-effect-free, non-trapping expression, where the difference cannot be observed.
| Operator | VM opcode |
|---|---|
&& | AND |
|| | OR |
& | BITAND |
| | BITOR |
^ | XOR |
<<, >> | SHL, SHR |
Comparison#
| Operator | Operands | Result |
|---|---|---|
==, != | Same type | bool |
<, <=, >, >= | Same type: int / float / byte / string, a tuple of Ord elements (lexicographic, see Types — Tuples), or a type with an Lt / Le / Gt / Ge instance (#[derive(Eq, Ord)]) | bool |
Strings order by their UTF-8 bytes, compared left to right, and a prefix sorts
first: "B" < "a", "10" < "9", "ab" < "abc", "z" < "é". There is no
locale or case folding. string is Ord, so generic code bounded by Ord
(collections::sort, num::min, a #[derive(Ord)] field) takes strings. A
string is not a range bound ("a".."c" is E0119). A #[repr(string)]
enum orders by its backing string too: #[derive(Ord)] compares its cases’
strings, and a case compares with a plain string without one.
Ordering any other type (bool, a class without an Ord instance) is a type
error.
Float and int comparisons use separate opcode families (LE vs LEF, etc.) selected at codegen from inferred types.
== / != on arrays, tuples, records and enums are structural, recursively: arrays and tuples by length and element-wise, records field by field ({ a: 1 } == { a: 1 }, whatever order the literals name the fields in), enums by variant and payload (Some("x") == Some("x")). Strings compare by UTF-8 content. A float inside an array, tuple or record compares by IEEE 754, as scalar float == does (NaN != NaN, 0.0 == -0.0).
Classes compare by identity: two new P(1) are not equal, though a value and its alias are. A class or enum with a user or derived Eq instance (#[derive(Eq)]) dispatches == to it. The same holds inside an array, Vec, tuple, record, Option or enum payload, at any depth: an element with an Eq instance compares with it ([new Q(1)] == [new Q(1)] when Q derives Eq), so a derived Eq over a Vec<Q> field compares the elements too, and a class element without one compares by identity. A ground Vec, Option, Result, tuple, array or record whose elements have Eq meets a T: Eq bound.
Assignment#
identifier = expr
field_access = expr
array[index] = expr
identifier += expr // and other compound forms
| Rule | Detail |
|---|---|
| LHS | Identifier, record field (d.x), or array index (arr[i]) |
| Compound | +=, -=, *=, /=, %=, **=, <<=, >>=, &=, |=, ^= |
| Type | x op= y checks like x = x op y: the RHS must unify with the assigned slot, except the int count of <<= / >>=; bitwise compound ops require int or byte, or a type with the operator’s trait |
| Undeclared | Error — use let first |
| Value | Assignment and compound-assignment expressions evaluate to the assigned value |
let bindings use STORE (pop TOS into the slot); match arms need no store opcode for pattern slots (value already placed by unpack/match).
Increment / decrement#
| Form | Syntax | Result value |
|---|---|---|
| Postfix increment | expr++ | Old value |
| Postfix decrement | expr-- | Old value |
| Prefix increment | ++expr | New value |
| Prefix decrement | --expr | New value |
Works on variables, mutable record fields, and array elements. Enum record fields and tuples are immutable.
Field access (.field)#
| Form | Example | Precedence |
|---|---|---|
| Postfix dot | p.x, p.x.y | Primary — left-to-right |
Binds like Rust/C:
a.b.c → Access(Access(a, "b"), "c")
t[i].x → Access(Index(t, i), "x")
Float literals remain atoms: 1.0 is not 1.x.
Field resolution:
| Receiver type | Mechanism |
|---|---|
| Enum record variant | LoadField (index by declaration order) |
{ } record | LoadField (slot of the field, fields sorted by name) |
Error-handling operators (?, ?., ??)#
Desugared to match / return / MakeEnum — no new opcodes. See Tutorial: Error handling.
| Operator | Form | Operand | Result type | Notes |
|---|---|---|---|---|
| Try | x? | Result<T,E> or Option<T> | T | Propagates Err / None via early return; hard error otherwise (E0114) |
| Optional access | a?.field | Option<R> with field U | Option<U> | Option-only; Result → E0116 |
| Coalesce | a ?? b | Option<T> or Result<T,E> | T | RHS must unify with T; ?? on Result swallows Err (document / prefer ? when failure matters) |
Precedence sketch:
a?.x ?? b? ?? c // (a?.x) ?? ((b?) ?? c) — ?? is right-associative
a || b ?? c // (a || b) ?? c
a = b ?? c // a = (b ?? c)
raise expr is a keyword expression (not a Pratt operator); it produces Err(expr) and requires result mode. Do not write raise err? — postfix ? binds to the operand (raise (err?)), not to raise. Use bare raise err; (or put ? on a Result-producing call: parse()?).
Indexing ([])#
| Form | Example | Precedence |
|---|---|---|
| Postfix index | arr[i], v[i], t[0] | Primary |
Empty index arr[] (append assignment) is removed — use vec.push(v) on a
Vec<T> (E0107). See Arrays and Vec.
Runtime out-of-range arr[i] and arr[i] = x panic (index out of bounds). Literal OOB on [T; N] and tuples is a compile error. See
Out-of-range index.
Unary operators#
| Operator | Name | Operand | Result |
|---|---|---|---|
- | Negate | numeric, a type parameter bounded by Neg (Num implies it), or a type with a Neg instance | same |
+ | Positive | numeric | numeric (no-op) |
~ | Bitwise NOT | int, a type parameter bounded by BitNot (Integral implies it), or a type with a BitNot instance | same (flip bits) |
! | Logical NOT | bool or int | bool |
typeof | Type name | any expression with a fully known type (not evaluated) | string |
For ! on integers, zero is false and any non-zero value is true (!0 → true, !42 → false).
Operator parsing notes#
| Input | Parses as | Not as |
|---|---|---|
(1 + 2) * 3 | Group then multiply | |
(1, 2) | Tuple | Two groups |
(1) | Group | 1-tuple |
(1,) | 1-tuple | |
1.0 | Float literal | 1 . 0 |
typeof(x) == "int" | (typeof x) == "int" | typeof ((x) == "int") |
a++ | Postfix inc | |
!= | Single operator | ! = |
!true | Prefix logical NOT | ! applied to true |