use std::collections::BTreeSet;
use irgx::{Error, Munch, MunchBuilder, Pick, Refusal, Why};
fn oracle(patterns: &[&str], text: &str, at: usize) -> Option<(usize, Vec<u32>)> {
let tail = &text[at..];
let mut best: Option<usize> = None;
let mut winners = Vec::new();
for (index, pattern) in patterns.iter().enumerate() {
let anchored = regex::Regex::new(&format!("^(?:{pattern})")).unwrap();
let Some(found) = anchored.find(tail) else {
continue;
};
let reach = found.end();
match best {
Some(far) if reach < far => continue,
Some(far) if reach == far => winners.push(index as u32),
_ => {
best = Some(reach);
winners.clear();
winners.push(index as u32);
},
}
}
best.map(|len| (len, winners))
}
fn ours(munch: &Munch, text: &str, at: usize) -> Option<(usize, Vec<u32>)> {
munch
.token(text, at)
.map(|token| (token.len(), token.patterns().to_vec()))
}
const TERMINALS: &[&str] = &[
"if",
"in",
"[a-z_]+",
"[0-9]+",
r"\s+",
">",
">>",
">>=",
"\"[^\"]*\"",
"//[^\n]*",
"\n",
];
const TEXTS: &[&str] = &[
"if x >>= 12",
"iffy in\tinner",
"a >> b > c >>= d",
"\"quoted\" // trailing\nnext",
"12345abc",
"",
"\n\n",
">>>>",
"in",
"\"unterminated",
];
#[test]
fn the_longest_reading_agrees_with_the_regex_crate_at_every_offset() {
let munch = Munch::new(TERMINALS).unwrap();
assert!(munch.declined().is_empty(), "{:?}", munch.declined());
for text in TEXTS {
for at in 0..=text.len() {
assert_eq!(
ours(&munch, text, at),
oracle(TERMINALS, text, at),
"at byte {at} of {text:?}"
);
}
}
}
#[test]
fn a_whole_text_tokenizes_the_same_way_both_engines_read_it() {
let munch = Munch::new(TERMINALS).unwrap();
let text = "if x >>= 12 // done\n\"str\" inner";
let mut mine = Vec::new();
let mut at = 0;
while at < text.len() {
let token = munch
.token(text, at)
.expect("every byte here starts a token");
assert!(!token.is_empty(), "a zero-length token would not advance");
mine.push((at..at + token.len(), token.patterns()[0]));
at += token.len();
}
let mut theirs = Vec::new();
let mut at = 0;
while at < text.len() {
let (len, winners) = oracle(TERMINALS, text, at).expect("as above");
theirs.push((at..at + len, winners[0]));
at += len;
}
assert_eq!(mine, theirs);
assert_eq!(mine.last().unwrap().0.end, text.len());
}
#[test]
fn leftmost_longest_is_this_engine_and_leftmost_first_is_the_regex_crate() {
let munch = Munch::new(["a|ab"]).unwrap();
assert_eq!(munch.token("ab", 0).unwrap().len(), 2);
assert_eq!(
regex::Regex::new("^(?:a|ab)")
.unwrap()
.find("ab")
.unwrap()
.end(),
1
);
}
#[test]
fn a_scan_never_finds_a_token_that_starts_later() {
let munch = Munch::new(["ab"]).unwrap();
assert!(munch.token("xxab", 0).is_none());
assert!(munch.token("xxab", 1).is_none());
assert_eq!(munch.token("xxab", 2).unwrap().len(), 2);
assert_eq!(
irgx::Regex::new("ab")
.unwrap()
.find("xxab")
.unwrap()
.start(),
2
);
}
#[test]
fn the_end_of_the_input_is_a_legal_offset() {
let munch = Munch::new(["a+", "b*"]).unwrap();
let token = munch.token("aa", 2).expect("`b*` accepts the empty string");
assert!(token.is_empty());
assert_eq!(token.patterns(), &[1]);
}
#[test]
fn an_offset_past_the_end_is_an_error_rather_than_a_panic_or_an_answer() {
let munch = Munch::new(["a"]).unwrap();
assert!(matches!(
munch.try_token("ab", 3),
Err(Error::Inconsistent { .. })
));
}
#[test]
fn a_zero_length_token_is_a_result_and_not_an_absence() {
let munch = Munch::new(["a*"]).unwrap();
let token = munch
.token("bbb", 0)
.expect("`a*` accepts the empty string");
assert!(token.is_empty());
assert_eq!(token.range(0), 0..0);
let nothing = Munch::new(["a+"]).unwrap();
assert!(nothing.token("bbb", 0).is_none());
}
#[test]
fn a_tie_is_reported_rather_than_arbitrated() {
let munch = Munch::new(["[a-z]+", "abc", "a.c"]).unwrap();
let token = munch.token("abc", 0).unwrap();
assert_eq!(token.len(), 3);
assert_eq!(token.patterns(), &[0, 1, 2]);
assert_eq!(munch.admitted(), 3);
}
#[test]
fn a_repeated_pattern_is_two_terminals_and_ties_with_itself() {
let munch = Munch::new(["let", "let"]).unwrap();
assert_eq!(munch.token("let", 0).unwrap().patterns(), &[0, 1]);
}
#[test]
fn restricting_the_walk_is_not_filtering_the_answer() {
let munch = Munch::new(["if", "[a-z]+"]).unwrap();
let free = munch.token("iffy", 0).unwrap();
assert_eq!((free.len(), free.patterns()), (4, &[1][..]));
let keyword_only = munch.token_among("iffy", 0, &[0]).unwrap();
assert_eq!((keyword_only.len(), keyword_only.patterns()), (2, &[0][..]));
assert!(
free.patterns().iter().all(|p| *p != 0),
"the unrestricted answer contains nothing to filter down to pattern 0, \
which is why the restriction has to ride the walk"
);
}
#[test]
fn permitting_nothing_answers_instead_of_erroring() {
let munch = Munch::new(["a", "b"]).unwrap();
assert!(munch.token_among("aaa", 0, &[]).is_none());
}
#[test]
fn permitting_an_unknown_or_declined_ordinal_is_a_no_op() {
let munch = Munch::new(["[a-z]+", r"(a)\1"]).unwrap();
assert_eq!(munch.declined().len(), 1, "{:?}", munch.declined());
assert_eq!(
munch.token_among("abc", 0, &[0, 1]).unwrap().patterns(),
&[0]
);
assert!(munch.token_among("abc", 0, &[1]).is_none());
assert!(munch.token_among("abc", 0, &[9_999]).is_none());
}
#[test]
fn a_state_directed_lexer_reads_what_a_free_one_cannot() {
let munch = Munch::new(["in", "[a-z]+"]).unwrap();
const KEYWORD: &[u32] = &[0];
const IDENTIFIER: &[u32] = &[1];
let as_keyword = munch.token_among("inx", 0, KEYWORD).unwrap();
assert_eq!((as_keyword.len(), as_keyword.patterns()), (2, &[0][..]));
let as_name = munch.token_among("inx", 0, IDENTIFIER).unwrap();
assert_eq!((as_name.len(), as_name.patterns()), (3, &[1][..]));
}
#[test]
fn shortest_is_the_other_reading_of_the_same_offset() {
let munch = Munch::new(["[a-z]", "[a-z]+"]).unwrap();
let greedy = munch.token("abc", 0).unwrap();
assert_eq!((greedy.len(), greedy.patterns()), (3, &[1][..]));
let brief = munch.shortest_among("abc", 0, &[0, 1]).unwrap();
assert_eq!((brief.len(), brief.patterns()), (1, &[0, 1][..]));
}
#[test]
fn one_undeterminizable_terminal_does_not_cost_the_others() {
let munch = Munch::new(["[a-z]+", r"(a)\1", "[0-9]+", r"(?<=x)y"]).unwrap();
let declined: BTreeSet<usize> = munch.declined().iter().map(|r| r.pattern).collect();
assert_eq!(declined, BTreeSet::from([1, 3]));
assert!(
munch.declined().iter().all(|r| r.why == Why::Syntax),
"{:?}",
munch.declined()
);
assert_eq!(munch.token("123", 0).unwrap().patterns(), &[2]);
assert_eq!(munch.token("abc", 0).unwrap().patterns(), &[0]);
assert_eq!(munch.len(), 4);
assert_eq!(munch.admitted(), 2);
assert_eq!(munch.patterns().len(), 4);
assert_eq!(munch.patterns()[1], r"(a)\1");
}
#[test]
fn a_slate_that_takes_everything_declines_nothing() {
let munch = Munch::new(["a", "b+", "[cd]"]).unwrap();
assert!(munch.declined().is_empty());
assert_eq!(munch.admitted(), munch.len());
}
#[test]
fn losing_every_pattern_is_the_one_refusal_this_plane_has() {
let refused = Munch::new([r"(a)\1", r"(?<=x)y"]).unwrap_err();
assert_eq!(refused, Error::NothingLexable { offered: 2 });
assert!(refused.to_string().contains("2 patterns"));
}
#[test]
fn a_malformed_pattern_alone_is_a_total_refusal_and_not_a_panic() {
assert!(matches!(
Munch::new(["(unclosed"]),
Err(Error::NothingLexable { offered: 1 })
));
}
#[test]
fn an_empty_slate_is_a_working_slate_that_matches_nothing() {
let munch = Munch::new(Vec::<&str>::new()).unwrap();
assert!(munch.is_empty());
assert_eq!(munch.admitted(), 0);
assert!(munch.declined().is_empty());
assert!(munch.token("anything", 0).is_none());
assert!(munch.token("", 0).is_none());
}
#[test]
fn the_flags_this_plane_honors_change_what_a_terminal_means() {
assert!(Munch::new(["abc"]).unwrap().token("ABC", 0).is_none());
let folded = MunchBuilder::new(["abc"])
.ignore_case(true)
.build()
.unwrap();
assert_eq!(folded.token("ABC", 0).unwrap().len(), 3);
let strict = MunchBuilder::new(["a.b"]).build().unwrap();
assert!(strict.token("a\nb", 0).is_none());
let loose = MunchBuilder::new(["a.b"])
.dot_matches_new_line(true)
.build()
.unwrap();
assert_eq!(loose.token("a\nb", 0).unwrap().len(), 3);
}
#[test]
fn a_whitespace_terminal_can_match_a_line_break_with_no_flag_at_all() {
for pattern in [r"\s+", "[ \t\n]+", "[^x]+"] {
let munch = Munch::new([pattern]).unwrap();
for text in ["\n", " \n ", "\t\n\t"] {
let token = munch
.token(text, 0)
.unwrap_or_else(|| panic!("{pattern:?} found nothing in {text:?}"));
assert_eq!(token.len(), text.len(), "{pattern:?} over {text:?}");
}
}
}
#[test]
fn an_anchor_is_the_scan_offset_which_is_why_there_is_no_multi_line_knob() {
let munch = Munch::new(["^b", "a$"]).unwrap();
assert!(munch.declined().is_empty(), "{:?}", munch.declined());
assert_eq!(munch.token_among("a\nb", 2, &[0]).unwrap().len(), 1);
assert!(munch.token_among("a\nb", 0, &[1]).is_none());
}
#[test]
fn a_buffer_anchor_is_refused_as_a_wall_and_not_as_a_budget() {
let munch = Munch::new(["b", r"\Ab", r"a\z"]).unwrap();
assert_eq!(
munch.declined(),
[
Refusal {
pattern: 1,
why: Why::BufferAnchor
},
Refusal {
pattern: 2,
why: Why::BufferAnchor
},
]
);
assert_eq!(munch.admitted(), 1);
assert_eq!(munch.token("b", 0).unwrap().len(), 1);
}
#[test]
fn unicode_off_matches_bytes() {
let bytes = MunchBuilder::new(["."]).unicode(false).build().unwrap();
assert_eq!(bytes.token("é", 0).unwrap().len(), 1);
assert_eq!(Munch::new(["."]).unwrap().token("é", 0).unwrap().len(), 2);
}
#[test]
fn scan_into_answers_exactly_what_the_owning_form_does() {
let munch = Munch::new(TERMINALS).unwrap();
let mut winners = Vec::with_capacity(munch.admitted());
for text in TEXTS {
for at in 0..=text.len() {
let raw = munch
.scan_into(text, at, None, Pick::Longest, &mut winners)
.unwrap();
assert_eq!(
raw.map(|len| (len, winners.clone())),
ours(&munch, text, at),
"at byte {at} of {text:?}"
);
}
}
}
#[test]
fn scan_into_clears_the_buffer_even_when_nothing_matches() {
let munch = Munch::new(["ab"]).unwrap();
let mut winners = vec![7, 7, 7];
assert_eq!(
munch
.scan_into("ab", 0, None, Pick::Longest, &mut winners)
.unwrap(),
Some(2)
);
assert_eq!(winners, vec![0]);
assert_eq!(
munch
.scan_into("zz", 0, None, Pick::Longest, &mut winners)
.unwrap(),
None
);
assert!(winners.is_empty());
}
#[test]
fn scan_into_leaves_the_buffer_empty_on_an_error_too() {
let munch = Munch::new(["a"]).unwrap();
let mut winners = vec![7];
assert!(
munch
.scan_into("a", 9, None, Pick::Longest, &mut winners)
.is_err()
);
assert!(winners.is_empty());
}
#[test]
fn scan_into_takes_the_restriction_and_the_pick_like_its_wrappers() {
let munch = Munch::new(["if", "[a-z]+"]).unwrap();
let mut winners = Vec::new();
assert_eq!(
munch
.scan_into("iffy", 0, Some(&[0]), Pick::Longest, &mut winners)
.unwrap(),
Some(2)
);
assert_eq!(winners, vec![0]);
assert_eq!(
munch
.scan_into("iffy", 0, Some(&[0, 1]), Pick::Shortest, &mut winners)
.unwrap(),
Some(1)
);
assert_eq!(winners, vec![1]);
}
#[test]
fn a_clone_reads_the_same_way_the_original_does() {
let munch = MunchBuilder::new(["abc", r"(a)\1"])
.ignore_case(true)
.build()
.unwrap();
let copy = munch.clone();
assert_eq!(copy.patterns(), munch.patterns());
assert_eq!(copy.declined(), munch.declined());
assert_eq!(copy.admitted(), munch.admitted());
assert_eq!(copy.token("ABC", 0).unwrap().len(), 3);
}
#[test]
fn many_threads_scanning_one_munch_read_what_one_thread_does() {
let munch = Munch::new(TERMINALS).unwrap();
let alone: Vec<_> = TEXTS
.iter()
.map(|text| {
(0..=text.len())
.map(|at| ours(&munch, text, at))
.collect::<Vec<_>>()
})
.collect();
let together: Vec<Vec<_>> = std::thread::scope(|scope| {
let handles: Vec<_> = TEXTS
.iter()
.map(|text| {
let munch = &munch;
scope.spawn(move || {
(0..64)
.map(|_| {
(0..=text.len())
.map(|at| ours(munch, text, at))
.collect::<Vec<_>>()
})
.last()
.unwrap()
})
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
assert_eq!(together, alone);
}
#[test]
fn a_munch_is_send_and_sync_so_it_can_live_in_a_static() {
const fn assert_shareable<T: Send + Sync>() {}
assert_shareable::<Munch>();
static OPERATORS: std::sync::LazyLock<Munch> =
std::sync::LazyLock::new(|| Munch::new([">", ">>", ">>="]).unwrap());
assert_eq!(OPERATORS.token(">>=", 0).unwrap().patterns(), &[2]);
}
#[test]
fn debug_names_the_patterns_and_what_was_lost() {
let munch = Munch::new(["a", r"(a)\1"]).unwrap();
let rendered = format!("{munch:?}");
assert!(rendered.contains("Munch"), "{rendered}");
assert!(rendered.contains(r#""(a)\\1""#), "{rendered}");
assert!(rendered.contains("Syntax"), "{rendered}");
}