>_TEXRS REFERENCE
A TeX engine written in Rust. Source is tokenised under a mutable category-code table, macros are expanded, and what is left is lowered to fusevm bytecode and executed on the same language-agnostic VM and three-tier Cranelift JIT that hosts zshrs, stryke, rubylang, pythonrs and scalars. In active development — the mouth, the expander, and a stomach that sets maths, pictures, colour and images onto a page.
What it is
TeX is two machines. The mouth turns bytes into tokens under a category-code table the document itself can change; the expander turns tokens into other tokens — \def, \csname, \the, the conditionals. Only after that does the stomach build boxes and ship DVI.
texrs implements the first two. That is the half a macro-heavy document spends its time in, and the half where a compiled implementation has something to prove: every mainstream engine — pdfTeX, XeTeX, LuaTeX — descends from tex.web through web2c and interprets the expander.
Scope today. --pdf breaks a paragraph the way tex.web §813–§891 does: every feasible set of breakpoints priced by how far each line's glue is from its natural width, the cheapest set taken, and Liang hyphenation widening the places a line may end. --dvi takes the first break that fits and does not hyphenate, because a DVI driver cannot set a run to a width. --pdf breaks its pages by penalty too, over the whole document: widows, orphans, hyphenated lines and headings that would otherwise be stranded at the foot of a page. Neither is tex.web's stomach: no maths, no boxes a document can nest, and the tolerances and demerit weights are constants rather than registers a document sets. Without either flag, \end stops the run rather than shipping a page, and the parity contract for the committed cases is still the \message stream.
Architecture
The pipeline mirrors how zshrs hosts zsh and rubylang hosts Ruby:
TeX source → mouth (catcodes, 3-state line scanner) → expander → command stream
│
lower → fusevm bytecode → fusevm VM + Cranelift JIT
│
host: \message append + flush
fusevm-hosted
No local vm.rs and no tree-walker. The command stream is compiled to fusevm bytecode and executed on the shared three-tier Cranelift JIT; jit-disk-cache persists native code across runs.
Registers are slots
TeX has exactly 256 \count registers (tex.web §236) and they map onto VM slots 0..255, so \advance\count0 by 5 is GetSlot / LoadInt / Add / SetSlot — native ops the JIT can compile.
Conditionals are branches
A tree-walker decides a branch as it goes; a compiler cannot, because the registers it tests are run-time state. Both arms are lowered and emitted as a real branch. \iftrue and \ifx — decidable from the macro table alone — are folded while lowering instead.
Compile-time expansion
\catcode and \def take effect while lowering: they change how the rest of the file reads, so they cannot be deferred to run time.
Bytecode cache
The chunk a document compiled to is kept in an rkyv shard keyed by path, valid while the source's mtime matches to the nanosecond. The second run of a document skips the mouth, the expander and the lowerer entirely — an mmap, a zero-copy lookup and a decode. TEXRS_CACHE=0 turns it off.
Example
\catcode`\{=1 \catcode`\}=2 \catcode`\#=6
\def\greet#1{HELLO-#1}
\def\pair#1,#2.{[#1|#2]}
\count1=7
\advance\count1 by 5
\message{\greet{WORLD}} % => HELLO-WORLD
\message{\pair 1,2.} % => [1|2]
\message{\ifnum\count1>3 BIG\else SMALL\fi} % => BIG
\message{count=\the\count1} % => count=12
\end
Run it with texrs file.tex and the output is written the way tex writes it on the terminal: (./file.tex HELLO-WORLD [1|2] BIG count=12 ).
Intercepts
Advice on macro expansion — before, after, around — matched by a glob over macro names, so the advice is registered before the macros it will catch exist. That is what makes it usable on a macro package rather than only on names the document already knows.
\def\greet#1{HELLO-#1}
\def\trace{[in]}
\def\loud{<<\proceed>>}
\intercept{before}{greet}{\trace} % => [in]HELLO-WORLD
\intercept{after}{sec*}{\note} % every sectioning macro, including later ones
\intercept{around}{greet}{\loud} % => <<HELLO-WORLD>>
Expansion is a compile-time act here, so advice is woven into the token stream and is undone by the group that registered it, like any other assignment. A handler that calls the macro it advises does not weave itself: a call inside advice is not advised, and the depth travels on two markers the mouth cannot produce.
Inline Rust
A \rust{ … } block is compiled by rustc, loaded, and its exported functions become callable from the document. The body is Rust, not TeX, so it is lifted out of the file before the mouth reads it — #, {, } and & are category codes the mouth would act on.
\rust{
#[no_mangle]
pub extern "C" fn twice(n: i64) -> i64 { n * 2 }
}
\catcode`\{=1 \catcode`\}=2
\count1=21
\message{\rustcall twice \count1 \endrust} % => 42
A call is a number wherever TeX reads one: a register assignment, an arithmetic operand, a conditional, or a \message body. The compiled library is cached by body hash, so only the first run pays for the compile, and a block that does not compile stops the run with rustc's own diagnostic rather than a missing-function error later.
Toolchain
texrs <file>
Tokenise, expand, lower, and run a .tex file on fusevm; print its \message stream.
--dump-tokens
Print the mouth's token stream and exit — no expansion, so what prints is what the category codes made of the bytes.
--disasm
Print the lowered fusevm bytecode disassembly and exit.
--aot
Compile the document to a standalone native executable — doc.tex becomes doc — through fusevm's ahead-of-time compiler. No interpreter dispatch loop, and no texrs needed on the machine that runs it.
--repl
An interactive prompt where state carries across lines the way it carries down a file: a \catcode set on one prompt changes how the next reads. Tab completes the primitives.
--dap
Debug Adapter Protocol over stdio: source-line breakpoints, stepping, and the \count registers as the variables scope. A breakpoint on a line that left no run-time work is reported unverified rather than silently never firing.
--tiers
Run the document, then report what fusevm's tiers did with its bytecode — block-tier eligibility, the largest eligible region, every loop header, and the ops the block tier refuses. Asked of fusevm's own predicates: enabling the JIT is not the same as being compiled by it.
--lsp
Speak the Language Server Protocol over stdio: completion and hover from the primitive corpus that generates the reference page, diagnostics from the engine's own lowerer.
--no-cache
Compile this run rather than reading the bytecode cache — how a warm run is timed against a cold one.
--cache-stats / --cache-clear
Say what the bytecode cache holds and where, or delete it. Deleting is never destructive: it holds only what can be recompiled.
--version / --help
Print the version banner or the option grammar.
-X new [DIR] / -X init
Start a document: writes a Texrs.toml and an index.tex. new takes a directory and names the document after it; init is new . for a directory that already exists.
-X build / -X watch / -X dump
Build the document the current directory belongs to, rebuild it whenever an input changes, or build it to stdout writing nothing. --profile NAME picks which output; --interval MS sets how often watch looks (default 250).
-X show
Say what the document is and what it can produce — the profiles declared in Texrs.toml, resolved from wherever inside the document you are standing.
-X bundle fetch URL / list
Download a bundle into the cache, or say which bundles have been fetched. The one place texrs uses the network, and only when asked: a build never reaches here.
-X dvi FILE.dvi
Read what real tex shipped for a document. Given two files it answers whether they are the same document — the comparison a parity harness makes.
-X bib FILE.bib / FILE.aux
Read a bibliography database, or, given an .aux, answer the different question: what this document cites and which of those entries are missing.
TeX-compatible options
The single-dash grammar real tex accepts, so a Makefile does not have to know which binary it is calling: -interaction=MODE, -jobname=NAME, -output-directory=DIR, -progname=NAME, -fmt=NAME, -ini, -halt-on-error, -file-line-error / -no-file-line-error, -recorder. The &FMT and \FIRST-LINE argument forms work too.
the parity binary
Diff the committed corpus against the real tex binary. No expectation is written by hand.
the parity-fuzz binary
Generate seeded random programs in the implemented subset, run both engines, and reduce whatever diverges to a minimal case.
Status & roadmap
The table below reflects the current state of the tree. See BUGS.md for the ledger of what is not yet carried, and ROADMAP.md for what the stomach would take.
| Component | State | Notes |
|---|---|---|
Category codes, \catcode | Implemented | INITEX's sparse defaults — { is not a group character until something makes it one (src/catcode.rs). |
Three-state line scanner, ^^X | Implemented | Blank line to \par, spaces collapsed, the space after a control word swallowed and after a control symbol kept (src/lexer.rs). |
\def with delimited parameters, ## | Implemented | Parameter text validated as tex.web §476 validates it (src/expand.rs). |
\let, \edef/\xdef, \gdef, \global | Implemented | \edef freezes register reads at definition time via scratch registers. It takes a parameter text exactly as \def does — \edef differs from \def only in when the body is expanded, never in whether it takes parameters (pinned by tests/cases/edef_with_parameters.tex). |
\csname, \string, \the, \number, \expandafter, \noexpand | Implemented | Control sequences built from characters, and printed back as text. |
| Conditionals | Implemented | \iftrue/\iffalse/\ifnum/\ifodd/\ifx/\ifcase with \or, nested and inside a \message body (src/lower.rs). |
| Groups scoping macros and registers | Implemented | The lowered body is wrapped in save/restore for exactly the registers it assigns. |
\count registers and arithmetic | Implemented | 256 registers on VM slots; \advance/\multiply/\divide with TeX's truncation. |
Differential parity vs real tex | Implemented | Corpus, examples, a generative reducing fuzzer, and cargo-fuzz targets for panics. |
| The LaTeX layer | Implemented | LaTeX is a program written in TeX, so texrs carries it as TeX: src/latex/prelude.tex is 161 lines of \newcommands compiled into the binary, so a run needs no support files. It covers what lives in the mouth and the expander; a macro that would need the stomach yields its text or consumes its arguments and produces nothing. |
Reading .dvi | Implemented | The reading half of the parity contract, ported from tectonic's xdv (src/dvi.rs). Both halves: reading, because the moment texrs sets a character the reference to compare against is what real tex shipped, and that is a DVI file; writing, because that is what the stomach will call, and dvitype accepts what it writes. -X dvi A.dvi B.dvi compares two as documents rather than as bytes. |
Font metrics, .tfm | Implemented | Ported from tectonic's read_font_info, which is tex.web §539–§576 in C (src/tfm.rs): character widths, heights and depths in design-size units, plus the ligature and kern program. The one format TeX cannot do without. |
Bibliographies, .bib / .bst | Implemented | -X bib FILE.bib reads a database; given an .aux it answers what a document cites and what is missing. src/bst.rs reads a .bst style — Patashnik's stack language — ported from the front of tectonic's engine_bibtex. |
| Errors that recover like tex's | In progress | An undefined control sequence prints its name instead of raising; every other error stops the run. |
Other registers (\dimen, \skip, \toks, \box) | Planned | Needed before \ifdim, \ifvoid, \ifhbox can be evaluated. |
| The stomach: boxes, glue, paragraphs, DVI | Planned | See docs/ROADMAP.md. |
Parity
The contract is the \message stream, compared byte-for-byte against the real tex binary. No expectation is written by hand — the reference is produced by running tex at test time, so a case cannot be made to pass by changing texrs.
# the committed corpus, and the examples on this page cargo test # the same comparison as a script cargo run --bin parity # random programs in the implemented subset, both engines, reduced on divergence cargo run --bin parity-fuzz -- --programs 500 # coverage-guided fuzzing for panics rather than divergences cargo +nightly fuzz run lower -- -timeout=10
Both shell harnesses read the tex version they were measured against out of BUGS.md and refuse to run against a different engine: a mismatched oracle does not fail loudly, it reports a different divergence set that reads like a regression.
Building from source
texrs builds as a standalone Rust crate (it is not a workspace member of the meta repo):
# clone git clone https://github.com/MenkeTechnologies/texrs cd texrs # build (produces target/debug/texrs) cargo build # run a .tex file ./target/debug/texrs examples/hello.tex # introspection ./target/debug/texrs --disasm examples/counters.tex ./target/debug/texrs --dump-tokens examples/macros.tex # run tests cargo test
fusevm is pulled from crates.io with the jit and jit-disk-cache features.
License
texrs is MIT licensed — free and open source. See LICENSE.
Repository & links
- Engineering report — report.html (architecture, lowering, parity posture, dependencies)
- Primitive reference — reference.html (every primitive texrs carries, and what it does)
- Source — github.com/MenkeTechnologies/texrs
- The shared VM — fusevm (also behind
zshrs,stryke,rubylang,pythonrs,scalars)