use std::fs::File;
use std::io::{BufReader, Read};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
const SCAN_BUF: usize = 64 * 1024;
const STRIDE: u64 = 1000;
pub struct LineIndex {
count: Arc<AtomicU64>,
done: Arc<AtomicBool>,
stop: Arc<AtomicBool>,
offsets: Arc<Mutex<Vec<u64>>>,
handle: Option<JoinHandle<()>>,
}
impl LineIndex {
pub fn spawn(path: PathBuf) -> Self {
let count = Arc::new(AtomicU64::new(0));
let done = Arc::new(AtomicBool::new(false));
let stop = Arc::new(AtomicBool::new(false));
let offsets = Arc::new(Mutex::new(vec![0u64]));
let (c, d, s, o) = (count.clone(), done.clone(), stop.clone(), offsets.clone());
let handle = std::thread::spawn(move || scan(&path, &c, &d, &s, &o));
LineIndex {
count,
done,
stop,
offsets,
handle: Some(handle),
}
}
pub fn count(&self) -> u64 {
self.count.load(Ordering::Relaxed)
}
pub fn is_done(&self) -> bool {
self.done.load(Ordering::Relaxed)
}
pub fn checkpoint_for(&self, line: usize) -> (u64, u64) {
let target0 = (line.max(1) - 1) as u64; let k = (target0 / STRIDE) as usize;
let offsets = self.offsets.lock().unwrap();
let k = k.min(offsets.len().saturating_sub(1));
let offset = offsets.get(k).copied().unwrap_or(0);
(offset, k as u64 * STRIDE + 1)
}
}
impl Drop for LineIndex {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
drop(self.handle.take());
}
}
fn scan(
path: &PathBuf,
count: &AtomicU64,
done: &AtomicBool,
stop: &AtomicBool,
offsets: &Mutex<Vec<u64>>,
) {
let Ok(file) = File::open(path) else {
done.store(true, Ordering::Relaxed);
return;
};
let mut reader = BufReader::with_capacity(SCAN_BUF, file);
let mut buf = [0u8; SCAN_BUF];
let mut newlines: u64 = 0;
let mut pos: u64 = 0;
let mut last_byte: Option<u8> = None;
loop {
if stop.load(Ordering::Relaxed) {
return; }
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
for (i, &b) in buf[..n].iter().enumerate() {
if b == b'\n' {
newlines += 1;
if newlines % STRIDE == 0 {
offsets.lock().unwrap().push(pos + i as u64 + 1);
}
}
}
last_byte = Some(buf[n - 1]);
pos += n as u64;
count.store(newlines, Ordering::Relaxed);
}
Err(_) => break,
}
}
let total = match last_byte {
Some(b'\n') | None => newlines,
Some(_) => newlines + 1,
};
count.store(total, Ordering::Relaxed);
done.store(true, Ordering::Relaxed);
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_tmp(name: &str, data: &[u8]) -> PathBuf {
let path = std::env::temp_dir().join(format!("ncoxide_idx_{name}_{}", std::process::id()));
let mut f = File::create(&path).unwrap();
f.write_all(data).unwrap();
path
}
fn finished(path: PathBuf) -> LineIndex {
let index = LineIndex::spawn(path);
for _ in 0..100_000 {
if index.is_done() {
break;
}
std::thread::yield_now();
}
assert!(index.is_done());
index
}
#[test]
fn test_count_trailing_newline() {
let path = write_tmp("trail", b"a\nb\nc\n");
assert_eq!(finished(path.clone()).count(), 3);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_count_no_trailing_newline() {
let path = write_tmp("notrail", b"a\nb\nc");
assert_eq!(finished(path.clone()).count(), 3);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_count_empty() {
let path = write_tmp("empty", b"");
assert_eq!(finished(path.clone()).count(), 0);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_checkpoint_offsets() {
let mut body = String::new();
for i in 0..5000 {
body.push_str(&format!("line{i:04}\n"));
}
let path = write_tmp("ckpt", body.as_bytes());
let index = finished(path.clone());
assert_eq!(index.checkpoint_for(1), (0, 1));
assert_eq!(index.checkpoint_for(2001), (2000 * 9, 2001));
assert_eq!(index.checkpoint_for(2500), (2000 * 9, 2001));
let _ = std::fs::remove_file(&path);
}
}