1use std::sync::OnceLock;
15
16use fancy_regex::Regex;
17
18use crate::protocol::Highlighter;
19use crate::repr_patterns::REPR_PATTERNS;
20use crate::text::Text;
21
22const ISO8601_PATTERNS: &[&str] = &[
30 r"^(?P<year>[0-9]{4})-(?P<month>1[0-2]|0[1-9])$",
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]))$",
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]))$",
36 r"^(?P<date>(?P<year>[0-9]{4})-?W(?P<week>5[0-3]|[1-4][0-9]|0[1-9]))$",
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]))$",
40 r"^(?P<time>(?P<hour>2[0-3]|[01][0-9]):?(?P<minute>[0-5][0-9]))$",
42 r"^(?P<time>(?P<hour>2[0-3]|[01][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-5][0-9]))$",
44 r"^(?P<timezone>(Z|[+-](?:2[0-3]|[01][0-9])(?::?(?:[0-5][0-9]))?))$",
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]))?)$",
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]))$",
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]))$",
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])?$",
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])?$",
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])?$",
58];
59
60fn compile(patterns: &[&str]) -> Vec<Regex> {
62 patterns
63 .iter()
64 .map(|pattern| Regex::new(pattern).expect("valid highlighter pattern"))
65 .collect()
66}
67
68fn 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
84pub 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#[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#[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#[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
170const JSON_STR: &str = r#"(?<![\\\w])(?P<str>b?".*?(?<!\\)")"#;
172
173fn 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#[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 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 #[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 assert_eq!(console().render_to_string(&text), "n = \x1b[1;36m42\x1b[0m");
280 }
281
282 #[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 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 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 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 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 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 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}