Lumis Docs
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.id_name()),
                            scope,
                            self.theme.as_ref(),
                            false,
                            false
                        )
                    )?;
                }
                _ => {}
            }
        }

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

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)

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

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. When the 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