use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex};
const CONTEXT_LINES: usize = 5;
const MAX_LINE_BYTES: usize = 256;
const CACHE_CAPACITY: usize = 100;
const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
pub struct SourceContext {
pub pre: Vec<String>,
pub line: String,
pub post: Vec<String>,
}
struct LineCache {
files: HashMap<String, Arc<Vec<String>>>,
order: Vec<String>,
}
static CACHE: LazyLock<Mutex<LineCache>> = LazyLock::new(|| {
Mutex::new(LineCache {
files: HashMap::new(),
order: Vec::new(),
})
});
pub fn fetch(path: &str, lineno: u32) -> Option<SourceContext> {
if lineno == 0 || path.is_empty() {
return None;
}
let lines = read_lines_cached(path)?;
let idx = (lineno - 1) as usize;
if idx >= lines.len() {
return None;
}
let pre_start = idx.saturating_sub(CONTEXT_LINES);
let post_end = (idx + CONTEXT_LINES).min(lines.len() - 1);
Some(SourceContext {
pre: lines[pre_start..idx].iter().map(|l| truncate(l)).collect(),
line: truncate(&lines[idx]),
post: lines[idx + 1..=post_end]
.iter()
.map(|l| truncate(l))
.collect(),
})
}
#[cfg(test)]
pub fn reset_cache() {
if let Ok(mut c) = CACHE.lock() {
c.files.clear();
c.order.clear();
}
}
fn read_lines_cached(path: &str) -> Option<Arc<Vec<String>>> {
let mut cache = match CACHE.lock() {
Ok(c) => c,
Err(poisoned) => poisoned.into_inner(),
};
if cache.files.contains_key(path) {
touch(&mut cache, path);
return cache.files.get(path).cloned(); }
let meta = std::fs::metadata(path).ok()?;
if meta.len() > MAX_FILE_BYTES {
return None;
}
let contents = std::fs::read(path).ok()?;
let text = String::from_utf8_lossy(&contents);
let lines = Arc::new(text.lines().map(|l| l.to_string()).collect::<Vec<String>>());
cache.files.insert(path.to_string(), lines.clone());
cache.order.push(path.to_string());
evict_if_needed(&mut cache);
Some(lines)
}
fn touch(cache: &mut LineCache, path: &str) {
if let Some(pos) = cache.order.iter().position(|p| p == path) {
let key = cache.order.remove(pos);
cache.order.push(key);
}
}
fn evict_if_needed(cache: &mut LineCache) {
while cache.order.len() > CACHE_CAPACITY {
let evicted = cache.order.remove(0);
cache.files.remove(&evicted);
}
}
fn truncate(line: &str) -> String {
if line.len() <= MAX_LINE_BYTES {
return line.to_string();
}
let mut end = MAX_LINE_BYTES;
while end > 0 && !line.is_char_boundary(end) {
end -= 1;
}
format!("{}…[truncated]", &line[..end])
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn fetches_window_around_line() {
reset_cache();
let mut tmp = std::env::temp_dir();
tmp.push(format!("errsight_src_test_{}.rs", crate::util::new_uuid()));
{
let mut f = std::fs::File::create(&tmp).unwrap();
for i in 1..=20 {
writeln!(f, "line {i}").unwrap();
}
}
let path = tmp.display().to_string();
let ctx = fetch(&path, 10).expect("context");
assert_eq!(ctx.line, "line 10");
assert_eq!(
ctx.pre,
vec!["line 5", "line 6", "line 7", "line 8", "line 9"]
);
assert_eq!(
ctx.post,
vec!["line 11", "line 12", "line 13", "line 14", "line 15"]
);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn out_of_range_and_missing_return_none() {
reset_cache();
assert!(fetch("/no/such/file/at/all.rs", 1).is_none());
assert!(fetch("", 1).is_none());
}
#[test]
fn truncates_long_lines_on_char_boundary() {
let long = "é".repeat(500); let t = truncate(&long);
assert!(t.ends_with("…[truncated]"));
assert!(t.len() <= MAX_LINE_BYTES + "…[truncated]".len());
}
}