//! Syntax highlighting for the blob view, using syntect. //! //! We emit `` tags instead of inline colours. The colours //! come from two generated stylesheets (light + dark), so the browser picks the //! right one via `prefers-color-scheme` without any JavaScript. use syntect::highlighting::ThemeSet; use syntect::html::{css_for_theme_with_class_style, ClassStyle, ClassedHTMLGenerator}; use syntect::parsing::SyntaxSet; use syntect::util::LinesWithEndings; const CLASS_STYLE: ClassStyle = ClassStyle::SpacedPrefixed { prefix: "hl-" }; /// Files bigger than this are shown without colours (highlighting is slow-ish). const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024; pub struct Highlighter { syntaxes: SyntaxSet, /// CSS for both themes, served at /static/highlight.css pub css: String, } impl Highlighter { pub fn new() -> Highlighter { let syntaxes = SyntaxSet::load_defaults_newlines(); let themes = ThemeSet::load_defaults(); let light = css_for_theme_with_class_style(&themes.themes["InspiredGitHub"], CLASS_STYLE).unwrap_or_default(); let dark = css_for_theme_with_class_style(&themes.themes["base16-ocean.dark"], CLASS_STYLE) .unwrap_or_default(); let css = format!("{light}\n@media (prefers-color-scheme: dark) {{\n{dark}\n}}\n"); Highlighter { syntaxes, css } } /// Return highlighted HTML for `code`. The result is already escaped and /// safe to insert into a page. Falls back to plain escaped text when the /// file type is unknown or the file is too large. pub fn highlight(&self, file_name: &str, code: &str) -> String { let ext = file_name.rsplit('.').next().unwrap_or(""); let syntax = self .syntaxes .find_syntax_by_extension(ext) .or_else(|| self.syntaxes.find_syntax_by_extension(file_name)) .or_else(|| self.syntaxes.find_syntax_by_first_line(code.lines().next().unwrap_or(""))); let syntax = match syntax { Some(s) if code.len() <= MAX_HIGHLIGHT_BYTES => s, _ => return escape(code), }; let mut gen = ClassedHTMLGenerator::new_with_class_style(syntax, &self.syntaxes, CLASS_STYLE); for line in LinesWithEndings::from(code) { if gen.parse_html_for_line_which_includes_newline(line).is_err() { return escape(code); } } gen.finalize() } } impl Default for Highlighter { fn default() -> Self { Highlighter::new() } } /// Minimal HTML escaping for text placed inside an element. pub fn escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), c => out.push(c), } } out } #[cfg(test)] mod tests { use super::*; #[test] fn escapes() { assert_eq!(escape("&'"), "<a href="x">&'"); } #[test] fn highlights_rust_and_escapes() { let h = Highlighter::new(); let out = h.highlight("main.rs", "fn main() { let s = \"\"; }\n"); assert!(out.contains("hl-"), "expected class spans: {out}"); assert!(!out.contains("")); assert!(out.contains("<b>")); assert!(h.css.contains("prefers-color-scheme: dark")); } #[test] fn unknown_type_is_plain_escaped() { let h = Highlighter::new(); assert_eq!(h.highlight("data.zzzunknown", ""), "<x>"); } }