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, 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 let theme = themes.get(EmbeddedThemeName::OneHalfDark).clone();
32 Self { syntaxes, theme }
33 }
34}
35
36impl Highlighter {
37 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 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 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}