use std::{io::Cursor, path::{Path, PathBuf}, sync::OnceLock}; use anyhow::{Result, anyhow}; use ratatui::{layout::Size, text::{Line, Span, Text}}; use syntect::{LoadingError, dumps, easy::HighlightLines, highlighting::{self, Theme, ThemeSet}, parsing::{SyntaxReference, SyntaxSet}}; use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader}; use yazi_config::{THEME, YAZI, preview::PreviewWrap}; use yazi_fs::provider::{Provider, local::Local}; use yazi_shared::{Ids, errors::PeekError, push_printable_char}; use yazi_shim::ratatui::line_count; static INCR: Ids = Ids::new(); static SYNTECT: OnceLock<(Theme, SyntaxSet)> = OnceLock::new(); pub struct Highlighter { path: PathBuf, } impl Highlighter { pub fn new

(path: P) -> Self where P: Into, { Self { path: path.into() } } pub fn init() -> (&'static Theme, &'static SyntaxSet) { let f = || { let theme = std::fs::File::open(&THEME.mgr.syntect_theme) .map_err(LoadingError::Io) .and_then(|f| ThemeSet::load_from_reader(&mut std::io::BufReader::new(f))) .or_else(|_| ThemeSet::load_from_reader(&mut Cursor::new(yazi_prebuilt::ansi_theme()))); let syntaxes = dumps::from_uncompressed_data(yazi_prebuilt::syntaxes()); (theme.unwrap(), syntaxes.unwrap()) }; let (theme, syntaxes) = SYNTECT.get_or_init(f); (theme, syntaxes) } pub fn abort() { INCR.next(); } pub async fn highlight(&self, skip: usize, size: Size) -> Result, PeekError> { let indent = YAZI.preview.indent(); let mut reader = BufReader::new(Local::regular(&self.path).open().await?); let syntax = Self::find_syntax(&self.path, &mut reader).await; let mut plain = syntax.is_err(); let mut before = Vec::with_capacity(if plain { 0 } else { skip }); let mut after = Vec::with_capacity(size.height as _); let mut i = 0; let mut buf = vec![]; let mut inspected = 0u16; while reader.read_until(b'\n', &mut buf).await.is_ok_and(|n| n > 0) { if Self::is_binary(&buf, &mut inspected) { return Err("Binary file".into()); } let remaining = Self::normalize_control_chars(&mut buf); if !plain && (remaining || buf.len() > 5000) { plain = true; before.clear(); } i += if i >= skip { after.push(String::from_utf8_lossy(&buf).into_owned()); line_count(&**after.last().unwrap(), size.width, &indent, YAZI.preview.wrap) } else if !plain { before.push(String::from_utf8_lossy(&buf).into_owned()); line_count(&**before.last().unwrap(), size.width, &indent, YAZI.preview.wrap) } else if YAZI.preview.wrap != PreviewWrap::No { line_count(String::from_utf8_lossy(&buf), size.width, &indent, YAZI.preview.wrap) } else { 1 }; buf.clear(); if i > skip + size.height as usize { break; } } if skip > 0 && i < skip + size.height as usize { return Err(PeekError::Exceed(i.saturating_sub(size.height as _))); } Ok(if plain { Text::from(Self::merge_highlight_lines(&after, YAZI.preview.tab_size)) } else { Self::highlight_with(before, after, syntax.unwrap()).await? }) } async fn highlight_with( before: Vec, after: Vec, syntax: &'static SyntaxReference, ) -> Result, PeekError> { let ticket = INCR.current(); tokio::task::spawn_blocking(move || { let (theme, syntaxes) = Self::init(); let mut h = HighlightLines::new(syntax, theme); for line in before { if ticket != INCR.current() { return Err("Highlighting cancelled".into()); } h.highlight_line(&line, syntaxes).map_err(|e| anyhow!(e))?; } let indent = YAZI.preview.indent(); let mut lines = Vec::with_capacity(after.len()); for line in after { if ticket != INCR.current() { return Err("Highlighting cancelled".into()); } let regions = h.highlight_line(&line, syntaxes).map_err(|e| anyhow!(e))?; lines.push(Self::to_line_widget(regions, &indent)); } Ok(Text::from(lines)) }) .await? } async fn find_syntax( path: &Path, reader: &mut BufReader, ) -> Result<&'static SyntaxReference> { let (_, syntaxes) = Self::init(); let name = path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(); if let Some(s) = syntaxes.find_syntax_by_extension(&name) { return Ok(s); } let ext = path.extension().map(|e| e.to_string_lossy()).unwrap_or_default(); if let Some(s) = syntaxes.find_syntax_by_extension(&ext) { return Ok(s); } let mut line = String::new(); reader.read_line(&mut line).await?; reader.rewind().await?; syntaxes.find_syntax_by_first_line(&line).ok_or_else(|| anyhow!("No syntax found")) } #[inline(always)] fn is_binary(buf: &[u8], inspected: &mut u16) -> bool { if let Some(n) = 1024u16.checked_sub(*inspected) { *inspected += n.min(buf.len() as u16); buf.iter().take(n as usize).any(|&b| b == 0) } else { false } } fn normalize_control_chars(buf: &mut Vec) -> bool { if buf.ends_with(b"\r\n") { buf.pop(); buf.pop(); buf.push(b'\n'); } let mut remaining = false; for b in buf.iter_mut() { if *b == b'\r' { *b = b'\n'; } else { remaining |= matches!(b, 0..=0x08 | 0x0B..=0x1F | 0x7F); } } remaining } fn merge_highlight_lines(s: &[String], tab_size: u8) -> String { let mut buf = Vec::new(); buf.reserve_exact(s.iter().map(|s| s.len()).sum::() | 15); for &b in s.iter().flat_map(|s| s.as_bytes()) { push_printable_char(&mut buf, b, true, tab_size, false); } unsafe { String::from_utf8_unchecked(buf) } } } impl Highlighter { pub fn to_line_widget(regions: Vec<(highlighting::Style, &str)>, indent: &str) -> Line<'static> { let spans: Vec<_> = regions .into_iter() .map(|(style, s)| { let mut modifier = ratatui::style::Modifier::empty(); if style.font_style.contains(highlighting::FontStyle::BOLD) { modifier |= ratatui::style::Modifier::BOLD; } if style.font_style.contains(highlighting::FontStyle::ITALIC) { modifier |= ratatui::style::Modifier::ITALIC; } if style.font_style.contains(highlighting::FontStyle::UNDERLINE) { modifier |= ratatui::style::Modifier::UNDERLINED; } Span { content: s.replace('\t', indent).into(), style: ratatui::style::Style { fg: Self::to_ansi_color(style.foreground), // bg: Self::to_ansi_color(style.background), add_modifier: modifier, ..Default::default() }, } }) .collect(); Line::from(spans) } // Copy from https://github.com/sharkdp/bat/blob/master/src/terminal.rs fn to_ansi_color(color: highlighting::Color) -> Option { if color.a == 0 { // Themes can specify one of the user-configurable terminal colors by // encoding them as #RRGGBBAA with AA set to 00 (transparent) and RR set // to the 8-bit color palette number. The built-in themes ansi, base16, // and base16-256 use this. Some(match color.r { // For the first 8 colors, use the Color enum to produce ANSI escape // sequences using codes 30-37 (foreground) and 40-47 (background). // For example, red foreground is \x1b[31m. This works on terminals // without 256-color support. 0x00 => ratatui::style::Color::Black, 0x01 => ratatui::style::Color::Red, 0x02 => ratatui::style::Color::Green, 0x03 => ratatui::style::Color::Yellow, 0x04 => ratatui::style::Color::Blue, 0x05 => ratatui::style::Color::Magenta, 0x06 => ratatui::style::Color::Cyan, 0x07 => ratatui::style::Color::White, // For all other colors, use Fixed to produce escape sequences using // codes 38;5 (foreground) and 48;5 (background). For example, // bright red foreground is \x1b[38;5;9m. This only works on // terminals with 256-color support. // // TODO: When ansi_term adds support for bright variants using codes // 90-97 (foreground) and 100-107 (background), we should use those // for values 0x08 to 0x0f and only use Fixed for 0x10 to 0xff. n => ratatui::style::Color::Indexed(n), }) } else if color.a == 1 { // Themes can specify the terminal's default foreground/background color // (i.e. no escape sequence) using the encoding #RRGGBBAA with AA set to // 01. The built-in theme ansi uses this. None } else { Some(ratatui::style::Color::Rgb(color.r, color.g, color.b)) } } }