Skip to main content

rich/
highlighter.rs

1//! Built-in highlighters.
2//!
3//! Port of upstream `rich/highlighter.py` — the [`RegexHighlighter`] base and
4//! the [`NullHighlighter`], [`ReprHighlighter`], [`JSONHighlighter`] and
5//! [`ISO8601Highlighter`] built-ins. Patterns
6//! (`repr_patterns.rs` for repr) use lookbehind/alternation, so we compile them
7//! with `fancy-regex`.
8//!
9//! Like upstream, a highlighter stylizes **every** matched named group with the
10//! style named `{base_style}{group}`; unknown names resolve to a null style,
11//! which still splits the text into spans (so e.g. an ISO date's sub-fields
12//! create the same segment boundaries upstream produces).
13
14use std::sync::OnceLock;
15
16use fancy_regex::Regex;
17
18use crate::protocol::Highlighter;
19use crate::repr_patterns::REPR_PATTERNS;
20use crate::text::Text;
21
22/// The full ISO 8601 pattern set from upstream's `ISO8601Highlighter.highlights`,
23/// in the same order (only `iso8601.date`/`time`/`timezone` carry a visible
24/// style; the sub-groups split segments but resolve to null, as upstream). The
25/// only rewrite: upstream's single conditional pattern (`(?(hyphen)…)`, which
26/// `fancy-regex` can't compile) is split into two non-conditional alternatives —
27/// the all-hyphen/colon form and the all-basic form — which together match
28/// exactly the same strings the conditional does.
29const ISO8601_PATTERNS: &[&str] = &[
30    // Year-month (no visible style: year/month resolve to null).
31    r"^(?P<year>[0-9]{4})-(?P<month>1[0-2]|0[1-9])$",
32    // Basic (compact) calendar date, e.g. 20230615.
33    r"^(?P<date>(?P<year>[0-9]{4})(?P<month>1[0-2]|0[1-9])(?P<day>3[01]|0[1-9]|[12][0-9]))$",
34    // Ordinal date, e.g. 2023-166 / 2023166.
35    r"^(?P<date>(?P<year>[0-9]{4})-?(?P<day>36[0-6]|3[0-5][0-9]|[12][0-9]{2}|0[1-9][0-9]|00[1-9]))$",
36    // Week date, e.g. 2023-W36 / 2023W36.
37    r"^(?P<date>(?P<year>[0-9]{4})-?W(?P<week>5[0-3]|[1-4][0-9]|0[1-9]))$",
38    // Week date with weekday, e.g. 2023-W36-7 / 2023W367.
39    r"^(?P<date>(?P<year>[0-9]{4})-?W(?P<week>5[0-3]|[1-4][0-9]|0[1-9])-?(?P<day>[1-7]))$",
40    // Basic/extended time hh[:]mm.
41    r"^(?P<time>(?P<hour>2[0-3]|[01][0-9]):?(?P<minute>[0-5][0-9]))$",
42    // Basic time hhmmss.
43    r"^(?P<time>(?P<hour>2[0-3]|[01][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-5][0-9]))$",
44    // Timezone alone.
45    r"^(?P<timezone>(Z|[+-](?:2[0-3]|[01][0-9])(?::?(?:[0-5][0-9]))?))$",
46    // Basic time hhmmss with timezone.
47    r"^(?P<time>(?P<hour>2[0-3]|[01][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-5][0-9]))(?P<timezone>Z|[+-](?:2[0-3]|[01][0-9])(?::?(?:[0-5][0-9]))?)$",
48    // Space-separated date + time — extended form (upstream conditional, part 1).
49    r"^(?P<date>(?P<year>[0-9]{4})-(?P<month>1[0-2]|0[1-9])-(?P<day>3[01]|0[1-9]|[12][0-9])) (?P<time>(?P<hour>2[0-3]|[01][0-9]):(?P<minute>[0-5][0-9]):(?P<second>[0-5][0-9]))$",
50    // Space-separated date + time — basic form (upstream conditional, part 2).
51    r"^(?P<date>(?P<year>[0-9]{4})(?P<month>1[0-2]|0[1-9])(?P<day>3[01]|0[1-9]|[12][0-9])) (?P<time>(?P<hour>2[0-3]|[01][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-5][0-9]))$",
52    // Extended calendar date with optional timezone.
53    r"^(?P<date>(?P<year>-?(?:[1-9][0-9]*)?[0-9]{4})-(?P<month>1[0-2]|0[1-9])-(?P<day>3[01]|0[1-9]|[12][0-9]))(?P<timezone>Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$",
54    // Extended clock time (with optional fraction) and timezone.
55    r"^(?P<time>(?P<hour>2[0-3]|[01][0-9]):(?P<minute>[0-5][0-9]):(?P<second>[0-5][0-9])(?P<frac>\.[0-9]+)?)(?P<timezone>Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$",
56    // Full extended date-time (T-separated) with optional fraction and timezone.
57    r"^(?P<date>(?P<year>-?(?:[1-9][0-9]*)?[0-9]{4})-(?P<month>1[0-2]|0[1-9])-(?P<day>3[01]|0[1-9]|[12][0-9]))T(?P<time>(?P<hour>2[0-3]|[01][0-9]):(?P<minute>[0-5][0-9]):(?P<second>[0-5][0-9])(?P<ms>\.[0-9]+)?)(?P<timezone>Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$",
58];
59
60/// Compile a slice of pattern strings.
61fn compile(patterns: &[&str]) -> Vec<Regex> {
62    patterns
63        .iter()
64        .map(|pattern| Regex::new(pattern).expect("valid highlighter pattern"))
65        .collect()
66}
67
68/// Apply `patterns` to `text`, stylizing each matched named group with the style
69/// *named* `{base_style}{group}`. Port of `RegexHighlighter.highlight`.
70///
71/// The names are left unresolved on the spans, so the colours come from the
72/// theme of whichever console renders the text. That is why this function — like
73/// upstream's — never needs to see a `Console`.
74///
75/// Takes already-compiled patterns rather than strings: the built-in
76/// highlighters run on every print, and recompiling their dozen patterns each
77/// time would be a real cost.
78fn run_highlighter(text: &mut Text, base_style: &str, patterns: &[Regex]) {
79    for regex in patterns {
80        text.highlight_with_regex(regex, None, base_style);
81    }
82}
83
84/// A generic regex highlighter — the extension point for custom highlighters.
85/// Give it a `base_style` prefix and named-group patterns; each matched group is
86/// styled with the style named `{base_style}{group}`, resolved against the
87/// rendering console's theme. Mirrors `rich.highlighter.RegexHighlighter`.
88///
89/// Because the names are resolved late, a console with a custom theme restyles
90/// highlighter output without the highlighter knowing anything about it.
91pub struct RegexHighlighter {
92    base_style: String,
93    patterns: Vec<Regex>,
94}
95
96impl RegexHighlighter {
97    pub fn new(base_style: impl Into<String>, patterns: &[&str]) -> Self {
98        RegexHighlighter {
99            base_style: base_style.into(),
100            patterns: compile(patterns),
101        }
102    }
103}
104
105impl Highlighter for RegexHighlighter {
106    fn highlight(&self, text: &mut Text) {
107        run_highlighter(text, &self.base_style, &self.patterns);
108    }
109}
110
111/// Highlights repr-style output — numbers, strings, bools, `None`, paths, URLs,
112/// braces, calls, IP/UUID/EUI, and tags. Mirrors `rich.highlighter.ReprHighlighter`.
113#[derive(Default)]
114pub struct ReprHighlighter;
115
116impl ReprHighlighter {
117    pub fn new() -> Self {
118        ReprHighlighter
119    }
120}
121
122fn repr_patterns() -> &'static [Regex] {
123    static COMPILED: OnceLock<Vec<Regex>> = OnceLock::new();
124    COMPILED.get_or_init(|| compile(&REPR_PATTERNS))
125}
126
127impl Highlighter for ReprHighlighter {
128    fn highlight(&self, text: &mut Text) {
129        run_highlighter(text, "repr.", repr_patterns());
130    }
131}
132
133/// Highlights ISO 8601 date/time strings (`iso8601.date`/`time`/`timezone`).
134/// Mirrors `rich.highlighter.ISO8601Highlighter` for standard extended formats.
135#[derive(Default)]
136pub struct ISO8601Highlighter;
137
138impl ISO8601Highlighter {
139    pub fn new() -> Self {
140        ISO8601Highlighter
141    }
142}
143
144fn iso8601_patterns() -> &'static [Regex] {
145    static COMPILED: OnceLock<Vec<Regex>> = OnceLock::new();
146    COMPILED.get_or_init(|| compile(ISO8601_PATTERNS))
147}
148
149impl Highlighter for ISO8601Highlighter {
150    fn highlight(&self, text: &mut Text) {
151        run_highlighter(text, "iso8601.", iso8601_patterns());
152    }
153}
154
155/// A highlighter that leaves text alone, which may be used to disable
156/// highlighting. Port of `rich.highlighter.NullHighlighter`.
157#[derive(Debug, Default, Clone, Copy)]
158pub struct NullHighlighter;
159
160impl NullHighlighter {
161    pub fn new() -> Self {
162        NullHighlighter
163    }
164}
165
166impl Highlighter for NullHighlighter {
167    fn highlight(&self, _text: &mut Text) {}
168}
169
170/// `JSONHighlighter.JSON_STR`: a JSON string, escaped quotes included.
171const JSON_STR: &str = r#"(?<![\\\w])(?P<str>b?".*?(?<!\\)")"#;
172
173/// The single combined pattern of `JSONHighlighter.highlights`.
174fn json_patterns() -> &'static [Regex] {
175    static COMPILED: OnceLock<Vec<Regex>> = OnceLock::new();
176    COMPILED.get_or_init(|| {
177        let combined = [
178            r"(?P<brace>[\{\[\(\)\]\}])",
179            r"\b(?P<bool_true>true)\b|\b(?P<bool_false>false)\b|\b(?P<null>null)\b",
180            r"(?P<number>(?<!\w)\-?[0-9]+\.?[0-9]*(e[\-\+]?\d+?)?\b|0x[0-9a-fA-F]*)",
181            JSON_STR,
182        ]
183        .join("|");
184        compile(&[combined.as_str()])
185    })
186}
187
188fn json_string() -> &'static Regex {
189    static COMPILED: OnceLock<Regex> = OnceLock::new();
190    COMPILED.get_or_init(|| Regex::new(JSON_STR).expect("valid JSON string pattern"))
191}
192
193/// Highlights JSON: braces, `true`/`false`/`null`, numbers and strings, then
194/// marks each string followed by a `:` (past any whitespace) as a key. Port
195/// of `rich.highlighter.JSONHighlighter`.
196#[derive(Debug, Default, Clone, Copy)]
197pub struct JSONHighlighter;
198
199impl JSONHighlighter {
200    pub fn new() -> Self {
201        JSONHighlighter
202    }
203}
204
205impl Highlighter for JSONHighlighter {
206    fn highlight(&self, text: &mut Text) {
207        run_highlighter(text, "json.", json_patterns());
208        let plain = text.plain().to_string();
209        let mut keys = Vec::new();
210        for found in json_string().find_iter(&plain) {
211            let Ok(found) = found else { break };
212            // The first character past any whitespace decides.
213            if plain[found.end()..]
214                .chars()
215                .find(|c| !matches!(c, ' ' | '\n' | '\r' | '\t'))
216                == Some(':')
217            {
218                keys.push((found.start(), found.end()));
219            }
220        }
221        for (start, end) in keys {
222            text.push_span(crate::text::Span {
223                start,
224                end,
225                style: crate::style::StyleType::Name("json.key".to_string()),
226            });
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::color::ColorSystem;
235    use crate::console::Console;
236    use crate::style::Style;
237
238    fn console() -> Console {
239        Console::builder()
240            .force_terminal(true)
241            .color_system(Some(ColorSystem::Truecolor))
242            .width(80)
243            .no_color(false)
244            .build()
245    }
246
247    fn highlight(input: &str) -> String {
248        let mut text = Text::new(input);
249        ReprHighlighter::new().highlight(&mut text);
250        console().render_to_string(&text)
251    }
252
253    /// Highlight colours come from the theme of the console that *renders* the
254    /// text, not from a process-global default.
255    ///
256    /// Before the named-span change this was impossible: the highlighter resolved
257    /// `repr.number` against `Theme::default_shared()` at highlight time, so a
258    /// custom theme could not reach it. Verified against real rich 15.0.0, where
259    /// `Console(theme=Theme({"repr.number": "bold red"}), highlight=True)`
260    /// prints `42` as `\x1b[1;31m`.
261    #[test]
262    fn highlight_colours_follow_the_consoles_theme() {
263        let mut theme = crate::theme::Theme::default_theme();
264        theme.insert("repr.number", Style::parse("bold red").unwrap());
265        let themed = Console::builder()
266            .force_terminal(true)
267            .color_system(Some(ColorSystem::Truecolor))
268            .width(80)
269            .no_color(false)
270            .theme(theme)
271            .build();
272
273        let mut text = Text::new("n = 42");
274        ReprHighlighter::new().highlight(&mut text);
275
276        assert_eq!(themed.render_to_string(&text), "n = \x1b[1;31m42\x1b[0m");
277        // The very same Text, rendered by a default console, keeps the stock
278        // colour — proof the styles are bound at render, not at highlight.
279        assert_eq!(console().render_to_string(&text), "n = \x1b[1;36m42\x1b[0m");
280    }
281
282    /// A custom highlighter's group names resolve against the theme too, so a
283    /// name the built-in table has never heard of still works.
284    #[test]
285    fn custom_group_names_resolve_against_the_theme() {
286        let mut theme = crate::theme::Theme::default_theme();
287        theme.insert("sql.keyword", Style::parse("bold blue").unwrap());
288        let sql = Console::builder()
289            .force_terminal(true)
290            .color_system(Some(ColorSystem::Truecolor))
291            .width(80)
292            .no_color(false)
293            .theme(theme)
294            .build();
295
296        let mut text = Text::new("SELECT x");
297        RegexHighlighter::new("sql.", &[r"(?P<keyword>SELECT)"]).highlight(&mut text);
298        assert_eq!(sql.render_to_string(&text), "\x1b[1;34mSELECT\x1b[0m x");
299    }
300
301    fn highlight_iso(input: &str) -> String {
302        let mut text = Text::new(input);
303        ISO8601Highlighter::new().highlight(&mut text);
304        console().render_to_string(&text)
305    }
306
307    #[test]
308    fn highlights_numbers_bools_none() {
309        // Captured from real rich 15.0.0 Console(highlight=True).
310        assert_eq!(
311            highlight("value = 42 and 3.14"),
312            "value = \x1b[1;36m42\x1b[0m and \x1b[1;36m3.14\x1b[0m"
313        );
314        assert_eq!(
315            highlight("flag is True, x is None"),
316            "flag is \x1b[3;92mTrue\x1b[0m, x is \x1b[3;35mNone\x1b[0m"
317        );
318    }
319
320    #[test]
321    fn highlights_paths_strings_urls_and_more() {
322        // All captured from real rich 15.0.0.
323        assert_eq!(
324            highlight("path /usr/bin done"),
325            "path \x1b[35m/usr/\x1b[0m\x1b[95mbin\x1b[0m done"
326        );
327        assert_eq!(
328            highlight("s = 'hello world' end"),
329            "s = \x1b[32m'hello world'\x1b[0m end"
330        );
331        assert_eq!(
332            highlight("see https://example.com/x now"),
333            "see \x1b[4;94mhttps://example.com/x\x1b[0m now"
334        );
335        assert_eq!(
336            highlight("list [1, 2, 3] and (a, b)"),
337            "list \x1b[1m[\x1b[0m\x1b[1;36m1\x1b[0m, \x1b[1;36m2\x1b[0m, \x1b[1;36m3\x1b[0m\x1b[1m]\x1b[0m and \x1b[1m(\x1b[0ma, b\x1b[1m)\x1b[0m"
338        );
339        assert_eq!(
340            highlight("call func(x) here"),
341            "call \x1b[1;35mfunc\x1b[0m\x1b[1m(\x1b[0mx\x1b[1m)\x1b[0m here"
342        );
343        assert_eq!(
344            highlight("id 12345678-1234-1234-1234-123456789abc x"),
345            "id \x1b[93m12345678-1234-1234-1234-123456789abc\x1b[0m x"
346        );
347        assert_eq!(
348            highlight("ip 192.168.0.1 addr"),
349            "ip \x1b[1;92m192.168.0.1\x1b[0m addr"
350        );
351        // "3:4" matches the ipv6 pattern in upstream; "..." is the ellipsis.
352        assert_eq!(
353            highlight("ratio 3:4 and dots ..."),
354            "ratio \x1b[1;92m3:4\x1b[0m and dots \x1b[33m...\x1b[0m"
355        );
356    }
357
358    #[test]
359    fn iso8601_dates_times_and_zones() {
360        // All captured from real rich 15.0.0 ISO8601Highlighter. Sub-fields
361        // (year/month/…) split the run into per-field segments even though only
362        // date/time/timezone carry color.
363        assert_eq!(
364            highlight_iso("2023-06-15"),
365            "\x1b[34m2023\x1b[0m\x1b[34m-\x1b[0m\x1b[34m06\x1b[0m\x1b[34m-\x1b[0m\x1b[34m15\x1b[0m"
366        );
367        assert_eq!(
368            highlight_iso("13:45:30"),
369            "\x1b[35m13\x1b[0m\x1b[35m:\x1b[0m\x1b[35m45\x1b[0m\x1b[35m:\x1b[0m\x1b[35m30\x1b[0m"
370        );
371        assert_eq!(
372            highlight_iso("2023-06-15T13:45:30.123+02:00"),
373            "\x1b[34m2023\x1b[0m\x1b[34m-\x1b[0m\x1b[34m06\x1b[0m\x1b[34m-\x1b[0m\x1b[34m15\x1b[0mT\
374             \x1b[35m13\x1b[0m\x1b[35m:\x1b[0m\x1b[35m45\x1b[0m\x1b[35m:\x1b[0m\x1b[35m30\x1b[0m\x1b[35m.123\x1b[0m\x1b[33m+02:00\x1b[0m"
375        );
376        assert_eq!(highlight_iso("not a date"), "not a date");
377    }
378
379    #[test]
380    fn iso8601_compact_and_basic_forms() {
381        // Compact/basic forms and the split of upstream's conditional pattern.
382        // All captured from real rich 15.0.0 ISO8601Highlighter.
383        assert_eq!(
384            highlight_iso("20230615"),
385            "\x1b[34m2023\x1b[0m\x1b[34m06\x1b[0m\x1b[34m15\x1b[0m"
386        );
387        assert_eq!(
388            highlight_iso("2023-166"),
389            "\x1b[34m2023\x1b[0m\x1b[34m-\x1b[0m\x1b[34m166\x1b[0m"
390        );
391        assert_eq!(
392            highlight_iso("2023-W36"),
393            "\x1b[34m2023\x1b[0m\x1b[34m-W\x1b[0m\x1b[34m36\x1b[0m"
394        );
395        assert_eq!(
396            highlight_iso("2023-W36-7"),
397            "\x1b[34m2023\x1b[0m\x1b[34m-W\x1b[0m\x1b[34m36\x1b[0m\x1b[34m-\x1b[0m\x1b[34m7\x1b[0m"
398        );
399        assert_eq!(highlight_iso("1345"), "\x1b[35m13\x1b[0m\x1b[35m45\x1b[0m");
400        assert_eq!(
401            highlight_iso("134530"),
402            "\x1b[35m13\x1b[0m\x1b[35m45\x1b[0m\x1b[35m30\x1b[0m"
403        );
404        assert_eq!(highlight_iso("Z"), "\x1b[33mZ\x1b[0m");
405        assert_eq!(highlight_iso("+02:00"), "\x1b[33m+02:00\x1b[0m");
406        assert_eq!(
407            highlight_iso("153000Z"),
408            "\x1b[35m15\x1b[0m\x1b[35m30\x1b[0m\x1b[35m00\x1b[0m\x1b[33mZ\x1b[0m"
409        );
410        // The split conditional pattern: extended and basic space-separated forms.
411        assert_eq!(
412            highlight_iso("2023-06-15 13:45:30"),
413            "\x1b[34m2023\x1b[0m\x1b[34m-\x1b[0m\x1b[34m06\x1b[0m\x1b[34m-\x1b[0m\x1b[34m15\x1b[0m \
414             \x1b[35m13\x1b[0m\x1b[35m:\x1b[0m\x1b[35m45\x1b[0m\x1b[35m:\x1b[0m\x1b[35m30\x1b[0m"
415        );
416        assert_eq!(
417            highlight_iso("20230615 134530"),
418            "\x1b[34m2023\x1b[0m\x1b[34m06\x1b[0m\x1b[34m15\x1b[0m \
419             \x1b[35m13\x1b[0m\x1b[35m45\x1b[0m\x1b[35m30\x1b[0m"
420        );
421    }
422}