1use syntect::easy::HighlightLines;
4use syntect::highlighting::{Style, Theme, ThemeSet};
5use syntect::html::highlighted_html_for_string;
6use syntect::parsing::{SyntaxSet, SyntaxSetBuilder};
7use syntect::util::{LinesWithEndings, as_24_bit_terminal_escaped};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Language {
12 Json,
14 Yaml,
16 Xml,
18 Kdl,
20 Rust,
22}
23
24impl Language {
25 pub fn extension(self) -> &'static str {
27 match self {
28 Language::Json => "json",
29 Language::Yaml => "yaml",
30 Language::Xml => "xml",
31 Language::Kdl => "kdl",
32 Language::Rust => "rs",
33 }
34 }
35
36 pub fn name(self) -> &'static str {
38 match self {
39 Language::Json => "JSON",
40 Language::Yaml => "YAML",
41 Language::Xml => "XML",
42 Language::Kdl => "KDL",
43 Language::Rust => "Rust",
44 }
45 }
46}
47
48pub struct Highlighter {
50 default_ps: SyntaxSet,
52 kdl_ps: Option<SyntaxSet>,
54 theme: Theme,
56}
57
58impl Default for Highlighter {
59 fn default() -> Self {
60 Self::new()
61 }
62}
63
64impl Highlighter {
65 pub fn new() -> Self {
67 let default_ps = SyntaxSet::load_defaults_newlines();
68
69 let theme = Self::load_tokyo_night_theme();
71
72 Self {
73 default_ps,
74 kdl_ps: None,
75 theme,
76 }
77 }
78
79 fn load_tokyo_night_theme() -> Theme {
81 let possible_paths = [
83 "themes/TokyoNight.tmTheme",
85 "../themes/TokyoNight.tmTheme",
87 "../../themes/TokyoNight.tmTheme",
89 ];
90
91 for path in possible_paths {
92 if let Ok(theme) = ThemeSet::get_theme(path) {
93 return theme;
94 }
95 }
96
97 let ts = ThemeSet::load_defaults();
99 ts.themes["base16-ocean.dark"].clone()
100 }
101
102 pub fn with_kdl_syntaxes(mut self, syntax_dir: &str) -> Self {
104 let mut builder = SyntaxSetBuilder::new();
105 builder.add_plain_text_syntax();
106 if builder.add_from_folder(syntax_dir, true).is_ok() {
107 self.kdl_ps = Some(builder.build());
108 }
109 self
110 }
111
112 pub fn theme(&self) -> &Theme {
114 &self.theme
115 }
116
117 pub fn highlight_to_terminal(&self, code: &str, lang: Language) -> String {
119 let mut output = String::new();
120
121 let (ps, syntax) = match lang {
122 Language::Kdl => {
123 if let Some(ref kdl_ps) = self.kdl_ps {
124 if let Some(syntax) = kdl_ps
126 .find_syntax_by_name("KDL")
127 .or_else(|| kdl_ps.find_syntax_by_name("KDL1"))
128 {
129 (kdl_ps, syntax)
130 } else {
131 return self.plain_text_with_indent(code);
133 }
134 } else {
135 return self.plain_text_with_indent(code);
136 }
137 }
138 _ => {
139 let syntax = self
140 .default_ps
141 .find_syntax_by_extension(lang.extension())
142 .unwrap_or_else(|| self.default_ps.find_syntax_plain_text());
143 (&self.default_ps, syntax)
144 }
145 };
146
147 let mut h = HighlightLines::new(syntax, &self.theme);
148 for line in LinesWithEndings::from(code) {
149 let ranges: Vec<(Style, &str)> = h.highlight_line(line, ps).unwrap_or_default();
150 let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
151 output.push_str(" ");
152 output.push_str(&escaped);
153 }
154 output.push_str("\x1b[0m"); if !output.ends_with('\n') {
158 output.push('\n');
159 }
160
161 output
162 }
163
164 pub fn highlight_to_terminal_with_line_numbers(&self, code: &str, lang: Language) -> String {
166 use owo_colors::OwoColorize;
167
168 let mut output = String::new();
169
170 let (ps, syntax) = match lang {
171 Language::Kdl => {
172 if let Some(ref kdl_ps) = self.kdl_ps {
173 if let Some(syntax) = kdl_ps
174 .find_syntax_by_name("KDL")
175 .or_else(|| kdl_ps.find_syntax_by_name("KDL1"))
176 {
177 (kdl_ps, syntax)
178 } else {
179 return self.plain_text_with_line_numbers(code);
180 }
181 } else {
182 return self.plain_text_with_line_numbers(code);
183 }
184 }
185 _ => {
186 let syntax = self
187 .default_ps
188 .find_syntax_by_extension(lang.extension())
189 .unwrap_or_else(|| self.default_ps.find_syntax_plain_text());
190 (&self.default_ps, syntax)
191 }
192 };
193
194 let mut h = HighlightLines::new(syntax, &self.theme);
195 for (i, line) in code.lines().enumerate() {
196 let line_with_newline = format!("{line}\n");
197 let ranges: Vec<(Style, &str)> =
198 h.highlight_line(&line_with_newline, ps).unwrap_or_default();
199 let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
200
201 output.push_str(&format!(
202 "{} {} {}",
203 format!("{:3}", i + 1).dimmed(),
204 "│".dimmed(),
205 escaped
206 ));
207 }
208 output.push_str("\x1b[0m"); output
211 }
212
213 pub fn build_miette_highlighter(
215 &self,
216 lang: Language,
217 ) -> miette::highlighters::SyntectHighlighter {
218 let (syntax_set, _) = match lang {
219 Language::Kdl => {
220 if let Some(ref kdl_ps) = self.kdl_ps {
221 (kdl_ps.clone(), ())
222 } else {
223 (self.default_ps.clone(), ())
224 }
225 }
226 _ => (self.default_ps.clone(), ()),
227 };
228
229 miette::highlighters::SyntectHighlighter::new(syntax_set, self.theme.clone(), false)
230 }
231
232 pub fn highlight_to_html(&self, code: &str, lang: Language) -> String {
234 let (ps, syntax) = match lang {
235 Language::Kdl => {
236 if let Some(ref kdl_ps) = self.kdl_ps {
237 if let Some(syntax) = kdl_ps
238 .find_syntax_by_name("KDL")
239 .or_else(|| kdl_ps.find_syntax_by_name("KDL1"))
240 {
241 (kdl_ps, syntax)
242 } else {
243 return html_escape(code);
244 }
245 } else {
246 return html_escape(code);
247 }
248 }
249 _ => {
250 let syntax = self
251 .default_ps
252 .find_syntax_by_extension(lang.extension())
253 .unwrap_or_else(|| self.default_ps.find_syntax_plain_text());
254 (&self.default_ps, syntax)
255 }
256 };
257
258 highlighted_html_for_string(code, ps, syntax, &self.theme)
260 .unwrap_or_else(|_| html_escape(code))
261 }
262
263 fn plain_text_with_indent(&self, code: &str) -> String {
264 let mut output = String::new();
265 for line in code.lines() {
266 output.push_str(" ");
267 output.push_str(line);
268 output.push('\n');
269 }
270 output
271 }
272
273 fn plain_text_with_line_numbers(&self, code: &str) -> String {
274 use owo_colors::OwoColorize;
275
276 let mut output = String::new();
277 for (i, line) in code.lines().enumerate() {
278 output.push_str(&format!(
279 "{} {} {}\n",
280 format!("{:3}", i + 1).dimmed(),
281 "│".dimmed(),
282 line
283 ));
284 }
285 output
286 }
287}
288
289pub fn html_escape(s: &str) -> String {
291 s.replace('&', "&")
292 .replace('<', "<")
293 .replace('>', ">")
294 .replace('"', """)
295}
296
297pub fn ansi_to_html(input: &str) -> String {
300 let mut output = String::new();
301 let mut chars = input.chars().peekable();
302 let mut in_span = false;
303
304 while let Some(c) = chars.next() {
305 if c == '\x1b' && chars.peek() == Some(&'[') {
306 chars.next(); let mut seq = String::new();
310 while let Some(&ch) = chars.peek() {
311 if ch.is_ascii_digit() || ch == ';' {
312 seq.push(chars.next().unwrap());
313 } else {
314 break;
315 }
316 }
317
318 let final_char = chars.next();
320
321 if final_char == Some('m') {
322 if in_span {
324 output.push_str("</span>");
325 in_span = false;
326 }
327
328 if let Some(style) = parse_ansi_style(&seq)
330 && !style.is_empty()
331 {
332 output.push_str(&format!("<span style=\"{style}\">"));
333 in_span = true;
334 }
335 }
336 } else if c == '<' {
337 output.push_str("<");
338 } else if c == '>' {
339 output.push_str(">");
340 } else if c == '&' {
341 output.push_str("&");
342 } else if c == '`' {
343 output.push_str("`");
345 } else if c == ' ' {
346 output.push('\u{00A0}');
348 } else {
349 output.push(c);
350 }
351 }
352
353 if in_span {
354 output.push_str("</span>");
355 }
356
357 output
358}
359
360fn parse_ansi_style(seq: &str) -> Option<String> {
362 if seq.is_empty() || seq == "0" {
363 return Some(String::new()); }
365
366 let parts: Vec<&str> = seq.split(';').collect();
367 let mut styles = Vec::new();
368
369 let mut i = 0;
370 while i < parts.len() {
371 match parts[i] {
372 "0" => return Some(String::new()), "1" => styles.push("font-weight:bold".to_string()),
374 "2" => styles.push("opacity:0.7".to_string()), "3" => styles.push("font-style:italic".to_string()),
376 "4" => styles.push("text-decoration:underline".to_string()),
377 "30" => styles.push("color:#000".to_string()),
378 "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" => {
386 if i + 1 < parts.len() && parts[i + 1] == "2" {
388 if i + 4 < parts.len() {
390 let r = parts[i + 2];
391 let g = parts[i + 3];
392 let b = parts[i + 4];
393 styles.push(format!("color:rgb({r},{g},{b})"));
394 i += 4;
395 }
396 } else if i + 1 < parts.len() && parts[i + 1] == "5" {
397 if i + 2 < parts.len() {
399 if let Ok(n) = parts[i + 2].parse::<u8>() {
400 let color = ansi_256_to_rgb(n);
401 styles.push(format!("color:{color}"));
402 }
403 i += 2;
404 }
405 }
406 }
407 "90" => styles.push("color:#5c6370".to_string()), "91" => styles.push("color:#e06c75".to_string()), "92" => styles.push("color:#98c379".to_string()), "93" => styles.push("color:#e5c07b".to_string()), "94" => styles.push("color:#61afef".to_string()), "95" => styles.push("color:#c678dd".to_string()), "96" => styles.push("color:#56b6c2".to_string()), "97" => styles.push("color:#fff".to_string()), _ => {}
416 }
417 i += 1;
418 }
419
420 Some(styles.join(";"))
421}
422
423fn ansi_256_to_rgb(n: u8) -> &'static str {
425 match n {
426 0 => "#000000",
428 1 => "#800000",
429 2 => "#008000",
430 3 => "#808000",
431 4 => "#000080",
432 5 => "#800080",
433 6 => "#008080",
434 7 => "#c0c0c0",
435 8 => "#808080",
437 9 => "#e06c75", 10 => "#98c379", 11 => "#e5c07b", 12 => "#61afef", 13 => "#c678dd", 14 => "#56b6c2", 15 => "#ffffff",
444 16..=231 => {
446 "#888888"
449 }
450 232..=255 => {
452 "#888888"
454 }
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::Language;
461
462 #[test]
463 fn xml_language_metadata_is_exposed() {
464 assert_eq!(Language::Xml.name(), "XML");
465 assert_eq!(Language::Xml.extension(), "xml");
466 }
467}