//! Syntax highlighting support using syntect. use std::io::Cursor; use std::path::Path; use syntect::{ easy::HighlightLines, highlighting::{Theme as SyntectTheme, ThemeSet}, parsing::{SyntaxReference, SyntaxSet}, }; /// Syntax highlighting configuration. /// /// Loading the syntax set is expensive; create one instance and pass it to /// the markdown renderer. pub struct Syntect { pub theme: SyntectTheme, pub syntax_set: SyntaxSet, } impl Syntect { /// `theme_bytes` must be a TextMate `.tmTheme` file. The syntax set is /// two-face's extended one, covering the 250+ languages bat ships. /// /// # Example /// /// ```ignore /// let syntect = Syntect::new(include_bytes!("assets/tokyo-night.tmTheme")); /// ``` pub fn new(theme_bytes: &[u8]) -> Self { let mut cursor = Cursor::new(theme_bytes); let theme = ThemeSet::load_from_reader(&mut cursor).expect("Failed to load theme"); let syntax_set = two_face::syntax::extra_newlines(); Self { theme, syntax_set } } /// Resolves on the extension alone; the rest of the file name is ignored. pub fn find_syntax_by_file_path(&self, file_path: &Path) -> Option<&SyntaxReference> { let ext = file_path.extension()?.to_str()?; self.syntax_set.find_syntax_by_extension(ext) } /// Find a syntax definition by language token (e.g., "rust", "python"). pub fn find_syntax_by_token(&self, token: &str) -> Option<&SyntaxReference> { self.syntax_set.find_syntax_by_token(token) } pub fn highlight_lines_by_file_path(&self, file_path: &Path) -> Option> { Some(HighlightLines::new( self.find_syntax_by_file_path(file_path)?, &self.theme, )) } pub fn highlight_lines_for_token(&self, token: &str) -> Option> { Some(HighlightLines::new( self.find_syntax_by_token(token)?, &self.theme, )) } /// Highlighter for a fenced code block *info* string: a normal language token /// (e.g. `rust`, `python`), or a **line-range citation** of the form /// `lineStart:lineEnd:path/to/file.ext` where the syntax is resolved the same /// way as [`Syntect::highlight_lines_by_file_path`] (see /// [`Syntect::find_syntax_by_file_path`]). /// /// If the string matches the citation form but no syntax is found for the /// path, this falls back to [`Syntect::find_syntax_by_token`] with the full /// `fence_info` string, so plain ` ```lang` blocks keep working and odd /// citations degrade to a plain token lookup. pub fn highlight_lines_for_fence_info(&self, fence_info: &str) -> Option> { Some(HighlightLines::new( self.find_syntax_for_fence_info(fence_info)?, &self.theme, )) } /// Resolve the [`SyntaxReference`] for a fenced code block *info* string, /// using the SAME rules as [`Syntect::highlight_lines_for_fence_info`]: /// a `lineStart:lineEnd:path` citation resolves by file path, otherwise /// (or if the path has no known syntax) it falls back to a language token. /// /// Exposed so the incremental open-code highlighter can build its own /// resumable `ParseState`/`HighlightState` against exactly the syntax the /// batch `HighlightLines` path would have used — keeping the two /// byte-identical. pub(crate) fn find_syntax_for_fence_info(&self, fence_info: &str) -> Option<&SyntaxReference> { if let Some((_, _, path)) = parse_line_citation_fence_info(fence_info) && let Some(s) = self.find_syntax_by_file_path(Path::new(path)) { return Some(s); } self.find_syntax_by_token(fence_info) } } /// ```text /// lineStart:lineEnd:path/to/file.ext /// ``` /// /// The path is the segment after the **second** colon; it is then parsed with /// [`Path::new`]. Paths with extra colons in the first two segments (e.g. some /// Windows `C:...` forms) are not supported; use a repo-relative or /// forward-slash form. fn parse_line_citation_fence_info(info: &str) -> Option<(&str, &str, &str)> { let mut it = info.splitn(3, ':'); let start = it.next()?; let end = it.next()?; let path = it.next()?; if start.is_empty() || !start.chars().all(|c| c.is_ascii_digit()) { return None; } if end.is_empty() || !end.chars().all(|c| c.is_ascii_digit()) { return None; } if path.is_empty() { return None; } Some((start, end, path)) } /// Syntax highlight code, returning raw styled segments per line. /// /// `fence_info` is the fenced code block *info* string (language tag or /// `lineStart:lineEnd:path` citation form); see /// [`Syntect::highlight_lines_for_fence_info`]. Lives here (not in `parse`) /// so both the parser and the streaming highlighter caches depend one-way on /// `syntax`. pub(crate) fn syntax_highlight_raw( syntect: Option<&Syntect>, fence_info: &str, text: &str, ) -> Option>> { use syntect::util::LinesWithEndings; let syn = syntect?; let mut hl = syn.highlight_lines_for_fence_info(fence_info)?; let mut lines = Vec::new(); for line in LinesWithEndings::from(text) { let highlighted = hl.highlight_line(line, &syn.syntax_set).ok()?; lines.push( highlighted .into_iter() .map(|(s, t)| (s, t.to_string())) .collect(), ); } Some(lines) } /// Shared Syntect instance for tests, using the crate's bundled tokyo-night /// theme. #[cfg(any(test, fuzzing))] #[allow(dead_code)] pub fn test_syntect() -> &'static Syntect { use std::sync::OnceLock; static TEST_SYNTECT: OnceLock = OnceLock::new(); TEST_SYNTECT.get_or_init(|| Syntect::new(include_bytes!("../assets/tokyo-night.tmTheme"))) } #[cfg(test)] mod tests { use super::parse_line_citation_fence_info; #[test] fn line_citation_fence_parses_start_end_path() { assert_eq!( parse_line_citation_fence_info("37:65:crates/example/src/tools/read.rs"), Some(("37", "65", "crates/example/src/tools/read.rs")) ); } #[test] fn line_citation_rejects_non_numeric_line() { assert_eq!(parse_line_citation_fence_info("37:ab:file.rs"), None); } #[test] fn line_citation_rejects_plain_lang_token() { assert_eq!(parse_line_citation_fence_info("rust"), None); assert_eq!(parse_line_citation_fence_info(""), None); } #[test] fn highlight_lines_for_fence_info_resolves_citation_path_to_rust() { let s = super::test_syntect(); assert!( s.highlight_lines_for_fence_info("37:65:crates/codegen/kigi-markdown/src/parse.rs") .is_some() ); } #[test] fn highlight_lines_for_fence_info_still_accepts_rust_token() { let s = super::test_syntect(); assert!(s.highlight_lines_for_fence_info("rust").is_some()); } }