LumisDocs
Formatters

Custom Formatters

Build your own output format on top of Lumis tokens in Rust, JavaScript, TypeScript, and Elixir.

Write your own formatter when the built-in ones don't produce the output you need. Common reasons:

  • wrap output with custom HTML
  • emit Markdown, LaTeX, or a structured AST
  • overlay your own semantic ranges with annotations
  • add data attributes or extra classes
  • feed Lumis tokens into an existing rendering pipeline

Rust

Implement the Formatter trait.

Lumis highlights the source and hands your formatter the resulting event stream. A Start opens a scope, Source events carry byte ranges into the original text, and End closes it.

lumis::formatters::Formatter is a re-export of lumis_core::formatter::Formatter, so one implementation serves both crates: hand it to lumis::highlight(), which parses the source with the grammar language() names, or call render() yourself with an event stream you already have.

use lumis::{
    events::HighlightEvent,
    formatters::html::{
        closing_tags, open_code_tag, open_pre_tag,
        span_inline,
    },
    formatters::Formatter,
    languages::Language,
    themes,
};
use std::io::{self, Write};

struct MinimalHtmlFormatter {
    language: Language,
    theme: Option<themes::Theme>,
}

impl Formatter for MinimalHtmlFormatter {
    fn language(&self) -> Language {
        self.language
    }

    fn render(
        &self,
        source: &str,
        events: &[HighlightEvent<'_>],
        output: &mut dyn Write,
    ) -> io::Result<()> {
        let mut scopes: Vec<&str> = Vec::new();

        open_pre_tag(output, None, self.theme.as_ref())?;
        open_code_tag(output, &self.language)?;

        for event in events {
            match event {
                HighlightEvent::Start { .. } => {
                    scopes.push(
                        event.scope().unwrap_or_default(),
                    );
                }
                HighlightEvent::End => {
                    scopes.pop();
                }
                HighlightEvent::Source { start, end } => {
                    let text = &source[*start..*end];
                    let scope = scopes
                        .last()
                        .copied()
                        .unwrap_or_default();
                    write!(
                        output,
                        "{}",
                        span_inline(
                            text,
                            Some(self.language),
                            scope,
                            self.theme.as_ref(),
                            false
                        )
                    )?;
                }
                _ => {}
            }
        }

        closing_tags(output)?;
        Ok(())
    }
}

render writes into output and returns io::Result<()>. An Err comes back from write_highlight() and write_highlight_with_options(); highlight() panics with it. When a time or match limit bound the highlight, Lumis calls render_budgeted() instead, with the BudgetExhausted that applied. Its default calls render(), so override it only to record the fact in your output.

JavaScript / TypeScript

Pass an object with language and render(source, events). Lumis highlights the source and hands you the event stream; the synchronous free functions highlightIter() and highlightEvents() from @lumis-sh/lumis remain available if you need to highlight something else from inside render().

import {createHighlighter} from '@lumis-sh/lumis'
import type {Formatter} from '@lumis-sh/lumis/formatters'
import {
  openPreTag,
  openCodeTag,
  closingTags,
  spanInline,
} from '@lumis-sh/lumis/formatters/html'
import rust from '@lumis-sh/wasm-rust'
import frappe from '@lumis-sh/themes/catppuccin_frappe'

const hl = await createHighlighter({languages: [rust]})

const formatter: Formatter = {
  language: rust,
  render(source, events) {
    const bytes = new TextEncoder().encode(source)
    const decoder = new TextDecoder()
    const scopes: string[] = []
    const parts: string[] = []

    parts.push(
      openPreTag({preClass: 'docs-demo', theme: frappe}),
    )
    parts.push(openCodeTag(this.language))

    for (const event of events) {
      if (event.type === 'start') {
        scopes.push(event.scope)
      } else if (event.type === 'end') {
        scopes.pop()
      } else if (event.type === 'source') {
        const slice = bytes.subarray(event.start, event.end)
        const text = decoder.decode(slice)
        const scope = scopes[scopes.length - 1]
        parts.push(
          scope
            ? spanInline(text, {
                language: 'rust',
                scope,
                theme: frappe,
              })
            : text,
        )
      }
    }

    parts.push(closingTags())
    return parts.join('')
  },
}

const html = hl.highlight('fn main() {}', formatter)

render returns the document as a string. Its optional third argument, budget, is 'time' or 'matches' when a limit bound the highlight, and undefined otherwise. An error thrown inside render propagates out of highlight() unchanged.

Elixir

Implement the Lumis.Formatter behaviour and pass the module as the formatter. Lumis highlights the source and hands render/3 the event stream, with the resolved language in options.

Lumis.Formatter.HTML and Lumis.Formatter.ANSI have the same HTML and terminal pieces the built-in formatters use.

defmodule MinimalHtmlFormatter do
  @behaviour Lumis.Formatter

  alias Lumis.Formatter.HTML

  @impl true
  def render(source, events, options) do
    language = Keyword.fetch!(options, :language)
    theme = Keyword.get(options, :theme)

    # Resolving a scope against a theme is a table
    # lookup, so build the table once here rather
    # than crossing into Rust once per token.
    attrs =
      HTML.span_attrs(theme: theme, language: language)

    body =
      Enum.map(events, fn
        {:start, %{scope: scope}} ->
          HTML.open_span(attrs, scope)

        :end ->
          "</span>"

        {:source, %{start: start, end: stop}} ->
          len = stop - start
          HTML.escape(binary_part(source, start, len))

        # Lumis adds event kinds as it grows. Render
        # the ones you know and skip the rest, or a
        # newer Lumis raises FunctionClauseError here.
        _event ->
          []
      end)

    [
      HTML.open_pre_tag(theme: theme),
      HTML.open_code_tag(language),
      body,
      HTML.closing_tags()
    ]
  end
end

Lumis.highlight!("defmodule App do\nend",
  formatter:
    {MinimalHtmlFormatter,
     language: "elixir", theme: "github_light"}
)

render/3 returns iodata, so there is no need to flatten it into a binary; Lumis does that once at the end. options also carries :budget_exhausted: :time or :matches when a limit bound the highlight, and nil otherwise. To fail, raise: Lumis.highlight/2 returns {:error, %Lumis.RenderError{reason: :custom_formatter}} naming the formatter and the exception, and Lumis.highlight!/2 raises. When the result is not one string, such as a list of rendered lines, Lumis.highlight_events/3 returns the same event stream without a formatter.

For line-based output, reach for render_lines_from_events/3 rather than splitting rendered markup on newlines. The results contain no line terminators; join wrapped lines with "\n" when building a block. Use the shared line layout CSS for a custom formatter's full-width lines. A <span> that crosses a newline has to be closed and reopened for each line's tags to nest. Swapping span_attrs/1 for span_multi_themes_attrs/1 is the whole difference between one theme and a set of them, restyled later by CSS alone:

defmodule MultiThemeFormatter do
  @behaviour Lumis.Formatter

  alias Lumis.Formatter.HTML

  @themes [light: "github_light", dark: "dracula"]

  @impl true
  def render(source, events, options) do
    language = Keyword.fetch!(options, :language)

    attrs =
      HTML.span_multi_themes_attrs(
        themes: @themes,
        default_theme: "light-dark()",
        language: language
      )

    body =
      source
      |> HTML.render_lines_from_events(events, attrs)
      |> Enum.with_index(1)
      |> Enum.map(fn {line, number} ->
        HTML.wrap_line(number, line)
      end)
      |> Enum.intersperse("\n")

    [
      HTML.open_multi_themes_pre_tag(
        themes: @themes,
        default_theme: "light-dark()"
      ),
      HTML.open_code_tag(language),
      body,
      HTML.closing_tags()
    ]
  end
end

Lumis.highlight!("defmodule App do\nend",
  formatter: {MultiThemeFormatter, language: "elixir"}
)

Lumis.Formatter.ANSI is the terminal counterpart, and styles/1 is where a scope becomes a color. Reach for it rather than theme.highlights, which misses the fallbacks :terminal applies: tag.delimiter is painted by tag in a theme that styles only tag. A theme can also style a scope per language, and an injected block carries its own language, so the table is per language.

defmodule MinimalTerminalFormatter do
  @behaviour Lumis.Formatter

  alias Lumis.Formatter.ANSI

  @impl true
  def render(source, events, options) do
    theme = Keyword.get(options, :theme)

    {output, _scopes, _tables} =
      Enum.reduce(events, {[], [], %{}}, fn
        {:start, %{scope: scope, language: language}},
        {output, scopes, tables} ->
          {output, [{scope, language} | scopes], tables}

        :end, {output, [_scope | scopes], tables} ->
          {output, scopes, tables}

        {:source, %{start: start, end: stop}},
        {output, scopes, tables} ->
          text = binary_part(source, start, stop - start)

          case scopes do
            [] ->
              {[text | output], scopes, tables}

            [{scope, language} | _rest] ->
              # One table per language, built once
              # and read per token.
              tables =
                Map.put_new_lazy(tables, language, fn ->
                  ANSI.styles(
                    theme: theme,
                    language: language
                  )
                end)

              style =
                ANSI.style_for(tables[language], scope)

              painted = ANSI.paint(text, style)
              {[painted | output], scopes, tables}
          end

        # Lumis adds event kinds as it grows. Render
        # the ones you know and skip the rest, or a
        # newer Lumis raises FunctionClauseError here.
        _event, state ->
          state
      end)

    Enum.reverse(output)
  end
end

Lumis.highlight!("defmodule App do\nend",
  formatter:
    {MinimalTerminalFormatter,
     language: "elixir", theme: "dracula"}
)

ANSI.paint/2 takes the nil that style_for/2 returns for an unstyled scope and hands the text back unchanged, so there is no branch to write for it. This minimal example paints only the innermost scope; use :terminal when enclosing scopes should contribute their styles too.

Highlight options

The built-in formatters take options that enrich highlighting. Custom formatters take the same options, as a trailing argument to the token iterator. The flat iterator presents rainbow brackets as the same six scopes the built-ins render for backward compatibility.

highlightIter(
  source,
  this.language,
  frappe,
  (text, language, _range, scope) => {
    // scope is punctuation.bracket.rainbow.1
    // through .6 on bracket pairs
  },
  {rainbowBrackets: true},
)

Nested events instead of flat tokens

highlightIter / highlight_iter hand you one callback per token. Its scope and language identify the innermost open scope; its style combines all open scopes. Foreground, background, and underline style come from the innermost scope that sets each property. Bold, italic, and strikethrough accumulate; false does not clear an enclosing style. A scope with no theme entry inherits its enclosing style, so an unstyled variable inside a colored string keeps the string's color. The terminal formatter uses the same rule.

When nesting matters — a string scope wrapping injected tag scopes inside a template literal — take the events instead. A start opens a scope, source events carry byte ranges, and end closes it. Annotations add their own start/end pairs, carrying your data and a resolved UTF-8 byte range. An invalid annotation range returns an error.

Call highlightEvents inside a formatter’s render method.

import {createHighlighter, highlightEvents} from '@lumis-sh/lumis'
import type {Formatter} from '@lumis-sh/lumis/formatters'
import rust from '@lumis-sh/wasm-rust'

const hl = await createHighlighter({languages: [rust]})

const formatter: Formatter = {
  language: rust,
  render(source) {
    const events = highlightEvents(source, this.language, {
      rainbowBrackets: true,
      annotations: [{
        range: {type: 'offset', start: 4, end: 9},
        data: {id: 7},
      }],
    })

    for (const event of events) {
      if (event.type === 'annotationStart') {
        console.log(event.range, event.data.id)
      }
      if (event.type === 'start') {
        console.log(event.scope, event.language)
      }
      if (
        event.type === 'decorationStart' &&
        event.decoration.type === 'rainbowBracket'
      ) {
        console.log(
          'real zero-based depth',
          event.decoration.depth,
        )
      }
    }
    return ''
  },
}

hl.highlight('let value = (1);', formatter)

Available helpers

Every runtime offers the same set, so a formatter written against one can be ported to another without reimplementing a piece of it. Names follow each host's convention — scope_to_class in Rust and Elixir, scopeToClass in the npm package — and so do argument shapes.

RuntimeModuleReference
JavaScript / TypeScript@lumis-sh/lumis/formatters/htmlsource
JavaScript / TypeScript@lumis-sh/lumis/formatters/ansisource
Rustlumis::formatters::htmldocs.rs
Rustlumis::formatters::ansidocs.rs
ElixirLumis.Formatter.HTMLhexdocs
ElixirLumis.Formatter.ANSIhexdocs

HTML

CapabilityHelper
Escape text, an attribute value, or braces a template would readescape, escape_attr, escape_braces
Resolve a scope to a CSS class, or a style to CSS declarationsscope_to_class, style_to_css, text_decoration
Open a <span> for a scopeopen_span, span_inline_attrs, span_inline, span_linked_attrs, span_linked
Open one for a set of themes, as CSS custom propertiesspan_multi_themes_attrs, span_multi_themes, sanitize_theme_name
Open and close the blockopen_pre_tag, open_multi_themes_pre_tag, open_code_tag, close_pre_tag, close_code_tag, closing_tags
Take the block attributes instead of the rendered tag, to merge your ownpre_attrs, multi_themes_pre_attrs, code_attrs
Render a tag from attributes, escaping the values and checking the namesopen_tag, valid_attr_name
Wrap a line, and decide whether it is highlightedwrap_line, line_is_highlighted, highlight_line_class
Turn the whole event stream into lines, reopening spans across newlinesrender_lines_from_events

Rendered lines carry their exact source \n or \r\n, after any closing syntax spans. The unterminated final line carries none, so pass each result to wrap_line directly in every runtime.

ANSI

CapabilityHelper
Build a color escapehex_to_rgb, rgb_to_ansi, style_to_ansi
Paint text, and clear formattingpaint, reset

A few helpers are one runtime's alone, because the boundary dictates the signature rather than taste. Elixir returns whole scope tables from classes/0, span_attrs/1 and ANSI.styles/1, because crossing into Rust once per token to read a constant costs more than the highlighting; the npm package exports encodeSource and decodeSourceSlice, because its strings are UTF-16 and the event offsets are UTF-8 byte offsets. fixtures/formatter-helpers.json records the set, the exceptions, and why each one is one.

Tip

Copy the built-in formatter closest to what you want, then strip out what you don't need.

On this page