use std::io::BufRead;
use std::path::PathBuf;
use std::sync::OnceLock;
use syntect::easy::HighlightFile;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
use tokio::sync::mpsc;
use crate::preview::provider::{sanitize, HighlightedLine, PreviewContent, StyledSpan};
use crate::workers::WorkerMsg;
const DEFAULT_THEME: &str = "base16-ocean.dark";
static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
fn syntax_set() -> &'static SyntaxSet {
SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
}
fn theme_set() -> &'static ThemeSet {
THEME_SET.get_or_init(ThemeSet::load_defaults)
}
pub fn spawn_highlight(
path: PathBuf,
generation: u64,
tx: mpsc::Sender<WorkerMsg>,
max_lines: usize,
max_bytes: usize,
) {
tokio::spawn(async move {
let path_for_msg = path.clone();
let outcome =
tokio::task::spawn_blocking(move || preview_file_sync(&path, max_lines, max_bytes))
.await
.unwrap_or(Outcome {
content: plain_text_fallback_empty(),
truncated: false,
is_text: None,
});
let msg = WorkerMsg::Preview {
generation,
path: path_for_msg,
content: outcome.content,
truncated: outcome.truncated,
is_text: outcome.is_text,
};
let _ = tx.send(msg).await;
});
}
struct Outcome {
content: PreviewContent,
truncated: bool,
is_text: Option<bool>,
}
fn preview_file_sync(path: &std::path::Path, max_lines: usize, max_bytes: usize) -> Outcome {
if !crate::preview::text::is_text_file(path) {
return Outcome {
content: crate::preview::binary::build_binary_preview(path, None),
truncated: false,
is_text: Some(false),
};
}
let (content, truncated) = highlight_file_sync(path, max_lines, max_bytes);
Outcome {
content,
truncated,
is_text: Some(true),
}
}
fn highlight_file_sync(
path: &std::path::Path,
max_lines: usize,
max_bytes: usize,
) -> (PreviewContent, bool) {
let ss = syntax_set();
let ts = theme_set();
let theme = ts.themes.get(DEFAULT_THEME).unwrap_or_else(|| {
ts.themes
.values()
.next()
.expect("syntect ships at least one theme")
});
let mut highlighter = match HighlightFile::new(path, ss, theme) {
Ok(h) => h,
Err(_) => return plain_text_fallback(path, max_lines, max_bytes),
};
let mut lines: Vec<HighlightedLine> = Vec::new();
let mut truncated = false;
let mut bytes_read = 0usize;
loop {
if lines.len() >= max_lines || bytes_read >= max_bytes {
let mut probe = String::new();
truncated = matches!(highlighter.reader.read_line(&mut probe), Ok(n) if n > 0);
break;
}
let mut line_buf = String::new();
match highlighter.reader.read_line(&mut line_buf) {
Ok(0) => break,
Err(_) => break,
Ok(n) => bytes_read += n,
}
let line_text = sanitize(line_buf.trim_end_matches(['\n', '\r']));
let regions = match highlighter.highlight_lines.highlight_line(&line_text, ss) {
Ok(r) => r,
Err(_) => break,
};
let spans: HighlightedLine = regions
.into_iter()
.filter(|(_, text)| !text.is_empty())
.map(|(style, text)| {
let fg = convert_color(style.foreground);
StyledSpan {
text: text.to_owned(),
fg,
}
})
.collect();
lines.push(spans);
}
if lines.is_empty() {
(PreviewContent::Empty, false)
} else {
(PreviewContent::Highlighted(lines), truncated)
}
}
fn convert_color(c: syntect::highlighting::Color) -> Option<ratatui::style::Color> {
if c.a == 0 {
None
} else {
Some(ratatui::style::Color::Rgb(c.r, c.g, c.b))
}
}
fn plain_text_fallback(
path: &std::path::Path,
max_lines: usize,
max_bytes: usize,
) -> (PreviewContent, bool) {
use std::io::Read;
let Ok(f) = std::fs::File::open(path) else {
return (PreviewContent::Empty, false);
};
let limit = max_bytes as u64;
let mut buf = Vec::new();
let _ = f.take(limit + 1).read_to_end(&mut buf);
let bytes_truncated = buf.len() as u64 > limit;
buf.truncate(limit as usize);
let text = String::from_utf8_lossy(&buf);
let all: Vec<&str> = text.lines().collect();
let lines_truncated = all.len() > max_lines;
let lines: Vec<String> = all
.into_iter()
.take(max_lines)
.enumerate()
.map(|(i, l)| format!("{:>4} {}", i + 1, sanitize(l)))
.collect();
(
PreviewContent::Text(lines),
bytes_truncated || lines_truncated,
)
}
fn plain_text_fallback_empty() -> PreviewContent {
PreviewContent::Empty
}
#[allow(dead_code)]
pub fn highlight_text_sync(path: &std::path::Path, max_lines: usize) -> (PreviewContent, bool) {
highlight_file_sync(
path,
max_lines,
crate::preview::text::TEXT_PREVIEW_MAX_BYTES,
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn the_byte_ceiling_bounds_a_file_with_enormous_lines() {
let mut f = tempfile::NamedTempFile::new().unwrap();
for _ in 0..4 {
writeln!(f, "{}", "x".repeat(1000)).unwrap();
}
f.flush().unwrap();
let (content, truncated) = highlight_file_sync(f.path(), 2000, 2048);
assert!(truncated, "the ceiling must be reported as truncation");
let loaded = match content {
PreviewContent::Highlighted(lines) => lines.len(),
PreviewContent::Text(lines) => lines.len(),
other => panic!("expected text content, got {other:?}"),
};
assert!(
loaded < 4,
"the ceiling must stop the load short; got {loaded} lines"
);
}
#[test]
fn a_file_inside_both_ceilings_is_not_truncated() {
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(f, "fn main() {{}}").unwrap();
f.flush().unwrap();
let (_, truncated) = highlight_file_sync(f.path(), 2000, 256 * 1024);
assert!(!truncated);
}
use tempfile::NamedTempFile;
#[test]
fn highlight_rust_source() {
let mut f = NamedTempFile::with_suffix(".rs").unwrap();
writeln!(f, "fn main() {{ println!(\"hello\"); }}").unwrap();
let (content, truncated) = highlight_text_sync(f.path(), 2000);
assert!(
matches!(
content,
PreviewContent::Highlighted(_) | PreviewContent::Text(_)
),
"expected Highlighted or Text, got: {content:?}"
);
assert!(!truncated, "a one-line file cannot be truncated");
}
#[test]
fn highlight_reports_truncation_at_the_cap() {
let mut f = NamedTempFile::with_suffix(".rs").unwrap();
for i in 0..50 {
writeln!(f, "let x{i} = {i};").unwrap();
}
f.flush().unwrap();
let (content, truncated) = highlight_text_sync(f.path(), 10);
assert!(truncated, "50 lines loaded with a cap of 10 is truncated");
match content {
PreviewContent::Highlighted(lines) => assert_eq!(lines.len(), 10),
PreviewContent::Text(lines) => assert_eq!(lines.len(), 10),
other => panic!("expected line content, got {other:?}"),
}
}
#[test]
fn highlight_at_exactly_the_cap_is_not_truncated() {
let mut f = NamedTempFile::with_suffix(".rs").unwrap();
for i in 0..10 {
writeln!(f, "let x{i} = {i};").unwrap();
}
f.flush().unwrap();
let (_, truncated) = highlight_text_sync(f.path(), 10);
assert!(!truncated, "10 lines with a cap of 10 shows the whole file");
}
#[test]
fn highlight_unknown_extension_falls_back_to_text() {
let mut f = NamedTempFile::with_suffix(".xyzunknown123").unwrap();
writeln!(f, "some content").unwrap();
let (content, _) = highlight_text_sync(f.path(), 2000);
assert!(
matches!(
content,
PreviewContent::Text(_) | PreviewContent::Highlighted(_)
),
"expected Text or Highlighted fallback, got: {content:?}"
);
}
#[test]
fn convert_color_opaque() {
let c = syntect::highlighting::Color {
r: 255,
g: 128,
b: 0,
a: 255,
};
assert!(matches!(
convert_color(c),
Some(ratatui::style::Color::Rgb(255, 128, 0))
));
}
#[test]
fn convert_color_transparent_returns_none() {
let c = syntect::highlighting::Color {
r: 0,
g: 0,
b: 0,
a: 0,
};
assert!(convert_color(c).is_none());
}
}