Why rustdoc output looks the way it does

Rust's documentation toolchain is genuinely excellent. cargo doc builds HTML and JSON from doc comments (///, or //! for modules) locally, so you can preview everything before publishing. Once a crate lands on crates.io, docs.rs builds it and drops the output into an immutable bucket of HTML, CSS, and JavaScript. Rebuilding historical versions isn't feasible — the docs.rs team only rebuilds for the latest version of each crate.

A screenshot of a browser window showing the docs.rs release queue. The currently being build section has two crates with names socket_port and gatio-der. And the build queue has a bunch of arborium crates with priority minus one. The two sections are labeled respectively "nice" and "naughty" with published 1200 crates this week.

That immutability is the core constraint. Making syntax highlighting changes retroactively would require rebuilding every version of every crate ever published. Not going to happen. The practical discussion about this in the docs.rs issue tracker confirms it.

Beyond that, there's a whole stack of practical questions to answer before any highlighting solution could even be considered:

  • Which highlighter do you standardize on?
  • Which languages do you support?
  • Can you trust the output quality and runtime behavior?
  • Does it need dynamic linking?
  • Does it compile on every platform rustdoc targets?
  • Syntax-highlighted HTML is larger — by how much, and can docs.rs afford it?
  • Who implements and maintains it?

The obvious technical answer to most of these is tree-sitter: it's a mature incremental parsing library, widely available in grammar form for hundreds of languages, with no dynamic linking requirements, and it builds across the platforms rustdoc cares about. The remaining problems are packaging and integration.

What "using tree-sitter" really means

tree-sitter's core and its highlighting subsystem are both available as crates. But those are just the foundations. To actually highlight a language, you need a grammar — and each grammar needs to be generated through the tree-sitter CLI, which emits a heap of C code and, ideally, a companion crate that exposes the parser's symbols.

A capture of the docs.rs page for tree-sitter-rust, showing that the only export, besides some queries, is the language function, which is a pointer to a struct.

That generated crate exports a pointer to a struct containing parsing tables and function pointers for the scanner, when present. It is not, by itself, enough to produce colored output. You also need two additional pieces: a highlights query, which maps parse tree nodes to semantic categories (keywords, functions, strings, numbers), and an injections query, which tells you when another language is embedded within the one you're parsing. Svelte components, for example, are HTML on the surface but contain JavaScript and CSS — or TypeScript — that need their own grammars applied.

A screenshot of the WebAssembly playground you can get when you compile a tree sitter grammar. It shows the code that's being parsed top left, the tree on the right, and bottom left we have queries that are used to highlight.

tree-sitter-highlight provides a callback mechanism for handling injections, but wiring that up correctly, with the right grammar dependencies, is left to the consumer. That's a significant amount of work when you repeat it across dozens of languages.

The alternative — loading a full language server for semantic highlighting — is technically superior but practically disqualifying. An LSP needs all of the source code, all of its dependencies, and the entire standard library sysroot loaded into memory before it can produce annotations. For batch documentation generation, that cost is prohibitive.

A purpose-built distribution

For the past six years, maintaining a private collection of curated grammars has been necessary for any project that needs reliable, offline syntax highlighting across multiple languages. Grammars from the official tree-sitter-grammars organization are high quality and current for mainstream languages like Rust and C++. Less popular languages are a different story: you might find a grammar that looks workable, but was written against an older tree-sitter API, or needs cleanup, regeneration, and removal of rules that cause compilation times to explode.

After collecting 18 grammars and finding the same packaging gaps across them, the solution is to stop solving this per-project and publish a single distribution that bundles the full toolchain: grammars that build cleanly against a modern tree-sitter, plus matching highlights and injections queries for every included language.

That distribution is arborium, now available as a public project.

A crate distribution with 96 grammars built in

Arborium started with a simple observation: people want syntax highlighting for a lot more than Rust. For the 96 languages that were requested, the project maintainers hunted down the best available grammar for each one, vendored it, patched it up, verified the highlight queries, made sure license and attribution were included in the redistribution, and wired it all into a cargo feature flag on the main arborium crate.

A screenshot of the Arborium 1.3.0 release showing a bunch of feature flags. They don't all fit on the page...

The dependency graph goes deeper than just the language itself. If you pull in Svelte, for example, the crate also brings along everything needed to highlight a Svelte component completely: HTML, CSS, and JavaScript.

Screenshot of arborium-svelte's dependencies, which includes arborium-html, arborium-css, arborium-jaascript, arborium-scss etc.

Like the original tree-sitter crates, these grammar crates can't do much on their own. Arborium provides simple high-level interfaces for highlighting code through the main crate:

use arborium::Highlighter; let mut highlighter = Highlighter::new(); let html = highlighter.highlight_to_html("rust", "fn main() {}")?;

This approach glosses over the finer points of tree-sitter's incremental parsing and highlighting, but the more complicated APIs are available if you need them. Everything from the theme — many of which ship built in — to the style of the HTML output is configurable. The default output is modern, compact, and widely supported:

<a-k>keyword</a-k>

For those who prefer the long-winded format and are confident Brotli compression will make up for it, there's an alternative:

<span class="code-keyword">keyword</span>

Terminal users can get escape codes in the output too, optionally with a background color, margin, padding, and border to make the highlighted code stand out:

A screenshot of my terminal showing some Rust code highlighted with the Tokyo night theme, some Haskell code highlighted with the Kanagawa dragon theme, and some Svelte code highlighted with the rosé pine moon theme.

Most importantly, the Rust crates are arranged so they compile through cargo to the wasm32-unknown-unknown target. That was the tricky part — it requires providing just enough libc symbols to keep the grammars happy.

crates/arborium-sysroot/wasm-sysroot › main 󰏗 1󰏫 18via v17.0.0-clang › 18:10 🪴 ls --tree . ├── assert.h ├── ctype.h ├── endian.h ├── inttypes.h (cut)

Cool bear

The tree-sitter playground you can get from tree-sitter build --wasm and tree-sitter playground targets wasm32-wasi, which is slightly different. At the end of the day, someone has to supply the system functions, and in Arborium's case, that someone is the project itself. Most of the functions needed are simple (isupper, islower, and so on), with malloc, free, and friends provided by dlmalloc.

Because every one of those crates compiles with a Rust toolchain (invoking a C toolchain along the way) to wasm32-unknown-unknown, they can run in a browser with a little glue.

Angle 1: a script to include

Right now, publishing a crate with documentation that needs highlighting for languages beyond Rust only takes a few steps, documented at arborium.bearcove.eu:

  • Create an HTML file in your repository
  • Add metadata to your Cargo.toml so the docs.rs build process picks it up

The arborium_docsrs_demo page shows this in action, with sources in the arborium repository. Arborium even detects when it's running on docs.rs and matches the active theme responsively — docs.rs light, docs.rs dark, or Ayu depending on the page. Those themes don't match the maintainer's personal taste, but consistency wins.

Amos

This approach works today, with zero extra work for the Rust docs team. They don't have to touch rustdoc, their build pipeline, or their infrastructure. It's an escape hatch that just works, and people have used it for everything from integrating KaTeX to rendering diagrams to front-end tricks.

A screenshot of a rustdoc katex demo
rustdoc-katex-demo

But the same approach is also a security disaster waiting to happen. It requires JavaScript and WebAssembly, forcing users to download large grammar bundles — sometimes hundreds of kilobytes — just to highlight small code blocks. More critically, it lets third-party JavaScript run in the main context of the docs.rs page. There isn't much to steal there today besides a favorite theme, but that could change. The docs.rs team knows this, and letting arbitrary packages inject scripts is bad practice regardless.

The risk scenario: everyone adopts Arborium as the standard way to highlight docs.rs pages. Years later, a malicious version of the arborium package gets published to NPM and instantly reaches millions of people. Pinning to a specific version would prevent that, but also block important updates. Ideally, all JavaScript on docs.rs pages comes from the docs team itself, limiting the blast radius substantially.

Angle 2: highlights in rustdoc itself

Arborium is ultimately just a set of Rust crates containing C code, both of which are extremely portable. There's no dynamic linking, no plugin folder, no asynchronous loading. Just grammars and code that highlights things. A pull request against rustdoc to add highlighting for other languages follows naturally:

A screenshot of the PR in question
rust PR #149944

At +537 −11 lines, the PR is small, but it pulls in literal millions of lines of C code from tree-sitter-generated parsers. That raises the question of which grammars to bundle, which is a decision the maintainer is glad not to be making alone.

rust › rustdoc-arborium 󰏫 3via v3.14.2 › 00:54 🪴 ls -lhA build/aarch64-apple-darwin/stage2/bin/rustdoc Permissions Size User Date Modified Name .rwxr-xr-x 171M amos 14 Dec 00:52 build/aarch64-apple-darwin/stage2/bin/rustdoc

rust › main via v3.14.2 › 01:44 🪴 ls -lhA build/aarch64-apple-darwin/stage2/bin/rustdoc Permissions Size User Date Modified Name .rwxr-xr-x 22M amos 14 Dec 01:44 build/aarch64-apple-darwin/stage2/bin/rustdoc

Amos

The comparison above shows a custom rustdoc with all 96 languages compiled in versus the main-branch rustdoc. Binary sizes will inevitably come up in discussion, which leads to the third angle.

Angle 3: highlighting in the docs.rs backend

If bundling hundreds of grammars into every rustdoc build isn't feasible, the alternative is to do the highlighting on the docs.rs server side. arborium-rustdoc is a post-processor specific to rustdoc — it detects code blocks in HTML files and highlights them, then patches the main CSS file to append its styles.

Tested against all dependencies of the facet monorepo, the ~900MB doc folder grew by only 24KB. It's fast, can be built with support for all languages, and avoids the security and bundle size problems of the other two approaches. It can even be sandboxed.

The build and what comes next

The hardest part of the project was the CI setup. Building a small package with GitHub Actions is bearable; orchestrating 2×96 builds plus supporting packages and publishing with provenance to two platforms is punishing. The project relied on donated, beefy CI runners from Depot.dev, without which the maintainer says they would have bailed early. Even with those, the plugin jobs were split into ten tree-themed groups:

A graph of all the github actions jobs that go into publishing arborium.

Because any CI failure is punishing, most logic was kept out of YAML and put into a cargo-xtask. That's not just for progress bars and nerd font icons — it's also about ensuring every WebAssembly artifact can be loaded in a browser by parsing the bundle and checking its imports with walrus, rather than piping wasm-objdump -x into grep. Blake3 hashes avoid recomputing inputs, chosen partly because the name sounds cool.

Arborium is released under Apache2+MIT, and the hope is that it brings accurate syntax highlighting to the web the way code editors suddenly got better at it years ago. The full details live on the arborium website.

For docs.rs specifically, the realistic recommendation is arborium-rustdoc as a post-processing step: it's fast, supports all languages at build time, and has none of the security or bundle size implications of client-side options.