//! syntect class-based highlighting (see docs/m2-interfaces.md). //! //! Output is class-based (`hl-` prefix) and never carries inline styles, so the //! CSP without 'unsafe-inline' is respected. Each source line becomes one HTML //! fragment whose spans are balanced: scopes that continue across a line break //! are closed at the end of the line and reopened at the start of the next. // Nothing outside the tests calls this module until the blob view is wired up. #![allow(dead_code)] use std::sync::LazyLock; use syntect::highlighting::ThemeSet; use syntect::html::{ClassStyle, css_for_theme_with_class_style, line_tokens_to_classed_spans}; use syntect::parsing::{ParseState, ScopeStack, SyntaxReference, SyntaxSet}; use syntect::util::LinesWithEndings; /// Inputs above this many bytes are not highlighted. const MAX_BYTES: usize = 512 * 1024; /// Inputs above this many lines are not highlighted. const MAX_LINES: usize = 20_000; const CLASS_PREFIX: &str = "hl-"; const LIGHT_THEME: &str = "InspiredGitHub"; const DARK_THEME: &str = "base16-ocean.dark"; /// Scope for the dark rules when the page has no explicit theme. const DARK_WHEN_AUTO: &str = ":root:not([data-theme=\"light\"])"; /// Scope for the dark rules when the user picked the dark theme. const DARK_WHEN_FORCED: &str = ":root[data-theme=\"dark\"]"; // Newline-aware syntaxes are required because input is fed through LinesWithEndings. static SYNTAX_SET: LazyLock = LazyLock::new(SyntaxSet::load_defaults_newlines); static THEME_SET: LazyLock = LazyLock::new(ThemeSet::load_defaults); static CSS: LazyLock = LazyLock::new(build_css); fn class_style() -> ClassStyle { ClassStyle::SpacedPrefixed { prefix: CLASS_PREFIX, } } fn find_syntax(file_name: &str, first_line: &str) -> Option<&'static SyntaxReference> { let ss: &'static SyntaxSet = &SYNTAX_SET; file_name .rsplit_once('.') .and_then(|(_, ext)| ss.find_syntax_by_extension(ext)) .or_else(|| ss.find_syntax_by_extension(file_name)) .or_else(|| ss.find_syntax_by_first_line(first_line)) } /// Highlights `text` as the language implied by `file_name` (extension, then /// full file name, then shebang/first line). Returns one HTML fragment per /// line without the trailing newline, or `None` when the language is unknown /// or plain text, or the input exceeds the size limits. pub fn highlight_lines(file_name: &str, text: &str) -> Option> { if text.len() > MAX_BYTES { return None; } let line_count = text.lines().count(); if line_count > MAX_LINES { return None; } let first_line = text.lines().next().unwrap_or(""); let syntax = find_syntax(file_name, first_line)?; if syntax.name == "Plain Text" { return None; } let ss: &SyntaxSet = &SYNTAX_SET; let style = class_style(); let mut parse_state = ParseState::new(syntax); // Holds the scopes that are still open at the start of the next line. let mut stack = ScopeStack::new(); let mut out = Vec::with_capacity(line_count); for line in LinesWithEndings::from(text) { let ops = parse_state.parse_line(line, ss).ok()?; let mut html = String::with_capacity(line.len() * 2); // Reopen spans for scopes carried over from the previous line. Each // scope's classes are its dot-separated atoms, each with the prefix. for scope in stack.as_slice() { html.push_str(" = name .split('.') .map(|atom| format!("{CLASS_PREFIX}{atom}")) .collect(); html.push_str(&classes.join(" ")); html.push_str("\">"); } let (body, _) = line_tokens_to_classed_spans(line, &ops, style, &mut stack).ok()?; html.push_str(&body); // Close every span still open at end of line; the stack persists. for _ in 0..stack.len() { html.push_str(""); } // The line's trailing newline is the last raw newline in the fragment // (closing tags never contain one). Drop it, and a CR before it. if let Some(pos) = html.rfind('\n') { html.remove(pos); if pos > 0 && html.as_bytes()[pos - 1] == b'\r' { html.remove(pos - 1); } } out.push(html); } Some(out) } fn build_css() -> String { let light = THEME_SET .themes .get(LIGHT_THEME) .expect("syntect default theme InspiredGitHub is present"); let dark = THEME_SET .themes .get(DARK_THEME) .expect("syntect default theme base16-ocean.dark is present"); let light_css = css_for_theme_with_class_style(light, class_style()) .expect("generating CSS for the light theme"); let dark_css = css_for_theme_with_class_style(dark, class_style()) .expect("generating CSS for the dark theme"); // The dark rules follow the page theme: the OS preference unless `data-theme="light"` is set, // and always when `data-theme="dark"` is set. format!( "{light_css}\n@media (prefers-color-scheme: dark) {{\n{}}}\n{}", scope_css(&dark_css, DARK_WHEN_AUTO), scope_css(&dark_css, DARK_WHEN_FORCED), ) } /// Prefixes every selector in a syntect stylesheet with `scope`. syntect writes each rule as a /// selector line ending in `{` (selectors separated by commas), then declarations, then `}`. fn scope_css(css: &str, scope: &str) -> String { let mut out = String::with_capacity(css.len() * 2); for line in css.lines() { match line.strip_suffix('{') { Some(selectors) => { let scoped: Vec = selectors .split(',') .map(|selector| format!("{scope} {}", selector.trim())) .collect(); out.push_str(&scoped.join(", ")); out.push_str(" {\n"); } None => { out.push_str(line); out.push('\n'); } } } out } /// CSS for the `hl-` classes: the light theme by default, and the dark theme /// under `prefers-color-scheme: dark`. pub fn highlight_css() -> String { CSS.clone() } #[cfg(test)] mod tests { use super::*; fn balanced(fragment: &str) -> bool { fragment.matches("").count() } #[test] fn rust_line_count_matches_input() { let src = "fn main() {\n println!(\"hi\");\n}\n"; let lines = highlight_lines("main.rs", src).expect("rust is known"); assert_eq!(lines.len(), src.lines().count()); assert_eq!(lines.len(), 3); assert!(lines.iter().all(|l| !l.contains('\n'))); } #[test] fn multiline_constructs_stay_balanced_per_line() { let src = "/* a\n b */ let x = \"multi\nline\";\nfn f() {}\n"; let lines = highlight_lines("lib.rs", src).expect("rust is known"); assert_eq!(lines.len(), 4); for line in &lines { assert!(balanced(line), "unbalanced fragment: {line}"); } } #[test] fn script_in_string_is_escaped() { let src = "let s = \"\";\n"; let lines = highlight_lines("page.rs", src).expect("rust is known"); let joined = lines.join("\n"); assert!( !joined.contains("