1use std::ops::Range;
7
8use tree_sitter_highlight::{HighlightEvent, Highlighter as TsHighlighter};
9
10use crate::syntax::{HIGHLIGHT_NAMES, LanguageRegistry};
11
12pub struct Highlighter {
13 registry: LanguageRegistry,
14 theme: SyntaxTheme,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum SyntaxTheme {
22 #[default]
23 OneHalfDark,
24 OneHalfLight,
25 Dracula,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct StyledRange {
31 pub range: Range<usize>,
32 pub fg: (u8, u8, u8),
33 pub bold: bool,
34 pub italic: bool,
35}
36
37impl Default for Highlighter {
38 fn default() -> Self {
39 Self::new(SyntaxTheme::default())
40 }
41}
42
43impl Highlighter {
44 pub fn new(syntax: SyntaxTheme) -> Self {
46 Self {
47 registry: LanguageRegistry::build(),
48 theme: syntax,
49 }
50 }
51
52 pub fn highlight(&self, path: &str, content: &str) -> Vec<Vec<StyledRange>> {
56 let bounds = crate::syntax::line_bounds(content);
57 let mut out: Vec<Vec<StyledRange>> = vec![Vec::new(); bounds.len()];
58
59 if content.len() > crate::syntax::MAX_PARSE_BYTES {
60 return out;
61 }
62 let Some(entry) = self.registry.for_path(path) else {
63 return out;
64 };
65 let Some(config) = entry.config.as_ref() else {
66 return out;
67 };
68
69 let mut ts = TsHighlighter::new();
70 let Ok(events) = ts.highlight(config, content.as_bytes(), None, |_| None) else {
71 return out;
72 };
73
74 let starts: Vec<usize> = bounds.iter().map(|&(s, _)| s).collect();
75 let mut stack: Vec<usize> = Vec::new();
76 for event in events {
77 let Ok(event) = event else {
78 return out;
79 };
80 match event {
81 HighlightEvent::HighlightStart(h) => stack.push(h.0),
82 HighlightEvent::HighlightEnd => {
83 stack.pop();
84 }
85 HighlightEvent::Source { start, end } => {
86 if let Some(&name_idx) = stack.last()
87 && let Some(name) = HIGHLIGHT_NAMES.get(name_idx)
88 && let Some(style) = self.theme.style(name)
89 {
90 crate::syntax::split_range_by_line(
91 &bounds,
92 &starts,
93 &(start..end),
94 |li, r| {
95 if let Some(line) = out.get_mut(li) {
96 line.push(StyledRange {
97 range: r,
98 fg: style.fg,
99 bold: style.bold,
100 italic: style.italic,
101 });
102 }
103 },
104 );
105 }
106 }
107 }
108 }
109 out
110 }
111
112 pub fn scope_index(&self, path: &str, content: &str) -> crate::syntax::ScopeIndex {
115 self.registry.scope_index(path, content)
116 }
117
118 pub fn syntactic_emphasis(&self, file: &mut crate::model::FileDiff) -> bool {
121 self.registry.syntactic_emphasis(file)
122 }
123}
124
125struct StyleSpec {
126 fg: (u8, u8, u8),
127 bold: bool,
128 italic: bool,
129}
130
131impl SyntaxTheme {
132 fn style(self, name: &str) -> Option<StyleSpec> {
135 let category = name.split('.').next().unwrap_or(name);
136 let italic = category == "comment";
137 let fg = self.color(category)?;
138 Some(StyleSpec {
139 fg,
140 bold: false,
141 italic,
142 })
143 }
144
145 fn color(self, category: &str) -> Option<(u8, u8, u8)> {
146 let c = match self {
147 SyntaxTheme::OneHalfDark => match category {
148 "keyword" | "label" => (198, 120, 221),
149 "function" => (97, 175, 239),
150 "type" | "constructor" => (229, 192, 123),
151 "string" => (152, 195, 121),
152 "comment" => (126, 134, 145),
155 "constant" | "number" | "attribute" => (209, 154, 102),
156 "operator" | "escape" => (86, 182, 194),
157 "property" | "tag" => (224, 108, 117),
158 "variable" | "punctuation" => (171, 178, 191),
159 _ => return None,
160 },
161 SyntaxTheme::OneHalfLight => match category {
162 "keyword" | "label" => (166, 38, 164),
163 "function" => (64, 120, 242),
164 "type" | "constructor" => (193, 132, 1),
165 "string" => (80, 161, 79),
166 "comment" => (160, 161, 167),
167 "constant" | "number" | "attribute" => (152, 104, 1),
168 "operator" | "escape" => (1, 132, 188),
169 "property" | "tag" => (228, 86, 73),
170 "variable" | "punctuation" => (56, 58, 66),
171 _ => return None,
172 },
173 SyntaxTheme::Dracula => match category {
174 "keyword" | "label" | "tag" | "operator" => (255, 121, 198),
175 "function" | "property" => (80, 250, 123),
176 "type" | "constructor" => (139, 233, 253),
177 "string" => (241, 250, 140),
178 "comment" => (98, 114, 164),
179 "constant" | "number" => (189, 147, 249),
180 "escape" | "attribute" => (255, 184, 108),
181 "variable" | "punctuation" => (248, 248, 242),
182 _ => return None,
183 },
184 };
185 Some(c)
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn python_keywords_get_distinct_color() {
195 let hl = Highlighter::default();
196 let lines = hl.highlight("a.py", "def f():\n return 1\n");
197 assert_eq!(lines.len(), 2);
198 let colors: std::collections::HashSet<(u8, u8, u8)> =
199 lines[0].iter().map(|r| r.fg).collect();
200 assert!(colors.len() > 1, "expected multiple colors, got {colors:?}");
201 }
202
203 #[test]
204 fn yaml_is_highlighted() {
205 let hl = Highlighter::default();
206 let lines = hl.highlight("ci.yml", "name: CI\non: push\njobs:\n lint: {}\n");
207 assert!(
208 lines.iter().any(|line| !line.is_empty()),
209 "expected styled ranges for a .yml file"
210 );
211 }
212
213 #[test]
214 fn ranges_cover_within_line_bounds() {
215 let hl = Highlighter::default();
216 let src = "fn main() { let x = \"hi\"; }\n";
217 let lines = hl.highlight("a.rs", src);
218 let visible = src.trim_end();
219 for r in &lines[0] {
220 assert!(r.range.end <= visible.len());
221 assert!(r.range.start < r.range.end);
222 }
223 }
224
225 #[test]
226 fn multiline_string_state_carries_across_lines() {
227 let hl = Highlighter::default();
228 let src = "s = \"\"\"first\nsecond\nthird\"\"\"\nx = 1\n";
229 let lines = hl.highlight("a.py", src);
230 let string_color = lines[0].iter().last().map(|r| r.fg).expect("line 0 styled");
231 assert!(
232 lines[1].iter().all(|r| r.fg == string_color),
233 "inside-string line must keep string color"
234 );
235 }
236
237 #[test]
238 fn unknown_extension_yields_plain_lines() {
239 let hl = Highlighter::default();
240 let lines = hl.highlight("file.zzz-unknown", "a\nb\n");
241 assert_eq!(lines, vec![Vec::new(), Vec::new()]);
242 }
243
244 #[test]
245 fn syntax_theme_changes_the_foreground_palette() {
246 let src = "fn main() { let x = 1; }\n";
247 let dark = Highlighter::new(SyntaxTheme::OneHalfDark).highlight("a.rs", src);
248 let light = Highlighter::new(SyntaxTheme::OneHalfLight).highlight("a.rs", src);
249 assert_ne!(dark, light, "a different syntax theme recolors the line");
250 }
251}