use std::path::PathBuf;
use std::sync::OnceLock;
use syntect::easy::HighlightLines;
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 Some((text, bytes_truncated)) = read_capped(path, max_bytes) else {
return (PreviewContent::Empty, false);
};
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 first_line = text.lines().next().unwrap_or_default();
let syntax = ss
.find_syntax_for_file(path)
.ok()
.flatten()
.or_else(|| ss.find_syntax_by_first_line(first_line))
.unwrap_or_else(|| ss.find_syntax_plain_text());
let mut highlighter = HighlightLines::new(syntax, theme);
let mut lines: Vec<HighlightedLine> = Vec::new();
let mut truncated = bytes_truncated;
for raw in text.lines() {
if lines.len() >= max_lines {
truncated = true;
break;
}
let line_text = sanitize(raw);
let Ok(regions) = highlighter.highlight_line(&line_text, ss) else {
truncated = true;
break;
};
let spans: HighlightedLine = regions
.into_iter()
.filter(|(_, text)| !text.is_empty())
.map(|(style, text)| StyledSpan {
text: text.to_owned(),
fg: convert_color(style.foreground),
})
.collect();
lines.push(spans);
}
(PreviewContent::Highlighted(lines), truncated)
}
fn read_capped(path: &std::path::Path, max_bytes: usize) -> Option<(String, bool)> {
use std::io::Read;
let file = std::fs::File::open(path).ok()?;
let mut buf = Vec::new();
file.take(max_bytes as u64 + 1).read_to_end(&mut buf).ok()?;
let truncated = buf.len() > max_bytes;
buf.truncate(max_bytes);
Some((crate::preview::text::decode_text(&buf), 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_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:?}"
);
}
fn loaded_lines(content: &PreviewContent) -> usize {
match content {
PreviewContent::Highlighted(lines) => lines.len(),
other => panic!("expected highlighted content, got {other:?}"),
}
}
#[test]
fn a_non_utf8_file_is_previewed_not_blanked() {
let mut f = NamedTempFile::with_suffix(".txt").unwrap();
f.write_all(b"caf\xe9\nol\xe9\n").unwrap();
f.flush().unwrap();
let (content, truncated) = highlight_text_sync(f.path(), 2000);
assert_eq!(loaded_lines(&content), 2);
assert!(!truncated);
}
#[test]
fn a_bad_byte_mid_file_does_not_cut_the_preview() {
let mut f = NamedTempFile::with_suffix(".txt").unwrap();
f.write_all(&b"ok\n".repeat(50)).unwrap();
f.write_all(b"\xff\nafter\n").unwrap();
f.flush().unwrap();
let (content, truncated) = highlight_text_sync(f.path(), 2000);
assert_eq!(loaded_lines(&content), 52);
assert!(!truncated);
}
#[test]
fn an_empty_file_has_no_lines_and_is_not_truncated() {
let f = NamedTempFile::with_suffix(".txt").unwrap();
let (content, truncated) = highlight_text_sync(f.path(), 2000);
assert_eq!(loaded_lines(&content), 0);
assert!(!truncated);
}
#[test]
fn a_single_line_past_the_byte_ceiling_is_truncated() {
let mut f = NamedTempFile::with_suffix(".txt").unwrap();
f.write_all(&[b'a'; 4096]).unwrap();
f.flush().unwrap();
let (content, truncated) = highlight_file_sync(f.path(), 2000, 1024);
assert!(truncated);
match content {
PreviewContent::Highlighted(lines) => {
let shown: usize = lines[0].iter().map(|s| s.text.len()).sum();
assert_eq!(shown, 1024);
}
other => panic!("expected highlighted content, got {other:?}"),
}
}
#[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());
}
}