use std::fs::File;
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::Path;
const CHUNK: usize = 64 * 1024;
pub struct FileWindow {
file: File,
len: u64,
}
impl FileWindow {
pub fn open(path: &Path) -> std::io::Result<Self> {
let file = File::open(path)?;
let len = file.metadata()?.len();
Ok(Self { file, len })
}
pub fn len(&self) -> u64 {
self.len
}
pub fn read_lines(&mut self, top: u64, count: usize) -> std::io::Result<Vec<String>> {
if top >= self.len || count == 0 {
return Ok(Vec::new());
}
self.file.seek(SeekFrom::Start(top))?;
let mut reader = BufReader::new(&mut self.file);
let mut out = Vec::with_capacity(count);
for _ in 0..count {
let mut buf = Vec::new();
let n = reader.read_until(b'\n', &mut buf)?;
if n == 0 {
break; }
while matches!(buf.last(), Some(b'\n') | Some(b'\r')) {
buf.pop();
}
out.push(String::from_utf8_lossy(&buf).into_owned());
}
Ok(out)
}
pub fn advance(&mut self, from: u64, n: usize) -> std::io::Result<(u64, usize)> {
if n == 0 || from >= self.len {
return Ok((from.min(self.len), 0));
}
self.file.seek(SeekFrom::Start(from))?;
let mut pos = from;
let mut found = 0usize;
let mut buf = [0u8; CHUNK];
loop {
let read = self.file.read(&mut buf)?;
if read == 0 {
return Ok((self.len, found)); }
for (i, &b) in buf[..read].iter().enumerate() {
if b == b'\n' {
found += 1;
if found == n {
return Ok(((pos + i as u64 + 1).min(self.len), found));
}
}
}
pos += read as u64;
}
}
pub fn retreat(&mut self, from: u64, n: usize) -> std::io::Result<(u64, usize)> {
if n == 0 || from == 0 {
return Ok((0, 0));
}
let mut found = 0usize;
let mut end = from; let mut buf = vec![0u8; CHUNK];
while end > 0 {
let chunk_len = (CHUNK as u64).min(end) as usize;
let start = end - chunk_len as u64;
self.file.seek(SeekFrom::Start(start))?;
let slice = &mut buf[..chunk_len];
self.file.read_exact(slice)?;
for i in (0..chunk_len).rev() {
if slice[i] == b'\n' {
found += 1;
if found == n + 1 {
return Ok((start + i as u64 + 1, n));
}
}
}
end = start;
}
Ok((0, found))
}
pub fn count_lines(&mut self) -> std::io::Result<usize> {
if self.len == 0 {
return Ok(0);
}
self.file.seek(SeekFrom::Start(0))?;
let mut count = 0usize;
let mut last = 0u8;
let mut buf = [0u8; CHUNK];
loop {
let read = self.file.read(&mut buf)?;
if read == 0 {
break;
}
for &b in &buf[..read] {
if b == b'\n' {
count += 1;
}
}
last = buf[read - 1];
}
if last != b'\n' {
count += 1;
}
Ok(count)
}
pub fn find_all_matches(
&mut self,
query: &str,
cap: usize,
) -> std::io::Result<Vec<(u64, usize, usize)>> {
let q = query.to_lowercase();
if q.is_empty() {
return Ok(Vec::new());
}
self.file.seek(SeekFrom::Start(0))?;
let mut reader = BufReader::new(&mut self.file);
let mut offset = 0u64;
let mut line_idx = 0usize;
let mut out = Vec::new();
let mut buf = Vec::new();
'outer: loop {
buf.clear();
let n = reader.read_until(b'\n', &mut buf)?;
if n == 0 {
break;
}
let line = String::from_utf8_lossy(&buf).to_lowercase();
let mut from = 0usize;
while let Some(rel) = line[from..].find(&q) {
let col = from + rel;
out.push((offset, line_idx, col));
if out.len() >= cap {
break 'outer;
}
from = col + q.len();
}
offset += n as u64;
line_idx += 1;
}
Ok(out)
}
pub fn last_page_top(&mut self, count: usize) -> std::io::Result<u64> {
let last = self.last_line_start()?;
if count <= 1 {
return Ok(last);
}
Ok(self.retreat(last, count - 1)?.0)
}
fn last_line_start(&mut self) -> std::io::Result<u64> {
if self.len == 0 {
return Ok(0);
}
let last_byte = self.byte_at(self.len - 1)?;
let probe = if last_byte == b'\n' {
if self.len < 2 {
return Ok(0); }
self.len - 2
} else {
self.len - 1
};
self.line_start_at(probe)
}
fn line_start_at(&mut self, pos: u64) -> std::io::Result<u64> {
let mut end = pos + 1; let mut buf = vec![0u8; CHUNK];
while end > 0 {
let chunk_len = (CHUNK as u64).min(end) as usize;
let start = end - chunk_len as u64;
self.file.seek(SeekFrom::Start(start))?;
let slice = &mut buf[..chunk_len];
self.file.read_exact(slice)?;
for i in (0..chunk_len).rev() {
if slice[i] == b'\n' {
return Ok(start + i as u64 + 1);
}
}
end = start;
}
Ok(0)
}
fn byte_at(&mut self, pos: u64) -> std::io::Result<u8> {
self.file.seek(SeekFrom::Start(pos))?;
let mut b = [0u8; 1];
self.file.read_exact(&mut b)?;
Ok(b[0])
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::PathBuf;
fn tmp(name: &str, bytes: &[u8]) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("konoma_window_{name}"));
let mut f = File::create(&p).unwrap();
f.write_all(bytes).unwrap();
p
}
#[test]
fn reads_window_and_strips_newlines() {
let p = tmp("basic", b"l0\nl1\nl2\nl3\nl4\n");
let mut w = FileWindow::open(&p).unwrap();
assert_eq!(w.read_lines(0, 2).unwrap(), vec!["l0", "l1"]);
let (t2, moved) = w.advance(0, 2).unwrap();
assert_eq!(moved, 2);
assert_eq!(w.read_lines(t2, 2).unwrap(), vec!["l2", "l3"]);
std::fs::remove_file(&p).ok();
}
#[test]
fn advance_retreat_report_moved_line_counts() {
let p = tmp("moved", b"a\nb\nc\nd\ne\n"); let mut w = FileWindow::open(&p).unwrap();
let (t3, m) = w.advance(0, 3).unwrap(); assert_eq!(m, 3);
assert_eq!(w.read_lines(t3, 1).unwrap(), vec!["d"]);
let (b1, m1) = w.retreat(t3, 1).unwrap();
assert_eq!(m1, 1);
assert_eq!(w.read_lines(b1, 1).unwrap(), vec!["c"]);
let (b0, m0) = w.retreat(t3, 10).unwrap();
assert_eq!((b0, m0), (0, 3));
assert_eq!(w.count_lines().unwrap(), 5);
std::fs::remove_file(&p).ok();
}
#[test]
fn advance_then_retreat_roundtrip() {
let p = tmp("rt", b"a\nbb\nccc\ndddd\ne\n");
let mut w = FileWindow::open(&p).unwrap();
let (t3, _) = w.advance(0, 3).unwrap(); assert_eq!(w.read_lines(t3, 1).unwrap(), vec!["dddd"]);
let (back, _) = w.retreat(t3, 3).unwrap(); assert_eq!(back, 0);
let (back1, _) = w.retreat(t3, 1).unwrap(); assert_eq!(w.read_lines(back1, 1).unwrap(), vec!["ccc"]);
std::fs::remove_file(&p).ok();
}
#[test]
fn find_all_matches_returns_each_occurrence_with_column() {
let p = tmp("find", b"alpha\nBETA beta\ngamma\nbeta2\n");
let mut w = FileWindow::open(&p).unwrap();
let hits = w.find_all_matches("beta", 100).unwrap();
assert_eq!(hits.len(), 3, "出現単位で3件: {hits:?}");
assert_eq!(hits[0], (6, 1, 0), "line1 の BETA(col0)");
assert_eq!(hits[1], (6, 1, 5), "line1 の beta(col5・同一行2つ目)");
assert_eq!(hits[2], (22, 3, 0), "line3 の beta2(col0)");
assert_eq!(w.read_lines(hits[0].0, 1).unwrap(), vec!["BETA beta"]);
assert_eq!(w.read_lines(hits[2].0, 1).unwrap(), vec!["beta2"]);
assert!(w.find_all_matches("zzz", 100).unwrap().is_empty());
assert_eq!(w.find_all_matches("beta", 1).unwrap().len(), 1);
std::fs::remove_file(&p).ok();
}
#[test]
fn no_trailing_newline_keeps_last_line() {
let p = tmp("notrail", b"x\ny\nz"); let mut w = FileWindow::open(&p).unwrap();
let all = w.read_lines(0, 10).unwrap();
assert_eq!(all, vec!["x", "y", "z"]);
let top = w.last_page_top(2).unwrap();
assert_eq!(w.read_lines(top, 2).unwrap(), vec!["y", "z"]);
std::fs::remove_file(&p).ok();
}
#[test]
fn trailing_newline_no_phantom_empty_line() {
let p = tmp("trail", b"x\ny\n"); let mut w = FileWindow::open(&p).unwrap();
assert_eq!(w.read_lines(0, 10).unwrap(), vec!["x", "y"]);
let top = w.last_page_top(1).unwrap(); assert_eq!(w.read_lines(top, 1).unwrap(), vec!["y"]);
std::fs::remove_file(&p).ok();
}
#[test]
fn empty_lines_in_middle_are_preserved() {
let p = tmp("blanks", b"a\n\n\nb\n");
let mut w = FileWindow::open(&p).unwrap();
assert_eq!(w.read_lines(0, 4).unwrap(), vec!["a", "", "", "b"]);
std::fs::remove_file(&p).ok();
}
#[test]
fn last_page_top_clamps_when_file_shorter_than_page() {
let p = tmp("short", b"only\n");
let mut w = FileWindow::open(&p).unwrap();
let top = w.last_page_top(10).unwrap();
assert_eq!(top, 0);
assert_eq!(w.read_lines(top, 10).unwrap(), vec!["only"]);
std::fs::remove_file(&p).ok();
}
#[test]
fn utf8_multibyte_lossy_safe() {
let p = tmp("utf8", "あ\nいい\nうううx\n".as_bytes());
let mut w = FileWindow::open(&p).unwrap();
assert_eq!(w.read_lines(0, 3).unwrap(), vec!["あ", "いい", "うううx"]);
std::fs::remove_file(&p).ok();
}
#[test]
fn read_past_eof_returns_empty() {
let p = tmp("eof", b"a\nb\n");
let mut w = FileWindow::open(&p).unwrap();
let beyond = w.len();
assert!(w.read_lines(beyond, 5).unwrap().is_empty());
std::fs::remove_file(&p).ok();
}
}