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
- A first look
- Notation
- Lexical structure
- Syntax
- Types
- Names, scopes and declarations
- Statements
- Expressions
- Values, places and references
- Builtin functions and output
- Program execution
- Static checks
- Diagnostics
- Token and AST dump formats
- Lowering to PIR (reference scheme)
- Implementation notes per chapter
- 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:
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 prefixes0x/0bmust be followed by at least one digit. - Leading zeros are allowed in decimal literals:
007is 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
9223372036854775808is accepted as the direct operand of unary minus, so that-9223372036854775808(the minimumint) can be written. - An identifier character immediately after a literal is an error:
12abis 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.0and0.5are floats, while1.and.5are not. This keeps ranges unambiguous:0..10is0,..,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&&bisa,&&,b, andx&-1isx,&-,1(wrapping subtraction).f(&-x)isf,(,&-,x,), which is a syntax error. Writef(&(-x))if you mean it (and note that-xis not a place, so this is still an error).a<<=bisa,<<,=,b, since there are no shift-assignment operators, and it is a syntax error.1..2is1,..,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¶
- Struct literals in conditions. In the
condpositions (afterif,while, and around the..offor), anIDENTdirectly followed by{is a name, and the{starts the block. Soif p { ... }tests the variablep. 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. - Calls.
IDENT "("is always a call. Functions are not values, so the callee is always a plain name. - Assignment targets. The left side of
=andop=must be a place expression: a name, a field access, an index, or a parenthesized place (§9.1). Anything else is E0207. - References.
&and&mutdirectly at the start of a call argument make a reference argument. Anywhere else, a prefix&is E0209. (Infix&is bitwise and.) - Array types and repeats. In
[T; N]and[e; N],Nmust be an integer literal (E0210), and N ≥ 1 (E0419). - Statements vs expressions. A statement that starts with
{is a block.if,whileandforare 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, butlet 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).
strsupports no operators: no==, no concatenation, no indexing. Strings can only be printed, stored in variables, fields and arrays, and passed to functions (includingexternones, as Cconst 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
printorwriteis E0313. Defining a function whose name starts withpebble_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 forextern fntoo. - 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 beint,float,boolorstr(E0418). A missing result type meansvoid.struct Name { f1: T1, ..., fn: Tn }defines a struct. A struct may have no fields:struct Empty {}has the single valueEmpty {}.
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. letandvardeclare a binding that is visible from the next statement to the end of the enclosing block. The initializer does not see the new binding, solet x = x + 1;refers to an outerx.- 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..bis visible only in the loop body. It is immutable and has typeint. - 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;evaluateseand binds it tox. With an annotation,eis checked againstT. Without one,xtakes the synthesized type ofe(local inference, §8.1; Chapter 7 revisits it).var x: T = e;,var x = e;: the same, but mutable.var x: T;declaresxand initializes it to the zero value ofT:0,0.0,false,"", or recursively zero fields and elements. For scalarT(int, float, bool, str) the program must still assignxbefore reading it (definite assignment, §12.2). The zero value only guarantees that the behavior is defined. Aggregates are exempt, sovar xs: [int; 10];followed byxs[3] = 1;is valid.e;evaluateseand discards the result.place = e;evaluates the place's subexpressions (left to right, including bounds checks), thene, then stores. The types must be equal (E0401).place op= e;withopin+ - * / %evaluates the place's subexpressions once, thene, then reads the place, computesplace op ewith the checks of §8.3, and stores the result.if c { A } else { B }:cmust bebool(E0410).else ifchains are nested ifs.while c { B }testscbefore each iteration.for i in a..b { B }evaluatesaand thenbonce, both of typeint, and runsBwithi= a, a+1, …, b−1. If a ≥ b, it runs zero times. It is equivalent to the following, wherestart,endandkare fresh names: AcontinueinBjumps to the incrementk = k &+ 1. The increment cannot overflow, because k < end ≤ 2⁶³ − 1.break;andcontinue;apply to the innermost enclosing loop. Outside a loop they are E0304.return;is valid in functions returning().return e;checkseagainst 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, theni, 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 typeint,floatorbool. 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 twoints or twofloats.- Float comparisons are IEEE: every comparison involving NaN is false, except
!=, which is true. Soa != bis always!(a == b). &&and||takebooloperands and short-circuit: the right operand is evaluated only if the left one does not decide the result.!negates abool.&,|and^on twobools 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 typeT, and aggregates are copied. - For an
&Tparameter, the argument must be&place(E0411), where the place has typeT(§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]evaluateseonce and makes N copies.S { f: e, ... }initializes every field of structSexactly once, in any order: a missing field is E0408, a duplicate one E0409, and an unknown one E0407.a[i]requiresa: [T; N]andi: int, and traps (bounds) unless 0 ≤ i < N. Its type isT.e.frequires a struct-typedewith fieldf(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
intorfloatand nothing else. Every integer literal gets a fresh one; alet/varwithout annotation gets the type of its initializer, variables included (solet 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 becomesintorfloatwhen it meets one, two variables become one. The operands of&+ &- &* << >>, indices andforbounds unify withint; an operand of& | ^that is still a variable becomesint; conditions must bebool(a variable never is). Casts andprint/writeaccept 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 afloat),let n = 1; let x: float = n;(nis afloat),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
xbound by an unannotatedlet/varwhose type contained a variable whenxwas 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 ofxinstead 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,
pbehaves as if it were that place: readingp,p.forp[i]reads the caller's storage. Through&mut,p = v,p.f = vandp[i] = vwrite it. &Tparameters are read-only (E0307 on assignment).- A reference parameter can be passed on as
&p, or as&mut pif 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). Soswap(&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&mutparameter 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:trueorfalse.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.0suffix. The special values areinf,-infandnan. 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:
| 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):
ifwithoutelseassigns nothing definitely;if/elseassigns 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
&xor&mut xcounts as a use. Compound assignmentx += eis a use. - Code after
return,breakorcontinueis 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
returnstatement does not complete; - a block does not complete if any of its statements does not;
- an
ifwith anelsedoes not complete if neither branch does; whileandforloops are always assumed to complete, evenwhile true. Add areturnafter such a loop.breakandcontinuedo 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:
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.
- 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.) - Lexer errors do not cascade.
unknowntokens (E0101) are skipped silently: the parser never sees them. - 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;
parseModulereturns the items parsed so far. - 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 ifcounts 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. - Synchronizing sets (panic mode, Chapter 2 Lesson 2.7):
- 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 toeof. - Item: after a malformed top-level token or item header, skip to the next
fn,structorextern, oreof. - 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. - Blocks: a block ends at its
}. If an item keyword oreofcomes first, "expected '}'" is reported there (with the note at the{), and the item is parsed normally. - Headers of
if,while,for: after a missing piece, if a{follows before the end of the statement, the body is parsed normally. - 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 aNamedTypeReprnamed<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. - Accepted here, rejected later.
[]parses as an emptyarray(Sema reports E0423), and[e; 0]asrepeat 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:
<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:colright after the tag (ShowLocations), and{type}at the end of the attributes once Sema has run (ShowTypes).pebblec --emit=typed-astprints the dump withShowTypesafter 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:
(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
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):
- Declarations, at the position of their name:
struct S : S,field f : T,fn f : fn(T1, T2) -> R(extern-fnfor extern functions;Ris()when no result is written),param p : T(a reference parameter's type is&Tor&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 alet, a by-value or¶meter or aforvariable) ormut-place(rooted at avaror a&mutparameter). A name bound to a reference parameter has the pointee type and denotes the caller's place. An integer literal checked againstfloatshowsfloat. A reference argument&e/&mut ehas type&T/&mut T; an interpolated string argument ofprint/writehas typestr. - 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
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'ssourceis 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
bb0for 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 within0..Nneeds no bounds check and becomes a constant index projection. forloops.for i in a..b { B }isi = a; end = b; goto header, thenheader: c = slt i, end; br c, body, exit, thenbody: B; i = add i, 1; goto header. The increment cannot overflow becausei < end. Acontinuejumps 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
&mutargument, 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:
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.