mdsee_render/
highlight.rs1use crate::style::Rgb;
8
9#[derive(Debug, Clone, PartialEq, Eq, Default)]
11pub struct HighlightedLine {
12 pub spans: Vec<HighlightedSpan>,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct HighlightedSpan {
18 pub text: String,
19 pub fg: Rgb,
20 pub bold: bool,
21 pub italic: bool,
22 pub underline: bool,
23}
24
25pub trait SyntaxHighlighter {
27 fn highlight(&self, code: &str, language: Option<&str>) -> Vec<HighlightedLine>;
28}
29
30#[derive(Debug, Clone, Copy, Default)]
32pub struct NoHighlight;
33
34impl SyntaxHighlighter for NoHighlight {
35 fn highlight(&self, code: &str, _language: Option<&str>) -> Vec<HighlightedLine> {
36 code.lines()
37 .map(|_| HighlightedLine { spans: Vec::new() })
38 .collect()
39 }
40}
41
42#[cfg(feature = "syntax")]
44pub mod syntect_backend {
45 use super::{HighlightedLine, HighlightedSpan, Rgb, SyntaxHighlighter};
46 use std::sync::OnceLock;
47 use syntect::easy::HighlightLines;
48 use syntect::highlighting::{FontStyle, ThemeSet};
49 use syntect::parsing::SyntaxSet;
50
51 static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
52 static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
53
54 #[derive(Debug, Clone)]
59 pub struct SyntectHighlighter {
60 theme_name: String,
61 }
62
63 impl SyntectHighlighter {
64 pub fn new(theme_name: impl Into<String>) -> Self {
65 Self {
66 theme_name: theme_name.into(),
67 }
68 }
69 }
70
71 impl Default for SyntectHighlighter {
72 fn default() -> Self {
73 Self::new("base16-ocean.dark")
74 }
75 }
76
77 impl SyntaxHighlighter for SyntectHighlighter {
78 fn highlight(&self, code: &str, language: Option<&str>) -> Vec<HighlightedLine> {
79 let syntax_set = SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines);
80 let theme_set = THEME_SET.get_or_init(ThemeSet::load_defaults);
81 let fallback = theme_set.themes.get("base16-ocean.dark");
82 let theme = theme_set
83 .themes
84 .get(&self.theme_name)
85 .or(fallback)
86 .expect("built-in theme set always contains base16-ocean.dark");
87
88 let syntax = language
89 .and_then(|token| syntax_set.find_syntax_by_token(token))
90 .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
91
92 let mut highlighter = HighlightLines::new(syntax, theme);
93 let mut lines = Vec::new();
94 for line in code.split_inclusive('\n') {
95 let mut spans: Vec<HighlightedSpan> = Vec::new();
96 let ranges = highlighter.highlight_line(line, syntax_set);
97 match ranges {
98 Ok(ranges) => {
99 for (style, text) in ranges {
100 let text = text.strip_suffix('\n').unwrap_or(text);
101 let text = text.strip_suffix('\r').unwrap_or(text);
102 if text.is_empty() {
103 continue;
104 }
105 spans.push(HighlightedSpan {
106 text: text.to_string(),
107 fg: Rgb(style.foreground.r, style.foreground.g, style.foreground.b),
108 bold: style.font_style.contains(FontStyle::BOLD),
109 italic: style.font_style.contains(FontStyle::ITALIC),
110 underline: style.font_style.contains(FontStyle::UNDERLINE),
111 });
112 }
113 }
114 Err(_) => spans.push(HighlightedSpan {
115 text: line.trim_end_matches(['\n', '\r']).to_string(),
116 fg: Rgb(166, 173, 186),
117 bold: false,
118 italic: false,
119 underline: false,
120 }),
121 }
122 lines.push(HighlightedLine { spans });
123 }
124 lines
127 }
128 }
129}
130
131#[cfg(all(test, feature = "syntax"))]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn rust_code_gets_multiple_colored_spans() {
137 let highlighter = syntect_backend::SyntectHighlighter::default();
138 let lines = highlighter.highlight("fn main() {}\n", Some("rust"));
139 assert_eq!(lines.len(), 1);
140 let joined: String = lines[0].spans.iter().map(|s| s.text.as_str()).collect();
141 assert_eq!(joined, "fn main() {}");
142 assert!(lines[0].spans.len() >= 2, "spans: {:?}", lines[0].spans);
144 let colors: std::collections::HashSet<_> = lines[0].spans.iter().map(|s| s.fg).collect();
145 assert!(colors.len() >= 2);
146 }
147
148 #[test]
149 fn unknown_language_falls_back_to_plain() {
150 let highlighter = syntect_backend::SyntectHighlighter::default();
151 let lines = highlighter.highlight("just text\n", Some("no-such-lang"));
152 assert_eq!(lines.len(), 1);
153 assert_eq!(lines[0].spans[0].text, "just text");
154 }
155
156 #[test]
157 fn multi_line_code_preserves_line_count() {
158 let highlighter = syntect_backend::SyntectHighlighter::default();
159 let lines = highlighter.highlight("a\nb\nc\n", None);
160 assert_eq!(lines.len(), 3);
161 let texts: Vec<String> = lines
162 .iter()
163 .map(|l| l.spans.iter().map(|s| s.text.as_str()).collect())
164 .collect();
165 assert_eq!(texts, ["a", "b", "c"]);
166 }
167
168 #[test]
169 fn unknown_theme_falls_back_to_default() {
170 let highlighter = syntect_backend::SyntectHighlighter::new("no-such-theme");
171 let lines = highlighter.highlight("x\n", None);
172 assert_eq!(lines.len(), 1);
173 }
174}