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