bat (Rust)
"A cat(1) clone with wings" — the CLI product layer over syntect: bat owns everything around the highlighting engine — pre-serialized lazy-loading assets, syntax detection, the line pipeline, decorations, paging, and the syntect-Style-to-ANSI mapping — while delegating every colored byte to syntect's per-line state machine. Within the highlighting cluster it is the closest existing shape to the planned sparkles:syntax tool, and the reference answer to "what does a highlighting pager need beyond an engine?".
| Field | Value |
|---|---|
| Language | Rust (library src/ + CLI src/bin/bat/; ~12 kLOC in src/) |
| License | MIT OR Apache-2.0 (dual) |
| Repository | sharkdp/bat |
| Documentation | README.md + in-repo doc/; man page |
| Key authors | David Peter (sharkdp, creator, 2018) + the bat-developers org |
| Category | Syntax highlighting — CLI pipeline / product layer |
| Algorithm / grammar class | n/a — delegated to syntect (HighlightLines over .sublime-syntax grammars); bat's own logic is detection + pipeline, not parsing |
| Lexing model | n/a (syntect's regex engines; the shipped binary selects oniguruma via regex-onig); input layer does encoding sniffing + UTF-16 decode |
| Output | ANSI-styled terminal text with decorations (line numbers, grid, git markers, headers) via nu_ansi_term; optional paging through less |
| Highlighting / theme model | syntect Style → ANSI tiers in src/terminal.rs (palette #RRGGBBAA encoding / default-color / truecolor / 256-color downsample) |
| Latest release | v0.26.1 (Cargo.toml); pinned checkout 78951393 (2026-07-01) is 363 commits past the tag, version string unchanged |
NOTE
This deep-dive surveys bat as the industrialization of syntect — the asset pipeline (assets/, src/assets*), syntax detection (src/syntax_mapping*), the controller/printer line loop, and terminal color mapping. Paging, git-diff decoration, and the --style system appear only where they shape the highlighting path. syntect's own internals (the parse/highlight state machines, grammar format, regex engines) are the syntect deep-dive; the grammar model is developed in the cluster synthesis.
Overview
What it solves
cat with colors sounds trivial; the engineering is in making a process-per-invocation tool feel instant while carrying a ~1 MB grammar corpus, detecting the right language for arbitrary files, and never breaking on hostile input. bat's README states the delegation up front (README.md):
"
batuses the excellentsyntectlibrary for syntax highlighting.syntectcan read any Sublime Text.sublime-syntaxfile and theme."
Everything bat adds is product scaffolding around that engine: 226 bundled .sublime-syntax grammars and 32 .tmTheme themes (93 git submodules), pre-parsed into binary dumps; filename/extension/shebang detection; a look-ahead line buffer for ranges; ANSI tiering for real terminals; --style decorations; automatic paging.
Design philosophy
Startup latency is the product. A pager runs thousands of times a day in
fzfpreviews and git aliases; parsing 226 YAML grammars per invocation is unaffordable. bat's answer is serialize at build time, embed, lazy-load at run time — the single most reusable idea in the codebase for anysparkles:syntax-shaped tool.Never fail on content. Unknown language → plain text; binary → skipped or dumped; pathological line → unstyled passthrough. Highlighting failures must never make
catworse thancat(the cluster's degrade-gracefully posture).Fidelity is outsourced, and guarded by regression tests. bat trusts the Sublime grammar ecosystem via syntect, and protects the trust boundary with syntax regression tests, because (
doc/assets.md):"…we do not run into issues we had in the past where either (1) syntax highlighting for some language is suddenly not working anymore or (2)
batsuddenly crashes for some input (due toregexincompatibilities betweensyntectand Sublime Text)."
How it works
The controller → printer pipeline
The library surface (PrettyPrinter builder → Controller → Printer) drives one loop. Controller::run sets up output/paging and calls print_file_ranges per input, which reads line by line with a VecDeque look-ahead buffer sized line_ranges.largest_offset_from_end() + 1 (for relative ranges like --line-range=:500) and dispatches each line to the active printer (controller.rs). Two printers implement the Printer trait: SimplePrinter (plain, --style=plain + no colors) and InteractivePrinter, which per line decodes bytes, optionally strips/sanitizes ANSI, calls highlight_regions_for_line → Vec<(syntect::Style, &str)>, and writes each region through the ANSI mapper with the enabled decorations (printer.rs).
The gate matters for a clone: a highlighter is only constructed when needed — needs_to_match_syntax is false for binary-as-binary or uncolored, unsanitized output, so bat --plain piped never touches the syntax set (printer.rs).
A stateful engine forces a full feed
syntect's HighlightLines carries ParseState/HighlightState across lines, so every preceding line must pass through the highlighter even if it will never be printed. The controller encodes this explicitly (controller.rs):
RangeCheckResult::BeforeOrBetweenRanges => {
// Call the printer in case we need to call the syntax highlighter
// for this line. However, set `out_of_range` to `true`.
printer.print_line(true, writer, line_nr, &line, max_buffered_line_number)?;and print_line computes regions before honoring the flag (printer.rs):
let regions = self.highlight_regions_for_line(&line)?;
if out_of_range {
return Ok(());
}So --line-range=1000:1010 still pays regex-tokenization for lines 1–999 (it does short-circuit after the last range). This is the TextMate model's forward-only state constraint surfacing as product cost — the design fact that motivates Shiki's GrammarState checkpointing and, in a two-mode design, an escape hatch to a whole-buffer parse that at least buys precision for the same full-file price.
The 16 KiB long-line guard
The one hard performance guard sits exactly on the engine boundary (printer.rs):
// skip syntax highlighting on long lines
let too_long = line.len() > 1024 * 16;
let for_highlighting: &str = if too_long { "\n" } else { line };
let mut highlighted_line = highlighter_from_set
.highlighter
.highlight_line(for_highlighting, highlighter_from_set.syntax_set)?;
if too_long {
highlighted_line[0].1 = line;
}A line over 16 KiB (minified JS, data blobs) feeds the regex engine a bare "\n" — keeping the parse/highlight state machinery stepping so later lines stay consistent — and stitches the real text back as a single unstyled region. Compare Shiki's two per-line guards (length, off by default; time, 500 ms default): bat bounds by length only, always on.
Lazy assets: syntaxes.bin / themes.bin
The grammar corpus lives in the binary as bincode dumps embedded via include_bytes! (syntaxes.bin ≈ 1 MB, themes.bin ≈ 58 KB) and is deserialized only on first use: HighlightingAssets holds a SerializedSyntaxSet plus a OnceCell<SyntaxSet>, populated by get_syntax_set() on demand (assets.rs). The enum's doc comment states the intent (serialized_syntax_set.rs):
"A SyntaxSet in serialized form, i.e. bincoded and flate2 compressed. We keep it in this format since we want to load it lazily."
Themes go a level finer — LazyThemeSet is "Same structure as a syntect::highlighting::ThemeSet but with themes stored in raw serialized form, and deserialized on demand" (lazy_theme_set.rs), each LazyTheme holding compressed bytes + a OnceCell<Theme>, so listing themes or using one theme never pays for the other 31. Compression is chosen per asset against the lazy-loading design, documented on the constants themselves (assets.rs): individual lazy themes compress ("Compress for size of ~40 kB instead of ~200 kB without much difference in performance due to lazy-loading", COMPRESS_LAZY_THEMES = true) while the outer sets don't re-compress already-compressed members (COMPRESS_SYNTAXES = false, COMPRESS_THEMES = false).
The rebuild story: assets/create.sh → bat cache --build parses the submodule grammars and reserializes; users can rebuild with their own syntaxes/themes into a cache dir, stamped by AssetsMetadata and rejected on major/minor version mismatch. Contributors are told not to commit the regenerated dump — "A new binary cache file will be created once before every new release of bat. This avoids bloating the repository size unnecessarily." (doc/assets.md) — with inclusion gated on grammar popularity (>10 000 Package Control downloads) plus a syntax regression test.
Syntax detection order
Detection is layered, documented on get_syntax_for_path (assets.rs):
"Detect the syntax based on, in order:1. Syntax mappings with
MappingTarget::MapToandMappingTarget::MapToUnknown(e.g./etc/profile->Bourne Again Shell (bash))2. The file name (e.g.Dockerfile)3. Syntax mappings withMappingTarget::MapExtensionToUnknown(e.g.*.conf)4. The file name extension (e.g..rs)"
with first-line/shebang detection (find_syntax_by_first_line, after stripping a UTF-8 BOM) as the documented fallback when path-based detection returns UndetectedSyntax (assets.rs). The mapping layer (syntax_mapping.rs + builtin.rs) is glob-based, user rules take precedence over ~built-ins ("Rules in front have precedence"), and — a startup micro-optimization worth noting — compilation of the built-in glob matchers is offloaded to a background thread at startup, cancelled via an AtomicBool on drop if it loses the race with first use. MapToUnknown/MapExtensionToUnknown exist to suppress wrong matches (e.g. generic *.conf), encoding "no syntax" as a positive decision. Compare tree-sitter's per-grammar file-types / first-line-regex / content-regex metadata — same problem, registry-side instead of tool-side.
Style → ANSI: the color tiers
src/terminal.rs (82 lines) is the entire rendering backend, and its to_ansi_color encodes a three-tier terminal reality (terminal.rs):
"Themes can specify one of the user-configurable terminal colors by encoding them as #RRGGBBAA with AA set to 00 (transparent) and RR set to the 8-bit color palette number. The built-in themes ansi, base16, and base16-256 use this."
a == 0x00— palette passthrough:rin0x00–0x07maps to the named ANSI colors (SGR 30–37), higher values toFixed(n)(256-color codes) — themes that respect the user's terminal palette rather than imposing RGB.a == 0x01— the terminal's default fg/bg: no escape emitted at all.- otherwise — true RGB if
true_color($COLORTERM-detected), else downsampled viaansi_colours::ansi256_from_rgb— the same downsampling call the tree-sitter CLI uses.
as_terminal_escaped then maps syntect FontStyle bits: BOLD and UNDERLINE always; ITALIC only if --italic-text=always (defensive default for terminals that render italics badly). The #RRGGBBAA alpha-channel trick is a de-facto extension of the .tmTheme format invented for terminal output — exactly the kind of convention a D clone must either adopt or re-invent.
Algorithm & grammar class
- The absence is the finding: bat implements no parsing or tokenization of its own. Its
Cargo.tomldependency is minimal and telling —syntectwithdefault-features = false, features = ["parsing"], plus the engine choice re-exported as bat features (regex-onigin the defaultminimal-applicationset;regex-fancyfor pure-Rust builds). All grammar-class questions resolve to syntect (and thence to the TextMate model). - What bat contributes algorithmically is boundary logic: detection layering (mappings → name → extension mapping → extension → first line), the look-ahead range buffer, the 16 KiB guard, encoding sniffing (UTF-8/UTF-16 BOM handling before highlighting).
- Grammar corpus policy, not grammar theory: curation (popularity threshold, post-conversion patches to upstream grammars, regression tests) is bat's substitute for owning the grammar semantics.
Interface & composition model
- Dual surface: a polished CLI (
bat) and a real library (batcrate:PrettyPrinterbuilder — inputs, language, theme, ranges, decorations — overController), used by tools likedelta-adjacent viewers; the CLI is a thinclapshell over the sameConfig. - Composition with syntect is by value, not abstraction: bat re-exports syntect types in its config (
Theme,SyntaxSet) rather than wrapping them — an honest coupling that keeps the product layer thin but pins bat to syntect's API and serialization format (cache invalidation is by bat version stamp). - Decorations compose as components: line numbers, git-change markers, and the grid are
Decorationobjects gated by--stylecomponents (StyleComponent::{Auto, Grid, Header, LineNumbers, Snip, Plain, …}), each contributing a fixed-width panel; the panel is dropped wholesale when the terminal is too narrow. - Process composition: output flows through an optional pager (
less, with flag negotiation) and respects--wrap,--terminal-width, piping detection — the un-glamorous half of a terminal product that a clone underestimates at its peril.
Performance
- Startup: the lazy-assets design. Embedded pre-serialized dumps +
OnceCelldeserialization on first use + per-theme laziness + background glob compilation;--list-languages/--list-themesand plain-text paths never parse grammars at all. This is the pattern to copy — Shiki solves the same cold-start problem with fine-grained bundles and lazy imports; bat solves it with binary embedding. - Steady state: syntect's per-line regex cost, unmitigated (bat adds no caching within a run beyond the engine's own). The 16 KiB guard bounds the worst case per line.
- The full-feed tax: ranges pay for all preceding lines (see above); the README's own recommended
fzfpreview recipe (--line-range=:500) works because it truncates the tail, not the head. - Memory: the look-ahead buffer is bounded by the largest relative-range offset; regions borrow from the line (
Vec<(Style, &str)>); the deserializedSyntaxSet(the dominant heap object) is built once and shared. - No parallelism — single-threaded per file (except the glob-build thread); a pager's bottleneck is the terminal, not the CPU, once the guards are in place.
Highlighting & theme model
This is the extra spine dimension for the syntax-highlighting cluster:
- Label vocabulary — inherited TextMate scopes, consumed already-resolved: bat never sees scope stacks, only syntect's final
Style { foreground, background, font_style }per region. The product layer is deliberately below the vocabulary — which is why it could, in principle, swap engines without touching rendering. - Inter-unit state — syntect's, fed religiously. One
HighlighterFromSet(aHighlightLines+ itsSyntaxSet) per input, every line pushed through in order, including invisible ones; state is never checkpointed or persisted (contrast Shiki's extractableGrammarState). - Theme resolution —
.tmThemescope selectors inside syntect, plus bat's two product-level extensions: the#RRGGBBAAalpha encodings for palette/default colors (making terminal-native themes expressible in a format that predates terminals-as-targets), and theme-pair auto-selection (--theme=autopicking light/dark variants by terminal background detection — the terminal cousin of Shiki'slight-dark()). - Rendering targets — ANSI only. Truecolor/256/palette tiers, italics gate, background highlights for
--highlight-line; per-line validity holds trivially (SGR state is reset per region write). HTML is out of scope by design — the gap Shiki fills from the opposite side, and the reasonsparkles:syntaxpairs the two.
Error handling & recovery
- Content never errors. Undetected syntax → plain text (
EMPTY_SYNTECT_STYLEgrey regions); binary input → skipped with a header (or--binary=as-text); invalid UTF-8 → lossy/replacement handling upstream of the highlighter; over-long lines → unstyled. The degrade-gracefully posture, end to end. - Errors that do surface are environmental: missing files, broken pipes (handled globally —
SIGPIPE-safe writing), unknown theme/language names (explicit user error, with suggestions), cache-version mismatches (rejected with a rebuild hint). - The trust boundary is tested, not assumed: the syntax regression suite exists specifically because syntect/Sublime regex divergence has crashed bat before (
doc/assets.md) — engine-compatibility risk managed exactly like Shiki's generated compat report, in test-suite form. - No recovery semantics of its own — there is nothing to recover; mis-highlighting (a runaway string after a weird construct) is displayed as-is. The model's precision ceiling, accepted.
Ecosystem & maturity
- Adoption: one of the most-installed Rust CLI tools (packaged in every major distro; the standard
cat/pager upgrade andfzfpreview backend);v0.1.0shipped April 2018,v0.26.xcurrent, with an active org (bat-developers) beyond the original author. - The asset pipeline is community infrastructure: 93 grammar/theme submodules, curated by inclusion criteria, patched where Sublime-upstream lags — bat effectively maintains the terminal ecosystem's working set of Sublime grammars.
- Library consumers embed
PrettyPrinterfor highlighted output without re-solving assets/detection; the crate's feature matrix (regex-onigvsregex-fancy,paging,git) lets embedders shed weight. - Boundary: bat is a viewer. No editing, no incrementality, no HTML, no semantic features — and in eight years it has needed none of them, which is itself a data point on how much of the highlighting problem is product engineering around a line-based engine.
Strengths
- The reference product architecture for a highlighting CLI: builder → controller → printer, decorations as components, pager negotiation, encoding sniffing — all the parts a clone needs enumerated in ~12 kLOC.
- Lazy binary assets solve grammar-corpus cold start decisively (embed serialized, deserialize on demand, per-theme granularity, background glob compilation).
- Layered, overridable detection with negative mappings (
MapToUnknown) — precise defaults, user-correctable, shebang fallback. - Terminal-realistic theming: palette/default-color encodings, truecolor detection with 256-downsample, italics gating — respects real terminals instead of assuming truecolor.
- Guarded engine boundary: the 16 KiB skip keeps hostile input from touching the regex engine while preserving state continuity.
- Trust-but-verify grammar supply: curation criteria + regression tests around a delegated engine.
Weaknesses
- The full-feed tax: stateful line highlighting means ranges pay for the whole prefix; no checkpointing or index exists to amortize repeated views of the same file.
- Engine-locked: syntect types in the public config and syntect's bincode in the cache format make the "swappable engine" theoretical; there is no precise mode and no seam to add one.
- ANSI-only: no HTML/export target despite the token stream being right there.
- Length-only guard: no per-line time bound — a pathological-but-short line can still stall the oniguruma engine (Shiki's 500 ms default has no bat equivalent).
- Assets are release-coupled: grammars update only with bat releases (or manual
bat cache --build); the corpus lags upstream Sublime packages. - Single-threaded viewing of large files highlights slower than it could; no incremental reuse across invocations of the same file.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Delegate the engine entirely to syntect | Instant access to the Sublime grammar corpus; product effort goes to UX | Model ceiling (line-local, approximate) and API/format lock-in; regressions arrive from upstream |
| Embed pre-serialized assets, deserialize lazily | Process-per-invocation startup stays instant despite a ~1 MB corpus | Corpus frozen per release; cache rebuild machinery + version stamping needed |
Per-theme lazy deserialization (LazyThemeSet) | Using one theme never pays for 32; compressed themes cost ~40 kB vs ~200 kB | More asset-layer machinery; three compression flags to reason about |
| Feed every line through the highlighter, even unprinted | Correct colors for any --line-range under a stateful engine | O(prefix) cost for windowed views — the full-feed tax |
| 16 KiB length guard, state-preserving | Bounds regex blowup on minified/hostile lines without desyncing later lines | Long lines silently lose colors; no time-based bound at all |
| Layered detection with negative mappings | Precise defaults (/etc/profile→bash) and suppression (*.conf→unknown→first-line) | A rule DSL users must learn; glob set large enough to warrant background compilation |
#RRGGBBAA palette/default encodings in themes | Terminal-native themes (ansi, base16) that respect user palettes | A de-facto format extension other tools must know to interoperate |
Italics off unless --italic-text=always | Many terminals render italics wrong; default output stays safe | Theme fidelity silently reduced by default |
| ANSI as the only backend | Focus; the terminal is the product | No HTML/doc export; every token dies at the SGR fold |
Sources
src/printer.rs—InteractivePrinter,highlight_regions_for_line(16 KiB guard),needs_to_match_syntax, out-of-range early return,as_terminal_escapedcall sitesrc/controller.rs— line loop, look-ahead buffer sizing, the out-of-range highlighter-feed commentsrc/assets.rs—HighlightingAssets,OnceCelllazy syntax set, detection-order doc, first-line/BOM fallback, compression constants;src/assets/serialized_syntax_set.rs+src/assets/lazy_theme_set.rs— the lazy-form doc comments;src/assets/build_assets.rs— bincode+flate2 serializationsrc/syntax_mapping.rs(+builtin.rs) —MappingTarget, precedence, background glob buildsrc/terminal.rs—to_ansi_colortiers (palette/default/truecolor/256), font-style mappingCargo.toml— syntect pin (5.3.0,features = ["parsing"]),regex-onig/regex-fancyfeatures, dual license, descriptionREADME.md— syntect attribution,.tmTheme-only custom themes, fzf recipe;doc/assets.md— asset rebuild flow, inclusion criteria, regression-test rationale- Related deep-dives: syntect (the engine) · Shiki (the web counterpart) · tree-sitter-highlight (the precise mode bat lacks) · the highlighting synthesis