Skip to content

Explaining ANSI output

Captured terminal output is hard to read: \x1b[1;38;5;208m means nothing at a glance, and a stray escape can break a log or a test snapshot. rich_ext::ansi_explain splits text into tokens, describes every escape sequence in words, and gives the text a terminal would show.

Use it to debug output that looks wrong, to check what a program writes to a pipe, or to show a readable failure when a snapshot contains escapes. rich ansi explain is the same thing on the command line.

The examples come from guide_ansi.rs (no features needed):

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

Explain a capture

explain(&str) returns an Explanation: the tokens, each with its byte offset and len, and the visible_text. ExplanationView renders it as a table of offset, raw input, kind and meaning, followed by the visible text:

fn show_table(console: &Console) {
    let explanation = explain(CAPTURE);
    // What a terminal would show, with every escape removed.
    assert_eq!(explanation.visible_text, "✔ compiled 3 crates\nlog ");
    // One token per escape, text run or control, with its byte offset.
    for spanned in explanation.escapes() {
        let meaning = spanned.token.meaning();
        eprintln!(
            "{:>3} {:<8} {meaning}",
            spanned.offset,
            spanned.token.kind()
        );
    }
    assert_eq!(explanation.invalid().count(), 1); // the trailing `ESC[`
    console.print(&ExplanationView::new(&explanation));
}

Every token of a capture, with its meaning

The capture above sets a window title, colours text, opens and closes an OSC 8 hyperlink, erases a line, moves the cursor, and ends with a truncated escape. The last one is reported as invalid rather than dropped.

What it recognises

Kind Examples Meaning given
SGR ESC[1;31m, ESC[38;5;208m, ESC[4:3m Each effect: bold, colours (16, 256 with its hex value, and RGB), underline styles and colour, resets
CSI ESC[2K, ESC[1A, ESC[?25l Cursor movement, erasing, scrolling, private modes
OSC ESC]0;title BEL, ESC]8;;url ESC\ Window titles, hyperlinks, colours and other commands, and whether BEL or ST ended them
ESC ESC 7, ESC ( B Plain escape sequences
DCS, APC, PM, SOS Sixel images Summarised by length; the payload is never dumped
control \n, \t, \r, BEL The control's name
invalid A truncated or interrupted sequence Why it is invalid
text Anything else Its length in characters

Both 7-bit (ESC [) and 8-bit C1 introducers (U+009B CSI, U+009D OSC, …) are recognised. explain_bytes(&[u8]) reads raw bytes: valid UTF-8 is decoded, lone bytes 0x80–0x9F become the C1 controls they are in 8-bit mode, and other invalid bytes become U+FFFD. Offsets then refer to the decoded string.

View options

Method Effect
escapes_only(true) Leave text runs out of the table
show_visible(false) Leave out the visible text after the table
raw_width(n) Characters of raw input shown per row before eliding (default 40, minimum 8)
mode(ViewMode::Inline) Show the text with ⟨…⟩ markers where the escapes were
fn show_options(console: &Console) {
    let explanation = explain(CAPTURE);
    // Only escapes, no text rows, no visible text, raw input cut at 24 chars.
    let view = ExplanationView::new(&explanation)
        .escapes_only(true)
        .show_visible(false)
        .raw_width(24);
    console.print(&view);
}

Escapes only, with long raw input elided

fn show_inline(console: &Console) {
    let explanation = explain(CAPTURE);
    console.print(&ExplanationView::new(&explanation).mode(ViewMode::Inline));
}

Escapes marked inline

inline_text(ascii) returns the inline form as a String. With ascii = true it uses <…> markers. The view needs no colour to carry its meaning.

Work with tokens

Token is an enum (Text, Sgr, Csi, Osc, Esc, Dcs, Control, Invalid) with the parsed parts of each sequence. kind(), raw() and meaning() work on any token. Explanation::escapes() iterates over everything except text, and invalid() over the invalid tokens only, which makes a quick test that output is clean.

fn inspect_tokens() {
    let explanation = explain("\x1b[4:3;58;2;255;0;0mwavy\x1b[0m");
    for spanned in &explanation.tokens {
        if let Token::Sgr {
            params, effects, ..
        } = &spanned.token
        {
            let described: Vec<String> = effects.iter().map(ToString::to_string).collect();
            println!("{params}: {}", described.join(", "));
        }
    }
    // The building blocks are public too.
    let effects = sgr_effects("1;38;5;208");
    assert_eq!(effects[1].code, "38;5;208");
    assert_eq!(escape_visible("\x1b[0m\x07"), "ESC[0m<BEL>");
    // Raw bytes: C1 controls (0x9B is CSI) in non-UTF-8 input are decoded.
    let from_bytes = explain_bytes(b"\x9b31mred\x9b0m");
    assert_eq!(from_bytes.visible_text, "red");
}
4:3;58;2;255;0;0: underline on (curly), underline colour #ff0000
0: reset

The building blocks are public: sgr_effects(params), csi_meaning(params, intermediates, final), osc_meaning(body), control_name(byte), escape_visible(raw) and decode_bytes(bytes). With the serde feature an Explanation serializes, tokens tagged by type.

On the command line

ls --color=always | rich ansi explain
rich ansi explain capture.txt --escapes-only
rich ansi explain capture.txt --ansi-inline

rich ansi explain [FILE|-] (or rich --ansi-explain FILE) prints the table and the visible text. --escapes-only and --ansi-inline match the view options above. The escapes are only ever printed as visible text, so explaining a capture cannot change your terminal's state. See Using the CLI.

Gotchas

  • No cursor emulation. visible_text is the text runs plus tabs and newlines. A carriage return, backspace or cursor movement does not overwrite anything, so progress-bar captures show every frame.
  • Offsets are bytes into the input (or into the decoded string for explain_bytes), not characters or cells.

See also