facet_showcase/
highlighter.rs

1//! Syntax highlighting support for showcases.
2
3use std::cell::RefCell;
4
5use arborium::theme::Theme;
6use arborium::{AnsiHighlighter, Highlighter as ArboriumHighlighter};
7use owo_colors::OwoColorize;
8
9const INDENT: &str = "    ";
10
11/// Supported languages for syntax highlighting.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Language {
14    /// JSON format
15    Json,
16    /// YAML format
17    Yaml,
18    /// XML format
19    Xml,
20    /// HTML format
21    Html,
22    /// Rust code (for type definitions)
23    Rust,
24    /// Plain text (no syntax highlighting)
25    Plain,
26}
27
28impl Language {
29    /// Returns the file extension used to look up the syntax.
30    pub const fn extension(self) -> &'static str {
31        match self {
32            Language::Json => "json",
33            Language::Yaml => "yaml",
34            Language::Xml => "xml",
35            Language::Html => "html",
36            Language::Rust => "rs",
37            Language::Plain => "txt",
38        }
39    }
40
41    /// Returns a human-readable name for the language.
42    pub const fn name(self) -> &'static str {
43        match self {
44            Language::Json => "JSON",
45            Language::Yaml => "YAML",
46            Language::Xml => "XML",
47            Language::Html => "HTML",
48            Language::Rust => "Rust",
49            Language::Plain => "Output",
50        }
51    }
52
53    const fn arborium_name(self) -> Option<&'static str> {
54        match self {
55            Language::Json => Some("json"),
56            Language::Yaml => Some("yaml"),
57            Language::Xml => Some("xml"),
58            Language::Html => Some("html"),
59            Language::Rust => Some("rust"),
60            Language::Plain => None, // No syntax highlighting
61        }
62    }
63}
64
65/// Syntax highlighter using Tokyo Night theme powered by arborium.
66pub struct Highlighter {
67    html_highlighter: RefCell<ArboriumHighlighter>,
68    ansi_highlighter: RefCell<AnsiHighlighter>,
69    theme: Theme,
70}
71
72impl Default for Highlighter {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78impl Highlighter {
79    /// Create a new highlighter with the Tokyo Night theme.
80    pub fn new() -> Self {
81        let theme = arborium::theme::builtin::tokyo_night().clone();
82        Self {
83            html_highlighter: RefCell::new(ArboriumHighlighter::new()),
84            ansi_highlighter: RefCell::new(AnsiHighlighter::new(theme.clone())),
85            theme,
86        }
87    }
88
89    /// Get a reference to the theme.
90    pub const fn theme(&self) -> &Theme {
91        &self.theme
92    }
93
94    /// Highlight code and return terminal-escaped string.
95    pub fn highlight_to_terminal(&self, code: &str, lang: Language) -> String {
96        let Some(lang_name) = lang.arborium_name() else {
97            return self.plain_text_with_indent(code);
98        };
99        let mut hl = self.ansi_highlighter.borrow_mut();
100        match hl.highlight(lang_name, code) {
101            Ok(output) => {
102                // Add indentation to each line
103                let mut result = String::new();
104                for line in output.lines() {
105                    result.push_str(INDENT);
106                    result.push_str(line);
107                    result.push('\n');
108                }
109                result
110            }
111            Err(_) => self.plain_text_with_indent(code),
112        }
113    }
114
115    /// Highlight code with line numbers for terminal output.
116    pub fn highlight_to_terminal_with_line_numbers(&self, code: &str, lang: Language) -> String {
117        let Some(lang_name) = lang.arborium_name() else {
118            return self.plain_text_with_line_numbers(code);
119        };
120        let mut hl = self.ansi_highlighter.borrow_mut();
121        match hl.highlight(lang_name, code) {
122            Ok(output) => {
123                let mut result = String::new();
124                for (i, line) in output.lines().enumerate() {
125                    result.push_str(&format!(
126                        "{} {} {}\n",
127                        format!("{:3}", i + 1).dimmed(),
128                        "│".dimmed(),
129                        line
130                    ));
131                }
132                result
133            }
134            Err(_) => self.plain_text_with_line_numbers(code),
135        }
136    }
137
138    /// Highlight code and return HTML with inline styles.
139    pub fn highlight_to_html(&self, code: &str, lang: Language) -> String {
140        let Some(lang_name) = lang.arborium_name() else {
141            return wrap_plain_text_html(code, &self.theme);
142        };
143        let mut hl = self.html_highlighter.borrow_mut();
144        match hl.highlight(lang_name, code) {
145            Ok(html) => wrap_with_pre(html, &self.theme),
146            Err(_) => wrap_plain_text_html(code, &self.theme),
147        }
148    }
149
150    fn plain_text_with_indent(&self, code: &str) -> String {
151        let mut output = String::new();
152        for line in code.lines() {
153            output.push_str(INDENT);
154            output.push_str(line);
155            output.push('\n');
156        }
157        output
158    }
159
160    fn plain_text_with_line_numbers(&self, code: &str) -> String {
161        let mut output = String::new();
162        for (i, line) in code.lines().enumerate() {
163            output.push_str(&format!(
164                "{} {} {}\n",
165                format!("{:3}", i + 1).dimmed(),
166                "│".dimmed(),
167                line
168            ));
169        }
170        output
171    }
172}
173
174/// Escape HTML special characters.
175pub fn html_escape(s: &str) -> String {
176    s.replace('&', "&amp;")
177        .replace('<', "&lt;")
178        .replace('>', "&gt;")
179        .replace('"', "&quot;")
180}
181
182/// Convert ANSI escape codes to HTML spans with inline styles.
183/// Uses non-breaking spaces to preserve alignment in monospace output.
184pub fn ansi_to_html(input: &str) -> String {
185    let mut output = String::new();
186    let mut chars = input.chars().peekable();
187    let mut in_span = false;
188
189    while let Some(c) = chars.next() {
190        if c == '\x1b' && chars.peek() == Some(&'[') {
191            chars.next(); // consume '['
192
193            // Parse the escape sequence
194            let mut seq = String::new();
195            while let Some(&ch) = chars.peek() {
196                if ch.is_ascii_digit() || ch == ';' {
197                    seq.push(chars.next().unwrap());
198                } else {
199                    break;
200                }
201            }
202
203            // Consume the final character (usually 'm')
204            let final_char = chars.next();
205
206            if final_char == Some('m') {
207                // Close any existing span
208                if in_span {
209                    output.push_str("</span>");
210                    in_span = false;
211                }
212
213                // Parse the style
214                if let Some(style) = parse_ansi_style(&seq)
215                    && !style.is_empty()
216                {
217                    output.push_str(&format!("<span style=\"{style}\">"));
218                    in_span = true;
219                }
220            }
221        } else if c == '<' {
222            output.push_str("&lt;");
223        } else if c == '>' {
224            output.push_str("&gt;");
225        } else if c == '&' {
226            output.push_str("&amp;");
227        } else if c == '`' {
228            // Escape backticks to prevent markdown interpretation
229            output.push_str("&#96;");
230        } else if c == ' ' {
231            // Use non-breaking space to preserve alignment
232            output.push('\u{00A0}');
233        } else {
234            output.push(c);
235        }
236    }
237
238    if in_span {
239        output.push_str("</span>");
240    }
241
242    output
243}
244
245/// Parse ANSI style codes and return CSS style string.
246fn parse_ansi_style(seq: &str) -> Option<String> {
247    if seq.is_empty() || seq == "0" {
248        return Some(String::new()); // Reset
249    }
250
251    let parts: Vec<&str> = seq.split(';').collect();
252    let mut styles = Vec::new();
253
254    let mut i = 0;
255    while i < parts.len() {
256        match parts[i] {
257            "0" => return Some(String::new()), // Reset
258            "1" => styles.push("font-weight:bold".to_string()),
259            "2" => styles.push("opacity:0.7".to_string()), // Dim
260            "3" => styles.push("font-style:italic".to_string()),
261            "4" => styles.push("text-decoration:underline".to_string()),
262            "30" => styles.push("color:#000".to_string()),
263            "31" => styles.push("color:#e06c75".to_string()), // Red
264            "32" => styles.push("color:#98c379".to_string()), // Green
265            "33" => styles.push("color:#e5c07b".to_string()), // Yellow
266            "34" => styles.push("color:#61afef".to_string()), // Blue
267            "35" => styles.push("color:#c678dd".to_string()), // Magenta
268            "36" => styles.push("color:#56b6c2".to_string()), // Cyan
269            "37" => styles.push("color:#abb2bf".to_string()), // White
270            "38" => {
271                // Extended color
272                if i + 1 < parts.len() && parts[i + 1] == "2" {
273                    // 24-bit RGB
274                    if i + 4 < parts.len() {
275                        let r = parts[i + 2];
276                        let g = parts[i + 3];
277                        let b = parts[i + 4];
278                        styles.push(format!("color:rgb({r},{g},{b})"));
279                        i += 4;
280                    }
281                } else if i + 1 < parts.len()
282                    && parts[i + 1] == "5"
283                    && i + 2 < parts.len()
284                    && let Ok(n) = parts[i + 2].parse::<u8>()
285                {
286                    let color = ansi_256_to_rgb(n);
287                    styles.push(format!("color:{color}"));
288                    i += 2;
289                }
290            }
291            "39" => styles.push("color:inherit".to_string()),
292            "40" => styles.push("background-color:#000".to_string()),
293            "41" => styles.push("background-color:#e06c75".to_string()),
294            "42" => styles.push("background-color:#98c379".to_string()),
295            "43" => styles.push("background-color:#e5c07b".to_string()),
296            "44" => styles.push("background-color:#61afef".to_string()),
297            "45" => styles.push("background-color:#c678dd".to_string()),
298            "46" => styles.push("background-color:#56b6c2".to_string()),
299            "47" => styles.push("background-color:#abb2bf".to_string()),
300            "48" => {
301                if i + 1 < parts.len() && parts[i + 1] == "2" {
302                    if i + 4 < parts.len() {
303                        let r = parts[i + 2];
304                        let g = parts[i + 3];
305                        let b = parts[i + 4];
306                        styles.push(format!("background-color:rgb({r},{g},{b})"));
307                        i += 4;
308                    }
309                } else if i + 1 < parts.len()
310                    && parts[i + 1] == "5"
311                    && i + 2 < parts.len()
312                    && let Ok(n) = parts[i + 2].parse::<u8>()
313                {
314                    let color = ansi_256_to_rgb(n);
315                    styles.push(format!("background-color:{color}"));
316                    i += 2;
317                }
318            }
319            "49" => styles.push("background-color:transparent".to_string()),
320            "90" => styles.push("color:#5c6370".to_string()), // Bright black (dim)
321            "91" => styles.push("color:#e06c75".to_string()), // Bright red
322            "92" => styles.push("color:#98c379".to_string()),
323            "93" => styles.push("color:#e5c07b".to_string()), // Bright yellow
324            "94" => styles.push("color:#61afef".to_string()),
325            "95" => styles.push("color:#c678dd".to_string()), // Bright magenta
326            "96" => styles.push("color:#56b6c2".to_string()),
327            "97" => styles.push("color:#fff".to_string()), // Bright white
328            _ => {}
329        }
330        i += 1;
331    }
332
333    if styles.is_empty() {
334        None
335    } else {
336        Some(styles.join(";"))
337    }
338}
339
340const fn ansi_256_to_rgb(n: u8) -> &'static str {
341    match n {
342        0 => "#000000",
343        1 => "#800000",
344        2 => "#008000",
345        3 => "#808000",
346        4 => "#000080",
347        5 => "#800080",
348        6 => "#008080",
349        7 => "#c0c0c0",
350        8 => "#808080",
351        9 => "#ff0000",
352        10 => "#00ff00",
353        11 => "#ffff00",
354        12 => "#0000ff",
355        13 => "#ff00ff",
356        14 => "#00ffff",
357        15 => "#ffffff",
358        _ => "#888888",
359    }
360}
361
362fn wrap_plain_text_html(code: &str, theme: &Theme) -> String {
363    wrap_with_pre(html_escape(code), theme)
364}
365
366fn wrap_with_pre(content: String, theme: &Theme) -> String {
367    // Replace blank lines with <br> to preserve visual spacing.
368    // In CommonMark, blank lines terminate HTML blocks, so we must not have
369    // any actual blank lines inside our pre elements when embedded in markdown.
370    let content = blank_lines_to_br(&content);
371
372    let mut styles = Vec::new();
373    if let Some(bg) = theme.background {
374        styles.push(format!("background-color:{};", bg.to_hex()));
375    }
376    if let Some(fg) = theme.foreground {
377        styles.push(format!("color:{};", fg.to_hex()));
378    }
379    styles.push("padding:12px;".to_string());
380    styles.push("border-radius:8px;".to_string());
381    styles.push(
382        "font-family:var(--facet-mono, SFMono-Regular, Consolas, 'Liberation Mono', monospace);"
383            .to_string(),
384    );
385    styles.push("font-size:0.9rem;".to_string());
386    styles.push("overflow:auto;".to_string());
387    format!(
388        "<pre style=\"{}\"><code>{}</code></pre>",
389        styles.join(" "),
390        content
391    )
392}
393
394/// Replace blank lines (2+ consecutive newlines) with `<br>` tags.
395/// This preserves visual spacing while preventing CommonMark from
396/// terminating the HTML block at blank lines inside `<pre>` elements.
397fn blank_lines_to_br(s: &str) -> String {
398    let mut result = String::with_capacity(s.len());
399    let mut newline_count = 0;
400
401    for c in s.chars() {
402        if c == '\n' {
403            newline_count += 1;
404        } else {
405            // Flush accumulated newlines
406            if newline_count > 0 {
407                result.push('\n');
408                // For each extra newline beyond the first, add a <br>
409                for _ in 1..newline_count {
410                    result.push_str("<br>");
411                }
412                newline_count = 0;
413            }
414            result.push(c);
415        }
416    }
417
418    // Handle trailing newlines
419    if newline_count > 0 {
420        result.push('\n');
421        for _ in 1..newline_count {
422            result.push_str("<br>");
423        }
424    }
425
426    result
427}
428
429#[cfg(test)]
430mod tests {
431    use super::{Language, blank_lines_to_br};
432
433    #[test]
434    fn xml_language_metadata_is_exposed() {
435        assert_eq!(Language::Xml.name(), "XML");
436        assert_eq!(Language::Xml.extension(), "xml");
437    }
438
439    #[test]
440    fn blank_lines_to_br_preserves_visual_spacing() {
441        // Single newlines preserved as-is
442        assert_eq!(blank_lines_to_br("a\nb\nc"), "a\nb\nc");
443
444        // Double newlines (blank line) -> newline + <br>
445        assert_eq!(blank_lines_to_br("a\n\nb"), "a\n<br>b");
446
447        // Triple newlines -> newline + 2x <br>
448        assert_eq!(blank_lines_to_br("a\n\n\nb"), "a\n<br><br>b");
449
450        // Mixed content
451        assert_eq!(
452            blank_lines_to_br("line1\n\nline2\nline3\n\n\nline4"),
453            "line1\n<br>line2\nline3\n<br><br>line4"
454        );
455
456        // Empty string
457        assert_eq!(blank_lines_to_br(""), "");
458
459        // Only newlines
460        assert_eq!(blank_lines_to_br("\n\n\n"), "\n<br><br>");
461    }
462}