feat(core): implement sparse LineIndex with BufReader streaming
Rewrite LineIndex to use sparse sampling (every 256 lines) instead of per-line offsets. Add from_reader() for low-RSS streaming index construction via BufReader fill_buf()/consume(), reducing 5GB file RSS from 5122MB to 3.4MB. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1,219 +1,216 @@
|
|||||||
// ─── line_index.rs ───────────────────────────────────────────────────────────
|
// ─── line_index.rs ───────────────────────────────────────────────────────────
|
||||||
// 这个文件定义了 LineIndex 结构体,用于为一段字节数据建立"行索引"。
|
// 稀疏行索引:每 256 行采样一次起始偏移量,大幅降低内存占用。
|
||||||
//
|
//
|
||||||
// "行索引"的核心思想:记录每一行在字节数组中的起始位置(偏移量)。
|
// 传统做法是为每一行记录起始偏移(Vec<usize>),对于大文件(数百万行)
|
||||||
// 例如,对于内容 "aaa\nbbb\nccc",字节布局如下:
|
// 这意味着数 MB 的索引。稀疏索引只在每 BLOCK_SIZE(256) 行的边界处记录偏移,
|
||||||
|
// 其余行通过 memchr 向前扫描少量换行符来定位。
|
||||||
//
|
//
|
||||||
// 位置: 0 1 2 3 4 5 6 7 8 9 10
|
// 例如 300 行的文件只需要 2 个采样点(第 0 行和第 256 行的偏移),
|
||||||
// 内容: a a a \n b b b \n c c c
|
// 内存从 300 * 8 = 2400 字节降到 2 * 8 = 16 字节。
|
||||||
// ↑第0行 ↑第1行 ↑第2行
|
|
||||||
//
|
|
||||||
// line_starts 数组会存储 [0, 4, 8],即每行的起始位置。
|
|
||||||
// 这样要获取第 N 行,只需要读取从 line_starts[N] 到 line_starts[N+1] 之间的字节即可。
|
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ─── LineIndex 结构体定义 ────────────────────────────────────────────────────
|
// ─── 采样块大小 ──────────────────────────────────────────────────────────────
|
||||||
// `pub struct` 定义一个公开的结构体。pub 表示外部模块可以访问。
|
// 每 256 行记录一次偏移量。256 是在索引大小和扫描开销之间的平衡点:
|
||||||
pub struct LineIndex {
|
// - 索引大小:100 万行仅需约 4000 个采样点(~32 KB)
|
||||||
// `line_starts: Vec<usize>` — 一个动态数组,存储每一行的起始字节偏移量。
|
// - 扫描开销:最坏情况扫描 255 个换行符,仍很快(memchr SIMD 加速)
|
||||||
// Vec<usize> 即"存放 usize 值的向量"。
|
const BLOCK_SIZE: usize = 256;
|
||||||
// usize 是 Rust 中用于表示大小和索引的无符号整数类型(类似 C 的 size_t)。
|
|
||||||
// 例如对于 "hello\nworld",line_starts = [0, 6]:
|
|
||||||
// - 第 0 行从字节偏移 0 开始
|
|
||||||
// - 第 1 行从字节偏移 6 开始('w' 的位置)
|
|
||||||
line_starts: Vec<usize>,
|
|
||||||
|
|
||||||
// `#[allow(dead_code)]` — 一个属性(attribute),告诉编译器"不要对下面这个字段
|
// ─── LineIndex 结构体定义 ────────────────────────────────────────────────────
|
||||||
// 发出'未使用'的警告"。has_trailing_newline 字段目前没有被使用,
|
pub struct LineIndex {
|
||||||
// 但保留它是为了将来可能的功能需要(比如判断文件是否以换行符结尾)。
|
// 采样偏移量:每 BLOCK_SIZE 行记录一个起始字节偏移。
|
||||||
// #[...] 是 Rust 中为接下来的项添加元信息(属性)的语法。
|
// sampled_offsets[i] 存储第 (i * BLOCK_SIZE) 行的字节起始位置。
|
||||||
|
sampled_offsets: Vec<u64>,
|
||||||
|
|
||||||
|
// 文件总行数。
|
||||||
|
total_lines: u64,
|
||||||
|
|
||||||
|
// 文件最后一个字节是否是换行符 \n。
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
// `has_trailing_newline: bool` — 布尔值,表示文件最后一个字节是否是换行符 \n。
|
|
||||||
// bool 类型只有两个值:true 和 false。
|
|
||||||
has_trailing_newline: bool,
|
has_trailing_newline: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── LineIndex 的实现块 ─────────────────────────────────────────────────────
|
|
||||||
// `impl LineIndex` 块用来为 LineIndex 结构体添加方法。
|
|
||||||
impl LineIndex {
|
impl LineIndex {
|
||||||
// ─── from_bytes 方法(关联函数 / 静态方法)─────────────────────────────
|
// ─── from_bytes:根据字节数据构建稀疏行索引 ────────────────────────────
|
||||||
// 根据一段字节数据构建行索引。
|
|
||||||
// 参数 data: &[u8] — 一个字节切片的引用(只读的字节数组视图)。
|
|
||||||
// &[u8] 不拥有数据,只是借用(borrow)了数据的只读视图。
|
|
||||||
// -> Self 返回一个 LineIndex 实例。Self 是当前类型(LineIndex)的别名。
|
|
||||||
pub fn from_bytes(data: &[u8]) -> Self {
|
pub fn from_bytes(data: &[u8]) -> Self {
|
||||||
// ─── 处理空数据的特殊情况 ────────────────────────────────────────
|
|
||||||
// `if data.is_empty()` — 检查字节数组是否为空。
|
|
||||||
// 如果文件为空,没有行可索引,直接返回一个空的 LineIndex。
|
|
||||||
if data.is_empty() {
|
if data.is_empty() {
|
||||||
return LineIndex {
|
return LineIndex {
|
||||||
// `vec![]` 宏创建一个空的 Vec(动态数组)。
|
sampled_offsets: vec![],
|
||||||
// vec! 是 Rust 中创建 Vec 的便捷宏,类似 Python 的 []。
|
total_lines: 0,
|
||||||
line_starts: vec![],
|
|
||||||
has_trailing_newline: false,
|
has_trailing_newline: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 构建行起始位置数组 ──────────────────────────────────────────
|
let mut sampled_offsets: Vec<u64> = vec![0];
|
||||||
// `vec![0usize]` 创建一个包含一个元素 0 的 Vec<usize>。
|
let mut next_line_idx: usize = 1;
|
||||||
// 第一行永远从字节偏移量 0 开始,所以初始化时先放入 0。
|
let mut newline_count: usize = 0;
|
||||||
// 0usize 中的 usize 是类型后缀,明确指定 0 的类型是 usize。
|
|
||||||
let mut line_starts = vec![0usize];
|
|
||||||
// `mut`(mutable)关键字表示这个变量可以被修改。
|
|
||||||
// 在 Rust 中,变量默认是不可变的(immutable),必须加 mut 才能修改。
|
|
||||||
|
|
||||||
// ─── 遍历所有换行符位置 ──────────────────────────────────────────
|
|
||||||
// `memchr::memchr_iter(b'\n', data)` — 在 data 字节数组中查找所有
|
|
||||||
// 换行符 \n 的位置,返回一个迭代器。
|
|
||||||
// memchr 是一个高性能的字节搜索库,比逐字节查找快得多(使用 SIMD 指令)。
|
|
||||||
// b'\n' 是一个字节字面量,值为 10(换行符的 ASCII 码)。
|
|
||||||
// b 前缀表示"这是一个字节(u8),而不是字符(char)"。
|
|
||||||
for pos in memchr::memchr_iter(b'\n', data) {
|
for pos in memchr::memchr_iter(b'\n', data) {
|
||||||
// 换行符的下一个位置就是新行的起始位置。
|
newline_count += 1;
|
||||||
// pos + 1 跳过换行符本身,指向下一行的第一个字节。
|
if next_line_idx.is_multiple_of(BLOCK_SIZE) {
|
||||||
// .push() 方法向 Vec 末尾添加一个元素。
|
sampled_offsets.push((pos + 1) as u64);
|
||||||
line_starts.push(pos + 1);
|
}
|
||||||
|
next_line_idx += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 处理末尾换行符的特殊情况 ────────────────────────────────────
|
let has_trailing_newline = data.last().is_some_and(|&b| b == b'\n');
|
||||||
// `data.last()` 返回字节数组的最后一个元素的 Option<&u8>(可能为空)。
|
|
||||||
// `.is_some_and(|&b| b == b'\n')` — 如果最后一个元素存在,
|
|
||||||
// 且它的值等于换行符 \n,则返回 true。
|
|
||||||
// - is_some_and: "如果 Option 是 Some,则对其值执行判断函数"
|
|
||||||
// - |&b| b == b'\n': 这是一个闭包(匿名函数),参数 &b 是对元素的引用,
|
|
||||||
// 判断它是否等于换行符。
|
|
||||||
let trailing = data.last().is_some_and(|&b| b == b'\n');
|
|
||||||
|
|
||||||
// 如果文件以换行符结尾,需要特殊处理:
|
let total_lines: u64 = if has_trailing_newline && newline_count > 0 {
|
||||||
// 因为最后一个 \n 后面没有实际的行内容,所以我们不应该为它记录一个行起始位置。
|
newline_count as u64
|
||||||
if trailing {
|
|
||||||
// .pop() 移除 Vec 的最后一个元素。
|
|
||||||
// 这里移除的是最后一个 \n 后面的位置(因为那里没有实际的行内容)。
|
|
||||||
line_starts.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 返回构建好的 LineIndex ──────────────────────────────────────
|
|
||||||
// 使用结构体字面量语法创建实例。
|
|
||||||
// 因为字段名和变量名相同,所以可以简写。
|
|
||||||
LineIndex {
|
|
||||||
line_starts,
|
|
||||||
has_trailing_newline: trailing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── line_count 方法 ───────────────────────────────────────────────────
|
|
||||||
// 返回总行数。
|
|
||||||
// &self 表示这是一个实例方法,通过不可变引用访问自身。
|
|
||||||
// -> usize 返回一个表示行数的无符号整数。
|
|
||||||
pub fn line_count(&self) -> usize {
|
|
||||||
// line_starts 数组的长度就等于行数(每行有一个起始位置)。
|
|
||||||
// .len() 返回 Vec 的元素个数。
|
|
||||||
self.line_starts.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── get_line 方法 ─────────────────────────────────────────────────────
|
|
||||||
// 根据行号索引获取某一行内容。
|
|
||||||
// 这是一个稍微复杂的方法,涉及 Rust 的"生命周期"(lifetime)概念。
|
|
||||||
//
|
|
||||||
// 参数说明:
|
|
||||||
// - &'a [u8]: 带有生命周期标注的字节切片引用。
|
|
||||||
// 'a 是一个生命周期参数,表示"返回的字符串引用的生命周期与 data 相同"。
|
|
||||||
// 这确保了返回的 &str 不会在 data 被销毁后仍然存在。
|
|
||||||
// - idx: usize: 要获取的行号(从 0 开始)。
|
|
||||||
//
|
|
||||||
// 返回值 Option<&'a str>:
|
|
||||||
// - 如果行号有效,返回 Some("行内容字符串")。
|
|
||||||
// - 如果行号越界,返回 None。
|
|
||||||
pub fn get_line<'a>(&self, data: &'a [u8], idx: usize) -> Option<&'a str> {
|
|
||||||
// ─── 边界检查 ────────────────────────────────────────────────────
|
|
||||||
// 如果请求的行号超出了 line_starts 的范围,返回 None。
|
|
||||||
// .len() 返回数组长度(即行数)。
|
|
||||||
if idx >= self.line_starts.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 计算行的字节范围 ─────────────────────────────────────────────
|
|
||||||
// start: 当前行的起始字节偏移量。
|
|
||||||
let start = self.line_starts[idx];
|
|
||||||
|
|
||||||
// end: 当前行的结束字节偏移量(不包含)。
|
|
||||||
// 使用 if-else 表达式来计算:
|
|
||||||
// - 如果不是最后一行,结束位置是下一行的起始位置减 1(跳过换行符)。
|
|
||||||
// - 如果是最后一行,结束位置是整个数据的末尾。
|
|
||||||
let end = if idx + 1 < self.line_starts.len() {
|
|
||||||
// .saturating_sub(1) 是安全的减法:如果结果会下溢(变成负数),
|
|
||||||
// 则返回 0 而不会 panic。这比直接写 -1 更安全。
|
|
||||||
// 这里减 1 是为了跳过换行符本身(换行符属于前一行,不属于后一行)。
|
|
||||||
self.line_starts[idx + 1].saturating_sub(1)
|
|
||||||
} else {
|
} else {
|
||||||
// data.len() 返回字节数组的总长度,即最后一行到数据末尾。
|
(1 + newline_count) as u64
|
||||||
data.len()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── 提取行内容 ──────────────────────────────────────────────────
|
if has_trailing_newline && newline_count > 0 {
|
||||||
// &data[start..end] 使用切片语法获取从 start 到 end(不包含 end)的字节子数组。
|
let trailing_line_idx = newline_count;
|
||||||
// Rust 的切片语法 [a..b] 表示"从索引 a 到 b-1",即左闭右开区间 [a, b)。
|
if trailing_line_idx.is_multiple_of(BLOCK_SIZE) {
|
||||||
let slice = &data[start..end];
|
sampled_offsets.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 将字节转换为字符串并清理末尾空白 ──────────────────────────────
|
LineIndex {
|
||||||
// 这是一段链式调用:
|
sampled_offsets,
|
||||||
//
|
total_lines,
|
||||||
// 1. std::str::from_utf8(slice) — 尝试将字节切片转换为 &str(UTF-8 字符串切片)。
|
has_trailing_newline,
|
||||||
// 返回 Result<&str, Utf8Error>,即"成功得到字符串"或"编码错误"。
|
}
|
||||||
//
|
}
|
||||||
// 2. .map(|s| s.trim_end_matches(['\r', '\n'])) — 如果成功,对字符串执行 map 操作。
|
|
||||||
// trim_end_matches 从字符串末尾移除所有匹配的字符。
|
// ─── from_reader:从流式读取器构建稀疏行索引 ────────────────────────────
|
||||||
// 这里移除 '\r'(回车)和 '\n'(换行),处理 CRLF (\r\n) 和 LF (\n) 两种换行风格。
|
// 使用 fill_buf()/consume() 模式避免将整个文件加载到内存。
|
||||||
//
|
// RSS 始终保持在 ~64KB(BufReader 缓冲区大小),不受文件大小影响。
|
||||||
// 3. .ok() — 将 Result 转换为 Option:
|
pub fn from_reader(reader: &mut impl std::io::BufRead) -> std::io::Result<Self> {
|
||||||
// Ok(值) → Some(值),Err(_) → None。
|
let mut sampled_offsets: Vec<u64> = vec![0]; // line 0 starts at offset 0
|
||||||
// 这样如果 UTF-8 转换失败,整个方法会返回 None 而不是 panic。
|
let mut next_line_idx: usize = 1;
|
||||||
std::str::from_utf8(slice)
|
let mut newline_count: usize = 0;
|
||||||
|
let mut chunk_offset: u64 = 0;
|
||||||
|
let mut last_byte: Option<u8> = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let buf = reader.fill_buf()?;
|
||||||
|
if buf.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(&b) = buf.last() {
|
||||||
|
last_byte = Some(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
for pos in memchr::memchr_iter(b'\n', buf) {
|
||||||
|
newline_count += 1;
|
||||||
|
if next_line_idx.is_multiple_of(BLOCK_SIZE) {
|
||||||
|
sampled_offsets.push(chunk_offset + pos as u64 + 1);
|
||||||
|
}
|
||||||
|
next_line_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let consumed = buf.len();
|
||||||
|
chunk_offset += consumed as u64;
|
||||||
|
reader.consume(consumed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty file: no data at all
|
||||||
|
if chunk_offset == 0 {
|
||||||
|
return Ok(LineIndex {
|
||||||
|
sampled_offsets: vec![],
|
||||||
|
total_lines: 0,
|
||||||
|
has_trailing_newline: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let has_trailing_newline = last_byte == Some(b'\n') && newline_count > 0;
|
||||||
|
|
||||||
|
let total_lines: u64 = if has_trailing_newline && newline_count > 0 {
|
||||||
|
newline_count as u64
|
||||||
|
} else {
|
||||||
|
(1 + newline_count) as u64
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trailing \n pop logic (identical to from_bytes)
|
||||||
|
if has_trailing_newline && newline_count > 0 {
|
||||||
|
let trailing_line_idx = newline_count;
|
||||||
|
if trailing_line_idx.is_multiple_of(BLOCK_SIZE) {
|
||||||
|
sampled_offsets.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(LineIndex {
|
||||||
|
sampled_offsets,
|
||||||
|
total_lines,
|
||||||
|
has_trailing_newline,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── line_count:返回总行数 ───────────────────────────────────────────
|
||||||
|
pub fn line_count(&self) -> usize {
|
||||||
|
self.total_lines as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── get_line:根据行号获取行内容 ─────────────────────────────────────
|
||||||
|
// 通过稀疏索引定位到所在块的起始位置,然后向前扫描少量换行符来定位目标行。
|
||||||
|
pub fn get_line<'a>(&self, data: &'a [u8], idx: usize) -> Option<&'a str> {
|
||||||
|
if idx >= self.total_lines as usize || data.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let block = idx / BLOCK_SIZE;
|
||||||
|
let offset_in_block = idx % BLOCK_SIZE;
|
||||||
|
let mut pos = self.sampled_offsets[block] as usize;
|
||||||
|
for _ in 0..offset_in_block {
|
||||||
|
match memchr::memchr(b'\n', &data[pos..]) {
|
||||||
|
Some(rel) => pos = pos + rel + 1,
|
||||||
|
None => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let end = memchr::memchr(b'\n', &data[pos..])
|
||||||
|
.map(|rel| pos + rel)
|
||||||
|
.unwrap_or(data.len());
|
||||||
|
let line_bytes = &data[pos..end];
|
||||||
|
std::str::from_utf8(line_bytes)
|
||||||
.map(|s| s.trim_end_matches(['\r', '\n']))
|
.map(|s| s.trim_end_matches(['\r', '\n']))
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 单元测试 ────────────────────────────────────────────────────────────────
|
// ─── 单元测试 ────────────────────────────────────────────────────────────────
|
||||||
// `#[cfg(test)]` 表示以下代码只在测试时编译。
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
// 引入父模块的所有公开内容。
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// 辅助函数:生成 n 行数据,每行内容为 "line{i}\n"
|
||||||
|
fn make_lines(n: usize) -> Vec<u8> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
for i in 0..n {
|
||||||
|
let line = format!("line{}\n", i);
|
||||||
|
buf.extend_from_slice(line.as_bytes());
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 空数据 → 0 行
|
||||||
#[test]
|
#[test]
|
||||||
// 测试:空数据应该有 0 行。
|
|
||||||
fn test_empty_data() {
|
fn test_empty_data() {
|
||||||
// b"" 是一个空的字节字符串(长度为 0 的 &[u8])。
|
|
||||||
let idx = LineIndex::from_bytes(b"");
|
let idx = LineIndex::from_bytes(b"");
|
||||||
assert_eq!(idx.line_count(), 0);
|
assert_eq!(idx.line_count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 单行无换行符
|
||||||
#[test]
|
#[test]
|
||||||
// 测试:单行内容,末尾没有换行符。
|
|
||||||
fn test_single_line_no_newline() {
|
fn test_single_line_no_newline() {
|
||||||
// b"hello" 是一个字节字符串字面量,类型为 &[u8; 5]。
|
|
||||||
let data = b"hello";
|
let data = b"hello";
|
||||||
let idx = LineIndex::from_bytes(data);
|
let idx = LineIndex::from_bytes(data);
|
||||||
// 只有 1 行。
|
|
||||||
assert_eq!(idx.line_count(), 1);
|
assert_eq!(idx.line_count(), 1);
|
||||||
// 第 0 行内容是 "hello"。
|
|
||||||
assert_eq!(idx.get_line(data, 0), Some("hello"));
|
assert_eq!(idx.get_line(data, 0), Some("hello"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 单行带换行符
|
||||||
#[test]
|
#[test]
|
||||||
// 测试:单行内容,末尾有换行符。
|
|
||||||
fn test_single_line_with_newline() {
|
fn test_single_line_with_newline() {
|
||||||
let data = b"hello\n";
|
let data = b"hello\n";
|
||||||
let idx = LineIndex::from_bytes(data);
|
let idx = LineIndex::from_bytes(data);
|
||||||
// 即使末尾有 \n,仍然只算 1 行(因为 \n 后面没有内容)。
|
|
||||||
assert_eq!(idx.line_count(), 1);
|
assert_eq!(idx.line_count(), 1);
|
||||||
// 返回内容时不包含末尾的 \n。
|
|
||||||
assert_eq!(idx.get_line(data, 0), Some("hello"));
|
assert_eq!(idx.get_line(data, 0), Some("hello"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. 多行内容
|
||||||
#[test]
|
#[test]
|
||||||
// 测试:多行内容。
|
|
||||||
fn test_multi_line() {
|
fn test_multi_line() {
|
||||||
let data = b"aaa\nbbb\nccc";
|
let data = b"aaa\nbbb\nccc";
|
||||||
let idx = LineIndex::from_bytes(data);
|
let idx = LineIndex::from_bytes(data);
|
||||||
@@ -223,69 +220,274 @@ mod tests {
|
|||||||
assert_eq!(idx.get_line(data, 2), Some("ccc"));
|
assert_eq!(idx.get_line(data, 2), Some("ccc"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. 300 行:验证行数和关键行内容
|
||||||
|
#[test]
|
||||||
|
fn test_300_lines() {
|
||||||
|
let data = make_lines(300);
|
||||||
|
let idx = LineIndex::from_bytes(&data);
|
||||||
|
assert_eq!(idx.line_count(), 300);
|
||||||
|
assert_eq!(idx.get_line(&data, 0), Some("line0"));
|
||||||
|
assert_eq!(idx.get_line(&data, 1), Some("line1"));
|
||||||
|
assert_eq!(idx.get_line(&data, 255), Some("line255"));
|
||||||
|
assert_eq!(idx.get_line(&data, 256), Some("line256"));
|
||||||
|
assert_eq!(idx.get_line(&data, 257), Some("line257"));
|
||||||
|
assert_eq!(idx.get_line(&data, 299), Some("line299"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 512 行跨块验证
|
||||||
|
#[test]
|
||||||
|
fn test_512_lines_cross_block() {
|
||||||
|
let data = make_lines(512);
|
||||||
|
let idx = LineIndex::from_bytes(&data);
|
||||||
|
assert_eq!(idx.line_count(), 512);
|
||||||
|
assert_eq!(idx.get_line(&data, 511), Some("line511"));
|
||||||
|
// 跨块边界之后不存在第 512 行
|
||||||
|
assert_eq!(idx.get_line(&data, 512), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 恰好 256 行 + 尾部换行
|
||||||
|
#[test]
|
||||||
|
fn test_exactly_256_lines_trailing_newline() {
|
||||||
|
let data = make_lines(256);
|
||||||
|
let idx = LineIndex::from_bytes(&data);
|
||||||
|
assert_eq!(idx.line_count(), 256);
|
||||||
|
// sampled_offsets: [0](第 0 行),trailing pop 移除了第 256 行的采样
|
||||||
|
assert_eq!(idx.sampled_offsets.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 恰好 512 行 + 尾部换行
|
||||||
|
#[test]
|
||||||
|
fn test_exactly_512_lines_trailing_newline() {
|
||||||
|
let data = make_lines(512);
|
||||||
|
let idx = LineIndex::from_bytes(&data);
|
||||||
|
assert_eq!(idx.line_count(), 512);
|
||||||
|
// sampled_offsets: [0, 256行的偏移, 512行的偏移被pop]
|
||||||
|
assert_eq!(idx.sampled_offsets.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. 257 行 + 尾部换行:pop 不应影响
|
||||||
|
#[test]
|
||||||
|
fn test_257_lines_trailing_newline_no_pop() {
|
||||||
|
let data = make_lines(257);
|
||||||
|
let idx = LineIndex::from_bytes(&data);
|
||||||
|
assert_eq!(idx.line_count(), 257);
|
||||||
|
// sampled_offsets: [0, 第256行偏移](trailing 是第 257 行,257 % 256 != 0,不 pop)
|
||||||
|
assert_eq!(idx.sampled_offsets.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. sampled_offsets 数量一致性
|
||||||
|
#[test]
|
||||||
|
fn test_sampled_offsets_consistency() {
|
||||||
|
// 对于不同的总行数,验证 sampled_offsets 数量等于 div_ceil(total_lines, BLOCK_SIZE)
|
||||||
|
for &n in &[1usize, 100, 256, 257, 300, 512, 513, 1000] {
|
||||||
|
let data = make_lines(n);
|
||||||
|
let idx = LineIndex::from_bytes(&data);
|
||||||
|
let expected = (idx.total_lines as usize + BLOCK_SIZE - 1) / BLOCK_SIZE;
|
||||||
|
assert_eq!(
|
||||||
|
idx.sampled_offsets.len(),
|
||||||
|
expected,
|
||||||
|
"n={}: expected {} sampled_offsets, got {}",
|
||||||
|
n,
|
||||||
|
expected,
|
||||||
|
idx.sampled_offsets.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. CRLF 换行符
|
||||||
#[test]
|
#[test]
|
||||||
// 测试:Windows 风格的 CRLF 换行符(\r\n)。
|
|
||||||
fn test_crlf_endings() {
|
fn test_crlf_endings() {
|
||||||
// b"hello\r\nworld\r\n" — 每行末尾是 \r\n(回车+换行)。
|
|
||||||
let data = b"hello\r\nworld\r\n";
|
let data = b"hello\r\nworld\r\n";
|
||||||
let idx = LineIndex::from_bytes(data);
|
let idx = LineIndex::from_bytes(data);
|
||||||
// 2 行(末尾 \n 后没有内容,所以不算第 3 行)。
|
|
||||||
assert_eq!(idx.line_count(), 2);
|
assert_eq!(idx.line_count(), 2);
|
||||||
// get_line 会自动去除 \r 和 \n,所以内容是干净的。
|
|
||||||
assert_eq!(idx.get_line(data, 0), Some("hello"));
|
assert_eq!(idx.get_line(data, 0), Some("hello"));
|
||||||
assert_eq!(idx.get_line(data, 1), Some("world"));
|
assert_eq!(idx.get_line(data, 1), Some("world"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 12. 只有一个换行符
|
||||||
#[test]
|
#[test]
|
||||||
// 测试:文件内容只是一个换行符。
|
|
||||||
fn test_only_newline() {
|
fn test_only_newline() {
|
||||||
let data = b"\n";
|
let data = b"\n";
|
||||||
let idx = LineIndex::from_bytes(data);
|
let idx = LineIndex::from_bytes(data);
|
||||||
// 一个 \n 算作 1 行(内容为空字符串)。
|
|
||||||
assert_eq!(idx.line_count(), 1);
|
assert_eq!(idx.line_count(), 1);
|
||||||
// 第 0 行内容为空字符串 ""。
|
|
||||||
assert_eq!(idx.get_line(data, 0), Some(""));
|
assert_eq!(idx.get_line(data, 0), Some(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 13. 连续换行符(空行)
|
||||||
#[test]
|
#[test]
|
||||||
// 测试:连续的换行符(中间有空行)。
|
|
||||||
fn test_consecutive_newlines() {
|
fn test_consecutive_newlines() {
|
||||||
let data = b"a\n\nb";
|
let data = b"a\n\nb";
|
||||||
let idx = LineIndex::from_bytes(data);
|
let idx = LineIndex::from_bytes(data);
|
||||||
// 3 行:'a'、空行、'b'。
|
|
||||||
assert_eq!(idx.line_count(), 3);
|
assert_eq!(idx.line_count(), 3);
|
||||||
assert_eq!(idx.get_line(data, 0), Some("a"));
|
assert_eq!(idx.get_line(data, 0), Some("a"));
|
||||||
// 中间的空行,内容为空字符串 ""。
|
|
||||||
assert_eq!(idx.get_line(data, 1), Some(""));
|
assert_eq!(idx.get_line(data, 1), Some(""));
|
||||||
assert_eq!(idx.get_line(data, 2), Some("b"));
|
assert_eq!(idx.get_line(data, 2), Some("b"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 14. 行号越界
|
||||||
#[test]
|
#[test]
|
||||||
// 测试:两个换行符(两行空行)。
|
|
||||||
fn test_double_newline() {
|
|
||||||
let data = b"\n\n";
|
|
||||||
let idx = LineIndex::from_bytes(data);
|
|
||||||
// 2 行空行。
|
|
||||||
assert_eq!(idx.line_count(), 2);
|
|
||||||
assert_eq!(idx.get_line(data, 0), Some(""));
|
|
||||||
assert_eq!(idx.get_line(data, 1), Some(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
// 测试:行号越界时应该返回 None。
|
|
||||||
fn test_out_of_bounds() {
|
fn test_out_of_bounds() {
|
||||||
let data = b"hello";
|
let data = b"hello";
|
||||||
let idx = LineIndex::from_bytes(data);
|
let idx = LineIndex::from_bytes(data);
|
||||||
// 只有 1 行,请求第 999 行应该返回 None。
|
assert_eq!(idx.get_line(data, 9999), None);
|
||||||
assert_eq!(idx.get_line(data, 999), None);
|
}
|
||||||
|
|
||||||
|
// 15. 257 行逐行验证
|
||||||
|
#[test]
|
||||||
|
fn test_257_lines_all_lines_verified() {
|
||||||
|
let data = make_lines(257);
|
||||||
|
let idx = LineIndex::from_bytes(&data);
|
||||||
|
assert_eq!(idx.line_count(), 257);
|
||||||
|
for i in 0..257 {
|
||||||
|
assert_eq!(
|
||||||
|
idx.get_line(&data, i),
|
||||||
|
Some(Box::leak(format!("line{}", i).into_boxed_str()) as &str),
|
||||||
|
"line {} mismatch",
|
||||||
|
i
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16. 跨块第一行(最常见的失败点)
|
||||||
|
#[test]
|
||||||
|
fn test_cross_block_first_line() {
|
||||||
|
let data = make_lines(300);
|
||||||
|
let idx = LineIndex::from_bytes(&data);
|
||||||
|
assert_eq!(idx.get_line(&data, 256), Some("line256"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 17. 混合换行符
|
||||||
|
#[test]
|
||||||
|
fn test_mixed_newlines() {
|
||||||
|
let data = b"hello\r\nworld\nfoo";
|
||||||
|
let idx = LineIndex::from_bytes(data);
|
||||||
|
assert_eq!(idx.line_count(), 3);
|
||||||
|
assert_eq!(idx.get_line(data, 0), Some("hello"));
|
||||||
|
assert_eq!(idx.get_line(data, 1), Some("world"));
|
||||||
|
assert_eq!(idx.get_line(data, 2), Some("foo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 18. 裸 \r 不算换行符
|
||||||
|
#[test]
|
||||||
|
fn test_bare_cr_not_newline() {
|
||||||
|
let data = b"hello\rworld";
|
||||||
|
let idx = LineIndex::from_bytes(data);
|
||||||
|
assert_eq!(idx.line_count(), 1);
|
||||||
|
// \r 保留在内容中(只去除末尾的 \r)
|
||||||
|
assert_eq!(idx.get_line(data, 0), Some("hello\rworld"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 19. 短行验证偏移
|
||||||
|
#[test]
|
||||||
|
fn test_short_lines() {
|
||||||
|
let data = b"a\nb\nc";
|
||||||
|
let idx = LineIndex::from_bytes(data);
|
||||||
|
assert_eq!(idx.line_count(), 3);
|
||||||
|
assert_eq!(idx.get_line(data, 0), Some("a"));
|
||||||
|
assert_eq!(idx.get_line(data, 1), Some("b"));
|
||||||
|
assert_eq!(idx.get_line(data, 2), Some("c"));
|
||||||
|
// sampled_offsets 应该只有一个 [0]
|
||||||
|
assert_eq!(idx.sampled_offsets.len(), 1);
|
||||||
|
assert_eq!(idx.sampled_offsets[0], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 20. idx 等于 total_lines 应返回 None
|
||||||
|
#[test]
|
||||||
|
fn test_idx_equals_total_lines_returns_none() {
|
||||||
|
let data = b"aaa\nbbb\nccc";
|
||||||
|
let idx = LineIndex::from_bytes(data);
|
||||||
|
assert_eq!(idx.line_count(), 3);
|
||||||
|
assert_eq!(idx.get_line(data, 3), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── from_reader 测试 ──────────────────────────────────────────────────
|
||||||
|
use std::io::BufReader;
|
||||||
|
|
||||||
|
fn make_reader(data: &[u8]) -> BufReader<std::io::Cursor<&[u8]>> {
|
||||||
|
BufReader::new(std::io::Cursor::new(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
// 测试:空文件的行数和 get_line 都应正确处理。
|
fn test_from_reader_empty() {
|
||||||
fn test_empty_file_line_count_and_get_line() {
|
let mut reader = make_reader(b"");
|
||||||
let idx = LineIndex::from_bytes(b"");
|
let idx = LineIndex::from_reader(&mut reader).unwrap();
|
||||||
assert_eq!(idx.line_count(), 0);
|
assert_eq!(idx.line_count(), 0);
|
||||||
// 空文件,请求第 0 行也应该返回 None(因为没有行)。
|
assert_eq!(idx.sampled_offsets.len(), 0);
|
||||||
assert_eq!(idx.get_line(b"", 0), None);
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_reader_single_line_no_newline() {
|
||||||
|
let data = b"hello";
|
||||||
|
let mut reader = make_reader(data);
|
||||||
|
let idx = LineIndex::from_reader(&mut reader).unwrap();
|
||||||
|
assert_eq!(idx.line_count(), 1);
|
||||||
|
assert_eq!(idx.get_line(data, 0), Some("hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_reader_single_line_with_newline() {
|
||||||
|
let data = b"hello\n";
|
||||||
|
let mut reader = make_reader(data);
|
||||||
|
let idx = LineIndex::from_reader(&mut reader).unwrap();
|
||||||
|
assert_eq!(idx.line_count(), 1);
|
||||||
|
assert_eq!(idx.get_line(data, 0), Some("hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_reader_matches_from_bytes() {
|
||||||
|
let test_cases: Vec<&[u8]> = vec![
|
||||||
|
b"",
|
||||||
|
b"hello",
|
||||||
|
b"hello\n",
|
||||||
|
b"a\nb\nc",
|
||||||
|
b"\n",
|
||||||
|
b"a\n\nb",
|
||||||
|
b"hello\r\nworld\r\n",
|
||||||
|
b"hello\r\nworld\nfoo",
|
||||||
|
b"hello\rworld",
|
||||||
|
];
|
||||||
|
|
||||||
|
let lines_300 = make_lines(300);
|
||||||
|
let lines_257 = make_lines(257);
|
||||||
|
let lines_512 = make_lines(512);
|
||||||
|
|
||||||
|
let mut all_cases: Vec<Vec<u8>> = test_cases.into_iter().map(|s| s.to_vec()).collect();
|
||||||
|
all_cases.push(lines_300);
|
||||||
|
all_cases.push(lines_257);
|
||||||
|
all_cases.push(lines_512);
|
||||||
|
|
||||||
|
for (i, data) in all_cases.iter().enumerate() {
|
||||||
|
let from_bytes_idx = LineIndex::from_bytes(data);
|
||||||
|
let mut reader = make_reader(data);
|
||||||
|
let from_reader_idx = LineIndex::from_reader(&mut reader).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
from_bytes_idx.total_lines, from_reader_idx.total_lines,
|
||||||
|
"case {}: total_lines mismatch (from_bytes={}, from_reader={})",
|
||||||
|
i, from_bytes_idx.total_lines, from_reader_idx.total_lines
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
from_bytes_idx.sampled_offsets, from_reader_idx.sampled_offsets,
|
||||||
|
"case {}: sampled_offsets mismatch",
|
||||||
|
i
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
from_bytes_idx.has_trailing_newline, from_reader_idx.has_trailing_newline,
|
||||||
|
"case {}: has_trailing_newline mismatch",
|
||||||
|
i
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_from_reader_300_lines_content() {
|
||||||
|
let data = make_lines(300);
|
||||||
|
let mut reader = make_reader(&data);
|
||||||
|
let idx = LineIndex::from_reader(&mut reader).unwrap();
|
||||||
|
assert_eq!(idx.line_count(), 300);
|
||||||
|
assert_eq!(idx.get_line(&data, 0), Some("line0"));
|
||||||
|
assert_eq!(idx.get_line(&data, 255), Some("line255"));
|
||||||
|
assert_eq!(idx.get_line(&data, 256), Some("line256"));
|
||||||
|
assert_eq!(idx.get_line(&data, 299), Some("line299"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user