// TEXRS — A TEX ENGINE IN RUST

texrs v0.6.0 · TeX on fusevm · mouth → expander → command stream → bytecode → Cranelift JIT · no interpreter, DVI out via --dvi and in via -X dvi · a fusevm language host (with zshrs, stryke, rubylang, pythonrs, scalars) · MIT · in active development

Report Reference GitHub
// Color scheme

>_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.

ComponentStateNotes
Category codes, \catcodeImplementedINITEX's sparse defaults — { is not a group character until something makes it one (src/catcode.rs).
Three-state line scanner, ^^XImplementedBlank 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, ##ImplementedParameter text validated as tex.web §476 validates it (src/expand.rs).
\let, \edef/\xdef, \gdef, \globalImplemented\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, \noexpandImplementedControl sequences built from characters, and printed back as text.
ConditionalsImplemented\iftrue/\iffalse/\ifnum/\ifodd/\ifx/\ifcase with \or, nested and inside a \message body (src/lower.rs).
Groups scoping macros and registersImplementedThe lowered body is wrapped in save/restore for exactly the registers it assigns.
\count registers and arithmeticImplemented256 registers on VM slots; \advance/\multiply/\divide with TeX's truncation.
Differential parity vs real texImplementedCorpus, examples, a generative reducing fuzzer, and cargo-fuzz targets for panics.
The LaTeX layerImplementedLaTeX 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 .dviImplementedThe 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, .tfmImplementedPorted 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 / .bstImplemented-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'sIn progressAn undefined control sequence prints its name instead of raising; every other error stops the run.
Other registers (\dimen, \skip, \toks, \box)PlannedNeeded before \ifdim, \ifvoid, \ifhbox can be evaluated.
The stomach: boxes, glue, paragraphs, DVIPlannedSee 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