Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

SymbolRanges 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 caseVariant and payload
a name with no bindingUnboundVariable { 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-locationTypeError { expected, found }
division or modulo by zeroDivByZero
a projection or field the value lacksNoSuchField { field }, the index of a projection as text
a struct literal missing a field, or naming one twiceMissingField { 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 includedNotAFunction { found }
a function, method, constructor, or relation atom given the wrong number of argumentsArityMismatch { expected, found }
a match no arm coversNonExhaustiveMatch
equality on a function or relationNotComparable { 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 thereNotOrdered { found }, found the argument
printing a function, relation, or type nameNotPrintable { found }
a method the receiver’s head lacksNoSuchMethod { method }
a self method called on a type name; a method without self called on a valueNoReceiver { method }, NotAMethod { method, ty }
a return no function catchesReturnOutsideFunction
a query, add, or clear inside a rule’s filterQueryInFilter
a query form whose name is bound to something other than a relationNotARelation { name }
a hole in an add atomHoleInAdd
a read_* whose input is not what it readsInputError { 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 functionMain { reason }
a cycle among global initializersInitializationCycle { names }, from the alphabetically first global on the cycle, each depending on the next
a call nested more than 100 000 frames deepStackOverflow { limit }
a range of more than 2^26 elementsRangeTooLarge { len, limit }
an expression nested more than 150 000 levels deep, rejected before the program runsTooDeep { 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 errorVariant and payload
a type where another was requiredMismatch { expected, found }
a name with no binding; a constructor, type, or trait no declaration introducesUnboundVariable { 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 structNotAConstructor { 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 twiceNoSuchField { 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 tupleNoSuchComponent { 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 functionNotAFunction { found }
==, contains, or an Eq bound on a type that admits no equality; printing, or a Print bound on, a type that cannot be printedNoEquality { ty, is_param }, NotPrintable { ty, is_param }
a bound no impl, built-in conformance, or bound in scope satisfiesUnsatisfiedBound { trait_, ty, is_param }
a type reaching itself with no constructor betweenInfiniteType
a type nothing in the declaration determinesAmbiguous
an expression nested past the checker’s boundTooDeep { limit }
main with parameters or type parametersMainSignature
a return or ? with no function to return fromReturnOutsideFunction
a fn or method with no -> whose body is not ()MissingResultType { name, found }
an else-less if whose branch has a valueIfWithoutElse { found }
a match whose arms leave a value uncoveredNonExhaustiveMatch
a pattern binding a name twice; the alternatives of | binding differentlyNonLinearPattern { name }, OrPatternBindings { name }
? on a value that is neither Option nor Result; ? in a function or a lambda whose result cannot carry the failureTryOnNonCarrier { 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 parameterNoSuchMethod { 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 instancesAmbiguousMethod { 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 headImplTarget { 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’sMissingMethod { trait_, method }, NotInTrait { trait_, method }, SignatureMismatch { method, expected, found }
a type parameter named like a typeTypeParamShadows { name }
a cycle among global initializersInitializationCycle { 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 valueUndeclaredRelation { name }, NotARelation { name }, RelationNotAValue { name }
a relation column whose type admits no equalityRelationColumn { name, ty }
a hole in an add atomHoleInAdd

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.

FailureVariant and payload
a rule atom naming no declared relationNotARelation { name }
a rule atom of the wrong arityArity { name, expected, found }
a generator argument that is neither a variable nor a literalArgumentNotATerm
a head or filter variable no generator bindsUnbound { var }
a filter that is not pureImpure { witness }
a filter reaching a recursive functionRecursive { name }
a filter or guard reaching a function with a function-typed parameterHigherOrder { name }
a match guard that is not pureImpureGuard { 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.