//! Syntax highlighting with syntect, emitted as classed spans so the //! stylesheet colours them in both themes (`templates/style.css`). use std::sync::OnceLock; use syntect::html::{ClassStyle, ClassedHTMLGenerator}; use syntect::parsing::SyntaxSet; use syntect::util::LinesWithEndings; fn syntaxes() -> &'static SyntaxSet { static SET: OnceLock = OnceLock::new(); SET.get_or_init(SyntaxSet::load_defaults_newlines) } /// The language name shown for a file, if syntect knows the extension. pub fn language(path: &str) -> Option<&'static str> { let ext = path.rsplit('.').next().unwrap_or(""); let name = path.rsplit('/').next().unwrap_or(path); let set = syntaxes(); set.find_syntax_by_extension(ext) .or_else(|| set.find_syntax_by_extension(name)) .or_else(|| match name { "Makefile" | "GNUmakefile" => set.find_syntax_by_name("Makefile"), "Dockerfile" => set.find_syntax_by_name("Dockerfile"), _ => None, }) .map(|s| s.name.as_str()) } /// Highlight `text`, returning one HTML fragment per line (no trailing /// newline); plain escaped lines when the language is unknown. pub fn lines(path: &str, text: &str) -> Vec { let set = syntaxes(); let ext = path.rsplit('.').next().unwrap_or(""); let name = path.rsplit('/').next().unwrap_or(path); let syntax = set.find_syntax_by_extension(ext).or_else(|| set.find_syntax_by_extension(name)).or_else(|| match name { "Makefile" | "GNUmakefile" => set.find_syntax_by_name("Makefile"), _ => None, }); let Some(syntax) = syntax else { return text.lines().map(super::escape).collect(); }; let mut generator = ClassedHTMLGenerator::new_with_class_style(syntax, set, ClassStyle::Spaced); let mut out = Vec::new(); for line in LinesWithEndings::from(text) { if generator.parse_html_for_line_which_includes_newline(line).is_err() { return text.lines().map(super::escape).collect(); } } let html = generator.finalize(); // syntect emits the whole file with spans that may span lines; split // on newlines and keep spans balanced per line. let mut open: Vec = Vec::new(); let mut raw_lines: Vec<&str> = html.split('\n').collect(); // syntect closes the outermost spans after the final newline; that // segment holds no text and every span is balanced per line below. if raw_lines.last().is_some_and(|l| l.replace("", "").trim().is_empty()) { raw_lines.pop(); } for raw in raw_lines { let mut line = String::new(); for tag in &open { line.push_str(tag); } line.push_str(raw); // Track spans opened and closed on this line. let mut i = 0; while let Some(p) = raw[i..].find('<') { let start = i + p; let Some(end_rel) = raw[start..].find('>') else { break }; let tag = &raw[start..start + end_rel + 1]; if tag.starts_with(""); } out.push(line); } out } #[cfg(test)] mod tests { use super::*; #[test] fn highlights_rust_and_falls_back() { let l = lines("main.rs", "fn main() {\n let x = 1;\n}\n"); assert_eq!(l.len(), 3); assert!(l[0].contains(""), "{}", l[0]); assert!(l[0].contains("storage type function"), "{}", l[0]); assert!(l[1].ends_with(""), "spans are balanced per line: {}", l[1]); assert_eq!(lines("x.unknownext", "a