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