use super::*;
use crate::fuzzy_matcher::FuzzyMatcher;
fn matcher() -> ArinaeMatcher {
ArinaeMatcher::default()
}
fn matcher_typos() -> ArinaeMatcher {
ArinaeMatcher {
allow_typos: true,
..Default::default()
}
}
fn score(choice: &str, pattern: &str) -> Option<i64> {
matcher().fuzzy_match(choice, pattern)
}
fn score_typos(choice: &str, pattern: &str) -> Option<i64> {
matcher_typos().fuzzy_match(choice, pattern)
}
fn indices(choice: &str, pattern: &str) -> Option<MatchIndices> {
matcher().fuzzy_indices(choice, pattern).map(|(_, v)| v)
}
#[test]
fn empty_pattern_always_matches() {
assert_eq!(score("anything", ""), Some(0));
assert_eq!(score("", ""), Some(0));
}
#[test]
fn empty_choice_never_matches() {
assert!(score("", "a").is_none());
}
#[test]
fn pattern_longer_than_max_pat_len_is_rejected() {
let pattern = "a".repeat(40);
let choice = "a".repeat(50);
assert!(score(&choice, &pattern).is_none());
assert!(matcher().fuzzy_indices(&choice, &pattern).is_none());
let pattern_u = "é".repeat(40);
let choice_u = "é".repeat(50);
assert!(score(&choice_u, &pattern_u).is_none());
}
#[test]
fn exact_match_scores_positive() {
assert!(score("hello", "hello").unwrap() > 0);
}
#[test]
fn no_match_returns_none() {
assert!(score("abc", "xyz").is_none());
}
#[test]
fn subsequence_match() {
assert!(score("axbycz", "abc").is_some());
let idx = indices("axbycz", "abc").unwrap();
assert_eq!(idx.as_slice(), &[0, 2, 4]);
}
#[test]
fn contiguous_beats_scattered() {
let contiguous = score("ab", "ab").unwrap();
let scattered = score("axb", "ab").unwrap();
assert!(
contiguous > scattered,
"contiguous={contiguous} should beat scattered={scattered}"
);
}
#[test]
fn fewer_gaps_beats_more_gaps() {
let one_gap = score("abxc", "abc").unwrap();
let two_gaps = score("axbxc", "abc").unwrap();
assert!(one_gap > two_gaps, "one_gap={one_gap} should beat two_gaps={two_gaps}");
}
#[test]
fn word_start_bonus() {
let boundary = score("src/reader.rs", "reader").unwrap();
let stitched = score("src/tui/header.rs", "reader").unwrap();
assert!(
boundary > stitched,
"word-boundary={boundary} should beat stitched={stitched}"
);
}
#[test]
fn start_of_string_bonus() {
let at_start = score("abc", "a").unwrap();
let at_mid = score("xabc", "a").unwrap();
assert!(at_start > at_mid, "start={at_start} should beat mid={at_mid}");
}
#[test]
fn consecutive_match_preferred() {
let consecutive = score("foobar", "oob").unwrap();
let spread = score("oxoxb", "oob").unwrap();
assert!(
consecutive > spread,
"consecutive={consecutive} should beat spread={spread}"
);
}
#[test]
fn camel_case_bonus() {
let camel = score("FooBar", "fb").unwrap();
let flat = score("foobar", "fb").unwrap();
assert!(camel > flat, "camel={camel} should beat flat={flat}");
}
#[test]
fn smart_case_insensitive_lowercase_pattern() {
let m = ArinaeMatcher {
case: CaseMatching::Smart,
allow_typos: false,
..Default::default()
};
assert!(m.fuzzy_match("FooBar", "foobar").is_some());
}
#[test]
fn smart_case_sensitive_uppercase_pattern() {
let m = ArinaeMatcher {
case: CaseMatching::Smart,
allow_typos: false,
..Default::default()
};
assert!(m.fuzzy_match("foobar", "FooBar").is_none());
assert!(m.fuzzy_match("FooBar", "FooBar").is_some());
}
#[test]
fn respect_case() {
let m = ArinaeMatcher {
case: CaseMatching::Respect,
allow_typos: false,
..Default::default()
};
assert!(m.fuzzy_match("abc", "ABC").is_none());
assert!(m.fuzzy_match("ABC", "ABC").is_some());
}
#[test]
fn ignore_case() {
let m = ArinaeMatcher {
case: CaseMatching::Ignore,
allow_typos: false,
..Default::default()
};
assert!(m.fuzzy_match("abc", "ABC").is_some());
}
#[test]
fn no_typos_rejects_mismatch() {
assert!(score("hxllo", "hello").is_none());
}
#[test]
fn typos_accepts_mismatch() {
assert!(score_typos("hxllo", "hello").is_some());
}
#[test]
fn no_typos_rejects_transposition() {
assert!(score("hlelo", "hello").is_none());
}
#[test]
fn typos_accepts_transposition() {
assert!(score_typos("hlelo", "hello").is_some());
}
#[test]
fn exact_match_same_with_and_without_typos() {
let with = score_typos("hello", "hello").unwrap();
let without = score("hello", "hello").unwrap();
assert_eq!(
with, without,
"exact match score should be identical regardless of typo flag"
);
}
#[test]
fn typo_match_scores_less_than_exact() {
let exact = score_typos("hello", "hello").unwrap();
let typo = score_typos("hxllo", "hello").unwrap();
assert!(exact > typo, "exact={exact} should beat typo={typo}");
}
#[test]
fn indices_exact_match() {
let idx = indices("hello", "hello").unwrap();
assert_eq!(idx.as_slice(), &[0, 1, 2, 3, 4]);
}
#[test]
fn transposition_matches() {
let result = matcher_typos().fuzzy_indices("abdc", "abcd");
assert!(result.is_some(), "transposed input should match with typos");
let (score_trans, _) = result.unwrap();
let (score_exact, _) = matcher_typos().fuzzy_indices("abcd", "abcd").unwrap();
assert!(
score_exact > score_trans,
"exact={score_exact} should beat transposed={score_trans}"
);
}
#[test]
fn reader_ranking() {
let pattern = "reader";
let dense = score("src/reader.rs", pattern).unwrap();
let sparse = score(
"tests/snapshots/normalize__insta_normalize_accented_item_unaccented_query.snap",
pattern,
)
.unwrap_or(0);
assert!(dense > sparse, "dense={dense} should beat sparse={sparse}");
}
#[test]
fn ordering_ab() {
use crate::fuzzy_matcher::util::assert_order;
let m = ArinaeMatcher {
case: CaseMatching::Ignore,
allow_typos: false,
..Default::default()
};
assert_order(&m, "ab", &["ab", "aoo_boo", "acb"]);
}
#[test]
fn ordering_print() {
use crate::fuzzy_matcher::util::assert_order;
let m = ArinaeMatcher {
case: CaseMatching::Ignore,
allow_typos: false,
..Default::default()
};
assert_order(&m, "print", &["printf", "sprintf"]);
}
#[test]
fn score_only_matches_full_dp() {
let m = ArinaeMatcher {
case: CaseMatching::Ignore,
allow_typos: true,
..Default::default()
};
let cases = [
("hello world", "hlo"),
("src/reader.rs", "reader"),
("FooBar", "fb"),
("axbycz", "abc"),
("hxllo", "hello"),
];
for (choice, pattern) in &cases {
let score_only = m.fuzzy_match(choice, pattern);
let full = m.fuzzy_indices(choice, pattern).map(|(s, _)| s);
assert_eq!(
score_only, full,
"score mismatch for ({choice}, {pattern}): score_only={score_only:?} full={full:?}"
);
}
}
#[test]
fn non_ascii_matching() {
let m = matcher();
assert!(m.fuzzy_match("café", "café").is_some());
assert!(m.fuzzy_match("naïve", "naive").is_none());
}
#[test]
fn all_subsequences_must_match() {
let m = matcher();
let cases = [
"audio/audio/bin/temp/usr/uploads/mnt/cache/media_3445258",
"audio/audio/audio/docs/cache/temp/downloads/backup/shared/data_9591740",
"audio/audio/audio/opt/media/sys/sys/backup/etc_744357",
"audio/audio/audio/temp/shared/uploads/downloads/config/home/mnt_9037278",
"audio/audio/opt/cache/usr/usr/var/temp_1579492",
];
for choice in &cases {
assert!(
m.fuzzy_match(choice, "test").is_some(),
"fuzzy_match should match subsequence 'test' in {choice:?}",
);
assert!(
m.fuzzy_indices(choice, "test").is_some(),
"fuzzy_indices should match subsequence 'test' in {choice:?}",
);
}
}
#[test]
fn score_and_full_dp_same() {
let cases = [("dist-workspace.toml", "tst")];
let m = matcher_typos();
for (choice, pat) in cases {
assert_eq!(
m.fuzzy_indices(choice, pat).map(|(s, _)| s),
m.fuzzy_match_range(choice, pat).map(|(s, _, _)| s)
);
}
}
#[test]
fn range_consistent_with_indices() {
let cases = [
("hello", "hello"),
("axbycz", "abc"),
("src/reader.rs", "reader"),
("FooBar", "fb"),
("dist-workspace.toml", "tst"),
];
let matchers = [matcher(), matcher_typos()];
for m in &matchers {
for &(choice, pattern) in &cases {
let range = m.fuzzy_match_range(choice, pattern);
let full = m.fuzzy_indices(choice, pattern);
match (range, full) {
(None, None) => {}
(Some((rs, rb, re)), Some((fs, fidx))) => {
assert_eq!(rs, fs, "score mismatch for ({choice}, {pattern})");
let fbegin = fidx.first().copied().unwrap_or_default();
let fend = fidx.last().copied().unwrap_or_default();
assert_eq!(
rb, fbegin,
"begin mismatch for ({choice}, {pattern}): range={rb} indices={fbegin}"
);
assert_eq!(
re, fend,
"end mismatch for ({choice}, {pattern}): range={re} indices={fend}"
);
}
_ => panic!("range/indices disagreement for ({choice}, {pattern})"),
}
}
}
}
#[test]
fn typo_prefilter_no_false_negative_on_extension() {
let choice = "src/fuzzy_matcher/arinae/algo.rs";
assert!(
score_typos(choice, "fobara").is_some(),
"\"fobara\" should match \"{choice}\""
);
assert!(
score_typos(choice, "fobaral").is_some(),
"\"fobaral\" should match \"{choice}\" (regression: greedy prefilter scan false negative)"
);
}
#[test]
fn use_last_match_prefers_later_occurrence() {
let m = ArinaeMatcher {
use_last_match: true,
..Default::default()
};
let (_, got) = m.fuzzy_indices("man/man1/sk.1", "man").expect("should match");
assert_eq!(got, vec![4, 5, 6], "expected second 'man' (indices 4,5,6), got {got:?}");
}
#[test]
fn no_use_last_match_prefers_first_occurrence() {
let m = ArinaeMatcher::default();
let (_, got) = m.fuzzy_indices("man/man1/sk.1", "man").expect("should match");
assert_eq!(got, vec![0, 1, 2], "expected second 'man' (indices 0,1,2), got {got:?}");
}
#[test]
fn range_with_use_last_match_prefers_later_occurrence() {
let m = ArinaeMatcher {
use_last_match: true,
..Default::default()
};
let (_score, begin, end) = m.fuzzy_match_range("man/man1/sk.1", "man").expect("should match");
assert_eq!(begin, 4);
assert_eq!(end, 6);
}
#[test]
fn range_with_gaps_walks_traceback() {
let m = ArinaeMatcher::default();
let (_score, begin, end) = m.fuzzy_match_range("a_b_c_d_e", "abe").expect("should match");
assert_eq!(begin, 0);
assert_eq!(end, 8);
}
#[test]
fn range_typo_dead_rows_rejects_long_mismatch() {
let m = ArinaeMatcher::new(crate::CaseMatching::Smart, true, false);
assert!(m.fuzzy_match_range("xxxxxxxxxxxxxxxx", "qwerty").is_none());
}
#[test]
fn first_match_inside_brackets_is_highlighted() {
let m = ArinaeMatcher::default();
let (_, got) = m.fuzzy_indices("[paste] some paste", "paste").expect("should match");
assert_eq!(
got,
vec![1, 2, 3, 4, 5],
"expected first 'paste' inside brackets, got {got:?}"
);
for choice in ["(paste) some paste", "{paste} some paste"] {
let (_, got) = m.fuzzy_indices(choice, "paste").expect("should match");
assert_eq!(
got,
vec![1, 2, 3, 4, 5],
"expected first 'paste' for {choice:?}, got {got:?}"
);
}
}
#[test]
fn range_empty_inputs() {
let m = matcher();
assert_eq!(m.fuzzy_match_range("hello", ""), Some((0, 0, 0)));
assert_eq!(m.fuzzy_match_range("", "hello"), None);
}
#[test]
fn range_non_ascii_consistent_with_indices() {
let cases = [
("héllo wörld", "hw"),
("café taverne", "café"),
("naïve élégance", "néé"),
("日本語テキスト", "本テ"),
];
let matchers = [matcher(), matcher_typos()];
for m in &matchers {
for &(choice, pattern) in &cases {
let range = m.fuzzy_match_range(choice, pattern);
let full = m.fuzzy_indices(choice, pattern);
match (range, full) {
(None, None) => {}
(Some((rs, rb, re)), Some((fs, fidx))) => {
assert_eq!(rs, fs, "score mismatch for ({choice}, {pattern})");
assert_eq!(rb, fidx.first().copied().unwrap_or_default());
assert_eq!(re, fidx.last().copied().unwrap_or_default());
}
_ => panic!("range/indices disagreement for ({choice}, {pattern})"),
}
}
}
}
#[test]
fn range_non_ascii_no_match() {
assert_eq!(matcher().fuzzy_match_range("café", "zzz"), None);
assert_eq!(matcher_typos().fuzzy_match_range("日本語", "xyz"), None);
}
#[test]
fn typo_prefilter_rejects_overlong_pattern() {
assert!(matcher_typos().fuzzy_match("ab", "abcdef").is_none());
assert!(matcher_typos().fuzzy_match("éé", "ééçñøü").is_none());
}
#[test]
fn typo_prefilter_single_char_pattern() {
assert!(matcher_typos().fuzzy_match("banana", "a").unwrap() > 0);
assert!(matcher_typos().fuzzy_match("banana", "z").is_none());
}
#[test]
fn ascii_choice_non_ascii_pattern_uses_char_path() {
assert!(matcher().fuzzy_match("hello world", "élé").is_none());
assert!(matcher().fuzzy_match_range("hello world", "élé").is_none());
assert!(matcher().fuzzy_match("clichéless", " é").is_none());
}
#[test]
fn typo_non_ascii_overlong_pattern_rejected() {
assert!(matcher_typos().fuzzy_match("café", "ééééééééé").is_none());
}
#[test]
fn typo_substitution_excluded_from_indices() {
let m = matcher_typos();
let (score, idx) = m.fuzzy_indices("cat", "cot").expect("typo match");
assert!(score > 0);
assert_eq!(idx, vec![0, 2], "substituted position should be excluded");
let (rscore, begin, end) = m.fuzzy_match_range("cat", "cot").expect("typo range match");
assert!(rscore > 0);
assert_eq!((begin, end), (0, 2));
}
#[test]
fn typo_range_dead_rows_after_anchor() {
assert!(matcher_typos().fuzzy_match_range("axxxxxxxx", "aqq").is_none());
}
#[test]
fn full_match_traceback_consumes_pattern() {
let (_, idx) = matcher().fuzzy_indices("abcdef", "abc").expect("prefix match");
assert_eq!(idx, vec![0, 1, 2]);
}
mod kernel {
use thread_local::ThreadLocal;
use super::super::algo::{full_dp, range_dp};
use super::super::banding::BandingInfo;
use super::super::constants::{MATCH_BONUS, MAX_PAT_LEN};
use super::super::helpers::{compute_last_match_cols, compute_row_col_bounds};
use super::ArinaeMatcher;
fn exact_banding(j_first: usize, rows: &[(usize, usize)]) -> BandingInfo {
let mut lo = [0usize; MAX_PAT_LEN];
let mut hi = [0usize; MAX_PAT_LEN];
for (idx, &(l, h)) in rows.iter().enumerate() {
lo[idx] = l;
hi[idx] = h;
}
BandingInfo {
row_bounds: Some((lo, hi)),
j_first,
bandwidth: 0,
min_true_matches: 0,
}
}
fn typo_banding(j_first: usize, bandwidth: usize) -> BandingInfo {
BandingInfo {
row_bounds: None,
j_first,
bandwidth,
min_true_matches: 0,
}
}
#[test]
fn match_slices_empty_pattern_is_trivial_match() {
let m = ArinaeMatcher::default();
assert_eq!(m.match_slices(b"abc", b"", true), Some((0, vec![])));
}
#[test]
fn match_slices_empty_choice_never_matches() {
let m = ArinaeMatcher::default();
assert_eq!(m.match_slices(b"", b"abc", true), None);
}
#[test]
fn compute_last_match_cols_rejects_overlong_pattern() {
let pat = vec![b'a'; MAX_PAT_LEN + 1];
let cho = vec![b'a'; MAX_PAT_LEN + 5];
assert_eq!(compute_last_match_cols(&pat, &cho, false), None);
let pat_ok = vec![b'a'; MAX_PAT_LEN];
assert!(compute_last_match_cols(&pat_ok, &cho, false).is_some());
}
#[test]
fn compute_row_col_bounds_handles_first_match_at_column_one() {
let mut first = [0usize; MAX_PAT_LEN];
let mut last = [0usize; MAX_PAT_LEN];
first[0] = 3;
last[0] = 3;
first[1] = 1;
last[1] = 4;
let (lo, hi) = compute_row_col_bounds(2, 5, &first, &last);
assert!(lo[0] >= 1 && hi[0] >= lo[0] && hi[0] <= 5);
assert!(lo[1] >= 1 && hi[1] >= lo[1] && hi[1] <= 5);
assert_eq!(hi[0], 3, "next_lo == 1 must not widen the previous row");
}
#[test]
fn full_dp_no_match_returns_none() {
let full_buf = ThreadLocal::new();
let indices_buf = ThreadLocal::new();
let cho = b"hello";
let pat = b"qq";
let bonuses = [0i16; 5];
let banding = exact_banding(1, &[(1, 5), (2, 5)]);
let res = full_dp::<false, true, u8>(cho, pat, &bonuses, true, &full_buf, &indices_buf, false, &banding);
assert_eq!(res, None);
}
#[test]
fn full_dp_exact_band_skip_is_infeasible() {
let full_buf = ThreadLocal::new();
let indices_buf = ThreadLocal::new();
let cho = b"abcd";
let pat = b"abcd";
let bonuses = [0i16; 4];
let banding = exact_banding(3, &[(3, 4), (4, 4), (4, 4), (4, 4)]);
let res = full_dp::<false, false, u8>(cho, pat, &bonuses, true, &full_buf, &indices_buf, false, &banding);
assert_eq!(res, None);
}
#[test]
fn full_dp_typo_band_skip_is_infeasible() {
let full_buf = ThreadLocal::new();
let indices_buf = ThreadLocal::new();
let cho = b"abcd";
let pat = b"abcd";
let bonuses = [0i16; 4];
let banding = typo_banding(3, 0);
let res = full_dp::<true, false, u8>(cho, pat, &bonuses, true, &full_buf, &indices_buf, false, &banding);
assert_eq!(res, None);
}
#[test]
fn range_dp_dead_rows_returns_none() {
let full_buf = ThreadLocal::new();
let cho = b"hello";
let pat = b"qq";
let bonuses = [0i16; 5];
let banding = exact_banding(1, &[(1, 5), (1, 5)]);
let res = range_dp::<false, u8>(cho, pat, &bonuses, true, &full_buf, false, &banding);
assert_eq!(res, None);
}
#[test]
fn range_dp_band_skip_middle_row() {
let full_buf = ThreadLocal::new();
let cho = b"abcde";
let pat = b"abc";
let bonuses = [0i16; 5];
let banding = exact_banding(1, &[(1, 5), (5, 2), (1, 5)]);
let res = range_dp::<false, u8>(cho, pat, &bonuses, true, &full_buf, false, &banding);
assert_eq!(res, Some((MATCH_BONUS, 2, 2)));
}
#[test]
fn range_dp_band_skip_two_consecutive_rows() {
let full_buf = ThreadLocal::new();
let cho = b"abcde";
let pat = b"abcd";
let bonuses = [0i16; 5];
let banding = exact_banding(1, &[(1, 5), (5, 2), (5, 2), (1, 5)]);
let res = range_dp::<false, u8>(cho, pat, &bonuses, true, &full_buf, false, &banding);
assert_eq!(res, None);
}
#[test]
fn range_dp_empty_last_row() {
let full_buf = ThreadLocal::new();
let cho = b"ab";
let pat = b"ab";
let bonuses = [0i16; 2];
let banding = exact_banding(1, &[(1, 2), (5, 2)]);
let res = range_dp::<false, u8>(cho, pat, &bonuses, true, &full_buf, false, &banding);
assert_eq!(res, None);
}
}
mod kernel_past_end {
use thread_local::ThreadLocal;
use super::super::algo::{full_dp, range_dp};
use super::super::banding::BandingInfo;
use super::super::constants::MAX_PAT_LEN;
fn exact_banding(j_first: usize, rows: &[(usize, usize)]) -> BandingInfo {
let mut lo = [0usize; MAX_PAT_LEN];
let mut hi = [0usize; MAX_PAT_LEN];
for (idx, &(l, h)) in rows.iter().enumerate() {
lo[idx] = l;
hi[idx] = h;
}
BandingInfo {
row_bounds: Some((lo, hi)),
j_first,
bandwidth: 0,
min_true_matches: 0,
}
}
fn typo_banding(j_first: usize, bandwidth: usize) -> BandingInfo {
BandingInfo {
row_bounds: None,
j_first,
bandwidth,
min_true_matches: 0,
}
}
#[test]
fn full_dp_peek_row_past_choice_end() {
let full_buf = ThreadLocal::new();
let indices_buf = ThreadLocal::new();
let cho = b"abcd"; let pat = b"abcde"; let bonuses = [0i16; 4];
let banding = exact_banding(3, &[(3, 4), (4, 4), (4, 4), (7, 8), (4, 4)]);
let res = full_dp::<false, false, u8>(cho, pat, &bonuses, true, &full_buf, &indices_buf, false, &banding);
assert_eq!(res, None);
}
#[test]
fn range_dp_rows_past_choice_end() {
let full_buf = ThreadLocal::new();
let cho = b"abcde"; let pat = b"abc";
let bonuses = [0i16; 5];
let banding = exact_banding(1, &[(1, 5), (7, 8), (7, 8)]);
let res = range_dp::<false, u8>(cho, pat, &bonuses, true, &full_buf, false, &banding);
assert_eq!(res, None);
}
#[test]
fn range_dp_last_row_past_choice_end() {
let full_buf = ThreadLocal::new();
let cho = b"ab"; let pat = b"ab";
let bonuses = [0i16; 2];
let banding = exact_banding(1, &[(1, 2), (7, 8)]);
let res = range_dp::<false, u8>(cho, pat, &bonuses, true, &full_buf, false, &banding);
assert_eq!(res, None);
}
#[test]
fn range_dp_typo_band_skip() {
let full_buf = ThreadLocal::new();
let cho = b"xxab"; let pat = b"abcd";
let bonuses = [0i16; 4];
let banding = typo_banding(3, 0);
let res = range_dp::<true, u8>(cho, pat, &bonuses, true, &full_buf, false, &banding);
assert_eq!(res, None);
}
}