1use std::cell::RefCell;
4
5use arborium::theme::Theme;
6use arborium::{AnsiHighlighter, Highlighter as ArboriumHighlighter};
7use miette_arborium::MietteHighlighter;
8use owo_colors::OwoColorize;
9
10const INDENT: &str = " ";
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Language {
15 Json,
17 Yaml,
19 Xml,
21 Kdl,
23 Rust,
25}
26
27impl Language {
28 pub fn extension(self) -> &'static str {
30 match self {
31 Language::Json => "json",
32 Language::Yaml => "yaml",
33 Language::Xml => "xml",
34 Language::Kdl => "kdl",
35 Language::Rust => "rs",
36 }
37 }
38
39 pub fn name(self) -> &'static str {
41 match self {
42 Language::Json => "JSON",
43 Language::Yaml => "YAML",
44 Language::Xml => "XML",
45 Language::Kdl => "KDL",
46 Language::Rust => "Rust",
47 }
48 }
49
50 fn arborium_name(self) -> &'static str {
51 match self {
52 Language::Json => "json",
53 Language::Yaml => "yaml",
54 Language::Xml => "xml",
55 Language::Kdl => "kdl",
56 Language::Rust => "rust",
57 }
58 }
59}
60
61pub struct Highlighter {
63 html_highlighter: RefCell<ArboriumHighlighter>,
64 ansi_highlighter: RefCell<AnsiHighlighter>,
65 theme: Theme,
66}
67
68impl Default for Highlighter {
69 fn default() -> Self {
70 Self::new()
71 }
72}
73
74impl Highlighter {
75 pub fn new() -> Self {
77 let theme = arborium::theme::builtin::tokyo_night().clone();
78 Self {
79 html_highlighter: RefCell::new(ArboriumHighlighter::new()),
80 ansi_highlighter: RefCell::new(AnsiHighlighter::new(theme.clone())),
81 theme,
82 }
83 }
84
85 pub fn with_kdl_syntaxes(self, _syntax_dir: &str) -> Self {
87 self
88 }
89
90 pub fn theme(&self) -> &Theme {
92 &self.theme
93 }
94
95 pub fn highlight_to_terminal(&self, code: &str, lang: Language) -> String {
97 let mut hl = self.ansi_highlighter.borrow_mut();
98 match hl.highlight(lang.arborium_name(), code) {
99 Ok(output) => {
100 let mut result = String::new();
102 for line in output.lines() {
103 result.push_str(INDENT);
104 result.push_str(line);
105 result.push('\n');
106 }
107 result
108 }
109 Err(_) => self.plain_text_with_indent(code),
110 }
111 }
112
113 pub fn highlight_to_terminal_with_line_numbers(&self, code: &str, lang: Language) -> String {
115 let mut hl = self.ansi_highlighter.borrow_mut();
116 match hl.highlight(lang.arborium_name(), code) {
117 Ok(output) => {
118 let mut result = String::new();
119 for (i, line) in output.lines().enumerate() {
120 result.push_str(&format!(
121 "{} {} {}\n",
122 format!("{:3}", i + 1).dimmed(),
123 "│".dimmed(),
124 line
125 ));
126 }
127 result
128 }
129 Err(_) => self.plain_text_with_line_numbers(code),
130 }
131 }
132
133 pub fn build_miette_highlighter(&self, _lang: Language) -> MietteHighlighter {
135 MietteHighlighter::new()
136 }
137
138 pub fn highlight_to_html(&self, code: &str, lang: Language) -> String {
140 let mut hl = self.html_highlighter.borrow_mut();
141 match hl.highlight(lang.arborium_name(), code) {
142 Ok(html) => wrap_with_pre(html, &self.theme),
143 Err(_) => wrap_plain_text_html(code, &self.theme),
144 }
145 }
146
147 fn plain_text_with_indent(&self, code: &str) -> String {
148 let mut output = String::new();
149 for line in code.lines() {
150 output.push_str(INDENT);
151 output.push_str(line);
152 output.push('\n');
153 }
154 output
155 }
156
157 fn plain_text_with_line_numbers(&self, code: &str) -> String {
158 let mut output = String::new();
159 for (i, line) in code.lines().enumerate() {
160 output.push_str(&format!(
161 "{} {} {}\n",
162 format!("{:3}", i + 1).dimmed(),
163 "│".dimmed(),
164 line
165 ));
166 }
167 output
168 }
169}
170
171pub fn html_escape(s: &str) -> String {
173 s.replace('&', "&")
174 .replace('<', "<")
175 .replace('>', ">")
176 .replace('"', """)
177}
178
179pub fn ansi_to_html(input: &str) -> String {
182 let mut output = String::new();
183 let mut chars = input.chars().peekable();
184 let mut in_span = false;
185
186 while let Some(c) = chars.next() {
187 if c == '\x1b' && chars.peek() == Some(&'[') {
188 chars.next(); let mut seq = String::new();
192 while let Some(&ch) = chars.peek() {
193 if ch.is_ascii_digit() || ch == ';' {
194 seq.push(chars.next().unwrap());
195 } else {
196 break;
197 }
198 }
199
200 let final_char = chars.next();
202
203 if final_char == Some('m') {
204 if in_span {
206 output.push_str("</span>");
207 in_span = false;
208 }
209
210 if let Some(style) = parse_ansi_style(&seq)
212 && !style.is_empty()
213 {
214 output.push_str(&format!("<span style=\"{style}\">"));
215 in_span = true;
216 }
217 }
218 } else if c == '<' {
219 output.push_str("<");
220 } else if c == '>' {
221 output.push_str(">");
222 } else if c == '&' {
223 output.push_str("&");
224 } else if c == '`' {
225 output.push_str("`");
227 } else if c == ' ' {
228 output.push('\u{00A0}');
230 } else {
231 output.push(c);
232 }
233 }
234
235 if in_span {
236 output.push_str("</span>");
237 }
238
239 output
240}
241
242fn parse_ansi_style(seq: &str) -> Option<String> {
244 if seq.is_empty() || seq == "0" {
245 return Some(String::new()); }
247
248 let parts: Vec<&str> = seq.split(';').collect();
249 let mut styles = Vec::new();
250
251 let mut i = 0;
252 while i < parts.len() {
253 match parts[i] {
254 "0" => return Some(String::new()), "1" => styles.push("font-weight:bold".to_string()),
256 "2" => styles.push("opacity:0.7".to_string()), "3" => styles.push("font-style:italic".to_string()),
258 "4" => styles.push("text-decoration:underline".to_string()),
259 "30" => styles.push("color:#000".to_string()),
260 "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" => {
268 if i + 1 < parts.len() && parts[i + 1] == "2" {
270 if i + 4 < parts.len() {
272 let r = parts[i + 2];
273 let g = parts[i + 3];
274 let b = parts[i + 4];
275 styles.push(format!("color:rgb({r},{g},{b})"));
276 i += 4;
277 }
278 } else if i + 1 < parts.len()
279 && parts[i + 1] == "5"
280 && i + 2 < parts.len()
281 && let Ok(n) = parts[i + 2].parse::<u8>()
282 {
283 let color = ansi_256_to_rgb(n);
284 styles.push(format!("color:{color}"));
285 i += 2;
286 }
287 }
288 "39" => styles.push("color:inherit".to_string()),
289 "40" => styles.push("background-color:#000".to_string()),
290 "41" => styles.push("background-color:#e06c75".to_string()),
291 "42" => styles.push("background-color:#98c379".to_string()),
292 "43" => styles.push("background-color:#e5c07b".to_string()),
293 "44" => styles.push("background-color:#61afef".to_string()),
294 "45" => styles.push("background-color:#c678dd".to_string()),
295 "46" => styles.push("background-color:#56b6c2".to_string()),
296 "47" => styles.push("background-color:#abb2bf".to_string()),
297 "48" => {
298 if i + 1 < parts.len() && parts[i + 1] == "2" {
299 if i + 4 < parts.len() {
300 let r = parts[i + 2];
301 let g = parts[i + 3];
302 let b = parts[i + 4];
303 styles.push(format!("background-color:rgb({r},{g},{b})"));
304 i += 4;
305 }
306 } else if i + 1 < parts.len()
307 && parts[i + 1] == "5"
308 && i + 2 < parts.len()
309 && let Ok(n) = parts[i + 2].parse::<u8>()
310 {
311 let color = ansi_256_to_rgb(n);
312 styles.push(format!("background-color:{color}"));
313 i += 2;
314 }
315 }
316 "49" => styles.push("background-color:transparent".to_string()),
317 "90" => styles.push("color:#5c6370".to_string()), "91" => styles.push("color:#e06c75".to_string()), "92" => styles.push("color:#98c379".to_string()),
320 "93" => styles.push("color:#e5c07b".to_string()), "94" => styles.push("color:#61afef".to_string()),
322 "95" => styles.push("color:#c678dd".to_string()), "96" => styles.push("color:#56b6c2".to_string()),
324 "97" => styles.push("color:#fff".to_string()), _ => {}
326 }
327 i += 1;
328 }
329
330 if styles.is_empty() {
331 None
332 } else {
333 Some(styles.join(";"))
334 }
335}
336
337fn ansi_256_to_rgb(n: u8) -> &'static str {
338 match n {
339 0 => "#000000",
340 1 => "#800000",
341 2 => "#008000",
342 3 => "#808000",
343 4 => "#000080",
344 5 => "#800080",
345 6 => "#008080",
346 7 => "#c0c0c0",
347 8 => "#808080",
348 9 => "#ff0000",
349 10 => "#00ff00",
350 11 => "#ffff00",
351 12 => "#0000ff",
352 13 => "#ff00ff",
353 14 => "#00ffff",
354 15 => "#ffffff",
355 _ => "#888888",
356 }
357}
358
359fn wrap_plain_text_html(code: &str, theme: &Theme) -> String {
360 wrap_with_pre(html_escape(code), theme)
361}
362
363fn wrap_with_pre(content: String, theme: &Theme) -> String {
364 let mut styles = Vec::new();
365 if let Some(bg) = theme.background {
366 styles.push(format!("background-color:{};", bg.to_hex()));
367 }
368 if let Some(fg) = theme.foreground {
369 styles.push(format!("color:{};", fg.to_hex()));
370 }
371 styles.push("padding:12px;".to_string());
372 styles.push("border-radius:8px;".to_string());
373 styles.push(
374 "font-family:var(--facet-mono, SFMono-Regular, Consolas, 'Liberation Mono', monospace);"
375 .to_string(),
376 );
377 styles.push("font-size:0.9rem;".to_string());
378 styles.push("overflow:auto;".to_string());
379 format!(
380 "<pre style=\"{}\"><code>{}</code></pre>",
381 styles.join(" "),
382 content
383 )
384}
385
386#[cfg(test)]
387mod tests {
388 use super::Language;
389
390 #[test]
391 fn xml_language_metadata_is_exposed() {
392 assert_eq!(Language::Xml.name(), "XML");
393 assert_eq!(Language::Xml.extension(), "xml");
394 }
395}