1use alloc::string::{String, ToString};
18use alloc::vec::Vec;
19
20use spg_storage::{TsLexeme, TsQueryAst};
21
22use crate::eval::EvalError;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum TsConfig {
27 Simple,
30 English,
33 Spanish,
36 French,
39 German,
42}
43
44impl TsConfig {
45 pub const fn stems(self) -> bool {
53 !matches!(self, Self::Simple)
54 }
55
56 pub fn stopwords(self) -> Option<&'static [&'static str]> {
58 match self {
59 Self::Simple => None,
60 Self::English => None, Self::Spanish => Some(crate::fts_stop::ES_STOP),
62 Self::French => Some(crate::fts_stop::FR_STOP),
63 Self::German => Some(crate::fts_stop::DE_STOP),
64 }
65 }
66
67 pub fn is_stopword(self, w: &str) -> bool {
69 match self {
70 Self::Simple => false,
71 Self::English => is_english_stopword(w),
72 Self::Spanish => crate::fts_stop::is_stop(crate::fts_stop::ES_STOP, w),
73 Self::French => crate::fts_stop::is_stop(crate::fts_stop::FR_STOP, w),
74 Self::German => crate::fts_stop::is_stop(crate::fts_stop::DE_STOP, w),
75 }
76 }
77
78 pub fn stem(self, w: &str) -> String {
80 match self {
81 Self::Simple => String::from(w),
82 Self::English => porter_stem(w),
83 Self::Spanish => crate::fts_es::stem_es(w),
84 Self::French => crate::fts_fr::stem_fr(w),
85 Self::German => crate::fts_de::stem_de(w),
86 }
87 }
88
89 pub fn from_name(name: &str) -> Option<Self> {
94 let bare = name.strip_prefix("pg_catalog.").unwrap_or(name);
95 match bare.to_ascii_lowercase().as_str() {
96 "simple" => Some(Self::Simple),
97 "english" => Some(Self::English),
98 "spanish" => Some(Self::Spanish),
102 "french" => Some(Self::French),
103 "german" => Some(Self::German),
104 _ => None,
105 }
106 }
107}
108
109pub fn to_tsvector(config: TsConfig, text: &str) -> Vec<TsLexeme> {
115 let mut out: Vec<TsLexeme> = Vec::new();
116 let mut position: u16 = 0;
117 let english = config.stems();
123 for token in tokenize_typed(text) {
124 let Some(dict) = token.ty.dictionary(english) else {
125 continue;
126 };
127 let folded = token.text.to_lowercase();
128 let lex = match dict {
129 TsDict::Simple => folded,
130 TsDict::EnglishStem => {
131 if config.is_stopword(&folded) {
132 position = position.saturating_add(1).min(16383);
136 continue;
137 }
138 config.stem(&folded)
139 }
140 };
141 if lex.is_empty() {
142 continue;
143 }
144 position = position.saturating_add(1).min(16383);
145 match out.binary_search_by(|l| l.word.as_str().cmp(lex.as_str())) {
146 Ok(idx) => {
147 if !out[idx].positions.contains(&position) {
148 out[idx].positions.push(position);
149 }
150 }
151 Err(idx) => {
152 out.insert(
153 idx,
154 TsLexeme {
155 word: lex,
156 positions: alloc::vec![position],
157 weight: 0,
158 },
159 );
160 }
161 }
162 }
163 out
164}
165
166pub fn plainto_tsquery(config: TsConfig, text: &str) -> TsQueryAst {
172 let lexs = collect_lexemes(config, text);
173 fold_and(&lexs)
174}
175
176pub fn phraseto_tsquery(config: TsConfig, text: &str) -> TsQueryAst {
182 let lexs = collect_lexemes_positioned(config, text);
183 fold_phrase_positioned(&lexs)
184}
185
186pub fn websearch_to_tsquery(config: TsConfig, text: &str) -> TsQueryAst {
197 let mut tokens = web_tokens(text);
198 for t in &mut tokens {
200 match t {
201 WebToken::Term(s) => {
202 let lexs = collect_lexemes(config, s);
203 *s = lexs.join(" ");
204 }
205 WebToken::Phrase(words) => {
206 let mut combined = String::new();
207 for w in words.iter() {
208 if !combined.is_empty() {
209 combined.push(' ');
210 }
211 combined.push_str(w);
212 }
213 let lexs = collect_lexemes(config, &combined);
214 *words = lexs;
215 }
216 WebToken::Or | WebToken::Neg => {}
217 }
218 }
219 let mut or_groups: Vec<Vec<TsQueryAst>> = alloc::vec![Vec::new()];
221 let mut pending_negs = 0usize;
224 let mut push_node = |groups: &mut Vec<Vec<TsQueryAst>>, negs: usize, node: TsQueryAst| {
225 let mut node = node;
226 for _ in 0..negs {
227 node = TsQueryAst::Not(alloc::boxed::Box::new(node));
228 }
229 groups.last_mut().unwrap().push(node);
230 };
231 let mut i = 0;
232 while i < tokens.len() {
233 match &tokens[i] {
234 WebToken::Or => {
235 or_groups.push(Vec::new());
236 pending_negs = 0;
237 }
238 WebToken::Neg => {
239 pending_negs += 1;
240 }
241 WebToken::Term(s) => {
242 if !s.is_empty() {
243 push_node(&mut or_groups, pending_negs, fold_and(&split_words(s)));
244 }
245 pending_negs = 0;
246 }
247 WebToken::Phrase(words) => {
248 if !words.is_empty() {
249 push_node(&mut or_groups, pending_negs, fold_phrase(words));
250 }
251 pending_negs = 0;
252 }
253 }
254 i += 1;
255 }
256 let group_nodes: Vec<TsQueryAst> = or_groups
257 .into_iter()
258 .filter_map(|g| {
259 if g.is_empty() {
260 None
261 } else {
262 let mut it = g.into_iter();
263 let first = it.next().unwrap();
264 Some(it.fold(first, |acc, n| {
265 TsQueryAst::And(alloc::boxed::Box::new(acc), alloc::boxed::Box::new(n))
266 }))
267 }
268 })
269 .collect();
270 if group_nodes.is_empty() {
271 return TsQueryAst::Term {
272 word: String::new(),
273 weight_mask: 0,
274 };
275 }
276 let mut it = group_nodes.into_iter();
277 let first = it.next().unwrap();
278 it.fold(first, |acc, n| {
279 TsQueryAst::Or(alloc::boxed::Box::new(acc), alloc::boxed::Box::new(n))
280 })
281}
282
283pub fn to_tsquery(config: TsConfig, text: &str) -> Result<TsQueryAst, EvalError> {
288 let mut ast = crate::eval::decode_tsquery_external(text)?;
289 stem_tsquery_in_place(&mut ast, config);
290 if config.stems()
298 && let Some(pruned) = prune_stopword_terms(&ast)
299 {
300 ast = pruned;
301 }
302 Ok(ast)
303}
304
305fn prune_stopword_terms(ast: &TsQueryAst) -> Option<TsQueryAst> {
306 match ast {
307 TsQueryAst::Term { word, .. } => {
308 if is_english_stopword(word) {
309 None
310 } else {
311 Some(ast.clone())
312 }
313 }
314 TsQueryAst::And(a, b) => match (prune_stopword_terms(a), prune_stopword_terms(b)) {
315 (Some(x), Some(y)) => Some(TsQueryAst::And(
316 alloc::boxed::Box::new(x),
317 alloc::boxed::Box::new(y),
318 )),
319 (Some(x), None) | (None, Some(x)) => Some(x),
320 (None, None) => None,
321 },
322 TsQueryAst::Or(a, b) => match (prune_stopword_terms(a), prune_stopword_terms(b)) {
323 (Some(x), Some(y)) => Some(TsQueryAst::Or(
324 alloc::boxed::Box::new(x),
325 alloc::boxed::Box::new(y),
326 )),
327 (Some(x), None) | (None, Some(x)) => Some(x),
328 (None, None) => None,
329 },
330 TsQueryAst::Not(x) => {
331 prune_stopword_terms(x).map(|p| TsQueryAst::Not(alloc::boxed::Box::new(p)))
332 }
333 TsQueryAst::Phrase {
334 left,
335 right,
336 distance,
337 } => match (prune_stopword_terms(left), prune_stopword_terms(right)) {
338 (Some(x), Some(y)) => Some(TsQueryAst::Phrase {
339 left: alloc::boxed::Box::new(x),
340 right: alloc::boxed::Box::new(y),
341 distance: *distance,
342 }),
343 (Some(x), None) | (None, Some(x)) => Some(x),
344 (None, None) => None,
345 },
346 }
347}
348
349fn stem_tsquery_in_place(ast: &mut TsQueryAst, config: TsConfig) {
350 match ast {
351 TsQueryAst::Term { word, .. } => {
352 let lower = word.to_lowercase();
353 *word = match config {
354 TsConfig::Simple => lower,
355 TsConfig::English => porter_stem(&lower),
356 TsConfig::Spanish => crate::fts_es::stem_es(&lower),
357 TsConfig::French => crate::fts_fr::stem_fr(&lower),
358 TsConfig::German => crate::fts_de::stem_de(&lower),
359 };
360 }
361 TsQueryAst::And(a, b) | TsQueryAst::Or(a, b) => {
362 stem_tsquery_in_place(a, config);
363 stem_tsquery_in_place(b, config);
364 }
365 TsQueryAst::Not(x) => stem_tsquery_in_place(x, config),
366 TsQueryAst::Phrase { left, right, .. } => {
367 stem_tsquery_in_place(left, config);
368 stem_tsquery_in_place(right, config);
369 }
370 }
371}
372
373fn collect_lexemes(config: TsConfig, text: &str) -> Vec<String> {
374 let mut out: Vec<String> = Vec::new();
375 let english = config.stems();
376 for token in tokenize_typed(text) {
377 let Some(dict) = token.ty.dictionary(english) else {
378 continue;
379 };
380 let folded = token.text.to_lowercase();
381 match dict {
382 TsDict::Simple => out.push(folded),
383 TsDict::EnglishStem => {
384 if config.is_stopword(&folded) {
385 continue;
386 }
387 let stemmed = config.stem(&folded);
388 if !stemmed.is_empty() {
389 out.push(stemmed);
390 }
391 }
392 }
393 }
394 out
395}
396
397fn split_words(s: &str) -> Vec<String> {
398 s.split_whitespace().map(|w| w.to_string()).collect()
399}
400
401fn fold_and(lexs: &[String]) -> TsQueryAst {
402 if lexs.is_empty() {
403 return TsQueryAst::Term {
404 word: String::new(),
405 weight_mask: 0,
406 };
407 }
408 let mut it = lexs.iter();
409 let first = TsQueryAst::Term {
410 word: it.next().unwrap().clone(),
411 weight_mask: 0,
412 };
413 it.fold(first, |acc, w| {
414 TsQueryAst::And(
415 alloc::boxed::Box::new(acc),
416 alloc::boxed::Box::new(TsQueryAst::Term {
417 word: w.clone(),
418 weight_mask: 0,
419 }),
420 )
421 })
422}
423
424fn fold_phrase(lexs: &[String]) -> TsQueryAst {
425 if lexs.is_empty() {
426 return TsQueryAst::Term {
427 word: String::new(),
428 weight_mask: 0,
429 };
430 }
431 let mut it = lexs.iter();
432 let first = TsQueryAst::Term {
433 word: it.next().unwrap().clone(),
434 weight_mask: 0,
435 };
436 it.fold(first, |acc, w| TsQueryAst::Phrase {
437 left: alloc::boxed::Box::new(acc),
438 right: alloc::boxed::Box::new(TsQueryAst::Term {
439 word: w.clone(),
440 weight_mask: 0,
441 }),
442 distance: 1,
443 })
444}
445
446fn collect_lexemes_positioned(config: TsConfig, text: &str) -> Vec<(String, u16)> {
451 let mut out: Vec<(String, u16)> = Vec::new();
452 let mut position: u16 = 0;
453 let english = config.stems();
454 for token in tokenize_typed(text) {
455 let Some(dict) = token.ty.dictionary(english) else {
456 continue;
457 };
458 let folded = token.text.to_lowercase();
459 let lex = match dict {
460 TsDict::Simple => folded,
461 TsDict::EnglishStem => {
462 if config.is_stopword(&folded) {
463 position = position.saturating_add(1).min(16383);
464 continue;
465 }
466 config.stem(&folded)
467 }
468 };
469 if lex.is_empty() {
470 continue;
471 }
472 position = position.saturating_add(1).min(16383);
473 out.push((lex, position));
474 }
475 out
476}
477
478fn fold_phrase_positioned(lexs: &[(String, u16)]) -> TsQueryAst {
481 if lexs.is_empty() {
482 return TsQueryAst::Term {
483 word: String::new(),
484 weight_mask: 0,
485 };
486 }
487 let mut it = lexs.iter();
488 let (first_word, first_pos) = it.next().unwrap();
489 let mut acc = TsQueryAst::Term {
490 word: first_word.clone(),
491 weight_mask: 0,
492 };
493 let mut prev_pos = *first_pos;
494 for (word, pos) in it {
495 let distance = pos.saturating_sub(prev_pos);
496 acc = TsQueryAst::Phrase {
497 left: alloc::boxed::Box::new(acc),
498 right: alloc::boxed::Box::new(TsQueryAst::Term {
499 word: word.clone(),
500 weight_mask: 0,
501 }),
502 distance,
503 };
504 prev_pos = *pos;
505 }
506 acc
507}
508
509#[must_use]
518pub fn ts_query_matches(vec: &[TsLexeme], query: &TsQueryAst) -> bool {
519 match query {
520 TsQueryAst::Term { word, weight_mask } => term_matches(vec, word, *weight_mask),
521 TsQueryAst::And(a, b) => ts_query_matches(vec, a) && ts_query_matches(vec, b),
522 TsQueryAst::Or(a, b) => ts_query_matches(vec, a) || ts_query_matches(vec, b),
523 TsQueryAst::Not(x) => !ts_query_matches(vec, x),
524 TsQueryAst::Phrase {
525 left,
526 right,
527 distance,
528 } => phrase_match(vec, left, right, *distance),
529 }
530}
531
532fn contains_lexeme(vec: &[TsLexeme], word: &str) -> bool {
533 vec.binary_search_by(|l| l.word.as_str().cmp(word)).is_ok()
534}
535
536fn term_matches(vec: &[TsLexeme], word: &str, mask: u8) -> bool {
540 let prefix = mask & 0x10 != 0;
541 let weights = mask & 0x0f;
542 let weight_ok = |l: &TsLexeme| weights == 0 || weights & (1 << l.weight) != 0;
543 if prefix {
544 return vec.iter().any(|l| l.word.starts_with(word) && weight_ok(l));
545 }
546 match vec.binary_search_by(|l| l.word.as_str().cmp(word)) {
547 Ok(idx) => weight_ok(&vec[idx]),
548 Err(_) => false,
549 }
550}
551
552fn phrase_positions(vec: &[TsLexeme], q: &TsQueryAst) -> Vec<u16> {
557 match q {
558 TsQueryAst::Term { word, .. } => {
559 match vec.binary_search_by(|l| l.word.as_str().cmp(word)) {
560 Ok(idx) => vec[idx].positions.clone(),
561 Err(_) => Vec::new(),
562 }
563 }
564 TsQueryAst::Phrase {
565 left,
566 right,
567 distance,
568 } => {
569 let lp = phrase_positions(vec, left);
570 let rp = phrase_positions(vec, right);
571 let mut out = Vec::new();
572 for l in &lp {
573 let target = l.saturating_add(*distance);
574 if rp.binary_search(&target).is_ok() {
575 out.push(target);
576 }
577 }
578 out.sort_unstable();
579 out.dedup();
580 out
581 }
582 _ => {
585 if ts_query_matches(vec, q) {
586 alloc::vec![u16::MAX]
587 } else {
588 Vec::new()
589 }
590 }
591 }
592}
593
594fn phrase_match(vec: &[TsLexeme], left: &TsQueryAst, right: &TsQueryAst, distance: u16) -> bool {
595 let lp = phrase_positions(vec, left);
596 let rp = phrase_positions(vec, right);
597 lp.iter().any(|l| {
598 let target = l.saturating_add(distance);
599 rp.binary_search(&target).is_ok()
600 })
601}
602
603#[must_use]
608pub fn ts_rank(weights: &RankWeights, vec: &[TsLexeme], query: &TsQueryAst) -> f32 {
609 let mut terms: Vec<&str> = Vec::new();
613 collect_query_terms(query, &mut terms);
614 if terms.is_empty() {
615 return 0.0;
616 }
617 let and_rooted = matches!(query, TsQueryAst::And(..) | TsQueryAst::Phrase { .. });
618 if and_rooted && terms.len() >= 2 {
620 calc_rank_and(vec, &terms, weights)
621 } else {
622 calc_rank_or(vec, &terms, weights)
623 }
624}
625
626#[must_use]
631pub fn ts_rank_cd(weights: &RankWeights, vec: &[TsLexeme], query: &TsQueryAst) -> f32 {
632 let mut terms: Vec<&str> = Vec::new();
638 collect_query_terms(query, &mut terms);
639 if terms.is_empty() {
640 return 0.0;
641 }
642 let mut doc: Vec<(u16, usize, u8)> = Vec::new();
644 for (t, word) in terms.iter().enumerate() {
645 if let Ok(idx) = vec.binary_search_by(|l| l.word.as_str().cmp(word)) {
646 for &pos in &vec[idx].positions {
647 doc.push((pos, t, vec[idx].weight));
648 }
649 }
650 }
651 doc.sort_unstable();
652 let nterms = terms.len();
653 let mut wdoc = 0.0f32;
654 let mut start = 0usize;
655 while start < doc.len() {
656 let mut seen = alloc::vec![false; nterms];
658 let mut cnt = 0usize;
659 let mut end = start;
660 while end < doc.len() {
661 if !seen[doc[end].1] {
662 seen[doc[end].1] = true;
663 cnt += 1;
664 }
665 if cnt == nterms {
666 break;
667 }
668 end += 1;
669 }
670 if cnt < nterms {
671 break; }
673 let mut begin = start;
675 while begin < end {
676 let bt = doc[begin].1;
677 if doc[begin + 1..=end].iter().any(|d| d.1 == bt) {
678 begin += 1;
679 } else {
680 break;
681 }
682 }
683 let p = doc[begin].0;
684 let q = doc[end].0;
685 let inv_sum: f32 = doc[begin..=end]
686 .iter()
687 .map(|d| 1.0 / weight_factor(d.2, weights))
688 .sum();
689 let mut cpos = ((end - begin + 1) as f32) / inv_sum;
690 let nnoise = (i32::from(q) - i32::from(p)) - (end as i32 - begin as i32);
691 if nnoise > 0 {
692 cpos /= (nnoise + 1) as f32;
693 }
694 wdoc += cpos;
695 start = begin + 1;
696 }
697 wdoc
698}
699
700#[must_use]
707pub fn apply_rank_norm(mut rank: f32, norm: i64, vec: &[TsLexeme]) -> f32 {
708 let len: usize = vec.iter().map(|l| l.positions.len()).sum();
709 let uniq = vec.len();
710 if norm & 1 != 0 && len > 0 {
711 rank /= log2_approx((len + 1) as f32);
712 }
713 if norm & 2 != 0 && len > 0 {
714 rank /= len as f32;
715 }
716 if norm & 8 != 0 && uniq > 0 {
717 rank /= uniq as f32;
718 }
719 if norm & 16 != 0 {
720 rank /= log2_approx((uniq + 1) as f32);
721 }
722 if norm & 32 != 0 {
723 rank /= rank + 1.0;
724 }
725 rank
726}
727
728fn log2_approx(x: f32) -> f32 {
729 ln_approx(x) / core::f32::consts::LN_2
730}
731
732fn ln_approx(x: f32) -> f32 {
736 if x <= 0.0 {
737 return 0.0;
738 }
739 let xd = f64::from(x);
740 let bits = xd.to_bits();
741 let exponent_raw = ((bits >> 52) & 0x7ff) as i64;
742 let exponent = exponent_raw - 1023;
743 let mantissa_bits = (bits & 0x000f_ffff_ffff_ffff) | 0x3ff0_0000_0000_0000;
744 let mantissa = f64::from_bits(mantissa_bits);
745 let t = (mantissa - 1.0) / (mantissa + 1.0);
746 let t2 = t * t;
747 let ln_mantissa = 2.0 * (t + t2 * t / 3.0 + t2 * t2 * t / 5.0 + t2 * t2 * t2 * t / 7.0);
748 let ln = (exponent as f64) * core::f64::consts::LN_2 + ln_mantissa;
749 ln as f32
750}
751
752pub type RankWeights = [f32; 4];
754pub const DEFAULT_RANK_WEIGHTS: RankWeights = [0.1, 0.2, 0.4, 1.0];
756
757fn weight_factor(w: u8, weights: &RankWeights) -> f32 {
758 weights[(w as usize).min(3)]
760}
761
762fn exp_approx(x: f32) -> f32 {
766 if x > 88.0 {
767 return f32::INFINITY;
768 }
769 if x < -88.0 {
770 return 0.0;
771 }
772 let xd = f64::from(x);
773 let k = (xd / core::f64::consts::LN_2).round();
774 let r = xd - k * core::f64::consts::LN_2;
775 let mut term = 1.0f64;
777 let mut er = 1.0f64;
778 for i in 1..8 {
779 term *= r / f64::from(i);
780 er += term;
781 }
782 (er * libm_exp2(k)) as f32
783}
784
785fn libm_exp2(k: f64) -> f64 {
787 let ki = k as i64;
788 f64::from_bits((((ki + 1023) as u64) & 0x7ff) << 52)
789}
790
791fn word_distance(d: u32) -> f32 {
794 1.0 / (1.005 + 0.05 * exp_approx((d as f32) / 1.5 - 2.0))
795}
796
797struct RankEntry {
799 term: usize,
800 pos: u16,
801 w: f32,
802}
803
804fn collect_query_terms<'a>(query: &'a TsQueryAst, out: &mut Vec<&'a str>) {
806 match query {
807 TsQueryAst::Term { word, .. } => {
808 if !out.iter().any(|t| *t == word.as_str()) {
809 out.push(word.as_str());
810 }
811 }
812 TsQueryAst::And(a, b) | TsQueryAst::Or(a, b) => {
813 collect_query_terms(a, out);
814 collect_query_terms(b, out);
815 }
816 TsQueryAst::Phrase { left, right, .. } => {
817 collect_query_terms(left, out);
818 collect_query_terms(right, out);
819 }
820 TsQueryAst::Not(_) => {}
821 }
822}
823
824fn calc_rank_or(vec: &[TsLexeme], terms: &[&str], weights: &RankWeights) -> f32 {
827 let mut res = 0.0f32;
828 for word in terms {
829 if let Ok(idx) = vec.binary_search_by(|l| l.word.as_str().cmp(word)) {
830 let wpos = weight_factor(vec[idx].weight, weights);
831 let (mut resj, mut wjm, mut jm) = (0.0f32, 0.0f32, 0usize);
832 for (j, _pos) in vec[idx].positions.iter().enumerate() {
833 let denom = ((j + 1) * (j + 1)) as f32;
834 resj += wpos / denom;
835 if wpos > wjm {
836 wjm = wpos;
837 jm = j;
838 }
839 }
840 let jm_denom = ((jm + 1) * (jm + 1)) as f32;
841 res += (wjm + resj - wjm / jm_denom) / 1.644_934;
842 }
843 }
844 res / (terms.len().max(1) as f32)
845}
846
847fn calc_rank_and(vec: &[TsLexeme], terms: &[&str], weights: &RankWeights) -> f32 {
850 let mut entries: Vec<RankEntry> = Vec::new();
851 for (t, word) in terms.iter().enumerate() {
852 if let Ok(idx) = vec.binary_search_by(|l| l.word.as_str().cmp(word)) {
853 let w = weight_factor(vec[idx].weight, weights);
854 for &pos in &vec[idx].positions {
855 entries.push(RankEntry { term: t, pos, w });
856 }
857 }
858 }
859 let mut res = -1.0f32;
860 for i in 1..entries.len() {
861 for k in 0..i {
862 if entries[i].term == entries[k].term {
863 continue;
864 }
865 let mut dist = u32::from(entries[i].pos.abs_diff(entries[k].pos));
866 if dist == 0 {
867 dist = 16384; }
869 let curw = sqrt_approx(entries[i].w * entries[k].w * word_distance(dist));
870 res = if res < 0.0 {
871 curw
872 } else {
873 1.0 - (1.0 - res) * (1.0 - curw)
874 };
875 }
876 }
877 if res < 0.0 { 1e-20 } else { res }
878}
879
880fn sqrt_approx(x: f32) -> f32 {
882 if x <= 0.0 {
883 return 0.0;
884 }
885 let mut g = f64::from(x);
886 for _ in 0..20 {
887 g = 0.5 * (g + f64::from(x) / g);
888 }
889 g as f32
890}
891
892#[derive(Debug, Clone, Copy, PartialEq, Eq)]
905pub enum TokenType {
906 AsciiWord = 1,
907 Word = 2,
908 NumWord = 3,
909 Email = 4,
910 Url = 5,
911 Host = 6,
912 SFloat = 7,
913 Version = 8,
914 HwordNumPart = 9,
915 HwordPart = 10,
916 HwordAsciiPart = 11,
917 Blank = 12,
918 Tag = 13,
919 Protocol = 14,
920 NumHword = 15,
921 AsciiHword = 16,
922 Hword = 17,
923 UrlPath = 18,
924 File = 19,
925 Float = 20,
926 Int = 21,
927 Uint = 22,
928 Entity = 23,
929}
930
931impl TokenType {
932 pub const fn alias(self) -> &'static str {
934 match self {
935 Self::AsciiWord => "asciiword",
936 Self::Word => "word",
937 Self::NumWord => "numword",
938 Self::Email => "email",
939 Self::Url => "url",
940 Self::Host => "host",
941 Self::SFloat => "sfloat",
942 Self::Version => "version",
943 Self::HwordNumPart => "hword_numpart",
944 Self::HwordPart => "hword_part",
945 Self::HwordAsciiPart => "hword_asciipart",
946 Self::Blank => "blank",
947 Self::Tag => "tag",
948 Self::Protocol => "protocol",
949 Self::NumHword => "numhword",
950 Self::AsciiHword => "asciihword",
951 Self::Hword => "hword",
952 Self::UrlPath => "url_path",
953 Self::File => "file",
954 Self::Float => "float",
955 Self::Int => "int",
956 Self::Uint => "uint",
957 Self::Entity => "entity",
958 }
959 }
960
961 pub const fn description(self) -> &'static str {
963 match self {
964 Self::AsciiWord => "Word, all ASCII",
965 Self::Word => "Word, all letters",
966 Self::NumWord => "Word, letters and digits",
967 Self::Email => "Email address",
968 Self::Url => "URL",
969 Self::Host => "Host",
970 Self::SFloat => "Scientific notation",
971 Self::Version => "Version number",
972 Self::HwordNumPart => "Hyphenated word part, letters and digits",
973 Self::HwordPart => "Hyphenated word part, all letters",
974 Self::HwordAsciiPart => "Hyphenated word part, all ASCII",
975 Self::Blank => "Space symbols",
976 Self::Tag => "XML tag",
977 Self::Protocol => "Protocol head",
978 Self::NumHword => "Hyphenated word, letters and digits",
979 Self::AsciiHword => "Hyphenated word, all ASCII",
980 Self::Hword => "Hyphenated word, all letters",
981 Self::UrlPath => "URL path",
982 Self::File => "File or path name",
983 Self::Float => "Decimal notation",
984 Self::Int => "Signed integer",
985 Self::Uint => "Unsigned integer",
986 Self::Entity => "XML entity",
987 }
988 }
989
990 pub const fn dictionary(self, english: bool) -> Option<TsDict> {
996 match self {
997 Self::Blank | Self::Tag | Self::Protocol | Self::Entity => None,
998 Self::AsciiWord
1003 | Self::Word
1004 | Self::HwordPart
1005 | Self::HwordAsciiPart
1006 | Self::AsciiHword
1007 | Self::Hword
1008 if english =>
1009 {
1010 Some(TsDict::EnglishStem)
1011 }
1012 _ => Some(TsDict::Simple),
1013 }
1014 }
1015}
1016
1017#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1019pub enum TsDict {
1020 Simple,
1021 EnglishStem,
1022}
1023
1024#[derive(Debug, Clone)]
1026pub struct Token {
1027 pub text: String,
1028 pub ty: TokenType,
1029}
1030
1031pub fn tokenize(text: &str) -> Vec<String> {
1033 tokenize_typed(text)
1034 .into_iter()
1035 .filter(|t| t.ty.dictionary(false).is_some())
1036 .map(|t| t.text)
1037 .collect()
1038}
1039
1040pub fn tokenize_typed(text: &str) -> Vec<Token> {
1057 let b: Vec<char> = text.chars().collect();
1058 let mut out: Vec<Token> = Vec::new();
1059 let mut i = 0usize;
1060 let is_word = |c: char| c.is_alphanumeric() || c == '_';
1061 while i < b.len() {
1062 let c = b[i];
1063 if c.is_whitespace() {
1064 i += 1;
1065 continue;
1066 }
1067 if c == '<'
1070 && let Some(end) = (i + 1..b.len()).find(|&j| b[j] == '>')
1071 {
1072 out.push(Token {
1073 text: b[i..=end].iter().collect(),
1074 ty: TokenType::Tag,
1075 });
1076 i = end + 1;
1077 continue;
1078 }
1079 if c == '&'
1081 && let Some(end) = (i + 1..b.len().min(i + 12)).find(|&j| b[j] == ';')
1082 && end > i + 1
1083 {
1084 out.push(Token {
1085 text: b[i..=end].iter().collect(),
1086 ty: TokenType::Entity,
1087 });
1088 i = end + 1;
1089 continue;
1090 }
1091 let leading_slash = c == '/' && i + 1 < b.len() && is_word(b[i + 1]);
1095 if is_word(c) || leading_slash || (c == '-' && i + 1 < b.len() && b[i + 1].is_ascii_digit())
1096 {
1097 let start = i;
1098 let signed = c == '-';
1100 if signed || leading_slash {
1101 i += 1;
1102 }
1103 while i < b.len() && (is_word(b[i]) || matches!(b[i], '.' | '@' | '/' | '-' | ':')) {
1104 if matches!(b[i], '.' | '@' | '/' | '-' | ':')
1108 && (i + 1 >= b.len() || !(is_word(b[i + 1]) || b[i + 1] == '/'))
1109 {
1110 break;
1111 }
1112 i += 1;
1113 }
1114 let raw: String = b[start..i].iter().collect();
1115 classify_into(&raw, signed, &mut out);
1116 continue;
1117 }
1118 i += 1;
1119 }
1120 out
1121}
1122
1123fn raw_of<'a>(raw: &'a str, lower: &'a impl AsRef<str>) -> &'a str {
1130 let lower = lower.as_ref();
1131 if raw.len() == lower.len() { raw } else { lower }
1132}
1133
1134fn raw_tail<'a>(raw: &'a str, body: &'a str) -> &'a str {
1136 if raw.len() >= body.len() && raw.is_char_boundary(raw.len() - body.len()) {
1137 let t = &raw[raw.len() - body.len()..];
1138 if t.len() == body.len() { t } else { body }
1139 } else {
1140 body
1141 }
1142}
1143
1144fn classify_into(raw: &str, signed: bool, out: &mut Vec<Token>) {
1145 let lower = raw.to_lowercase();
1149 let _ = &lower;
1150 let push = |out: &mut Vec<Token>, t: &str, ty: TokenType| {
1154 if !t.is_empty() {
1155 out.push(Token {
1156 text: alloc::string::String::from(t),
1157 ty,
1158 });
1159 }
1160 };
1161 let ascii = lower.is_ascii();
1162 let has_alpha = lower.chars().any(char::is_alphabetic);
1163 let has_digit = lower.chars().any(|c| c.is_ascii_digit());
1164
1165 let mut body = lower.as_str();
1169 if let Some(pos) = lower.find("://") {
1170 push(
1171 out,
1172 &alloc::format!("{}://", &lower[..pos]),
1173 TokenType::Protocol,
1174 );
1175 body = &lower[pos + 3..];
1176 }
1177 if body.contains('/') {
1187 let (head, path) = match body.find('/') {
1188 Some(p) => body.split_at(p),
1189 None => (body, ""),
1190 };
1191 let host_like = head.rsplit_once('.').is_some_and(|(pre, tld)| {
1192 !pre.is_empty() && tld.len() >= 2 && tld.chars().all(char::is_alphabetic)
1193 });
1194 if host_like && !head.is_empty() {
1195 push(out, raw_tail(raw, body), TokenType::Url);
1196 push(out, &raw_tail(raw, body)[..head.len()], TokenType::Host);
1197 push(out, &raw_tail(raw, body)[head.len()..], TokenType::UrlPath);
1198 } else {
1199 push(out, raw_tail(raw, body), TokenType::File);
1200 }
1201 return;
1202 }
1203 let lower = alloc::string::String::from(body);
1204 let lower = lower.as_str();
1205 if let Some(at) = lower.find('@')
1207 && at > 0
1208 && lower[at + 1..].contains('.')
1209 && !lower[at + 1..].contains('@')
1210 {
1211 push(out, raw_of(raw, &lower), TokenType::Email);
1212 return;
1213 }
1214 if lower.contains('-') && has_alpha {
1216 let compound = if has_digit {
1217 TokenType::NumHword
1218 } else if ascii {
1219 TokenType::AsciiHword
1220 } else {
1221 TokenType::Hword
1222 };
1223 push(out, raw_of(raw, &lower), compound);
1224 for (part, raw_part) in lower.split('-').zip(raw_of(raw, &lower).split('-')) {
1225 if part.is_empty() {
1226 continue;
1227 }
1228 let pty = if part.chars().any(|c| c.is_ascii_digit()) {
1229 TokenType::HwordNumPart
1230 } else if part.is_ascii() {
1231 TokenType::HwordAsciiPart
1232 } else {
1233 TokenType::HwordPart
1234 };
1235 push(out, raw_part, pty);
1236 }
1237 return;
1238 }
1239 if lower.contains('.') {
1240 let dots = lower.matches('.').count();
1241 let numeric = lower.chars().all(|c| c.is_ascii_digit() || c == '.');
1242 if numeric && dots >= 2 {
1243 push(out, raw_of(raw, &lower), TokenType::Version);
1244 return;
1245 }
1246 if numeric && dots == 1 {
1247 push(out, raw_of(raw, &lower), TokenType::Float);
1248 return;
1249 }
1250 if dots == 1
1252 && has_digit
1253 && lower
1254 .chars()
1255 .all(|c| c.is_ascii_digit() || c == '.' || c == 'e' || c == '+' || c == '-')
1256 {
1257 push(out, raw_of(raw, &lower), TokenType::SFloat);
1258 return;
1259 }
1260 if has_alpha {
1261 push(out, raw_of(raw, &lower), TokenType::Host);
1262 return;
1263 }
1264 push(out, raw_of(raw, &lower), TokenType::Version);
1265 return;
1266 }
1267 if !has_alpha && has_digit {
1268 push(
1269 out,
1270 raw_of(raw, &lower),
1271 if signed {
1272 TokenType::Int
1273 } else {
1274 TokenType::Uint
1275 },
1276 );
1277 return;
1278 }
1279 let ty = if has_digit {
1280 TokenType::NumWord
1281 } else if ascii {
1282 TokenType::AsciiWord
1283 } else {
1284 TokenType::Word
1285 };
1286 push(out, raw_of(raw, &lower), ty);
1287}
1288
1289enum WebToken {
1290 Term(String),
1291 Phrase(Vec<String>),
1292 Or,
1293 Neg,
1299}
1300
1301fn web_tokens(text: &str) -> Vec<WebToken> {
1304 let mut out = Vec::new();
1305 let bytes = text.as_bytes();
1306 let mut i = 0;
1307 while i < bytes.len() {
1308 let b = bytes[i];
1309 if b.is_ascii_whitespace() {
1310 i += 1;
1311 continue;
1312 }
1313 if b == b'"' {
1314 i += 1;
1315 let start = i;
1316 while i < bytes.len() && bytes[i] != b'"' {
1317 i += 1;
1318 }
1319 let phrase_text = &text[start..i];
1320 let words: Vec<String> = phrase_text
1321 .split_whitespace()
1322 .map(|w| w.to_string())
1323 .collect();
1324 out.push(WebToken::Phrase(words));
1325 if i < bytes.len() {
1326 i += 1; }
1328 continue;
1329 }
1330 if b == b'-' {
1331 out.push(WebToken::Neg);
1332 i += 1;
1333 continue;
1334 }
1335 let start = i;
1336 while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'"' {
1337 i += 1;
1338 }
1339 let word = &text[start..i];
1340 if word.eq_ignore_ascii_case("or") {
1341 out.push(WebToken::Or);
1342 } else {
1343 out.push(WebToken::Term(word.to_string()));
1344 }
1345 }
1346 let n = out.len();
1355 let mut at_operand_pos = true;
1356 for idx in 0..n {
1357 if matches!(out[idx], WebToken::Or) && (at_operand_pos || idx + 1 == n) {
1358 out[idx] = WebToken::Term(String::from("or"));
1359 }
1360 at_operand_pos = match out[idx] {
1362 WebToken::Or => true,
1363 WebToken::Neg => at_operand_pos,
1364 _ => false,
1365 };
1366 }
1367 out
1368}
1369
1370pub fn is_english_stopword(word: &str) -> bool {
1373 matches!(
1374 word,
1375 "i" | "me"
1376 | "my"
1377 | "myself"
1378 | "we"
1379 | "our"
1380 | "ours"
1381 | "ourselves"
1382 | "you"
1383 | "your"
1384 | "yours"
1385 | "yourself"
1386 | "yourselves"
1387 | "he"
1388 | "him"
1389 | "his"
1390 | "himself"
1391 | "she"
1392 | "her"
1393 | "hers"
1394 | "herself"
1395 | "it"
1396 | "its"
1397 | "itself"
1398 | "they"
1399 | "them"
1400 | "their"
1401 | "theirs"
1402 | "themselves"
1403 | "what"
1404 | "which"
1405 | "who"
1406 | "whom"
1407 | "this"
1408 | "that"
1409 | "these"
1410 | "those"
1411 | "am"
1412 | "is"
1413 | "are"
1414 | "was"
1415 | "were"
1416 | "be"
1417 | "been"
1418 | "being"
1419 | "have"
1420 | "has"
1421 | "had"
1422 | "having"
1423 | "do"
1424 | "does"
1425 | "did"
1426 | "doing"
1427 | "a"
1428 | "an"
1429 | "the"
1430 | "and"
1431 | "but"
1432 | "if"
1433 | "or"
1434 | "because"
1435 | "as"
1436 | "until"
1437 | "while"
1438 | "of"
1439 | "at"
1440 | "by"
1441 | "for"
1442 | "with"
1443 | "about"
1444 | "against"
1445 | "between"
1446 | "into"
1447 | "through"
1448 | "during"
1449 | "before"
1450 | "after"
1451 | "above"
1452 | "below"
1453 | "to"
1454 | "from"
1455 | "up"
1456 | "down"
1457 | "in"
1458 | "out"
1459 | "on"
1460 | "off"
1461 | "over"
1462 | "under"
1463 | "again"
1464 | "further"
1465 | "then"
1466 | "once"
1467 | "here"
1468 | "there"
1469 | "when"
1470 | "where"
1471 | "why"
1472 | "how"
1473 | "all"
1474 | "any"
1475 | "both"
1476 | "each"
1477 | "few"
1478 | "more"
1479 | "most"
1480 | "other"
1481 | "some"
1482 | "such"
1483 | "no"
1484 | "nor"
1485 | "not"
1486 | "only"
1487 | "own"
1488 | "same"
1489 | "so"
1490 | "than"
1491 | "too"
1492 | "very"
1493 | "s"
1494 | "t"
1495 | "can"
1496 | "will"
1497 | "just"
1498 | "don"
1499 | "should"
1500 | "now"
1501 )
1502}
1503
1504fn stem_exception(word: &str) -> Option<&'static str> {
1518 Some(match word {
1519 "skis" => "ski",
1520 "skies" => "sky",
1521 "dying" => "die",
1522 "lying" => "lie",
1523 "tying" => "tie",
1524 "idly" => "idl",
1525 "gently" => "gentl",
1526 "ugly" => "ugli",
1527 "early" => "earli",
1528 "only" => "onli",
1529 "singly" => "singl",
1530 "sky" | "news" | "howe" | "atlas" | "cosmos" | "bias" | "andes" => {
1531 return Some(match word {
1532 "sky" => "sky",
1533 "news" => "news",
1534 "howe" => "howe",
1535 "atlas" => "atlas",
1536 "cosmos" => "cosmos",
1537 "bias" => "bias",
1538 _ => "andes",
1539 });
1540 }
1541 _ => return None,
1542 })
1543}
1544
1545pub fn porter_stem(word: &str) -> String {
1546 if let Some(fixed) = stem_exception(word) {
1547 return String::from(fixed);
1548 }
1549 if !word.is_ascii() {
1550 return word.to_string();
1551 }
1552 let bytes: Vec<u8> = word.bytes().collect();
1553 if bytes.len() <= 2 {
1554 return word.to_string();
1555 }
1556 let mut b = bytes;
1557 step1a(&mut b);
1558 step1b(&mut b);
1559 step1c(&mut b);
1560 step2(&mut b);
1561 step3(&mut b);
1562 step4(&mut b);
1563 step5a(&mut b);
1564 step5b(&mut b);
1565 String::from_utf8(b).expect("porter stem produced non-UTF8 bytes")
1567}
1568
1569fn is_vowel(b: &[u8], i: usize) -> bool {
1570 match b[i] {
1571 b'a' | b'e' | b'i' | b'o' | b'u' => true,
1572 b'y' => i > 0 && !is_vowel(b, i - 1),
1573 _ => false,
1574 }
1575}
1576
1577fn measure(b: &[u8]) -> usize {
1579 let mut m = 0;
1580 let mut prev_vowel = false;
1581 let mut started = false;
1582 for i in 0..b.len() {
1583 let v = is_vowel(b, i);
1584 if started && prev_vowel && !v {
1585 m += 1;
1586 }
1587 prev_vowel = v;
1588 started = true;
1589 }
1590 m
1591}
1592
1593fn has_vowel(b: &[u8]) -> bool {
1594 (0..b.len()).any(|i| is_vowel(b, i))
1595}
1596
1597fn ends_with(b: &[u8], suf: &[u8]) -> bool {
1598 b.len() >= suf.len() && &b[b.len() - suf.len()..] == suf
1599}
1600
1601fn replace_suffix(b: &mut Vec<u8>, suf_len: usize, new_suf: &[u8]) {
1602 let new_len = b.len() - suf_len;
1603 b.truncate(new_len);
1604 b.extend_from_slice(new_suf);
1605}
1606
1607fn measure_stem(b: &[u8], suf_len: usize) -> usize {
1608 measure(&b[..b.len() - suf_len])
1609}
1610
1611fn step1a(b: &mut Vec<u8>) {
1612 if ends_with(b, b"sses") {
1613 replace_suffix(b, 4, b"ss");
1614 } else if ends_with(b, b"ies") {
1615 if b.len() - 3 <= 1 {
1620 replace_suffix(b, 3, b"ie");
1621 } else {
1622 replace_suffix(b, 3, b"i");
1623 }
1624 } else if ends_with(b, b"ss") {
1625 } else if ends_with(b, b"s") {
1627 replace_suffix(b, 1, b"");
1628 }
1629}
1630
1631fn step1b_post(b: &mut Vec<u8>) {
1632 if ends_with(b, b"at") {
1633 replace_suffix(b, 2, b"ate");
1634 } else if ends_with(b, b"bl") {
1635 replace_suffix(b, 2, b"ble");
1636 } else if ends_with(b, b"iz") {
1637 replace_suffix(b, 2, b"ize");
1638 } else if b.len() >= 2 && b[b.len() - 1] == b[b.len() - 2] {
1639 let last = b[b.len() - 1];
1640 if !matches!(last, b'l' | b's' | b'z') {
1641 b.pop();
1642 }
1643 } else if cvc(b) {
1644 b.extend_from_slice(b"e");
1645 }
1646}
1647
1648fn cvc(b: &[u8]) -> bool {
1649 if b.len() < 3 {
1650 return false;
1651 }
1652 let l = b.len();
1653 if !(is_vowel(b, l - 2) && !is_vowel(b, l - 3) && !is_vowel(b, l - 1)) {
1654 return false;
1655 }
1656 !matches!(b[l - 1], b'w' | b'x' | b'y')
1657}
1658
1659fn step1b(b: &mut Vec<u8>) {
1660 if ends_with(b, b"eed") {
1661 if measure_stem(b, 3) > 0 {
1662 replace_suffix(b, 3, b"ee");
1663 }
1664 return;
1665 }
1666 if ends_with(b, b"ed") {
1667 let stem_has_vowel = has_vowel(&b[..b.len() - 2]);
1668 if stem_has_vowel {
1669 replace_suffix(b, 2, b"");
1670 step1b_post(b);
1671 }
1672 return;
1673 }
1674 if ends_with(b, b"ing") {
1675 let stem_has_vowel = has_vowel(&b[..b.len() - 3]);
1676 if stem_has_vowel {
1677 replace_suffix(b, 3, b"");
1678 step1b_post(b);
1679 }
1680 }
1681}
1682
1683fn step1c(b: &mut Vec<u8>) {
1684 if ends_with(b, b"y") && has_vowel(&b[..b.len() - 1]) {
1685 replace_suffix(b, 1, b"i");
1686 }
1687}
1688
1689const STEP2_RULES: &[(&[u8], &[u8])] = &[
1690 (b"ational", b"ate"),
1691 (b"tional", b"tion"),
1692 (b"enci", b"ence"),
1693 (b"anci", b"ance"),
1694 (b"izer", b"ize"),
1695 (b"abli", b"able"),
1696 (b"alli", b"al"),
1697 (b"entli", b"ent"),
1698 (b"eli", b"e"),
1699 (b"ousli", b"ous"),
1700 (b"ization", b"ize"),
1701 (b"ation", b"ate"),
1702 (b"ator", b"ate"),
1703 (b"alism", b"al"),
1704 (b"iveness", b"ive"),
1705 (b"fulness", b"ful"),
1706 (b"ousness", b"ous"),
1707 (b"aliti", b"al"),
1708 (b"iviti", b"ive"),
1709 (b"biliti", b"ble"),
1710];
1711
1712fn step2(b: &mut Vec<u8>) {
1713 for (suf, repl) in STEP2_RULES {
1714 if ends_with(b, suf) && measure_stem(b, suf.len()) > 0 {
1715 replace_suffix(b, suf.len(), repl);
1716 return;
1717 }
1718 }
1719}
1720
1721const STEP3_RULES: &[(&[u8], &[u8])] = &[
1722 (b"icate", b"ic"),
1723 (b"ative", b""),
1724 (b"alize", b"al"),
1725 (b"iciti", b"ic"),
1726 (b"ical", b"ic"),
1727 (b"ful", b""),
1728 (b"ness", b""),
1729];
1730
1731fn step3(b: &mut Vec<u8>) {
1732 for (suf, repl) in STEP3_RULES {
1733 if ends_with(b, suf) && measure_stem(b, suf.len()) > 0 {
1734 replace_suffix(b, suf.len(), repl);
1735 return;
1736 }
1737 }
1738}
1739
1740const STEP4_RULES: &[&[u8]] = &[
1741 b"al", b"ance", b"ence", b"er", b"ic", b"able", b"ible", b"ant", b"ement", b"ment", b"ent",
1742 b"ou", b"ism", b"ate", b"iti", b"ous", b"ive", b"ize",
1743];
1744
1745fn step4(b: &mut Vec<u8>) {
1746 if ends_with(b, b"ion") && measure_stem(b, 3) > 1 {
1748 let stem = &b[..b.len() - 3];
1749 if matches!(stem.last(), Some(b's') | Some(b't')) {
1750 replace_suffix(b, 3, b"");
1751 return;
1752 }
1753 }
1754 for suf in STEP4_RULES {
1755 if ends_with(b, suf) && measure_stem(b, suf.len()) > 1 {
1756 replace_suffix(b, suf.len(), b"");
1757 return;
1758 }
1759 }
1760}
1761
1762fn step5a(b: &mut Vec<u8>) {
1763 if ends_with(b, b"e") {
1764 let m = measure_stem(b, 1);
1765 if m > 1 || (m == 1 && !cvc(&b[..b.len() - 1])) {
1766 replace_suffix(b, 1, b"");
1767 }
1768 }
1769}
1770
1771fn step5b(b: &mut Vec<u8>) {
1772 if b.len() >= 2 && b[b.len() - 1] == b'l' && b[b.len() - 2] == b'l' && measure(b) > 1 {
1773 b.pop();
1774 }
1775}
1776
1777#[cfg(test)]
1778mod tests {
1779
1780 use super::*;
1781
1782 #[test]
1783 fn porter_simple_cases() {
1784 assert_eq!(porter_stem("caresses"), "caress");
1785 assert_eq!(porter_stem("ponies"), "poni");
1786 assert_eq!(porter_stem("ties"), "tie");
1788 assert_eq!(porter_stem("cats"), "cat");
1789 assert_eq!(porter_stem("running"), "run");
1790 assert_eq!(porter_stem("happy"), "happi");
1791 assert_eq!(porter_stem("relational"), "relat");
1792 assert_eq!(porter_stem("conditional"), "condit");
1793 assert_eq!(porter_stem("hopefulness"), "hope");
1794 }
1795
1796 #[test]
1797 fn english_drops_stopwords_and_stems() {
1798 let v = to_tsvector(
1799 TsConfig::English,
1800 "The quick brown foxes are jumping over the lazy dogs",
1801 );
1802 let words: Vec<&str> = v.iter().map(|l| l.word.as_str()).collect();
1803 assert!(words.contains(&"fox"), "expected `fox`, got {words:?}");
1807 assert!(words.contains(&"jump"), "expected `jump`, got {words:?}");
1808 assert!(words.contains(&"dog"), "expected `dog`, got {words:?}");
1809 assert!(!words.contains(&"the"), "stopword `the` leaked: {words:?}");
1810 assert!(!words.contains(&"are"), "stopword `are` leaked: {words:?}");
1811 }
1812
1813 #[test]
1814 fn simple_preserves_words() {
1815 let v = to_tsvector(TsConfig::Simple, "The Quick brown Foxes");
1816 let words: Vec<&str> = v.iter().map(|l| l.word.as_str()).collect();
1817 assert_eq!(words, alloc::vec!["brown", "foxes", "quick", "the"]);
1819 }
1820
1821 #[test]
1822 fn plainto_tsquery_drops_stopwords() {
1823 let q = plainto_tsquery(TsConfig::English, "the quick brown fox");
1824 let s = crate::eval::format_tsquery(&q);
1826 assert_eq!(s, "'quick' & 'brown' & 'fox'");
1827 }
1828
1829 #[test]
1830 fn to_tsquery_stems_terms() {
1831 let q = to_tsquery(TsConfig::English, "running & jumps").unwrap();
1832 let s = crate::eval::format_tsquery(&q);
1833 assert_eq!(s, "'run' & 'jump'");
1834 }
1835}