Operators reference

coil expressions use a Pratt parser with prefix, infix, and postfix operators. Higher rows in the precedence table bind tighter.

Associativity:

ClassOperatorsAssociativity
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)#

PrecedenceOperators / formsNotes
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 exprNumeric negation, no-op plus, bitwise NOT, logical NOT, read-only view, compile-time type name
Cast (postfix)expr as TBelow 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:

FormBinding
Qualified construct E::V(...)Atom
Grouping (expr)Atom
match, new, literalsAtoms

Arithmetic#

OperatorTypesResultVM op (int / float)
+int / float / byte (both same), string + string, or a type with an Add instancesameADD / ADDF; strings lower through FORMAT "%s%s"; an instance is a CALL
-int / float / byte, or a type with a Sub instancesameSUB / SUBF
*int / float / byte, or a type with a Mul instancesameMUL / MULF
/int / float / byte, or a type with a Div instancesameDIV / DIVF
%int / float / byte, or a type with a Rem instancesameMOD / MODF
**int / float / byte, or a type with a Pow instancesamePow / 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:

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

LeftRightResult
(T,…,T)(T,…,T) (same arity)zip
[T; N][T; N]zip
aggregatescalar 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):

OperatorMeaningTrait
&Bitwise ANDBitAnd
|Bitwise ORBitOr
^Bitwise XORBitXor
<<Shift leftShl
>>Shift rightShr
~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#

OperatorOperandsResult
&&bool, boolbool
||bool, boolbool

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

OperatorVM opcode
&&AND
||OR
&BITAND
|BITOR
^XOR
<<, >>SHL, SHR

Comparison#

OperatorOperandsResult
==, !=Same typebool
<, <=, >, >=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
RuleDetail
LHSIdentifier, record field (d.x), or array index (arr[i])
Compound+=, -=, *=, /=, %=, **=, <<=, >>=, &=, |=, ^=
Typex 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
UndeclaredError — use let first
ValueAssignment 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#

FormSyntaxResult value
Postfix incrementexpr++Old value
Postfix decrementexpr--Old value
Prefix increment++exprNew value
Prefix decrement--exprNew value

Works on variables, mutable record fields, and array elements. Enum record fields and tuples are immutable.


Field access (.field)#

FormExamplePrecedence
Postfix dotp.x, p.x.yPrimary — 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 typeMechanism
Enum record variantLoadField (index by declaration order)
{ } recordLoadField (slot of the field, fields sorted by name)

Error-handling operators (?, ?., ??)#

Desugared to match / return / MakeEnum — no new opcodes. See Tutorial: Error handling.

OperatorFormOperandResult typeNotes
Tryx?Result<T,E> or Option<T>TPropagates Err / None via early return; hard error otherwise (E0114)
Optional accessa?.fieldOption<R> with field UOption<U>Option-only; Result → E0116
Coalescea ?? bOption<T> or Result<T,E>TRHS 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 ([])#

FormExamplePrecedence
Postfix indexarr[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#

OperatorNameOperandResult
-Negatenumeric, a type parameter bounded by Neg (Num implies it), or a type with a Neg instancesame
+Positivenumericnumeric (no-op)
~Bitwise NOTint, a type parameter bounded by BitNot (Integral implies it), or a type with a BitNot instancesame (flip bits)
!Logical NOTbool or intbool
typeofType nameany 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#

InputParses asNot as
(1 + 2) * 3Group then multiply
(1, 2)TupleTwo groups
(1)Group1-tuple
(1,)1-tuple
1.0Float literal1 . 0
typeof(x) == "int"(typeof x) == "int"typeof ((x) == "int")
a++Postfix inc
!=Single operator! =
!truePrefix logical NOT! applied to true

DocumentContents
SyntaxFull expression grammar
TypesUnification on operator operands
Keywordstrue, false, new, etc.
↑ ↓ navigate ↵ open