Skip to content

Code highlighters

Syntax, Markdown code blocks, source_view and the diff views all highlight code through one extension point, core's CodeHighlighter. Two adapters ship with the port, and you can write your own in about 40 lines.

The examples come from guide_highlighters.rs:

cargo run -p rs-rich-ext --example guide_highlighters --features testing

Choose one

syntect (default) lumis (rs-rich-lumis)
Parsing TextMate grammars, regex-based tree-sitter grammars
Code Pure Rust by default; the onig feature swaps in the Oniguruma C library C (tree-sitter's runtime and grammars)
Languages syntect's default set 116 with default features; bundles select fewer
Themes 7 syntect themes, plus ansi_dark and ansi_light 256 Neovim themes (default monokai), plus ansi_dark and ansi_light
Speed 2–4× faster with onig a fixed cost per language, then the fastest on long files (benchmarks)
Binary about 15.7 MB for the CLI about 169 MB for the CLI with every language; a bundle is far smaller
Minimum Rust the workspace's (1.90) 1.91

syntect is the default everywhere and is what upstream parity is measured with. Choose lumis for tree-sitter's more precise parsing, its languages or its themes, and accept the larger build.

One tree-sitter per binary

lumis links tree-sitter's C runtime. Another crate that links a different version of that runtime (another tree-sitter highlighter, for example) cannot go into the same binary. That is why lumis lives in its own crate and is behind an off-by-default feature in the CLI.

In the CLI, choose with --highlighter NAME and --code-theme NAME, or the highlighter and code_theme keys in rich.toml. rich doctor lists the highlighters compiled in, their themes, and the one in use.

Use one

Give a highlighter to one Syntax, or make it the default for every block a console renders: register it, choose it by name and install the registry.

// One block of code, with an engine and one of its themes.
let code = "fn main() {\n    let answer = 42;\n}\n";
console.print(
    &Syntax::new(code, "rust")
        .highlighter(Arc::new(Keywords))
        .theme("blue"),
);

// Or every block a console renders: register it and choose it by name.
let mut registry = ExtensionRegistry::with_defaults();
registry
    .register_code_highlighter("keywords", Arc::new(Keywords))
    .unwrap();
registry
    .set_default_code_highlighter("keywords", None)
    .unwrap();
let mut console = Console::new();
registry.install(&mut console);
console.print(&Syntax::new(code, "rust"));

A Syntax with a highlighter of its own always uses it; the console's default covers everything else, including Markdown code blocks. Theme names are each highlighter's own.

Write your own

An adapter implements four methods. highlight returns one line per element of code.split('\n'), each with styled byte ranges: sorted, not overlapping, inside the line and on character boundaries. An unknown language is plain text, and an unknown theme is an error. This one only bolds (or colours) a few Rust keywords:

/// Highlights a handful of Rust keywords, and nothing else.
struct Keywords;

const KEYWORDS: &[&str] = &["fn", "let", "mut", "if", "else", "return", "struct"];

impl CodeHighlighter for Keywords {
    fn highlight(
        &self,
        code: &str,
        language: Option<&str>,
        theme: &str,
    ) -> Result<HighlightedCode, HighlightError> {
        let style = match theme {
            "bold" => Style::parse("bold").unwrap(),
            "blue" => Style::parse("bright_blue").unwrap(),
            other => return Err(HighlightError::UnknownTheme(other.to_string())),
        };
        let rust = matches!(language, Some("rust" | "rs"));
        // One line per `split('\n')` element; spans are byte ranges in the line.
        let lines = code
            .split('\n')
            .map(|line| {
                let mut spans = Vec::new();
                let mut start = 0;
                for word in line.split(|c: char| !c.is_alphanumeric() && c != '_') {
                    if rust && KEYWORDS.contains(&word) {
                        spans.push(HighlightSpan {
                            range: start..start + word.len(),
                            style: style.clone(),
                        });
                    }
                    start += word.len()
                        + line[start + word.len()..]
                            .chars()
                            .next()
                            .map_or(0, char::len_utf8);
                }
                HighlightedLine {
                    spans,
                    newline_style: None,
                }
            })
            .collect();
        Ok(HighlightedCode {
            lines,
            ..Default::default()
        })
    }
    fn default_theme(&self) -> &str {
        "bold"
    }
    fn themes(&self) -> Vec<String> {
        vec!["bold".into(), "blue".into()]
    }
    fn languages(&self) -> Vec<String> {
        vec!["rust".into()]
    }
}

Core checks what an adapter returns against the source. It drops bad spans, renders missing lines unstyled, strips hyperlinks from styles and takes the text from the source, so an adapter can change colours but never the text. That is a safety net, not a licence: the conformance kit holds adapters to the contract.

Check it with the conformance kit

rich_ext::testing::conformance (the testing feature) runs the checks both shipped adapters pass in CI:

  • the default theme is listed and highlights (with ansi_dark, if listed), and an unknown theme is UnknownTheme;
  • line counts match split('\n') for empty input, a missing final newline, CRLF, tabs and multi-byte text;
  • spans are sorted, disjoint, in range and on character boundaries;
  • an unknown language renders exactly as no language;
  • rendered output carries no control characters beyond styling;
  • 10,000 lines cost at most 40× what 1,000 lines do, a relative budget rather than a wall-clock one.
// The same kit the shipped adapters run in CI.
conformance::check(Arc::new(Keywords)).expect("Keywords conforms");

As a test:

#[test]
fn keywords_conforms() {
    conformance::check(Arc::new(Keywords)).unwrap_or_else(|error| panic!("{error}"));
}

check_with(…, Options { all_themes: true, .. }) also runs every theme the adapter lists. A failure lists every problem with the input that caused it.

The ANSI themes

ansi_dark and ansi_light are upstream rich's ANSI_DARK and ANSI_LIGHT: the terminal's own 16 colours, with no background, so highlighted code follows the user's terminal palette. Both shipped adapters map their token names onto upstream's Pygments token types (see Divergences #18).

// The ANSI themes use the terminal's own 16 colours, so code follows the
// user's terminal palette. Both shipped adapters have them.
console.print(
    &Syntax::new(
        "def greet(name):\n    return f\"hi {name}\"  # ok\n",
        "python",
    )
    .highlighter(SyntectHighlighter::shared())
    .theme("ansi_dark"),
);