Skip to main content

diffler_core/
highlight.rs

1//! Whole-file syntax highlighting sliced into per-line styled ranges.
2//! Highlighting whole files (not hunks) keeps stateful constructs like
3//! multi-line strings correct across hunk boundaries.
4
5use std::ops::Range;
6
7use syntect::easy::HighlightLines;
8use syntect::highlighting::Theme;
9use syntect::parsing::SyntaxSet;
10use two_face::theme::{EmbeddedLazyThemeSet, EmbeddedThemeName};
11
12pub struct Highlighter {
13    syntaxes: SyntaxSet,
14    theme: Theme,
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
28impl SyntaxTheme {
29    fn embedded(self) -> EmbeddedThemeName {
30        match self {
31            Self::OneHalfDark => EmbeddedThemeName::OneHalfDark,
32            Self::OneHalfLight => EmbeddedThemeName::OneHalfLight,
33            Self::Dracula => EmbeddedThemeName::Dracula,
34        }
35    }
36}
37
38/// Foreground color + style for a byte range of one line.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct StyledRange {
41    pub range: Range<usize>,
42    pub fg: (u8, u8, u8),
43    pub bold: bool,
44    pub italic: bool,
45}
46
47impl Default for Highlighter {
48    fn default() -> Self {
49        Self::new(SyntaxTheme::default())
50    }
51}
52
53impl Highlighter {
54    /// Build a highlighter whose foregrounds come from `syntax`.
55    pub fn new(syntax: SyntaxTheme) -> Self {
56        let syntaxes = two_face::syntax::extra_newlines();
57        let themes: EmbeddedLazyThemeSet = two_face::theme::extra();
58        let theme = themes.get(syntax.embedded()).clone();
59        Self { syntaxes, theme }
60    }
61
62    /// Highlight `content` as the language guessed from `path`'s extension.
63    /// Returns one `Vec<StyledRange>` per line (without trailing newlines).
64    /// Unknown languages produce empty ranges per line (plain rendering).
65    pub fn highlight(&self, path: &str, content: &str) -> Vec<Vec<StyledRange>> {
66        let extension = std::path::Path::new(path)
67            .extension()
68            .and_then(|e| e.to_str())
69            .unwrap_or("");
70        let Some(syntax) = self.syntaxes.find_syntax_by_extension(extension) else {
71            return content.lines().map(|_| Vec::new()).collect();
72        };
73        let mut machine = HighlightLines::new(syntax, &self.theme);
74        let mut out = Vec::new();
75        for line in syntect::util::LinesWithEndings::from(content) {
76            let spans = machine
77                .highlight_line(line, &self.syntaxes)
78                .unwrap_or_default();
79            let mut ranges = Vec::new();
80            let mut pos = 0usize;
81            let visible_len = line.trim_end_matches(['\n', '\r']).len();
82            for (style, text) in spans {
83                let start = pos;
84                pos += text.len();
85                let end = pos.min(visible_len);
86                if start >= end {
87                    continue;
88                }
89                ranges.push(StyledRange {
90                    range: start..end,
91                    fg: (style.foreground.r, style.foreground.g, style.foreground.b),
92                    bold: style
93                        .font_style
94                        .contains(syntect::highlighting::FontStyle::BOLD),
95                    italic: style
96                        .font_style
97                        .contains(syntect::highlighting::FontStyle::ITALIC),
98                });
99            }
100            out.push(ranges);
101        }
102        out
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn python_keywords_get_distinct_color() {
112        let hl = Highlighter::default();
113        let lines = hl.highlight("a.py", "def f():\n    return 1\n");
114        assert_eq!(lines.len(), 2);
115        // line styled with more than one color: keyword vs identifier
116        let colors: std::collections::HashSet<(u8, u8, u8)> =
117            lines[0].iter().map(|r| r.fg).collect();
118        assert!(colors.len() > 1, "expected multiple colors, got {colors:?}");
119    }
120
121    #[test]
122    fn ranges_cover_within_line_bounds() {
123        let hl = Highlighter::default();
124        let src = "fn main() { let x = \"hi\"; }\n";
125        let lines = hl.highlight("a.rs", src);
126        let visible = src.trim_end();
127        for r in &lines[0] {
128            assert!(r.range.end <= visible.len());
129            assert!(r.range.start < r.range.end);
130        }
131    }
132
133    #[test]
134    fn multiline_string_state_carries_across_lines() {
135        let hl = Highlighter::default();
136        let src = "s = \"\"\"first\nsecond\nthird\"\"\"\nx = 1\n";
137        let lines = hl.highlight("a.py", src);
138        // the middle line is entirely inside the string: single styled run,
139        // same color as the string-opening run on line 0
140        let string_color = lines[0].iter().last().map(|r| r.fg).expect("line 0 styled");
141        assert!(
142            lines[1].iter().all(|r| r.fg == string_color),
143            "inside-string line must keep string color"
144        );
145    }
146
147    #[test]
148    fn unknown_extension_yields_plain_lines() {
149        let hl = Highlighter::default();
150        let lines = hl.highlight("file.zzz-unknown", "a\nb\n");
151        assert_eq!(lines, vec![Vec::new(), Vec::new()]);
152    }
153
154    #[test]
155    fn syntax_theme_changes_the_foreground_palette() {
156        let src = "fn main() { let x = 1; }\n";
157        let dark = Highlighter::new(SyntaxTheme::OneHalfDark).highlight("a.rs", src);
158        let light = Highlighter::new(SyntaxTheme::OneHalfLight).highlight("a.rs", src);
159        assert_ne!(dark, light, "a different syntax theme recolors the line");
160    }
161}