1use std::{
2 collections::{BTreeMap, HashSet},
3 fmt::Write,
4 fs,
5 io::{self, Write as _},
6 path::{self, Path, PathBuf},
7 sync::{Arc, atomic::AtomicUsize},
8 time::Instant,
9};
10
11use ansi_colours::{ansi256_from_rgb, rgb_from_ansi256};
12use anstyle::{Ansi256Color, AnsiColor, Color, Effects, RgbColor};
13use anyhow::Result;
14use clap::ValueEnum;
15use log::{info, warn};
16use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeMap};
17use serde_json::{Value, json};
18use tree_sitter::ffi::{self, TSInputEncoding};
19use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter, HtmlRenderer};
20use tree_sitter_loader::Loader;
21
22pub const HTML_HEAD_HEADER: &str = "
23<!doctype HTML>
24<head>
25 <title>Tree-sitter Highlighting</title>
26 <style>
27 body {
28 font-family: monospace
29 }";
30
31pub const HTML_LINE_NUMBER_STYLE: &str = " .line-number {
32 user-select: none;
33 text-align: right;
34 color: rgba(27,31,35,.3);
35 padding: 0 10px;
36 }
37 .line {
38 white-space: pre;
39 }";
40
41pub const HTML_BODY_HEADER: &str = "
42</head>
43<body>
44";
45
46pub const HTML_FOOTER: &str = "
47</body>
48";
49
50#[derive(Debug, Default)]
51pub struct Style {
52 pub ansi: anstyle::Style,
53 pub css: Option<String>,
54}
55
56#[derive(Debug)]
57pub struct Theme {
58 pub styles: Vec<Style>,
59 pub highlight_names: Vec<String>,
60}
61
62#[derive(Default, Deserialize, Serialize)]
63pub struct ThemeConfig {
64 #[serde(default)]
65 pub theme: Theme,
66}
67
68impl Theme {
69 pub fn load(path: &path::Path) -> io::Result<Self> {
70 let json = fs::read_to_string(path)?;
71 Ok(serde_json::from_str(&json).unwrap_or_default())
72 }
73
74 #[must_use]
75 pub fn default_style(&self) -> Style {
76 Style::default()
77 }
78}
79
80impl<'de> Deserialize<'de> for Theme {
81 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
82 where
83 D: Deserializer<'de>,
84 {
85 let mut styles = Vec::new();
86 let mut highlight_names = Vec::new();
87 if let Ok(colors) = BTreeMap::<String, Value>::deserialize(deserializer) {
88 styles.reserve(colors.len());
89 highlight_names.reserve(colors.len());
90 for (name, style_value) in colors {
91 let mut style = Style::default();
92 parse_style(&mut style, style_value);
93 highlight_names.push(name);
94 styles.push(style);
95 }
96 }
97 Ok(Self {
98 styles,
99 highlight_names,
100 })
101 }
102}
103
104impl Serialize for Theme {
105 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
106 where
107 S: Serializer,
108 {
109 let mut map = serializer.serialize_map(Some(self.styles.len()))?;
110 for (name, style) in self.highlight_names.iter().zip(&self.styles) {
111 let style = &style.ansi;
112 let color = style.get_fg_color().map(|color| match color {
113 Color::Ansi(color) => match color {
114 AnsiColor::Black => json!("black"),
115 AnsiColor::Blue => json!("blue"),
116 AnsiColor::Cyan => json!("cyan"),
117 AnsiColor::Green => json!("green"),
118 AnsiColor::Magenta => json!("purple"),
119 AnsiColor::Red => json!("red"),
120 AnsiColor::White => json!("white"),
121 AnsiColor::Yellow => json!("yellow"),
122 _ => unreachable!(),
123 },
124 Color::Ansi256(Ansi256Color(n)) => json!(n),
125 Color::Rgb(RgbColor(r, g, b)) => json!(format!("#{r:x?}{g:x?}{b:x?}")),
126 });
127 let effects = style.get_effects();
128 if effects.contains(Effects::BOLD)
129 || effects.contains(Effects::ITALIC)
130 || effects.contains(Effects::UNDERLINE)
131 {
132 let mut style_json = BTreeMap::new();
133 if let Some(color) = color {
134 style_json.insert("color", color);
135 }
136 if effects.contains(Effects::BOLD) {
137 style_json.insert("bold", Value::Bool(true));
138 }
139 if effects.contains(Effects::ITALIC) {
140 style_json.insert("italic", Value::Bool(true));
141 }
142 if effects.contains(Effects::UNDERLINE) {
143 style_json.insert("underline", Value::Bool(true));
144 }
145 map.serialize_entry(&name, &style_json)?;
146 } else if let Some(color) = color {
147 map.serialize_entry(&name, &color)?;
148 } else {
149 map.serialize_entry(&name, &Value::Null)?;
150 }
151 }
152 map.end()
153 }
154}
155
156impl Default for Theme {
157 fn default() -> Self {
158 serde_json::from_value(json!({
159 "attribute": {"color": 124, "italic": true},
160 "comment": {"color": 245, "italic": true},
161 "constant": 94,
162 "constant.builtin": {"color": 94, "bold": true},
163 "constructor": 136,
164 "embedded": null,
165 "function": 26,
166 "function.builtin": {"color": 26, "bold": true},
167 "keyword": 56,
168 "module": 136,
169 "number": {"color": 94, "bold": true},
170 "operator": {"color": 239, "bold": true},
171 "property": 124,
172 "property.builtin": {"color": 124, "bold": true},
173 "punctuation": 239,
174 "punctuation.bracket": 239,
175 "punctuation.delimiter": 239,
176 "punctuation.special": 239,
177 "string": 28,
178 "string.special": 30,
179 "tag": 18,
180 "type": 23,
181 "type.builtin": {"color": 23, "bold": true},
182 "variable": 252,
183 "variable.builtin": {"color": 252, "bold": true},
184 "variable.parameter": {"color": 252, "underline": true}
185 }))
186 .unwrap()
187 }
188}
189
190fn parse_style(style: &mut Style, json: Value) {
191 if let Value::Object(entries) = json {
192 for (property_name, value) in entries {
193 match property_name.as_str() {
194 "bold" if value == Value::Bool(true) => {
195 style.ansi = style.ansi.bold();
196 }
197 "italic" if value == Value::Bool(true) => {
198 style.ansi = style.ansi.italic();
199 }
200 "underline" if value == Value::Bool(true) => {
201 style.ansi = style.ansi.underline();
202 }
203 "color" => {
204 if let Some(color) = parse_color(value) {
205 style.ansi = style.ansi.fg_color(Some(color));
206 }
207 }
208 _ => {}
209 }
210 }
211 style.css = Some(style_to_css(style.ansi));
212 } else if let Some(color) = parse_color(json) {
213 style.ansi = style.ansi.fg_color(Some(color));
214 style.css = Some(style_to_css(style.ansi));
215 } else {
216 style.css = None;
217 }
218
219 if let Some(Color::Rgb(RgbColor(red, green, blue))) = style.ansi.get_fg_color()
220 && !terminal_supports_truecolor()
221 {
222 let ansi256 = Color::Ansi256(Ansi256Color(ansi256_from_rgb((red, green, blue))));
223 style.ansi = style.ansi.fg_color(Some(ansi256));
224 }
225}
226
227fn parse_color(json: Value) -> Option<Color> {
228 match json {
229 Value::Number(n) => n.as_u64().map(|n| Color::Ansi256(Ansi256Color(n as u8))),
230 Value::String(s) => match s.to_lowercase().as_str() {
231 "black" => Some(Color::Ansi(AnsiColor::Black)),
232 "blue" => Some(Color::Ansi(AnsiColor::Blue)),
233 "cyan" => Some(Color::Ansi(AnsiColor::Cyan)),
234 "green" => Some(Color::Ansi(AnsiColor::Green)),
235 "purple" => Some(Color::Ansi(AnsiColor::Magenta)),
236 "red" => Some(Color::Ansi(AnsiColor::Red)),
237 "white" => Some(Color::Ansi(AnsiColor::White)),
238 "yellow" => Some(Color::Ansi(AnsiColor::Yellow)),
239 s => {
240 if let Some((red, green, blue)) = hex_string_to_rgb(s) {
241 Some(Color::Rgb(RgbColor(red, green, blue)))
242 } else {
243 None
244 }
245 }
246 },
247 _ => None,
248 }
249}
250
251fn hex_string_to_rgb(s: &str) -> Option<(u8, u8, u8)> {
252 if s.starts_with('#') && s.len() >= 7 {
253 if let (Ok(red), Ok(green), Ok(blue)) = (
254 u8::from_str_radix(&s[1..3], 16),
255 u8::from_str_radix(&s[3..5], 16),
256 u8::from_str_radix(&s[5..7], 16),
257 ) {
258 Some((red, green, blue))
259 } else {
260 None
261 }
262 } else {
263 None
264 }
265}
266
267fn style_to_css(style: anstyle::Style) -> String {
268 let mut result = String::new();
269 let effects = style.get_effects();
270 if effects.contains(Effects::UNDERLINE) {
271 write!(&mut result, "text-decoration: underline;").unwrap();
272 }
273 if effects.contains(Effects::BOLD) {
274 write!(&mut result, "font-weight: bold;").unwrap();
275 }
276 if effects.contains(Effects::ITALIC) {
277 write!(&mut result, "font-style: italic;").unwrap();
278 }
279 if let Some(color) = style.get_fg_color() {
280 write_color(&mut result, color);
281 }
282 result
283}
284
285fn write_color(buffer: &mut String, color: Color) {
286 match color {
287 Color::Ansi(color) => match color {
288 AnsiColor::Black => write!(buffer, "color: black").unwrap(),
289 AnsiColor::Red => write!(buffer, "color: red").unwrap(),
290 AnsiColor::Green => write!(buffer, "color: green").unwrap(),
291 AnsiColor::Yellow => write!(buffer, "color: yellow").unwrap(),
292 AnsiColor::Blue => write!(buffer, "color: blue").unwrap(),
293 AnsiColor::Magenta => write!(buffer, "color: purple").unwrap(),
294 AnsiColor::Cyan => write!(buffer, "color: cyan").unwrap(),
295 AnsiColor::White => write!(buffer, "color: white").unwrap(),
296 _ => unreachable!(),
297 },
298 Color::Ansi256(Ansi256Color(n)) => {
299 let (r, g, b) = rgb_from_ansi256(n);
300 write!(buffer, "color: #{r:02x}{g:02x}{b:02x}").unwrap();
301 }
302 Color::Rgb(RgbColor(r, g, b)) => write!(buffer, "color: #{r:02x}{g:02x}{b:02x}").unwrap(),
303 }
304}
305
306fn terminal_supports_truecolor() -> bool {
307 std::env::var("COLORTERM")
308 .is_ok_and(|truecolor| truecolor == "truecolor" || truecolor == "24bit")
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
313pub enum HtmlOutput {
314 Document,
317 #[value(name = "line-numbers")]
319 NumberedDocument,
320 Fragment,
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
326pub enum HtmlStyling {
327 Classes,
329 Inline,
331 Minimal,
333}
334
335pub struct HighlightOptions {
336 pub theme: Theme,
337 pub check: bool,
338 pub captures_path: Option<PathBuf>,
339 pub html: Option<(HtmlOutput, HtmlStyling)>,
341 pub quiet: bool,
342 pub print_time: bool,
343 pub cancellation_flag: Arc<AtomicUsize>,
344 pub encoding: Option<TSInputEncoding>,
345}
346
347pub fn highlight(
348 loader: &Loader,
349 path: &Path,
350 name: &str,
351 config: &HighlightConfiguration,
352 print_name: bool,
353 opts: &HighlightOptions,
354) -> Result<()> {
355 if opts.check {
356 let names = if let Some(path) = opts.captures_path.as_deref() {
357 let file = fs::read_to_string(path)?;
358 let capture_names = file
359 .lines()
360 .filter_map(|line| {
361 if line.trim().is_empty() || line.trim().starts_with(';') {
362 return None;
363 }
364 line.split(';').next().map(|s| s.trim().trim_matches('"'))
365 })
366 .collect::<HashSet<_>>();
367 config.nonconformant_capture_names(&capture_names)
368 } else {
369 config.nonconformant_capture_names(&HashSet::new())
370 };
371 if names.is_empty() {
372 info!("All highlight captures conform to standards.");
373 } else {
374 warn!(
375 "Non-standard highlight {} detected:\n* {}",
376 if names.len() > 1 {
377 "captures"
378 } else {
379 "capture"
380 },
381 names.join("\n* ")
382 );
383 }
384 }
385
386 let source = fs::read(path)?;
387
388 fn is_utf16_le_bom(bom_bytes: &[u8]) -> bool {
389 bom_bytes == [0xFF, 0xFE]
390 }
391
392 fn is_utf16_be_bom(bom_bytes: &[u8]) -> bool {
393 bom_bytes == [0xFE, 0xFF]
394 }
395
396 let encoding = match opts.encoding {
397 None if source.len() >= 2 => {
398 if is_utf16_le_bom(&source[0..2]) {
399 Some(ffi::TSInputEncodingUTF16LE)
400 } else if is_utf16_be_bom(&source[0..2]) {
401 Some(ffi::TSInputEncodingUTF16BE)
402 } else {
403 None
404 }
405 }
406 _ => opts.encoding,
407 };
408
409 let stdout = io::stdout();
410 let mut stdout = stdout.lock();
411 let time = Instant::now();
412 let mut highlighter = Highlighter::new();
413 let events = highlighter.highlight(
414 config,
415 &source,
416 encoding,
417 Some(&opts.cancellation_flag),
418 |string| loader.highlight_config_for_injection_string(string),
419 )?;
420 let theme = &opts.theme;
421
422 let html_fragment = opts
424 .html
425 .is_some_and(|(layout, _)| layout == HtmlOutput::Fragment);
426 if !opts.quiet && print_name && !html_fragment {
427 writeln!(&mut stdout, "{name}")?;
428 }
429
430 if let Some((layout, style)) = opts.html {
431 if !opts.quiet && layout != HtmlOutput::Fragment {
432 writeln!(&mut stdout, "{HTML_HEAD_HEADER}")?;
433 if layout == HtmlOutput::NumberedDocument {
434 writeln!(&mut stdout, "{HTML_LINE_NUMBER_STYLE}")?;
435 }
436 if style == HtmlStyling::Classes {
437 for (name, style) in theme.highlight_names.iter().zip(&theme.styles) {
438 if let Some(css) = &style.css {
439 writeln!(&mut stdout, " .{name} {{ {css}; }}")?;
440 }
441 }
442 }
443 writeln!(&mut stdout, " </style>")?;
444 writeln!(&mut stdout, "{HTML_BODY_HEADER}")?;
445 }
446
447 let mut renderer = HtmlRenderer::new();
448 renderer.render(events, &source, &move |highlight, output| {
449 if style == HtmlStyling::Inline {
450 output.extend(b"style='");
451 output.extend(
452 theme.styles[highlight.0]
453 .css
454 .as_ref()
455 .map_or_else(|| "".as_bytes(), |css_style| css_style.as_bytes()),
456 );
457 } else {
458 output.extend(b"class='");
459 let mut parts = theme.highlight_names[highlight.0].split('.').peekable();
460 while let Some(part) = parts.next() {
461 output.extend(part.as_bytes());
462 if parts.peek().is_some() {
463 output.extend(b" ");
464 }
465 }
466 }
467 output.extend(b"'");
468 })?;
469
470 if !opts.quiet {
471 if layout == HtmlOutput::NumberedDocument {
472 writeln!(&mut stdout, "<table>")?;
473 for (i, line) in renderer.lines().enumerate() {
474 writeln!(
475 &mut stdout,
476 "<tr><td class=line-number>{}</td><td class=line>{line}</td></tr>",
477 i + 1,
478 )?;
479 }
480 writeln!(&mut stdout, "</table>")?;
481 } else {
482 let mut body = renderer.lines().collect::<String>();
483 if body.ends_with('\n') {
484 body.pop();
485 }
486 writeln!(
487 &mut stdout,
488 "<div class=\"highlight\">\n<pre><code>{body}</code></pre>\n</div>",
489 )?;
490 }
491 if layout != HtmlOutput::Fragment {
492 writeln!(&mut stdout, "{HTML_FOOTER}")?;
493 }
494 }
495 } else {
496 let mut style_stack = vec![theme.default_style().ansi];
497 for event in events {
498 match event? {
499 HighlightEvent::HighlightStart(highlight) => {
500 style_stack.push(theme.styles[highlight.0].ansi);
501 }
502 HighlightEvent::HighlightEnd => {
503 style_stack.pop();
504 }
505 HighlightEvent::Source { start, end } => {
506 let style = style_stack.last().unwrap();
507 write!(&mut stdout, "{style}").unwrap();
508 stdout.write_all(&source[start..end])?;
509 write!(&mut stdout, "{style:#}").unwrap();
510 }
511 }
512 }
513 }
514
515 if opts.print_time {
516 info!("Time: {}ms", time.elapsed().as_millis());
517 }
518
519 Ok(())
520}
521
522#[cfg(test)]
523mod tests {
524 use std::env;
525
526 use super::*;
527
528 const JUNGLE_GREEN: &str = "#26A69A";
529 const DARK_CYAN: &str = "#00AF87";
530
531 #[test]
532 fn test_parse_style() {
533 let original_environment_variable = env::var("COLORTERM");
534
535 let mut style = Style::default();
536 assert_eq!(style.ansi.get_fg_color(), None);
537 assert_eq!(style.css, None);
538
539 unsafe { env::set_var("COLORTERM", "") };
541 parse_style(&mut style, Value::String(DARK_CYAN.to_string()));
542 assert_eq!(
543 style.ansi.get_fg_color(),
544 Some(Color::Ansi256(Ansi256Color(36)))
545 );
546 assert_eq!(style.css, Some("color: #00af87".to_string()));
547
548 unsafe { env::set_var("COLORTERM", "truecolor") };
550 parse_style(&mut style, Value::String(JUNGLE_GREEN.to_string()));
551 assert_eq!(
552 style.ansi.get_fg_color(),
553 Some(Color::Rgb(RgbColor(38, 166, 154)))
554 );
555 assert_eq!(style.css, Some("color: #26a69a".to_string()));
556
557 unsafe { env::set_var("COLORTERM", "") };
559 parse_style(&mut style, Value::String(JUNGLE_GREEN.to_string()));
560 assert_eq!(
561 style.ansi.get_fg_color(),
562 Some(Color::Ansi256(Ansi256Color(72)))
563 );
564 assert_eq!(style.css, Some("color: #26a69a".to_string()));
565
566 if let Ok(environment_variable) = original_environment_variable {
567 unsafe { env::set_var("COLORTERM", environment_variable) };
568 } else {
569 unsafe { env::remove_var("COLORTERM") };
570 }
571 }
572}