Phoenix / The Phoenix manual
The Phoenix manual
The notation, explained in the order you would learn it. This page argues; reference.md looks things up, cheatsheet.md is one page, and the two tutorials — a picture language and an assembler — build something from nothing.
Contents
- What you are writing
- Characters, then tokens
- Tokens, then a tree
- What a production builds
- Passes
- Order, and why some things take two walks
- The expression language
- Rewrites
- Modules
- Two things the reader does
- Writing the compiler out
- Working on a description
- Idioms and pitfalls
1. What you are writing
A .phx file is a description: it says how a language is written and what
each construct means. Phoenix turns a description into a compiler.
pascal.phx ──phx──▶ pascal.c ──cc──▶ cpas
│
myprogram.pas ────────────────────────────┴──▶ myprogram.c ──cc──▶ a program
It is not a program — nothing runs it. It is not a grammar either, not since it
grew passes. When the file is being talked about as a unit — one of several,
importable, sitting in a directory — it is a module. %import brings in a
module; a module with a %start can stand on its own, and one without is only
ever imported.
The claim the whole tool rests on is that a compiler is a grammar plus a sequence of tree walks, and that both halves can be written down instead of programmed. What lex and yacc give you is the front of that and a syntax tree; after it you still hand-write forward-declaration collection, type checking, optimisation and code generation — four large modules that are mostly traversal boilerplate and a little content, written again for every language.
A description has, in this order:
(* comments look like this, or a ; to the end of the line *)
%import "lexical.phx" . (* modules, merged into one grammar *)
...lexical rules... (* characters into tokens *)
%skip space comment .
%syntax . (* the seam *)
%start program .
...syntactic rules... (* tokens into a tree, with -> actions *)
%pass name ...clauses... (* walks over the tree *)
%rewrite name strategy ...
%driver name = a, b -> attr . (* what to run, in what order *)
%driver name = a, b until x, c -> attr . (* b again, until x settles *)
Nothing else. Every mechanism below fits one of those lines.
2. Characters, then tokens
A file starts in the lexical half — %tokens is the default and so is optional.
%fragment letter digit .
letter = "a" .. "z" | "A" .. "Z" | "_" .
digit = "0" .. "9" .
name = letter { letter | digit } .
integer = digit { digit } .
space = ( " " | "\t" | "\n" ) { " " | "\t" | "\n" } .
comment = "{" { ! "}" } "}" .
%skip space comment .
The notation is Wirth’s, which nearly every published grammar is written in and which describes itself. Phoenix makes three additions and no more, because a lexer cannot be written without them:
"a" .. "z" |
a range of one character |
! factor |
one character, provided that does not match here |
"\n", "\t" |
the escapes, inside a literal |
All three are lexical only. Using one where there are only tokens is an error with a line number, because it asks about characters where there are none.
! factor is how a comment is written: "{" { ! "}" } "}". There is no “any
character” — ! "" never matches, since an empty literal always matches and so
“one character provided nothing matches here” always fails.
The scanner takes the longest match over every token rule, ties going to the
rule declared first. That is what every lexer does, and it is what makes
"<" | "<=" a question nobody has to answer between two rules. It does not
settle it within one: symbol = ":=" | ":" is ordered choice, and the longer
alternative has to come first. Phoenix warns when it does not.
%fragment, which has to be learned
letter and digit are what the token rules are written out of. They are not
tokens. Nothing about their shape says so, and a longest-match scanner with
ties broken by declaration order will hand back a whole file as a stream of
letter, because both rules match x and letter is declared first.
warning: 'digit' is used only by other lexical rules and is not a %fragment
-- it will be returned as a token of its own
phx: add `%fragment digit` if it is a helper rather than a token
%skip
Which token kinds are produced and then thrown away. It is deliberately not
in lib/lexical.phx: whether whitespace is discarded is the language’s own
business, since a layout-sensitive language keeps it.
3. Tokens, then a tree
%syntax names the seam, and everything after it is matched over tokens.
The seam is declared rather than guessed. A rule is not lexical because of
anything about its shape — identifier and expression look alike — and a tool
that guesses wrong reports a correct file as broken, which is the worst thing
this one could do.
%syntax .
%start program .
program = { statement } .
statement = "print" expression ";" | "let" name ":=" expression ";" .
%start is the goal rule, and the first syntactic rule if unsaid.
Reserved words are worked out rather than declared. Every word-shaped
literal in the syntactic half is one, so print cannot arrive as an identifier
and no grammar had to say so. The other side of that bargain is that importing a
grammar module reserves its words too, which is why
lib/expression.phx says so at the top of the file and
why div and mod are deliberately not in it.
The matcher
The syntactic half is ordered choice with local backtracking — a PEG, not a
general parser. a | b tries a, and tries b only if a failed; if a
succeeds and the rule around it fails later, the choice is not revisited.
That is a real restriction and it is said here rather than buried, because the failure it produces is a syntax error on a file that is correct. It costs nothing on an LL(1) grammar, which is what Wirth’s Pascal is and what almost every published grammar is. It costs something on a grammar with an alternative that is a proper prefix of a later one — put the specific one first.
Three languages later this has still not cost anything, including awk, whose grammar is famously not LL(1).
When a PEG fails at the top it has usually backtracked a long way from the real mistake, so the position Phoenix reports is the one the match got furthest, not the one it stopped at:
missing-semicolon.pas:13:3: error: expected else, ; or end, and found "n"
n := n + 1;
^
4. What a production builds
Without an action, a rule answers what it matched, and the tree is the concrete
one — every bracket and semicolon in it. -> says what an alternative means:
program = { statement } -> Program(body: $1) .
statement = "print" e:expression ";" -> Print(value: $e)
| "let" n:name ":=" e:expression ";" -> Let(name: $n, value: $e) .
factor = number -> Number(value: $1)
| name -> Variable(name: $1)
| "(" expression ")" -> $2 .
$n is the n’th factor, counting from one and counting everything — the
literals too. A factor can be labelled instead — e:expression, read back as
$e — which survives the alternative being edited.
Both are checked when the grammar is read, because $3 drifting after a
factor is inserted before it is yacc’s most famous silent failure: the grammar
still builds and the tree is quietly wrong.
$$, and why a fold needs saying only once
$$ is what has been built so far. An action mentioning it replaces the
value before it rather than following it — which is a left fold, and is how the
flat repetition every grammar writes for a binary operator becomes the tree it
means:
expression = term { ( "+" | "-" ) term -> Binary(op: $1, left: $$, right: $2) } .
term = factor { ( "*" | "/" ) factor -> Binary(op: $1, left: $$, right: $2) } .
Precedence comes from the grammar, associativity from the fold, and
width * height - 1 comes out as Binary(-, Binary(*, width, height), 1).
Interior nodes are never built
One rule decides what a production answers, and it has no exceptions:
A body that produced one value answers that value. A body that produced any other number answers a node named after the rule, holding them.
So a chain of rules that each pass one thing along — expression to term to
factor to a number — collapses to the number, and nobody had to say so. The
useless interior nodes that every hand-written tree-builder exists to strip are
not stripped; they are never built.
The vocabulary
--nodes prints what a grammar can build and the fields each node carries,
which is the surface a pass will be written against:
$ bin/phx --nodes languages/calc/calc.phx
Program(body)
Print(value)
Let(name, value)
Binary(op, left, right)
Number(value)
Variable(name)
A type built with two different field lists is a warning, since a pass keyed on it would have to handle both.
Where a node came from
Every node carries a position, and $pos reads it. It answers a node:
Position(line, column, file, endline, endcolumn)
A number would have been smaller and would have meant nothing to a description —
every use a description has for a position is a line or the name of a file. A
node makes reading part of one an ordinary field read, so the notation needs no
new syntax and no library function, and a fifth thing later is a field rather
than a second reserved name. endline and endcolumn arrived exactly that way.
A node is a stretch of source and not a point. That matters wherever something is emitted after the things it is about: a call’s own bytes go in after its arguments, so the line they belong to is where the argument list ends.
pos is the one reserved name in the whole notation. A grammar building a node
with a field of that name is refused when the description is read, rather than
giving two answers later.
5. Passes
A %pass is one walk over the tree, post-order, with two phases at each node. A
clause is keyed on a pattern and says what a node answers for an attribute.
%pass typecheck
thread env = empty
otherwise type = "void"
Number : type = "int" .
Variable : type = lookup($env, $name)
! not defined($env, $name) : "'{}' is not defined" of $name .
Let : env = bind($env, $name, $value.type) .
Binary : type = "int"
! $left.type <> "int" or $right.type <> "int"
: "'{}' wants two ints: {} is {} and {} is {}"
of $op, $left.show, $left.type, $right.show, $right.type .
Three kinds of attribute
: attr = e |
synthesised | leaving, after the children |
: down attr = e |
inherited | entering, before the children, visible below this node |
: attr = e, attr declared thread |
threaded | leaving, and the value flows on to what the walk visits next |
That is the whole of it, and each answers a different question. What is this subtree? is synthesised. What is this node inside of? is inherited. What has the walk seen so far? is threaded — a symbol table, a label counter, a slot number, anything that is a fold over the tree in document order.
An indentation level is the clearest inherited attribute there is: a node hands it to its children and to nothing else.
Block : down indent = "{} " of $indent
: out = join($body.out, "\n") .
Reading
$name is resolved in this order, and the order is worth knowing because two
of the steps can hide a third:
$pos· a binding from the pattern · a field of this node · an attribute — inherited, threaded or synthesised · an embedded file
So a binding wins over a field (which is what lets a pattern rename one),
and a field wins over an attribute. $child.attr reads a child’s
attribute, and that goes through the same order on the child.
. over a list means that of each, which is why there is no map in this
notation and no traversal to write:
Program : out = join($body.out, "\n") .
That is a whole block. $body.pos.line is its line table.
Checks
! condition : message
A check runs before the attributes it guards, which is the point of it: a diagnostic arrives instead of a consequence. The message is an ordinary expression, so a complaint can render the thing it is about using another pass’s work:
print-a-bool.calc:3:3: error: print wants an int, and (n < 2) is bool
print n < 2;
^
$value.show there came from lib/expression.phx, a module that knows nothing
about the language importing it.
A pass that reports an error stops the ones after it — not because the sequence could not continue but because it should not, since a later pass reading what a failed one left produces consequences of the first mistake rather than new information.
otherwise
Some attributes are answered the same way by nearly every node type.
languages/pascal/pascal.phx wrote type = "void" twenty-one times, with a
comment saying why: so that a node above it can read a type without asking
which kind of statement it was.
otherwise type = "void"
One clause, for every node whose own rule works nothing out — including a node that matched no rule at all. It runs after that rule, so it can read what the rule worked out, and only for the attributes the rule left alone. A node with a field of that name reads the field, which is the node saying so itself, and is what this is “otherwise” to.
It is still a clause about a node, which is why it is this rather than a function or a macro. A description that could call a function would be a description with two kinds of thing in it, and the reason this tool is small is that there is only ever one.
What it buys, besides twenty lines of Pascal: a list of nodes has a column of
whatever every node answers. Neither $body.runs nor $body.fileidx needed a
way to map over a list — they needed an attribute every node has.
Nesting a thread
A thread runs in one chain along the whole walk, which is right for anything the
program has one of and wrong for anything a scope has its own of. A down
clause naming a threaded attribute sets the thread for the subtree instead:
Block : down held = $names (* save the enclosing table *)
: down names = [] (* start this scope's own *)
... (* the children fill it in *)
: names = $held . (* and the enclosing one back *)
The save is an ordinary down attribute and the restore is the node’s own
leaving clause, which works because a node’s scope is torn down after its
leaving clauses run. This is the mechanism a bytecode backend found the need
for: a method chunk carries its own name and constant tables, so entering one
has to start them empty and leaving one has to put the enclosing tables back,
and a single chain along the walk has no stack in it.
A rule that resets a thread and does not restore it lets the inner value flow on to its siblings, which is legal and is occasionally what is wanted — a slot counter that only ever grows, say.
What a pass cannot do
An attribute is computed once per node, in one walk. A while needs its body
evaluated an unknown number of times and a branch not taken must leave the
variables alone, and neither is a thing a value computed once per node can say.
So an eval pass works on straight-line programs and cannot work on any other
kind. That is not a gap waiting to be filled: interpreting is for checking a
language while it is being designed, and compiling is what Phoenix is for.
languages/calc/calc.phx says so in the clauses, where a program runs into it:
While : out = ""
! true : "a loop cannot be interpreted -- compile it with emit-c" .
6. Order, and why some things take two walks
%driver c = show, typecheck, emit-c -> out .
%driver run = show, typecheck, eval -> out .
%driver check = show, typecheck .
Passes in order, and which attribute of the root is the answer. A driver with no
-> is a validation run: nothing is printed and the exit status is all it says.
The first declared is the default, for the same reason the first syntactic rule
is the default %start.
Attributes stay on the nodes between passes, which is what makes a sequence
worth having: typecheck reads what show worked out.
The order is a claim, and it is checked
If a pass reads $left.type and the driver forgot to run typecheck, the
alternative to catching it here is a message from inside a pass about a missing
attribute — naming neither the driver that got the order wrong nor the pass that
would have supplied it.
error: driver 'bad' runs 'typecheck', which reads '.show', and nothing before
it defines one
phx: 'show' defines 'show' -- did the driver mean to run it first?
That message is the reason to declare a driver rather than to run passes in whatever order they were typed.
Why two passes
An inherited attribute runs before a node’s children; gathering runs after them. One walk cannot do both, and Phoenix says so when you try:
error: this reads 'labels', which this rule computes on the way out
-- and an inherited clause runs on the way in
phx: write the expression out here, or compute 'labels' in an earlier pass
That is why Pascal’s checker is two passes — a symbols pass gathers each
block’s declarations on the way up, and a typecheck pass reads them on the way
down — and it is also the whole answer to a forward reference. Nothing in a
walk can look ahead; a pass that builds the table and a later pass that hands it
down does. awk needed exactly that, and it took twenty lines, which is why
reference attributes were settled against rather than built. The assembler in
tutorial-assembler.md is this from nothing.
7. The expression language
The same language appears in a grammar action, a pass clause, a check message
and a rewrite. What it means is fixed in semantics.md — which
is executable: every claim on that page is a check that make test runs,
through phx and through a compiler phx wrote.
Six kinds of value
integer (64-bit, and overflow is an error, not a wrap), float (IEEE
754, which does not trap), text (bytes, one-based, both ends of a range
included), boolean (no truthiness — a condition must be a boolean),
nil (absence; not zero, not empty text, not false), and node and
list.
45 is an integer and 45.0 is a float.
No implicit conversion, anywhere
1 + 1.0 is an error, not 2.0. int(x) and float(x) convert, and are the
only things that do.
This is the single rule that most protects the property the whole design rests on — that the interpreter and every generated compiler agree — because implicit conversion is where host languages differ from one another most, and most quietly.
It also means $text from a number rule is text, and a pass that wants to
do arithmetic on it writes int($text).
The most important thing on this page
Phoenix’s arithmetic is not the target language’s arithmetic, and a pass that folds target constants must say so.
A pass computing 2 + 2 while compiling Pascal is doing Phoenix’s arithmetic.
If it folds a Pascal maxint + 1, the answer must be Pascal’s — and Pascal’s
integers trap where another language’s would wrap. Phoenix will not guess: its
arithmetic is for the compiler’s own bookkeeping — counters, offsets, sizes,
table indices — and a pass modelling a target’s arithmetic writes that model
out.
The sharp edge is division. div and mod are floored, so that a mod b
carries the sign of b:
7 div 2 = 3 7 mod 2 = 1
-7 div 2 = -4 -7 mod 2 = 1
C, Java and Pascal truncate instead. A description emitting C therefore writes
quotient and remainder, and languages/calc/calc.phx says so at the one
line where it matters:
(* `quotient`, not `div`. Phoenix's `div` is floored and C's `/` truncates,
* so `0 - 7 / 2` would be -4 interpreted and -3 compiled -- the same program
* answering two things. *)
Binary(op: "/") : val = quotient($left.val, $right.val)
! $right.val = 0 : "division by zero" .
Get that wrong and you have the classic constant-folding bug: the interpreted program and the compiled one disagree on a negative division, and nothing else notices.
Comparison
= and <> work on any two values and compare structurally: two lists
are equal when their elements are, two nodes when their type, fields and values
are. Values of different kinds are unequal rather than an error, so a guard may
ask $x = nil without knowing what $x is. < > <= >= work only within a
kind, because across kinds there is no order anyone would agree on.
Formatting
"cannot add {} and {}" of $left.show, $right.show
of is the loosest operator there is, and fills the holes left to right. {{
and }} are literal braces, which is what an emit pass writing C needs. Too few
or too many arguments is an error checked when the description is read.
A float writes as the shortest decimal that reads back as the same value, so
0.1 is 0.1. A nil, a node or a list has no written form — deliberately,
because a default rendering for a node is a thing that would silently appear in
generated code.
Choosing: shape first, then if
Two spellings that differ are two clauses, keyed on shape:
Logical(op: "and") : out = "({} && {})" of $left.out, $right.out .
Logical(op: "or") : out = "({} || {})" of $left.out, $right.out .
A choice about a value, which no pattern can see, is an if:
Number : out = if $v = 0 then "zero" else "{}" of 100 div $v .
Only the arm taken is worked out, so the 100 div $v above never divides by
zero. There is always an else, and the condition must be a boolean.
lookup(env, name, default) is still the answer for absence, and otherwise
for a node with no clause.
Naming a computation: %define
An expression written out again with a different $name in it is a
computation that wants a name:
%define ptrsof(t) =
if size(at(split($t, "a:"), 1)) < size(at(split($t, "b:"), 1))
then size(at(split($t, "a:"), 1)) div 8
else size(at(split($t, "b:"), 1)) div 8 .
Variable : ptrs = ptrsof($lv) .
Member : ptrs = ptrsof($elv) .
It is called like a library function, and its body sees its parameters and nothing else, so it means the same thing wherever it is called; what it needs from the node is passed to it. It may call another define but not itself, directly or through another, because a pass is a walk that ends. Use an attribute when the computation is about the node it is on, and a define when it is about a value handed to it. reference.md § 3 has the rules.
The library
Small, and drawn around on purpose: a library nobody drew a line around is a
language nobody can reimplement. Environments (empty, bind, lookup,
defined, positions), conversions, text (size, slice, split, find, join),
lists (at, sizes, flatten, each, repeat) and bytes. Every entry is one a pass
for a real language needed and could not write in the notation.
reference.md § 11 has all of them.
An environment is an association list — [name, value] pairs, most recent
first — so shadowing is what naturally happens and nobody implemented it. A
value can be a node, so an environment binding a name to the thing it was
declared as is an ordinary list, and Pascal’s with origin do ... x ... is four
hops through one.
8. Rewrites
A %pass decorates: it works out attributes and leaves the tree alone. A
%rewrite replaces:
%rewrite fold bottomup
Binary(op: "+", left: Number(text: a), right: Number(text: b))
=> Number(text: text(int($a) + int($b))) .
Both halves were already there — a pattern tests and binds, and the evaluator builds nodes — so what this adds is a traversal that puts the answer back. It is the same matcher and the same evaluator a pass uses, so the two cannot disagree about what a pattern means.
The strategy is a word and not a default, because getting it wrong is
silent: 2 + 3 * 4 + 1 folds to 15 bottom-up and stops at ((2 + 12) + 1)
top-down, which asks about the outside of an expression before its inside.
bottomup |
children first, then this node, once |
topdown |
this node first, then the children of whatever it became |
innermost |
bottom-up, and again on the result until nothing matches |
A rewrite sees what its pattern bound, $pos, and the fields of the node it
matched — and nothing a pass worked out, because it runs to change the tree
rather than to answer about one.
What it is for is the thing a clause cannot say. A clause answers about a
node; some things are answered by there being a different node. Solveig’s
x:ifTrue({ ... }) compiles to a jump rather than to a block, and written as
clauses that would mean conditioning thirty of Block’s on whether its parent
inlined it. Written as a rewrite it is one rule: the block is not something to
compile differently, it is something that should not be there.
Send(to: c, message: "ifTrue", args: [Block(params: [], temps: [], body: b)])
=> IfTrue(cond: $c, body: $b) .
9. Modules
A description is read into one grammar however many files it came from, and a file is read once however many times it is named — so two modules may both import a third, and two modules may import each other, without anybody arranging for it. A cycle ends because a file that comes round to itself finds itself already read.
A module is looked for beside the file that names it, then in the library beside
the phx executable ($PHX_LIB overrides). --imports lists what a description
was assembled from, which is what a Makefile that rebuilds on a change wants,
and a diagnostic always names the file it came from with that file’s own line
numbers.
The split that matters
It is not grammar-versus-passes. It is the language against the target:
languages/calc/calc.phx |
the grammar, the tree, typecheck, eval — none of which has an opinion about any target |
languages/calc/calc-c.phx |
%import "calc.phx" and an emit pass |
languages/calc/calc-awk.phx |
%import "calc.phx" and a different emit pass |
languages/calc/calc-solveig.phx |
%import "calc.phx" and a third |
Everything upstream of emitting is about the source language. Only emit is per
target — which is why calc-solveig.phx went from ninety lines of duplicated
grammar, quietly wrong the first time calc.phx changed, to an emit pass and a
line naming the language.
The C and awk passes are 24 clause lines each, 17 of them identical character
for character. The seven that differ are the ones awk forces: its special
variables are ordinary assignable names, its / is floating division, it has
no declarations, print x > y redirects to a file, and its unit of execution
is a rule. A diff between the two files is a list of the ways awk is not C and
contains nothing else, because there is nothing else in either file.
Modules declare their holes
An expression grammar cannot know what a language’s atoms are — a number, a name, a call, an index — and that is the one thing every language differs about. So a module says what it needs and leaves it open:
%require primary .
...
unary = "-" u:unary -> Negate(value: $u) | "(" expression ")" -> $2 | primary .
A description with an open hole reads fine — that is what a module is — and is
refused, by name, the moment something asks it to parse a file. A module system
needs an interface in both directions, and %require is the half that is easy
to forget.
What is in lib/
lib/expression.phx is infix with the precedence
everybody expects. Fill in primary and you have expressions:
%import "expression.phx" .
primary = integer -> Number(text: $1) | name -> Variable(name: $1) .
Importing a grammar module costs two things, and both are worth stating. It
reserves words — and, or and not cannot be identifiers in a language
that imports this one. And it hands you a vocabulary: the tree will contain
Logical, Not, Compare, Binary and Negate, and your passes have to
answer for them. Phoenix names the missing clause the first time a program
reaches for one.
lib/lexical.phx is the other citizen and the simpler
one: name, integer, real, hex, text with escapes, three comment
shapes, space, and the %fragment declarations. Import only what a language
uses — every non-fragment rule there becomes a token kind, and a rule that
matches something the language does not have will happily match it.
A pass is only reusable together with the grammar that produces the nodes it
keys on. Building expression.phx turned up exactly one pass worth
sharing, and it is not a typechecker: show renders an expression back to
something close to what was read, for putting inside a diagnostic, and it
qualifies because it depends on the shape of these nodes and on nothing else. A
typechecker over the same nodes would need the importing language’s type system;
an emit pass would need its target. Both are the caller’s.
10. Two things the reader does
Two mechanisms are not passes and could not be, because they act while the file is being read.
%embed — a file’s bytes, under a name
A backend that emits a language needs that language’s runtime, and a
description has nowhere to put one. languages/pascal/pascal-c.phx gets away
with four #includes because Pascal’s types are C’s types.
languages/awk/awk-c.phx needs seven hundred lines, because awk’s value model
is a string and a number at once and its one aggregate is a hash table.
%embed runtime "awk-runtime.c" .
...
: out = join([$runtime, $declarations], "\n") .
The file is read when the description is read and its bytes are frozen into
whatever -o writes, so one file, no headers, no library still holds.
The reason is not that the literals were ugly. Seven hundred lines of C
inside a .phx cannot be compiled. That runtime was written as a standalone
file and checked against awk before being embedded — and then the file was
thrown away, so the artefact that had been tested was not the artefact in the
repository. Now it is one, and make test compiles it on its own.
%include — a target language’s own imports
%import is about the description. A target language often has the same thing
about its own files — Solveig writes @include "library.sol" — and that one is
not a pass and cannot be:
A pass is a walk over one tree that has already been read. An include is a second file, which has to be read before there is a tree to walk. No clause can reach it, however it is written.
So it is a directive the reader acts on, and it says two things:
include = "@include" p:string -> Include(path: slice($p, 2, size($p) - 1)) .
%include Include path .
which node an include is built as, and which of its fields holds the file. What happens then is fixed and is not a description’s to vary: the file is read with this same grammar, and the items its root holds take the include node’s place in the list that held it, before the first pass. A backend needs no clause for an include and never meets one.
The quotes come off in the action, not in the reader, and that is deliberate: how a language spells a string is the one thing only its own description knows.
The rules are C’s, for C’s reasons. A file is found beside the file that
includes it — never beside the directory you were standing in, so a program
survives being moved — and failing that on the search path, -I dir in the
order given. --no-includes asks about one file’s own text instead, which is
what a round trip is a question about.
11. Writing the compiler out
phx languages/pascal/pascal-c.phx -o pasc.c # a Pascal-to-C compiler, one file
cc pasc.c -o pasc # no flags, no headers, no library
./pasc prog.pas > prog.c && cc prog.c -o prog && ./prog
A generator has two ways to produce a compiler. It can emit code — a recursive-descent function per rule, a switch per pass — which is faster and is what most of the yacc family does. Or it can emit the description as data and ship the machine that already runs it.
Phoenix emits data, and the reason is the one this project keeps returning to: the alternative is a second implementation of everything. A second matcher with the same ordered-choice rules, a second evaluator with the same floored division, a second pattern matcher. Two implementations of one notation have to agree, and avoiding exactly that is why actions are not host-language splices and why semantics.md exists.
So a generated compiler runs the same lex.c, parse.c, eval.c and run.c
that phx runs, over a grammar frozen into static tables, all of it written into
one file. There is nothing for the two to disagree about, because there is only
one of them.
The conformance rule: the same
.phx, interpreted and through every backend, produces identical output.
What it costs is speed: the generated compiler interprets a PEG rather than being one. performance.md says how much — the matcher is linear in every shape tried, and 20,000 lines of Pascal reach running C in 238 ms.
12. Working on a description
The loop, in the order you will want it.
phx desc.phx |
does it read at all? Prints the grammar back, warnings and all. Do this first, every time |
phx --nodes desc.phx |
the vocabulary a pass will be written against |
phx --tokens desc.phx prog |
the lexical half, on its own |
phx --tree desc.phx prog |
the tree, whatever drivers exist |
phx --run PASS --show ATTR desc.phx prog |
one pass in isolation, and one attribute of the root |
phx --drivers desc.phx |
what there is to choose from |
phx --imports desc.phx |
what the description was assembled from |
phx --stats desc.phx prog |
match-steps per token — the constant, which should not grow with the file |
phx --quiet ... |
for a Makefile: the exit status is the answer |
Read the warnings. Every check in this tool exists because getting that thing wrong produces the same failure — a correct file reported as broken, at a place that is not the mistake — and the warnings are the half that were not worth refusing outright.
A language keeps its own tests. languages/pascal/tests/ holds the
published grammar it is checked against, the programs compared with fpc, and
the programs that must be refused.
And build the oracle early. The single most useful thing in this repository is a directory of programs compiled twice — once by an existing compiler for the language, once by the description — run, and compared byte for byte. The first eight Pascal programs found six bugs at once, every one of them a place C’s obvious answer is not Pascal’s, and none of them findable by reading the output and thinking it looked right.
And run the documentation. Both tutorials on this page are executed by
tests/tutorials.sh, which builds every file they say to build, runs every
command they show, and checks that what came back appears verbatim in the
page. It was added after reading them had missed three defects that running
them found in one pass — including a command using a driver the page never
told the reader to write.
semantics.md and reference.md § 11
are executable for the same reason, and between them 110 claims and their
refusals run on every build, through phx and through a compiler phx wrote.
A round trip is not a substitute. Three tests agreed with each other for
months because they all asked the same wrong question: a miscounted ...
spread built a wrong tree, wrote it back out wrongly in the same way, and
re-parsed to an identical tree. Running the program saw it at once.
13. Idioms and pitfalls
A repetition flattens
line = { integer ";" } -> L(items: $1) . (* 4;9; gives ["4",";","9",";"] *)
line = { i:integer ";" -> $i } -> L(items: $1) . (* 4;9; gives ["4","9"] *)
A { ... } factor answers a list holding everything every iteration
produced. An iteration with more than one factor and no action contributes all
of them. Put an action inside the repetition, or make its body a single rule,
when you want one value per iteration.
An [ ... ] optional answers a list too: empty when it did not match.
Label your factors
$1 counts literals. $3 is right until somebody inserts a factor before it,
and then it is silently wrong for the rest of the file’s life. e:expression
and $e cost four characters and cannot drift.
Put the longer alternative first
Ordered choice, in both halves. symbol = ":=" | ":", not the other way round.
Phoenix warns in the lexical half; in the syntactic half it is your problem, and
the symptom is a syntax error on a correct file.
Both of a node’s clauses go in one rule
Rules are tried in order and the first match wins, so a second Block rule
further down would never be reached — and Phoenix refuses one.
Block : down indent = "{} " of $indent
: out = join($body.out, "\n") .
The same applies to shape patterns: put Logical(op: "and") above the bare
Logical, never below it.
int($text), always
A number rule matched text. Nothing converts implicitly. The same goes the
other way: text(n) before a number can be joined into a string, unless of is
doing it for you.
quotient, not div, when modelling C
Floored versus truncating division is the one arithmetic difference that survives into generated code and disagrees with the interpreter only on negative numbers.
A node has no written form
"{}" of $node is an error, deliberately: a default rendering for a node is a
thing that would silently appear in generated code. Say what you want it to look
like — that is what an emit pass is.
A filter is [] plus flatten
There is no filter. A node that contributes nothing
contributes an empty list, and one level of flatten drops them:
Label : entry = [[$name, $pc]] .
otherwise entry = []
Program : labels = flatten($items.entry) .
... of a non-list is an error, and that is a kindness
[$e, ...$3] where $3 is the third item rather than a repetition would build
the first element twice and drop the rest. That bug lived in a real grammar for
months, invisible to a round trip, because the tree and what was written back
out were wrong in the same way.
positions is zero-based
Everything else in the notation is one-based — text indices, at, columns.
positions(list) is the exception, because what it exists for is turning a list
of names into slot numbers, and slots start at zero.
otherwise before writing the same line twenty-one times
If a clause is about to be copied to every node type, it is an otherwise.
When a down clause will not read what you want
It is telling you the truth: gathering runs on the way up and handing down runs on the way in. Split it into two passes and name them in the driver.
Where to go next
| tutorial-picture.md | a language and a compiler, from nothing, in half an hour |
| tutorial-assembler.md | two passes, a thread, and a forward reference |
| reference.md | every directive, clause form and library function |
| cheatsheet.md | one page |
| semantics.md | the meta-language’s arithmetic, executably |
languages/calc/ |
the smallest complete description worth reading |
languages/pascal/ |
the same shape at full size, against fpc |
| ROADMAP.md | what is deliberately absent, and why |