Phoenix

Phoenix

A compiler generator. It rests on the claim that a compiler is a grammar plus a sequence of tree walks, and that both halves can be written down instead of programmed.

pascal.phx phx pascal.c cc cpas myprogram.pas myprogram.c cc a program
C11, no dependencies. Nothing in that chain but cc.

Why not lex and yacc

They solve the front of the problem and leave you at the syntax tree.

After the parser 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.

Phoenix's claim is that a compiler is a grammar plus a sequence of tree walks, and that both halves can be written down instead of programmed.

The name belongs to Brooker and Morris, whose Compiler Compiler ran at Manchester around 1960 and meant something larger than a parser: their system had a notation for what constructs meant. Yet Another Compiler-Compiler borrowed the name and delivered a fraction of it.

One — the tree

Say what a production builds, not just what it matches.

-> after an alternative names the node it means. Everything else — the brackets, the semicolons, the chain of rules that each pass one thing along — is never built in the first place.

calc.phx
program   = { statement } -> Program(body: $1) .

statement = "print" e:expression ";"
              -> Print(value: $e)
          | "let" n:name ":=" e:expression ";"
              -> Let(name: $n, value: $e) .

(* $$ is what has been built so far, which
   makes this a left fold -- and is why
   associativity is said once. *)
expression = term { ( "+" | "-" ) term
    -> Binary(op: $1, left: $$, right: $2) } .
$ phx --tree calc.phx sum.calc
Program
`- body: [3]
   |- Let
   |  |- name: "width"
   |  `- value: Binary
   |     |- op: "+"
   |     |- left: Number
   |     |  `- text: "3"
   |     `- right: Number
   |        `- text: "4"
   |- Let
   |  |- name: "height"
   |  `- value: Binary
   |     |- op: "*"
   |     |- left: Variable
   |     |  `- name: "width"
   |     `- right: Number
   |        `- text: "2"
   `- Print
      `- value: Binary ...

Interior nodes are never built. One rule, no exceptions: a body that produced one value answers that value, and a body that produced any other number answers a node named after the rule. The useless nodes every hand-written tree-builder exists to strip are not stripped — they never exist.

Two — the walks

A pass is a clause per node type, and no traversal.

There is no visitor class and no recursion to write. $left.out reads a child's attribute; over a list it means that of each, which is why there is no map either.

calc-c.phx — the whole backend
%pass emit-c

  Number   : out = "{}" of $text .
  Variable : out = "{}" of $name .
  Binary   : out = "({} {} {})"
               of $left.out, $op, $right.out .

  Let    : out = "{}long {} = {};"
             of $indent, $name, $value.out .
  Print  : out = "{}printf(\"%ld\\n\", (long)({}));"
             of $indent, $value.out .

  (* indentation flows downward: an inherited
     attribute is the one thing a synthesised
     or a threaded one cannot say *)
  Block  : down indent = "{}    " of $indent
         : out = join($body.out, "\n") .

%driver c = show, typecheck, emit-c -> out .
$ phx calc-c.phx sum.calc
#include <stdio.h>

int main(void)
{
    long width = (3 + 4);
    long height = (width * 2);
    printf("%ld\n", (long)(((width * height) - 1)));
    return 0;
}
and the diagnostics come free
$ phx --driver check calc-c.phx bad.calc
bad.calc:3:3: error: print wants an int,
              and (n < 2) is bool
    print n < 2;
    ^

A driver is a claim about order, and it is checked. That message reads $value.show, which a different pass computed. If a driver forgets to run it, Phoenix names the driver and the pass that would have supplied it — when the description is read, not when somebody's program hits it.

Three — the compiler

One C file. No flags, no headers, no library.

terminal
$ phx calc-c.phx -o calcc.c
$ cc calcc.c -o calcc
$ ./calcc sum.calc
#include <stdio.h>

int main(void)
{
    long width = (3 + 4);
    long height = (width * 2);
    printf("%ld\n", (long)(((width * height) - 1)));
    return 0;
}
why it is data and not code

A generator 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, because 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. Two implementations of one notation have to agree.

A generated compiler runs the same scanner, matcher and evaluator that phx runs, over a grammar frozen into static tables. 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 — and that is a test on every build.

What you get that a parser generator does not

Six things, and each exists because getting it wrong is silent.

01

The target belongs to the description

An emit pass builds a string and nothing anywhere cares what is in it. C, SVG, another language — or bytes: Solveig's .sob bytecode is emitted by a description, not by Phoenix.

02

Factor drift is caught, not shipped

$3 going stale after somebody inserts a factor before it is yacc's most famous silent failure — the grammar still builds and the tree is quietly wrong. Both $n and named factors are checked when the description is read.

03

Pass order is a declaration

A %driver names the walks and their sequence, and Phoenix refuses one whose passes read attributes nothing before them defines — naming the pass that would have supplied it.

04

The arithmetic is specified, executably

Integers that trap rather than wrap, floored division, no implicit conversion, structural equality. Every claim on that page is a check the test suite runs, through phx and through a compiler phx wrote.

05

Diagnostics point at the program

A line, a column and a caret in the file somebody wrote — not in the description, and not at the place a PEG happened to give up. When a match fails at the top, the position reported is the one it got furthest.

06

It describes itself

The .phx notation is written in .phx, and it parses itself along with every other description in the repository. Wirth's own argument for the notation, carried out rather than asserted.

Evidence

Three languages, and the third was not written for this.

Pascal came with Wirth's grammar and Solveig with its own BNF, both vendored and read unmodified. There is no awk grammar on this machine, so awk is the POSIX definition transcribed — and what checks it is not a grammar at all.

35 Pascal programs compiled to C that prints exactly what fpc -Miso's does. The first eight found six bugs at once.
75 Solveig programs whose compiled bytecode prints what solas's does — byte for byte, tracebacks included.
13 awk programs other people wrote, compiled to C that prints what /usr/bin/awk prints.

Why an oracle and not a round trip

et_c.awk is 269 lines of awk that e2fsprogs uses to generate C error tables. Compiled by a Phoenix description it becomes 1,149 lines of C and prints the same 56 lines that /usr/bin/awk does. Nobody involved in writing it had heard of this project.

That matters because the cheaper test lies. Three tests agreed with each other for months while 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.

176 checks on every build, 174 of which need nothing outside the repository.

Get started

Build it, then write a language.

build
$ git clone https://github.com/hansolovkarlsson/Phoenix
$ cd Phoenix
$ make
$ make test
176 passed, 0 failed
try it
$ bin/phx languages/calc/calc-c.phx \
      languages/calc/programs/sum.calc
then

A picture language takes you from an empty file to a language, a compiler to SVG, and a standalone C compiler — in about half an hour. Every command and every piece of output in it was run.

Two passes, and a forward reference is the mechanism the first one did not need: a threaded attribute, an inherited one, and why a symbol table is two walks and not one.

Documentation

Everything, in the shape you want it in.