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