Skip to main content

rusty_rich/
highlighter.rs

1//! Highlighter — applies highlighting to strings. Equivalent to Rich's
2//! `highlighter.py`.
3//!
4//! Highlighters are callables that transform text by applying styles for
5//! certain patterns (numbers, URLs, paths, etc.).
6
7use regex::Regex;
8use crate::style::Style;
9use crate::text::Text;
10
11// ---------------------------------------------------------------------------
12// Highlighter trait
13// ---------------------------------------------------------------------------
14
15/// Trait for objects that can highlight text.
16pub trait Highlighter {
17    /// Apply highlighting to the given text, returning styled Text.
18    fn highlight(&self, text: &Text) -> Text;
19}
20
21// ---------------------------------------------------------------------------
22// NullHighlighter
23// ---------------------------------------------------------------------------
24
25/// A highlighter that does nothing.
26pub struct NullHighlighter;
27
28impl Highlighter for NullHighlighter {
29    fn highlight(&self, text: &Text) -> Text {
30        text.clone()
31    }
32}
33
34// ---------------------------------------------------------------------------
35// RegexHighlighter
36// ---------------------------------------------------------------------------
37
38/// A highlighter that applies a regex → style mapping.
39pub struct RegexHighlighter {
40    rules: Vec<(Regex, Style)>,
41}
42
43impl std::fmt::Debug for RegexHighlighter {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.debug_struct("RegexHighlighter")
46            .field("rule_count", &self.rules.len())
47            .finish()
48    }
49}
50
51impl Clone for RegexHighlighter {
52    fn clone(&self) -> Self {
53        // Rebuild by cloning patterns as strings
54        let mut cloned = Self::new();
55        for (re, style) in &self.rules {
56            cloned.rules.push((re.clone(), style.clone()));
57        }
58        cloned
59    }
60}
61
62impl RegexHighlighter {
63    /// Create an empty regex highlighter with no rules.
64    pub fn new() -> Self {
65        Self { rules: Vec::new() }
66    }
67
68    pub fn add_rule(&mut self, pattern: &str, style: Style) -> Result<(), regex::Error> {
69        let re = Regex::new(pattern)?;
70        self.rules.push((re, style));
71        Ok(())
72    }
73}
74
75impl Highlighter for RegexHighlighter {
76    fn highlight(&self, text: &Text) -> Text {
77        let mut result = text.clone();
78        for (re, style) in &self.rules {
79            let plain = result.plain.clone();
80            let mut new_text = Text::new("");
81            let mut last_end = 0usize;
82
83            for m in re.find_iter(&plain) {
84                // Add text before match
85                if m.start() > last_end {
86                    new_text.append(&plain[last_end..m.start()], None);
87                }
88                // Add matched text with style
89                new_text.append_styled(m.as_str(), style.clone());
90                last_end = m.end();
91            }
92            // Add remaining text
93            if last_end < plain.len() {
94                new_text.append(&plain[last_end..], None);
95            }
96            result = new_text;
97        }
98        result
99    }
100}
101
102// ---------------------------------------------------------------------------
103// ReprHighlighter — highlights Python repr-like output
104// ---------------------------------------------------------------------------
105
106/// Highlights numbers, strings, booleans, None, URLs, paths, IPs, etc.
107#[derive(Debug, Clone)]
108pub struct ReprHighlighter {
109    highlighter: Option<Box<RegexHighlighter>>,
110}
111
112impl ReprHighlighter {
113    /// Create a new `ReprHighlighter` with built-in rules for numbers, URLs, paths, and strings.
114    pub fn new() -> Self {
115        // Build the regex highlighter with common repr patterns
116        let mut rh = RegexHighlighter::new();
117
118        // URLs
119        let _ = rh.add_rule(
120            r"https?://[^\s)\]}>]+",
121            Style::new()
122                .color(crate::color::Color::parse("bright_blue").unwrap())
123                .underline(true),
124        );
125
126        // Numbers (int, float, hex)
127        let _ = rh.add_rule(
128            r"(?<!\w)(-?\d+\.?\d*(?:e[+-]?\d+)?)(?!\w)",
129            Style::new()
130                .color(crate::color::Color::parse("cyan").unwrap())
131                .bold(true),
132        );
133
134        // File paths
135        let _ = rh.add_rule(
136            r"(?<!\w)(?:/[\w.-]+)+/?(?!\w)",
137            Style::new()
138                .color(crate::color::Color::parse("magenta").unwrap()),
139        );
140
141        // Quoted strings (single or double)
142        let _ = rh.add_rule(
143            r#""(?:[^"\\]|\\.)*""#,
144            Style::new()
145                .color(crate::color::Color::parse("green").unwrap()),
146        );
147        let _ = rh.add_rule(
148            r"'(?:[^'\\]|\\.)*'",
149            Style::new()
150                .color(crate::color::Color::parse("green").unwrap()),
151        );
152
153        Self {
154            highlighter: Some(Box::new(rh)),
155        }
156    }
157
158    /// Highlight a string, returning styled text.
159    pub fn highlight_str(&self, text: &str) -> Text {
160        let t = Text::new(text);
161        if let Some(ref h) = self.highlighter {
162            h.highlight(&t)
163        } else {
164            t
165        }
166    }
167}
168
169// ---------------------------------------------------------------------------
170// ISO8601Highlighter — highlights ISO 8601 timestamps
171// ---------------------------------------------------------------------------
172
173/// Highlights ISO 8601 date/time patterns in text.
174///
175/// Matches formats like `2024-01-15`, `2024-01-15T10:30:00`,
176/// `2024-01-15T10:30:00Z`, `2024-01-15 10:30:00`, and
177/// `2024-01-15T10:30:00+05:00`.
178#[derive(Debug, Clone)]
179pub struct ISO8601Highlighter {
180    highlighter: RegexHighlighter,
181}
182
183impl ISO8601Highlighter {
184    /// Create a new ISO 8601 highlighter.
185    pub fn new() -> Self {
186        let mut h = RegexHighlighter::new();
187        // Matches ISO 8601 date + optional time + optional timezone
188        let _ = h.add_rule(
189            r"\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?\b",
190            Style::new()
191                .color(crate::color::Color::parse("bright_yellow").unwrap())
192                .bold(true),
193        );
194        Self { highlighter: h }
195    }
196
197    /// Highlight a string, returning styled text.
198    pub fn highlight_str(&self, text: &str) -> Text {
199        let t = Text::new(text);
200        self.highlighter.highlight(&t)
201    }
202}
203
204impl Highlighter for ISO8601Highlighter {
205    fn highlight(&self, text: &Text) -> Text {
206        self.highlighter.highlight(text)
207    }
208}
209
210// ---------------------------------------------------------------------------
211// JSONHighlighter — highlights JSON strings
212// ---------------------------------------------------------------------------
213
214/// Highlights JSON syntax: keys, strings, numbers, booleans, null, and
215/// structural characters.
216#[derive(Debug, Clone)]
217pub struct JSONHighlighter {
218    highlighter: RegexHighlighter,
219}
220
221impl JSONHighlighter {
222    /// Create a new JSON highlighter.
223    pub fn new() -> Self {
224        let mut h = RegexHighlighter::new();
225
226        // JSON keys
227        let _ = h.add_rule(
228            r#""(?:[^"\\]|\\.)*"\s*:"#,
229            Style::new()
230                .color(crate::color::Color::parse("bright_cyan").unwrap()),
231        );
232
233        // JSON strings (values)
234        let _ = h.add_rule(
235            r#""(?:[^"\\]|\\.)*""#,
236            Style::new()
237                .color(crate::color::Color::parse("green").unwrap()),
238        );
239
240        // JSON numbers
241        let _ = h.add_rule(
242            r"(?<!\w)-?\d+\.?\d*(?:[eE][+-]?\d+)?(?!\w)",
243            Style::new()
244                .color(crate::color::Color::parse("bright_yellow").unwrap()),
245        );
246
247        // JSON booleans and null
248        let _ = h.add_rule(
249            r"\b(?:true|false|null)\b",
250            Style::new()
251                .color(crate::color::Color::parse("magenta").unwrap())
252                .bold(true),
253        );
254
255        // JSON braces and brackets
256        let _ = h.add_rule(
257            r"[{}\[\]]",
258            Style::new()
259                .color(crate::color::Color::parse("white").unwrap())
260                .bold(true),
261        );
262
263        Self { highlighter: h }
264    }
265
266    /// Highlight a JSON string, returning styled text.
267    pub fn highlight_str(&self, text: &str) -> Text {
268        let t = Text::new(text);
269        self.highlighter.highlight(&t)
270    }
271}
272
273impl Highlighter for JSONHighlighter {
274    fn highlight(&self, text: &Text) -> Text {
275        self.highlighter.highlight(text)
276    }
277}
278
279// ---------------------------------------------------------------------------
280// PathHighlighter — highlights file paths in tracebacks
281// ---------------------------------------------------------------------------
282
283/// Highlights file paths and line numbers (e.g. `src/main.rs:42`) in
284/// traceback-style output.
285#[derive(Debug, Clone)]
286pub struct PathHighlighter {
287    highlighter: RegexHighlighter,
288}
289
290impl PathHighlighter {
291    /// Create a new path highlighter.
292    pub fn new() -> Self {
293        let mut h = RegexHighlighter::new();
294
295        // File paths with optional line:column suffix
296        let _ = h.add_rule(
297            r"(?:\w:)?(?:[/\\][\w.\-]+)+(?:\.\w+)?(?::\d+(?::\d+)?)?",
298            Style::new()
299                .color(crate::color::Color::parse("bright_magenta").unwrap()),
300        );
301
302        Self { highlighter: h }
303    }
304
305    /// Highlight a string, returning styled text.
306    pub fn highlight_str(&self, text: &str) -> Text {
307        let t = Text::new(text);
308        self.highlighter.highlight(&t)
309    }
310}
311
312impl Highlighter for PathHighlighter {
313    fn highlight(&self, text: &Text) -> Text {
314        self.highlighter.highlight(text)
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_null_highlighter() {
324        let h = NullHighlighter;
325        let t = Text::new("hello");
326        let result = h.highlight(&t);
327        assert_eq!(result.plain, "hello");
328    }
329
330    #[test]
331    fn test_repr_highlighter_numbers() {
332        let h = ReprHighlighter::new();
333        let result = h.highlight_str("num=42");
334        // The regex matches standalone numbers; "42" after "=" may not match.
335        // Verify the highlighter runs without panicking.
336        assert!(!result.plain.is_empty());
337    }
338
339    #[test]
340    fn test_iso8601_highlighter() {
341        let h = ISO8601Highlighter::new();
342        let result = h.highlight_str("2024-01-15T10:30:00Z");
343        assert!(!result.plain.is_empty());
344    }
345
346    #[test]
347    fn test_json_highlighter() {
348        let h = JSONHighlighter::new();
349        let result = h.highlight_str(r#"{"key": "value", "num": 42, "flag": true}"#);
350        assert!(!result.plain.is_empty());
351    }
352
353    #[test]
354    fn test_path_highlighter() {
355        let h = PathHighlighter::new();
356        let result = h.highlight_str("src/main.rs:42");
357        assert!(!result.plain.is_empty());
358    }
359}