use std::{
any::Any,
borrow::Cow,
collections::BTreeSet,
str::{from_utf8, from_utf8_unchecked},
sync::Arc,
};
use unicode_segmentation::UnicodeSegmentation;
use wide::u8x16;
use super::reader::BoolMode;
const NON_ASCII_BYTE_MIN: u8 = 0x80;
const LANE_BITMASK: u32 = 0xFFFF;
const TOKEN_SCRATCH_INITIAL_CAP: usize = 32;
pub trait Tokenizer: Send + Sync + std::fmt::Debug + 'static {
fn name(&self) -> &'static str;
fn tokenize<'a>(&'a self, text: &'a str) -> Box<dyn Iterator<Item = String> + 'a>;
fn tokenize_each(&self, text: &str, f: &mut dyn FnMut(&str)) {
for s in self.tokenize(text) {
f(&s);
}
}
fn as_any(&self) -> &dyn Any;
fn tokenize_each_query<'q>(&self, text: &'q str, f: &mut dyn FnMut(Cow<'q, str>)) {
self.tokenize_each(text, &mut |t| f(Cow::Owned(t.to_owned())));
}
fn parse<'q>(&self, query: &'q str) -> ParsedQuery<'q> {
let mut parsed = ParsedQuery::default();
let bytes = query.as_bytes();
let mut i = 0usize;
let mut seg_start = 0usize;
while i < bytes.len() {
if bytes[i] != b'"' {
i += 1;
continue;
}
let Some(close_rel) = query[i + 1..].find('"') else {
self.parse_unquoted_segment(&query[seg_start..i], &mut parsed);
i += 1;
seg_start = i;
continue;
};
let close = i + 1 + close_rel;
let sigil = match i > seg_start {
true => {
let boundary = i - 1 == seg_start || bytes[i - 2].is_ascii_whitespace();
match (boundary, bytes[i - 1]) {
(true, b'+') => Some(b'+'),
(true, b'-') => Some(b'-'),
_ => None,
}
}
false => None,
};
let unquoted_end = match sigil {
Some(_) => i - 1,
None => i,
};
self.parse_unquoted_segment(&query[seg_start..unquoted_end], &mut parsed);
let mut terms: Vec<Cow<'q, str>> = Vec::new();
self.tokenize_each_query(&query[i + 1..close], &mut |t| terms.push(t));
match (terms.len(), sigil) {
(0, _) => {}
(1, Some(b'-')) => parsed.negatives.push(terms.pop().expect("one term")),
(1, Some(b'+')) => parsed.musts.push(terms.pop().expect("one term")),
(1, _) => parsed.positives.push(terms.pop().expect("one term")),
(_, Some(b'-')) => parsed.negative_phrases.push(terms),
(_, Some(b'+')) => parsed.must_phrases.push(terms),
(_, _) => parsed.positive_phrases.push(terms),
}
i = close + 1;
seg_start = i;
}
self.parse_unquoted_segment(&query[seg_start..], &mut parsed);
parsed
}
fn parse_unquoted_segment<'q>(&self, segment: &'q str, parsed: &mut ParsedQuery<'q>) {
for run in segment.split_whitespace() {
match (run.strip_prefix('-'), run.strip_prefix('+')) {
(Some(rest), _) if !rest.is_empty() => {
self.tokenize_each_query(rest, &mut |t| parsed.negatives.push(t));
}
(_, Some(rest)) if !rest.is_empty() => {
self.tokenize_each_query(rest, &mut |t| parsed.musts.push(t));
}
_ => self.tokenize_each_query(run, &mut |t| parsed.positives.push(t)),
}
}
}
}
pub(crate) fn unique_tokens<'a>(
tok: &dyn Tokenizer,
texts: impl IntoIterator<Item = &'a str>,
) -> Vec<String> {
texts
.into_iter()
.flat_map(|t| tok.tokenize(t))
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
#[derive(Debug, Clone, Copy, Default)]
pub struct AsciiLowerTokenizer;
impl AsciiLowerTokenizer {
pub fn new() -> Self {
Self
}
#[inline]
pub fn tokenize_each_inline<F: FnMut(&str)>(&self, text: &str, mut f: F) {
self.tokenize_each_inline_positioned(text, |tok, _position| f(tok));
}
#[inline]
pub fn tokenize_each_inline_positioned<F: FnMut(&str, u64)>(&self, text: &str, mut f: F) {
let bytes = text.as_bytes();
let mut buf: Vec<u8> = Vec::new();
let mut pos = 0;
let mut position: u64 = 0;
while pos < bytes.len() {
pos = simd_skip_non_token(bytes, pos);
if pos >= bytes.len() {
return;
}
let start = pos;
let (end, had_upper, had_non_ascii) = simd_scan_token_run(bytes, pos);
pos = end;
if start == pos {
continue;
}
let this_position = position;
position += 1;
if had_non_ascii {
continue;
}
if !had_upper {
let s = unsafe { from_utf8_unchecked(&bytes[start..end]) };
f(s, this_position);
} else {
buf.clear();
buf.reserve(end - start);
for &b in &bytes[start..end] {
buf.push(b.to_ascii_lowercase());
}
let s = unsafe { from_utf8_unchecked(&buf) };
f(s, this_position);
}
}
}
}
#[inline(always)]
fn simd_skip_non_token(bytes: &[u8], mut pos: usize) -> usize {
const LANES: usize = 16;
while pos + LANES <= bytes.len() {
let arr: [u8; LANES] = unsafe { *(bytes.as_ptr().add(pos) as *const [u8; LANES]) };
let chunk = u8x16::from(arr);
let is_digit = chunk.simd_ge(u8x16::splat(b'0')) & chunk.simd_le(u8x16::splat(b'9'));
let is_upper = chunk.simd_ge(u8x16::splat(b'A')) & chunk.simd_le(u8x16::splat(b'Z'));
let is_lower = chunk.simd_ge(u8x16::splat(b'a')) & chunk.simd_le(u8x16::splat(b'z'));
let is_token = is_digit | is_upper | is_lower;
let mask = is_token.to_bitmask() & LANE_BITMASK;
if mask == 0 {
pos += LANES;
} else {
return pos + mask.trailing_zeros() as usize;
}
}
while pos < bytes.len() && !bytes[pos].is_ascii_alphanumeric() {
pos += 1;
}
pos
}
#[inline(always)]
fn simd_scan_token_run(bytes: &[u8], mut pos: usize) -> (usize, bool, bool) {
const LANES: usize = 16;
let mut had_upper = false;
let mut had_non_ascii = false;
while pos + LANES <= bytes.len() {
let arr: [u8; LANES] = unsafe { *(bytes.as_ptr().add(pos) as *const [u8; LANES]) };
let chunk = u8x16::from(arr);
let is_digit = chunk.simd_ge(u8x16::splat(b'0')) & chunk.simd_le(u8x16::splat(b'9'));
let is_upper = chunk.simd_ge(u8x16::splat(b'A')) & chunk.simd_le(u8x16::splat(b'Z'));
let is_lower = chunk.simd_ge(u8x16::splat(b'a')) & chunk.simd_le(u8x16::splat(b'z'));
let is_high =
(chunk & u8x16::splat(NON_ASCII_BYTE_MIN)).simd_eq(u8x16::splat(NON_ASCII_BYTE_MIN));
let is_token = is_digit | is_upper | is_lower;
let is_extend = is_token | is_high;
let extend_mask = is_extend.to_bitmask() & LANE_BITMASK;
let upper_mask = is_upper.to_bitmask() & LANE_BITMASK;
let high_mask = is_high.to_bitmask() & LANE_BITMASK;
let non_extend = !extend_mask & LANE_BITMASK;
if non_extend == 0 {
had_upper |= upper_mask != 0;
had_non_ascii |= high_mask != 0;
pos += LANES;
} else {
let sep_idx = non_extend.trailing_zeros() as usize;
let prefix_mask: u32 = (1u32 << sep_idx).wrapping_sub(1);
had_upper |= (upper_mask & prefix_mask) != 0;
had_non_ascii |= (high_mask & prefix_mask) != 0;
pos += sep_idx;
return (pos, had_upper, had_non_ascii);
}
}
while pos < bytes.len() {
let b = bytes[pos];
if is_token_byte(b) {
had_upper |= b.is_ascii_uppercase();
pos += 1;
} else if b >= NON_ASCII_BYTE_MIN {
had_non_ascii = true;
pos += 1;
} else {
break;
}
}
(pos, had_upper, had_non_ascii)
}
#[derive(Debug, Default)]
pub struct ParsedQuery<'q> {
pub musts: Vec<Cow<'q, str>>,
pub positives: Vec<Cow<'q, str>>,
pub negatives: Vec<Cow<'q, str>>,
pub must_phrases: Vec<Vec<Cow<'q, str>>>,
pub positive_phrases: Vec<Vec<Cow<'q, str>>>,
pub negative_phrases: Vec<Vec<Cow<'q, str>>>,
}
#[derive(Debug, Default)]
pub struct QueryClauses<'q> {
pub musts: Vec<Cow<'q, str>>,
pub shoulds: Vec<Cow<'q, str>>,
pub negatives: Vec<Cow<'q, str>>,
pub must_phrases: Vec<Vec<Cow<'q, str>>>,
pub should_phrases: Vec<Vec<Cow<'q, str>>>,
pub negative_phrases: Vec<Vec<Cow<'q, str>>>,
}
impl<'q> ParsedQuery<'q> {
pub fn into_clauses(self, mode: BoolMode) -> QueryClauses<'q> {
let ParsedQuery {
mut musts,
positives,
negatives,
mut must_phrases,
positive_phrases,
negative_phrases,
} = self;
let shoulds = match mode {
BoolMode::And => {
musts.extend(positives);
Vec::new()
}
BoolMode::Or => positives,
};
let should_phrases = match mode {
BoolMode::And => {
must_phrases.extend(positive_phrases);
Vec::new()
}
BoolMode::Or => positive_phrases,
};
QueryClauses {
musts,
shoulds,
negatives,
must_phrases,
should_phrases,
negative_phrases,
}
}
}
impl Tokenizer for AsciiLowerTokenizer {
fn name(&self) -> &'static str {
ASCII_LOWER_TOKENIZER
}
fn tokenize<'a>(&'a self, text: &'a str) -> Box<dyn Iterator<Item = String> + 'a> {
Box::new(AsciiLowerIter::new(text.as_bytes()))
}
fn tokenize_each(&self, text: &str, f: &mut dyn FnMut(&str)) {
self.tokenize_each_inline(text, |s| f(s));
}
fn as_any(&self) -> &dyn Any {
self
}
fn tokenize_each_query<'q>(&self, text: &'q str, f: &mut dyn FnMut(Cow<'q, str>)) {
let bytes = text.as_bytes();
let mut pos = 0;
while pos < bytes.len() {
pos = simd_skip_non_token(bytes, pos);
if pos >= bytes.len() {
return;
}
let start = pos;
let (end, had_upper, had_non_ascii) = simd_scan_token_run(bytes, pos);
pos = end;
if had_non_ascii || start == pos {
continue;
}
let s = from_utf8(&bytes[start..end]).expect("ASCII-only by construction");
if had_upper {
f(Cow::Owned(s.to_ascii_lowercase()));
} else {
f(Cow::Borrowed(s));
}
}
}
}
struct AsciiLowerIter<'a> {
src: &'a [u8],
pos: usize,
buf: Vec<u8>,
}
impl<'a> AsciiLowerIter<'a> {
fn new(src: &'a [u8]) -> Self {
Self {
src,
pos: 0,
buf: Vec::with_capacity(TOKEN_SCRATCH_INITIAL_CAP),
}
}
}
impl Iterator for AsciiLowerIter<'_> {
type Item = String;
fn next(&mut self) -> Option<String> {
loop {
while self.pos < self.src.len() && !is_token_byte(self.src[self.pos]) {
self.pos += 1;
}
if self.pos >= self.src.len() {
return None;
}
self.buf.clear();
let mut had_non_ascii = false;
while self.pos < self.src.len() {
let b = self.src[self.pos];
if is_token_byte(b) {
self.buf.push(b.to_ascii_lowercase());
self.pos += 1;
} else if b >= NON_ASCII_BYTE_MIN {
had_non_ascii = true;
self.pos += 1;
} else {
break;
}
}
if had_non_ascii || self.buf.is_empty() {
continue;
}
let s = from_utf8(&self.buf)
.expect("ASCII-only by construction")
.to_owned();
return Some(s);
}
}
}
#[inline]
fn is_token_byte(b: u8) -> bool {
b.is_ascii_alphanumeric()
}
pub const ASCII_LOWER_TOKENIZER: &str = "ascii_lower";
pub const STANDARD_TOKENIZER: &str = "standard";
pub fn tokenizer_for_name(name: &str) -> Option<Arc<dyn Tokenizer>> {
match name {
ASCII_LOWER_TOKENIZER => Some(Arc::new(AsciiLowerTokenizer)),
STANDARD_TOKENIZER => Some(Arc::new(StandardTokenizer)),
_ => None,
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct StandardTokenizer;
impl StandardTokenizer {
pub fn new() -> Self {
Self
}
}
impl Tokenizer for StandardTokenizer {
fn name(&self) -> &'static str {
STANDARD_TOKENIZER
}
fn tokenize<'a>(&'a self, text: &'a str) -> Box<dyn Iterator<Item = String> + 'a> {
Box::new(text.unicode_words().map(str::to_lowercase))
}
fn tokenize_each(&self, text: &str, f: &mut dyn FnMut(&str)) {
let mut buf = String::new();
for word in text.unicode_words() {
if word.chars().all(|c| !c.is_alphabetic() || c.is_lowercase()) {
f(word);
} else {
buf.clear();
buf.push_str(&word.to_lowercase());
f(&buf);
}
}
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tokens(text: &str) -> Vec<String> {
AsciiLowerTokenizer.tokenize(text).collect()
}
fn positioned(text: &str) -> Vec<(String, u64)> {
let mut out = Vec::new();
AsciiLowerTokenizer
.tokenize_each_inline_positioned(text, |tok, pos| out.push((tok.to_owned(), pos)));
out
}
fn std_tokens(text: &str) -> Vec<String> {
StandardTokenizer.tokenize(text).collect()
}
fn std_tokens_each(text: &str) -> Vec<String> {
let mut out = Vec::new();
StandardTokenizer.tokenize_each(text, &mut |t| out.push(t.to_owned()));
out
}
#[test]
fn standard_lowercases_and_splits_ascii() {
assert_eq!(
std_tokens("Rust Async Runtime"),
vec!["rust", "async", "runtime"]
);
assert_eq!(
std_tokens_each("Rust Async Runtime"),
vec!["rust", "async", "runtime"]
);
}
#[test]
fn standard_keeps_non_ascii_lowercased() {
assert_eq!(std_tokens("Café RÉSUMÉ"), vec!["café", "résumé"]);
assert_eq!(std_tokens_each("Café RÉSUMÉ"), vec!["café", "résumé"]);
assert_eq!(
AsciiLowerTokenizer
.tokenize("Café RÉSUMÉ")
.collect::<Vec<_>>(),
Vec::<String>::new()
);
}
#[test]
fn standard_splits_cjk_per_ideograph() {
assert_eq!(std_tokens("日本語"), vec!["日", "本", "語"]);
}
#[test]
fn standard_keeps_intra_word_numeric_and_apostrophe() {
assert_eq!(std_tokens("pi is 3.14"), vec!["pi", "is", "3.14"]);
assert_eq!(std_tokens("don't stop"), vec!["don't", "stop"]);
}
#[test]
fn standard_splits_on_hyphen_and_drops_punctuation() {
assert_eq!(std_tokens("wi-fi, hello!"), vec!["wi", "fi", "hello"]);
assert_eq!(std_tokens("... ???"), Vec::<String>::new());
}
#[test]
fn standard_borrow_and_copy_paths_agree() {
let text = "alpha Beta 42 gamma2 Δelta";
assert_eq!(std_tokens(text), std_tokens_each(text));
}
#[test]
fn standard_copy_path_lowercases_final_sigma_like_tokenize() {
let text = "ΟΔΟΣ"; assert_eq!(std_tokens(text), std_tokens_each(text));
assert_eq!(std_tokens_each(text), vec![text.to_lowercase()]);
assert!(
std_tokens_each(text)[0].ends_with('ς'),
"word-final Σ must fold to final sigma ς, not σ"
);
}
#[test]
fn standard_empty_and_whitespace_yield_nothing() {
assert_eq!(std_tokens(""), Vec::<String>::new());
assert_eq!(std_tokens(" \t\n"), Vec::<String>::new());
}
#[test]
fn standard_query_parse_keeps_non_ascii_and_sigils() {
let p = StandardTokenizer.parse("Café -Résumé +Ötzi");
assert_eq!(p.positives, vec!["café"]);
assert_eq!(p.negatives, vec!["résumé"]);
assert_eq!(p.musts, vec!["ötzi"]);
}
#[test]
fn tokenizer_for_name_resolves_known_and_rejects_unknown() {
assert!(tokenizer_for_name(ASCII_LOWER_TOKENIZER).is_some());
assert!(tokenizer_for_name(STANDARD_TOKENIZER).is_some());
assert!(tokenizer_for_name("nonesuch").is_none());
let tok = tokenizer_for_name(STANDARD_TOKENIZER).expect("standard");
assert_eq!(tok.tokenize("Café").collect::<Vec<_>>(), vec!["café"]);
}
#[test]
fn positioned_leaves_a_gap_for_dropped_runs() {
assert_eq!(
positioned("the quick brown fox"),
vec![
("the".into(), 0),
("quick".into(), 1),
("brown".into(), 2),
("fox".into(), 3),
],
);
assert_eq!(
positioned("new café york"),
vec![("new".into(), 0), ("york".into(), 2)],
);
assert_eq!(
positioned("café new york"),
vec![("new".into(), 1), ("york".into(), 2)],
);
assert_eq!(
positioned("new york café"),
vec![("new".into(), 0), ("york".into(), 1)],
);
let mut plain = Vec::new();
AsciiLowerTokenizer.tokenize_each_inline("new café york", |t| plain.push(t.to_owned()));
assert_eq!(plain, vec!["new".to_string(), "york".to_string()]);
}
#[test]
fn empty_input_yields_nothing() {
assert_eq!(tokens(""), Vec::<String>::new());
}
#[test]
fn whitespace_only_yields_nothing() {
assert_eq!(tokens(" \t\n\r"), Vec::<String>::new());
}
#[test]
fn single_token_lowercased() {
assert_eq!(tokens("Hello"), vec!["hello"]);
}
#[test]
fn unique_tokens_dedups_and_sorts_across_values() {
let tok = AsciiLowerTokenizer;
let got = unique_tokens(&tok, ["Orange Juice", "Apple Juice"]);
assert_eq!(got, vec!["apple", "juice", "orange"]);
}
#[test]
fn multiple_tokens_split_on_whitespace() {
assert_eq!(
tokens("Rust async runtime"),
vec!["rust", "async", "runtime"]
);
}
#[test]
fn punctuation_splits_tokens() {
assert_eq!(
tokens("hello,world!foo;bar.baz?"),
vec!["hello", "world", "foo", "bar", "baz"]
);
}
#[test]
fn case_folding_applies_to_uppercase_only() {
assert_eq!(tokens("ABC abc XyZ"), vec!["abc", "abc", "xyz"]);
}
#[test]
fn alphanumerics_kept_together() {
assert_eq!(tokens("foo123 bar456"), vec!["foo123", "bar456"]);
}
#[test]
fn pure_numeric_tokens_kept() {
assert_eq!(tokens("404 200 500"), vec!["404", "200", "500"]);
}
#[test]
fn underscore_is_a_separator_in_v1() {
assert_eq!(tokens("foo_bar"), vec!["foo", "bar"]);
}
#[test]
fn dash_is_a_separator() {
assert_eq!(tokens("rust-async"), vec!["rust", "async"]);
}
#[test]
fn non_ascii_token_is_dropped() {
assert_eq!(tokens("café"), Vec::<String>::new());
}
#[test]
fn non_ascii_token_drops_only_that_token() {
assert_eq!(tokens("hello café world"), vec!["hello", "world"]);
}
#[test]
fn cjk_input_yields_nothing() {
assert_eq!(tokens("日本語"), Vec::<String>::new());
}
#[test]
fn emoji_input_yields_nothing() {
assert_eq!(tokens("hello 🚀 world"), vec!["hello", "world"]);
}
#[test]
fn multiple_consecutive_separators_are_collapsed() {
assert_eq!(tokens("foo,,,bar"), vec!["foo", "bar"]);
assert_eq!(tokens("foo bar"), vec!["foo", "bar"]);
}
#[test]
fn leading_and_trailing_separators_are_skipped() {
assert_eq!(tokens(" foo bar "), vec!["foo", "bar"]);
assert_eq!(tokens("...foo..."), vec!["foo"]);
}
#[test]
fn tokenizer_is_send_and_sync() {
fn is_send_sync<T: Send + Sync>() {}
is_send_sync::<AsciiLowerTokenizer>();
}
#[test]
fn tokenizer_used_via_dyn_trait() {
let tok: Box<dyn Tokenizer> = Box::new(AsciiLowerTokenizer);
let v: Vec<String> = tok.tokenize("Hello WORLD").collect();
assert_eq!(v, vec!["hello", "world"]);
}
#[test]
fn stress_long_input_does_not_panic() {
let chunk = "lorem ipsum dolor sit amet, consectetur adipiscing elit. ";
let big = chunk.repeat(20_000);
let count = AsciiLowerTokenizer.tokenize(&big).count();
assert_eq!(count, 8 * 20_000);
}
fn parse(query: &str) -> ParsedQuery<'_> {
AsciiLowerTokenizer.parse(query)
}
#[test]
fn parse_default_trait_impl_matches_override() {
#[derive(Debug)]
struct PlainTok;
impl Tokenizer for PlainTok {
fn name(&self) -> &'static str {
"plain_test"
}
fn tokenize<'a>(&'a self, text: &'a str) -> Box<dyn Iterator<Item = String> + 'a> {
AsciiLowerTokenizer.tokenize(text)
}
fn as_any(&self) -> &dyn Any {
self
}
}
let p = PlainTok.parse("Rust -PYTHON");
assert_eq!(p.positives, vec!["rust"]);
assert_eq!(p.negatives, vec!["python"]);
assert!(matches!(p.positives[0], Cow::Owned(_)));
}
#[test]
fn parse_positives_only() {
let p = parse("rust async");
assert_eq!(p.positives, vec!["rust", "async"]);
assert!(p.negatives.is_empty());
}
#[test]
fn parse_single_negative() {
let p = parse("rust -python");
assert_eq!(p.positives, vec!["rust"]);
assert_eq!(p.negatives, vec!["python"]);
}
#[test]
fn parse_multiple_negatives() {
let p = parse("rust async -python -php");
assert_eq!(p.positives, vec!["rust", "async"]);
assert_eq!(p.negatives, vec!["python", "php"]);
}
#[test]
fn parse_negation_only() {
let p = parse("-python");
assert!(p.positives.is_empty());
assert_eq!(p.negatives, vec!["python"]);
}
#[test]
fn parse_interior_hyphen_is_not_negation() {
let p = parse("a-b");
assert_eq!(p.positives, vec!["a", "b"]);
assert!(p.negatives.is_empty());
}
#[test]
fn parse_bare_dash_contributes_nothing() {
let p = parse("rust - python");
assert_eq!(p.positives, vec!["rust", "python"]);
assert!(p.negatives.is_empty());
}
#[test]
fn parse_double_dash_strips_one_then_tokenizes() {
let p = parse("--py");
assert!(p.positives.is_empty());
assert_eq!(p.negatives, vec!["py"]);
}
#[test]
fn parse_negated_term_is_normalized() {
let p = parse("rust -PYTHON");
assert_eq!(p.negatives, vec!["python"]);
}
#[test]
fn parse_empty_query() {
let p = parse("");
assert!(p.musts.is_empty());
assert!(p.positives.is_empty());
assert!(p.negatives.is_empty());
}
#[test]
fn parse_must_sigil() {
let p = parse("+climate policy");
assert_eq!(p.musts, vec!["climate"]);
assert_eq!(p.positives, vec!["policy"]);
assert!(p.negatives.is_empty());
}
#[test]
fn parse_all_must() {
let p = parse("+griffith +observatory");
assert_eq!(p.musts, vec!["griffith", "observatory"]);
assert!(p.positives.is_empty());
}
#[test]
fn parse_must_with_negation() {
let p = parse("+python -snake -monty");
assert_eq!(p.musts, vec!["python"]);
assert!(p.positives.is_empty());
assert_eq!(p.negatives, vec!["snake", "monty"]);
}
#[test]
fn parse_interior_plus_is_not_must() {
let p = parse("a+b");
assert!(p.musts.is_empty());
assert_eq!(p.positives, vec!["a", "b"]);
}
#[test]
fn parse_bare_plus_contributes_nothing() {
let p = parse("rust + python");
assert!(p.musts.is_empty());
assert_eq!(p.positives, vec!["rust", "python"]);
}
#[test]
fn parse_must_term_is_normalized() {
let p = parse("+RUST async");
assert_eq!(p.musts, vec!["rust"]);
assert_eq!(p.positives, vec!["async"]);
}
#[test]
fn parse_minus_wins_over_plus_ordering() {
let p = parse("-+x");
assert_eq!(p.negatives, vec!["x"]);
let p = parse("+-x");
assert_eq!(p.musts, vec!["x"]);
}
#[test]
fn parse_pure_phrase() {
let p = parse(r#""griffith observatory""#);
assert_eq!(p.positive_phrases, vec![vec!["griffith", "observatory"]]);
assert!(p.positives.is_empty());
assert!(p.musts.is_empty());
}
#[test]
fn parse_phrase_polarities() {
let p = parse(r#"+"the who" -"memory unsafe" "new york""#);
assert_eq!(p.must_phrases, vec![vec!["the", "who"]]);
assert_eq!(p.negative_phrases, vec![vec!["memory", "unsafe"]]);
assert_eq!(p.positive_phrases, vec![vec!["new", "york"]]);
}
#[test]
fn parse_phrase_mixes_with_terms() {
let p = parse(r#"+"the who" +uk rust -python"#);
assert_eq!(p.must_phrases, vec![vec!["the", "who"]]);
assert_eq!(p.musts, vec!["uk"]);
assert_eq!(p.positives, vec!["rust"]);
assert_eq!(p.negatives, vec!["python"]);
}
#[test]
fn parse_single_token_phrase_degrades_to_term() {
let p = parse(r#""york" +"london" -"paris""#);
assert!(p.positive_phrases.is_empty());
assert!(p.must_phrases.is_empty());
assert!(p.negative_phrases.is_empty());
assert_eq!(p.positives, vec!["york"]);
assert_eq!(p.musts, vec!["london"]);
assert_eq!(p.negatives, vec!["paris"]);
}
#[test]
fn parse_empty_quotes_contribute_nothing() {
let p = parse(r#"rust "" async"#);
assert_eq!(p.positives, vec!["rust", "async"]);
assert!(p.positive_phrases.is_empty());
}
#[test]
fn parse_unbalanced_quote_is_whitespace() {
let p = parse(r#"rust "new york"#);
assert_eq!(p.positives, vec!["rust", "new", "york"]);
assert!(p.positive_phrases.is_empty());
}
#[test]
fn parse_phrase_tokens_are_normalized() {
let p = parse(r#""New-York City""#);
assert_eq!(p.positive_phrases, vec![vec!["new", "york", "city"]]);
}
#[test]
fn parse_interior_sigil_before_quote_is_not_polarity() {
let p = parse(r#"abc+"x y""#);
assert_eq!(p.positives, vec!["abc"]);
assert_eq!(p.positive_phrases, vec![vec!["x", "y"]]);
assert!(p.must_phrases.is_empty());
}
#[test]
fn parse_adjacent_phrases() {
let p = parse(r#""a b""c d""#);
assert_eq!(p.positive_phrases, vec![vec!["a", "b"], vec!["c", "d"]]);
}
#[test]
fn into_clauses_resolves_phrase_polarity_by_mode() {
let c = parse(r#""new york" +"the who" -"bad seq" rust"#).into_clauses(BoolMode::Or);
assert_eq!(c.should_phrases, vec![vec!["new", "york"]]);
assert_eq!(c.must_phrases, vec![vec!["the", "who"]]);
assert_eq!(c.negative_phrases, vec![vec!["bad", "seq"]]);
assert_eq!(c.shoulds, vec!["rust"]);
let c = parse(r#""new york" rust"#).into_clauses(BoolMode::And);
assert_eq!(c.must_phrases, vec![vec!["new", "york"]]);
assert!(c.should_phrases.is_empty());
assert_eq!(c.musts, vec!["rust"]);
}
#[test]
fn into_clauses_or_maps_bare_to_should() {
let c = parse("+climate policy -spam").into_clauses(BoolMode::Or);
assert_eq!(c.musts, vec!["climate"]);
assert_eq!(c.shoulds, vec!["policy"]);
assert_eq!(c.negatives, vec!["spam"]);
}
#[test]
fn into_clauses_and_folds_bare_into_musts() {
let c = parse("+climate policy -spam").into_clauses(BoolMode::And);
assert_eq!(c.musts, vec!["climate", "policy"]);
assert!(c.shoulds.is_empty());
assert_eq!(c.negatives, vec!["spam"]);
}
#[test]
fn into_clauses_legacy_shapes_unchanged() {
let c = parse("rust async").into_clauses(BoolMode::Or);
assert!(c.musts.is_empty());
assert_eq!(c.shoulds, vec!["rust", "async"]);
let c = parse("rust async").into_clauses(BoolMode::And);
assert_eq!(c.musts, vec!["rust", "async"]);
assert!(c.shoulds.is_empty());
}
#[test]
fn parse_lowercase_tokens_borrow_the_query() {
let p = parse("rust -python");
assert!(matches!(p.positives[0], Cow::Borrowed(_)));
assert!(matches!(p.negatives[0], Cow::Borrowed(_)));
}
#[test]
fn parse_uppercase_token_is_the_only_copy() {
let p = parse("rust -PYTHON");
assert!(matches!(p.positives[0], Cow::Borrowed(_)));
assert!(matches!(p.negatives[0], Cow::Owned(_)));
}
#[test]
fn dyn_tokenize_each_lowercases_and_splits() {
let tok = AsciiLowerTokenizer::new();
let dynt: &dyn Tokenizer = &tok;
let mut out = Vec::new();
dynt.tokenize_each("Hello, World rust", &mut |s| out.push(s.to_string()));
assert_eq!(out, vec!["hello", "world", "rust"]);
}
}