Phoenix / Phoenix reference
Phoenix reference
Every directive, every clause form, every operator and every library function. For an explanation rather than a lookup, read manual.md; for one page, cheatsheet.md.
What the meta-language’s arithmetic means is specified separately and executably in semantics.md; this page repeats its conclusions and does not restate its argument.
Contents
- Running
phx - The text of a description
- Directives
- The grammar
- Actions — what a production builds
- Passes
- Rewrites
- Drivers
- Expressions
- Patterns
- The library
- What is checked before anything runs
1. Running phx
phx [options] <description.phx> [source]
With no source, the description is read, checked and printed back — which is the fastest way to find out whether Phoenix understood it. With a source, the source is scanned and parsed, and the default driver runs over the tree.
| Option | |
|---|---|
--tree |
print the tree and stop, whatever drivers there are |
--tokens |
print the token stream and stop |
--nodes |
print the node types the grammar builds, and each one’s fields |
--grammar |
print the grammar as it was understood |
--drivers |
list the drivers, marking the default |
--imports |
list every file the description was assembled from |
-o FILE.c |
write this description out as a C program that is its compiler |
--driver NAME |
which %driver to run. Default: the first declared |
--run PASS |
run one %pass on its own, ignoring the drivers |
--show ATTR |
print this attribute of the root, overriding the driver’s |
--raw |
write the answer exactly, adding no trailing newline |
--stats |
report how much work reading the source took |
--no-includes |
do not follow the source’s own %includes |
-I DIR, -Idir |
where to look for a file the source includes. Repeatable, in order |
--quiet |
say nothing on success |
--help, -h |
the usage |
Exit status is 0 on success, 1 when the description or the program was
refused, 2 when the command line was wrong.
Where files are looked for
%import "x.phx" |
beside the file that names it, then in the library beside the phx executable |
%embed n "x.c" |
the same two places |
the source’s own @include |
beside the file that includes it, then each -I directory in order — C’s rules, for C’s reasons |
$PHX_LIB overrides the library directory. A file is read once however
many times it is reached, so two modules may import a third, and two modules
may import each other, without anybody arranging for it — and a cycle ends
because a file that comes round to itself finds itself already read.
--stats
111 bytes 21 tokens 22 nodes 2984 scan-steps 265 match-steps depth 26 (12.6 match-steps per token)
Match-steps per token is the number to watch: it is the constant, and it should not grow with the size of the file. performance.md has the measurements.
2. The text of a description
Comments are (* ... *), which do not nest, or ; to the end of a line.
Names are a letter or _, then letters, digits and -. A hyphen is an
ordinary character in a name: line-comment and emit-c are one name each.
Literals are written in double or single quotes, and take these escapes:
\" \\ \n \t \r \0 \' \xHH
\0 is a real NUL and survives into a generated compiler — a literal holding
one is frozen with its length, not with strlen.
Numbers are 45 (an integer) and 45.0 (a float). There is no other
numeric literal syntax in an action.
A directive may be terminated with a ., as everything else in the
notation is. Without one, its arguments end at the end of the line.
Reserved words are worked out, not declared
Every word-shaped literal in the syntactic half becomes a reserved word. A
grammar writing "begin" in a syntactic rule cannot then see begin arrive as
an identifier, and nothing had to say so.
This is why importing a grammar module costs something:
lib/expression.phx writes "and", "or" and
"not", so a language importing it cannot have identifiers of those names.
div and mod are deliberately not in that module for the same reason.
Inside a pass, the words of, and, or, not, div, mod, thread,
down and otherwise are operators, and true, false and nil are values.
All of them are still perfectly good field names, because the reader knows an
operator from a field by where it is looking.
pos is reserved across the whole notation. A grammar building a node with
a field of that name, or a pass defining an attribute of that name, is refused
when the description is read — because $pos has to mean one thing in every
clause of every pass.
3. Directives
%tokens |
what follows is lexical. This is the default, so it is optional |
%syntax |
what follows is matched over tokens |
%fragment a b . |
these lexical rules are helpers and never tokens on their own |
%skip a b . |
these token kinds are produced and then thrown away |
%start name . |
the goal rule. The first syntactic rule if unsaid |
%ignorecase . |
letters compare without regard to case |
%import "path" . |
read that module into this grammar |
%embed name "path" . |
that file’s bytes, readable as $name |
%require name . |
this module leaves that rule open |
%include Type field . |
which node an include is built as, and which field holds the file |
%define name(a, b) = expr . |
a named computation, called like a library function |
%pass name |
clauses, until the next directive |
%rewrite name strategy |
rewrite rules, until the next directive |
%driver name = a, b -> attr . |
stages in order, and which attribute of the root is the answer. b until x runs a pass until the root’s x settles |
An unknown directive is refused and the message lists the ones there are.
%fragment
letter and digit are lexical rules and are not tokens; they are what the
token rules are written out of. Nothing about their shape says so, and a
scanner taking the longest match with ties broken by declaration order returns
a whole file as a stream of letter, because both rules match x and letter
is declared first. Phoenix warns when a rule looks like a forgotten fragment.
%skip
%skip is deliberately not in lib/lexical.phx. Whether whitespace is
thrown away is the language’s business — a layout-sensitive language keeps it —
so the description that imports the lexical rules says so itself.
%import
A description is read into one grammar however many files it came from. A
diagnostic always names the file a rule came from, with that file’s own line
numbers. --imports lists what a description was assembled from, which is what
a Makefile that rebuilds on a change wants.
Two rules of the same name in two files is an error: modules merge, they do not override.
The split that matters is not grammar-versus-passes. It is the language
against the target: everything upstream of emitting belongs to the language
and has no opinion about C, so pascal.phx is imported by both pascal-c.phx
and pascal-outline.phx and knows about neither.
%require
%require primary .
unary = "-" u:unary -> Negate(value: $u) | "(" expression ")" -> $2 | primary .
A module says what it needs and leaves it open. 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:
lib/expression.phx: this description has holes in it, so it cannot parse an-expression.txt
phx: 'primary' is required and never defined
%embed
%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 it exists is not that the literals were ugly. Seven hundred lines of
C inside a .phx cannot be compiled, so the artefact that had been tested
was never the artefact in the repository. Now it is one file that make test
compiles on its own.
--imports names embedded files too. Embedding two files under one name is
refused.
%include
%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.
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: 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 — how a language spells a string is the one thing only its own description knows.
Two shapes are refused rather than guessed at: an include where a field is expected, since a file is a number of things and a field holds one; and a file whose root holds two parts, since nothing says which of them a statement position wanted.
--no-includes asks about one file’s own text instead, with the include nodes
still in the tree — which is what a round trip is a question about.
%define
%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) .
A named expression with parameters, called the way a library function is.
An attribute shares a computation within a node; a define shares one across
values, which is what an expression written out again with a different
$name in it wants.
- The body sees its parameters and nothing else. No field, attribute or thread of the clause that calls it: what it needs is passed. So a define means the same thing at every call, and a name in the body that is not a parameter is refused when the description is read.
- The arguments are worked out left to right before the body, as any
call’s are. An
ifinside the body still works out only the arm it takes. - No define calls itself, directly or through another. A pass is one walk that ends (ROADMAP 3.1).
- Its name starts with a lower-case letter and is not the library’s, since
either would change what a call means. One with no parameters is called
bare,
ten, or with(). - The
.at the end is required: a body is an expression, and the next production’sname =would otherwise read as more of it. - A read inside the body counts as a read by every clause that calls it, so a driver that runs the caller before the pass defining what the body reads is refused, as it would be with the read written in the clause.
Defines belong to the whole description, imports included, and a generated compiler carries them with everything else.
4. The grammar
The notation is Wirth’s, from What can we do about the unnecessary diversity of notation for syntactic definitions (1977):
production = identifier "=" expression "." .
expression = term { "|" term } .
term = factor { factor } .
factor = identifier | literal | "(" expression ")"
| "[" expression "]" | "{" expression "}" .
The older shape is read by the same reader: <name> is a name, = and :=
and ::= are all the definition symbol, and the trailing . is optional.
Factors
name |
another rule |
"lit" |
a literal |
( a b ) |
grouping |
[ a ] |
optional |
{ a } |
repetition, zero or more |
"a" .. "z" |
a range of one character — lexical only |
! factor |
one character, provided that does not match here — lexical only |
l:factor |
a label |
"{" { ! "}" } "}" is a brace comment. Using .. or ! where there are only
tokens is an error with a line number: it asks about characters where there are
none.
There is no “any character”. ! "" never matches — an empty literal always
matches, so “one character provided nothing matches here” always fails. Write
out the alternatives you mean.
The value of a factor
This is the part that surprises people, so it is worth stating exactly.
| a literal | the text it matched |
| a rule | whatever that rule answered |
( ... ) |
whatever the group produced, by the rule below |
[ ... ] |
a list — empty when it did not match, otherwise everything it matched |
{ ... } |
a list, holding everything every iteration produced, flattened |
The repetition rule catches people out:
line = { integer ";" } -> L(items: $1) . (* 4;9; gives ["4", ";", "9", ";"] *)
line = { i:integer ";" -> $i } -> L(items: $1) . (* 4;9; gives ["4", "9"] *)
An iteration with more than one factor and no action contributes all of them. Put an action inside the repetition — or make the body a single rule — when you want one value per iteration.
The two halves
A grammar for a language is written over tokens and says nothing about how
characters become them. %syntax names the seam, and it 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.
The lexical half takes the longest match over every token rule, ties going
to the rule declared first. That is what every lexer does, and it makes
"<" | "<=" a question nobody has to answer between rules.
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. It costs nothing on an LL(1) grammar, which is what
nearly every published grammar is. It costs a syntax error on a correct file
where an alternative is a proper prefix of a later one. Within a rule, ordered
choice applies to the lexical half too, so symbol = ":=" | ":" must put the
longer first — Phoenix warns when it does not.
When a PEG fails at the top it has usually backtracked a long way from the real mistake, so the position reported is the one the match got furthest, not the one it stopped at.
That holds on both failure paths. A start rule that is a repetition never
fails outright — program = { statement } matches zero statements and succeeds
— so a syntax error in such a language surfaces as the goal matched and there
is more file. The furthest point is still what is reported: the leftover
token is the fallback, used only when nothing got further than it.
5. Actions — what a production builds
-> after an alternative says what it means, as against what it looks like.
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)
| "(" expression ")" -> $2 .
$1, $2 … |
the n’th factor, counting from one and counting everything, literals included |
$name |
a labelled factor, which survives the alternative being edited |
$$ |
what has been built so far |
$pos |
where this node came from |
Both $n and $label 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.
Building a node
Name(field: value, ...) builds a node. A capital starts a node type and a
lower-case letter starts a function call — that is the one convention the
notation asks for, and it is what lets Empty be a node with no fields while
empty is a call with no arguments.
A node with no fields is written bare: -> Halt.
$$, 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:
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).
$$ inside a repetition whose enclosing alternative builds something of its own
is refused: the fold and the value it would fold onto are in different lists.
No action at all
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 node a rule with no action builds is named after the rule exactly as
written, in lower case: line = one-thing ";" . builds a line.
--nodes
$ bin/phx --nodes languages/calc/calc.phx
Position(line, column, file, endline, endcolumn)
Number(text)
Variable(name)
Binary(op, left, right)
Program(body)
Print(value)
Let(name, value)
That is the surface a pass will be written against. A type built with two different field lists is a warning, since a pass keyed on it would have to handle both.
$pos
Every node carries a position, and $pos answers a node:
Position(line, column, file, endline, endcolumn)
which is the whole of the design. A number would have meant nothing to a
description — every use a description has for a position is a line or the name
of a file — and a node makes reading part of one an ordinary field read, so 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 send’s own bytes go in after its arguments, so the line they belong to is where the argument list ends.
line and column count from one. file is the file that stretch of source
came from, which after an include is not necessarily the one the command line
named. Because . over a list already means that of each, a table with a row
per statement is written the way every other list is:
Program : lines = bytes($body.pos.line, 4) .
A node’s span is the syntax that built it, which is not always everything
underneath it. An action on a whole alternative gets the whole alternative:
"print" e:expression ";" gives a Print covering print a; from its p to
its ;. An action inside a repetition gets that turn of the repetition only,
and for the left fold every binary operator is written with —
expression = term { "+" t:term -> Binary(op: "+", left: $$, right: $t) } .
— $$ was built on an earlier turn, so the Binary spans + t and starts at
its operator. In 1 + 2 the Binary claims columns 3 to 5 while its left
child is the 1 at column 1.
That is usually what a diagnostic about an operator wants, and it is worth
knowing before computing anything from it: a parent’s span does not
necessarily enclose its children’s, so the extent of a subtree is not
$pos of its root.
A node built by a rewrite takes the position of the node it replaced, so a later diagnostic points at the program rather than at the rule.
6. Passes
%pass typecheck
thread env = empty
otherwise type = "void"
Variable : type = lookup($env, $name)
! not defined($env, $name) : "'{}' is not defined" of $name .
Let : env = bind($env, $name, $value.type)
: type = "void" .
A pass is one walk, post-order, with two phases at each node. A clause is keyed on a pattern; rules are tried in order and the first match wins.
Clause forms
: attr = e |
synthesised — computed on leaving, after the children |
: down attr = e |
inherited — computed on entering, before the children, and visible to everything below this node |
: attr = e where attr was declared thread |
threaded — computed on leaving, and the value flows on to whatever the walk visits next |
! condition : message |
a check |
A rule is a pattern, then one or more of those, then an optional .:
Assign : type = "void"
! not defined($env, $name) : "'{}' is assigned before it is declared" of $name
! $value.type <> lookup($env, $name)
: "'{}' holds {}, and this assigns {}"
of $name, lookup($env, $name), $value.type .
Reading things
$field |
a field of the node this clause matched |
$binding |
a name bound by the pattern |
$attr |
an attribute handed down, or the current value of a thread |
$child.attr |
an attribute of a child |
$list.attr |
that of each — a list of the attribute over every element |
$embedded |
the bytes of a %embeded file |
$pos |
this node’s position |
f(x).attr |
an attribute of whatever an expression answered |
These resolve in a fixed order — $pos, then a binding, then a field, then an
attribute of any kind, then an embedded file. A binding wins over a field,
which is what lets a pattern rename one; a field wins over an attribute,
which is why an attribute shadowed by a field is reported:
| the shadowed attribute is | |
|---|---|
| synthesised | a warning — it is computed, but nothing outside the pass can see it |
inherited (down) |
a warning — what it hands down still reaches a descendant with no such field, but this node cannot read what it wrote |
| threaded | an error — the clause updating the thread reads the field instead, so the thread does not pass through this node at all and every node after it carries on from a value that never went through here |
$pos resolves before bindings, fields and attributes, so that it means the
same thing everywhere.
$child.attr over a list is what makes a list of nodes answerable without a
map: join($body.out, "\n") is a whole block, and $body.pos.line is its line
table.
Checks
! $value.type <> "int" : "print wants an int, and {} is {}" of $value.show, $value.type
A check runs before the attributes it guards — which is the point of it, and is why a check reading those attributes is refused when the description is read. The message is an ordinary expression, so a diagnostic can render the thing it is complaining 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;
^
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
otherwise type = "void"
What a node answers for that attribute when the rule it matched has no clause for it — including a node that matched no rule at all. It runs after the node’s own clauses, so it can read what they worked out, and it may name a threaded attribute, in which case it updates the thread the way a clause would.
languages/pascal/pascal.phx wrote type = "void" twenty-one times before this
existed, with a comment saying why: so that a node above it can read a type
without asking which kind of statement it was.
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.
otherwise down is refused: what a node hands its children is not what it
answers with. Two otherwise clauses for one attribute is refused: that is two
answers to one question.
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.
Threads
thread env = empty
A threaded attribute is a fold over the tree in document order — a symbol table, a label counter, a slot number. It is declared before the clauses that update it, so that a clause naming it is recognisably an update rather than a synthesised attribute that happens to share the name. The starting value may be left off.
A down clause naming a threaded attribute sets the thread for the subtree,
which is what makes a thread nest. A thread otherwise 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. 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:
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 *)
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.
Why some things need two passes
An inherited attribute runs before a node’s children, and gathering runs
after them, so one walk cannot do both. Pascal’s checker is two passes for
exactly this reason: a symbols pass gathers each block’s declarations on the
way up, and a typecheck pass reads them on the way down.
That is also the answer to a forward reference. Nothing in a pass can look ahead; a pass that computes the table and a later pass that hands it down does.
What a pass cannot do
An attribute is computed once per node, in one walk. A loop needs its body
evaluated an unknown number of times and a branch that is 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 no others — which is
why interpreting is for checking a language while it is being designed, and
compiling is what Phoenix is for. See
ROADMAP 3.1.
7. 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.
| Strategy | |
|---|---|
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 |
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.
innermost is the only one that can fail to settle, and it stops with a message
rather than running forever.
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. Reading an attribute from a rewrite is refused.
A rewrite is a stage of a %driver like a pass and is named the same way, so a
rewrite and a pass may not share a name.
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) .
8. Drivers
%driver c = show, typecheck, emit-c -> out .
%driver run = show, typecheck, eval -> out .
%driver check = show, typecheck .
Stages in order, and which attribute of the root is the answer. A stage is a
%pass or a %rewrite. 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.
phx calc-c.phx prog.calc # the first driver
phx calc-c.phx prog.calc --driver run # by name
phx --drivers calc-c.phx # what there is to choose from
Attributes stay on the nodes between passes, which is what makes a sequence
worth having: typecheck can render the expression it is complaining about
using show, a pass that came from a library module and knows nothing about the
language importing it.
A stage run until something settles
%driver code = layout, relax until labels, reach, code -> out .
relax until labels runs relax again and again until the root’s labels
comes out of a round = to what went in, then goes on to reach. The first
round’s what went in is what an earlier stage left, so one stage before it
has to define the attribute, and the pass itself has to define it on a node.
A down clause in that pass may read the attribute its own rule computes,
which is otherwise refused: here it is last round’s answer, which is the
point. A pass that has not settled after 256 rounds stops with the stage and
the attribute named. until is not reserved; it means this only as the
second of three names in a stage. A rewrite takes no until: innermost is
how a rewrite runs until nothing changes.
A driver is a claim about order, 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. What each pass defines and what it reads are both
decidable when the description is read:
misordered-driver.phx:20:15: 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.
Refused as well: a driver naming a stage that does not exist, a driver answering with an attribute none of its passes defines, and two drivers of one name.
9. Expressions
The expression language is the same in a grammar action, a pass clause, a check message and a rewrite. What it means is specified in semantics.md, which is executable; this is the summary.
Values
Six kinds, and no others.
| integer | 64-bit, signed. Overflow is an error, not a wrap |
| float | IEEE 754 binary64. Floats do not trap |
| text | a sequence of bytes, one-based, both ends of a range included |
| boolean | true, false. There is no truthiness |
| nil | one value meaning absence. Not zero, not empty text, not false |
| node, list | as the grammar half builds them |
45 is an integer and 45.0 is a float. 1 + 1.0 is an error — int(x)
and float(x) convert, and are the only things that do. This is the single rule
that most protects the conformance rule, because implicit conversion is where
host languages differ from one another most, and most quietly.
Operators
Precedence, tightest first:
f(x) $a.b call, attribute
not - unary
* / div mod
+ -
= <> < > <= >=
and short-circuits, left to right
or short-circuits, left to right
of formatting, loosest
if c then a else b chooses, and works out only the arm it takes, so the
other may be something that would fail. c must be a boolean. The else is
required, and it runs as far as an expression does, so else if chains and an
if inside an operator wants parentheses:
: out = if $n = 0 then "zero" else if $n mod 2 = 0 then "even" else "odd"
: safe = if $n = 0 then 0 else 100 div $n
Evaluation is left to right everywhere, and the operands of an operator are evaluated before it.
div and mod are floored, so that a mod b always carries the sign of
b — which is what makes x mod n an index into n things without a
correction:
7 div 2 = 3 7 mod 2 = 1
-7 div 2 = -4 -7 mod 2 = 1
7 div -2 = -4 7 mod -2 = -1
-7 div -2 = 3 -7 mod -2 = -1
C, Java and Pascal truncate instead, so a pass modelling one of those calls
quotient and remainder rather than div and mod. Getting this wrong is
the classic constant-folding bug: the interpreted program and the compiled one
answer differently on a negative division, and nothing else notices.
= 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 fills the holes left to right. {{ and }} are literal braces — which is
why an emit pass writing C writes "{{\n{}\n}}". Too few or too many arguments
is an error, checked when the description is read.
| integer | decimal digits, - when negative, never +, no padding |
| float | the shortest decimal that reads back as the same value — 0.1, not 0.1000000000000000055 |
| text | itself, unquoted |
| boolean | true or false |
| nil, node, list | an error |
The last is deliberate. A default rendering for a node is a thing that would silently appear in generated code, and a pass that wants one says what it is.
Lists
[a, b] builds one; ...x inside a list spreads it. ... of anything that
is not a list is an error, because the likeliest way to write one is
miscounting a factor — [$e, ...$3] where $3 is the third item rather than
the repetition builds the first element twice, and nothing downstream can tell.
That bug survived a round-trip test for months in a real grammar.
10. Patterns
A pattern tests and binds at once, and there is one for every kind a value can be:
Binary |
a node of that type |
Binary(op: "+", left: a) |
a field written with a value tests it, with a name binds it, left out is not looked at |
[a, b], [] |
a list of exactly that many, each element matching |
"text", 45, true, nil |
that value |
name |
anything, bound to that name — read back as $name |
_ |
anything |
Rules are tried in order and the first match wins, in a pass and in a rewrite alike — which is why a general pattern above a specific one is refused rather than silently taking every case the specific one was for. Both of a node’s clauses go in one rule:
Block : down indent = "{} " of $indent
: out = join($body.out, "\n") .
A second Block rule further down would never be reached.
Shape matching is how a clause says that two spellings differ, and it is still
the first thing to reach for. A choice that is about a node’s shape belongs in
the pattern; if (§ 9) is for a choice about a value the
pattern cannot see:
Logical(op: "and") : out = "({} && {})" of $left.out, $right.out .
Logical(op: "or") : out = "({} || {})" of $left.out, $right.out .
11. The library
Every entry is one a pass for a real language actually needed. library.c’s own
header says why the line is drawn: a library nobody drew a line around is a
language nobody can reimplement. See
ROADMAP 3.4.
And this section is executable.
tests/grammars/library.phx is every claim
below as a check, in the order they are made, and
library-refused.phx is every refusal
it names. Both run under make test, through phx and through a compiler
phx wrote — which is what the conformance rule says has to hold.
It was added because the library was held by nothing: eighteen of the
twenty-two functions had no executable check at all, and nearly every sentence
here is an edge case written down because somebody got it wrong. each
running to the longer of two lists is on this page because taking the shorter
turned abs(i) into abs().
An environment is an association list — a list of [name, value] pairs,
most recent first. bind puts a pair on the front, so shadowing is what
naturally happens and nobody implemented it.
Environments
empty |
the empty environment. A bare lower-case name is a call with no arguments, so empty() is the same thing |
bind(env, name, value) |
a new environment with that pair on the front. name may be a list of names — as many values as names binds them pairwise, one value binds every name to it |
lookup(env, name) |
the value, or nil |
lookup(env, name, default) |
the value, or that default. Both are worked out, so a choice where one side can fail is an if |
defined(env, name) |
a boolean |
positions(list) |
a table of [value, index] pairs. The index is zero-based, which is what turns a list of names into slot numbers. A repeated element keeps its first position, matching what lookup would answer |
Keys are compared the way = compares, not as text only.
Conversions
int(text), int(text, base) |
base 2 to 36. An integer answers itself; a float is refused — narrowing has a direction, so it is named. Text that does not fit in sixty-four bits is refused, as every other integer that does not fit traps |
float(x) |
from text or an integer |
text(x) |
any value with a written form |
floor(f), ceiling(f), round(f), truncate(f) |
a float to an integer, saying which direction |
Arithmetic the target’s way
quotient(a, b), remainder(a, b) |
division that truncates toward zero, as C, Java and Pascal do |
These were added because truncation could not be written in terms of flooring while the notation had no conditional, and without them an emit pass and an eval pass for the same language quietly disagree about negative division. They stay because the name says which rule a pass means.
bitand(a, b), bitor(a, b), bitxor(a, b) |
the bits both, either and exactly one of two integers have, on their sixty-four bits of two’s complement |
These exist for the same reason: a pass that works out a target language’s
constants before its program runs, C’s case A | B:, has no way to spell them
in + - * div mod. They are functions because and, or and not already
belong to the booleans. None can overflow, and each wants two integers.
Text
size(t) |
bytes |
slice(t, from, to) |
one-based, both ends included. Out-of-range ends are clamped |
split(t, sep) |
a list of text. Splitting on nothing is an error |
find(t, part) |
where part first begins in t, counting from one, or 0 when it is not there. find(t, w) > 0 asks whether t contains w. Looking for nothing is an error |
join(list), join(list, between) |
one piece of text |
Turning a Pascal 'it''s' into "it's" is
join(split(slice(t, 2, size(t) - 1), "''"), "'"), and cannot be written any
other way here.
Lists
size(x) |
elements of a list, fields of a node, bytes of text |
sizes(list) |
the same, for each element |
at(list, i) |
one-based |
flatten(list) |
one level only, because a deeper one would be guessing |
repeat(x, n) |
text n times over, or a list’s elements n times over. The one way to make something of a computed length; a count below zero is refused |
each(list, template) |
the template’s {} is the element |
each(a, b, template) |
two lists in step, one hole from each. It runs to the longer of the two; a list that runs out contributes nothing rather than ending the walk |
There is no map and no lambda; each is the map, and its template is built with
of like any other piece of text, so the parts that do not vary are already in
it by the time it is called:
each($names, "{} {}{};" of $type.pre, "{}", $type.post)
Bytes
bytes(n, width) |
one integer as width bytes, least significant first. Width is 1 to 8 |
bytes(list, width) |
a list of numbers gives a list of encodings — which is what a column of a table in a binary format is |
A float is written as its IEEE 754 bits, at width 8 or 4. Narrower is refused rather than quietly rounded.
bytes cannot be written in the notation: there is no way to take a value apart
into bytes with arithmetic that answers integers and text that answers
characters.
12. What is checked before anything runs
Every one of these exists because getting it wrong produces the same failure: a correct file reported as broken, at a place that is not the mistake.
About the grammar
| left recursion | a = a "+" b is an infinite descent for ordered choice. Warshall over leftmost-reachability, so mutual recursion is caught too |
| a name that is not a rule | and one named by a directive but never defined |
.. or ! over tokens |
asking about characters where there are none |
| a literal nothing spells | "," in the syntactic half when no token rule produces a comma — the rule can never match, and without this the message arrives at the first file with a comma in it |
| alternatives in the wrong order | "<" \| "<=", in the lexical half where it matters (warning) |
| a fragment not declared one | the letter trap (warning) |
| a rule nothing reaches | a leftover or a typo (warning) |
| a rule defined in two files | modules merge, they do not override |
| a module used with its hole open | %require names it |
About actions
$n past the last factor |
and $label naming no factor — yacc’s silent drift, made loud |
$$ with nothing to fold onto |
the fold and its subject are in different lists |
| one node type, two shapes | a pass keyed on it would have to handle both (warning) |
a field called pos |
the name every node says its position with |
the wrong number of {} holes |
counted when the description is read, in an action, a clause, a rewrite or a define, since 2026-10-10. This row said so before it was true: until then they were counted only when a node reached the template |
About passes
| a clause nothing can reach | a general pattern above a specific one takes every case the specific one was for |
| an attribute with a field’s name | a field is read before an attribute, so nothing outside the pass could see it (warning) |
| an inherited attribute with a field’s name | it hands a value down that the node itself cannot read (warning) |
| a threaded attribute with a field’s name | the update reads the field, so the thread does not pass through that node — and nothing about the answer looks wrong |
| an inherited clause reading its own rule’s work | down runs on the way in and the attribute is computed on the way out |
| a check reading the attributes it guards | a check runs first, by design |
two otherwise clauses for one attribute |
two answers to what a node answers when it has none of its own |
otherwise down |
what a node hands its children is not what it answers with |
a bare $name nothing could answer |
not bound by the pattern, a field of any node, an attribute this pass defines or any pass leaves on a node, or an embed. A thread or an inherited attribute counts only in its own pass, which is the one walk it exists in |
a clause defining pos |
one reserved name, meaning one thing |
About calls and defines
All since 2026-10-10. Before then, a call that could not run was found only when a node reached it, so one in a clause no test input reached was a description that read cleanly and could not work.
| a call to no function | not the library’s, and no %define names it |
| the wrong number of arguments | to the library, whose counts are in § 11, or to a define |
| a define with the library’s name | it would change what every call of that name means |
| a define named twice, or a parameter named twice | |
| a name in a define’s body that is not a parameter | the body would read whatever the calling clause had, and mean something different at each |
$n or $$ in a define’s body |
those belong to a production |
| a define that calls itself | directly or through another: a pass is one walk that ends |
| a read inside a define | counts as a read by every clause that calls it, so the driver’s order is checked through the call |
About rewrites and drivers
| a rewrite named like a pass | a driver names a stage by its name, so which one it meant has to be one stage |
| a rewrite reading an attribute | it runs to change the tree, so the walk it would be reading has not happened |
an innermost rewrite that never settles |
it stops with a message rather than running forever |
| a driver in the wrong order | a stage reads an attribute nothing before it defines |
| a driver naming no such stage | |
| a driver answering with nothing | none of its passes defines that attribute |
| two drivers of one name | |
until on a rewrite |
a rewrite defines no attribute to settle |
until on something the stage does not define |
nothing it does could settle it |
until with nothing before it to start from |
the first round has nothing to read or be compared with |
| a stage that never settles | stopped after 256 rounds, with the stage and the attribute named, when it runs |
About %include and %embed
%include naming a node nothing builds |
or a field that node has not got — the mechanism would then do nothing, quietly, and every include would reach a pass as a node it has no clause for |
%include declared twice |
|
| an include where one value is wanted | a file is a number of things and a field holds one |
| an included file with a two-part root | nothing says which of them a statement position wanted |
%embed of a file that is not there |
|
| two files embedded under one name |
Known warts
pos is a reserved field name. One word, across the whole notation.
A grammar module imposes reserved words. Importing
expression.phx means and, or and not cannot be
identifiers. There is no way to import a grammar and decline its vocabulary.
There is no syntactic negative lookahead. ! is lexical only. The cost is
that the notation cannot describe one thing its own reader does: a production
ending without its ., which needs two-token lookahead.
Ordered choice is not revisited. Seven grammars later this has still not cost anything, including awk, whose grammar is famously not LL(1) — both hard cases were describable by putting the specific alternative first, and two descriptions now rely on it rather than merely surviving it.
There is no iteration over data inside a pass. %rewrite innermost
reaches a fixpoint over the shape of a tree, and a driver can run a whole pass
until an attribute settles; nothing
inside one walk runs again on what it produced. See
ROADMAP.md § 5.
A field can shadow an attribute handed down, so a down clause may hand a
value to its children that the node itself cannot read back. A warning; the
threaded version is an error.
positions is zero-based, alone in a notation that counts from one
everywhere else, because what it exists for is slot numbers.
ROADMAP.md § 5 is the maintained list.