Skip to main content

spg_engine/
fts.rs

1//! v7.12.1 — full-text search lexer / stemmer.
2//!
3//! Powers `to_tsvector`, `plainto_tsquery`, `to_tsquery`, and
4//! friends. Two configs are supported in v7.12:
5//!   - `simple` — lowercase + tokenise; no stopwords, no stemming.
6//!   - `english` — lowercase + tokenise + drop PG-standard
7//!     english stopwords + Porter v1 stem.
8//!
9//! Other configs (`spanish`, `german`, `russian`, …) error with
10//! `EvalError::TypeMismatch` carrying the unsupported-config name
11//! so callers see the same shape as `::regtype` rejection.
12//!
13//! Porter stemmer implementation follows the original 1980
14//! Algorithm; corner-case behaviour matches Snowball english v1
15//! (the variant PG also uses).
16
17use alloc::string::{String, ToString};
18use alloc::vec::Vec;
19
20use spg_storage::{TsLexeme, TsQueryAst};
21
22use crate::eval::EvalError;
23
24/// v7.12.1 — supported tokeniser configs.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum TsConfig {
27    /// `simple` / `pg_catalog.simple` — lowercase + split, no
28    /// stopword drop, no stem.
29    Simple,
30    /// `english` / `pg_catalog.english` — lowercase + split +
31    /// stopword drop + Porter v1 stem.
32    English,
33}
34
35impl TsConfig {
36    /// Resolve a PG text-search config name. The PG-qualified
37    /// form `pg_catalog.<name>` is accepted too. Returns `None`
38    /// for any other name so the caller can produce a clear
39    /// "config not implemented" error listing what is supported.
40    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
50/// v7.12.1 — tokenise + (optionally) stem `text` into a sorted +
51/// deduped lexeme set with merged positions. Each token's
52/// position is 1-based and clamped at 16383 (PG18-measured, round
53/// 753: a 20k-word document's positions top out at 16383 — the
54/// `MAXENTRYPOS - 1` clamp — while every lexeme is still recorded).
55pub fn to_tsvector(config: TsConfig, text: &str) -> Vec<TsLexeme> {
56    let mut out: Vec<TsLexeme> = Vec::new();
57    let mut position: u16 = 0;
58    // v7.39 (round 651) — the TOKEN's type picks the dictionary, which
59    // is what `pg_ts_config_map` records. Two things fall out that the
60    // config alone could not give: a tag or an entity maps to nothing
61    // and so never reaches the index, and a number under the `english`
62    // configuration goes to `simple` rather than through the stemmer.
63    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                    // PG drops stopwords from the vector but
74                    // still increments position so phrase
75                    // distances stay meaningful.
76                    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
107/// v7.12.1 — `plainto_tsquery(config, text)`: tokenise + stem,
108/// fold the surviving lexemes into an AND tree. Returns
109/// `EvalError::TypeMismatch` only for an unsupported config — an
110/// all-stopwords input becomes an empty `Term("")` so the caller
111/// can detect it.
112pub fn plainto_tsquery(config: TsConfig, text: &str) -> TsQueryAst {
113    let lexs = collect_lexemes(config, text);
114    fold_and(&lexs)
115}
116
117/// v7.12.1 — `phraseto_tsquery(config, text)`: same tokenise + stem,
118/// but preserve order — fold into nested phrase nodes whose `<N>`
119/// distance is the position gap between surviving lexemes. Dropped
120/// stopwords still advance the position counter (as in `to_tsvector`),
121/// so `'cats and dogs'` yields `'cat' <2> 'dog'`, matching PG.
122pub fn phraseto_tsquery(config: TsConfig, text: &str) -> TsQueryAst {
123    let lexs = collect_lexemes_positioned(config, text);
124    fold_phrase_positioned(&lexs)
125}
126
127/// v7.12.1 — `websearch_to_tsquery(config, text)`: Google-style
128/// syntax. Quoted phrases → phrase node; `OR` (case-insensitive)
129/// → OR; leading `-` → NOT; otherwise AND.
130///
131/// The grammar is liberal — malformed input degrades instead of
132/// erroring. PG18-measured (round 753): an unclosed quote still
133/// forms a phrase (`"unclosed phrase` → `'unclosed' <-> 'phrase'`,
134/// SPG agrees); bare operator words become terms (`or or and -` →
135/// `'or' | 'and'` in PG, SPG answers `'and'` — the leading or-term
136/// is dropped; ledgered as F31-B7).
137pub fn websearch_to_tsquery(config: TsConfig, text: &str) -> TsQueryAst {
138    let mut tokens = web_tokens(text);
139    // Apply config to each plain term + each phrase part.
140    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    // Group by OR boundaries; within each group AND together.
161    let mut or_groups: Vec<Vec<TsQueryAst>> = alloc::vec![Vec::new()];
162    // v7.39 (round 756, F31-B7) — dashes seen since the last operand;
163    // each wraps one `Not` level (PG stacks: `--apple` → !!'apple').
164    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
224/// v7.12.1 — `to_tsquery(config, text)`: explicit operator syntax
225/// over already-stemmed terms. Reuses the v7.12.0 external-form
226/// parser, then walks each leaf through `porter_stem` (when the
227/// config is `english`). Returns `TypeMismatch` on malformed input.
228pub 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    // v7.39 (round 245) — the english config drops stopwords from a QUERY
232    // too, collapsing the tree around them: PG's
233    // `to_tsquery('english','!(a & b)')` is `!'b'` because `a` is a
234    // stopword. SPG kept the stopword term, so the query demanded a
235    // lexeme no vector ever contains. A tree that is ALL stopwords is
236    // left as parsed (PG returns an empty tsquery there — an empty-tree
237    // representation SPG doesn't have; recorded residual).
238    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
384/// v7.39 (read01 round 43) — tokenise + stem while tracking each
385/// surviving lexeme's tsvector position. Dropped stopwords advance the
386/// counter without emitting a lexeme, mirroring `to_tsvector`, so the
387/// gap between consecutive survivors is PG's phrase distance.
388fn 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
416/// v7.39 (read01 round 43) — fold positioned lexemes into a phrase
417/// chain whose `<N>` distance is the position delta between neighbours.
418fn 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/// v7.12.2 — evaluate `tsvector @@ tsquery`. Walks the query AST
448/// treating each leaf as "does the vector contain this lexeme".
449/// Phrase semantics: the v7.12.2 implementation honours the
450/// `<N>` distance — both operand terms must appear with their
451/// positions exactly `N` apart in the vector. Higher-arity
452/// phrase chains nest as `Phrase(Phrase(a,b,1), c, 1)`, so the
453/// match recursion folds position sets across the AND of the
454/// chain (a fully general n-gram match in a single pass).
455#[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
474/// v7.39 (round 245) — Term matching with the two mask-carried modifiers:
475/// bit 4 is the PREFIX flag (`fox:*` — any lexeme starting with the word
476/// matches) and the low four bits the accepted-weight set (`0` = any).
477fn 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
490/// Phrase positions of a sub-AST. For atomic terms returns the
491/// vector's recorded positions; for nested phrases returns the
492/// rightmost position of each surviving match. Empty positions
493/// mean "no match anywhere".
494fn 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        // For mixed-shape phrases (Phrase contains an AND/OR/NOT),
521        // fall back to the boolean match (no position tracking).
522        _ => {
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/// v7.12.2 — `ts_rank(vec, q)` basic form. Score is the sum of
542/// per-matched-lexeme weight factors divided by `1 + log(unique
543/// terms in query)`. Matches PG's `ts_rank` with default
544/// normalisation flag 0.
545#[must_use]
546pub fn ts_rank(weights: &RankWeights, vec: &[TsLexeme], query: &TsQueryAst) -> f32 {
547    // v7.38 (read01, T12.1) — PG's calc_rank: an AND/PHRASE-rooted query uses
548    // the cover (distance-weighted) branch, everything else the OR branch.
549    // Normalization flag defaults to 0 (no length/uniqueness division).
550    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    // calc_rank_and delegates to calc_rank_or for a single distinct term.
557    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/// v7.12.2 — `ts_rank_cd(vec, q)` cover-density variant. Higher
565/// score when matched lexemes cluster closer together; defaults
566/// to a per-lexeme contribution divided by the average gap
567/// between matched positions. Returns 0 when no terms match.
568#[must_use]
569pub fn ts_rank_cd(weights: &RankWeights, vec: &[TsLexeme], query: &TsQueryAst) -> f32 {
570    // v7.38 (read01, T12.1) — PG's calc_rank_cd cover density. Sum, over each
571    // minimal cover (window containing every distinct query term), a
572    // per-cover weight `Cpos = (#entries / Σ 1/weight) / (noise + 1)`, where
573    // noise is the extra positional span beyond the matched entries. Default
574    // normalization flag 0 (no division).
575    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    // doc = (position, term-index, weight), sorted by position.
581    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        // Grow a window from `start` until every distinct term is present.
595        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; // no further cover
610        }
611        // Shrink from the left to the minimal cover.
612        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/// v7.38 (read01, T12.1) — apply PG's ranking normalization bitmask to a raw
639/// rank. Flags are applied in PG's order over the tsvector's total position
640/// count (`len`) and distinct-lexeme count (`uniq`):
641///   1 → /log2(len+1) · 2 → /len · 8 → /uniq · 16 → /log2(uniq+1) · 32 → r/(r+1)
642/// (flag 4, the cover-extent distance, is cover-density only and handled by the
643/// caller.) Verified against live PG 18.4.
644#[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
670/// `f32::ln` is std-only; spg-engine is no_std. Reuse the bit-
671/// trick decomposition the spg-storage bloom filter uses
672/// (precision ≈ 1e-7, ample for ranking).
673fn 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
690/// v7.38 (read01, T12.1) — a ts_rank weight array in PG order `[D, C, B, A]`.
691pub type RankWeights = [f32; 4];
692/// PG default weights: D=0.1, C=0.2, B=0.4, A=1.0.
693pub const DEFAULT_RANK_WEIGHTS: RankWeights = [0.1, 0.2, 0.4, 1.0];
694
695fn weight_factor(w: u8, weights: &RankWeights) -> f32 {
696    // Weight byte: D=0, C=1, B=2, A=3 — a direct index into the PG-order array.
697    weights[(w as usize).min(3)]
698}
699
700/// v7.38 (read01, T12.1) — no_std `exp`, sibling of `ln_approx`. Range-reduce
701/// `x = k·ln2 + r` and evaluate `2^k · e^r` with a Taylor series on the small
702/// remainder; saturate the far tails.
703fn 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    // e^r, r in [-ln2/2, ln2/2]; 7 Taylor terms.
714    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
723/// 2^k for an integer-valued `k` (built from the f64 exponent field).
724fn libm_exp2(k: f64) -> f64 {
725    let ki = k as i64;
726    f64::from_bits((((ki + 1023) as u64) & 0x7ff) << 52)
727}
728
729/// v7.38 (read01, T12.1) — PG `word_distance`: how much a lexeme gap of `d`
730/// positions dampens an AND cover's contribution. Only `calc_rank_and` uses it.
731fn word_distance(d: u32) -> f32 {
732    1.0 / (1.005 + 0.05 * exp_approx((d as f32) / 1.5 - 2.0))
733}
734
735/// A matched query-term occurrence: which query term, its position, its weight.
736struct RankEntry {
737    term: usize,
738    pos: u16,
739    w: f32,
740}
741
742/// Collect the distinct query-term words (in first-seen order).
743fn 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
762/// PG `calc_rank_or`: sum a per-term contribution over the term's positions,
763/// then divide by the number of distinct query terms.
764fn 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
785/// PG `calc_rank_and`: probabilistic-OR combine of every position pair from
786/// DISTINCT query terms, each weighted by the inter-position `word_distance`.
787fn 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; // MAXENTRYPOS
806            }
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
818/// no_std `sqrt` for f32 (Newton, ample precision for ranking).
819fn 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/// Tokenise on Unicode word boundaries — anything that is not an
831/// alphanumeric scalar value (or `_`) splits the token. Lowercases
832/// each emitted token.
833/// v7.39 (round 651) — PG's token types, as `ts_token_type('default')`
834/// publishes them. Only the ones SPG's parser actually produces are
835/// here; the numbering is PG's so `pg_ts_config_map.maptokentype` and
836/// `ts_debug.alias` agree with it.
837///
838/// The four PG does NOT map to any dictionary — blank(12), tag(13),
839/// protocol(14), entity(23) — are recognised precisely so they can be
840/// DROPPED. That is the difference between indexing `<b>x</b>` as `x`,
841/// which PG does, and as `b`, `x`, `b`, which SPG did.
842#[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    /// PG's `alias` column.
871    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    /// PG's `description` column, verbatim.
900    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    /// Which dictionary a configuration sends this token to, or `None`
929    /// when the configuration maps it to nothing and the token produces
930    /// no lexeme at all. Read off PG18's `pg_ts_config_map`: the same
931    /// nineteen types are mapped by both `simple` and `english`, and
932    /// the four that are not are blank, tag, protocol and entity.
933    pub const fn dictionary(self, english: bool) -> Option<TsDict> {
934        match self {
935            Self::Blank | Self::Tag | Self::Protocol | Self::Entity => None,
936            // The stemmer only ever sees words; everything with digits,
937            // punctuation or structure goes to `simple` even under the
938            // english configuration — measured, and the reason
939            // `to_tsvector('english', '42')` is `42` and not a stem.
940            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/// The two dictionaries SPG has (round 650).
956#[derive(Debug, Clone, Copy, PartialEq, Eq)]
957pub enum TsDict {
958    Simple,
959    EnglishStem,
960}
961
962/// v7.39 (round 651) — a typed token, PG-shaped.
963#[derive(Debug, Clone)]
964pub struct Token {
965    pub text: String,
966    pub ty: TokenType,
967}
968
969/// The old tokenizer, kept for callers that want bare words.
970pub 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
978/// v7.39 (round 651) — split `text` the way PG's default parser does.
979///
980/// What this replaces was sixteen lines: "split on anything that is not
981/// alphanumeric". Measured against PG across its 23 token types, that
982/// agreed on FOUR — asciiword, word, numword and uint — and differed on
983/// thirteen. `user@example.com` indexed as `user`, `example`, `com` so a
984/// search for the address found nothing; `3.14` became `3` and `14`;
985/// `-42` lost its sign; and `<b>x</b>` put the tag name `b` INTO the
986/// index, twice. That last one is not a near-miss, it is markup
987/// polluting the search results.
988///
989/// Recognised here, longest-shape-first because the shapes nest (an
990/// email contains a host, a url contains a host and a path):
991/// tag, entity, url/protocol, email, file, host, version, sfloat,
992/// float, signed int, hyphenated word (compound AND parts, as PG emits
993/// both), and finally plain words and unsigned integers.
994pub 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        // `<...>` — an XML tag. PG emits it as a `tag` token, which no
1006        // configuration maps, so nothing of it reaches the index.
1007        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        // `&name;` / `&#123;` — an XML entity, likewise unmapped.
1018        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        // A leading `/` belongs to the path it starts: PG's `file` token
1030        // for `/usr/local/bin` keeps it, and a token that drops it is a
1031        // different string to search for.
1032        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            // A signed integer only counts as one at the start of a run.
1037            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                // Stop before a trailing separator that is really
1043                // punctuation: `end.` / `a/b,` — the separator has to be
1044                // followed by more of the token.
1045                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
1061/// Assign a PG token type to one raw run, emitting the sub-parts PG
1062/// emits alongside a compound.
1063/// The original text for a run whose lowercased form is `lower`. Equal
1064/// lengths is the common case (ASCII); when a fold changed the byte
1065/// length the lowercased form is the honest fallback — `ts_debug`'s
1066/// `token` column would otherwise slice mid-character.
1067fn 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
1072/// Same, for the part of a run after an optional `proto://` head.
1073fn 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    // Classification reads the lowercased form; what is PUSHED is the
1084    // original, so the two never disagree about which token this is
1085    // while still reporting what was written.
1086    let lower = raw.to_lowercase();
1087    let _ = &lower;
1088    // v7.39 (round 651) — the token keeps the text the PARSER saw.
1089    // Lowercasing is the DICTIONARY's job, which is why `ts_debug`'s
1090    // `token` column shows `The` while its `lexemes` shows `{}`.
1091    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    // A `proto://` head is its own token and maps to nothing; what
1104    // follows is judged on its own, exactly as the same string without
1105    // the head would be.
1106    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    // url vs file, and the discriminator is measured rather than
1116    // guessed. `ts_debug` on PG18: `http://example.com/a/b` gives url +
1117    // host + url_path, `http://x.y/z` gives a single `file`, and
1118    // `http://x.co/z` gives url again — so what decides it is whether
1119    // the part before the first `/` looks like a HOST, and a one-letter
1120    // last label does not. An earlier version of this function emitted
1121    // host and path for every URL because the type list says those
1122    // types exist; that put `x.y` and `/z` into the index where PG has
1123    // neither.
1124    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    // email: one `@`, something either side, a dot on the right
1144    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    // hyphenated word: PG emits the compound AND each part
1153    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        // `1.5e10` — scientific notation
1189        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    /// v7.39 (round 756, F31-B7) — one `-` prefix. PG18-measured: a
1232    /// dash attaches ACROSS whitespace to the next word or phrase and
1233    /// STACKS (`- apple` → `!'apple'`, `-"a b"` → `!('a' <-> 'b')`,
1234    /// `--apple` / `- - apple` → `!!'apple'`); the old tokenizer only
1235    /// negated a directly-attached word and dropped the rest.
1236    Neg,
1237}
1238
1239/// websearch tokenizer — splits on whitespace, recognises quoted
1240/// phrases, leading `-` for NOT, and bare `OR` (case-insensitive).
1241fn 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; // close quote
1265            }
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    // v7.39 (round 756, F31-B7) — PG18-measured: the word "or" is an
1285    // OR operator only when it has a left operand and is not the last
1286    // token. At operand position (start, or right after another OR)
1287    // and at end of input it is a plain term: 'or apple' → 'or' &
1288    // 'apple', 'apple or' → 'apple' & 'or', 'or or and -' → 'or' |
1289    // 'and'. (An operator whose right side comes up empty still
1290    // vanishes with it — 'apple or -' → 'apple' — which the grouping
1291    // below already does by dropping empty OR groups.)
1292    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        // A `-` prefix leaves us still waiting for the operand.
1299        at_operand_pos = match out[idx] {
1300            WebToken::Or => true,
1301            WebToken::Neg => at_operand_pos,
1302            _ => false,
1303        };
1304    }
1305    out
1306}
1307
1308/// PG's standard english stopword list (`tsearch_data/english.stop`).
1309/// Subset of the 127 words in PG 17's distribution — verbatim.
1310pub 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
1442// --------------------------------------------------------------
1443// Porter stemmer (English, original 1980 algorithm). Operates on
1444// pure ASCII — non-ASCII input falls through unchanged.
1445// --------------------------------------------------------------
1446
1447/// v7.12.1 — Porter v1 stem. Lowercased ASCII input gives a
1448/// stemmed form; non-ASCII characters bypass the algorithm
1449/// (returned verbatim).
1450/// v7.39 (round 245) — Snowball's exceptional forms: a handful of words
1451/// whose stem the algorithm gets wrong are mapped directly (skies→sky and
1452/// friends), and a few short words are left untouched. PG's english
1453/// config is the Snowball stemmer, so these are observable in every
1454/// to_tsvector/to_tsquery differential.
1455fn 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    // Safe: we only ever produced ASCII via the steps above.
1504    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
1515/// Porter's `m` measure — the number of `[C](VC)^m[V]` units.
1516fn 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        // v7.39 (round 245) — Snowball's refinement of Porter's rule:
1554        // `ies` becomes `ie` when only one letter precedes it (dies→die,
1555        // ties→tie) and `i` otherwise (cries→cri, flies→fli). The
1556        // unconditional `i` gave PG-divergent stems for the short words.
1557        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        // No change.
1564    } 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    // Special-case `ion` — only strip when preceded by s/t.
1685    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        // v7.39 (round 245) — Snowball's short-word rule (and PG): tie.
1724        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        // Stopwords removed: the, are, over
1741        // Stems: quick → quick, brown → brown, foxes → fox,
1742        // jumping → jump, lazy → lazi, dogs → dog.
1743        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        // Sorted ascending.
1755        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        // Expect (quick & brown) & fox after stopword drop.
1762        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}