use crate::{Document, RegexSearchQuery, TextPosition, TextRange};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
const PIECE_TABLE_THRESHOLD_BYTES: usize = 1024 * 1024;
fn doc_with(text: &str) -> Document {
let mut doc = Document::new();
if !text.is_empty() {
let _ = doc
.try_insert(TextPosition::new(0, 0), text)
.expect("seeded insert succeeds");
}
doc
}
fn fresh_test_dir(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"qem-regex-tests-{label}-{}-{}",
std::process::id(),
Instant::now().elapsed().as_nanos()
));
fs::create_dir_all(&dir).unwrap();
dir
}
fn open_clean_mmap_doc(content: &[u8], dir: &Path, name: &str) -> Document {
let path = dir.join(name);
fs::write(&path, content).unwrap();
let doc = Document::open(&path).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while doc.is_indexing() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
doc
}
fn open_piece_table_doc(content: &[u8], dir: &Path, name: &str) -> Document {
let path = dir.join(name);
assert!(
content.len() >= PIECE_TABLE_THRESHOLD_BYTES,
"piece-table fixture must be at least {PIECE_TABLE_THRESHOLD_BYTES} bytes",
);
fs::write(&path, content).unwrap();
let mut doc = Document::open(&path).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while doc.is_indexing() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
let _ = doc
.try_insert(TextPosition::new(0, 0), "X")
.expect("seed edit promotes to piece-table");
let _ = doc
.try_replace(TextRange::new(TextPosition::new(0, 0), 1), "")
.expect("undo seed edit");
doc
}
#[test]
fn regex_compile_error_surfaces_non_empty_message_for_invalid_patterns() {
let err = RegexSearchQuery::new("(unclosed").unwrap_err();
assert!(!err.message().is_empty());
let display = format!("{err}");
assert_eq!(display, err.message());
}
#[test]
fn regex_compile_error_rejects_empty_patterns() {
let err = RegexSearchQuery::new("").unwrap_err();
assert!(!err.message().is_empty());
}
#[test]
fn one_shot_find_next_regex_propagates_compile_error() {
let doc = doc_with("anything\n");
let err = doc
.find_next_regex("(unclosed", TextPosition::new(0, 0))
.unwrap_err();
assert!(!err.message().is_empty());
}
#[test]
fn one_shot_find_prev_regex_propagates_compile_error() {
let doc = doc_with("anything\n");
let err = doc
.find_prev_regex("(unclosed", TextPosition::new(0, 8))
.unwrap_err();
assert!(!err.message().is_empty());
}
#[test]
fn find_next_regex_matches_on_rope_document() {
let doc = doc_with("alpha 12 bravo 345 charlie\n");
let m = doc
.find_next_regex(r"\d+", TextPosition::new(0, 0))
.unwrap()
.expect("expected first numeric match");
assert_eq!(m.start(), TextPosition::new(0, 6));
assert_eq!(m.end(), TextPosition::new(0, 8));
assert_eq!(m.len_chars(), 2);
}
#[test]
fn find_next_regex_query_skips_to_position_offset() {
let doc = doc_with("alpha 12 bravo 345 charlie\n");
let query = RegexSearchQuery::new(r"\d+").unwrap();
let first = doc
.find_next_regex_query(&query, TextPosition::new(0, 0))
.unwrap();
assert_eq!(first.start().col0(), 6);
let second = doc.find_next_regex_query(&query, first.end()).unwrap();
assert_eq!(second.start().col0(), 15);
assert_eq!(second.len_chars(), 3);
}
#[test]
fn find_prev_regex_returns_last_match_at_or_before_boundary() {
let doc = doc_with("alpha 12 bravo 345 charlie\n");
let prev = doc
.find_prev_regex(r"\d+", TextPosition::new(0, 26))
.unwrap()
.expect("expected last numeric match before EOL");
assert_eq!(prev.start(), TextPosition::new(0, 15));
assert_eq!(prev.end(), TextPosition::new(0, 18));
}
#[test]
fn find_prev_regex_returns_only_match_fully_within_boundary() {
let doc = doc_with("alpha 12 bravo 345\n");
let prev = doc
.find_prev_regex(r"\d+", TextPosition::new(0, 8))
.unwrap()
.expect("expected match ending at or before col 8");
assert_eq!(prev.start(), TextPosition::new(0, 6));
assert_eq!(prev.end(), TextPosition::new(0, 8));
}
#[test]
fn find_prev_regex_returns_none_at_document_start() {
let doc = doc_with("alpha\n");
let prev = doc
.find_prev_regex(r"\w+", TextPosition::new(0, 0))
.unwrap();
assert!(prev.is_none());
}
#[test]
fn find_all_regex_iterates_non_overlapping_matches() {
let doc = doc_with("a1 b22 c333 d4444\n");
let matches: Vec<_> = doc.find_all_regex(r"\d+").unwrap().collect();
let starts: Vec<usize> = matches.iter().map(|m| m.start().col0()).collect();
let lengths: Vec<usize> = matches.iter().map(|m| m.len_chars()).collect();
assert_eq!(starts, vec![1, 4, 8, 13]);
assert_eq!(lengths, vec![1, 2, 3, 4]);
}
#[test]
fn find_all_regex_terminates_on_empty_match_pattern() {
let doc = doc_with("aaa\n");
let matches: Vec<_> = doc.find_all_regex(r"a*").unwrap().take(64).collect();
assert!(!matches.is_empty());
assert!(matches.len() <= 64);
}
#[test]
fn find_all_regex_iterator_is_fused_after_exhaustion() {
let doc = doc_with("alpha\n");
let mut iter = doc.find_all_regex(r"\d+").unwrap();
assert!(iter.next().is_none());
assert!(iter.next().is_none());
assert!(iter.next().is_none());
}
#[test]
fn find_all_regex_no_match_returns_empty_iterator() {
let doc = doc_with("alpha bravo charlie\n");
let collected: Vec<_> = doc.find_all_regex(r"\d+").unwrap().collect();
assert!(collected.is_empty());
}
#[test]
fn anchors_match_line_starts() {
let doc = doc_with("first\nsecond\nthird\n");
let starts: Vec<_> = doc.find_all_regex(r"(?m)^\w+").unwrap().collect();
assert_eq!(starts.len(), 3);
assert_eq!(starts[0].start(), TextPosition::new(0, 0));
assert_eq!(starts[1].start(), TextPosition::new(1, 0));
assert_eq!(starts[2].start(), TextPosition::new(2, 0));
}
#[test]
fn anchors_match_line_ends_in_multiline_mode() {
let doc = doc_with("first\nsecond\nthird\n");
let ends: Vec<_> = doc.find_all_regex(r"(?m)\w+$").unwrap().collect();
assert_eq!(ends.len(), 3);
assert_eq!(ends[0].end(), TextPosition::new(0, 5));
assert_eq!(ends[1].end(), TextPosition::new(1, 6));
assert_eq!(ends[2].end(), TextPosition::new(2, 5));
}
#[test]
fn unicode_classes_match_combining_marks_as_separate_columns() {
let doc = doc_with("café\n");
let m = doc
.find_next_regex(r"\w+", TextPosition::new(0, 0))
.unwrap()
.expect("expected unicode word");
assert_eq!(m.start(), TextPosition::new(0, 0));
assert_eq!(m.end(), TextPosition::new(0, 4));
assert_eq!(m.len_chars(), 4);
}
#[test]
fn unicode_class_matches_cyrillic_word() {
let doc = doc_with("hello мир world\n");
let collected: Vec<_> = doc.find_all_regex(r"\w+").unwrap().collect();
let starts: Vec<usize> = collected.iter().map(|m| m.start().col0()).collect();
let ends: Vec<usize> = collected.iter().map(|m| m.end().col0()).collect();
assert_eq!(starts, vec![0, 6, 10]);
assert_eq!(ends, vec![5, 9, 15]);
}
#[test]
fn case_insensitive_flag_matches_mixed_case() {
let doc = doc_with("Hello hello HELLO\n");
let collected: Vec<_> = doc.find_all_regex(r"(?i)hello").unwrap().collect();
assert_eq!(collected.len(), 3);
}
#[test]
fn bounded_in_range_returns_only_fully_contained_matches() {
let doc = doc_with("xxx 1234 yyy 5678 zzz\n");
let range = TextRange::new(TextPosition::new(0, 3), 6);
let m = doc
.find_next_regex_in_range(r"\d+", range)
.unwrap()
.expect("expected first number to fit");
assert_eq!(m.start(), TextPosition::new(0, 4));
assert_eq!(m.end(), TextPosition::new(0, 8));
}
#[test]
fn bounded_regex_returns_prefix_of_overflowing_match_within_bounds() {
let doc = doc_with("xxx 1234 yyy 5678 zzz\n");
let range = TextRange::new(TextPosition::new(0, 3), 4);
let m = doc
.find_next_regex_in_range(r"\d+", range)
.unwrap()
.expect("expected prefix match within bounds");
assert_eq!(m.start(), TextPosition::new(0, 4));
assert_eq!(m.end(), TextPosition::new(0, 7));
assert_eq!(m.len_chars(), 3);
}
#[test]
fn bounded_between_orders_endpoints_and_finds_match() {
let doc = doc_with("xxx 1234 yyy 5678 zzz\n");
let m = doc
.find_next_regex_between(r"\d+", TextPosition::new(0, 13), TextPosition::new(0, 9))
.unwrap();
assert!(m.is_none(), "no number in [9..13)");
let m = doc
.find_next_regex_between(r"\d+", TextPosition::new(0, 21), TextPosition::new(0, 9))
.unwrap()
.expect("expected match in [9..21)");
assert_eq!(m.start(), TextPosition::new(0, 13));
assert_eq!(m.end(), TextPosition::new(0, 17));
}
#[test]
fn find_prev_regex_query_mirrors_literal_semantics_with_full_iteration() {
let doc = doc_with("a1 a2 a3 a4 a5\n");
let query = RegexSearchQuery::new(r"a\d").unwrap();
let prev = doc
.find_prev_regex_query(&query, TextPosition::new(0, 14))
.expect("expected last match before EOL");
assert_eq!(prev.start(), TextPosition::new(0, 12));
assert_eq!(prev.end(), TextPosition::new(0, 14));
let prev = doc
.find_prev_regex_query(&query, TextPosition::new(0, 8))
.expect("expected last match ending at or before col 8");
assert_eq!(prev.start(), TextPosition::new(0, 6));
assert_eq!(prev.end(), TextPosition::new(0, 8));
}
#[test]
fn find_all_regex_query_bounded_iterator_stops_at_end() {
let doc = doc_with("1 2 3 4 5 6\n");
let query = RegexSearchQuery::new(r"\d").unwrap();
let collected: Vec<_> = doc
.find_all_regex_query_between(&query, TextPosition::new(0, 0), TextPosition::new(0, 5))
.collect();
let starts: Vec<usize> = collected.iter().map(|m| m.start().col0()).collect();
assert_eq!(starts, vec![0, 2, 4]);
}
#[test]
fn find_all_regex_in_range_skips_matches_outside_window() {
let doc = doc_with("aa bb cc dd ee ff\n");
let range = TextRange::new(TextPosition::new(0, 6), 6);
let collected: Vec<_> = doc
.find_all_regex_in_range(r"\w+", range)
.unwrap()
.collect();
let starts: Vec<usize> = collected.iter().map(|m| m.start().col0()).collect();
let ends: Vec<usize> = collected.iter().map(|m| m.end().col0()).collect();
assert_eq!(starts, vec![6, 9]);
assert_eq!(ends, vec![8, 11]);
}
#[test]
fn dot_does_not_cross_newlines_by_default() {
let doc = doc_with("foo\nbar\nbaz\n");
let m = doc
.find_next_regex(r"foo.bar", TextPosition::new(0, 0))
.unwrap();
assert!(m.is_none(), "default `.` must not cross `\\n` (got {m:?})");
}
#[test]
fn dot_all_flag_crosses_newlines_when_explicitly_enabled() {
let doc = doc_with("foo\nbar\n");
let m = doc
.find_next_regex(r"(?s)foo.bar", TextPosition::new(0, 0))
.unwrap()
.expect("expected (?s)-mode match across newline");
assert_eq!(m.start(), TextPosition::new(0, 0));
assert_eq!(m.end(), TextPosition::new(1, 3));
}
#[test]
fn rope_backing_normalizes_crlf_for_regex_search() {
let doc = doc_with("alpha\r\nbeta\r\ngamma\n");
let starts: Vec<_> = doc.find_all_regex(r"(?m)^\w+").unwrap().collect();
assert_eq!(starts.len(), 3);
assert_eq!(starts[0].start(), TextPosition::new(0, 0));
assert_eq!(starts[1].start(), TextPosition::new(1, 0));
assert_eq!(starts[2].start(), TextPosition::new(2, 0));
}
#[test]
fn mmap_backing_preserves_stored_crlf_during_regex_match() {
let dir = fresh_test_dir("mmap-crlf");
let doc = open_clean_mmap_doc(b"alpha\r\nbeta\r\ngamma\n", &dir, "crlf.txt");
let collected: Vec<_> = doc.find_all_regex(r"(?m)^\w+").unwrap().collect();
assert_eq!(collected.len(), 3);
assert_eq!(collected[0].start(), TextPosition::new(0, 0));
assert_eq!(collected[1].start(), TextPosition::new(1, 0));
assert_eq!(collected[2].start(), TextPosition::new(2, 0));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_next_regex_on_clean_mmap_finds_first_match_with_correct_position() {
let dir = fresh_test_dir("mmap-find-next");
let doc = open_clean_mmap_doc(
b"alpha 12 bravo 345 charlie 6789\n",
&dir,
"single-line.txt",
);
let m = doc
.find_next_regex(r"\d+", TextPosition::new(0, 0))
.unwrap()
.expect("expected first number on mmap path");
assert_eq!(m.start(), TextPosition::new(0, 6));
assert_eq!(m.end(), TextPosition::new(0, 8));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_prev_regex_on_clean_mmap_returns_last_match_before_boundary() {
let dir = fresh_test_dir("mmap-find-prev");
let doc = open_clean_mmap_doc(
b"alpha 12 bravo 345 charlie 6789 delta\n",
&dir,
"with-numbers.txt",
);
let prev = doc
.find_prev_regex(r"\d+", TextPosition::new(0, 31))
.unwrap()
.expect("expected last numeric match before col 31");
assert_eq!(prev.start(), TextPosition::new(0, 27));
assert_eq!(prev.end(), TextPosition::new(0, 31));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_all_regex_on_clean_mmap_iterates_all_matches_across_lines() {
let dir = fresh_test_dir("mmap-find-all");
let doc = open_clean_mmap_doc(
b"line 1 has 11\nline 2 has 222\nline 3 has 3333\n",
&dir,
"multiline.txt",
);
let collected: Vec<_> = doc.find_all_regex(r"\d+").unwrap().collect();
let starts: Vec<(usize, usize)> = collected
.iter()
.map(|m| (m.start().line0(), m.start().col0()))
.collect();
let lengths: Vec<usize> = collected.iter().map(|m| m.len_chars()).collect();
assert_eq!(
starts,
vec![(0, 5), (0, 11), (1, 5), (1, 11), (2, 5), (2, 11)]
);
assert_eq!(lengths, vec![1, 2, 1, 3, 1, 4]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_next_regex_on_clean_mmap_with_no_match_returns_none() {
let dir = fresh_test_dir("mmap-no-match");
let doc = open_clean_mmap_doc(b"alpha bravo charlie delta\n", &dir, "letters.txt");
let result = doc
.find_next_regex(r"\d+", TextPosition::new(0, 0))
.unwrap();
assert!(result.is_none());
let _ = fs::remove_dir_all(&dir);
}
fn build_piece_table_fixture() -> Vec<u8> {
let line_padding = "abcdefghij".repeat(80);
let mut bytes = Vec::with_capacity(PIECE_TABLE_THRESHOLD_BYTES + 64 * 1024);
for i in 0..1500 {
if i == 100 {
bytes.extend_from_slice(b"PREFIX TARGET 999\n");
} else {
bytes.extend_from_slice(line_padding.as_bytes());
bytes.extend_from_slice(b"\n");
}
}
bytes
}
#[test]
fn find_next_regex_on_piece_table_backing_finds_known_seed_position() {
let dir = fresh_test_dir("pt-find-next");
let bytes = build_piece_table_fixture();
let doc = open_piece_table_doc(&bytes, &dir, "pt.txt");
let m = doc
.find_next_regex(r"TARGET", TextPosition::new(0, 0))
.unwrap()
.expect("expected TARGET on piece-table backing");
assert_eq!(m.start(), TextPosition::new(100, 7));
assert_eq!(m.end(), TextPosition::new(100, 13));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_all_regex_on_piece_table_backing_yields_all_numeric_runs() {
let dir = fresh_test_dir("pt-find-all");
let bytes = build_piece_table_fixture();
let doc = open_piece_table_doc(&bytes, &dir, "pt-numbers.txt");
let mut count = 0usize;
for m in doc.find_all_regex(r"\d+").unwrap() {
assert_eq!(m.start().line0(), 100);
assert_eq!(m.start().col0(), 14);
assert_eq!(m.end().col0(), 17);
count += 1;
}
assert_eq!(count, 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_prev_regex_on_piece_table_backing_returns_last_match_before_eof() {
let dir = fresh_test_dir("pt-find-prev");
let bytes = build_piece_table_fixture();
let doc = open_piece_table_doc(&bytes, &dir, "pt-prev.txt");
let prev = doc
.find_prev_regex(r"TARGET", TextPosition::new(usize::MAX, usize::MAX))
.unwrap()
.expect("expected reverse match on piece-table backing");
assert_eq!(prev.start(), TextPosition::new(100, 7));
assert_eq!(prev.end(), TextPosition::new(100, 13));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn rope_and_mmap_return_same_first_match_for_same_content() {
let dir = fresh_test_dir("xback-same-first");
let content = "alpha 12 bravo 345 charlie 67890\n";
let rope_doc = doc_with(content);
let mmap_doc = open_clean_mmap_doc(content.as_bytes(), &dir, "same-first.txt");
let from = TextPosition::new(0, 0);
let from_rope = rope_doc.find_next_regex(r"\d+", from).unwrap();
let from_mmap = mmap_doc.find_next_regex(r"\d+", from).unwrap();
assert_eq!(from_rope, from_mmap);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn rope_and_mmap_return_same_match_count_via_find_all() {
let dir = fresh_test_dir("xback-find-all");
let content = "x1 y2 z3 a44 b55 c66 d777 e888 f999\n";
let rope_doc = doc_with(content);
let mmap_doc = open_clean_mmap_doc(content.as_bytes(), &dir, "find-all.txt");
let rope_matches: Vec<_> = rope_doc.find_all_regex(r"\d+").unwrap().collect();
let mmap_matches: Vec<_> = mmap_doc.find_all_regex(r"\d+").unwrap().collect();
assert_eq!(rope_matches.len(), mmap_matches.len());
assert_eq!(rope_matches, mmap_matches);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn compiled_query_can_be_reused_across_many_calls() {
let doc = doc_with("a1 a2 a3 a4 a5 a6 a7 a8 a9\n");
let query = RegexSearchQuery::new(r"a\d").unwrap();
let mut from = TextPosition::new(0, 0);
let mut found_count = 0usize;
while let Some(m) = doc.find_next_regex_query(&query, from) {
found_count += 1;
from = m.end();
if found_count > 20 {
panic!("compiled-query loop did not advance");
}
}
assert_eq!(found_count, 9);
}
#[test]
fn compiled_query_pattern_is_preserved() {
let pattern = r"\bword\b";
let query = RegexSearchQuery::new(pattern).unwrap();
assert_eq!(query.pattern(), pattern);
}
#[test]
fn empty_document_returns_no_matches() {
let doc = Document::new();
let result = doc
.find_next_regex(r"\w+", TextPosition::new(0, 0))
.unwrap();
assert!(result.is_none());
let collected: Vec<_> = doc.find_all_regex(r"\w+").unwrap().collect();
assert!(collected.is_empty());
}
#[test]
fn single_char_document_matches_pattern_once() {
let doc = doc_with("a");
let collected: Vec<_> = doc.find_all_regex(r"\w").unwrap().collect();
assert_eq!(collected.len(), 1);
assert_eq!(collected[0].start(), TextPosition::new(0, 0));
assert_eq!(collected[0].end(), TextPosition::new(0, 1));
}
#[test]
fn whole_document_match_returns_full_span() {
let doc = doc_with("hello\nworld\n");
let m = doc
.find_next_regex(r"(?s)^.*$", TextPosition::new(0, 0))
.unwrap()
.expect("expected match spanning entire document");
assert_eq!(m.start(), TextPosition::new(0, 0));
assert!(m.end().line0() >= 1);
}
fn build_far_offset_fixture(target_offset: usize, marker: &[u8]) -> Vec<u8> {
let prefix_len = target_offset;
let mut bytes = Vec::with_capacity(prefix_len + marker.len() + 64);
bytes.extend(std::iter::repeat_n(b' ', prefix_len));
bytes.extend_from_slice(marker);
bytes.extend_from_slice(b"\n");
bytes.extend(std::iter::repeat_n(b' ', 32));
bytes
}
#[test]
fn find_next_regex_on_clean_mmap_finds_match_past_8mib_offset() {
let dir = fresh_test_dir("mmap-far");
let target_offset = 9 * 1024 * 1024;
let bytes = build_far_offset_fixture(target_offset, b"FAR_MARKER_42");
let doc = open_clean_mmap_doc(&bytes, &dir, "far.bin");
let m = doc
.find_next_regex(r"FAR_MARKER_\d+", TextPosition::new(0, 0))
.unwrap()
.expect("regex must find marker past 8 MiB on mmap path");
assert_eq!(m.start(), TextPosition::new(0, target_offset));
assert_eq!(m.end(), TextPosition::new(0, target_offset + 13));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_next_regex_on_clean_mmap_finds_match_past_64mib_offset() {
let dir = fresh_test_dir("mmap-very-far");
let target_offset = 64 * 1024 * 1024 + 12345;
let bytes = build_far_offset_fixture(target_offset, b"VERY_FAR_99");
let doc = open_clean_mmap_doc(&bytes, &dir, "very-far.bin");
let m = doc
.find_next_regex(r"VERY_FAR_\d+", TextPosition::new(0, 0))
.unwrap()
.expect("regex must find marker past 64 MiB on mmap path");
assert_eq!(m.start().col0(), target_offset);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_next_regex_on_clean_mmap_returns_none_when_no_match_past_8mib() {
let dir = fresh_test_dir("mmap-far-none");
let bytes = vec![b' '; 9 * 1024 * 1024 + 64];
let doc = open_clean_mmap_doc(&bytes, &dir, "none.bin");
let result = doc
.find_next_regex(r"NEVER_MATCHES", TextPosition::new(0, 0))
.unwrap();
assert!(result.is_none());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_prev_regex_on_clean_mmap_finds_match_far_before_boundary() {
let dir = fresh_test_dir("mmap-prev-far");
let target_offset = 9 * 1024 * 1024;
let bytes = build_far_offset_fixture(target_offset, b"REV_MARK_7");
let doc = open_clean_mmap_doc(&bytes, &dir, "prev-far.bin");
let prev = doc
.find_prev_regex(r"REV_MARK_\d+", TextPosition::new(usize::MAX, usize::MAX))
.unwrap()
.expect("reverse regex must find marker past 8 MiB on mmap path");
assert_eq!(prev.start().col0(), target_offset);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_all_regex_on_clean_mmap_iterates_matches_across_8mib_boundary() {
let dir = fresh_test_dir("mmap-iter-cross");
let mut bytes = Vec::with_capacity(10 * 1024 * 1024);
bytes.extend_from_slice(b"FIRST_HIT_1\n");
bytes.extend(std::iter::repeat_n(b' ', 9 * 1024 * 1024));
bytes.extend_from_slice(b"SECOND_HIT_2\n");
let doc = open_clean_mmap_doc(&bytes, &dir, "iter-cross.bin");
let collected: Vec<_> = doc.find_all_regex(r"_HIT_\d").unwrap().collect();
assert_eq!(
collected.len(),
2,
"expected both markers (got {collected:?})"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_next_regex_on_piece_table_finds_match_past_8mib_chunk_boundary() {
let dir = fresh_test_dir("pt-far");
let target_offset = 9 * 1024 * 1024;
let bytes = build_far_offset_fixture(target_offset, b"PT_MARKER_5");
let doc = open_piece_table_doc(&bytes, &dir, "pt-far.bin");
let m = doc
.find_next_regex(r"PT_MARKER_\d+", TextPosition::new(0, 0))
.unwrap()
.expect("regex must find marker past 8 MiB on piece-table path");
assert_eq!(m.start().col0(), target_offset);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_next_regex_on_large_rope_finds_match_past_chunk_boundary() {
let prefix_size = 4 * 1024 * 1024;
let mut content = String::with_capacity(prefix_size + 32);
content.extend(std::iter::repeat_n(' ', prefix_size));
content.push_str("ROPE_HIT_42\n");
let doc = doc_with(&content);
let m = doc
.find_next_regex(r"ROPE_HIT_\d+", TextPosition::new(0, 0))
.unwrap()
.expect("expected rope chunked regex match past chunk threshold");
assert_eq!(m.start(), TextPosition::new(0, prefix_size));
assert_eq!(m.len_chars(), 11);
}
#[test]
fn find_all_regex_on_large_rope_yields_matches_across_chunks() {
let mut content = String::with_capacity(5 * 1024 * 1024);
content.push_str("FIRST_X_1\n");
content.extend(std::iter::repeat_n(' ', 4 * 1024 * 1024));
content.push_str("SECOND_X_2\n");
let doc = doc_with(&content);
let collected: Vec<_> = doc.find_all_regex(r"_X_\d").unwrap().collect();
assert_eq!(
collected.len(),
2,
"expected both rope markers (got {collected:?})"
);
}
#[test]
fn reusable_query_compiles_text_engine_lazily_only_when_needed() {
let query = RegexSearchQuery::new(r"\w+\s\d+").expect("byte engine compiles");
let dir = fresh_test_dir("lazy-bytes-only");
let doc = open_clean_mmap_doc(b"hello 42\n", &dir, "lazy.txt");
let m = doc.find_next_regex_query(&query, TextPosition::new(0, 0));
assert!(m.is_some());
let _ = fs::remove_dir_all(&dir);
let rope_doc = doc_with("hello 42\n");
let m = rope_doc.find_next_regex_query(&query, TextPosition::new(0, 0));
assert!(m.is_some());
for _ in 0..10 {
let m = rope_doc.find_next_regex_query(&query, TextPosition::new(0, 0));
assert!(m.is_some());
}
}
#[test]
fn cloning_a_query_re_defers_text_engine_compilation() {
let query = RegexSearchQuery::new(r"\w+").unwrap();
let cloned = query.clone();
assert_eq!(cloned.pattern(), query.pattern());
let rope_doc = doc_with("hello world\n");
let original_match = rope_doc.find_next_regex_query(&query, TextPosition::new(0, 0));
let cloned_match = rope_doc.find_next_regex_query(&cloned, TextPosition::new(0, 0));
assert_eq!(original_match, cloned_match);
}
#[test]
fn find_next_regex_returns_compile_error_without_partial_state() {
let doc = doc_with("alpha bravo charlie\n");
let err = doc
.find_next_regex("(unclosed", TextPosition::new(0, 0))
.unwrap_err();
assert!(!err.message().is_empty());
let m = doc
.find_next_regex(r"\w+", TextPosition::new(0, 0))
.unwrap()
.expect("valid pattern must work after a compile error on the same doc");
assert_eq!(m.start(), TextPosition::new(0, 0));
assert_eq!(m.end(), TextPosition::new(0, 5));
}
#[test]
fn iterator_yields_no_more_items_after_first_failure_on_invalid_pattern() {
let doc = doc_with("alpha\n");
let err = doc.find_all_regex("(unclosed").unwrap_err();
assert!(!err.message().is_empty());
}
#[test]
fn find_next_regex_on_empty_or_oob_position_does_not_panic() {
let doc = doc_with("alpha\n");
let m = doc
.find_next_regex(r"\w+", TextPosition::new(usize::MAX, usize::MAX))
.unwrap();
assert!(m.is_none());
let prev = doc
.find_prev_regex(r"\w+", TextPosition::new(usize::MAX, usize::MAX))
.unwrap()
.expect("reverse with MAX before-position must clamp and find the only word");
assert_eq!(prev.start(), TextPosition::new(0, 0));
}
#[test]
fn dropping_partial_iterator_does_not_leave_observable_state() {
let mut doc = doc_with("a1 a2 a3 a4 a5 a6\n");
{
let mut it = doc.find_all_regex(r"a\d").unwrap();
let _ = it.next();
let _ = it.next();
}
let _ = doc.try_insert(TextPosition::new(0, 0), "PRE ").unwrap();
let collected: Vec<_> = doc.find_all_regex(r"a\d").unwrap().collect();
assert_eq!(collected.len(), 6);
assert_eq!(collected[0].start(), TextPosition::new(0, 4));
}
#[test]
fn rebuilding_iterator_after_edit_observes_new_content() {
let mut doc = doc_with("alpha bravo charlie\n");
let before: Vec<_> = doc.find_all_regex(r"\w+").unwrap().collect();
assert_eq!(before.len(), 3);
let _ = doc
.try_insert(TextPosition::new(0, 0), "delta echo ")
.unwrap();
let after: Vec<_> = doc.find_all_regex(r"\w+").unwrap().collect();
assert_eq!(after.len(), 5);
assert_eq!(after[0].start(), TextPosition::new(0, 0));
}
use crate::DocumentEncoding;
#[test]
fn find_next_regex_finds_ascii_target_in_utf16le_document() {
let dir = fresh_test_dir("utf16le-regex");
let path = dir.join("source.txt");
let text = "first\nsecond TARGET line\nthird\n";
let mut encoded: Vec<u8> = Vec::with_capacity(text.len() * 2);
for unit in text.encode_utf16() {
encoded.extend_from_slice(&unit.to_le_bytes());
}
fs::write(&path, &encoded).unwrap();
let doc = Document::open_with_encoding(&path, DocumentEncoding::utf16le()).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while doc.is_indexing() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
let m = doc
.find_next_regex("TARGET", TextPosition::new(0, 0))
.unwrap()
.expect("expected TARGET match in UTF-16LE document");
assert_eq!(m.start(), TextPosition::new(1, 7));
assert_eq!(m.end(), TextPosition::new(1, 13));
assert_eq!(m.len_chars(), 6);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_next_regex_finds_ascii_target_in_utf16be_document() {
let dir = fresh_test_dir("utf16be-regex");
let path = dir.join("source.txt");
let text = "first\nsecond TARGET line\nthird\n";
let mut encoded: Vec<u8> = Vec::with_capacity(text.len() * 2);
for unit in text.encode_utf16() {
encoded.extend_from_slice(&unit.to_be_bytes());
}
fs::write(&path, &encoded).unwrap();
let doc = Document::open_with_encoding(&path, DocumentEncoding::utf16be()).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while doc.is_indexing() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
let m = doc
.find_next_regex("TARGET", TextPosition::new(0, 0))
.unwrap()
.expect("expected TARGET match in UTF-16BE document");
assert_eq!(m.start(), TextPosition::new(1, 7));
assert_eq!(m.end(), TextPosition::new(1, 13));
assert_eq!(m.len_chars(), 6);
let _ = fs::remove_dir_all(&dir);
}
use encoding_rs::{EUC_KR, GB18030, SHIFT_JIS};
#[test]
fn find_next_regex_finds_ascii_target_in_shift_jis_document() {
let dir = fresh_test_dir("shift-jis-regex");
let path = dir.join("source.txt");
let source_text = "first\n\u{65E5}\u{672C}\u{8A9E}\nthird TARGET line\n";
let (encoded, used, had_errors) = SHIFT_JIS.encode(source_text);
assert_eq!(used, SHIFT_JIS);
assert!(!had_errors, "shift_jis fixture must round-trip cleanly");
fs::write(&path, encoded.as_ref()).unwrap();
let doc =
Document::open_with_encoding(&path, DocumentEncoding::from_label("shift_jis").unwrap())
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while doc.is_indexing() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
let m = doc
.find_next_regex("TARGET", TextPosition::new(0, 0))
.unwrap()
.expect("expected TARGET match in Shift_JIS document");
assert_eq!(m.start(), TextPosition::new(2, 6));
assert_eq!(m.end(), TextPosition::new(2, 12));
assert_eq!(m.len_chars(), 6);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_next_regex_finds_ascii_target_in_gb18030_document() {
let dir = fresh_test_dir("gb18030-regex");
let path = dir.join("source.txt");
let source_text = "first\n\u{4F60}\u{597D}\u{4E16}\u{754C}\nthird TARGET line\n";
let (encoded, used, had_errors) = GB18030.encode(source_text);
assert_eq!(used, GB18030);
assert!(!had_errors, "gb18030 fixture must round-trip cleanly");
fs::write(&path, encoded.as_ref()).unwrap();
let doc = Document::open_with_encoding(&path, DocumentEncoding::from_label("gb18030").unwrap())
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while doc.is_indexing() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
let m = doc
.find_next_regex("TARGET", TextPosition::new(0, 0))
.unwrap()
.expect("expected TARGET match in gb18030 document");
assert_eq!(m.start(), TextPosition::new(2, 6));
assert_eq!(m.end(), TextPosition::new(2, 12));
assert_eq!(m.len_chars(), 6);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn find_next_regex_finds_ascii_target_in_euc_kr_document() {
let dir = fresh_test_dir("euc-kr-regex");
let path = dir.join("source.txt");
let source_text = "first\n\u{C548}\u{B155}\nthird TARGET line\n";
let (encoded, used, had_errors) = EUC_KR.encode(source_text);
assert_eq!(used, EUC_KR);
assert!(!had_errors, "euc-kr fixture must round-trip cleanly");
fs::write(&path, encoded.as_ref()).unwrap();
let doc = Document::open_with_encoding(&path, DocumentEncoding::from_label("EUC-KR").unwrap())
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while doc.is_indexing() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
let m = doc
.find_next_regex("TARGET", TextPosition::new(0, 0))
.unwrap()
.expect("expected TARGET match in EUC-KR document");
assert_eq!(m.start(), TextPosition::new(2, 6));
assert_eq!(m.end(), TextPosition::new(2, 12));
assert_eq!(m.len_chars(), 6);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn ensure_reverse_compiles_a_simple_pattern() {
let query = RegexSearchQuery::new("abc").expect("pattern compiles");
let dfa = query
.ensure_reverse()
.expect("reverse DFA fits in size limit");
let dfa_again = query.ensure_reverse().expect("cached reverse DFA");
assert!(
std::ptr::eq(dfa, dfa_again),
"ensure_reverse must memoize the compiled DFA"
);
}
#[test]
fn ensure_reverse_compiles_anchored_and_class_patterns() {
for pattern in [
"^line$",
r"\d+",
"(foo|bar|baz)+",
r"[A-Za-z_][A-Za-z0-9_]*",
] {
let query = RegexSearchQuery::new(pattern)
.unwrap_or_else(|err| panic!("forward compile failed for {pattern:?}: {err}"));
let _ = query
.ensure_reverse()
.unwrap_or_else(|err| panic!("reverse compile failed for {pattern:?}: {err}"));
}
}
#[test]
fn ensure_reverse_returns_typed_error_on_size_limit_overflow() {
use super::regex_search::build_reverse_dfa_with_limit;
let pattern = "(foo|bar|baz|quux|alpha|beta|gamma|delta|epsilon|zeta){0,128}";
let err = build_reverse_dfa_with_limit(pattern, 64 * 1024)
.expect_err("64 KiB ceiling must reject the wide bounded alternation under LeftmostFirst");
assert!(
!err.message().is_empty(),
"RegexCompileError message must be non-empty"
);
}
#[test]
fn ensure_reverse_caches_the_failure_for_overflow_patterns() {
let pattern = format!(
"[a-z]{{0,{r}}}|[A-Z]{{0,{r}}}|[0-9]{{0,{r}}}|[!-/]{{0,{r}}}|[:-@]{{0,{r}}}|[\\[-`]{{0,{r}}}",
r = 16384usize
);
let Ok(query) = RegexSearchQuery::new(&pattern) else {
return;
};
let first = query
.ensure_reverse()
.expect_err("expected reverse DFA size-limit overflow");
let second = query
.ensure_reverse()
.expect_err("expected cached reverse DFA size-limit overflow");
assert_eq!(
first.message(),
second.message(),
"ensure_reverse must memoize the typed error so callers do not re-pay determinization cost",
);
}
#[test]
fn cloned_query_recompiles_reverse_dfa_lazily() {
let query = RegexSearchQuery::new("abc").unwrap();
let _original = query.ensure_reverse().expect("original reverse compiles");
let cloned = query.clone();
let cloned_dfa = cloned.ensure_reverse().expect("clone reverse compiles");
let cloned_dfa_again = cloned.ensure_reverse().unwrap();
assert!(std::ptr::eq(cloned_dfa, cloned_dfa_again));
}