diffler_core/
highlight.rs1use 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#[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#[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 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 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 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 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}