I went looking for dependencies to remove from term-llm, a Go terminal application that talks to a growing collection of LLM providers. Chroma looked promising. term-llm uses it in exactly two places: syntax colouring inside fenced Markdown blocks, and syntax colouring inside file diffs.

The dependency itself looked modest in go.mod. The way it was packaged was not.

Importing Chroma’s lexer package gives term-llm 285 registered lexer entries. lexers.Names(true) returns 752 entries when canonical names and aliases are included. The registry contains Go and Ruby, but also MorrowindScript, MonkeyC, OpenEdge ABL, TypoScript, Brainfuck, a Game Boy assembler and three flavours of Genshi. Importing Chroma’s style package similarly gives us about 70 themes even though term-llm asks for one: Monokai.

The tempting conclusion is obvious: throw out the least plausible defaults and collect the savings.

That conclusion is mostly wrong.

The short version: removing only the least plausible default lexers saved 414 KB from a 63.6 MB stripped binary and 113 KB from its 21.1 MB release-like gzip. A broad 104-entry lexer configuration plus two styles saved 1.85 MB stripped and, in a warm local process-launch benchmark, reduced median time for a trivial command from 12.1 ms to 7.3 ms. The useful fix is not deciding whether Brainfuck deserves colours. It is letting Go programs choose a registry at compile time.

What term-llm actually uses

The rendering code imports three Chroma packages:

import (
    "github.com/alecthomas/chroma/v2"
    "github.com/alecthomas/chroma/v2/lexers"
    "github.com/alecthomas/chroma/v2/styles"
)

For a fenced Markdown block, term-llm asks for a lexer by language name and then falls back to filename matching:

lexer := lexers.Get(language)
if lexer == nil {
    lexer = lexers.Match("file." + language)
}

For a diff, it matches the real filename:

lexer := lexers.Match(filePath)

Once Chroma turns source into token types such as keyword, string and comment, term-llm formats the tokens itself. It does not use Chroma’s formatter packages. It asks the style registry for Monokai, reads the foreground colour and bold/italic/underline flags, and emits ANSI escape sequences.

That is a narrow use of Chroma’s core. It is not a narrow import of Chroma’s data.

Why the whole registry enters the binary

Chroma v2.23.1 keeps its XML lexers under lexers/embedded and builds one global registry. The important code is essentially:

//go:embed embedded
var embedded embed.FS

var GlobalLexerRegistry = func() *chroma.LexerRegistry {
    reg := chroma.NewLexerRegistry()
    paths, _ := fs.Glob(embedded, "embedded/*.xml")
    for _, path := range paths {
        reg.Register(chroma.MustNewXMLLexer(embedded, path))
    }
    return reg
}()

There are 268 XML lexer files totalling 1,874,204 bytes. Importing lexers embeds the directory, enumerates every XML file during package initialization, reads each configuration and registers it. Chroma lazily loads some rule details, but all configurations are constructed and all embedded XML remains reachable.

Another 19 production Go files implement or modify handwritten lexers. Go, Markdown, Raku, Haxe, HTTP and several template languages live here. These are ordinary files in the same lexers package, so importing lexers.Go would not help even if term-llm changed its API calls: package initialization still runs and the aggregate embedded filesystem still enters the program.

There are no independently importable packages like this:

import "github.com/alecthomas/chroma/v2/lexers/go"

A runtime allowlist also cannot shrink the binary. By the time a program chooses its allowlist, the compiler and linker have already included the package and its embedded data.

Styles repeat the pattern on a smaller scale. The styles package embeds and parses roughly 70 XML themes into a global map. term-llm calls styles.Get("monokai"); the other themes still arrive.

How I measured it

The whole-binary tests used term-llm commit 8df82b0e, Chroma v2.23.1 and the installed Linux amd64 Go toolchain (go1.26.5-X:nodwarf5). Every binary was built from the same term-llm source with:

go build -trimpath -ldflags='-s -w' -o term-llm .
gzip -9 -c term-llm > term-llm.gz

I built five variants:

  1. Full: stock Chroma.
  2. Pruned unlikely defaults: removed 58 XML assets plus the handwritten Genshi, Markless and TypoScript files; retained full styles.
  3. Broad core: retained 93 XML assets and the core handwritten files, producing 104 registered entries and 304 results from Names(true) when aliases are included; retained only Monokai and Chroma’s fallback style.
  4. Narrow: retained a deliberately austere 24-name registry plus the same two styles.
  5. No Chroma: replaced both term-llm highlighting paths with their existing plaintext fallback behaviour.

For startup, I randomized process order across 200 warm executions of term-llm version per variant to reduce temporal load, thermal and ordering bias. Peak RSS is the median of 40 separate /usr/bin/time executions.

I also measured every lexer asset individually. Each XML file was built alone into the same stripped probe program and compared with an empty registry. Each handwritten Go lexer file was measured by removing it from the complete package and rebuilding. These per-asset figures reveal the expensive outliers, but they are not perfectly additive: Go section alignment commonly rounds stripped deltas to 4 KiB, composite lexers share dependencies, and changing one asset perturbs gzip compression around it. The five whole term-llm binaries are the authoritative totals.

The complete inventory is available as a sortable CSV and a grouped Markdown table. The raw startup summary contains the run counts, medians, means, p95 values and RSS ranges. Whole-binary prose uses decimal KB/MB; per-asset tables use binary KiB.

Five real binaries

VariantRegistered entriesStripped binaryRelease-like gzipStripped savingGzip saving
Full Chroma28563,586,569 B21,071,955 B
Pruned unlikely defaults22363,172,873 B20,959,448 B413,696 B112,507 B
Broad core + two styles10461,735,177 B20,634,078 B1,851,392 B437,877 B
Narrow 24-entry prototype + two styles2461,083,913 B20,497,621 B2,502,656 B574,334 B
No Chroma060,330,249 B20,217,550 B3,256,320 B854,405 B

The narrow prototype keeps more than three quarters of the maximum uncompressed saving available from deleting highlighting altogether. The broad configuration keeps much more coverage while still recovering about 57% of Chroma’s maximum stripped cost.

The broad and narrow rows change two things: lexer coverage and the style bundle. A separate release-like style-only build attributes 143,360 stripped bytes and 23,756 gzip bytes to reducing roughly 70 styles to Monokai plus fallback. The remaining difference is primarily lexer data and code; the startup comparison likewise measures the combined lexer-and-style configuration, not lexers in isolation.

The compressed numbers are less dramatic. A release-like gzip already compresses XML and repetitive lexer tables well. Even deleting Chroma completely saves 854 KB from the experiment’s 21.1 MB gzip file. This is not a download-size emergency.

Startup is the stronger result

The registries do work during package initialization. That shows up before term-llm does anything useful.

VariantMedian warm version process time
Full Chroma12.05 ms
Pruned unlikely defaults11.46 ms
Broad core + two styles7.32 ms
Narrow registry + two styles6.78 ms
No Chroma6.43 ms

These are local whole-process measurements on an Intel i9-14900K, not isolated measurements of Chroma. The association between smaller lexer-and-style configurations and lower process time is consistent with eager package initialization, but the experiment does not isolate initialization from binary layout or other whole-process effects.

Peak RSS did not establish a reliable ordering. Median ru_maxrss ranged from about 43 to 47 MiB, the 40-run ranges overlapped, and the broad, narrow and no-Chroma variants were non-monotonic. The raw startup summary includes those numbers, but they do not support a quantitative memory-saving claim.

The broad lexer-and-style configuration was associated with a 4.73 ms lower median for this trivial command while preserving 104 registered entries. That difference is irrelevant once an LLM request begins, but it matters more for shell completions, version, config inspection and other commands expected to feel instantaneous.

In a separate full-versus-narrow probe, filename matching was about five times faster with the smaller registry:

OperationFull registryNarrow registry
Match known filename1.38 ms/op0.269 ms/op
Match unknown filename1.41 ms/op0.275 ms/op
Get known alias6–7 ns/op6–7 ns/op

Get uses indexed names and remained extremely cheap. Match walks filename patterns and was about five times faster for these two registries in this probe; two points are not a general scaling law. term-llm caches diff highlighters by path and fenced-code highlighters by language, so this is a first-use cost rather than a hot-loop problem. Chroma’s own API documentation warns that Match iterates the lexer collection. This does not mean retained languages tokenize faster: a separate retained-Java lexer benchmark was effectively unchanged.

The obvious cuts are not the expensive part

I classified the registry into three product categories:

“Drop” is a packaging judgment, not a claim that nobody should use the language. If a user asks an LLM about RPGLE, plaintext remains readable; optional lexer bundles would still be possible.

The most expensive obvious defaults to drop were:

Lexer assetMarginal strippedMarginal gzipWhy it is surprising here
OpenEdge ABL36 KiB12.1 KiBLarge proprietary enterprise language
Scilab32 KiB14.1 KiBLarge, specialized numerical environment
Markless24 KiB10.8 KiBHandwritten lexer for obscure markup
Igor Pro24 KiB9.7 KiBProprietary scientific environment
Factor20 KiB4.9 KiBNiche concatenative language
V shell16 KiB4.9 KiBV-specific shell rather than generic shell
RPGLE16 KiB7.1 KiBIBM i language
TypoScript familyabout 20 KiBabout 6.8 KiBTYPO3-specific lexer plus support data
MorrowindScript12 KiB4.3 KiBScripting language for a 2002 game
Genshi family12 KiB6.5 KiBThree lexers for an old Python template system
GDScript312 KiB4.2 KiBVersion-specific duplicate beside current GDScript
RGBDS Assembly8 KiB2.5 KiBGame Boy-specific assembly
MCFunction8 KiB1.3 KiBMinecraft command files
HolyC8 KiB1.9 KiBTempleOS language
Plutus Core4 KiB1.0 KiBCardano’s internal language
MonkeyC4 KiB1.4 KiBGarmin wearable applications
SNBT4 KiB0.6 KiBMinecraft’s stringified NBT format
Brainfuckalignment noise0.4 KiBAn educational esolang

The complete drop list also contains ActionScript 1 and 3, BlitzBasic, BQN, Ceylon, Cheetah, Dylan, fixed-format Fortran, Io, J, Jungle, Lox, Mason, Modula-2, MoonScript, Myghty, NDISASM, Newspeak, Pig, POV-Ray, QBasic, Ring, SNOBOL, SourcePawn, TASM, Turing, WDTE, Whiley and Z80 assembly.

Removing that entire group saves 113 KB from the release-like gzip. A private fork maintained solely for this subset would cost more in upkeep than it returns.

The real whales live in the respectable long tail

Two handwritten lexers dominate the surprising costs:

LexerGo sourceMarginal strippedMarginal gzip
Raku60,139 B264 KiB73.7 KiB
Haxe24,876 B132 KiB42.3 KiB

Raku handles dynamic delimiters, runtime rule replacement, POD markup and a large state machine. It is a serious implementation of a language designed to make lexical analysis earn its salary. Haxe is another substantial handwritten lexer.

Together they cost about 396 KiB stripped and 116 KiB compressed in the probes—roughly the same stripped cost as deleting the entire “obviously implausible default” category from term-llm.

Other costly long-tail assets include:

LexerMarginal strippedMarginal gzip
Racket48 KiB14.7 KiB
Emacs Lisp remapping44 KiB14.4 KiB
WebGPU Shading Language32 KiB8.2 KiB
GDScript24 KiB7.5 KiB
Crystal24 KiB3.6 KiB
Common Lisp remapping24 KiB7.1 KiB
Mojo20 KiB3.5 KiB
C320 KiB3.4 KiB
V16 KiB3.9 KiB
Standard ML16 KiB2.0 KiB
Gnuplot16 KiB3.4 KiB
AppleScript16 KiB5.6 KiB
Arduino16 KiB5.3 KiB

These are legitimate languages with specialized audiences. They show why a useful default cannot be selected only by removing obsolete or amusing entries: the meaningful saving requires a product decision about the long tail.

Some expensive lexers should stay

The largest XML lexer in Chroma v2.23.1 is Solidity at 61,396 source bytes. Its isolated probe added about 60 KiB stripped and 15.4 KiB compressed. That is expensive relative to one lexer and trivial relative to a general coding tool.

The same applies to several core languages and formats:

LexerMarginal strippedMarginal gzip
Batchfile32 KiB3.0 KiB
CSS28 KiB7.9 KiB
SCSS28 KiB7.5 KiB
Julia24 KiB8.1 KiB
Ruby24 KiB3.7 KiB
Elixir24 KiB2.5 KiB
Python20 KiB3.7 KiB
Scala20 KiB2.5 KiB
Sass20 KiB5.3 KiB
Objective-C16 KiB2.8 KiB
Perl16 KiB3.1 KiB
C++12 KiB2.5 KiB
Rust12 KiB2.2 KiB

Dropping a current language to save tens of kilobytes would make the default less useful for little return. The broad core kept these.

What a broad default can cover

The 104-entry build was not a Sam-specific language list. It retained a proper general developer surface:

Unknown languages use term-llm’s existing plaintext fallback. They lose colours, not content.

The relevant term-llm UI and rendering tests passed with both the broad registry and the narrow prototype, and go build ./... passed. Chroma’s own copied test suite was not clean without updating fixtures and expectations for the intentionally removed lexers, so neither experimental fork should be described as production-ready. Composite lexers also deserve dedicated closure tests before a real implementation: HTML can delegate to JavaScript and CSS, Docker can delegate to shell and JSON, and handwritten Go templates depend on HTML/template definitions. A generated registry should encode and test these relationships rather than rely on a list maintained by memory.

Styles are loose change

Trimming the style package from roughly 70 themes to Monokai plus Chroma’s fallback saved:

It also avoided parsing dozens of unused style XML files during initialization. This is real but not a standalone project. If lexer packaging is fixed, style packaging should be fixed alongside it. Copying Monokai into term-llm solely for a 24 KB gzip reduction would not justify the maintenance.

Chroma has no supported switch for this

term-llm currently uses Chroma v2.23.1. As checked on 16 July 2026, Chroma’s stable release was v2.27.0, and its newest prerelease was v3.0.0-alpha.5. The v3 lexer package still embeds the complete embedded directory and constructs one global registry.

There is an old issue that proposed almost exactly this change: #163, “Refactor to package lexers, formatter and styles individually”. In 2018, the proposal argued that users should be able to compile only the lexers, formatters and styles needed for a task. The maintainer pointed out that global registries also serve consumers such as Hugo; the proposer withdrew the idea, and the issue closed without an implementation.

Chroma did later improve the representation. PR #598, the Chroma v2 work, moved many lexers to XML, added external definition loading and reported an example binary reduction from 8.8 MB to 7.8 MB. That made a consumer-owned registry technically possible and reduced the aggregate package’s cost, but it did not add per-lexer packages or a bundled allowlist. I found no newer open issue or active pull request providing selective lexer builds, build tags or configurable embedding.

Chroma does expose chroma.NewLexerRegistry(), and a consumer can register selected lexer objects. The missing part is packaging: the XML data and handwritten lexer implementations are not available as independent importable units. A consumer cannot build a small registry without copying assets, generating code or maintaining a fork.

The API I would want

The cleanest model is compile-time composition:

import (
    "github.com/alecthomas/chroma/v3"
    chromago "github.com/alecthomas/chroma/v3/lexers/go"
    chromaruby "github.com/alecthomas/chroma/v3/lexers/ruby"
)

registry := chroma.NewLexerRegistry()
registry.Register(chromago.Lexer)
registry.Register(chromaruby.Lexer)

Chroma could also provide generated bundles:

import "github.com/alecthomas/chroma/v3/lexers/bundles/developer"

registry := developer.Registry()

A developer bundle could cover common languages, configuration formats and aliases. A full bundle would preserve today’s behaviour. Applications with narrower needs could compose packages directly. Styles could use the same arrangement.

The implementation needs to preserve more than names. Filename patterns, aliases and composite dependencies are part of the contract. LLMs routinely emit fence labels such as sh, shell, console, dockerfile, tsx, jsx, yml, golang, rs, py and csharp. A bundle that includes the lexer but loses those aliases would look correct in a dependency report and fail in actual conversations.

What term-llm should do

I would not merge the experimental private Chroma fork as-is.

A term-llm-owned curated registry would mean carrying selected MIT-licensed XML files, handwritten implementations such as Go, provenance, regeneration scripts and dependency-closure tests. That is defensible if startup or binary size becomes an urgent product constraint. It is not justified merely by the 113 KB compressed saving from the first-pass pruning.

The better order is:

  1. Publish the measurements.
  2. Turn the experimental manifests and benchmark harness into a small reproducible case.
  3. Open an upstream Chroma issue proposing compile-time registries or generated bundles while v3 is still in alpha.
  4. Keep stock Chroma in term-llm unless upstream declines or the cost becomes operationally important.

The broad configuration is the useful target if term-llm eventually acts alone. It preserves a generous developer experience, saves 1.85 MB stripped in this build, reduces eager initialization and does not turn language selection into a personal popularity contest.

Dependency audits need binaries, not module counts

This investigation began with a list of obscure languages, but removing only those entries recovered little. Most of the useful saving required a broader product decision, and the compressed artifact reduced the apparent payoff further.

Counting modules would not reveal embedded lexer data. Reading source sizes would overstate compressed impact. A controlled build exposed linked code, embedded data and initialization work as separate costs—and showed that maintenance transferred to a private fork belongs in the calculation too.

Chroma is good at syntax highlighting. The problem is not that it supports 285 lexer entries. The problem is that a Go application cannot say which of them it wants.