Skip to content

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?".

FieldValue
LanguageRust (library src/ + CLI src/bin/bat/; ~12 kLOC in src/)
LicenseMIT OR Apache-2.0 (dual)
Repositorysharkdp/bat
DocumentationREADME.md + in-repo doc/; man page
Key authorsDavid Peter (sharkdp, creator, 2018) + the bat-developers org
CategorySyntax highlighting — CLI pipeline / product layer
Algorithm / grammar classn/a — delegated to syntect (HighlightLines over .sublime-syntax grammars); bat's own logic is detection + pipeline, not parsing
Lexing modeln/a (syntect's regex engines; the shipped binary selects oniguruma via regex-onig); input layer does encoding sniffing + UTF-16 decode
OutputANSI-styled terminal text with decorations (line numbers, grid, git markers, headers) via nu_ansi_term; optional paging through less
Highlighting / theme modelsyntect Style → ANSI tiers in src/terminal.rs (palette #RRGGBBAA encoding / default-color / truecolor / 256-color downsample)
Latest releasev0.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):

"bat uses the excellent syntect library for syntax highlighting. syntect can read any Sublime Text .sublime-syntax file 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

  1. Startup latency is the product. A pager runs thousands of times a day in fzf previews 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 any sparkles:syntax-shaped tool.

  2. Never fail on content. Unknown language → plain text; binary → skipped or dumped; pathological line → unstyled passthrough. Highlighting failures must never make cat worse than cat (the cluster's degrade-gracefully posture).

  3. 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) bat suddenly crashes for some input (due to regex incompatibilities between syntect and Sublime Text)."


How it works

The controller → printer pipeline

The library surface (PrettyPrinter builder → ControllerPrinter) 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_lineVec<(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):

rust
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):

rust
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):

rust
// 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.shbat 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::MapTo and MappingTarget::MapToUnknown (e.g. /etc/profile -> Bourne Again Shell (bash))2. The file name (e.g. Dockerfile)3. Syntax mappings with MappingTarget::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: r in 0x00–0x07 maps to the named ANSI colors (SGR 30–37), higher values to Fixed(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 via ansi_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.toml dependency is minimal and telling — syntect with default-features = false, features = ["parsing"], plus the engine choice re-exported as bat features (regex-onig in the default minimal-application set; regex-fancy for 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 (bat crate: PrettyPrinter builder — inputs, language, theme, ranges, decorations — over Controller), used by tools like delta-adjacent viewers; the CLI is a thin clap shell over the same Config.
  • 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 Decoration objects gated by --style components (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 + OnceCell deserialization on first use + per-theme laziness + background glob compilation; --list-languages/--list-themes and 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 fzf preview 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 deserialized SyntaxSet (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 (a HighlightLines + its SyntaxSet) per input, every line pushed through in order, including invisible ones; state is never checkpointed or persisted (contrast Shiki's extractable GrammarState).
  • Theme resolution — .tmTheme scope selectors inside syntect, plus bat's two product-level extensions: the #RRGGBBAA alpha encodings for palette/default colors (making terminal-native themes expressible in a format that predates terminals-as-targets), and theme-pair auto-selection (--theme=auto picking light/dark variants by terminal background detection — the terminal cousin of Shiki's light-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 reason sparkles:syntax pairs the two.

Error handling & recovery

  • Content never errors. Undetected syntax → plain text (EMPTY_SYNTECT_STYLE grey 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 and fzf preview backend); v0.1.0 shipped April 2018, v0.26.x current, 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 PrettyPrinter for highlighted output without re-solving assets/detection; the crate's feature matrix (regex-onig vs regex-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

DecisionRationaleTrade-off
Delegate the engine entirely to syntectInstant access to the Sublime grammar corpus; product effort goes to UXModel ceiling (line-local, approximate) and API/format lock-in; regressions arrive from upstream
Embed pre-serialized assets, deserialize lazilyProcess-per-invocation startup stays instant despite a ~1 MB corpusCorpus 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 kBMore asset-layer machinery; three compression flags to reason about
Feed every line through the highlighter, even unprintedCorrect colors for any --line-range under a stateful engineO(prefix) cost for windowed views — the full-feed tax
16 KiB length guard, state-preservingBounds regex blowup on minified/hostile lines without desyncing later linesLong lines silently lose colors; no time-based bound at all
Layered detection with negative mappingsPrecise 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 themesTerminal-native themes (ansi, base16) that respect user palettesA de-facto format extension other tools must know to interoperate
Italics off unless --italic-text=alwaysMany terminals render italics wrong; default output stays safeTheme fidelity silently reduced by default
ANSI as the only backendFocus; the terminal is the productNo HTML/doc export; every token dies at the SGR fold

Sources