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/// Foreground color + style for a byte range of one line.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct StyledRange {
20    pub range: Range<usize>,
21    pub fg: (u8, u8, u8),
22    pub bold: bool,
23    pub italic: bool,
24}
25
26impl Default for Highlighter {
27    fn default() -> Self {
28        let syntaxes = two_face::syntax::extra_newlines();
29        let themes: EmbeddedLazyThemeSet = two_face::theme::extra();
30        // closest bundled match to the spec's GitHub-dark look
31        let theme = themes.get(EmbeddedThemeName::OneHalfDark).clone();
32        Self { syntaxes, theme }
33    }
34}
35
36impl Highlighter {
37    /// Highlight `content` as the language guessed from `path`'s extension.
38    /// Returns one `Vec<StyledRange>` per line (without trailing newlines).
39    /// Unknown languages produce empty ranges per line (plain rendering).
40    pub fn highlight(&self, path: &str, content: &str) -> Vec<Vec<StyledRange>> {
41        let extension = std::path::Path::new(path)
42            .extension()
43            .and_then(|e| e.to_str())
44            .unwrap_or("");
45        let Some(syntax) = self.syntaxes.find_syntax_by_extension(extension) else {
46            return content.lines().map(|_| Vec::new()).collect();
47        };
48        let mut machine = HighlightLines::new(syntax, &self.theme);
49        let mut out = Vec::new();
50        for line in syntect::util::LinesWithEndings::from(content) {
51            let spans = machine
52                .highlight_line(line, &self.syntaxes)
53                .unwrap_or_default();
54            let mut ranges = Vec::new();
55            let mut pos = 0usize;
56            let visible_len = line.trim_end_matches(['\n', '\r']).len();
57            for (style, text) in spans {
58                let start = pos;
59                pos += text.len();
60                let end = pos.min(visible_len);
61                if start >= end {
62                    continue;
63                }
64                ranges.push(StyledRange {
65                    range: start..end,
66                    fg: (style.foreground.r, style.foreground.g, style.foreground.b),
67                    bold: style
68                        .font_style
69                        .contains(syntect::highlighting::FontStyle::BOLD),
70                    italic: style
71                        .font_style
72                        .contains(syntect::highlighting::FontStyle::ITALIC),
73                });
74            }
75            out.push(ranges);
76        }
77        out
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn python_keywords_get_distinct_color() {
87        let hl = Highlighter::default();
88        let lines = hl.highlight("a.py", "def f():\n    return 1\n");
89        assert_eq!(lines.len(), 2);
90        // line styled with more than one color: keyword vs identifier
91        let colors: std::collections::HashSet<(u8, u8, u8)> =
92            lines[0].iter().map(|r| r.fg).collect();
93        assert!(colors.len() > 1, "expected multiple colors, got {colors:?}");
94    }
95
96    #[test]
97    fn ranges_cover_within_line_bounds() {
98        let hl = Highlighter::default();
99        let src = "fn main() { let x = \"hi\"; }\n";
100        let lines = hl.highlight("a.rs", src);
101        let visible = src.trim_end();
102        for r in &lines[0] {
103            assert!(r.range.end <= visible.len());
104            assert!(r.range.start < r.range.end);
105        }
106    }
107
108    #[test]
109    fn multiline_string_state_carries_across_lines() {
110        let hl = Highlighter::default();
111        let src = "s = \"\"\"first\nsecond\nthird\"\"\"\nx = 1\n";
112        let lines = hl.highlight("a.py", src);
113        // the middle line is entirely inside the string: single styled run,
114        // same color as the string-opening run on line 0
115        let string_color = lines[0].iter().last().map(|r| r.fg).expect("line 0 styled");
116        assert!(
117            lines[1].iter().all(|r| r.fg == string_color),
118            "inside-string line must keep string color"
119        );
120    }
121
122    #[test]
123    fn unknown_extension_yields_plain_lines() {
124        let hl = Highlighter::default();
125        let lines = hl.highlight("file.zzz-unknown", "a\nb\n");
126        assert_eq!(lines, vec![Vec::new(), Vec::new()]);
127    }
128}