1use comrak::adapters::SyntaxHighlighterAdapter;
10use std::borrow::Cow;
11use std::collections::HashMap;
12use std::fmt::{self, Write};
13use std::sync::LazyLock;
14use syntect::highlighting::ThemeSet;
15use syntect::html::{
16 css_for_theme_with_class_style, ClassStyle, ClassedHTMLGenerator,
17};
18use syntect::parsing::SyntaxSet;
19use syntect::util::LinesWithEndings;
20
21static SYNTAX_SET: LazyLock<SyntaxSet> =
23 LazyLock::new(SyntaxSet::load_defaults_newlines);
24static THEME_SET: LazyLock<ThemeSet> =
26 LazyLock::new(ThemeSet::load_defaults);
27
28pub const DEFAULT_THEME: &str = "base16-ocean.dark";
30
31const CLASS_STYLE: ClassStyle = ClassStyle::Spaced;
36
37pub struct SyntectAdapter {
45 theme_name: String,
46}
47
48impl SyntectAdapter {
49 pub fn new(theme: Option<&str>) -> Self {
57 let theme_name = theme
58 .filter(|t| THEME_SET.themes.contains_key(*t))
59 .unwrap_or(DEFAULT_THEME)
60 .to_owned();
61 Self { theme_name }
62 }
63
64 pub fn theme_name(&self) -> &str {
66 &self.theme_name
67 }
68
69 pub fn available_themes() -> Vec<&'static str> {
71 THEME_SET.themes.keys().map(|s| s.as_str()).collect()
72 }
73}
74
75impl SyntaxHighlighterAdapter for SyntectAdapter {
76 fn write_highlighted(
77 &self,
78 output: &mut dyn Write,
79 lang: Option<&str>,
80 code: &str,
81 ) -> fmt::Result {
82 let syntax = lang
83 .and_then(|l| SYNTAX_SET.find_syntax_by_token(l))
84 .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
85
86 let mut generator = ClassedHTMLGenerator::new_with_class_style(
87 syntax,
88 &SYNTAX_SET,
89 CLASS_STYLE,
90 );
91
92 for line in LinesWithEndings::from(code) {
93 if generator
94 .parse_html_for_line_which_includes_newline(line)
95 .is_err()
96 {
97 return output
101 .write_str(&html_escape::encode_text(code));
102 }
103 }
104
105 output.write_str(&generator.finalize())
106 }
107
108 fn write_pre_tag(
109 &self,
110 output: &mut dyn Write,
111 attributes: HashMap<&'static str, Cow<'_, str>>,
112 ) -> fmt::Result {
113 write!(output, "<pre")?;
114 for (attr, value) in &attributes {
115 write!(
116 output,
117 " {}=\"{}\"",
118 attr,
119 html_escape::encode_double_quoted_attribute(value)
120 )?;
121 }
122 write!(output, ">")
123 }
124
125 fn write_code_tag(
126 &self,
127 output: &mut dyn Write,
128 attributes: HashMap<&'static str, Cow<'_, str>>,
129 ) -> fmt::Result {
130 write!(output, "<code")?;
131 for (attr, value) in &attributes {
132 if value.is_empty() {
134 continue;
135 }
136 write!(
137 output,
138 " {}=\"{}\"",
139 attr,
140 html_escape::encode_double_quoted_attribute(value)
141 )?;
142 }
143 write!(output, ">")
144 }
145}
146
147pub fn apply_syntax_highlighting(
154 code: &str,
155 lang: &str,
156) -> Result<String, crate::error::MarkdownError> {
157 let syntax = SYNTAX_SET
158 .find_syntax_by_token(lang)
159 .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
160
161 let mut generator = ClassedHTMLGenerator::new_with_class_style(
162 syntax,
163 &SYNTAX_SET,
164 CLASS_STYLE,
165 );
166
167 for line in LinesWithEndings::from(code) {
168 generator
169 .parse_html_for_line_which_includes_newline(line)
170 .map_err(|e| {
171 crate::error::MarkdownError::SyntaxHighlightError(
172 e.to_string(),
173 )
174 })?;
175 }
176
177 Ok(generator.finalize())
178}
179
180pub fn theme_css(theme_name: &str) -> Option<String> {
187 let theme = THEME_SET.themes.get(theme_name)?;
188 css_for_theme_with_class_style(theme, CLASS_STYLE).ok()
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn test_syntect_adapter_emits_class_spans() {
197 let adapter = SyntectAdapter::new(None);
198 let mut output = String::new();
199 adapter
200 .write_highlighted(
201 &mut output,
202 Some("rust"),
203 "fn main() {}",
204 )
205 .unwrap();
206 assert!(
207 output.contains("<span class="),
208 "should contain class-based syntax spans, got: {output}"
209 );
210 assert!(
211 !output.contains(" style=\""),
212 "must not contain inline styles: {output}"
213 );
214 }
215
216 #[test]
217 fn test_syntect_adapter_unknown_lang_fallback() {
218 let adapter = SyntectAdapter::new(None);
219 let mut output = String::new();
220 adapter
221 .write_highlighted(
222 &mut output,
223 Some("nonexistent-lang-xyz"),
224 "hello world",
225 )
226 .unwrap();
227 assert!(!output.is_empty());
229 }
230
231 #[test]
232 fn test_syntect_adapter_invalid_theme_falls_back() {
233 let adapter = SyntectAdapter::new(Some("no-such-theme"));
234 assert_eq!(adapter.theme_name(), DEFAULT_THEME);
235 }
236
237 #[test]
238 fn test_available_themes_not_empty() {
239 let themes = SyntectAdapter::available_themes();
240 assert!(!themes.is_empty());
241 assert!(themes.contains(&DEFAULT_THEME));
242 }
243
244 #[test]
245 fn test_standalone_highlighting_emits_classes() {
246 let html =
247 apply_syntax_highlighting("fn main() {}", "rust").unwrap();
248 assert!(html.contains("<span class="));
249 assert!(!html.contains(" style=\""));
250 }
251
252 #[test]
253 fn test_write_pre_tag_with_attributes() {
254 let adapter = SyntectAdapter::new(None);
255 let mut output = String::new();
256 let mut attrs = HashMap::new();
257 attrs.insert("class", Cow::Borrowed("highlight"));
258 attrs.insert("data-lang", Cow::Borrowed("rust"));
259 adapter.write_pre_tag(&mut output, attrs).unwrap();
260 assert!(output.starts_with("<pre"));
261 assert!(output.ends_with('>'));
262 assert!(output.contains("class=\"highlight\""));
263 assert!(output.contains("data-lang=\"rust\""));
264 }
265
266 #[test]
267 fn test_write_pre_tag_no_attributes() {
268 let adapter = SyntectAdapter::new(None);
269 let mut output = String::new();
270 adapter.write_pre_tag(&mut output, HashMap::new()).unwrap();
271 assert_eq!(output, "<pre>");
272 }
273
274 #[test]
275 fn test_write_code_tag_with_attributes() {
276 let adapter = SyntectAdapter::new(None);
277 let mut output = String::new();
278 let mut attrs = HashMap::new();
279 attrs.insert("class", Cow::Borrowed("language-rust"));
280 adapter.write_code_tag(&mut output, attrs).unwrap();
281 assert!(output.starts_with("<code"));
282 assert!(output.ends_with('>'));
283 assert!(output.contains("class=\"language-rust\""));
284 }
285
286 #[test]
287 fn test_write_code_tag_skips_empty_attributes() {
288 let adapter = SyntectAdapter::new(None);
289 let mut output = String::new();
290 let mut attrs = HashMap::new();
291 attrs.insert("class", Cow::Borrowed(""));
292 attrs.insert("id", Cow::Borrowed("my-code"));
293 adapter.write_code_tag(&mut output, attrs).unwrap();
294 assert!(!output.contains("class"));
296 assert!(output.contains("id=\"my-code\""));
298 }
299
300 #[test]
301 fn test_write_code_tag_no_attributes() {
302 let adapter = SyntectAdapter::new(None);
303 let mut output = String::new();
304 adapter.write_code_tag(&mut output, HashMap::new()).unwrap();
305 assert_eq!(output, "<code>");
306 }
307
308 #[test]
309 fn test_write_pre_tag_escapes_attribute_values() {
310 let adapter = SyntectAdapter::new(None);
311 let mut output = String::new();
312 let mut attrs = HashMap::new();
313 attrs.insert("data-info", Cow::Borrowed("a\"b"));
314 adapter.write_pre_tag(&mut output, attrs).unwrap();
315 assert!(!output.contains("a\"b"));
317 assert!(output.contains("data-info="));
318 }
319
320 #[test]
321 fn test_write_highlighted_no_lang() {
322 let adapter = SyntectAdapter::new(None);
323 let mut output = String::new();
324 adapter
325 .write_highlighted(&mut output, None, "plain text")
326 .unwrap();
327 assert!(!output.is_empty());
328 }
329
330 #[test]
331 fn test_standalone_highlighting_unknown_language() {
332 let result = apply_syntax_highlighting(
334 "hello world",
335 "nonexistent-language-xyz",
336 );
337 assert!(result.is_ok());
338 }
339
340 #[test]
341 fn test_theme_css_known_theme() {
342 let css = theme_css(DEFAULT_THEME).expect("default theme");
343 assert!(css.contains(".code"));
344 }
345
346 #[test]
347 fn test_theme_css_unknown_theme() {
348 assert!(theme_css("no-such-theme").is_none());
349 }
350
351 struct FailingWrite;
354 impl fmt::Write for FailingWrite {
355 fn write_str(&mut self, _: &str) -> fmt::Result {
356 Err(fmt::Error)
357 }
358 }
359
360 #[test]
361 fn test_write_pre_tag_propagates_write_error() {
362 let adapter = SyntectAdapter::new(None);
363 let err =
364 adapter.write_pre_tag(&mut FailingWrite, HashMap::new());
365 assert!(err.is_err());
366 }
367
368 #[test]
369 fn test_write_pre_tag_propagates_attribute_write_error() {
370 struct FailAfterFirst(usize);
373 impl fmt::Write for FailAfterFirst {
374 fn write_str(&mut self, _: &str) -> fmt::Result {
375 if self.0 == 0 {
376 self.0 += 1;
377 Ok(())
378 } else {
379 Err(fmt::Error)
380 }
381 }
382 }
383
384 let adapter = SyntectAdapter::new(None);
385 let mut attrs = HashMap::new();
386 attrs.insert("class", Cow::Borrowed("demo"));
387 let err = adapter
388 .write_pre_tag(&mut FailAfterFirst(0), attrs)
389 .unwrap_err();
390 let _ = err; }
392
393 #[test]
394 fn test_write_code_tag_propagates_write_error() {
395 let adapter = SyntectAdapter::new(None);
396 let err =
397 adapter.write_code_tag(&mut FailingWrite, HashMap::new());
398 assert!(err.is_err());
399 }
400
401 #[test]
402 fn test_write_code_tag_propagates_attribute_write_error() {
403 struct FailAfterFirst(usize);
404 impl fmt::Write for FailAfterFirst {
405 fn write_str(&mut self, _: &str) -> fmt::Result {
406 if self.0 == 0 {
407 self.0 += 1;
408 Ok(())
409 } else {
410 Err(fmt::Error)
411 }
412 }
413 }
414
415 let adapter = SyntectAdapter::new(None);
416 let mut attrs = HashMap::new();
417 attrs.insert("class", Cow::Borrowed("language-rust"));
418 let err = adapter
419 .write_code_tag(&mut FailAfterFirst(0), attrs)
420 .unwrap_err();
421 let _ = err;
422 }
423
424 #[test]
425 fn test_write_highlighted_propagates_write_error() {
426 let adapter = SyntectAdapter::new(None);
429 let err = adapter
430 .write_highlighted(
431 &mut FailingWrite,
432 Some("rust"),
433 "fn x() {}",
434 )
435 .unwrap_err();
436 let _ = err;
437 }
438}