Appendix D: Bridger Language Reference
This appendix covers Bridger’s formal definitions: the rules that dictate what a program means. The concrete syntax (how one writes a Bridger program as a string of characters) can be found in Appendix B, and Bridger’s prelude (the set of built-in functions and capabilities) is in Appendix C. Throughout the book, each chapter develops the semantics of Bridger one feature at a time. This appendix develops the full formal semantics all at once in its complete form. To learn how to read an inference rule, and the symbols the rules use, see the Notation reference in Appendix G.
Abstract syntax
The rules range over the abstract syntax (the output of Bridger’s parser, after the surface
details of the concrete syntax are resolved). In the AST chapter
we detail that tree, including the exact Rust enum this book asks you to interpret through its
milestones. Below is the grammar, over programs, expressions e, blocks, definitions d, patterns
p, and query atoms q:
Here ranges over the arithmetic operators and
over the comparisons. A projection index
i is a literal integer; f is a field name, m a method name, C a constructor, S a struct
name, a trait, R a relation. The quoted '|' inside a data type or an or-pattern is
Bridger’s own token, set apart from the metagrammar’s as in Appendix B. The ref x = e
binding desugars to let x = ref e, and fn foo(x: Int) = e; to fn foo(x: Int) { e }. The
keyword form if e then e else e is this grammar’s spelling of the braced concrete syntax
if e { e } else { e }. Error propagation e? is sugar over match and return
(error handling); it has no evaluation rule of its own below, and
its typing is stated after .
Definitions appear only at the top level; a block holds let bindings and expressions, so a local
function is a let-bound lambda — which, like any let, is not in scope in its own right-hand
side, so a local function cannot call itself; recursion is for top-level fns — and execution
begins at a required main. A type, struct,
trait, impl, or fn may take type parameters with trait bounds; the
grammar leaves these implicit and the typing rules make them
explicit. A bound on a type or struct parameter is required where a value of the type is
built — a constructor application or a struct literal — so a Box<T: Ord> never holds a
function.
Values
Evaluation produces a value. The values are the literals, the compound forms built from them, a location (the identity of an allocated cell), and a closure (a function paired with the environment it captured):
where n ranges over integers (), b over the booleans and
(), and s over strings. An integer is a signed 64-bit two’s-complement value — every
literal denotes one, the least of them written -9223372036854775808 — and the
arithmetic operators wrap on overflow: the the rules write is addition modulo
, so no overflow is stuck. Division truncates toward zero and the remainder takes the
dividend’s sign, so -7 / 2 is -3 and -7 % 2 is -1. A list is the empty list or a
cons ; the literal is shorthand for
. A nullary constructor value
is written C for C(). The last two are not data: a relation value is what a declared
relation’s name evaluates to, consumed only by the query forms, and a type name is what a
bare struct, type, or built-in type name evaluates to, consumed only as the receiver of an
associated call. Neither compares, prints, or enters a relation.
Evaluation is carried out against three pieces of state. The environment maps each variable in scope to its value; the store maps each allocated location to the value it holds; and (from the relations section) the relation database holds the facts that queries read.
The grammar above is complete — every value a Bridger program produces is one of these. Its forms enter the language across the book (locations with mutable references, closures with functions, the compound forms with tuples and lists); the reference gathers them here so the rules below have their operands defined.
The evaluation judgment
The central judgment is
read in environment , evaluating expression against store and relation database
produces outcome , updated store , and updated database . The
outcome is for an expression that finishes normally with value , and
for one that returns early (return w) without executing the remaining body of
the enclosing function. Once hits the enclosing function boundary, it
turns from a returned value into a regular .
Two conventions keep the rules readable:
- A bare value () in result position means . A conclusion or premise written abbreviates . The
outcome is always written out. So every value-case rule reads as an ordinary big-step rule, and
only
return, application, and the loops mention at all. - short-circuits. When a premise written is discharged by a subexpression that instead yields , the rule does not fire; the enclosing expression evaluates to , and the subexpressions to its right are not evaluated. This one convention threads outward through every rule without doubling any of them.
Worked expansion. To see what the conventions abbreviate, consider the rule for addition:
The above rule is actually three separate rules. The case where bot operands evaluate to values:
and two more cases where the left or right operand return a value :
Every rule uses these conventions to implicitly thread returned values while only needing to explicitly describe the semantics for the case where all operands evaluate to values. The application is explicitly stated as it unlike other rules explicitly turns a returned value within the function to a value (the value computed/returned by the function).
The environment is read-only; the store and the database are threaded —
each rule passes them out of one subexpression and into the next, so a change one subexpression
makes is visible to those evaluated after it. Threading is needed because a subexpression can be
effectful: a call may allocate or write a reference, add and clear change the database, and
either can sit as an operand of +, so x + f() may alter or between evaluating
x and combining the results.
Every rule below threads both the store and in evaluation order, so each records how its subexpressions are sequenced; the rules that leave a piece of state untouched thread it unchanged rather than drop it. Slices appear in the chapters, not here — each an honest projection of the judgment above. Nothing writes the store until mutable references in Part IV, so those chapters use the store-free ; nothing writes until the relations material, so earlier chapters drop it.
The typing judgment
The static semantics is the judgment
read in typing context , expression has type , where records the type of each variable in scope together with the enclosing function’s return type. The typing rules below give the static semantics declaratively; the bidirectional checking and local inference that decide it are built with the type checker in Part VI. The judgment is stated here first so the evaluation rules can name the types they assume.
The metavariables used throughout extend the notation reference ’s table:
| Symbol | Ranges over |
|---|---|
| , , | expressions, values, patterns |
| , , | integer, boolean, and string literals |
| , , | variable, field, and method names |
| , , | constructor, struct, and relation names |
| locations | |
| , , | environment, store, relation database |
| , | typing context, types |
Runtime errors: stuck evaluation
Some expressions have no value. Adding a boolean to an integer, applying a non-function, or reading a field a value does not have fits no rule, so no derivation of exists. Evaluation is then stuck: the interpreter halts and reports the offending expression rather than producing an outcome. These are Bridger’s runtime type errors, and in the untyped evaluator they are how a nonsensical program fails.
Stuckness is the absence of an outcome rather than a special one. It stands apart from the
recoverable failures modeled by Option and Result, which are ordinary values handled with
match and live entirely inside the evaluation relation — evaluating Err("…") is a successful
derivation that yields a value. Error handling develops that
distinction; the type checker later rules out the type-shaped stuck
cases for the programs it accepts — a non-exhaustive match among them — leaving division by
zero and a failed read from input as the errors that survive typing.
The stuck cases, gathered from the rules below, are: an unbound variable; arithmetic, negation, or
ordering on a non-integer, and min, max, minimum, or maximum on operands with no shared
order; a non-boolean condition to if, while, and, or, or not;
division or modulo by zero; ++ on operands that are not both strings or both lists, or :: onto
a value that is not a list; a projection or field access on a value that lacks it; applying a
non-function, or calling a function, a method, a constructor, or a relation — in add or a
query — with the wrong arity; applying a name that is not a constructor, or a struct literal
naming no struct, missing one of its fields, or giving one twice; a match no arm covers;
iterating a non-list; dereferencing or assigning through a non-location; equality on a function
or relation, and printing one; a method the receiver’s head lacks, a self method called on a
type name, or one without self called on a value; a return no function catches; a query,
an add, or a clear inside a rule’s filter, and a filter or a guard whose value is not a
boolean; a hole in an add atom; a program with no main, or whose global initializers form a
cycle; a
read_* whose input is not what it reads (its _opt form returns None instead and consumes
no input; read_line drops a trailing carriage return); and two bounds of the machine, a call
nested more than 100 000 frames deep — a frame per application of a function, lambda, or method
body, the from a ? converts through included; natives and constructors open none, while the
prelude’s functions written in Bridger open one like any other — and a range of more than 2^26
elements. The static checks report the first error in source order, and reject an expression
nested more than 150 000 levels deep — every expression node a level, a block or a branch
included. The interpreter holds that same bound before a program runs, so a milestone with no
type checker rejects such a tree in place of exhausting the evaluator’s recursion; where the
checker runs, it reports the depth first.
Premises are discharged left to right, and a premise whose result has the wrong form ends evaluation there: a non-location
target of := is stuck before its right-hand side runs, a non-function operator before its
arguments, and a wrong arity — of a function, a method, a constructor, or a relation atom alike
— or a method the receiver lacks, after them.
The reference interpreter names the stuck cases as follows in its RuntimeError, and a
submission reports the same names. Each carries the span of the node whose rule failed —
the operator, the call, the match, the guard, the add or query form; a return or ?
that escapes an initializer or a filter carries that initializer’s or filter’s span, and a
missing main a synthetic one. Where a rule has several operands, found is the first, left
to right, whose form is wrong, and expected is the form the rule required of it.
| Stuck case | Variant and payload |
|---|---|
| a name with no binding | UnboundVariable { name } |
an operand of the wrong form: arithmetic, negation, or ordering on a non-integer; a non-boolean condition, connective operand, filter, or guard; ++ or :: on the wrong operands; iterating a non-list; deref or := on a non-location | TypeError { expected, found } |
| division or modulo by zero | DivByZero |
| a projection or field the value lacks | NoSuchField { field }, the index of a projection as text |
| a struct literal missing a field, or naming one twice | MissingField { field }, DuplicateField { field } |
| a struct literal naming no struct (a pattern naming one simply fails to match) | NotAStruct { name } |
| applying a value that is not a function, a type or struct name included | NotAFunction { found } |
| a function, method, constructor, or relation atom given the wrong number of arguments | ArityMismatch { expected, found } |
a match no arm covers | NonExhaustiveMatch |
| equality on a function or relation | NotComparable { found } |
cmp on an Int, String, or Bool receiver with an argument of another type — reached by min, max, minimum, and maximum, whose span is then the prelude’s own a.cmp(b); a receiver with no cmp at all is NoSuchMethod there | NotOrdered { found }, found the argument |
| printing a function, relation, or type name | NotPrintable { found } |
| a method the receiver’s head lacks | NoSuchMethod { method } |
a self method called on a type name; a method without self called on a value | NoReceiver { method }, NotAMethod { method, ty } |
a return no function catches | ReturnOutsideFunction |
a query, add, or clear inside a rule’s filter | QueryInFilter |
| a query form whose name is bound to something other than a relation | NotARelation { name } |
a hole in an add atom | HoleInAdd |
a read_* whose input is not what it reads | InputError { expected, found }, found the next token or None at the end of input |
no main, a main with parameters or type parameters, or a main that is not a function | Main { reason } |
| a cycle among global initializers | InitializationCycle { names }, from the alphabetically first global on the cycle, each depending on the next |
| a call nested more than 100 000 frames deep | StackOverflow { limit } |
a range of more than 2^26 elements | RangeTooLarge { len, limit } |
| an expression nested more than 150 000 levels deep, rejected before the program runs | TooDeep { limit } |
Evaluation rules
Throughout, the store and database are threaded left to right. Where a rule’s premises evaluate several subexpressions, they are written in evaluation order, and both pass and from each to the next.
Literals and variables
A literal evaluates to itself and leaves the store untouched (, , , and () all take
this form):
A variable evaluates to the value the environment binds to it; self, inside a method, is the
variable bound to the receiver:
When has no binding for the premise cannot be met, no rule applies, and evaluation is stuck on the unbound variable.
Arithmetic
Addition evaluates its left operand, then its right against the store the left produced, then combines the results — threading store and database, and , in evaluation order:
Subtraction and multiplication take the same shape, with computed on integers:
Division and modulo add a premise that the divisor is nonzero; when no rule applies and evaluation is stuck:
Modulo () is identical with the remainder in place of the quotient. Negation evaluates its operand and negates an integer:
Comparison and equality
A comparison evaluates both operands and returns a boolean. Writing for one of the six operators and for the corresponding relation on values:
The ordering operators <, <=, >, >= require integer operands. Equality == and inequality
!= are structural: they compare integers, booleans, strings, unit, tuples, lists,
constructor values, and struct values componentwise, so is true. A
location compares by identity: two references are equal exactly when they are the same cell,
whatever the cells hold. Equality on a function (closure) or a relation has no value and is
stuck. Comparisons do not chain: the
grammar makes them non-associative, so a < b < c is not a program.
Boolean connectives
and and or short-circuit: the right operand is evaluated only when the left does not
already settle the result. For and, a false left operand settles it, and the right is not
evaluated:
or is symmetric: a true left operand settles it as without evaluating the right,
and a false left operand yields the value of the right.
Negation evaluates its operand, which must be a boolean, and flips it:
A non-boolean operand to any of the three fits no rule and is stuck.
Concatenation, tuples, and lists
++ concatenates two strings or two lists, threading the store left to right. On lists it
appends; on strings it joins:
where on lists is when and
when . :: prepends one value to a list; its
right operand must be a list, and its left may be any value:
A tuple and a list evaluate their elements left to right and collect the results:
Tuple projection e.i and struct field access e.f evaluate the compound and select a
component; a projection out of range, or a field the value lacks, is stuck:
Conditionals
if evaluates its condition, which must be a boolean, and then evaluates the chosen branch
against the store the condition produced. The other branch is not evaluated.
Both branches must have the same type, which is a typing rule rather than an evaluation one. The
else -less form is admitted only when the then-branch has type (); it behaves as
if e1 then e2 else (), taking the value () when the condition is false.
Bindings and blocks
A block evaluates its elements top to bottom in an environment that grows as its bindings are met,
and its value is the final expression (or () when the last element is followed by ;). A let
evaluates its right-hand side and extends the environment over the rest of the block:
An element evaluated for its effect discards its value and threads its store into the rest of the block; the final expression is the block’s value:
A block whose last item is a let has the value (), like a block with no trailing expression;
naming that value binds x to (), and the checker catches a later use of x as anything
else.
A program is a set of definitions whose order does not matter: every top-level name is
visible in every definition, so any function, global, or method may refer to any other wherever it
is written. Top-level names are distinct — declaring one twice is rejected, and so is declaring a
name the prelude defines. Within one declaration, names are distinct too: a function’s or
lambda’s parameters, a declaration’s type parameters, a struct’s fields, and a trait’s methods
each name a thing once. A constructor is a top-level name like any other, since a constructor
expression or pattern refers to it by that name alone: it may not coincide with a type, struct,
trait, or another constructor, its own type included. A constructor name is not a value: it
appears only applied. Evaluation binds them all in one program
environment — each relation to a relation value that stands for it, each
fn to a closure over , each global let x = e to the value of its right-hand side — and
runs main under it — main takes no parameters, and its result is the program’s value, which
the bridger command discards: a program reports through print. A missing main, or one that
takes parameters or is not a function, is reported before the globals initialize. The
fn bindings make recursive, each closure capturing the very
environment that holds it:
Global initializers run in dependency order: a global depends on every global its initializer
can reach — directly, through the body of any function it calls, through any method of a name it
calls, or through the rule bodies if it evaluates a query — and each is initialized after the
globals it depends on, so let a = b + 1; let b = 10; binds a to 11 whichever is written
first. Globals are visited in alphabetical order of their names, each initialized after the
globals it depends on, so a program’s initialization order is fixed; beyond the dependencies it
carries no meaning. A cycle among initializers has no such order and is rejected
before anything runs.
The type, struct, trait, impl, relation, and rule definitions register in the contexts they introduce (structs, relations) and bind no runtime value.
References and the store
ref e allocates a fresh location holding the value of e; the location is the value. deref e
reads the location’s contents; e1 := e2 writes and yields (). Allocation picks a location
outside the current store’s domain:
Assignment evaluates the cell first, then the value, then updates the store — the cell, then the right-hand side, then the write:
Two names may hold the same location, so a write through one is visible through the other — the aliasing the store model makes possible. Dereferencing or assigning through a value that is not a location is stuck.
Loops
while evaluates its condition; on false it stops with (), and on true it runs the body
(whose value is discarded and is ()), then repeats against the resulting store:
By the convention, a return inside the body abandons the loop: the body premise
yields , so the whole while does, and the
remaining iterations do not run.
for x in e evaluates the iterable to a list, then walks it, binding x to each element. Once
the iterable is a value, two rules drive the walk (a list value stands in the in position as an
intermediate configuration):
A return in the body propagates out by the same convention, abandoning the rest of the
iteration. Iterating a non-list is stuck. The relational form for q e_b walks the solutions of a
query instead of a list; its rule is with the relations.
return
return e evaluates e and wraps the value as a outcome, which the conventions
above carry outward until a function boundary catches it:
That return is legal only inside a function body is checked rather than evaluated: a
that reaches the top level with no function boundary to catch it is a static error,
ruled out before evaluation.
Functions and application
A lambda evaluates, with no effect, to a closure that captures the current environment — the source of Bridger’s lexical scope, since the body will later run under the environment of the lambda’s definition rather than its call:
A named fn is reached through E-Var: hoisting bound it to its closure
in the program environment, so f evaluates to that closure and is applied by the same rule as a
lambda. Application evaluates the operator to a closure, then the arguments left to right, then
the body under the captured environment extended with the parameters; a return in the body is
caught here and becomes the call’s value. When the operator evaluates to a relation value
instead, the application is the hole-free query of
E-Query-True/False, so path(0, 3) is a query exactly when path resolves to the
relation —
a local of that name shadows it, as it would a function:
where : whether the
body finished normally or hit return, the application yields that value as a , so
the never escapes the function it belongs to. Applying a value that is not a
closure, or supplying the wrong number of arguments, is stuck.
A constructor application C(e_1, …, e_n) builds a tagged value rather than calling code —
the constructor C comes from a type definition, and evaluation collects its arguments:
Constructors and pattern matching
match evaluates its scrutinee, then tries the arms top to bottom, taking the first whose pattern
matches and whose guard holds. Pattern matching is the auxiliary relation
giving a binding (a finite map from the pattern’s variables to values) when v fits p,
and when it does not. Its clauses:
A constructor pattern matches only its own constructor (a different one gives ); a list
pattern matches a list of exactly elements, and
a list of at least , binding x to the remaining tail as a list.
A struct pattern names a subset of the fields, each at most once, and constrains only those
(unchecked, a pattern naming a field the struct lacks simply fails to match);
fields it omits may be anything. An or-pattern requires both sides to bind the same
variables at the same types. Patterns are linear — a variable appears at most once in one
pattern, a list pattern’s ...rest binder included — so the unions never collide.
Both are static checks
(T-Match). Any structural mismatch yields .
The match rule takes the first arm whose pattern matches and whose guard (if present)
evaluates to true. A guard is pure — the purity judgment is required
of every guard, as of every rule filter — so a failed guard leaves the store and the database as
the scrutinee left them:
An arm with no guard is the case . When no arm matches, no rule applies and
evaluation is stuck on the non-exhaustive match; the untyped evaluator reports it at run time,
and the checker’s exhaustiveness check (T-Match) rules it out for the programs
it accepts.
Structs, fields, and methods
A struct literal evaluates its fields in source order and builds a struct value whose fields are
held in declaration order, so two literals that name the same fields in different orders build
the same value — under ==, in a pattern, and when printed. A literal names every declared field
exactly once; a missing or unknown field is stuck.
Field access reads one back:
A method call e0.m(e1, …, en) evaluates the receiver and the arguments, then runs the method
whose impl matches the receiver’s runtime head type. Let be that type — a
struct name, a constructor’s type, or a built-in — and the
method the matching impl provides. The body runs with self bound to the receiver, catching
return as an application does; as with an application, the wrong number of arguments is
stuck. When e0 is a bare struct or type name, or one of the built-in names Int, Bool, and
String, the call is an associated call: the name is the head, the method is one declared
without self, and its body runs with only the arguments bound. A method with self called on
a type name, and one without called on a value, are both stuck:
Resolution is by the receiver’s runtime type, and coherence — at most one impl of a given
trait instance per head type; one definition of an inherent method name per head type, and of a
trait’s method per head type and instance, across all of its impl blocks; both checked
statically — makes that method the same one a static, declared-type dispatch would choose. A
method name belongs to one trait per head: a trait may provide it at several instances, but a
second trait’s method of that name, an inherent one, or a built-in conformance’s cannot join it
on the head, since a call through a bound is dispatched by head and name alone. An
impl therefore extends a head type: a declared type or struct, Int, Bool, String, (),
or the list head. A tuple, a function type, a
reference, and a bare type parameter have no head, so an impl may not name one. An impl may
extend a head at a particular instance, impl Len for Seq<Int>, or generically, impl<T> Len for [T]: a call on a receiver of known type is resolved to the impl covering it when the program is
checked, and the evaluator runs the method the checker chose, so the two dispatch strategies
agree on the code that
runs. A trait may have several impls for one head only at instances its type arguments tell
apart by their outermost form — a head, or a tuple, function, or reference type — From<[T]>
and From<Map<K, V>> for Seq<…> — so a generic instance
such as From<T> is the only one, and two impls of a trait without arguments are one too many. A
call through a bound T: Tr runs the head’s method of that name, so a bound met by a type whose
head implements Tr at several instances is rejected, whether the bound is a call’s or an
impl’s own; a ? chooses its from by the error type, and a direct call chooses by its
arguments or is ambiguous. (Unchecked, a run dispatches on the head alone and may take the first
impl declared, and e? returns an Err payload as it is, converting nothing; passing the
chosen impl along with a bounded call — dictionary passing — would lift the restriction and is
left as further reading.) The built-in conformances
count as impls for coherence: a program may declare neither impl Ord for Int nor an inherent
cmp on Int, and likewise for the other built-in
types and Len. An impl Tr for τ conforms to its trait: it provides every method Tr
declares, each with the trait’s signature under Self = τ and the trait’s type arguments, and no
method the trait lacks; a bounded call through Tr is therefore always answered. The built-in types carry the prelude’s trait methods
without an impl: with no impl for the receiver, cmp on Int, String, or Bool and
length on String or a list resolve to primitives. A call to a method neither an impl nor a
built-in conformance provides is stuck.
Relations
The Datalog fragment reads and extends a global relation database , a set of ground facts . Every rule threads ; the rules here are the ones that change it, where the rest pass it along untouched. The chapters before the relations material present the -free slice.
A program’s rule definitions form a fixed rule set . Together with the current facts
, they denote a set of derivable facts: the least set that contains
and is closed under every rule, where a rule
derives whenever a substitution
makes each generator atom a member and each pure filter evaluate
to true under — the program environment extended by the substitution. A rule
is a top-level definition and closes over like a fn body; the locals in scope where a
query is asked are not visible to it. is the least fixpoint of that
immediate-consequence operator; it exists and is unique because the operator is monotone (all
recursion is positive — negation is confined to pure filters), and it is finite because the
fragment is range-restricted and terminating, the subject of the chapter on rule bodies and
safety. Every query reads ; an implementation may compute
it once and reuse it until or changes, which a pure filter cannot tell.
add evaluates a relation atom’s arguments to values and inserts the ground fact; clear drops a
relation’s facts. Both change and yield (). An atom — in add or in a query — with
other than the relation’s number of columns is stuck:
A rule with no body is a base fact of , not of : clear leaves it. In add, a query,
solutions, and for, the bound arguments evaluate left to right, threading the store and
, once per form — a for evaluates them once, not per solution — and the query reads
as the arguments left it, so an add inside an argument is seen by that query. A
filter whose evaluation is stuck makes the query, and the program, stuck. Global initializers
may add, clear, and query, in initialization order; a global holding solutions q keeps the
list as it was then.
add is monotone by design — facts accumulate and are never retracted individually — which is
what lets a query denote a least fixpoint; clear resets a relation wholesale between uses. The
name R in each of these forms resolves in like any other: binds it to the
relation, a local of the same name shadows that binding, and a form whose name is bound to
anything but a relation is stuck. A query atom q carries holes ?x; a solution is a
substitution for its holes with . A hole named twice is one
variable, bound to one value at every position it names, and the query’s holes are its distinct
names in order of first appearance. An anonymous hole ? binds no name: a solution is an
assignment to the named holes, each distinct assignment counted once, so solutions r(?) is
[()] when some fact matches and for r(?) { … } runs its body once. solutions q returns the
list of solutions — the tuples of hole values, in that order; a single hole gives the value
itself, and a hole-free query gives (), so its list is [()] when the fact is derivable and
[] when it is not. A query in
boolean position is true exactly when it has a solution: for a hole-free query, when its fact
is derivable; with holes, when some filling of them is.
The canonical order is the total order on data values: integers numerically, false before
true, strings lexicographically, tuples and lists lexicographically with a shorter prefix first,
constructor values by constructor name and then by arguments, struct values by name and then by
fields in declaration order, and references by location. Solutions are ordered by their hole
values, first hole first, so the list depends on alone and never on the order the
facts were added or derived.
The relational for q e_b walks the solutions of q in that same canonical order, binding the
hole variables of each solution and running the body once per solution, threading the store and
through:
A return in the body abandons the remaining solutions by the convention. The body
neither reads a partially built relation nor writes one it is iterating — for iterates a fixed
— so the iteration order is the only thing the canonical ordering pins down, and it
pins it down precisely for reproducibility.
Typing rules
The rules below are declarative: they say which programs are well-typed. The bidirectional
checking and local monotype inference that decide well-typedness — filling in the types a let
or a lambda parameter omits — are built with the type checker in Part
VI; they compute what these rules specify. The typing context binds variables to types
and carries one extra slot, the enclosing function’s return type , which
return consults; at the top level, outside any function, that slot is empty.
The types are those of the grammar:
where names a declared type or struct (a bare N when it takes no arguments), and a type
variable T is such a name bound by an enclosing <T>. A written type is well-formed when
every name in it is a declared type or struct applied to as many type arguments as it declares,
or a type parameter of the enclosing declaration applied to none; any other name is a static
error. A type parameter may not take the name of a declared or built-in type. A bound T: C<τ, …> names a declared trait applied to as many type arguments as it declares, each well-formed.
Self stands for the type of the enclosing impl, in a method’s signature and in any
annotation inside its body alike; outside an impl it names no type.
Literals, variables, and operators
Arithmetic and negation are on Int; the ordering comparisons take Int operands and yield
Bool; and, or, not are on Bool. Writing and
:
== and != share ; a type admits equality when it is built from Int, Bool,
String, (), tuples, lists, references (compared by identity, whatever they hold), and
constructor and struct types whose declared components admit equality — with generics
instantiated, a type that reaches itself at the same instance taken to admit equality on that
path, and one whose instances grow without settling — R<T> reaching R<[T]> — taken not
to. A function
does not, nor does a relation, so neither does a tuple, list, variant, or struct with a function
anywhere inside it. Boolean connectives:
Concatenation joins two strings or two lists of the same element type:
Which rule applies is settled by the operands: a String operand chooses string concatenation,
a list operand the list rule. When neither operand chooses — two return forms, or two operands
of other types — the expression is judged as string concatenation, so two diverging operands
leave it undetermined and any other pair is a mismatch, blamed on the first operand that is not a
String, left to right.
Cons prepends an element to a list of that element’s type:
Compound data
Tuples and lists; a list literal’s elements share one type, and the empty list takes any element type (the inference pass fixes it from context):
Projection reads a tuple component by index; field access reads a struct field; a constructor and
a struct literal build a value of their declared type. Writing for a struct whose field f
has type , and for a constructor of type taking arguments
:
A struct literal supplies every field, each once; a type definition gives each constructor the
arrow above.
Control flow
if needs a boolean condition and branches of one type; the else -less form is that type at
(). match types each arm’s body against the scrutinee’s type through the pattern, and all arms
agree:
The judgment reads *pattern matches a value of type and binds the variables in *: a wildcard and a literal bind nothing (, at the literal’s type), a variable binds itself (), and a constructor, tuple, list, cons, or struct pattern types its parts against the corresponding component types and unions their bindings; the union is disjoint, since a pattern binding a variable twice is rejected, and a struct pattern naming a field twice with it. An or-pattern types both sides against , and both must bind the same variables at the same types, since the arm’s body runs whichever side matched.
The premise that the patterns exhaust holds when the unguarded arms together cover
every value of — equivalently, when a wildcard arm added after them could match nothing
they miss. The check looks inside patterns: Some(1) and None do not exhaust Option<Int>,
since Some(2) fits neither, and [] with [x] do not exhaust a list type. A type whose values
are finitely many at the head — Bool, (), a tuple, a list (empty or cons), a declared type
(its constructors), a struct — is exhausted by covering each head and, under it, the components;
Int and String have no such finite head, so only a wildcard or variable completes them. A
guarded arm contributes nothing, since its guard may fail.
Loops are () -typed; return checks at any type, provided its operand matches the enclosing
function’s return type:
gives return e an arbitrary type , so it slots into any context (as in
let x = if c then 1 else return 0), while its operand is pinned to . No
bottom type and no subtyping are needed; an empty slot — a return at the
top level — is the static error.
A form whose every path leaves — a block with a statement that leaves, an if or a match whose
branches or arms all leave — leaves too and, like return, takes any type. What follows a
leaving statement in a block, and the branches or body of a form whose condition, scrutinee, or
iterable leaves, never runs, so its types need not be determined and its bounds are not judged;
a value such code produces — a lambda whose body leaves — is judged as usual.
e? is typed as its desugaring. With e of type Option<τ> it has type τ and pins
to an Option; with e of type Result<τ, ε> it has type τ and pins
to a Result<τ', ε'> whose error type ε’ is ε itself or one an
impl From<ε> for ε' converts to, the impl chosen by the instantiated error types. One carrier
does not cross into the other. An empty slot is the static error, as for
return, and inside a lambda the slot is the lambda’s own result type. The conversion is a use
of impl, so it arrives with the objects milestone; until then the two error types must be one.
References, bindings, and blocks
A let extends the context over the rest of the block; a non-binding element may have any type
and is discarded; the block’s type is its final expression’s (or () when it ends in ;, unless
a statement leaves):
A let may carry an annotation let x : \ty_1 = e_1, which pins ; without one the checker
synthesizes it. A program’s fn definitions enter with their declared signatures —
alongside its globals and type declarations — before any body is checked, so a definition may refer
to any other regardless of order, itself included. A global enters at its annotation or,
without one, at the type synthesized for its initializer, checked once in initialization order;
it has that one type at every reference:
Each function body is checked against its declared return type, and both return e and the
fall-through value are checked against it. Declaration signatures stay mandatory even where local
inference is available.
Functions, methods, and generics
A lambda types its body under its parameters, its result type serving as for
a return inside it; application checks each argument against the corresponding parameter type:
A method call resolves m against the receiver’s type and checks the arguments against the
method’s parameter types, with Self standing for that type in the signature. An associated
call T.m(e1, …, en) on a bare struct, type, or built-in name T (Int, Bool, String)
types the same way with T, freshly instantiated, as and a method declared without
self; the mismatched pairings are errors wherever the method is found — an impl, a built-in
conformance, or the bound of a type parameter — and T must be a declared or built-in type,
since a type parameter has no type to dispatch on at run time:
Generics are declaration-level. A fn f<T: C>(…) -> … has, at its definition, a scheme
quantified over T with the single trait bound C. At a call site the checker instantiates T
with a concrete type by first-order matching, and requires the bound to be
satisfied: by an impl C for \sigma, by a built-in conformance (Int, String, and Bool are
Ord; String and every list type are Len), or, inside the declaration, by being a
type parameter that carries the bound C; the call is then typed by on the
instantiated signature. A bound’s type arguments are part of it — T: Conv<String> is met by an
impl Conv<String>, not by an impl Conv<Int> — and an impl with bounds of its own,
impl<T: Show> Show for [T], is an impl for [σ] only when σ meets them, at a call through it
and as evidence for a bound alike. Every expression’s type is fully determined once its
declaration is inferred: a type that nothing in the declaration pins down — an unannotated global
or local, a lambda parameter, a bare [] or None — is a static error (“type annotation
needed”), so a bound is always judged on a determined type, and no type crosses from one
declaration into another except through an annotation. The prelude’s min, max, minimum,
maximum, and len are bounded functions of this kind, written over Ord::cmp and
Len::length, so a user impl Ord or impl Len flows through them. There is no
let-generalization — a let never acquires a ∀ — so the only schemes are the ones written
on declarations. The matching and bound-checking are the mechanical heart of Part VI;
stated here, a generic call is well-typed exactly when some instantiation of its type parameters
makes the arguments check and satisfies every bound.
Two prelude traits declare no methods and are satisfied by a type’s shape alone: a type is Eq
when it admits equality and Print when it can be printed — data all the way down, a reference
being Eq whatever it holds and Print when its contents are. Neither can be implemented by an
impl, and an impl Ord for τ requires that τ admit equality — a generic impl under its own
bounds, so impl<T> Ord for W<T> needs T: Eq or T: Ord — so an Ord bound also grants
==. A bare type parameter admits equality only under an Eq or Ord bound and prints only
under Print. The prelude’s contains is bounded by Eq, and print, println, and
to_string by Print, so a function where data is required is a type error rather than a stuck
state.
Relations
A relation R : (τ₁, …, τₙ) declares R; each column type must admit equality, since facts are
distinguished by it, so no column holds a function at any depth. add checks its arguments —
expressions, never holes — against that signature and has type (), as does clear. A query
in boolean position is Bool, holes or not; solutions q collects the tuples filling the
distinct holes, in order of first appearance, at their positions’ declared types — a hole named
at two positions takes one type, so those columns must agree. The premise
is read after is consulted: a local R in makes the form an
error rather than a query, and a relation name on its own is not a value — it has no
T-Var type — so it may appear only as the head of a query
form or a call:
A single hole gives solutions q : [τ] rather than a one-tuple, and no hole gives [()]. The
relational for q e_b binds the hole variables at their declared types and checks the body at
():
Relation and rule declarations are checked by this same system: a rule’s head and generator atoms
check against relation signatures, and its filter conjuncts against Bool, so the Datalog
fragment reuses the core type checker rather than a second one. A rule is also
range-restricted: every variable its head uses is bound by a generator of the same rule, and
every variable a filter uses is bound by a generator or names a top-level definition — a global
constant or a function — which every definition may refer to; a generator-bound variable shadows
a top-level name.
The static errors by name
The reference’s TyError names the static errors of this section as follows, and a submission
reports the same names. The span is the expression or declaration the rule failed at: for
a mismatch, the sub-expression whose type is wrong, expected being the type the context
required of it and found the type it has; for a struct literal’s field, the field’s value;
for an impl, its header or the method’s signature; for a call of a non-function, the callee.
A bound that fails is blamed on the argument of the bounded parameter, or on the call when no
argument mentions the parameter; an undetermined type on the earliest expression still
undetermined once its declaration is inferred, an argument rather than the callee; is_param
marks a failure on a bare type parameter, one a bound on the declaration would grant in the
case of NoEquality, NotPrintable, and UnsatisfiedBound. ArityMismatch names an
anonymous callee “this function”.
| Static error | Variant and payload |
|---|---|
| a type where another was required | Mismatch { expected, found } |
| a name with no binding; a constructor, type, or trait no declaration introduces | UnboundVariable { name }, UnknownConstructor { name }, UnknownType { name }, UnknownTrait { name } |
| a type, struct, or trait name used as a constructor; a type or trait name used as a struct | NotAConstructor { name }, NotAStruct { name } |
| a field the struct lacks; a field read off a value that is not a struct; a struct literal missing a field or naming one twice | NoSuchField { field }, FieldOfNonStruct { field, found }, MissingField { field }, DuplicateField { field } |
| a projection past the end of a tuple; a projection off a value that is not a tuple | NoSuchComponent { index, arity }, NotATuple { index, found } |
a function, method, or constructor given the wrong number of arguments; a type or trait applied to the wrong number of type arguments; an add, clear, query, or for form of the wrong arity (a rule’s own atoms are the rule checks’ Arity) | ArityMismatch { callee, expected, found }, TypeArity { name, expected, found }, TraitArity { name, expected, found }, RelationArity { name, expected, found } |
| applying a value that is not a function | NotAFunction { found } |
==, contains, or an Eq bound on a type that admits no equality; printing, or a Print bound on, a type that cannot be printed | NoEquality { ty, is_param }, NotPrintable { ty, is_param } |
| a bound no impl, built-in conformance, or bound in scope satisfies | UnsatisfiedBound { trait_, ty, is_param } |
| a type reaching itself with no constructor between | InfiniteType |
| a type nothing in the declaration determines | Ambiguous |
| an expression nested past the checker’s bound | TooDeep { limit } |
main with parameters or type parameters | MainSignature |
a return or ? with no function to return from | ReturnOutsideFunction |
a fn or method with no -> whose body is not () | MissingResultType { name, found } |
an else-less if whose branch has a value | IfWithoutElse { found } |
a match whose arms leave a value uncovered | NonExhaustiveMatch |
a pattern binding a name twice; the alternatives of | binding differently | NonLinearPattern { name }, OrPatternBindings { name } |
? on a value that is neither Option nor Result; ? in a function or a lambda whose result cannot carry the failure | TryOnNonCarrier { found }, TryInNonCarrierFunction { ret }, TryInLambda { found } |
a method the receiver’s type lacks; a field called as a method; a method without self called on a value; a self method called on a type name; an associated call through a type parameter | NoSuchMethod { method }, FieldNotMethod { field, ty }, NotAMethod { method, ty, is_param }, NoReceiver { method }, TypeParamCall { name } |
| a method provided at several instances that the arguments do not choose between; a bound met at several instances | AmbiguousMethod { method }, AmbiguousInstance { trait_, ty } |
an impl whose target has no head — a type parameter, a tuple, a function, or a reference type; an impl of Eq or Print; an impl of a trait a built-in type satisfies by rule; two impls of one trait instance for one head; two overlapping instances; a method name defined twice for one head | ImplTarget { ty, reason }, BuiltinImpl { trait_, ty }, DuplicateImpl { trait_, ty }, OverlappingImpls { trait_, ty }, DuplicateMethod { method, ty } |
an impl of a trait lacking a method, with a method the trait does not declare, or with a signature other than the trait’s | MissingMethod { trait_, method }, NotInTrait { trait_, method }, SignatureMismatch { method, expected, found } |
| a type parameter named like a type | TypeParamShadows { name } |
| a cycle among global initializers | InitializationCycle { names } |
an add, clear, query, or for form naming a relation no declaration introduces (a rule’s atoms are the rule checks’ NotARelation); a query form whose name is bound to something other than a relation; a relation name used as a value | UndeclaredRelation { name }, NotARelation { name }, RelationNotAValue { name } |
| a relation column whose type admits no equality | RelationColumn { name, ty } |
a hole in an add atom | HoleInAdd |
Purity and effects
A Datalog rule’s filters must be pure — free of effects and a deterministic function of the variables the generators bind — so that a query denotes a least fixpoint that the evaluation schedule cannot perturb. The judgment
reads given the set of functions known pure, expression is pure. The effect-free forms are pure when their subexpressions are, so purity is a congruence over most of the grammar:
The same congruence covers negation, the comparisons, and/or/not, ++, tuples, lists,
projection, field access, constructor application, struct literals, if, and match (arms and
guards): each is pure when all of its subexpressions are. A call is pure when it targets a named
function already known pure and its arguments are pure:
A method call is pure on the same condition, taking every impl ’s version of the method to be
pure. A call whose operator is anything but a named function or a native primitive — a parameter,
a local, a global holding a closure, or a computed closure — has no instance and
so is impure, since the analysis cannot follow what it will run; this is what excludes
function-typed parameters from filters. A named function that appears as a value, handed to
fold say, counts as called, and the function handed to map, filter, or fold must be a named
function, a pure native, or a lambda, for the same reason a call must. e? is a match and a
return around a call to from, so it is pure when e is and every impl From ’s from is. The
prelude’s own functions are trusted: map, filter, and fold iterate their list and call the
function they are handed once per element, and the Bridger-written ones are written over fold
and match, so
each is total and pure whenever the functions handed to it are. The remaining forms are never
pure, each because it reads or writes state that makes its result depend on more than its
inputs:
ref/deref/:= touch the store; add/clear and every query read or write the relation
database ; the loops exist for effect. None has a purity rule, so any expression
containing one is impure, and so is a call to an input/output primitive (print, println, the
read_* family). return e is control, not an effect: a function that returns early is as pure
as one written with if, and a return directly in a rule body, where there is no function to
return from, is a static error rather than an impurity. A top-level constant read in a filter is a
variable and pure by ; a global holding a reference is readable only through
deref, which is not.
Which functions are pure is the least solution for the impure set. Let be the smallest set
of function names such that whenever f ’s body contains one of the never-pure forms
directly, or calls a function in , or calls through anything but a named function or a native.
Then is every function name not in . Because is a least fixpoint over the call
graph, it is exactly the functions that can reach an effect, and a rejected filter is reported
with the witness path — the chain of calls from the filter to the effect it reaches.
The rule this serves: in a rule H :- B₁ and … and Bₘ, every conjunct that is a filter (an
expression rather than a generator atom) must satisfy . The body
is its and-chain however it is bracketed — and is associative, and parentheses leave no
trace — so r(x) and (s(x) and t(x)) has three conjuncts, each read as a generator or a filter on
its own. Enforcing
it keeps rules monotone and their meaning independent of evaluation order, which is what makes the
least-fixpoint semantics of the relations section well defined. A relation applied
in call syntax is a query wherever it stands: as a whole conjunct it is a generator atom, and
inside any other expression (not r(x), r(x) or e) it makes that filter impure, so a rule body
reads the database only through its generators and there is no negation. The same judgment is
required of every match guard, , which is what lets
E-Match promise that a failed guard changes nothing. Neither
a filter nor a guard may reach a function that takes a function-typed parameter, which the
analysis cannot follow. A filter must also be total — no function it reaches may recurse — while
a guard, which runs once per arm, need not be. The judgment is syntactic, reading types only
from declarations: a method call whose receiver is self, a parameter, a literal, a type name,
a struct literal, or a field read off one of those — and whose name the body never rebinds —
reaches that head type’s method alone, so impl Ord for P { fn cmp(self, o: P) -> Ordering = self.a.cmp(o.a); } delegates to Int’s cmp rather than recursing; any other receiver reaches
every impl’s method of that name — so does a receiver whose declared type is a type
parameter, which has no head, the enclosing impl’s own method included; a generator-bound
variable has its column’s declared type. A name bound locally — a parameter, a let, a
pattern variable, a loop variable, a hole, a logic variable — is a local wherever it appears,
never the function or primitive of that name, so a call through it is a call through a local.
to_string is an effect: a reference prints as the value it holds, so to_string reads the
store, through whatever helper a filter or guard reaches it by. e? is impure when any impl From’s from is,
whether or not the conversion applies; e? standing in a rule body, with no function to return
from, is the static error it is at the top level, and return there does not parse. In a rule
atom the name before the parentheses is always the relation, even where a logic variable of
that name is bound.
The reference’s RuleError names the failures of this section as follows; the span is the
rule atom, the filter, the guard, or the offending generator argument: a column mismatch in a
generator, whose arguments are expressions, is blamed on the argument, while one in the head,
whose terms carry no span of their own, is blamed on the whole head atom. witness is the
chain of calls from a filter or guard to the effect it reaches, outermost first — a function by
its name, a local or parameter called through by its name, a method by its head and name as
H.m or by name alone as .m when the receiver is not settled, and last the impure primitive
or the relation queried in call form — and empty when the effect is a form of the filter’s own:
add, clear, solutions, for q, while, ref, deref, :=, or a lambda applied on
the spot.
| Failure | Variant and payload |
|---|---|
| a rule atom naming no declared relation | NotARelation { name } |
| a rule atom of the wrong arity | Arity { name, expected, found } |
| a generator argument that is neither a variable nor a literal | ArgumentNotATerm |
| a head or filter variable no generator binds | Unbound { var } |
| a filter that is not pure | Impure { witness } |
| a filter reaching a recursive function | Recursive { name } |
| a filter or guard reaching a function with a function-typed parameter | HigherOrder { name } |
a match guard that is not pure | ImpureGuard { witness } |
Determinism and evaluation order
For every , , , and there is at most one triple
with :
evaluation is a partial function, partial exactly where an expression is stuck. The order is
fixed. Each rule with several premises evaluates them left to right and threads the store (and
) between them, so a program’s effects — writes, add s, print s — occur in one
determined order. Allocation chooses some fresh location, so a store is determined up to the
renaming of locations, which no expression can observe. This determinism is what lets the
reference interpreter serve as an oracle and lets step counts reproduce.
Cost semantics
The challenge measures a program by a step count: the size of
its big-step evaluation derivation — one unit per rule applied, so the count is the number of nodes
in the tree for , not a new construct. Being big-step, the tree already reflects the work done: an
unevaluated and/or operand or an untaken branch adds nothing, each loop iteration is its own
subtree, and a short-circuiting return drops its right-hand siblings.
Two computations otherwise ride along as side conditions, which the bare tree would charge nothing; the count adds them back. Each step of the matching relation counts as a node, and computing the relation database costs one unit per fixed-point iteration of its immediate-consequence operator. Environment and store lookups stay atomic — one unit within the rule that performs them. The model is fixed with Part X, where each construct’s contribution is set.