The Stage 1 design writeup, per docs/archive/INIT.md. This documents the choices that
are expensive to reverse; things that are cheap to change later are noted
as deliberate deferrals rather than decisions. The Stage 6 font
architecture has its own writeup in docs/dev/FONTS.md.
A PostScript value is an Object: a Value enum plus an executable
flag.
Object { value: Value, executable: bool }
Value = Integer(i64) | Real(f64) | Boolean | Mark | Null
| Name(Rc<str>) | String(Rc<RefCell<Vec<u8>>>)
| Array(Rc<RefCell<Vec<Object>>>) | Dict(Rc<RefCell<Dict>>)
| Operator(fn)
Why a flag rather than doubled-up variants (name vs. executable name,
array vs. procedure): in PostScript the literal/executable attribute is
orthogonal to type — cvx/cvl can flip it on any object, including
integers. One representation per type, with the attribute alongside, is
both smaller and truer to the language.
Why Rc<RefCell<..>> for composites: PostScript composites have
reference semantics — dup on an array yields a second handle to the same
storage, and mutation through one handle is visible through the other.
Rc<RefCell> gives exactly that. Rc, not Arc, because the interpreter
core is single-threaded by design (see "Live rendering plan" below); if
that ever changes, the type aliases are the choke point.
Integers are i64, a documented deviation from the PLRM's 32-bit
integers. Arithmetic that overflows still promotes to a real, so spec
behavior is preserved with a higher threshold. Nothing hand-written will
notice; a conformance-test suite someday might, and can be revisited then.
Strings are Vec<u8>, not Rust String — PostScript is a byte
language, and real-world files aren't reliably UTF-8. The lexer operates
on bytes throughout for the same reason.
Arrays tear down iteratively: Drop for PsArray drains last-owner
storage through a worklist instead of letting Rc trees drop
recursively — 10k levels of [ used to abort the process via Rust
stack overflow (found by the Stage 4 fuzz tests). The impl originally
sat on Object; Stage 11 profiling showed that taxing every popped
operand (~18% of fib) and moved it to the array view, which also made
obj.value freely movable.
Names are Rc<str> and dictionaries are HashMap<Rc<str>, Object>.
Interning names to integer symbols (making dict lookup a small-int hash or
array index) is the single most obvious performance lever left unpulled;
it's deferred until there's a benchmark showing lookup on the hot path,
which won't be before Stage 3's procedure-heavy programs exist. The PLRM
also allows non-name dict keys; the key type will need generalizing when
put/get on arbitrary keys land (noted in the code).
Three stacks, per the language spec: operand stack (Vec<Object>),
dictionary stack (Vec<Rc<RefCell<Dict>>>, systemdict then userdict), and
an explicit execution stack of frames:
Frame = Scanner(Lexer) — tokens read incrementally from source
| Proc { body, pc } — a procedure being executed element-wise
The core loop pulls one object at a time from the top frame and executes it. This "machine, not recursive evaluator" shape is the load-bearing Stage 1 decision, made for three reasons:
- Live rendering (Stage 2) needs the interpreter to be pausable mid-program. A step-able machine can be driven "run N steps, present a frame" from a render loop without threads or locks.
- Deep PostScript recursion must not consume Rust call stack. Depth
is bounded by an interpreter-owned limit, and exceeding it is a
catchable
execstackoverflow, not a process abort. - Tail calls are free: a procedure frame is popped the moment it yields its last element, so tail-recursive PostScript runs in constant execution-stack space.
The scanner is itself a frame, so tokenization is incremental (matching
real PostScript's file-execution semantics) rather than a parse-then-run
phase. {...} is collected at scan time into a procedure object and
pushed; executable arrays only run when a name resolves to one — the
distinction between "encountered" and "invoked" that makes deferred
execution work.
Semantics rule of thumb throughout: when the PLRM specifies behavior
(promotion rules, roll direction, mod sign, ties-round-to-greater,
degrees for trig), implement that, not the convenient approximation. The
tests cite the PLRM's own examples where they exist.
PsError variants are named for the PLRM's standard errors
(stackunderflow, typecheck, undefined, ...) so behavior can track
the spec, and so the future errordict/stop machinery has the right
vocabulary from day one. Every operator returns Result; nothing derived
from program input panics. Malformed-input recursion (e.g. 2000 nested
{) is depth-capped into limitcheck. The CLI reports errors in
LaserWriter style: %%[ Error: undefined; OffendingCommand: frobnicate ]%%.
src/
lib.rs crate root, re-exports
main.rs CLI: file / -e eval / REPL modes, error reporting
error.rs PsError (PLRM error names)
lexer.rs byte-oriented tokenizer; unit tests in-module
object.rs Object / Value / Dict / Num
interp.rs the machine: three stacks, frames, name resolution
gfx.rs graphics state, device-space paths, tiny-skia painting
font.rs font registry, FontState, glyph→path engine (see docs/dev/FONTS.md)
encodings.rs StandardEncoding/ISOLatin1Encoding tables, name→Unicode
window.rs winit event loop stepping the interpreter live
ops/ operators, grouped like the PLRM operator summary
stack.rs pop exch dup copy index roll clear count marks ]
arith.rs add sub mul div idiv mod neg abs rounding sqrt trig exp ln log
graphics.rs paths, painting, gsave/grestore, colors, translate/scale/rotate
font.rs findfont..setfont, show family, stringwidth, charpath
misc.rs = == stack pstack print quit
tests/eval.rs end-to-end: source in, operand-stack contents out
tests/render.rs headless rendering: source in, canvas pixels out
examples/*.ps sample programs (stage2_demo.ps runs today; fractals need Stage 3)
Single-threaded, step-driven, as planned. The winit event loop owns
the interpreter (src/window.rs); each frame runs a budget of interpreter
steps (--speed, default 100/frame ≈ 6000 objects/sec) and blits the
canvas via softbuffer. No locks, no channels, deterministic; pause and
slow-motion are just budget changes. The interpreter's begin_source +
step_n API is the seam between the machine and any front end — the
headless --png mode drives the identical machine to completion.
One exception (#895): a --spool job's interpreter lives on a thread of
its own (JobThread), so each job starts from the empty name interner a
standalone run has. The event loop still drives it one call at a time
(step a budget, draw the view) and waits for each reply, so it runs at the
same pace and stays deterministic.
Crates: winit (windowing — the standard choice), softbuffer (CPU
pixel presentation), tiny-skia (rasterization — pure Rust and its
model matches PostScript's needs almost exactly: Bézier paths,
nonzero/even-odd fills, stroking with joins/caps/miter limits).
Considered and passed on: minifb (less maintained, less control),
pixels/wgpu (GPU pipeline is overkill for CPU 2D), skia-safe (huge
C++ dependency — against the spirit of the project).
Graphics semantics worth knowing (src/gfx.rs):
- Paths live in device space — points go through the CTM at
construction time, per the PLRM, which is what lets programs transform
the coordinate system mid-path.
currentpointand the relative operators map back through the inverse CTM. - Arcs are flattened to ≤90° cubic Béziers in user space and the control points transformed, so arcs under rotation/scaling become the correct ellipses.
- Stroke width approximation: PostScript strokes with a user-space
pen; we stroke in device space with width scaled by √|det CTM|. Exact
for uniform scale/rotation; anisotropic
scalewon't produce elliptical pens. Deliberate, documented, revisit on demand. showpagedeviation: marks the page complete and leaves the image visible instead of erasing for the next page — erasing would defeat watching. Multi-page documents are a future problem.- The current path is part of the
gsave/grestoresnapshot (that's what makesgsave fill grestore strokework), per the PLRM.
save/restore—deferredlanded in Stage 8 as an object-granularity copy-on-write journal (design writeup:docs/dev/VM.md). The feared reach into the object model amounted to one newValuevariant (Save) andCloneonDict; theRc<RefCell>composite representation absorbed the rest unchanged.- Name interning — see above; do it when a benchmark exists.
//nameimmediate evaluation, ASCII85 strings,<</>>dict construction — the lexer recognizes and cleanly rejects the first two, tokenizes the third (it's just names); all await their stages.- REPL line-continuation for multi-line procedure definitions — single-line REPL semantics until the REPL matters more.