1use std::cell::RefCell;
4
5use arborium::theme::Theme;
6use arborium::{AnsiHighlighter, Highlighter as ArboriumHighlighter};
7use owo_colors::OwoColorize;
8
9const INDENT: &str = " ";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Language {
14 Json,
16 Yaml,
18 Xml,
20 Html,
22 Rust,
24 Plain,
26}
27
28impl Language {
29 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 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, }
62 }
63}
64
65pub 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 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 pub const fn theme(&self) -> &Theme {
91 &self.theme
92 }
93
94 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 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 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 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
174pub fn html_escape(s: &str) -> String {
176 s.replace('&', "&")
177 .replace('<', "<")
178 .replace('>', ">")
179 .replace('"', """)
180}
181
182pub 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(); 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 let final_char = chars.next();
205
206 if final_char == Some('m') {
207 if in_span {
209 output.push_str("</span>");
210 in_span = false;
211 }
212
213 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("<");
223 } else if c == '>' {
224 output.push_str(">");
225 } else if c == '&' {
226 output.push_str("&");
227 } else if c == '`' {
228 output.push_str("`");
230 } else if c == ' ' {
231 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
245fn parse_ansi_style(seq: &str) -> Option<String> {
247 if seq.is_empty() || seq == "0" {
248 return Some(String::new()); }
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()), "1" => styles.push("font-weight:bold".to_string()),
259 "2" => styles.push("opacity:0.7".to_string()), "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()), "32" => styles.push("color:#98c379".to_string()), "33" => styles.push("color:#e5c07b".to_string()), "34" => styles.push("color:#61afef".to_string()), "35" => styles.push("color:#c678dd".to_string()), "36" => styles.push("color:#56b6c2".to_string()), "37" => styles.push("color:#abb2bf".to_string()), "38" => {
271 if i + 1 < parts.len() && parts[i + 1] == "2" {
273 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()), "91" => styles.push("color:#e06c75".to_string()), "92" => styles.push("color:#98c379".to_string()),
323 "93" => styles.push("color:#e5c07b".to_string()), "94" => styles.push("color:#61afef".to_string()),
325 "95" => styles.push("color:#c678dd".to_string()), "96" => styles.push("color:#56b6c2".to_string()),
327 "97" => styles.push("color:#fff".to_string()), _ => {}
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 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
394fn 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 if newline_count > 0 {
407 result.push('\n');
408 for _ in 1..newline_count {
410 result.push_str("<br>");
411 }
412 newline_count = 0;
413 }
414 result.push(c);
415 }
416 }
417
418 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 assert_eq!(blank_lines_to_br("a\nb\nc"), "a\nb\nc");
443
444 assert_eq!(blank_lines_to_br("a\n\nb"), "a\n<br>b");
446
447 assert_eq!(blank_lines_to_br("a\n\n\nb"), "a\n<br><br>b");
449
450 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 assert_eq!(blank_lines_to_br(""), "");
458
459 assert_eq!(blank_lines_to_br("\n\n\n"), "\n<br><br>");
461 }
462}