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 fourteen binary operators and two 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:

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

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

§ 1The 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
Infixjoins a left and a right operanda + b, a == b, a && b, 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.

§ 2A 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}")
text
$ 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.

§ 3Member 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
The documentany top-level nameparent.region, from inside a block

Chains mix the rows freely, and each step is resolved only when the whole path is forced. With report.wcl from above:

text
$ 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 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.

§ 4There 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:

text
$ 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': 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

§ 4.1Three 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" }
text
$ 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'

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 — lists have no membersunresolved reference

Position and label are different questions

A @children gather field holds blocks, and a block is addressed 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.

§ 5Calls

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

Any other callee is evaluated first and must produce a function value: r.f(2) calls the function stored at r.f. That is worth saying plainly, because it is not a method call: 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. Every argument expression is evaluated in order and the values are handed 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.

§ 5.1Two 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 is reported 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".

§ 6Arithmetic

Five infix operators — +, -, *, /, % — 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

§ 6.1Integers refuse to be wrong; 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 inf 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
inf
wcl> 0.0 / 0.0
NaN

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.

§ 7Numeric promotion

Every arithmetic and comparison 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 u8u16u32 staircase, and no unsigned result type is ever chosen for a mixed pair.

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. That is reported as 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, overflow and compare normally.

A declared field type then converts the result on the way in, which is why 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 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.

§ 8Comparison

== and != compare any two values. Same-typed values compare structurally — lists element by element, records field by field — and numbers of different types promote first. Values of unrelated kinds are simply unequal; 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 (promoting mixed pairs the same way) 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 ordering family as <> rather than the operator you wrote; the two type names after it are the ones to read. Symbols are a set of names, not a scale — when the order matters, model it with a number.

§ 9Logic

&&, || 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 offending value's type and leaves the other side as :

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

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

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

  × operator '!' is not defined for i64 and —

Both binary operators short-circuit. false && … and true || … answer without touching the right operand, which is the one place besides ?? where an expression you wrote may never run:

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.

§ 10None-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 is evaluated 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:

text
$ 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, which 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

§ 11Joining 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

Note what the last two lines show. format and ${…} render a value's display form — 8080u32 becomes 8080, with no type suffix — while 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, Builtins the rest of the string functions.

§ 12Precedence and associativity

Nine 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-x, !ready
3* / %multiply, divide, remaindera * b % c
4+ -add, subtracta + b - c
5< <= > >=orderingcost > budget
6== !=equalitytier == :prod
7&&logical anda && b
8||logical ora || b
9??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; both read better with parentheses.

§ 13What is not an operator

Seven spellings that other languages make operators, and what WCL does instead.

Not thisWriteBecause
2.0 ^ 10.0pow(2.0, 10.0)^ is not a token at all — it is a lexer error
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
&TA reference type, in a declaration, not an operator on values
->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
parse error: wcl::parse

  × unexpected character '^'
   ╭─[<repl>:1:5]
 1 │ 2.0 ^ 10.0
   ·     ┬
   ·     ╰── unexpected character '^'
   ╰────

wcl> pow(2.0, 10.0)
1024.0

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

§ 14Where to go next