Skip to content

The Pebble Language Specification

Version 1.0 · the language that pebblec compiles throughout the course.

Pebble is a small, statically typed, C/Rust-flavored language. It is designed to exercise a compiler: every construct has a precise meaning, safety checks (overflow, bounds, division by zero) are part of the semantics, so the optimizer has real work to do, and nothing is left "implementation-defined" in a way that would make two correct compilers disagree.

This document is normative. When the reference implementation (solutions/) and this text disagree, one of them has a bug; report it.

Contents

  1. A first look
  2. Notation
  3. Lexical structure
  4. Syntax
  5. Types
  6. Names, scopes and declarations
  7. Statements
  8. Expressions
  9. Values, places and references
  10. Builtin functions and output
  11. Program execution
  12. Static checks
  13. Diagnostics
  14. Token and AST dump formats
  15. Lowering to PIR (reference scheme)
  16. Implementation notes per chapter
  17. More examples

1. A first look

struct Point { x: int, y: int }

fn fib(n: int) -> int {
    if n < 2 { return n; }
    return fib(n - 1) + fib(n - 2);
}

fn main() -> int {
    var xs: [int; 10];                   // zero-initialized
    for i in 0..10 { xs[i] = fib(i); }   // bounds-checked
    var total = 0;                       // type inferred: int
    for i in 0..10 { total = total + xs[i]; }
    print(total);                        // 88
    let p = Point { x: 3, y: 4 };
    print("p = (\(p.x), \(p.y))");       // p = (3, 4)
    return 0;
}

Output:

88
p = (3, 4)

In one paragraph: a program is a set of functions and structs. let introduces an immutable binding and var a mutable one; both infer their type from the initializer. int is a 64-bit signed integer, and + - * / % on it trap on overflow (&+ &- &* wrap instead). Arrays and structs are values that are copied on assignment. Functions can take &T and &mut T parameters to read or modify a caller's variable in place. print writes a value and a newline, and string literals can interpolate values with \(expr).

2. Notation

The grammar uses EBNF:

Notation Meaning
"fn" the terminal token with that spelling
IDENT, INT_LITERAL, ... a token class from §3
a b a followed by b
a \| b a or b
[ a ] zero or one a
{ a } zero or more a
( a ) grouping

"Trap" means the program stops with a runtime error, as described in §11.3. "Error" without qualification means a compile-time error; each one has a code (§13).

3. Lexical structure

A source file is a sequence of bytes, which should be UTF-8. Outside comments and string literals, only ASCII characters may appear.

3.1 Whitespace and comments

  • Whitespace is space (U+0020), horizontal tab, carriage return and line feed. It separates tokens and is otherwise ignored.
  • A line comment starts with // and extends to the end of the line.
  • A block comment starts with /* and ends with the matching */. Block comments nest: /* a /* b */ c */ is one comment. An unterminated block comment is error E0103.

Whitespace and comments are trivia. The lexer records how many trivia bytes precede each token (Token::LeadingTrivia), so the token stream is lossless.

3.2 Identifiers and keywords

IDENT = ( letter | "_" ) { letter | digit | "_" } ;
letter = "A".."Z" | "a".."z" ;   digit = "0".."9" ;

These identifiers are keywords and cannot be used as names:

as     bool   break  continue  else   extern  false  float  fn
for    if     in     int       let    mut     return str    struct
true   var    while

print and write are ordinary identifiers that name builtin functions (§10). Names that begin with pebble_ are reserved for the runtime: defining a function with such a name is error E0315.

3.3 Integer literals

INT_LITERAL = decimal | "0x" hexdigit { hexdigit | "_" } | "0b" bindigit { bindigit | "_" } ;
decimal     = digit { digit | "_" } ;
  • Underscores separate digits and are ignored: 1_000_000. A literal cannot start with _ (that is an identifier), and the prefixes 0x/0b must be followed by at least one digit.
  • Leading zeros are allowed in decimal literals: 007 is 7.
  • The lexer rejects literals that do not fit in 64 bits unsigned (E0105). The type checker rejects values above 2⁶³ − 1 (E0424), except that the literal 9223372036854775808 is accepted as the direct operand of unary minus, so that -9223372036854775808 (the minimum int) can be written.
  • An identifier character immediately after a literal is an error: 12ab is E0106.

3.4 Float literals

FLOAT_LITERAL = decimal "." digit { digit | "_" } [ exponent ]
              | decimal exponent ;
exponent      = ( "e" | "E" ) [ "+" | "-" ] digit { digit } ;
  • A float literal needs digits on both sides of the point: 1.0 and 0.5 are floats, while 1. and .5 are not. This keeps ranges unambiguous: 0..10 is 0, .., 10.
  • The value is the nearest binary64 number (round to nearest, ties to even). Literals that overflow become infinity.

3.5 String literals and interpolation

Source text outside comments must be valid UTF-8 (E0109). Comment bodies may contain arbitrary bytes, because the lexer skips them without decoding. Identifiers are ASCII-only in Pebble (E0110); see Ch 1 lesson 1.9 for the full UAX #31 story.

A string literal is delimited by " and may not contain an unescaped line break (E0108). Escape sequences:

Escape Meaning
\n \t \r line feed, tab, carriage return
\0 NUL; note that the runtime stops printing at a NUL
\\ \" \' backslash, double quote, single quote
\u{H…} the UTF-8 encoding of the Unicode scalar value written with 1–6 hex digits (surrogates D800–DFFF and values above 10FFFF are E0104)
\( starts an interpolation (below)

Any other escape is error E0104.

Interpolation. Inside a string literal, \( starts an embedded expression that ends at the matching ). The expression is lexed as ordinary tokens, and it may itself contain parentheses and string literals, including interpolated ones. The lexer therefore needs a mode stack (Chapter 1): each open interpolation remembers how many unmatched ( it has seen, and the ) that brings that count below zero closes the interpolation and resumes the string. The string is split into tokens as follows:

Source Tokens
"plain" string_literal
"a\(x)b" string_head "a\(, identifier x, string_tail )b"
"a\(x)b\(f(y))c" string_head "a\(, x, string_middle )b\(, f, (, y, ), string_tail )c"
"\(x)" string_head "\(, x, string_tail )"

The decoded text of each piece, without delimiters, is stored in Token::StringValue ("a", "b", "c", and "" for empty pieces). An interpolation that reaches the end of the file is E0107; a string that does is E0102.

3.6 Operators and punctuators

(  )  {  }  [  ]  ,  ;  :  .  ..  ->
+  -  *  /  %  &+  &-  &*  &  |  ^  <<  >>  &&  ||  !
=  ==  !=  <  <=  >  >=  +=  -=  *=  /=  %=

The lexer uses maximal munch: at each position it takes the longest token that matches. Some consequences:

  • a&&b is a, &&, b, and x&-1 is x, &-, 1 (wrapping subtraction).
  • f(&-x) is f, (, &-, x, ), which is a syntax error. Write f(&(-x)) if you mean it (and note that -x is not a place, so this is still an error).
  • a<<=b is a, <<, =, b, since there are no shift-assignment operators, and it is a syntax error.
  • 1..2 is 1, .., 2 (see §3.4).

A character that starts no token is E0101. The lexer reports it, produces an unknown token, and continues.

4. Syntax

4.1 Grammar

module        = { item } EOF ;
item          = function | extern_fn | struct_decl ;

function      = "fn" IDENT "(" [ params ] ")" [ "->" type ] block ;
extern_fn     = "extern" "fn" IDENT "(" [ params ] ")" [ "->" type ] ";" ;
params        = param { "," param } [ "," ] ;
param         = IDENT ":" type ;
struct_decl   = "struct" IDENT "{" [ field { "," field } [ "," ] ] "}" ;
field         = IDENT ":" type ;

type          = "int" | "float" | "bool" | "str" | IDENT
              | "[" type ";" INT_LITERAL "]"
              | "&" [ "mut" ] type ;

block         = "{" { statement } "}" ;
statement     = "let" IDENT [ ":" type ] "=" expr ";"
              | "var" IDENT ( ":" type [ "=" expr ] | "=" expr ) ";"
              | "if" cond block [ "else" ( if_stmt | block ) ]
              | "while" cond block
              | "for" IDENT "in" cond ".." cond block
              | "break" ";"
              | "continue" ";"
              | "return" [ expr ] ";"
              | block
              | expr [ assign_op expr ] ";" ;
if_stmt       = "if" cond block [ "else" ( if_stmt | block ) ] ;
assign_op     = "=" | "+=" | "-=" | "*=" | "/=" | "%=" ;
cond          = expr ;    (* with the restriction of §4.3 *)

expr          = or_expr ;
or_expr       = and_expr { "||" and_expr } ;
and_expr      = cmp_expr { "&&" cmp_expr } ;
cmp_expr      = bitor_expr [ cmp_op bitor_expr ] ;
cmp_op        = "==" | "!=" | "<" | "<=" | ">" | ">=" ;
bitor_expr    = bitxor_expr { "|" bitxor_expr } ;
bitxor_expr   = bitand_expr { "^" bitand_expr } ;
bitand_expr   = shift_expr { "&" shift_expr } ;
shift_expr    = add_expr { ( "<<" | ">>" ) add_expr } ;
add_expr      = mul_expr { ( "+" | "-" | "&+" | "&-" ) mul_expr } ;
mul_expr      = cast_expr { ( "*" | "/" | "%" | "&*" ) cast_expr } ;
cast_expr     = unary_expr { "as" type } ;
unary_expr    = ( "-" | "!" ) unary_expr | postfix_expr ;
postfix_expr  = primary { "[" expr "]" | "." IDENT } ;
primary       = INT_LITERAL | FLOAT_LITERAL | "true" | "false" | string
              | IDENT
              | IDENT "(" [ args ] ")"
              | IDENT "{" [ field_init { "," field_init } [ "," ] ] "}"
              | "(" expr ")"
              | "[" expr { "," expr } [ "," ] "]"
              | "[" expr ";" INT_LITERAL "]" ;
args          = arg { "," arg } [ "," ] ;
arg           = [ "&" [ "mut" ] ] expr ;
field_init    = IDENT ":" expr ;
string        = STRING_LITERAL
              | STRING_HEAD expr { STRING_MIDDLE expr } STRING_TAIL ;

4.2 Precedence and associativity

The grammar above encodes this table. Level 1 binds loosest.

Level Operators Associativity Operand types
1 \|\| left bool
2 && left bool
3 == != < <= > >= none (a < b < c is E0206) see §8.5
4 \| left int, bool
5 ^ left int, bool
6 & left int, bool
7 << >> left int
8 + - &+ &- left int, float (&± int only)
9 * / % &* left int, float (&* int only)
10 as left (x as int as float) see §8.7
11 prefix - ! right int/float, bool
12 postfix [] .field, calls left

As in Rust (and unlike C), bitwise operators bind tighter than comparisons, so x & 1 == 0 means (x & 1) == 0. Casts bind tighter than binary operators but looser than prefix operators, so -x as float is (-x) as float.

4.3 Disambiguation rules

  1. Struct literals in conditions. In the cond positions (after if, while, and around the .. of for), an IDENT directly followed by { is a name, and the { starts the block. So if p { ... } tests the variable p. Parenthesize to use a struct literal there: if (Point { x: 0, y: 0 }).x == 0 { ... }. A struct literal in a condition is E0208 when the parser can detect it.
  2. Calls. IDENT "(" is always a call. Functions are not values, so the callee is always a plain name.
  3. Assignment targets. The left side of = and op= must be a place expression: a name, a field access, an index, or a parenthesized place (§9.1). Anything else is E0207.
  4. References. & and &mut directly at the start of a call argument make a reference argument. Anywhere else, a prefix & is E0209. (Infix & is bitwise and.)
  5. Array types and repeats. In [T; N] and [e; N], N must be an integer literal (E0210), and N ≥ 1 (E0419).
  6. Statements vs expressions. A statement that starts with { is a block. if, while and for are statements, never expressions.

5. Types

Type Values Notes
int integers −2⁶³ … 2⁶³ − 1 two's complement, 64 bits
float IEEE 754 binary64 round to nearest even; no traps
bool true, false
str immutable strings only from literals; can be stored, passed and printed
() the single value () the result of functions without ->; cannot be written in source
[T; N] N values of type T N ≥ 1; T is not a reference
S (struct) one value per field nominal: two structs with the same fields are different types
&T, &mut T a reference to a place of type T parameter types only (E0420)
  • Type equality is structural for arrays ([int; 3] equals [int; 3]) and nominal for structs.
  • No implicit conversions, with one exception: an integer literal whose expected type is float (§8.1) denotes the float value of the literal. let x: float = 1; is valid, but let n = 1; let x: float = n; is not (E0401).
  • Structs may contain arrays and other structs, but not themselves, directly or through arrays (E0310). Field names are unique within a struct (E0302).
  • str supports no operators: no ==, no concatenation, no indexing. Strings can only be printed, stored in variables, fields and arrays, and passed to functions (including extern ones, as C const char *).
  • References (&T, &mut T) are second-class: they exist only as parameters, so they can never outlive the variable they refer to. §9.3 gives their semantics.

6. Names, scopes and declarations

6.1 Namespaces

Pebble has three namespaces:

Namespace Contains Looked up by
types struct names (and the type keywords) type, struct literals
functions fn, extern fn, and the builtins print, write calls
values parameters, let/var bindings, for variables names in expressions

A function and a variable may therefore share a name (let fib = fib(3);), although it is poor style.

6.2 Top-level declarations

  • All top-level declarations are visible everywhere in the module, including before their definition. Functions may be mutually recursive.
  • Function names are unique among functions (E0302), and struct names among structs (E0302). Defining print or write is E0313. Defining a function whose name starts with pebble_ is E0315.
  • Parameter names are unique within a function (E0302, reported at the later parameter with a note at the earlier one; the earlier parameter keeps the name). Every E0302 points at the name of the later definition and has the note "previous definition is here" at the earlier one. pebble_ names are reserved for extern fn too.
  • A cycle of structs that contain each other (directly or through arrays) is reported once, as E0310 at the struct of the cycle that comes first in the file, with a note "'A' contains 'B' here" at every field type that stays inside the cycle. A struct that merely contains a recursive struct is not reported.
  • fn name(params) -> T { body } defines a function. Without -> T, it returns ().
  • extern fn name(params) -> T; declares a C function with the same symbol name. Parameters and result must be int, float, bool or str (E0418). A missing result type means void.
  • struct Name { f1: T1, ..., fn: Tn } defines a struct. A struct may have no fields: struct Empty {} has the single value Empty {}.

6.3 Local scopes

  • Each block { ... } opens a scope. So do the bodies of functions and loops. A function's parameters are in the scope of its body.
  • let and var declare a binding that is visible from the next statement to the end of the enclosing block. The initializer does not see the new binding, so let x = x + 1; refers to an outer x.
  • A binding may shadow any earlier binding with the same name, in an outer scope or in the same block. Parameters may be shadowed as well.
  • The variable of for i in a..b is visible only in the loop body. It is immutable and has type int.
  • Using an undeclared name is E0301. Using a struct or function name where a value is expected is E0309, and calling something that is not a function is E0308.
  • Unresolved names are reported once per name, namespace and item (function or struct): the first failed lookup reports E0301/E0303/E0308/E0309, and later uses of the same name in the same namespace within the item are bound to an error symbol and not reported again (§13.2). An assignment to an immutable binding (E0307, §6.4) points at the root name of the target, with a note at its declaration. A missing main (E0311) has no source position; both E0307 and E0311 are reported by name resolution, E0312 by the type checker.

6.4 Mutability

Binding Assignable? &mut allowed?
var x yes, including fields and elements yes
let x, by-value parameter, for variable no (E0307) no (E0413)
p: &mut T parameter assignments go to the referenced place yes (reborrow)
p: &T parameter no (E0307) no (E0413)

Mutability belongs to the binding: a field or element is assignable exactly when its root binding is.

7. Statements

  • let x [: T] = e; evaluates e and binds it to x. With an annotation, e is checked against T. Without one, x takes the synthesized type of e (local inference, §8.1; Chapter 7 revisits it).
  • var x: T = e;, var x = e;: the same, but mutable.
  • var x: T; declares x and initializes it to the zero value of T: 0, 0.0, false, "", or recursively zero fields and elements. For scalar T (int, float, bool, str) the program must still assign x before reading it (definite assignment, §12.2). The zero value only guarantees that the behavior is defined. Aggregates are exempt, so var xs: [int; 10]; followed by xs[3] = 1; is valid.
  • e; evaluates e and discards the result.
  • place = e; evaluates the place's subexpressions (left to right, including bounds checks), then e, then stores. The types must be equal (E0401).
  • place op= e; with op in + - * / % evaluates the place's subexpressions once, then e, then reads the place, computes place op e with the checks of §8.3, and stores the result.
  • if c { A } else { B }: c must be bool (E0410). else if chains are nested ifs.
  • while c { B } tests c before each iteration.
  • for i in a..b { B } evaluates a and then b once, both of type int, and runs B with i = a, a+1, …, b−1. If a ≥ b, it runs zero times. It is equivalent to the following, where start, end and k are fresh names:
    { let start = a; let end = b; var k = start;
      while k < end { let i = k; B; k = k &+ 1; } }
    
    A continue in B jumps to the increment k = k &+ 1. The increment cannot overflow, because k < end ≤ 2⁶³ − 1.
  • break; and continue; apply to the innermost enclosing loop. Outside a loop they are E0304.
  • return; is valid in functions returning (). return e; checks e against the result type.

8. Expressions

8.1 Typing discipline

Type checking is bidirectional (Chapter 6). Every expression is either checked against an expected type (initializers with an annotation, arguments, return operands, right sides of assignments and compound assignments, struct-literal field values, array elements after the first, indices and for bounds) or has its type synthesized. Only integer literals behave differently in the two modes: checked against float, they denote a float.

An expected type is passed into subexpressions only by these forms; everywhere else the expression is synthesized and its type compared with the expected one (E0401 if they differ):

Form checked against T Checks
( e ) e against T
-e, when T is int or float e against T
a + b, a - b, a * b, a / b, a % b, when T is int or float a and b against T
[e1, …, en], when T is [U; m] every ei against U; then [U; n] must equal T
[e; N], when T is [U; m] e against U; then [U; N] must equal T

So let x: float = -(1 + 2) * 3; is valid (every literal denotes a float), while let y = 1.5; let z = y * 2; is E0402: the operands of a binary operator in synthesis mode are synthesized independently, and 2 synthesizes int. A let/var without annotation takes the synthesized type of its initializer (the local inference of §7). Chapter 7 revisits literals and let inference with constraint solving; this chapter's rules are the reference for the tests.

8.2 Evaluation order

Evaluation is strictly left to right, and all operands are evaluated before the operator applies. In detail:

  • binary operators: left operand, then right operand, except for the short-circuit && and ||;
  • calls: arguments left to right, then the call;
  • a[i]: a, then i, then the bounds check;
  • struct literals: field initializers in the order they are written, not in declaration order;
  • array literals: elements left to right;
  • assignment: see §7.

A trap stops evaluation immediately, so side effects that come after it never happen.

8.3 Integer arithmetic

Expression Result Traps (§11.3)
a + b, a - b, a * b the exact result overflow if it is not in the int range
a / b quotient truncated toward zero div_by_zero if b = 0; overflow if a = −2⁶³ and b = −1
a % b a − (a / b) · b (the sign of a) div_by_zero if b = 0; overflow if a = −2⁶³ and b = −1
-a negation overflow if a = −2⁶³
a &+ b, a &- b, a &* b the result modulo 2⁶⁴ (wrapping) never
a & b, a \| b, a ^ b bitwise never
a << b a · 2ᵇ modulo 2⁶⁴ shift if b < 0 or b > 63
a >> b arithmetic shift: ⌊a / 2ᵇ⌋ shift if b < 0 or b > 63

Both operands must be int; mixing int and float is E0402.

8.4 Float arithmetic

+ - * / % and unary - on floats follow IEEE 754 binary64 with round to nearest, ties to even. % is the C fmod (the result has the sign of the dividend). Nothing traps: 1.0 / 0.0 is infinity, and 0.0 / 0.0 is NaN.

8.5 Comparisons and logic

  • == and != accept two operands of the same type int, float or bool. An operand of another type (str, an array, a struct) is E0422; two scalars of different types are E0402, as for the other operators.
  • <, <=, > and >= accept two ints or two floats.
  • Float comparisons are IEEE: every comparison involving NaN is false, except !=, which is true. So a != b is always !(a == b).
  • && and || take bool operands and short-circuit: the right operand is evaluated only if the left one does not decide the result.
  • ! negates a bool. &, | and ^ on two bools are logical and, or and xor, without short-circuiting.

8.6 Calls

f(a1, ..., an) calls function f with n arguments (E0405 on a count mismatch). Each argument is checked against its parameter:

  • For a by-value parameter of type T, the argument is an expression of type T, and aggregates are copied.
  • For an &T parameter, the argument must be &place (E0411), where the place has type T (§9.3). For &mut T, it must be &mut place, and the place must be mutable (E0413).

A call to a function returning () can only be used as an expression statement (E0421).

8.7 Casts

e as T converts between scalar types:

From To int float bool
int identity nearest float E0404 (write e != 0)
float truncate toward zero, saturating: NaN → 0, too large → 2⁶³ − 1, too small → −2⁶³ identity E0404
bool false → 0, true → 1 E0404 identity

All other casts (involving str, arrays or structs) are E0404. Casts never trap.

8.8 Aggregates

  • [e1, ..., en] (n ≥ 1, E0423) has type [T; n], where every element has type T.
  • [e; N] evaluates e once and makes N copies.
  • S { f: e, ... } initializes every field of struct S exactly once, in any order: a missing field is E0408, a duplicate one E0409, and an unknown one E0407.
  • a[i] requires a: [T; N] and i: int, and traps (bounds) unless 0 ≤ i < N. Its type is T.
  • e.f requires a struct-typed e with field f (E0407).

8.9 Inference mode (Chapter 7)

Chapter 7 re-derives the two inferences of §5 and §8.1 — the type of an integer literal and the type of an unannotated let/var — as constraint solving, in a second checker, inferTypes (pebble/include/pebble/Sema/Infer.h, tested with the tool ch07-infer, which prints the table of §14.4). pebblec and every other chapter keep the rules of §8.1; this subsection only describes the Chapter 7 checker.

  • Numeric variables. Types may contain numeric type variables, which stand for int or float and nothing else. Every integer literal gets a fresh one; a let/var without annotation gets the type of its initializer, variables included (so let a = [1, 2]; has type [ν; 2]).
  • The checker is §8.1's, with the same checking forms and the same visiting order, except that wherever §8.1 requires two types to be equal (the mode switch, both operands of + - * / % < <= > >= == != & | ^, array elements, assignments, arguments, return), the two types are unified. Unification is solved eagerly, in the order the checker visits the program: a variable becomes int or float when it meets one, two variables become one. The operands of &+ &- &* << >>, indices and for bounds unify with int; an operand of & | ^ that is still a variable becomes int; conditions must be bool (a variable never is). Casts and print/write accept a variable without deciding it.
  • Defaulting. When a function body has been checked, every variable still undecided becomes int; only then are the types written into the AST.
  • Conservative extension. A program that §8.1 accepts is accepted with the same type table. More programs are accepted: let y = 1.5; let z = y * 2; (the literal is a float), let n = 1; let x: float = n; (n is a float), var s = 0; s += 0.5;, let d = [1, 2.5];.
  • E0416. Where a unification fails and one of the two types is that of a name x bound by an unannotated let/var whose type contained a variable when x was declared, and that variable was decided by an earlier constraint, and the unification would have succeeded had it still been undecided, the checker reports E0416 at that use of x instead of E0401/E0402, with the note "'x' is inferred to have type 'T' because of this" at the constraint that decided it: the checked expression (for an expected type) or the operator (for the operands of an operator). For an operator, the left operand is tried first. Every other failure keeps its §13.3 code.

9. Values, places and references

9.1 Places

A place is an expression that denotes storage: a variable or parameter name, place.field, place[index], or (place). Places can be assigned (if mutable), passed by reference, and read. Reading a place yields a copy of its current value.

9.2 Value semantics

Arrays and structs are values. Assignment, initialization, by-value argument passing and returning all copy the whole value. After var b = a; b[0] = 1;, the array a is unchanged. Implementations are free to avoid copies that no one can observe.

9.3 Reference parameters

A parameter p: &T or p: &mut T is an alias for the place passed at the call (compare Swift's inout, which Pebble's &mut resembles).

  • Inside the callee, p behaves as if it were that place: reading p, p.f or p[i] reads the caller's storage. Through &mut, p = v, p.f = v and p[i] = v write it.
  • &T parameters are read-only (E0307 on assignment).
  • A reference parameter can be passed on as &p, or as &mut p if it is &mut, or by value, which copies the referenced value.
  • Exclusivity. If an argument of a call is &mut place, the root variable of that place must not appear in any other argument of the same call (E0414). So swap(&mut a[0], &mut a[1]) is rejected, as in Rust. This static rule is deliberately simple: it is conservative, and it guarantees that a &mut parameter never aliases any other parameter.
  • Index expressions inside a reference argument are evaluated and bounds-checked at the call. The reference then denotes that element for the whole call.

10. Builtin functions and output

Call Effect
print(v) writes the text of v, then a line feed
write(v) writes the text of v

v must be an int, float, bool or str (E0417), or an interpolated string. The text of a value is:

  • int: decimal, with a leading - for negative values, e.g. -42.
  • bool: true or false.
  • str: the bytes of the string, up to the first NUL.
  • float: the shortest decimal that reads back as exactly the same double. It is positional for 10⁻⁷ ≤ |v| < 10²¹ and scientific otherwise, and a positional integral value gets a .0 suffix. The special values are inf, -inf and nan. Examples:
value text
1.0 1.0
0.1 + 0.2 0.30000000000000004
1e20 100000000000000000000.0
1e21 1e+21
1.5e-8 1.5e-8
−0.0 -0.0

An interpolated string "a\(e1)b\(e2)c" may only appear as the direct argument of print or write (E0415). It writes its pieces and the texts of the interpolated values in order. Each ei is evaluated left to right, and must be an int, float, bool or str.

Output goes to standard output and is flushed when the program exits or traps.

11. Program execution

11.1 Entry point

A program must define fn main() -> int (E0311 if it is missing, E0312 if it has a different signature). Execution calls main. When main returns n, the process exits with status n. The operating system keeps only the low 8 bits, so return 256; exits with status 0.

11.2 What is undefined

Nothing in a program accepted by the compiler has undefined behavior, except for exhausting resources. Deep recursion may crash with a stack overflow, and the course does not specify what happens then.

11.3 Traps

A failed runtime check stops the program. The runtime first flushes standard output, then writes one line to standard error, and then exits with status 101:

pebble: trap: <message> at <file>:<line>:<column>
Kind Message Raised by
overflow arithmetic overflow checked + - *, unary -, / and % of −2⁶³ by −1
div_by_zero division by zero / and % with an integer divisor of 0
bounds index out of bounds a[i] with i < 0 or i ≥ N
shift shift amount out of range << and >> with an amount outside 0..63

The location is the first character of the expression that trapped. For example, x + y reports the position of x.

12. Static checks

12.1 Type and name errors

These checks are described with the constructs they apply to, in §5–§10.

12.2 Definite assignment

A var of scalar type declared without an initializer must be definitely assigned before every use. That means an assignment x = e; must happen on every control-flow path from the declaration to the use (E0306). This is the classic forward "must" dataflow problem (Chapter 5 previews it, Chapter 14 generalizes it):

  • if without else assigns nothing definitely; if/else assigns what both branches assign.
  • Loop bodies may run zero times, so after a loop only what was definitely assigned before it counts. Inside the body, the analysis iterates to a fixed point.
  • Passing &x or &mut x counts as a use. Compound assignment x += e is a use.
  • Code after return, break or continue is unreachable, and every variable counts as assigned there.
  • E0306 is reported at most once per variable, at its first illegal read in source order, with the note "declared here" at the declaration.

12.3 Missing returns

In a function with a result type other than (), the end of the body must be unreachable (E0305). Reachability is decided structurally:

  • a return statement does not complete;
  • a block does not complete if any of its statements does not;
  • an if with an else does not complete if neither branch does;
  • while and for loops are always assumed to complete, even while true. Add a return after such a loop.
  • break and continue do not complete either (but the loop containing them does).

E0305 points at the function's name.

12.4 Unreachable code

A statement that directly follows return, break or continue in the same block gets warning W0314 (once per block).

13. Diagnostics

Every diagnostic has a severity, a stable code, a message and a source range. Notes attach extra locations. The complete list, with message templates, is pebble/include/pebble/Support/DiagnosticKinds.def.

Codes Stage Chapter
E00xx driver and input –
E01xx lexer 1
E02xx parser 4
E03xx, W03xx names, scopes, control flow 5
E04xx types and inference 6, 7
E05xx lowering 11

Tests match codes and positions, not message wording. The human-readable form is:

fib.pbl:3:12: error[E0301]: use of undeclared name 'm'
    3 |     return m + 1;
      |            ^

pebblec --diagnostics=json prints one JSON object per line instead (docs/architecture.md).

Error recovery expectations. The lexer and the parser recover and keep reporting. A test may expect several independent errors from one file. Sema does not report errors that are caused by an earlier error: an ill-typed expression gets the type <error>, which is compatible with everything.

13.1 Syntax errors and parser recovery

The parser (Chapter 4) reports every syntax error with a code from E0201–E0212 and then recovers, so that one run reports the independent errors of a file and no cascades. Tests check codes and positions (line and column of the range's start); the message wording is free.

Where an error points. At the first token that cannot continue the construct being parsed (the lookahead; eof at the end of the file), with these exceptions: E0206 points at the second comparison operator, E0207 at the first token of the assignment target, E0208 at the struct name, E0209 at the &.

Code When
E0201, E0202 a required token is missing: ;, ), ], }, {, :, =, in, .., a name after fn/let/., … Either code is accepted by the tests. A missing closer adds the note "to match this '('" at the opener.
E0203 an expression is required and the lookahead cannot start one
E0204 a type is required and the lookahead cannot start one
E0205 a top-level token that does not start an item
E0206 a second comparison operator on the same level: a < b < c, a == b != c (§4.2)
E0207 the left side of = or op= is not a place (§4.3 rule 3)
E0208 S { f: … where a condition is expected (§4.3 rule 1)
E0209 a prefix & that does not start a call argument (§4.3 rule 4)
E0210 the length of [T; N] or [e; N] is not an integer literal
E0211 the 21st syntax error: the parser reports this instead and stops
E0212 nesting deeper than 256 levels; the parser reports it once and stops

Recovery rules.

  1. One error per location. The parser never reports two syntax errors at the same source location. (After "expected expression" at ), the statement's "expected ';'" at the same ) is not reported.)
  2. Lexer errors do not cascade. unknown tokens (E0101) are skipped silently: the parser never sees them.
  3. Error cap. At most 20 syntax errors are reported. When a 21st would be reported, E0211 is reported instead at that location and parsing stops; parseModule returns the items parsed so far.
  4. Nesting limit. Programs whose brackets, blocks and prefix operators nest at most 100 deep must be accepted. Binary operators do not count (only the brackets around them do); each else if counts as one level, because the chain nests in the AST. An implementation may reject deeper nesting, but above 256 levels it must report E0212 (once, at the token where the limit is exceeded) and stop instead of overflowing the stack.
  5. Synchronizing sets (panic mode, Chapter 2 Lesson 2.7):
  6. Statement: after a statement is found to be malformed, skip tokens to just after the next ;, or to the next }, statement keyword (let var if while for return break continue) or item keyword (fn struct extern), counting { } so that these only stop the skip at brace depth 0; or to eof.
  7. Item: after a malformed top-level token or item header, skip to the next fn, struct or extern, or eof.
  8. Bracketed lists (arguments, parameters, fields, elements, indices, parentheses): skip to the matching closer and consume it; stop without consuming at a ; or an unmatched closer at depth 0.
  9. Blocks: a block ends at its }. If an item keyword or eof comes first, "expected '}'" is reported there (with the note at the {), and the item is parsed normally.
  10. Headers of if, while, for: after a missing piece, if a { follows before the end of the statement, the body is parsed normally.
  11. Error nodes. Where an expression was required and could not be parsed, the AST holds an ErrorExpr (dumped as (error)); a type that could not be parsed is a NamedTypeRepr named <error> (dumped as (type <error>)). A statement whose first token cannot start any statement or expression is dropped. Everything else that was recognized stays in the AST, so later functions of a file with errors are still complete.
  12. Accepted here, rejected later. [] parses as an empty array (Sema reports E0423), and [e; 0] as repeat 0 (E0419).

13.2 Name-resolution diagnostics, error symbols and suggestions

Name resolution (Chapter 5) reports E0301–E0315 with these positions and notes. Tests check the codes, the positions and the positions of the notes; the wording is free.

Code Points at Note
E0301, E0303 the undeclared name (for a call, the callee name; for a struct literal, the struct name) "'x' is declared here, after its use…" at a later local declaration of the name in the same function, if there is one; otherwise, if a suggestion exists, "did you mean 'y'?" at the same position
E0302 the later definition's name "previous definition is here" at the earlier one
E0304 the break/continue statement –
E0305 the function name –
E0306 the first illegal read of the variable "'x' declared here" at the declaration
E0307 the root name of the assignment target "'x' declared here"
E0308, E0309 the name used "'x' declared here" at the declaration in the other namespace (none for the builtins)
E0310 the first struct of the cycle "'A' contains 'B' here" at each field type inside the cycle
E0311 no position –
W0314 the first unreachable statement of the block –

Error symbols. After an unresolved name has been reported in an item (a function or a struct), later uses of the same name in the same namespace within that item are silently left unresolved (§6.3). A later phase does not report anything about such uses.

Suggestions ("did you mean"). For a name of length \(\ell \ge 3\) that fails to resolve, the candidates are the names visible at the use in the same namespace (for values: the bindings in scope; for calls: all functions and the builtins; for types: all structs and int, float, bool, str; for the struct name of a struct literal: all structs). The distance is the optimal string alignment distance (insertions, deletions, substitutions and swaps of two adjacent characters cost 1; Lesson 5.8). A candidate at the smallest distance \(d\) is suggested if it is the only one at that distance and \(3d \le \ell\) (Clang's rule).

13.3 Type-checking diagnostics

The type checker (Chapter 6) reports E0401–E0424 and E0312 with these positions and notes. Tests check the codes, the positions and the positions of the notes; the wording is free.

Code Points at Note
E0401 the start of the expression whose type is wrong; for return; in a function with a result, the return "expected 'T' because of this" at the type annotation that set the expected type: the let/var annotation, the parameter's type, the function's result type, or the struct field's type (none for assignments, compound assignments, indices, for bounds, and the elements of an array literal in synthesis mode, which are checked against the first element's type)
E0402, E0422 the binary operator (for op=, the assignment operator) –
E0403 the unary operator –
E0404 the start of the cast expression –
E0405 the callee name "'f' declared here" at the function's name (none for print/write)
E0406 the start of the indexed expression –
E0407 the field name (of e.f, or of the field initializer) –
E0408 the struct name of the literal, once per missing field in declaration order –
E0409 the later field initializer's name "'f' is first initialized here" at the earlier one
E0410 the start of the condition –
E0411 the start of the argument –
E0412 the start of the operand of &/&mut –
E0413 the root name of the place "'x' declared here"
E0414 the first occurrence of the root variable in another argument (arguments in order, then source order) "'x' is passed as '&mut' here" at the root name of the &mut argument; reported once per call and variable
E0415, E0423 the start of the literal –
E0417 the start of the argument, or of the interpolated expression –
E0418 the offending type in the extern signature (a reference type included) –
E0419 the length literal (of [T; 0] or [e; 0]) –
E0420 the reference type –
E0421 the start of the call –
E0424 the literal –
E0312 the name of main –

The error type. An ill-typed expression gets the type <error>, which is compatible with everything: no rule reports a mismatch, a bad operand or a bad argument when one of the types involved is <error>, so every mistake is reported once (§13). A written type that is invalid (E0419, E0420) is the error type too. A call of a function returning () in a value position is reported (E0421) and gives its context the error type; the call itself keeps the type ().

14. Token and AST dump formats

14.1 pebblec --emit=tokens

The output has one line per token, including the final eof:

<line>:<column> <kind> '<spelling>'

<kind> is the enumerator name from pebble/include/pebble/Lex/TokenKinds.def (kw_fn, identifier, string_head, ...). <spelling> is the exact source text, with \, ' and control characters escaped as \\, \', \n, \t, \r and \xHH. For example, fn f() {} produces:

1:1 kw_fn 'fn'
1:4 identifier 'f'
1:5 l_paren '('
1:6 r_paren ')'
1:8 l_brace '{'
1:9 r_brace '}'
1:10 eof ''

14.2 pebblec --emit=ast

This prints the parsed (not yet analyzed) module as an S-expression. The implementation is pebble/lib/AST/ASTDumper.cpp.

  • Each node prints as (tag attributes…) followed by its children.
  • A node without children fits on one line. Otherwise each child goes on its own line, indented two spaces more than its parent, and the closing ) follows the last child directly.
  • Optional attributes, off by default: @line:col right after the tag (ShowLocations), and {type} at the end of the attributes once Sema has run (ShowTypes). pebblec --emit=typed-ast prints the dump with ShowTypes after all three Sema phases succeeded (Chapter 6).
Node Tag and attributes Children
module module items
struct / field struct Name, field name fields; the field's type
function fn name or extern-fn name params, (returns T) if written, body
parameter param name its type
let / var let name / var name the type if written, the initializer if any
types type int, type Point, array-type N, ref-type / ref-type mut element / pointee
statements block, expr-stmt, assign = (or +=, …), if, while, for i, break, continue, return in source order; for has start, end, body
literals int 42, float 1.5, bool true, str "text" –
interpolation interp alternating (text "…") and expressions, starting and ending with text
names, operators name x, unary -, binary +, cast operands; cast has operand and type
calls, refs call f, ref / ref mut arguments / operand
access index, member f base and index / base
aggregates array, repeat N, struct-lit S, field-init f, paren, error elements / value / inits / value / inner

Integer literals print their magnitude (-5 is (unary - (int 5))). Floats use the output format of §10, and strings are quoted with the escapes of PIR (\n, \t, \r, \\, \", \xHH). Example:

fn main() -> int { let x = 1 + 2 * 3; return x; }
(module
  (fn main
    (returns
      (type int))
    (block
      (let x
        (binary +
          (int 1)
          (binary *
            (int 2)
            (int 3))))
      (return
        (name x)))))

14.3 Resolution dump (ch05-resolve)

ch05-resolve file.pbl (Chapter 5's test tool, tests/ch05/tools/) parses the file, runs name resolution and, if that succeeded, the flow checks, and prints one line per use of a name, in the order of the AST dump (§14.2), followed by all diagnostics sorted by position:

<line>:<col> value <name> -> <target>          a NameExpr
<line>:<col> call <name> -> <target>           a call's callee
<line>:<col> type <name> -> <target>           a named type (int, float, bool, str or a struct)
<line>:<col> struct-lit <name> -> <target>     a struct literal's struct
<line>:<col> break -> <loop>                   also `continue`
<line>:<col>: error[E0301]: <message>          diagnostics; notes follow as "<line>:<col>: note: …"

A <target> is <kind> <name>@<line>:<col> with the position of the declaration's name and kind fn, extern-fn, struct, param, let, var or for (a for loop variable); or builtin <name> for print, write and the builtin types; or <error> for a type that did not resolve; or <none> when nothing was recorded. A <loop> is while@<line>:<col> or for@<line>:<col> (the loop keyword) or <none>. A diagnostic without a position prints ?:?. The exit status is 1 if an error was reported. Example: for fn main() -> int { let x = 1; return x; } the dump is

1:14 type int -> builtin int
1:38 value x -> let x@1:24

14.4 Type table (ch06-typecheck)

ch06-typecheck file.pbl (Chapter 6's test tool, tests/ch06/tools/) parses the file, runs name resolution and, if that succeeded, the type checker and then (if that succeeded) the flow checks. If the type checker ran, it prints one line per declaration and expression, in the order of the AST dump (§14.2), followed by all diagnostics sorted by position (as in §14.3):

<line>:<col> <node> : <type>[ <category>]
  • Declarations, at the position of their name: struct S : S, field f : T, fn f : fn(T1, T2) -> R (extern-fn for extern functions; R is () when no result is written), param p : T (a reference parameter's type is &T or &mut T), let x : T, var x : T, for i : int.
  • Expressions, at the start of their range, by their dump tag and attribute (int 42, float 1.5, bool true, str "s", interp, name x, unary -, binary +, cast, call f, ref/ref mut, index, member f, array, repeat N, struct-lit S, paren, error), then the type, then the category of §9.1: value, place (rooted at a let, a by-value or & parameter or a for variable) or mut-place (rooted at a var or a &mut parameter). A name bound to a reference parameter has the pointee type and denotes the caller's place. An integer literal checked against float shows float. A reference argument &e/&mut e has type &T/&mut T; an interpolated string argument of print/write has type str.
  • A type that was never set prints <none>; the error type prints <error>.

Example: for fn main() -> int { let x: float = 1; return 0; } the table is

1:4 fn main : fn() -> int
1:24 let x : float
1:35 int 1 : float value
1:45 int 0 : int value

15. Lowering to PIR (reference scheme)

The Pebble front end compiles to PIR (docs/pir/pir-spec.md). Any lowering is correct if its PIR passes the verifier and behaves as this specification says, which pir-run checks. The reference lowering (solutions/pebble/lib/Lower/, Chapter 11) follows the scheme below. The PIR golden tests compare against its output, so follow it if you want those tests to pass verbatim.

Pebble PIR
int, float, bool, str i64, f64, bool, str
[T; N], struct S, &T, &mut T [T'; N], %S, &T', &mut T'
function f / extern fn g fn @f / extern fn @g with the same name
print(v) / write(v) call @pebble_print_int/float/bool/str(v), then call @pebble_print_newline() for print
a + b (int) o = saddo a, b · assert !o, overflow · r = add a, b
a / b, a % b (int) c = eq b, 0 · assert !c, div_by_zero · check a == MIN and b == -1 with eq/and · assert !…, overflow · sdiv a, b / srem a, b
a << b, a >> b c = ult b, 64 · assert c, shift · shl a, b / ashr a, b
-a (int) o = ssubo 0, a · assert !o, overflow · neg a
a[i] c = ult i, N · assert c, bounds · place a[i]
a && b, a \|\| b control flow (jumping code), never and/or
x as float / f as int sitofp / fptosi (saturating)
b as int zext b to i64
var x: T; x = <zero value of T> (for aggregates, [0; N] or a struct aggregate)
for i in a..b hidden locals for the end value and the counter
reference parameter p: &mut T local of type &mut T'; uses of p become (*_p) places
&mut place argument t = &mut place into a temporary, then pass t
  • Locations. Every statement and terminator carries @line:col, the start of the Pebble construct that produced it. The module's source is the path of the input file as given to the compiler.
  • Locals. Parameters come first, then locals in order of first need. Local debug names are the Pebble names; temporaries have no name.
  • Blocks. They are numbered in creation order, starting with bb0 for the entry.
  • Runtime functions. Declare only the pebble_print_* externs the module uses, in the order of first use.
  • Constants. A negated integer or float literal is a constant, with no overflow check (this is the only way to write -9223372036854775808, §3.3). An index that is a constant within 0..N needs no bounds check and becomes a constant index projection.
  • for loops. for i in a..b { B } is i = a; end = b; goto header, then header: c = slt i, end; br c, body, exit, then body: B; i = add i, 1; goto header. The increment cannot overflow because i < end. A continue jumps to a separate latch block holding the increment, created on first use. This is the worked example of pir-spec §18.
  • Output. print("…\(e1)…\(e2)…") evaluates every interpolated value first, left to right, and then prints the text pieces and values in order. String constants end at the first NUL (\0), because the runtime stops printing there and PIR strings cannot contain one.
  • Evaluation order. Operands are evaluated left to right (§8.2). An operand that is a place (read when it is used) is first copied to a temporary if a later operand of the same list contains a call with a &mut argument, which could write it. The destination of an assignment is computed before its right-hand side (§7); if the right-hand side contains such a call, the destination's index variables are copied first (Lesson 11.4, Algorithm 11.4.4).

16. Implementation notes per chapter

Chapter 1 (lexer). Implement Lexer::lex (pebble/include/pebble/Lex/Lexer.h). - Handle nested comments with a depth counter. - Handle interpolation with the InterpolationDepth stack: push 0 on \(, count ( and ) inside, and when a ) arrives while the top is 0, pop and continue lexing the string. - Decode escapes into StringValue and literal values into IntValue/FloatValue. Use llvm::StringRef::getAsDouble for floats: it is exact and locale-independent, and floating-point std::from_chars is unavailable on macOS before 26. Hand-rolled parsing gets rounding wrong. - Report errors and recover: skip the bad character, or end the string at the line break. - Test the lexer with pebblec --emit=tokens.

Chapter 4 (parser). - Use recursive descent for items and statements. Use precedence climbing or Pratt parsing driven by the table in §4.2, and remember that comparisons are non-associative. - Recover with the synchronizing sets of §13.1 (;, }, statement and item keywords), follow its rules (one error per location, the error cap, the nesting limit), and use ErrorExpr for expressions that failed to parse. - Implement §4.3 with a "no struct literal" flag while parsing conditions. - Test with pebblec --emit=ast (which prints the AST only when there are no errors) and ch04-parsedump (which prints the recovered AST and the diagnostics).

Chapter 5 (names and flow). resolveNames walks the module with a scope stack, in two passes: first collect all top-level declarations, then resolve bodies. checkFlow implements §12.2–§12.4. Definite assignment is a forward dataflow over the AST. Remember that loops need a fixed point. Test with ch05-resolve (§14.3), which prints what resolveNames recorded and every diagnostic; pebblec runs the type checker between the two phases.

Chapters 6 and 7 (types). - typeCheck implements bidirectional checking (§8.1), including the literal rule of §5 and the local inference of let/var from initializers (their synthesized type). - Set the type of every expression, including <error> after an error, and of every declaration. - Resolve MemberExpr fields (callees are resolved by name resolution), and enforce the reference rules (§9.3). - Report the diagnostics of §13.3. Test with ch06-typecheck (§14.4) and pebblec --emit=typed-ast. - Chapter 7 re-derives literal typing and let inference as constraint solving (unification) in a second checker, inferTypes, which extends them (for example, a literal operand next to a float operand) and reports E0416; §8.9 defines it. Test with ch07-infer.

Chapter 11 (lowering and code generation). - lowerToPIR implements §15. - The C++ Pebble front end is then complete: pebblec fib.pbl && ./fib. - The back half, PIR → LLVM IR, is pebble/include/pebble/CodeGen/PIRToLLVM.h.

Chapter 24 (another front end). A Rust front end reimplements §3–§15 and prints PIR (docs/architecture.md). It passes the same conformance suite (tests/conformance/).

17. More examples

References and exclusivity.

fn swap(a: &mut int, b: &mut int) {
    let t = a;      // reads the caller's variable
    a = b;          // writes it
    b = t;
}

fn sum(xs: &[int; 4]) -> int {
    var s = 0;
    for i in 0..4 { s += xs[i]; }
    return s;
}

fn main() -> int {
    var x = 1;
    var y = 2;
    swap(&mut x, &mut y);
    print("\(x) \(y)");                // 2 1
    var v = [1, 2, 3, 4];
    print(sum(&v));                    // 10
    // swap(&mut v[0], &mut v[1]);     // E0414: v appears twice
    return 0;
}

Traps.

fn main() -> int {
    let big = 9223372036854775807;
    print(big &+ 1);                   // -9223372036854775808 (wrapping)
    print(big + 1);                    // trap
    return 0;
}

Output (stdout, then stderr), exit status 101:

-9223372036854775808
pebble: trap: arithmetic overflow at big.pbl:4:11

Floats and casts.

fn main() -> int {
    let third = 1.0 / 3.0;
    print(third);                      // 0.3333333333333333
    print(third * 3.0 == 1.0);         // true
    print(2.9 as int);                 // 2
    print(-2.9 as int);                // -2
    print(1e300 as int);               // 9223372036854775807
    let half: float = 1;               // an int literal checked as float
    print(half / 2.0);                 // 0.5
    print(true as int + 1);            // 2
    return 0;
}

Structs, arrays and value semantics.

struct Vec2 { x: float, y: float }
struct Body { pos: Vec2, vel: Vec2 }

fn step(b: &mut Body, dt: float) {
    b.pos.x += b.vel.x * dt;
    b.pos.y += b.vel.y * dt;
}

fn main() -> int {
    var bodies = [Body { pos: Vec2 { x: 0.0, y: 0.0 }, vel: Vec2 { x: 1.0, y: 2.0 } }; 3];
    let snapshot = bodies;             // a copy
    for i in 0..3 { step(&mut bodies[i], 0.5); }
    print("\(bodies[2].pos.y) \(snapshot[2].pos.y)");   // 1.0 0.0
    return 0;
}

Definite assignment.

fn sign(n: int) -> int {
    var s: int;
    if n < 0 { s = -1; } else if n == 0 { s = 0; } else { s = 1; }
    return s;                          // OK: assigned on every path
}

fn bad(c: bool) -> int {
    var t: int;
    if c { t = 1; }
    return t;                          // E0306: not assigned when !c
}