Skip to content

Extensions

rs-rich-ext is where everything that is not in Python rich lives: semantic styles, extra highlighters, hyperlinks, diagnostics, logging, macros and more. This page covers the pieces every other ext feature builds on: the extension registry, the extended theme, highlighters, hyperlinks and the input-safety helpers.

cargo add rs-rich rs-rich-ext

The packages are rs-rich and rs-rich-ext; the use lines are rich and rich_ext.

Why a separate crate

rs-rich (the core) is a faithful mirror of upstream rich: its default behaviour matches the Python library, and golden tests check covered output byte for byte. That is what lets the port absorb a new upstream release as a diff instead of a merge.

So the rule is: the core never learns about our features. Our additions live in rs-rich-ext and reach the core only through its public API and a few extension-point traits (Renderable, Highlighter, RenderEnvironment). The dependency only goes one way:

rs-rich-ext ──▶ rs-rich        (the core has no idea rs-rich-ext exists)

In practice this means:

  • A plain rich::Console behaves like Python rich. Nothing in this crate changes it until you opt in.
  • Opting in is explicit: install extensions onto a console, pass a theme, wrap a renderable. There is no global state and no auto-discovery.
  • Anything here renders through ordinary core types, so it mixes freely with Table, Panel, Text and the rest.

See Extending for the design and the roadmap to a public plugin API.

Installing the default extensions

ConsoleExt::install_extensions registers this crate's default extensions on a console. Today that is NumberHighlighter, which styles every run of ASCII digits, including ones inside words that core's ReprHighlighter leaves alone:

let line = "build42 finished: 3 crates, x86_64, 12 warnings";

let core = Console::new();
let mut with_ext = Console::new();
with_ext.install_extensions(); // registers NumberHighlighter

// `build_text` applies a console's markup and highlighters.
console.print(&core.build_text(line));
console.print(&with_ext.build_text(line));

Core highlighting, then the same line with the ext NumberHighlighter

The first line is the core alone: 3 and 12 are numbers to ReprHighlighter, but build42 and x86_64 are not. The second line adds the ext highlighter.

In an application you would normally write:

use rich::Console;
use rich_ext::ConsoleExt;

let mut console = Console::new();
console.install_extensions();
console.print_str("build42 finished");

Highlighters only run on markup strings

Registered highlighters run in print_str, render_str_to_string and build_text: the paths that parse console markup. Printing a Text, Table or other renderable with print does not re-highlight it.

The extension registry

ExtensionRegistry holds factories, not instances. install calls each factory and adds the result to a console, so one registry can set up any number of consoles, in a fixed order.

use rich_ext::{ExtensionRegistry, NumberHighlighter};

/// Our extensions, as a registry that can be installed on any console.
fn my_extensions() -> ExtensionRegistry {
    let mut registry = ExtensionRegistry::new();
    registry
        .register_highlighter(|| Box::new(NumberHighlighter::new()))
        .register_highlighter(|| Box::new(TodoHighlighter));
    registry
}

fn my_console(builder: ConsoleBuilder) -> Console {
    let mut console = builder.build();
    // `install` calls every factory, so one registry serves many consoles.
    my_extensions().install(&mut console);
    console
}

Install it and print as usual:

console.print_str("TODO: retry step 3 of 7 (FIXME: flaky on runner2)");

Numbers and TODO markers highlighted by a custom registry

  • ExtensionRegistry::new() is empty; ExtensionRegistry::with_defaults() holds what install_extensions installs.
  • register_highlighter returns &mut Self, so calls chain.
  • Highlighters must be Send, so the console stays Send (a Live display may move it to a refresh thread).
  • The registry only knows highlighters today. The registry API is usable but not yet a stability promise; see Extending.

Writing a highlighter

A highlighter is any type implementing the core Highlighter trait: it gets the plain text of a Text and adds style spans to it.

/// Highlights `TODO` and `FIXME` markers, wherever they appear.
struct TodoHighlighter;

impl Highlighter for TodoHighlighter {
    fn highlight(&self, text: &mut Text) {
        // `highlight_words` styles every occurrence; the count is not needed.
        let _ = text.highlight_words(&["TODO", "FIXME"], "bold black on yellow", true);
    }
}

Useful Text methods for highlighters:

Method What it does
highlight_words(&words, style, case_sensitive) Style every occurrence of any word
highlight_regex(pattern, Some(style), prefix) Style regex matches; named groups get prefix + group name as a theme key
stylize(style, start, end) Style a byte range of plain()

Gotchas:

  • Spans are byte offsets into text.plain(), not character or cell indexes.
  • Registered highlighters run before the built-in ReprHighlighter, and explicit markup is applied last, so [green]42[/] stays green.
  • Styles can be theme names ("repr.number"): they are resolved when the text is rendered, against the console's theme.

The extended theme

Core's default theme is upstream's list of styles and nothing more. Upstream has no [error] or [warning] style, so neither does the core. rich_ext::theme::extended_theme() returns upstream's theme plus our names:

use rich_ext::theme::extended_theme;

fn themed_console(builder: ConsoleBuilder) -> Console {
    builder.theme(extended_theme()).build()
}
console.print_str("[error]error[/]  [warning]warning[/]  [info]info[/]  [success]ok[/]");
console.print_str("[help.option]--width[/] [help.metavar]<SIZE>[/]  [diff.added]+ added[/]  [diff.removed]- removed[/]");

Semantic, help and diff styles from the extended theme

What it adds:

Names Styles Used by
error, warning, info, success bold red, yellow, cyan, bold green your markup (EXTRA_STYLES)
help.*, config.* usage, headings, options, metavars, config winners CLI authoring (cli_doc::STYLES)
diff.*, test.* added/removed lines, hunks, test states Diffs and test reports (diff::STYLES)

Other ext renderables look up their own keys and fall back to a built-in style when the theme does not define them, so they work with any theme. To restyle them, insert the key into your theme:

Prefix Keys Renderable
diagnostic. error, warning, info, note, help, message, headline, gutter, secondary, suggestion Diagnostic
stacktrace. error, function, location, library, bridge StackTrace
event. message, field, value, severity.<level> StructuredEvent
let mut theme = rich_ext::theme::extended_theme();
theme.insert("diagnostic.error", rich::Style::parse("bold magenta").unwrap());

hyperlink::Hyperlinker finds linkable things in text and turns them into OSC 8 terminal hyperlinks:

  • http:// and https:// URLs (trailing punctuation is left out);
  • file paths: absolute, ./, ../, ~/, dir/file.ext, and a bare file.ext when a line follows;
  • path:line and path:line:column locations;
  • #123 and owner/repo#123 references, once you set a repository.
use rich_ext::hyperlink::Hyperlinker;

let linker = Hyperlinker::new()
    .base_dir("/work/app")
    .repository("https://github.com/acme/app");
let message = "see src/main.rs:12:5, fixed in #42 and acme/lib#7 (https://acme.dev/faq)";

// Add OSC 8 link spans to a Text in place...
let mut text = Text::new(message);
linker.link(&mut text);
console.print(&text);

// ...or just ask what would be linked.
let mut table = Table::new();
table.add_column("Span");
table.add_column("URL");
for link in linker.find(message) {
    table.add_row(&[&message[link.start..link.end], &link.url]);
}
console.print(&table);

A message with a path, issue references and a URL, and the links found in it

Links are style attributes, so they cost nothing where they cannot be shown: a console that is not a terminal prints the same text with no escape codes. (The screenshot cannot show links; in a terminal that supports OSC 8, the spans are clickable.)

Options

Builder Default Effect
urls(bool) on Link http(s):// URLs
paths(bool) on Link paths and path:line:col
base_dir(dir) none Resolve relative paths against dir
repository(url) none Enable #123 references, linked to url/issues/123
editor(template) none Link files through an editor URL instead of file://
enabled(bool), disabled() enabled The plain fallback, chosen explicitly

find(text) returns the Links (byte start, end and url) without touching anything; link(&mut text) adds the link spans; file_url and reference_url build single URLs.

Paths are percent-encoded in file:// URLs and in an editor template's {path}: every byte outside RFC 3986's path characters becomes %XX, which covers control characters, space, ", %, #, ?, <, >, ^, the backtick, braces, |, brackets and non-ASCII (as its UTF-8 bytes). Backslashes become / first. So /tmp/café report.rs links to file:///tmp/caf%C3%A9%20report.rs, and a crafted file name cannot break out of the OSC 8 escape.

With an editor template, file links open in your editor at the right place. {path}, {line} and {column} are replaced; a missing line or column becomes 1.

use rich_ext::hyperlink::Hyperlinker;

let vscode = Hyperlinker::new().editor("vscode://file{path}:{line}:{column}");
let url = vscode.file_url("/work/app/src/main.rs", Some(12), Some(5));
console.print(&Text::new(format!("{url:?}")));

// A ready-made, linked `path:line:col` label in a style of your choice.
console.print(&vscode.location("src/lib.rs", Some(3), None, "magenta"));

// `disabled()` is the explicit plain fallback: same text, no links.
let mut text = Text::new("src/lib.rs:3");
Hyperlinker::disabled().link(&mut text);
assert!(text.spans().is_empty());

An editor URL, a linked location label

No slash after file

{path} is an absolute path that already starts with / (Windows paths become /C:/…), so write vscode://file{path}:{line}:{column}. vscode://file/{path}… produces a double slash.

location(path, line, column, style) builds a path:line:col label that is already linked; diagnostics, the diagnostics dashboard and stack traces use it for their locations.

Anywhere a highlighter is accepted

Hyperlinker implements Highlighter, so it can be registered on a console or handed to the log handler:

use rich_ext::{hyperlink::Hyperlinker, RichHandler};

let handler = RichHandler::new(rich::Console::new())
    .highlighter(Some(Box::new(Hyperlinker::new())));

Sanitizing untrusted text

Core rich keeps the ESC character, as upstream does, so text that contains escape sequences can move the cursor or clear the screen when printed. When you display file names, log lines or other text you did not write, pass it through sanitize_terminal_controls first:

use rich_ext::sanitize_terminal_controls;

// A file name from an untrusted source that tries to clear the screen.
let untrusted = "report\x1b[2J\x1b[H.txt\tsize\u{7}";
let safe = sanitize_terminal_controls(untrusted);
console.print(&Text::new(safe));

Escape sequences shown as visible symbols

  • ESC becomes ␛, other C0 controls become their Unicode control pictures (␇, ␍), DEL becomes ␡, and C1 controls become \u{009B}-style text.
  • Newlines and tabs are kept: they are layout, not attacks.
  • It does not touch markup. For untrusted text passed to print_str, also escape it with rich::markup::escape (see Markup and style).

Decoding input explicitly

encoding::Encoding decodes bytes strictly, in an encoding you choose. It never guesses:

use rich_ext::encoding::{has_utf16_bom, Encoding};

let bytes = [0xff, 0xfe, b'h', 0, b'i', 0]; // UTF-16LE with a BOM
assert!(has_utf16_bom(&bytes));
let text = Encoding::Utf16.decode(&bytes).expect("valid UTF-16");
console.print(&Text::new(format!("decoded: {text:?}")));

// Nothing is guessed: headerless UTF-16 needs an explicit byte order.
let err = Encoding::Utf16.decode(b"h\0i\0").unwrap_err();
console.print(&Text::new(format!("error:   {err}")));
let le: Encoding = "utf-16le".parse().expect("a known name");
console.print(&Text::new(format!(
    "utf-16le: {:?}",
    le.decode(b"h\0i\0").unwrap()
)));

Decoded text and a strict decoding error

Variant Accepts
Utf8 UTF-8, with or without a BOM
Utf16 UTF-16 with a BOM (the BOM picks the byte order)
Utf16Le, Utf16Be That byte order, with or without a matching BOM

Malformed input, an odd byte count, an unpaired surrogate, a BOM that contradicts the chosen byte order and UTF-32 signatures are all errors (io::ErrorKind::InvalidData). Encoding parses from utf-8, utf-16, utf-16le and utf-16be, which is how the rich CLI's --encoding option uses it.

Run the example

cargo run -p rs-rich-ext --example guide_extensions

See also