Lumis Docs
Advanced

Pinning parsers

How lumis-lock.toml pins parser versions, which runtimes need one, and how each project declares the languages it may load.

Highlighting resolves a parser the first time a document names one, then keeps what it found. Whichever compatible version a machine downloaded first is the one it uses from then on, so two machines can render the same document differently and nothing records which parser produced which output.

Pinning fixes that. It also closes a quieter gap: the parser is verified against a digest the package metadata declares, and that metadata arrives over the network — so without a pin, a wrong response on first fetch is trusted forever.

One rule, three declarations

Every runtime already honours the same rule: a language outside the set it can use renders plain. What differs is how the set is declared, and a project gets exactly one of these.

RuntimeDeclares its set inNeeds lumis-lock.toml
RustCargo.toml featuresno
JavaScript / TypeScriptpackage.json dependenciesno
Elixirlumis-lock.tomlyes
CLInothingno

Rust links parsers statically, so a language you did not build in does not exist and there is nothing to fetch. JavaScript installs @lumis-sh/wasm-* packages, which your package manager's lockfile already pins. Elixir fetches parsers at runtime and has no manifest of its own — that is the gap the lock fills.

The CLI is its own runtime with its own store. lumis highlight resolves freely, because a viewer is not the application whose output a lock governs, and a highlighter that behaved differently depending on the directory you ran it in would be worse than one that does not.

Record a language and cache it
mix lumis.add rust
A bundle
mix lumis.add bundle-web
Move a pinned version
mix lumis.update rust
mix lumis.update --all
Stop pinning
mix lumis.remove rust
Cache exactly what the lock names — the release step
mix lumis.install

What the file holds

lumis-lock.toml
version = 1
range = "0.26"
[[package]]
name = "@lumis-sh/wasm-rust"
version = "0.26.4"
languages = ["rust"]
manifest_sha256 = "..."
parser_sha256 = "..."
definition_hash = "..."

Commit it. range records the Tree-sitter series it was resolved against, so upgrading Lumis across series tells you to re-resolve rather than silently ignoring every entry.

Rows are keyed on the package because that is what gets published and versioned, and one package can back several languages — ejs and erb are both @lumis-sh/wasm-embedded-template. languages records what you added, so a lock can pin ejs without also allowing erb.

A bundle is shorthand when you add it: its members are recorded and the bundle name is not. Re-run mix lumis.add bundle-web to pick up a language the bundle gained, so widening what a project may load stays a visible diff.

What a lock changes at runtime

Naming a language explicitly is your decision, so it is an error when the lock does not pin it:

Lumis.Languages.load("haskell")
#=> {:error, :not_locked}

A language an injection names is content rather than a decision — a fenced block in a README, say — so that block renders plain and the document is fine. This is the same rule Lumis already follows for a language that fails to download: a failure costs one block, not the page.

Releases

A release ships priv/ but not your project directory, so mix lumis.add and mix lumis.install keep a copy of the lock in the data directory and Lumis reads it from there at boot. Running mix lumis.install as a build step is enough; point config :lumis, :data_dir at a writable directory if the default does not suit.

config :lumis, :lock names the lock explicitly when neither default applies. A path that does not exist is an error rather than "no lock", since booting without the pins you checked in would restore exactly the behaviour the lock removes.

On this page