Skip to main content

diffler_core/
highlight.rs

1//! Whole-file syntax highlighting via tree-sitter, sliced into per-line styled
2//! ranges. Highlighting whole files (not hunks) keeps multi-line constructs
3//! like strings correct across hunk boundaries. Unknown languages and parse
4//! failures degrade to plain (empty) ranges so rendering never breaks.
5
6use 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/// Syntax-highlight palette, paired with a UI theme so foreground colors stay
18/// legible against the diff backgrounds (a dark UI needs dark-theme syntax, a
19/// light UI light-theme syntax).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum SyntaxTheme {
22    #[default]
23    OneHalfDark,
24    OneHalfLight,
25    Dracula,
26}
27
28/// Foreground color + style for a byte range of one line.
29#[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    /// Build a highlighter whose foregrounds come from `syntax`.
45    pub fn new(syntax: SyntaxTheme) -> Self {
46        Self {
47            registry: LanguageRegistry::build(),
48            theme: syntax,
49        }
50    }
51
52    /// Highlight `content` as the language guessed from `path`'s extension.
53    /// Returns one `Vec<StyledRange>` per line (without trailing newlines).
54    /// Unknown languages produce empty ranges per line (plain rendering).
55    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    /// Definition breadcrumb index for `content`, computed via the same grammar
113    /// registry used for highlighting. Empty for unsupported languages.
114    pub fn scope_index(&self, path: &str, content: &str) -> crate::syntax::ScopeIndex {
115        self.registry.scope_index(path, content)
116    }
117
118    /// Set AST-diff char-precise emphasis on `file`. Returns `false` (caller
119    /// should fall back to the textual engine) when unavailable.
120    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    /// Style for a tree-sitter capture name, matched by its leading category
133    /// (`function.method` -> `function`). `None` leaves the span at default fg.
134    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                // brighter than One Dark's default so comments stay legible on
153                // the added/removed diff backgrounds, not just the editor bg
154                "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}