The WCL language · reference

Expressions and operators

15 min read · 2026-11-01 · wcl 0.33.2-alpha

Everything to the right of an = is an expression, and so is every call argument, every let initialiser, every table cell and every ${…} slot in an interpolated string. This chapter is the grammar of that right-hand side: the four ways expressions combine, the nineteen binary operators and three prefix ones, the precedence that orders them, and what each does to the values it is handed.

Every value and every error message below came from a real run. The quickest way to check one is wcl repl, which evaluates a bare expression against an empty document:

console
$ wcl repl
wcl> 1 + 2 * 3
7
wcl> :quit

Give it a file and identifiers resolve against that document's top-level names. The transcripts later in this chapter read a real document that way. The CLI covers repl in full.

The shape of an expression

An expression is built in four layers. Read them from the inside out and the precedence table at the end of this chapter stops being a list to memorise.

LayerWritten asExamples
Atoma value, a name, or a bracketed form8080u32, "web", :prod, none, rate, self, [1, 2], { w: 3 }, (a + b)
Postfixsticks to the atom on its leftsvc.port, len(xs), Scale::Small
Prefixsits in front of what it acts on-x, !ready, ~mask
Infixjoins a left and a right operanda + b, a == b, a && b, flags & mask, a ?? b

Three atoms are covered elsewhere because they are whole subjects: if, match and try are expressions like any other and live in Control flow. A { … } block expression with its let name = expr; bindings lives there too. A fn(…) -> T … literal lives in Functions. Every one of them may appear anywhere this chapter says "expression", including as an operand.

Parentheses are the fifth thing, and they are only grouping. They are also kept: wcl fmt prints 1 + (2 * 3) back exactly as written, because the parentheses are a node in the tree rather than a hint the parser discards. Write them wherever they help the reader, and never write them to fix a precedence you had to guess.

A report that computes itself

One file exercises most of this chapter. Save it as report.wcl:

report.wclwcl
# report.wcl — a capacity report that computes its own numbers.

@block("service")
type Service {
  @inline(0) id: identifier
  replicas: u32
  cpu:      f64
  budget:   f64?      # optional — a service need not set one
  cost:     f64       # computed below
  over:     bool      # computed below
  line:     utf8      # computed below
}

@document
type Report {
  region:         utf8
  rate:           f64
  default_budget: f64
  @children("service") services: list<Service>
  total:          f64
  any_over:       bool
  headline:       utf8
}

region         = "us-east-1"
rate           = 12.5
default_budget = 20.0

service web {
  replicas = 3u32
  cpu      = 0.5
  budget   = 30.0
  cost     = self.replicas * self.cpu * rate
  over     = self.cost > (self.budget ?? default_budget)
  line     = format("web  {} x {} = {}", self.replicas, self.cpu, self.cost)
}

service db {
  replicas = 2u32
  cpu      = 1.5
  cost     = self.replicas * self.cpu * rate
  over     = self.cost > (self.budget ?? default_budget)
  line     = format("db   {} x {} = {}", self.replicas, self.cpu, self.cost)
}

total    = services.web.cost + services.db.cost
any_over = services.web.over || services.db.over
headline = concat(region, $" — ${total}")
console
$ wcl check report.wcl
OK
$ wcl get report.wcl services.web.cost
18.75
$ wcl get report.wcl services.web.over
false
$ wcl get report.wcl services.db.cost
37.5
$ wcl get report.wcl services.db.over
true
$ wcl get report.wcl services.db.line
"db   2 x 1.5 = 37.5"
$ wcl get report.wcl total
56.25
$ wcl get report.wcl any_over
true
$ wcl get report.wcl headline
"us-east-1 — 56.25"

Four of the sections below are already on that page. self.replicas * self.cpu multiplies a u32 by an f64 and answers an f64. That is numeric promotion. self.budget ?? default_budget supplies the missing budget for db without a conditional. That is none-coalescing. services.web.cost walks a gather field, a label and a field. That is member access. format(…) and concat(…) build the strings, because + does not. That is joining strings. Keep the file. Several later transcripts read it.

Member access

A dot reads a member of the value on its left. It binds tighter than everything else, so a dotted path is one atom as far as the rest of an expression is concerned: -svc.port negates the port, and a.b + c.d adds two members.

What a dot may read depends on what is on its left:

Left of the dotWhat a segment namesExample
A blockone of its fields, or a nested blockself.cpu, svc.limits.memory
A gather fieldone instance, by its first labelservices.web
A recordone of its fieldsbox.width
A union variant valueone field of its record payloadscale.shards
A &T field naming a blocka member of the block it namesself.upstream.host
Any other expressionone field of the record it evaluates toat(rows, 0).name
The documentany top-level nameparent.region, from inside a block

Chains mix the rows freely, and each step resolves only when something forces the whole path. With report.wcl from above:

console
$ wcl repl report.wcl
wcl> services.web.cost
18.75
wcl> services.db.replicas
2u32
wcl> services.web.zone
eval error: wcl::eval::unresolved_reference

  × unresolved reference 'services.web.zone'

A path does not stop where the document tree does. When it lands on a field whose value is a record, the next segment reads a field of that record. A top-level field is no exception:

limits.wclwcl
# limits.wcl — a record in a top-level field, read from another.

type Limits {
  cpu:    f64
  memory: u32
}

@document
type Plan {
  limits: Limits
  memory: u32
}

limits = { cpu: 0.5, memory: 512u32 }
memory = limits.memory
console
$ wcl check --eval limits.wcl
OK
$ wcl get limits.wcl memory
512u32
$ wcl get limits.wcl limits.cpu
0.5

The document comes first. A segment that names a block, a field of a block or a declaration resolves to it, and the record's fields are read only when the tree has nothing further to offer. A variant with a record body reads the same way, so shape.radius works on a field holding Shape::Circle { radius: 5.0 }. A list or a number has no fields, and the error says which it is: ports.name reports value of type 'list' has no field 'name'. Ask a record for a field it lacks and the error names the record's type and lists what it has. Change the last line to memory = limits.mem:

console
$ wcl check --eval limits.wcl
wcl::eval::unresolved_reference

  × unresolved reference 'limits.mem': record of type 'Limits' has no field
  │ 'mem'
    ╭─[limits.wcl:15:10]
 14 │ limits = { cpu: 0.5, memory: 512u32 }
 15 │ memory = limits.mem
    ·          ─────┬────
    ·               ╰── no such field
    ╰────
  help: its fields are: cpu, memory

limits.wcl: 1 evaluation error

wcl get limits.wcl limits.mem says only no such path: limits.mem, and exits 3 like the check.

Through a reference

A field declared &T holds a reference: the path of a block somewhere else in the document, not a copy of it. A dot after such a field follows the reference and reads the block it names, exactly as if you had written that block's own path. Here each service points at the one behind it:

routes.wclwcl
# routes.wcl — services that point at the service behind them.

@block("service")
type Service {
  @inline(0) id: identifier
  host:     utf8
  upstream: &Service?
  backend:  utf8?
}

@document
type Mesh {
  @children("service") services: list<Service>
  origin: utf8
}

service db {
  host = "db.internal"
}

service api {
  host     = "api.internal"
  upstream = services.db
  backend  = self.upstream.host
}

service web {
  host     = "web.internal"
  upstream = services.api
}

origin = services.web.upstream.upstream.host
console
$ wcl check --eval routes.wcl
OK
$ wcl get routes.wcl services.api.backend
"db.internal"
$ wcl get routes.wcl origin
"db.internal"
$ wcl get routes.wcl services.web.upstream
&services.api<block>
$ wcl get routes.wcl services.web.upstream.host
"api.internal"

self.upstream.host reads the host of db, because api's upstream names db. origin follows two references in a row, web to api to db. wcl get follows them the same way, and a path that stops at the reference prints the reference itself.

Only a reference to a block is followed. A &T field that names a declaration, such as target = Row, is a handle for the reflection builtins, and a dot after it does not read the type's fields. When the block a reference names does not exist, every read through it reports the reference. Point api at services.cache, which nothing declares:

console
$ wcl check --eval routes.wcl
wcl::eval::unresolved_reference

  × unresolved reference 'services.cache'
    ╭─[routes.wcl:23:14]
 22 │   host     = "api.internal"
 23 │   upstream = services.cache
    ·              ───────┬──────
    ·                     ╰── does not resolve
 24 │   backend  = self.upstream.host
    ╰────

routes.wcl: 1 evaluation error

A reference whose own path runs back through itself, such as upstream = self.upstream.upstream, cannot name anything. It fails with cycle while evaluating 'upstream' rather than looping.

After any expression

The left of a dot need not be a name. A call, a bracketed if or match, a block or a record literal is evaluated first, and the dot reads a field of the record it produces. With report.wcl from above:

text
wcl> (if services.web.over { services.web } else { services.db }).line
"db   2 x 1.5 = 37.5"
wcl> max_by([services.web, services.db], fn(s: Service) -> f64 s.cost).line
"db   2 x 1.5 = 37.5"
wcl> at([services.web, services.db], 1).cost
37.5
wcl> { let s = services.web; s }.replicas
3u32

The same rules apply as for a field holding a record. A missing field names the record's type and lists what it has, and a value that is not a record says what it is instead:

console
$ wcl repl report.wcl
wcl> at([services.web], 0).zone
eval error: wcl::eval::unresolved_reference

  × unresolved reference 'at([services.web], 0).zone': record of type
  │ 'Service' has no field 'zone'
  help: its fields are: budget, cost, cpu, id, line, over, replicas

wcl> total.cents
eval error: wcl::eval::unresolved_reference

  × unresolved reference 'total.cents': value of type 'f64' has no field
  │ 'cents'

A block carries its type with it, so Service above comes from the record itself. A record literal carries none. { sku: "a-1" } is the same value wherever it is written, so the error takes the type from where the record was read. Here the records sit in a field declared list<Item>:

stock.wclwcl
# stock.wcl — record literals in a typed list.

type Item {
  sku:   utf8
  count: u32
}

@document
type Stock {
  items: list<Item>
  first: utf8
}

items = [
  { sku: "a-1", count: 3u32 },
  { sku: "b-2", count: 0u32 },
]

first = at(items, 0).sku
console
$ wcl check --eval stock.wcl
OK
$ wcl get stock.wcl first
"a-1"

Ask the element for a field Item does not declare. Change the last line to first = at(items, 0).name:

console
$ wcl check --eval stock.wcl
wcl::eval::unresolved_reference

  × unresolved reference 'at(items, 0).name': record of type 'Item' has no
  │ field 'name'
    ╭─[stock.wcl:19:9]
 18 │
 19 │ first = at(items, 0).name
    ·         ────────┬────────
    ·                 ╰── no such field
    ╰────
  help: its fields are: count, sku

stock.wcl: 1 evaluation error

The element itself is not changed. at(items, 0) is still the literal { sku: "a-1", count: 3u32 }, with no type name, and it equals that literal written anywhere else. Only the error looks back at where the record came from. Any of these routes names the declared type:

A literal nothing declared, such as an element of let xs = [{ sku: "a-1" }], says only record has no field 'name'.

A dot binds tighter than a call, so f(x).g(y) reads the field g of f(x)'s result and then calls it. That is how a record of functions is used: ops(3).scale(7) calls the scale field of the record ops(3) returns.

A dot is also how self and parent are useful. On their own they name a block, and the member is what you wanted from it. Documents, fields and blocks covers both keywords.

A label is not readable through the dot

@inline(0) id: identifier says which field a block's label fills, not that the label is readable back. services.web.id resolves to the field's declaration, not to web, and a sibling field cannot read it either. When an expression needs the value, declare an ordinary field and write it out. See Documents, fields and blocks.

There is no index operator

[ opens a list literal, a tensor shape or a table row. It never indexes, and writing it after an expression is a parse error rather than a missing feature:

console
$ wcl repl
wcl> [10, 20, 30][0]
parse error: wcl::parse

  × expected end of input after expression, found '['
   ╭─[<repl>:1:13]
 1 │ [10, 20, 30][0]
   ·             ┬
   ·             ╰── unexpected trailing token
   ╰────

Reaching into a list is a call. at(xs, i) is the direct one, and it errors on a negative or out-of-range index rather than answering none:

text
wcl> at([10, 20, 30], 1)
20
wcl> at([10, 20, 30], 3)
eval error: wcl::eval::builtin_type

  × 'at': index 3 out of bounds

wcl> head([10, 20, 30])
10
wcl> slice([1, 2, 3, 4], 1, 3)
[2, 3]
wcl> index_of([10, 20, 30], 20)
1

Three notations that look like indexing

The trap is that one of them is a dot followed by a number, and it means something else entirely. A numeric segment after a dot is a label match, exactly like a named one. It finds the block whose first label reifies to those digits. Take this file:

steps.wclwcl
@block("step")
type Step {
  @inline(0) n: u32
  action: utf8
}

@document
type Doc {
  ports: list<u32>
  @children("step") steps: list<Step>
}

ports = [8080u32, 5432u32, 9000u32]

step 1 { action = "checkout" }
step 2 { action = "build" }
console
$ wcl repl steps.wcl
wcl> steps.1.action
"checkout"
wcl> steps.0.action
eval error: wcl::eval::unresolved_reference

  × unresolved reference 'steps.0'

wcl> at(ports, 0)
8080u32
wcl> ports.0
eval error: wcl::eval::unresolved_reference

  × unresolved reference 'ports.0': value of type 'list' has no field '0'

steps.1 is the step labelled 1, which happens to be the first. steps.0 is not the first step. It is a step that does not exist, because no block carries the label 0. And ports.0 fails outright. ports is a list value, not a list of blocks, so it has no members to name at all.

WrittenMeansWorks onMissing element
at(xs, 0)the element at position 0a list or tensor valuean error
steps.1the block whose first label is 1a @children gather fieldunresolved reference
ports.0nothing, because lists have no members—unresolved reference

Position and label are different questions

A @children gather field holds blocks, and you address a block by its label, so steps.1 reads the same whether the steps are written in order, out of order, or one per imported file. at(steps, 0) would ask a different question, which one is written first, and the answer changes when someone reorders the file. Prefer the label.

Calls

Parentheses after an expression call it. When the callee is a bare name, the resolver searches three places, in this order:

Any other callee evaluates first and must produce a function value: r.f(2) calls the function stored at r.f. That is not a method call, because there are no methods. xs.len() does not exist, because len is not a member of a list. len(xs) is the call, and r.f(2) works only because r.f really is a function value. Argument lists are positional, there are no named arguments and no defaults, so the arity must match:

text
wcl> len([1, 2, 3])
3
wcl> len([1, 2, 3], 2)
eval error: wcl::eval::builtin_arity

  × 'len' expects 1 argument(s), got 2

wcl> { let r = { f: fn(x: i64) -> i64 x + 1 }; r.f(2) }
3

Arguments evaluate before the call, left to right

Fields are lazy. Arguments are not. WCL evaluates every argument expression in order and hands the values to the callee, so an argument that the body never reads still runs, and still fails. With fn first(x: i64, y: i64) -> i64 { x } in scope, first(1, 1 / 0) reports operator '/' cannot divide by zero even though y is unused. The only expressions that skip an operand are &&, || and ??, below.

Two things that look like calls

Scale::Small { replicas: 2u32 } is variant construction, not a call. The :: binds as tightly as a dot, the braces carry a record payload and the parenthesised form Shape::Polygon(7) carries a positional one. Records themselves have no constructor. A record value is the bare literal { width: 480.0 }, typed by where it is used. Types covers both.

A name in call position that is not a function comes back as a missing builtin, because the builtin registry is the last place the resolver looks. box(1), where box is a record, answers unknown built-in 'box' rather than something about records. Read that message as "nothing callable is named box".

Arithmetic

There are five infix arithmetic operators, +, -, *, / and %, plus prefix -. They are defined for numbers and for nothing else. + does not join strings and does not concatenate lists:

text
wcl> "us-" + "east-1"
eval error: wcl::eval::type_mismatch

  × operator '+' is not defined for utf8 and utf8

wcl> [1] + [2]
eval error: wcl::eval::type_mismatch

  × operator '+' is not defined for list and list

Division and remainder follow the operand type. Between integers, / truncates toward zero and % takes the sign of the left operand. Between floats, both are the IEEE operations:

text
wcl> 7 / 2
3
wcl> -7 / 2
-3
wcl> -7 % 3
-1
wcl> 7 % -3
1
wcl> 7.0 / 2.0
3.5
wcl> 7.5 % 2.0
1.5

Integers refuse to be wrong, but floats do not

This is the comparison to take away from the section. An integer operation with no representable answer is an error, not a wrapped value and not a debug-build panic. A float operation with no finite answer is infinity or NaN, silently, because that is what IEEE arithmetic means.

text
wcl> 255u8 + 1u8
eval error: wcl::eval::arithmetic

  × operator '+' cannot represent the result in u8 (overflow)

wcl> 2u32 - 3u32
eval error: wcl::eval::arithmetic

  × operator '-' cannot represent the result in u32 (overflow)

wcl> 1 / 0
eval error: wcl::eval::arithmetic

  × operator '/' cannot divide by zero

wcl> 1.0 / 0.0
(1.0 / 0.0)
wcl> 0.0 / 0.0
(0.0 / 0.0)

WCL has no literal for infinity or NaN, so each prints as the division that produces it. Paste the output back in and you get the same value. Values and primitives has the rest of the printing rules.

2u32 - 3u32 is the one that catches people. The operands are both u32, the answer is −1, and u32 has no −1, so the operation has no result. It does not widen to reach one. See the next section for why.

Numeric promotion

Every arithmetic operator tries the operands' own type first, and promotes only when that fails. The rule is two lines:

text
wcl> 1u8 + 1u8
2u8
wcl> 2u8 + 300u32
302i128
wcl> 1u32 + 2.5
3.5
wcl> 3u32 * 0.5
1.5

Read 2u8 + 300u32 closely: the result is neither a u8 nor a u32. The operands disagreed, both went to i128, and i128 is what came back. The promotion ladder has exactly these two rungs. There is no u8 → u16 → u32 staircase, and a mixed pair never produces an unsigned result type.

i128 is the top of the integer rung, so one mixed pair has nowhere to promote to: a u128 larger than i128::MAX beside any other numeric type. The message is a type mismatch rather than an overflow, because the operator was never reached. 340282366920938463463374607431768211455u128 + 1u32 answers operator '+' is not defined for u128 and u32. Two u128s never promote at all, so they add and overflow normally. Comparison is not arithmetic and never promotes, so 340282366920938463463374607431768211455u128 > 1u32 is simply true. Comparison explains.

A declared field type then converts the result on the way in, so report.wcl never mentions i128 or f64. cost: f64 accepts the f64 the multiplication produced. A field declared u32 accepts an i128 result that fits and rejects one that does not. Schemas covers the conversion, and Values and primitives covers the numeric types themselves.

A unit literal is not a number yet

512MiB carries its unit until a declared type resolves it, so it is not an operand you can do arithmetic on in the REPL. Read the field instead. Against the inventory.wcl of Documents, fields and blocks, wcl get inventory.wcl services.web.limits.memory answers 536870912, and that value is an ordinary integer. See Values and primitives.

Comparison

== and != compare any two values. Same-typed values compare structurally: lists element by element, records field by field. Numbers of different types compare by value. Values of unrelated kinds are unequal, and nothing errors:

text
wcl> 1u32 == 1u64
true
wcl> 1 == 1.0
true
wcl> [1, 2] == [1, 2]
true
wcl> { a: 1 } == { a: 1 }
true
wcl> :prod == :prod
true
wcl> true == 1
false
wcl> "a" == 1
false

<, <=, > and >= are narrower. They order numbers of any two types and they order utf8 / ascii strings byte by byte. Everything else is a type error, including symbols, lists and booleans:

text
wcl> 2u8 < 300u32
true
wcl> "abc" < "abd"
true
wcl> :prod < :dev
eval error: wcl::eval::type_mismatch

  × operator '<' is not defined for symbol and symbol

The message names the operator you wrote and the two types it met. Symbols are a set of names, not a scale. When the order matters, model it with a number.

Numbers compare exactly

Comparison does not use the promotion ladder. Every comparison operator reads both numbers exactly and compares the values, so mixing widths costs nothing, and the answer never depends on a rounding step. This is the same order sort puts numbers in:

text
wcl> 9007199254740993 == 9007199254740992.0
false
wcl> 340282366920938463463374607431768211455u128 > 1u32
true

The first line is the one a promoting comparison gets wrong. 2⁵³ + 1 has no f64, so widening the integer to meet the float rounds it down to 2⁵³, and the two would compare equal. They are not equal, and WCL says so.

NaN is unordered. It is not less than, equal to or greater than anything, itself included. So <, <=, >, >= and == are all false when either side is NaN, and != is true, as IEEE 754 specifies:

text
wcl> (0.0 / 0.0) < 1.0
false
wcl> (0.0 / 0.0) >= 1.0
false
wcl> (0.0 / 0.0) == (0.0 / 0.0)
false

A NaN therefore fails every range guard you write, in either direction. To test for one, use x != x, which is true for NaN and nothing else.

Logic

&&, || and prefix ! take booleans and nothing else. There is no truthiness: 1 && true is a type error, and so is !1. The message names the operator and the one operand's type that was not a bool:

text
wcl> true && 1
eval error: wcl::eval::type_mismatch

  × operator '&&' is not defined for i64

wcl> !1
eval error: wcl::eval::type_mismatch

  × operator '!' is not defined for i64

Both binary operators short-circuit. false && … and true || … answer without touching the right operand. Apart from ??, these are the only operators that leave an expression you wrote unevaluated:

text
wcl> false && error("boom")
false
wcl> true || error("boom")
true
wcl> true && error("boom")
eval error: wcl::eval::user_error

  × error: boom

&& binds tighter than ||, so a && b || c is (a && b) || c. Both bind looser than every comparison, so x > 0 && y > 0 needs no parentheses at all.

Bits and shifts

Six operators work on the bits of an integer: & (and), | (or), ^ (exclusive or), << (shift left), >> (shift right) and prefix ~ (complement). They are the operators a file needs when it describes something laid out in bits: a flag mask, an opcode field, a header that packs four values into one word.

text
wcl> 0xF0u32 & 0x3Cu32
48u32
wcl> 0xF0u32 | 0x0Fu32
255u32
wcl> 0xFFu8 ^ 0x0Fu8
240u8
wcl> 1u32 << 3u32
8u32
wcl> ~0u8
255u8
wcl> ~0
-1

~ flips every bit of the operand's own type. That is why ~0u8 is 255u8 and ~0 is -1: the unsuffixed 0 is an i64, and an i64 with every bit set is −1.

Integers only

All six take integers of any width, isize, usize and the 128-bit types included. They take nothing else. A float has no bits worth naming, and a boolean already has && and ||, so both are type errors that name the operator:

text
wcl> 1.0 & 1
eval error: wcl::eval::type_mismatch

  × operator '&' is not defined for f64 and i64

wcl> true | false
eval error: wcl::eval::type_mismatch

  × operator '|' is not defined for bool and bool

wcl> ~1.5
eval error: wcl::eval::type_mismatch

  × operator '~' is not defined for f64

And, or and exclusive or promote like arithmetic

&, | and ^ follow the same two-line rule as +. Two operands of the same type keep it. A mixed pair is promoted to i128 first, so the answer is an i128:

text
wcl> 0xF0u8 & 0x3Cu16
48i128

A u128 above i128::MAX beside any other type has nowhere to go and is a type mismatch, exactly as it is for +. Write both operands with the same suffix when the width matters, which in bit-level work it usually does.

Shifts keep the left type

Shifts are different. Nothing is promoted: the result has the type of the left operand, and the amount on the right may be any integer type. A shift left discards the bits that fall off the top. That is not an overflow, it is what a shift is:

text
wcl> 1u8 << 7u64
128u8
wcl> 0x80000000u32 >> 31i8
1u32
wcl> 128u8 << 1
0u8

A shift right on a signed value copies the sign bit in from the left, so a negative number stays negative. On an unsigned value it fills with zeros:

text
wcl> -16i8 >> 2
-4i8
wcl> 0x80u8 >> 7
1u8

The amount is checked. It must be at least 0 and less than the width of the left type, so 0 to 31 for a u32. Anything else, negative included, is an error like a division by zero, and try recovers from it the same way:

text
wcl> 1u32 << 32
eval error: wcl::eval::arithmetic

  × operator '<<' cannot shift u32 by 32 (the amount must be 0 to 31)

wcl> 1u32 >> -1
eval error: wcl::eval::arithmetic

  × operator '>>' cannot shift u32 by -1 (the amount must be 0 to 31)

To read a field back out of a word, call bits. bits(v, hi, lo) answers bits hi down to lo of v, both ends included, shifted down to bit 0 and in v's own type. It is the shift and the mask, (v >> lo) & ((1u32 << w) - 1u32), with the width worked out for you and an error when hi names a bit the type does not have:

text
wcl> bits(0b1011_0110u8, 5, 2)
13u8
wcl> (0b1011_0110u8 >> 2) & 0b1111u8
13u8

Where a << can be misread

<< also opens a heredoc, and | also separates table cells. WCL settles each case by how the source is spaced or bracketed, and three of them are worth knowing before they surprise you.

Put spaces on both sides of <<. a << b and a<<b are shifts. a <<b, with a space before and none after, is the shape of a heredoc label such as p <<'TXT' in a wdoc page, and the lexer reads it as one. An encoding name touching << is always a typed heredoc too, so ascii<<n opens an ASCII heredoc even when ascii is a field:

text
$ wcl check shift.wcl
wcl::parse

  × unterminated heredoc starting with '<<b'
   ╭─[shift.wcl:8:7]
 7 │ a = 3
 8 │ x = a <<b
   ·       ──┬─
   ·         ╰── unterminated heredoc starting with '<<b'
   ╰────

A shift is two touching angle brackets. a > > b is a comparison followed by a stray >, and a parse error. There is no <<= or >>=, because WCL has no assignment operators at all.

Bracket a | inside a table cell. A bare | in a row is the cell delimiter. Inside parentheses, a list or a call it is bitwise or again. &, ^ and the shifts need no brackets:

perms.wclwcl
@table("perm")
type Perm {
  name: utf8
  mask: u8
}

@document
type Perms {
  @children("perm") perms: list<Perm>
}

let READ  = 4u8
let WRITE = 2u8

perms:
  | "ro" | (READ)         |
  | "rw" | (READ | WRITE) |
  | "x"  | 1u8 << 0u8     |

The (READ) in the first row is there for a different reason: a cell cannot start with a bare name. A | between two patterns of a match arm is untouched by any of this. 1 | 2 => … is still two alternatives, and a guard is an ordinary expression, so 1 | 2 if a | b == 3 => … tests (a | b) == 3.

None-coalescing

a ?? b is the left value unless it is none, in which case it is the right value. It is the operator for a default, and it is the only reason a document with optional fields rarely needs a conditional. The right side runs only when it is needed:

text
wcl> none ?? 5
5
wcl> 0 ?? 5
0
wcl> false ?? 5
false
wcl> "ok" ?? error("boom")
"ok"
wcl> none ?? none ?? "last"
"last"

0 and false are values, so they win. Only none gives way: the absent optional field, the else-less if that did not fire, the field a schema declares as T? and the document never wrote. That last one is what report.wcl uses:

console
$ wcl get report.wcl services.db.budget
none
$ wcl get report.wcl services.db.over
true

?? is not a try

?? reads a value and asks whether it is none. It does not catch a failure. error("gone") ?? 5 reports error: gone, and so does a ?? whose left side is a field that failed to evaluate or a path that does not resolve. Recovering from an error is try body catch e => fallback. See Control flow.

?? is the loosest operator in the language. That looseness is a real trap when the default sits next to a comparison. width ?? 480.0 > 100.0 is width ?? (480.0 > 100.0). When width is set you get a number, and when it is none you get a boolean, out of one expression. Parenthesise the default:

text
wcl> none ?? 480.0 > 100.0
true
wcl> 5.0 ?? 480.0 > 100.0
5.0
wcl> (none ?? 480.0) > 100.0
true

Joining strings

+ is arithmetic, so joining two strings is a call or an interpolation. There are four ways, and they are not interchangeable:

WrittenAnswersNon-string argumentsReach for it when
concat(a, b)"us-east-1"a type errorExactly two strings meet
join(parts, sep)"a-b-c"a type errorA list becomes one string
format("{}:{}", a, b)"web:8080"formatted in placeA template with holes
$"web:${port}""web:8080"formatted in placeThe template is the literal
text
wcl> concat("us-", "east-1")
"us-east-1"
wcl> join(["a", "b", "c"], "-")
"a-b-c"
wcl> format("{}:{}", "web", 8080u32)
"web:8080"
wcl> $"web:${8080u32}"
"web:8080"
wcl> concat("n=", 5)
eval error: wcl::eval::builtin_type

  × 'concat': expected utf8 string, found i64

The last two lines show the difference. format and ${…} render a value's display form, so 8080u32 becomes 8080, with no type suffix. concat and join demand strings and say so. Reach for the interpolated literal when the template is written out and for format when it is computed. The $ prefix is what makes a string literal interpolating, and a plain "…" never substitutes. Values and primitives covers the string forms, and Builtins covers the rest of the string functions.

Precedence and associativity

Thirteen levels, tightest first. Every binary operator is left-associative, so 10 - 3 - 2 is (10 - 3) - 2 and answers 5.

LevelOperatorsFormExample
1. :: (…)member access, variant construction, callsvc.limits.cpu, len(xs)
2- ! ~prefix negation, prefix not, prefix complement-x, !ready, ~mask
3* / %multiply, divide, remaindera * b % c
4+ -add, subtracta + b - c
5<< >>shift left, shift right1u32 << n
6&bitwise andflags & mask
7^bitwise exclusive ora ^ b
8|bitwise orlo | hi
9< <= > >=orderingcost > budget
10== !=equalitytier == :prod
11&&logical anda && b
12||logical ora || b
13??none-coalescingw ?? 480.0

Level 1 is why a dotted path never needs parentheses, and level 2 is why -svc.port negates the port rather than negating svc. Everything else is the usual arithmetic ordering, with two surprises worth pinning down:

text
wcl> 1 < 2 == true
true
wcl> "a" ?? "b" == "c"
"a"

The first is ordering binding tighter than equality: 1 < 2 == true is (1 < 2) == true, which is true. The second is ?? binding loosest: "a" ?? "b" == "c" is "a" ?? ("b" == "c"), and since the left side is not none it answers "a". The comparison never runs. Both parse exactly as the table says, and both read better with parentheses.

The bitwise levels sit between arithmetic and comparison. That is Rust's order, not C's, and it makes the common tests read the way they look:

text
wcl> 6 & 3 == 2
true
wcl> 1 << 2 + 1
8

6 & 3 == 2 is (6 & 3) == 2. In C it would be 6 & (3 == 2), and in WCL that spelling is a type error, because & does not take a bool. Shifts bind looser than +, so 1 << 2 + 1 is 1 << 3.

How long a chain can be

Left-associativity means a chain nests: 1 + 1 + 1 is (1 + 1) + 1, and every further + adds a level. Member accesses and calls nest the same way, so a.b.c and f()() are two levels each. One expression may be at most 256 levels deep, counting every operator, call, member access and bracket on the way down. A 256-term sum answers normally. A 301-term one stops the parse:

text
$ wcl check long.wcl
wcl::parse

  × expression too deep (more than 256 levels of operators, calls or member
  │ accesses)

The limit exists because evaluating, printing and freeing an expression each walk it one level at a time, and a deeper tree would exhaust the stack of the thread doing the walking. A generated file is the usual way to reach it. Split the chain with let bindings, or build a list and hand it to sum or join. This is a limit on how one expression is written, checked when the file parses. It is separate from the evaluation limit of 200 nested fields, lets and fn calls forcing one another at run time, which How a document evaluates covers.

Brackets, if bodies and ${…} slots nested inside one another count against a second parse limit of 128 levels, reported as nesting too deep. Either parse limit can also fire sooner on a thread with little stack, such as a language server's, where the same message replaces what would otherwise be a crash.

What is not an operator

Other languages make these seven spellings operators, or give them a meaning WCL does not. This table gives what WCL does instead.

Not thisWriteBecause
2.0 ^ 10.0pow(2.0, 10.0)^ is exclusive or, not a power. See Bits and shifts
xs[0]at(xs, 0)[ opens a list literal, a tensor shape or a table row
xs.len()len(xs)There are no methods. len is a function, not a member
a + b (strings)concat(a, b)+ is arithmetic. See Joining strings
&T—A reference type where a type is expected. In an expression, & is bitwise and
->—A function's return type, or a connection statement
=>—Separates a match arm or a catch binding from its body
text
wcl> 2.0 ^ 10.0
eval error: wcl::eval::type_mismatch

  × operator '^' is not defined for f64 and f64

wcl> 2 ^ 10
8
wcl> pow(2.0, 10.0)
1024.0

The ^ row is the one to watch. Between floats, 2.0 ^ 10.0 fails loudly. Between integers, 2 ^ 10 is a valid exclusive or and quietly answers 8, and wcl check has nothing to object to. Read every ^ in a file ported from a language where it means a power.

:: is the one that belongs in this chapter rather than on the list above. It really is postfix syntax. It constructs a union variant, it also qualifies a namespaced name, and it binds at level 1, beside the dot. Types covers variants and Namespaces and imports covers qualification.

Where to go next