The WCL language · reference
Expressions and operators
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:
$ 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.
| Layer | Written as | Examples |
|---|---|---|
| Atom | a value, a name, or a bracketed form | 8080u32, "web", :prod, none, rate, self, [1, 2], { w: 3 }, (a + b) |
| Postfix | sticks to the atom on its left | svc.port, len(xs), Scale::Small |
| Prefix | sits in front of what it acts on | -x, !ready, ~mask |
| Infix | joins a left and a right operand | a + 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.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, $" — ")
$ 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 dot | What a segment names | Example |
|---|---|---|
| A block | one of its fields, or a nested block | self.cpu, svc.limits.memory |
| A gather field | one instance, by its first label | services.web |
| A record | one of its fields | box.width |
| A union variant value | one field of its record payload | scale.shards |
| A &T field naming a block | a member of the block it names | self.upstream.host |
| Any other expression | one field of the record it evaluates to | at(rows, 0).name |
| The document | any top-level name | parent.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:
$ 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.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
$ 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:
$ 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.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
$ 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:
$ 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:
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:
$ 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.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
$ 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:
$ 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 field declared with a record type, or a field of such a record: cfg.db.host when cfg: Cfg and Cfg declares db: Db.
- An element of a declared list, through at, head, find, max_by or min_by, including after filter, sort_by, reverse, take, drop, tail, slice or unique.
- A function parameter or return type: fn(r: Item) -> utf8 r.name, or mk().name when fn mk() -> Item.
- The payload of a union variant, bound by a match or if let arm.
- A let bound to any of these.
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:
$ 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:
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:
@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" }
$ 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.
| Written | Means | Works on | Missing element |
|---|---|---|---|
| at(xs, 0) | the element at position 0 | a list or tensor value | an error |
| steps.1 | the block whose first label is 1 | a @children gather field | unresolved reference |
| ports.0 | nothing, 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:
- A local name. A function-valued let binding or a function parameter.
- A document name. A fn name(…) item, or any field or let holding a function value. See Functions.
- A builtin. len, at, format, map, concat and the rest of Builtins.
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:
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:
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:
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.
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:
- Same variant, same answer. u8 + u8 is u8 arithmetic and the result is a u8. Nothing widens, so 255u8 + 1u8 overflows instead of answering 256.
- Mixed variants promote once. If either side is a float, both become f64. Otherwise both become i128. The operator then runs on the promoted pair.
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:
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:
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:
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:
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:
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:
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.
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:
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:
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:
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:
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:
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:
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:
$ 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:
@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:
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:
$ 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:
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:
| Written | Answers | Non-string arguments | Reach for it when |
|---|---|---|---|
| concat(a, b) | "us-east-1" | a type error | Exactly two strings meet |
| join(parts, sep) | "a-b-c" | a type error | A list becomes one string |
| format("{}:{}", a, b) | "web:8080" | formatted in place | A template with holes |
| $"web:${port}" | "web:8080" | formatted in place | The template is the literal |
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.
| Level | Operators | Form | Example |
|---|---|---|---|
| 1 | . :: (…) | member access, variant construction, call | svc.limits.cpu, len(xs) |
| 2 | - ! ~ | prefix negation, prefix not, prefix complement | -x, !ready, ~mask |
| 3 | * / % | multiply, divide, remainder | a * b % c |
| 4 | + - | add, subtract | a + b - c |
| 5 | << >> | shift left, shift right | 1u32 << n |
| 6 | & | bitwise and | flags & mask |
| 7 | ^ | bitwise exclusive or | a ^ b |
| 8 | | | bitwise or | lo | hi |
| 9 | < <= > >= | ordering | cost > budget |
| 10 | == != | equality | tier == :prod |
| 11 | && | logical and | a && b |
| 12 | || | logical or | a || b |
| 13 | ?? | none-coalescing | w ?? 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:
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:
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:
$ 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 this | Write | Because |
|---|---|---|
| 2.0 ^ 10.0 | pow(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 |
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
- Control flow. if, match, try/catch and the block expression, all of which are expressions you can drop into any operand position.
- Functions. fn items, function literals, parameters and what a function value captures.
- Builtins. The full catalogue behind at, len, format, concat, map and the rest.
- Values and primitives. The numeric types the promotion ladder walks, and the string forms interpolation builds.
- How a document evaluates. When an expression runs, what caches it, and how a cycle is caught.