An ISO 7185 Standard Pascal compiler with an LLVM backend.
The long-term goal is bootstrapping: Afterschool Pascal should be written in Afterschool Pascal and able to compile itself. Everything below is arranged to serve that.
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DLLVM_DIR=/usr/lib/llvm-21/lib/cmake/llvm
cmake --build build -j
ctest --test-dir build --output-on-failureRequires LLVM 21 development files, a C++20 compiler, and clang on PATH for
linking.
build/bin/pascalc hello.pas # -> ./hello
build/bin/pascalc -o greet hello.pas
build/bin/pascalc --emit-llvm hello.pas # -> hello.ll
build/bin/pascalc -c hello.pas # -> hello.o
build/bin/pascalc -O0 hello.pas # -O0..-O3, default -O2
build/bin/pascalc --std=extended hello.pas # ISO/IEC 10206:1991 insteadThe generated program links against libpasrt.a, built from runtime/pasrt.c.
pascalc finds it in the build tree; set AFTERSCHOOL_PASCAL_RUNTIME to point
somewhere else.
The language is selected per source. --std=iso7185 is the default and is
what everything below describes; --std=extended is ISO/IEC 10206:1991, which
is not a superset — it reserves word-symbols (otherwise, value, only,
…) that a valid ISO 7185 program may use as ordinary identifiers, and this
compiler's own stage-1 source does. See
ADR-0033.
program-header, const part, type part, var part, compound statement
types integer real boolean char
(red, green, blue) — enumerations
lo..hi — subranges of any ordinal type, range-checked on store
array [ordinal-type] of T, multi-dimensional, any ordinal index
record, nested to any depth, packed, with variant parts
set of T — sets over any ordinal type with values in 0..255
packed array [1..n] of char — the string types
^T — pointers, including to a type defined later
text — text files, with the buffer variable f^
file of T — files of any type that is not, and holds, no file
routines procedures and functions, nested to any depth, recursive,
value and var parameters, forward declarations,
procedural and functional parameters, congruity-checked
statements := , if/then/else, while, repeat/until, for/to/downto,
begin/end, case, with, procedure call,
label declarations, labelled statements,
goto — within a block, and out of one into an enclosing block,
write, writeln, read, readln
operators + - * / div mod, and or not (short-circuiting),
= <> < <= > >= (including on strings),
+ - * on sets — union, difference, intersection,
<= >= on sets — inclusion, and `in` — membership
functions abs sqr odd ord chr succ pred sqrt sin cos ln exp arctan
trunc round eof eoln
procedures new, dispose, reset, rewrite, get, put
literals integers, reals, 'strings', '' escapes, nil,
[a, b..c] set constructors, and [] the empty set,
{ } and (* *) comments
constants named constants, plus predefined true, false, maxint
This is the whole of ISO 7185. Every feature of the standard is implemented; what is left of the language is the next standard, not more of this one.
Enumerations and subranges are ordinal types like char: they index arrays,
drive for loops, answer ord/succ/pred, and select case arms. succ
runs out at the end of its own type — at blue, or at 9 for a 1..9 — not at
maxint. A record may have a variant part, tagged or not, which is what makes
a tag-plus-variant AST node expressible. See
ADR-0018.
A set is one 256-bit word — a bit per possible member — so its base type's
values must lie in 0..255, and set of integer is refused rather than
truncated. That makes a set a value: it is assigned, compared and passed
exactly as an integer is, and the operators are one instruction each. Set
compatibility is decided on the base type structurally, which is ISO 7185's own
departure from the name equivalence it gives every other structured type. See
ADR-0028.
Arrays and records assign whole (b := a copies every component), pass as
value parameters by copy and as var parameters by reference, and nest freely
in each other. A string literal has the type ISO 7185 gives it — packed array [1..n] of char — so it assigns to, compares with, and passes as a variable of
that type with no special case anywhere.
Two types are the same only when one type identifier denotes both, as
ISO 7185 §6.4.5 requires, so two separately written array [1..3] of integer
are different types. See
ADR-0017.
A pointer's domain may name a type defined later in the same type part — the only forward reference in the language, and what lets a record contain a pointer to itself:
type
link = ^cell; { cell arrives on the next line }
cell = record value: integer; next: link end;Every dereference is checked against nil, and dispose(p) sets p to nil so
the commonest use-after-dispose becomes that same check. Use-after-dispose
through a second pointer to the same storage is not detected, and nothing here
claims it is — see
ADR-0019.
A text file has a buffer variable f^ — one character of lookahead — and
ISO's primitives get and put are what read and write are built from,
here as in the standard. That is the operation a lexer is written against, and
a lexer is the first thing the self-hosted compiler needs:
program Copy(input, output, src, dst);
var src, dst: text; c: char;
begin
reset(src); rewrite(dst); { src and dst are argv[1] and argv[2] }
while not eof(src) do begin
while not eoln(src) do begin
c := src^; { look at the next character... }
get(src); { ...and only now consume it }
write(dst, c)
end;
readln(src); writeln(dst)
end
end.Program parameters are the program's only connection to the outside world, as
ISO 7185 §6.10 has it: input and output are the standard streams, and every
other one names a file variable bound to a command-line argument, in the order
written. A file variable that is not a program parameter is a scratch file
with no external name. Files are closed when the block declaring them exits,
which is the standard's own rule and needs no close. Using write without
output in the program header is an error, because §6.10 says it is. See
ADR-0021.
A file of T is the same thing with the component type changed. It has no
lines and no external representation of a number, so readln, writeln and
eoln are all refused on one; what it has instead is read and write in the
form ISO 7185 §6.6.5.2 defines for it — read(f, v) is v := f^; get(f), and
write(f, e) is f^ := e; put(f). The component may be any type that is not,
and does not contain, a file:
program Records(output, data);
type point = record x, y: integer end;
var data: file of point; p: point;
begin
rewrite(data);
p.x := 1; p.y := 2; write(data, p);
reset(data);
while not eof(data) do begin
writeln(data^.x:1, ' ', data^.y:1); { f^ is a designator with fields }
get(data)
end
end.text is not file of char: §6.4.3.5 makes them different types, and only
the first has lines. See
ADR-0031.
Characters are bytes. char is one octet with an ordinal of 0..255, and
nothing in the compiler or the runtime consults the locale — write emits the
bytes it is given and read returns the bytes it finds. UTF-8 text therefore
passes through unchanged, but a multi-byte character is several char values:
é is two, 日 is three. Encoding is the program's business, not the
language's.
Field widths follow Pascal: write(x:8), write(x:8:3) for reals.
A real written without a width comes out in floating form (5.0E-01 style);
with a width and a fraction length it comes out fixed-point.
Errors are detected, not ignored. ISO 7185 calls integer overflow, an array
subscript outside its bounds, a value stored outside a subrange, a case whose
selector matches no label, a dereference of nil, chr of a non-ordinal,
succ past the end of a type, and trunc of a real too large errors; this
compiler stops the program with a message rather than letting it wrap, read
past the array, or produce an arbitrary value:
$ pascalc overflow.pas && ./overflow
runtime error: integer overflow in sqr
The integer type is -maxint..maxint, which is narrower than the machine word
it lives in — so 2147483648 is rejected at compile time rather than silently
becoming -2147483648. See
ADR-0014 and
ADR-0015.
The last of ISO 7185 to arrive was the non-local goto, which leaves a block
rather than a statement: the target's activation record carries somewhere to
jump back to, and the jump closes the files of every block it abandons — the
work those blocks' own exits would have done
(ADR-0032).
One implementation limit: nesting deeper than 1000 levels — parentheses, statements, type denoters, or the depth of the tree an operator chain builds — is a compile-time error rather than a stack overflow, in the parser or in any walk after it. See ADR-0020.
ISO/IEC 10206:1991, one feature at a time. Everything above is accepted here
too, except that Extended Pascal reserves the word-symbols its own features
need — so an ISO 7185 program that uses one of them as an identifier compiles
under --std=iso7185 and not under --std=extended. That is the standard's
rule, not a limitation of this compiler.
statements case ... otherwise <statements> end — the default arm
types case ... otherwise (fields) in a record — the variant no label
claims
labels 1..9, 'a'..'z' — a range wherever a case constant may appear,
in a case statement and in a variant alike
literals 16#ff, 2#1010, 36#z — any base from 2 to 36, letters as the
digits above nine
operators x ** y and x pow y — exponentiation, binding tighter than * and
looser than not. ** always yields a real; pow takes an integer
exponent and yields the type of its left operand, so 2 pow 3 is
the integer 8 and 2 ** 3 is 8.0
a and then b, a or else b — the short-circuit operators, which
evaluate the right operand only when the left has not settled
the answer. Each is one word-symbol written as two words
words otherwise and pow are reserved; `and then` and `or else` reserve
nothing new, because both of their words already are
Not accepted yet: schemata (parameterised types), the string type, modules,
protected parameters, initial-state specifiers
(value), binding (bind/unbind), direct-access files, and complex
numbers. A word-symbol is reserved only when the feature needing it
lands, so until the list above is complete --std=extended accepts some
programs a conforming processor would reject.
doc/roadmap.md has the order and the reasoning.
| File | Role |
|---|---|
src/lexer.cpp |
source text to tokens; folds case, handles both comment forms |
src/parser.cpp |
recursive descent over the ISO grammar, builds the AST |
src/astdump.cpp |
the --dump-* format — a specification, since the Pascal compiler writes it too |
src/ast.h |
tag-dispatched nodes (NK + as<T>()), no C++ RTTI |
src/sema.cpp |
scopes, name resolution, type checking, constant folding |
src/codegen.cpp |
AST to LLVM IR via IRBuilder; main is the program body |
src/main.cpp |
driver: optimisation pipeline, object emission, linking |
runtime/pasrt.c |
formatted output and runtime checks |
tests/ |
one .pas per case, with expected stdout in .out or an expected failure in .err |
tests/extended/ |
the same, for cases written in Extended Pascal — the directory is what selects --std |
verify/ |
SMT proofs that the lowering means what ISO 7185 says |
selfhost/compiler.pas |
the same compiler, written in Afterschool Pascal |
Two deliberate constraints, both there for the bootstrap:
- No
dynamic_cast, no exceptions in the AST walk. Node kinds are explicit tags. Pascal has neither facility, so the C++ code stays within shapes that translate directly into a Pascal variant record. - Textual
.lloutput is a first-class path, not a debugging aid. A compiler written in Pascal cannot call LLVM's C++ API. Emitting IR text is the backend that survives the rewrite.
A compiler is the one program whose bugs are inherited by everything it builds, and a miscompilation is silent — the source is right, the test is right, the answer is wrong. So the arithmetic the compiler emits is proved correct rather than sampled:
pip install z3-solver
python3 verify/verify.py --pascalc build/bin/pascalcFor each construct, verify/ states what ISO 7185 requires of the result as a
property, models what the compiler emits, and asks Z3 whether any input makes
the two disagree. Thirty-five rules are currently established — the
non-negative mod, truncating div, odd on negative values, ordinal char
comparison, the exact integer-to-real widening, the for loop's inability to
overflow, an array subscript's inability to leave its bounds, a subrange's
inability to hold a value outside it, a set constructor containing exactly the
members it names, and the digit accumulator in read being unable to wrap
before its check sees it — twenty-seven of them for all 2³² inputs.
Several rules keep their bounds symbolic, so they are theorems about every array, every subrange, every enumeration and every set base type rather than about the ones a test happens to declare.
Each runtime check is proved to fire exactly when ISO says the operation is in error. Both directions matter: trapping always would satisfy "never produces a wrong answer", and never trapping would satisfy "never rejects a valid program". There are currently no known gaps.
Some rules exist to justify a check the compiler deliberately does not emit —
the for loop's step, unary negation, and an array's offset subtraction. The
last of those failed the first time it was run, on an array whose bounds span
more than maxint values, and the compiler now rejects such an array at compile
time. Proving why a check is unnecessary is how you find out that it isn't.
Not everything gets a rule. Pointer safety is not an arithmetic-lowering
question, and a rule saying "the nil check fires exactly when the pointer is
nil" would be the same sentence written twice — it would pass at once and prove
nothing while making the count look better. Pointers are covered by the
cross-check and by a run under AddressSanitizer instead, and ADR-0019 says so
plainly rather than inflating the catalogue. The same reasoning keeps eof,
eoln and the buffer variable out: they are state properties of a stream. What
they get instead is a test that can actually fail — files_scratch.pas opens
three thousand scratch files, which exhausts the descriptor table if a block
exit ever stops closing them, and that was checked against a deliberately
broken runtime.
The proofs are paired with a cross-check that compiles and runs real Pascal at
the adversarial points, at both -O0 and -O2, because a proof about a model of
the compiler is only worth what keeps it tied to the compiler. See
ADR-0013 for what this
does and does not establish.
doc/adr/ records the architecture decisions and what each one costs — why the
AST avoids C++ RTTI, why textual IR is a supported output, why and and or
short-circuit, and what is still open. Start with
ADR-0004 if you only read
one.
doc/glossary.md defines the terms this codebase uses in a specific sense — ordinal, designator, type-denoter, static link, tautological rule — and says which decision governs each.
The classic three-stage build. Stage 0 is the C++ compiler in this repository; it only has to be good enough to compile the Pascal-written compiler once.
stage 0 pascalc (C++) — this repo, grown until it accepts the stage-1 source
stage 1 pascalc1 = stage0(compiler.pas)
stage 2 pascalc2 = pascalc1(compiler.pas)
stage 3 pascalc3 = pascalc2(compiler.pas) require pascalc2 ≡ pascalc3 byte-for-byte
This now holds. The compiler compiles itself, and stage 2 and stage 3 are
identical, byte for byte, checked under ctest by
selfhost/irtest.sh.
Reaching stage 1 means the accepted language has to cover what a compiler is written in. In dependency order:
Procedures and functions— done: nested to any depth, value andvarparameters,forward, implemented with static links (ADR-0016).Arrays and records— done: static arrays of any ordinal index,packed, nested records,with, bounds-checked subscripts (ADR-0017). Variant parts wait forcase.Enumerations, subranges,— done, together with the variant records they unlock (ADR-0018). An AST node is now expressible: the tag is an enumeration and the node is a variant record.casePointers and— done, with the forward-referenced pointer domain that makes a recursive type possible (ADR-0019). The AST can now be a heap-allocated tree rather than an array of nodes.new/disposeText files— done:reset,rewrite,read,readln,eof,eoln, and the buffer variable withget/putthat a lexer wants (ADR-0021). The compiler can now read source and write.ll.Character strings— decided: a length-plus-buffer record in strict ISO Pascal, no extension (ADR-0012). Measuring the existing compiler settled it: a compiler reads text in and writes text out rather than manipulating it, so nearly every concatenation becomes awriteand the only strings that must be stored are identifiers and about sixty padded table entries.tests/bootstrap_strings.pasis the working evidence.
Every prerequisite for stage 1 is now in place, and the Pascal source that needed them is written.
selfhost/compiler.pas is the compiler written in its own language: the lexer,
the parser, Sema and the code generator, in one source file, because ISO
7185 has no include mechanism and the finished compiler is one source. It is
checked against the C++ stages it was ported from, on every Pascal source in the
tree — 207 files, compared stage for stage:
selfhost/difftest.sh build/bin/pascalc # also runs under ctestBoth write the same three sections, so the comparison is a plain diff:
$ build/bin/pascalc --dump-tokens tests/hello.pas | head -3
1 1 kw program
1 9 ident hello
1 14 op (
$ build/bin/pascalc --dump-sema tests/hello.pas | head -6
program hello
params
name output @1:15
frames
frame hello level 0
var output #0 : text (stdout 0)
That is the checkpoint rather than a convenience. A disagreement between the
two is bisectable now; the same disagreement discovered at stage 3 is two
compiler binaries differing by a byte. The corpus includes the Pascal compiler's
own source, and three directories cover the error paths a valid program never
reaches: selfhost/torture.pas for the lexer, selfhost/badparse/ for the
parser (one file per message, because the parser stops at its first error) and
selfhost/badsema/ for Sema (thirteen files, because Sema accumulates). See
ADR-0022,
ADR-0023 and
ADR-0024.
The AST is where the bootstrap constraints paid off: the NK tag of
ADR-0005 became a
variant record's tag and as<T>() became the case that reads it, with no
dynamic_cast to replace and nothing to redesign.
The code generator is the one component that is not diffed, and could not
be: the C++ backend builds an llvm::Module through the API while the Pascal
one prints assembler text, and LLVM's own printer is not a specification — it
renumbers, reorders and changes between releases. So it is checked by running
what it produces, against the same golden output the C++ compiler is held to,
and then by closing the bootstrap:
selfhost/irtest.sh build/bin/pascalc # also runs under ctestThat compiles every case in tests/ with the Pascal compiler, links the IR with
clang, runs it and compares against tests/*.out and tests/*.err; then
compiles the compiler with itself twice and requires stage 2 and stage 3 to be
identical. A compiler that reproduced itself and nothing else would pass that
last comparison alone, so stage 2 is put through the golden suite too. See
ADR-0025.
build/bin/pascalc selfhost/compiler.pas -o stage1
./stage1 selfhost/compiler.pas stage2.ll # source, then where the IR goes
clang stage2.ll build/lib/libpasrt.a -lm -o stage2doc/roadmap.md expands this: what items 5 and 6 actually involve, the order the stage-1 source gets ported in, and the known limitations — including the ones that are deliberate.
Drop tests/name.pas plus its expectation into tests/, then re-run CMake so
the case is registered — the suite is globbed at configure time.
name.out— expected stdout. The program must compile and exit 0.name.err— expected stderr, for a program that is supposed to fail: one that should be rejected at compile time, or that should stop on a runtime error. A non-zero exit is then required, andname.out(if present) is compared against whatever was written before the failure.name.in— fed to the program's standard input. Without it stdin is/dev/null, so a program that reads sees end-of-file rather than waiting for a terminal. Two writable scratch paths are always passed as arguments, so a program whose header names external files has somewhere to put them.
tests/run_test.sh compiles, runs, and diffs. Source paths are rewritten to
<source> in stderr, so diagnostics can be pinned without depending on where
the checkout lives.