# Budget (/advanced/budget)

A budget is the work one render is allowed to do. Lumis bounds two dimensions of
it, and says so in the output when either one runs out.

## Why a render needs a bound

Highlighting cost is not a function of input size. A parse can retry, a query
pattern can stay open across a large subtree, and a malformed document can drive
either far past what its byte count suggests. A 40 KB file that is mostly one
unterminated construct can cost more than a 4 MB file that parses cleanly, so
"reject anything over N bytes" does not bound anything. Time does.

## The two dimensions

| Dimension | Option | Default | Bounds |
| --- | --- | --- | --- |
| Query matches | `budget.match_limit` | 8192 | in-progress query matches tree-sitter keeps, for the highlight and bracket queries alike |
| Wall clock | `budget.time_limit` | 5000 ms | the whole render — parse and query walk together |

Both dimensions bound the same render, so they are one `budget` option rather
than two loose ones; set only the one you care about and the other keeps its
default. `time_limit` is **milliseconds in every runtime**, including Rust.

It does not count language loading. Not the root language, which is ready
before the clock starts, and not the languages an injection names — those load
in the middle of the walk, and the time they take is given back. A parser
download and compile costs hundreds of milliseconds against a render measured in
single digits, so charging it would mean the first document to use a language
came back plain and the next one did not, with nothing about either document to
explain the difference. [Load languages ahead of time](/languages) if a first
request should not pay the latency either.

## Exhaustion is never an error

Parser failures are separate from budget exhaustion. If a parser returns no
tree without an interrupt, Lumis reports a parse error. Increasing the time
limit will not fix a crashing parser. Rust exposes `ParseFailed`, Elixir
returns `%Lumis.RenderError{reason: :parse}`, and JavaScript and the CLI report
which language failed to parse.

Neither bound raises, returns an error tuple, or truncates. The document comes
back whole, in the formatter you asked for. What changes is how much of it is
highlighted, and the two bounds differ there:

- **Time** returns **the whole document, unhighlighted**. A render stopped part
  way has no tree left to highlight from, so there are no scopes to emit.
- **Matches** returns **the whole document, still highlighted**, with some
  scopes missing. Tree-sitter drops in-progress matches at the limit and keeps
  going, so the output is ordinary highlighted output that is less complete
  than it would otherwise be.

A page that renders a code block slowly and correctly stays a page. One that
raises turns a large paste into a 500.

Partial highlighting is deliberately not offered for the time limit. Stopping a
query walk halfway produces output that is coloured down to an arbitrary byte
and bare after it, which reads as a rendering bug rather than as a limit.

## The marker

Every HTML formatter marks the `<pre>` when a budget was exhausted:

```html
<pre class="lumis" data-lumis-budget="time"><code class="language-json">...
```

| Value | Meaning |
| --- | --- |
| `time` | the time limit was reached |
| `matches` | tree-sitter dropped matches at the match limit |

`matches` is the case that needs the attribute most: the output is ordinary
highlighted output, and nothing else distinguishes it from a document that
genuinely had few captures.

Style it, log it, count it, or ignore it. Lumis attaches no behaviour to it.

```css
pre.lumis[data-lumis-budget] { border-left: 2px solid var(--warn); }
```

The terminal and BBCode formatters have no attribute channel, so they report
nothing. They still degrade the same way — plain text on the time limit, missing
scopes on the match limit — the caller just has no way to tell. Event-stream
APIs return events only, so they carry no marker either; the rendering entry
points are where it appears. A [custom formatter](/formatters/custom) is told
which limit applied and decides for itself what to do with it.

## Setting the limits

### JavaScript / TypeScript

```ts
import { highlight, htmlInline } from "@lumis-sh/lumis";

const html = await highlight(source, htmlInline({language, theme}), {
  budget: {
    timeLimit: 2000, // milliseconds
    matchLimit: 16384,
  },
});
```

`timeLimit: 0` removes the time limit. The budget applies to the native addon
and to the portable `web-tree-sitter` runtime, including in browsers.

### Elixir

```elixir
Lumis.highlight(source,
  budget: [
    # milliseconds
    time_limit: 2_000,
    match_limit: 16_384
  ]
)
```

`time_limit: 0` removes the time limit; omitting it uses the 5000 ms default.

### Rust

```rust
use lumis::highlight::{Budget, HighlightOptions};

// milliseconds
let options =
    HighlightOptions::new().budget(Budget::new().time_limit(Some(2_000)).match_limit(16_384));

// No time limit.
let unbounded = HighlightOptions::new().budget(Budget::new().time_limit(None));
```

The unit is milliseconds here too, rather than a `Duration`, so that one number
means the same thing in every runtime. `Some(0)` also removes the time limit.

### CLI

```bash
lumis highlight big.json --budget-time-limit 2000 --budget-match-limit 16384
```

A CLI has no nesting, so the group becomes a flag prefix.
`--budget-time-limit 0` removes the time limit.


`lumis4j` does not expose these options; it is maintained in a separate
repository.

## The limit is a bound, not a stopwatch

The time limit is checked where tree-sitter offers to be interrupted, during the
parse and during the query walk. Those points are frequent on ordinary content
and the render stops within a few milliseconds of its limit. They are not
uniform: on a document built to be pathological, a single step between two
checks can run long, and the render overshoots. The portable
`web-tree-sitter` runtime overshoots furthest, because its query walk offers
fewer of them.

So treat the limit as a ceiling on the order of magnitude, not a deadline you
can schedule against. If a render must return within a hard wall-clock bound,
enforce that where you can cancel the work — a worker with a timeout, a request
deadline — and use the time limit to keep the common case cheap.

## Choosing a value

The default is 5000 ms because it is far above what real content costs and far
below what a browser or a proxy will wait. Across Lumis's own corpus the 99th
percentile render is about 13 ms; content that legitimately takes seconds is
megabytes long.

Lumis deliberately publishes no bytes-per-second rule of thumb. Throughput
varies by more than two orders of magnitude between languages and between
well-formed and malformed documents of the same language, so any such number
would be wrong in the cases that matter. Measure your own content, then leave
headroom.

Lower it when a render happens on a request path and a slow one is worse than a
plain one. Raise it for a batch job rendering documents you know are large and
valid. Remove it only where nothing is waiting on the result.

## Related

- [Match limit](/usage/highlight#match-limit) — what the match bound does inside
  tree-sitter, and when scopes go missing
- [Languages](/languages) — load languages ahead of the first render