Six dev tools, built the same way as the compiler that needs them
Every one of these grew directly out of pain felt while developing PatLang itself. Debugging a real bug in self_hosting/lib/lower.patlang — a stray extra end that the native parser silently tolerated but the self-hosted parser correctly rejected — meant manually running a file through pat --ir-run, pat --patc, and patc1.exe and diffing the output by hand, more than once. That repeated manual ritual is now one command. The other five followed the same pattern: notice a recurring annoyance, build a small self-hosted tool for it, verify the tool against the real codebase, and along the way usually find a real bug — in the codebase, or in the tool itself.
All six live under self_hosting/tools/ (with matching self_hosting/lib/ libraries and selftests), written in PatLang, compiled by PatLang, following the project's own rule about itself.
The parity checker: one command instead of a manual ritual
self_hosting/tools/parity_main.patlang runs a file through all three execution paths — interpreted (--ir-run), natively compiled (--patc), self-hosted compiled (patc1.exe) — and diffs the output. A compile failure on one path is reported as its own distinct failure mode, not lumped in with an output mismatch, because the two need different fixes.
pat --ir-run self_hosting/tools/parity_main.patlang file.patlang [--quiet]
Building it found two real bugs in itself before it ever checked anything else. file_exists() turns out to return the string "1" or "0", not a boolean — a documented "legacy string-bool convention" — so an == false comparison against it silently never matched, defeating every compile-failure check in the tool. And the first version wrote each compiled binary to a fixed, reused filename: a failed compile left no new file, but the old file from a previous successful run was still sitting there, so the tool would silently report stale output as current with no error at all. Fixed by tagging each run's scratch filename with now_ms(), so it structurally cannot collide with a previous run's leftovers.
The dependency graph: what includes what, and does anything loop
self_hosting/tools/depgraph_main.patlang scans every include "..." statement across the library, builds a directed graph (and its reverse — "what would break if I changed this file"), and runs a proper 3-color DFS cycle detector that reports the actual cycle path, not just "a cycle exists somewhere."
pat --ir-run self_hosting/tools/depgraph_main.patlang [--html out.html]
Running it against the real codebase for the first time immediately surfaced a genuine, pre-existing bug: self_hosting/lib/family_tree.patlang had a hardcoded absolute, machine-specific path baked into its own include statement ("F:/PatLang/self_hosting/lib/sql.patlang") where every other file in the codebase uses a relative one. Not a tool bug — the first real thing it found.
The re-indenter: fixing whitespace without ever touching a comment
This one needed a real design decision before any code got written. The self-hosted lexer strips # comments entirely at the token level — they never become tokens at all — which means a conventional formatter (parse to an AST, rebuild canonical text from it) would silently delete every comment in any file it touched. This codebase's comments are long, load-bearing, and full of the kind of rationale you're reading right now. That was not an acceptable trade.
So self_hosting/tools/format_main.patlang is deliberately not a pretty-printer. It's a line-based re-indenter: it tracks nesting depth by scanning each line's whole words (not naive substrings — a variable named domain must not misfire on spotting "do") for block-opening and -closing keywords, and only ever adjusts a line's leading whitespace. It never discards or rebuilds line content, so comments and blank lines are structurally impossible to lose.
pat --ir-run self_hosting/tools/format_main.patlang file.patlang [--write]
The harder design question — teaching the lexer to preserve comments as trivia, so a real AST pretty-printer eventually becomes safe to build — is deliberately left for later. It's a compiler-core change, not a tool.
The fuzzer: making the parity checker's job automatic
self_hosting/tools/fuzz_main.patlang generates random-but-valid nested control-flow programs — if/else, while, function bodies, deliberately including same-line if x then ... end one-liners, since those turned out to be common in the real codebase (87 occurrences in lib/codegen.patlang alone) — and checks that the native and self-hosted parsers agree on each one.
pat --ir-run self_hosting/tools/fuzz_main.patlang [--seed S] [--iterations N]
It deliberately does not reuse the parity checker's full compile-and-run comparison for every generated program: both compiled paths shell out to rustc, and two rustc invocations per fuzz iteration would cap iteration counts far too low to be useful. Instead it checks parse-only agreement — the native side via plain interpretation (with generation made semantically safe by construction, so any failure reliably means a parse rejection and not an incidental runtime one), the self-hosted side via the same typed AST-walking error detector the compiler-diagnostics work below already built. Milliseconds per iteration instead of tens of seconds, and it's exactly the check that would have caught the original lower.patlang bug automatically.
Building it found two real bugs in its own construction. The seeded PRNG originally combined hash digits via base-16 positional accumulation — mathematically degenerate under modulo of any power of two, since every digit except the last cancels out, so fuzz_int(seed, 4) depended on nothing but the seed's last hex character. Every generated program came out identical regardless of seed until that was replaced with a plain digit sum. And the while-loop generator used bare reassignment (x = x + 1) for its counter instead of the codebase's actual convention of re-let shadowing (let x = x + 1) — which the native compiler correctly rejects as mutating a non-mutable binding, producing a run of false-positive "disagreements" until fixed.
Proof the detector genuinely works, not just silence: the exact fixed lower.patlang bug class was manually reintroduced into a scratch copy of the generator, and the fuzzer correctly flagged all five resulting programs.
Runtime-error suggestions: the one that couldn't stay purely self-hosted
The parse-error reasoning work made every parse failure report its line, show the offending source, and suggest a fix — entirely as self-hosted PatLang, because the self-hosted parser deliberately returns an inspectable ["Err", message, line] AST node as data instead of aborting. Runtime errors turned out to be architecturally different in a way that couldn't be worked around from PatLang alone: the interpreter's result type carries no line information, and PatLang has no try/catch, so a host-function error simply terminates the process. There was never a moment where self-hosted code could intercept it and add a suggestion.
So this one is a small, deliberate exception: a real rust-runtime/src/main.rs change, suggest_runtime_fix(), mirroring the same message-substring dispatch style as the parse-error reasoning — wrong argument count, wrong argument type, an out-of-range value, a typo'd function name, an operating-system-level failure. Categories were surveyed directly from every error string in the host-function library, not guessed. The original message is always still printed in full; the suggestion is additive, never a replacement.
Checking they actually hold up
Before merging, all five were run against the real codebase, not just their own test fixtures: the parse-agreement check across all 104 real .patlang files in the library and examples, the fuzzer for 900 iterations across three independent seeds, and full three-way execution parity on eight real runnable demos. The result was clean once a few bugs in the verification scripts themselves were found and fixed — PatLang functions don't close over top-level variables the way a Rust closure would, and a same-directory include only resolves correctly if a reformatted scratch copy is written back into the same directory as the original, not somewhere else. Two apparent parser "disagreements" turned out to be files written in an entirely different embedded syntax (a small regex/router DSL, never meant to go through the ordinary PatLang parser at all), and two apparent parity failures turned out to be example files that document their own dependency on being concatenated with a library file first, run standalone against their own stated convention.
One genuine, minor, non-blocking bug came out of it: the self-hosted-compiled path's contract-violation messages print a debug-style dump of the failed condition ((Var(b) != Num(0))) instead of clean source text (b != 0), traced to a place in the lowering code that reused the compiler's internal AST debug-printer rather than the original source text. Enforcement itself is correct on every path; only the message's wording differs. Left for later, flagged rather than hidden.
The PEG grammar validator: a third, independent opinion on "is this valid PatLang"
The parity checker and the fuzzer both answer "do the native and self-hosted parsers agree" — but they can't catch a bug both parsers happen to share, or tell you which one is actually right when they disagree. self_hosting/tools/peg_check_main.patlang is a third, structurally independent implementation of "does this program conform to PatLang's grammar," built from a single canonical PEG (Parsing Expression Grammar) file, docs/grammar/patlang-full.peg (published in full, with syntax highlighting, on its own page), interpreted identically by two separate engines — self_hosting/lib/peg.patlang and rust-runtime/src/ir/peg.rs — that share nothing but the grammar file itself and a thin per-side token adapter. It reports accept/reject only, not a full AST: building a generic PEG match back into PatLang's specific AST shapes needs per-rule semantic actions, real complexity that's only worth paying for if a future decision actually replaces a hand-written parser with this engine.
pat --ir-run self_hosting/tools/peg_check_main.patlang file.patlang
The interesting design problem wasn't the PEG interpreter itself (a standard, unmemoized sequence/choice/star/plus/optional/and/not engine, deliberately simple) — it was resolving three places where PatLang's grammar depends on things a plain PEG grammar has no native way to express, because the hand-written recursive-descent parser handles them with parser-level state instead of grammar shape. A bare = means assignment at the start of a statement but equality mid-expression — resolved with a dedicated statement-level production tried before the generic expression-statement fallback. A trailing { ... } block attaches as a closure argument after a call (f(x) { ... }) everywhere except inside an if/while condition, where it must not be swallowed — resolved with two parallel expression towers, one that allows the trailing-block production and one that doesn't, diverging only at the postfix/primary tier. And a declarative rule Head(...) :- Body. versus rule(...) used as an ordinary function call versus bare, malformed legacy DSL text all share the rule keyword — resolved for free by PEG's own ordered-choice backtracking, trying the declarative form first and falling through, with no keyword reservation needed at all.
Wired into the fuzzer as a third participant (a disagreement between any pair of the three implementations is now reported, not just native-vs-self-hosted), it found zero disagreements across 400 iterations of the fuller generator once the grammar file itself was debugged. Getting there surfaced the single hardest bug of the whole exercise: tokenize() returns a vec (a mutable, growable sequence needing vec_get/vec_len), not a list (PatLang's other, distinct sequence type, needing list_len/[]) — and calling list_len on a vec doesn't error, it silently returns 0. Every real token-stream access in the grammar interpreter had to go through the vec accessors specifically; only the .peg grammar file's own tokenizer (a genuine list) uses the list ones.
The actual question this tool exists to answer — is a general PEG interpreter fast enough to ever be worth considering as a replacement for the hand-written recursive-descent parsers, rather than staying a third-opinion validator forever — got a real, measured answer: benchmarked against the real 176-file codebase (both sides run through the same include-preprocessing the production pipeline actually applies, since an earlier unpreprocessed comparison gave a misleading result), the PEG engine runs at roughly 1.8× the native parser's wall-clock time, with zero memoization or packrat caching. A genuinely promising number for a naively-implemented general interpreter — not a decision to replace either real parser, which stays a separate, later, deliberate call, but no longer a question mark either.