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    /// v7.38.18 — `spanish`: lowercase + split + Snowball's 313-word
34    /// Spanish stopword list + the Snowball Spanish stem.
35    Spanish,
36    /// v7.38.18 — `french`: Snowball's 155-word French stopword list +
37    /// the Snowball French stem.
38    French,
39    /// v7.38.18 — `german`: Snowball's 231-word German stopword list +
40    /// the Snowball German stem.
41    German,
42}
43
44impl TsConfig {
45    /// v7.38.18 — does this configuration stem at all?
46    ///
47    /// The four places that decide stopwords and stemming used to ask
48    /// `config.stems()`, a two-valued question.
49    /// Adding a language to an enum those read as a boolean would have
50    /// made it tokenise without dropping a stopword and without
51    /// stemming — silently, since every token still comes out.
52    pub const fn stems(self) -> bool {
53        !matches!(self, Self::Simple)
54    }
55
56    /// This configuration's stopword list, or `None` when it has none.
57    pub fn stopwords(self) -> Option<&'static [&'static str]> {
58        match self {
59            Self::Simple => None,
60            Self::English => None, // its own list, see `is_english_stopword`
61            Self::Spanish => Some(crate::fts_stop::ES_STOP),
62            Self::French => Some(crate::fts_stop::FR_STOP),
63            Self::German => Some(crate::fts_stop::DE_STOP),
64        }
65    }
66
67    /// Is `w` a stopword under this configuration?
68    pub fn is_stopword(self, w: &str) -> bool {
69        match self {
70            Self::Simple => false,
71            Self::English => is_english_stopword(w),
72            Self::Spanish => crate::fts_stop::is_stop(crate::fts_stop::ES_STOP, w),
73            Self::French => crate::fts_stop::is_stop(crate::fts_stop::FR_STOP, w),
74            Self::German => crate::fts_stop::is_stop(crate::fts_stop::DE_STOP, w),
75        }
76    }
77
78    /// The stem `w` reduces to under this configuration.
79    pub fn stem(self, w: &str) -> String {
80        match self {
81            Self::Simple => String::from(w),
82            Self::English => porter_stem(w),
83            Self::Spanish => crate::fts_es::stem_es(w),
84            Self::French => crate::fts_fr::stem_fr(w),
85            Self::German => crate::fts_de::stem_de(w),
86        }
87    }
88
89    /// Resolve a PG text-search config name. The PG-qualified
90    /// form `pg_catalog.<name>` is accepted too. Returns `None`
91    /// for any other name so the caller can produce a clear
92    /// "config not implemented" error listing what is supported.
93    pub fn from_name(name: &str) -> Option<Self> {
94        let bare = name.strip_prefix("pg_catalog.").unwrap_or(name);
95        match bare.to_ascii_lowercase().as_str() {
96            "simple" => Some(Self::Simple),
97            "english" => Some(Self::English),
98            // v7.38.18 — Snowball's other three, each implemented from
99            // the published algorithm and verified word-for-word
100            // against PG 18.4. See `fts_es` / `fts_fr` / `fts_de`.
101            "spanish" => Some(Self::Spanish),
102            "french" => Some(Self::French),
103            "german" => Some(Self::German),
104            _ => None,
105        }
106    }
107}
108
109/// v7.12.1 — tokenise + (optionally) stem `text` into a sorted +
110/// deduped lexeme set with merged positions. Each token's
111/// position is 1-based and clamped at 16383 (PG18-measured, round
112/// 753: a 20k-word document's positions top out at 16383 — the
113/// `MAXENTRYPOS - 1` clamp — while every lexeme is still recorded).
114pub fn to_tsvector(config: TsConfig, text: &str) -> Vec<TsLexeme> {
115    let mut out: Vec<TsLexeme> = Vec::new();
116    let mut position: u16 = 0;
117    // v7.39 (round 651) — the TOKEN's type picks the dictionary, which
118    // is what `pg_ts_config_map` records. Two things fall out that the
119    // config alone could not give: a tag or an entity maps to nothing
120    // and so never reaches the index, and a number under the `english`
121    // configuration goes to `simple` rather than through the stemmer.
122    let english = config.stems();
123    for token in tokenize_typed(text) {
124        let Some(dict) = token.ty.dictionary(english) else {
125            continue;
126        };
127        let folded = token.text.to_lowercase();
128        let lex = match dict {
129            TsDict::Simple => folded,
130            TsDict::EnglishStem => {
131                if config.is_stopword(&folded) {
132                    // PG drops stopwords from the vector but
133                    // still increments position so phrase
134                    // distances stay meaningful.
135                    position = position.saturating_add(1).min(16383);
136                    continue;
137                }
138                config.stem(&folded)
139            }
140        };
141        if lex.is_empty() {
142            continue;
143        }
144        position = position.saturating_add(1).min(16383);
145        match out.binary_search_by(|l| l.word.as_str().cmp(lex.as_str())) {
146            Ok(idx) => {
147                if !out[idx].positions.contains(&position) {
148                    out[idx].positions.push(position);
149                }
150            }
151            Err(idx) => {
152                out.insert(
153                    idx,
154                    TsLexeme {
155                        word: lex,
156                        positions: alloc::vec![position],
157                        weight: 0,
158                    },
159                );
160            }
161        }
162    }
163    out
164}
165
166/// v7.12.1 — `plainto_tsquery(config, text)`: tokenise + stem,
167/// fold the surviving lexemes into an AND tree. Returns
168/// `EvalError::TypeMismatch` only for an unsupported config — an
169/// all-stopwords input becomes an empty `Term("")` so the caller
170/// can detect it.
171pub fn plainto_tsquery(config: TsConfig, text: &str) -> TsQueryAst {
172    let lexs = collect_lexemes(config, text);
173    fold_and(&lexs)
174}
175
176/// v7.12.1 — `phraseto_tsquery(config, text)`: same tokenise + stem,
177/// but preserve order — fold into nested phrase nodes whose `<N>`
178/// distance is the position gap between surviving lexemes. Dropped
179/// stopwords still advance the position counter (as in `to_tsvector`),
180/// so `'cats and dogs'` yields `'cat' <2> 'dog'`, matching PG.
181pub fn phraseto_tsquery(config: TsConfig, text: &str) -> TsQueryAst {
182    let lexs = collect_lexemes_positioned(config, text);
183    fold_phrase_positioned(&lexs)
184}
185
186/// v7.12.1 — `websearch_to_tsquery(config, text)`: Google-style
187/// syntax. Quoted phrases → phrase node; `OR` (case-insensitive)
188/// → OR; leading `-` → NOT; otherwise AND.
189///
190/// The grammar is liberal — malformed input degrades instead of
191/// erroring. PG18-measured (round 753): an unclosed quote still
192/// forms a phrase (`"unclosed phrase` → `'unclosed' <-> 'phrase'`,
193/// SPG agrees); bare operator words become terms (`or or and -` →
194/// `'or' | 'and'` in PG, SPG answers `'and'` — the leading or-term
195/// is dropped; ledgered as F31-B7).
196pub fn websearch_to_tsquery(config: TsConfig, text: &str) -> TsQueryAst {
197    let mut tokens = web_tokens(text);
198    // Apply config to each plain term + each phrase part.
199    for t in &mut tokens {
200        match t {
201            WebToken::Term(s) => {
202                let lexs = collect_lexemes(config, s);
203                *s = lexs.join(" ");
204            }
205            WebToken::Phrase(words) => {
206                let mut combined = String::new();
207                for w in words.iter() {
208                    if !combined.is_empty() {
209                        combined.push(' ');
210                    }
211                    combined.push_str(w);
212                }
213                let lexs = collect_lexemes(config, &combined);
214                *words = lexs;
215            }
216            WebToken::Or | WebToken::Neg => {}
217        }
218    }
219    // Group by OR boundaries; within each group AND together.
220    let mut or_groups: Vec<Vec<TsQueryAst>> = alloc::vec![Vec::new()];
221    // v7.39 (round 756, F31-B7) — dashes seen since the last operand;
222    // each wraps one `Not` level (PG stacks: `--apple` → !!'apple').
223    let mut pending_negs = 0usize;
224    let mut push_node = |groups: &mut Vec<Vec<TsQueryAst>>, negs: usize, node: TsQueryAst| {
225        let mut node = node;
226        for _ in 0..negs {
227            node = TsQueryAst::Not(alloc::boxed::Box::new(node));
228        }
229        groups.last_mut().unwrap().push(node);
230    };
231    let mut i = 0;
232    while i < tokens.len() {
233        match &tokens[i] {
234            WebToken::Or => {
235                or_groups.push(Vec::new());
236                pending_negs = 0;
237            }
238            WebToken::Neg => {
239                pending_negs += 1;
240            }
241            WebToken::Term(s) => {
242                if !s.is_empty() {
243                    push_node(&mut or_groups, pending_negs, fold_and(&split_words(s)));
244                }
245                pending_negs = 0;
246            }
247            WebToken::Phrase(words) => {
248                if !words.is_empty() {
249                    push_node(&mut or_groups, pending_negs, fold_phrase(words));
250                }
251                pending_negs = 0;
252            }
253        }
254        i += 1;
255    }
256    let group_nodes: Vec<TsQueryAst> = or_groups
257        .into_iter()
258        .filter_map(|g| {
259            if g.is_empty() {
260                None
261            } else {
262                let mut it = g.into_iter();
263                let first = it.next().unwrap();
264                Some(it.fold(first, |acc, n| {
265                    TsQueryAst::And(alloc::boxed::Box::new(acc), alloc::boxed::Box::new(n))
266                }))
267            }
268        })
269        .collect();
270    if group_nodes.is_empty() {
271        return TsQueryAst::Term {
272            word: String::new(),
273            weight_mask: 0,
274        };
275    }
276    let mut it = group_nodes.into_iter();
277    let first = it.next().unwrap();
278    it.fold(first, |acc, n| {
279        TsQueryAst::Or(alloc::boxed::Box::new(acc), alloc::boxed::Box::new(n))
280    })
281}
282
283/// v7.12.1 — `to_tsquery(config, text)`: explicit operator syntax
284/// over already-stemmed terms. Reuses the v7.12.0 external-form
285/// parser, then walks each leaf through `porter_stem` (when the
286/// config is `english`). Returns `TypeMismatch` on malformed input.
287pub fn to_tsquery(config: TsConfig, text: &str) -> Result<TsQueryAst, EvalError> {
288    let mut ast = crate::eval::decode_tsquery_external(text)?;
289    stem_tsquery_in_place(&mut ast, config);
290    // v7.39 (round 245) — the english config drops stopwords from a QUERY
291    // too, collapsing the tree around them: PG's
292    // `to_tsquery('english','!(a & b)')` is `!'b'` because `a` is a
293    // stopword. SPG kept the stopword term, so the query demanded a
294    // lexeme no vector ever contains. A tree that is ALL stopwords is
295    // left as parsed (PG returns an empty tsquery there — an empty-tree
296    // representation SPG doesn't have; recorded residual).
297    if config.stems()
298        && let Some(pruned) = prune_stopword_terms(&ast)
299    {
300        ast = pruned;
301    }
302    Ok(ast)
303}
304
305fn prune_stopword_terms(ast: &TsQueryAst) -> Option<TsQueryAst> {
306    match ast {
307        TsQueryAst::Term { word, .. } => {
308            if is_english_stopword(word) {
309                None
310            } else {
311                Some(ast.clone())
312            }
313        }
314        TsQueryAst::And(a, b) => match (prune_stopword_terms(a), prune_stopword_terms(b)) {
315            (Some(x), Some(y)) => Some(TsQueryAst::And(
316                alloc::boxed::Box::new(x),
317                alloc::boxed::Box::new(y),
318            )),
319            (Some(x), None) | (None, Some(x)) => Some(x),
320            (None, None) => None,
321        },
322        TsQueryAst::Or(a, b) => match (prune_stopword_terms(a), prune_stopword_terms(b)) {
323            (Some(x), Some(y)) => Some(TsQueryAst::Or(
324                alloc::boxed::Box::new(x),
325                alloc::boxed::Box::new(y),
326            )),
327            (Some(x), None) | (None, Some(x)) => Some(x),
328            (None, None) => None,
329        },
330        TsQueryAst::Not(x) => {
331            prune_stopword_terms(x).map(|p| TsQueryAst::Not(alloc::boxed::Box::new(p)))
332        }
333        TsQueryAst::Phrase {
334            left,
335            right,
336            distance,
337        } => match (prune_stopword_terms(left), prune_stopword_terms(right)) {
338            (Some(x), Some(y)) => Some(TsQueryAst::Phrase {
339                left: alloc::boxed::Box::new(x),
340                right: alloc::boxed::Box::new(y),
341                distance: *distance,
342            }),
343            (Some(x), None) | (None, Some(x)) => Some(x),
344            (None, None) => None,
345        },
346    }
347}
348
349fn stem_tsquery_in_place(ast: &mut TsQueryAst, config: TsConfig) {
350    match ast {
351        TsQueryAst::Term { word, .. } => {
352            let lower = word.to_lowercase();
353            *word = match config {
354                TsConfig::Simple => lower,
355                TsConfig::English => porter_stem(&lower),
356                TsConfig::Spanish => crate::fts_es::stem_es(&lower),
357                TsConfig::French => crate::fts_fr::stem_fr(&lower),
358                TsConfig::German => crate::fts_de::stem_de(&lower),
359            };
360        }
361        TsQueryAst::And(a, b) | TsQueryAst::Or(a, b) => {
362            stem_tsquery_in_place(a, config);
363            stem_tsquery_in_place(b, config);
364        }
365        TsQueryAst::Not(x) => stem_tsquery_in_place(x, config),
366        TsQueryAst::Phrase { left, right, .. } => {
367            stem_tsquery_in_place(left, config);
368            stem_tsquery_in_place(right, config);
369        }
370    }
371}
372
373fn collect_lexemes(config: TsConfig, text: &str) -> Vec<String> {
374    let mut out: Vec<String> = Vec::new();
375    let english = config.stems();
376    for token in tokenize_typed(text) {
377        let Some(dict) = token.ty.dictionary(english) else {
378            continue;
379        };
380        let folded = token.text.to_lowercase();
381        match dict {
382            TsDict::Simple => out.push(folded),
383            TsDict::EnglishStem => {
384                if config.is_stopword(&folded) {
385                    continue;
386                }
387                let stemmed = config.stem(&folded);
388                if !stemmed.is_empty() {
389                    out.push(stemmed);
390                }
391            }
392        }
393    }
394    out
395}
396
397fn split_words(s: &str) -> Vec<String> {
398    s.split_whitespace().map(|w| w.to_string()).collect()
399}
400
401fn fold_and(lexs: &[String]) -> TsQueryAst {
402    if lexs.is_empty() {
403        return TsQueryAst::Term {
404            word: String::new(),
405            weight_mask: 0,
406        };
407    }
408    let mut it = lexs.iter();
409    let first = TsQueryAst::Term {
410        word: it.next().unwrap().clone(),
411        weight_mask: 0,
412    };
413    it.fold(first, |acc, w| {
414        TsQueryAst::And(
415            alloc::boxed::Box::new(acc),
416            alloc::boxed::Box::new(TsQueryAst::Term {
417                word: w.clone(),
418                weight_mask: 0,
419            }),
420        )
421    })
422}
423
424fn fold_phrase(lexs: &[String]) -> TsQueryAst {
425    if lexs.is_empty() {
426        return TsQueryAst::Term {
427            word: String::new(),
428            weight_mask: 0,
429        };
430    }
431    let mut it = lexs.iter();
432    let first = TsQueryAst::Term {
433        word: it.next().unwrap().clone(),
434        weight_mask: 0,
435    };
436    it.fold(first, |acc, w| TsQueryAst::Phrase {
437        left: alloc::boxed::Box::new(acc),
438        right: alloc::boxed::Box::new(TsQueryAst::Term {
439            word: w.clone(),
440            weight_mask: 0,
441        }),
442        distance: 1,
443    })
444}
445
446/// v7.39 (read01 round 43) — tokenise + stem while tracking each
447/// surviving lexeme's tsvector position. Dropped stopwords advance the
448/// counter without emitting a lexeme, mirroring `to_tsvector`, so the
449/// gap between consecutive survivors is PG's phrase distance.
450fn collect_lexemes_positioned(config: TsConfig, text: &str) -> Vec<(String, u16)> {
451    let mut out: Vec<(String, u16)> = Vec::new();
452    let mut position: u16 = 0;
453    let english = config.stems();
454    for token in tokenize_typed(text) {
455        let Some(dict) = token.ty.dictionary(english) else {
456            continue;
457        };
458        let folded = token.text.to_lowercase();
459        let lex = match dict {
460            TsDict::Simple => folded,
461            TsDict::EnglishStem => {
462                if config.is_stopword(&folded) {
463                    position = position.saturating_add(1).min(16383);
464                    continue;
465                }
466                config.stem(&folded)
467            }
468        };
469        if lex.is_empty() {
470            continue;
471        }
472        position = position.saturating_add(1).min(16383);
473        out.push((lex, position));
474    }
475    out
476}
477
478/// v7.39 (read01 round 43) — fold positioned lexemes into a phrase
479/// chain whose `<N>` distance is the position delta between neighbours.
480fn fold_phrase_positioned(lexs: &[(String, u16)]) -> TsQueryAst {
481    if lexs.is_empty() {
482        return TsQueryAst::Term {
483            word: String::new(),
484            weight_mask: 0,
485        };
486    }
487    let mut it = lexs.iter();
488    let (first_word, first_pos) = it.next().unwrap();
489    let mut acc = TsQueryAst::Term {
490        word: first_word.clone(),
491        weight_mask: 0,
492    };
493    let mut prev_pos = *first_pos;
494    for (word, pos) in it {
495        let distance = pos.saturating_sub(prev_pos);
496        acc = TsQueryAst::Phrase {
497            left: alloc::boxed::Box::new(acc),
498            right: alloc::boxed::Box::new(TsQueryAst::Term {
499                word: word.clone(),
500                weight_mask: 0,
501            }),
502            distance,
503        };
504        prev_pos = *pos;
505    }
506    acc
507}
508
509/// v7.12.2 — evaluate `tsvector @@ tsquery`. Walks the query AST
510/// treating each leaf as "does the vector contain this lexeme".
511/// Phrase semantics: the v7.12.2 implementation honours the
512/// `<N>` distance — both operand terms must appear with their
513/// positions exactly `N` apart in the vector. Higher-arity
514/// phrase chains nest as `Phrase(Phrase(a,b,1), c, 1)`, so the
515/// match recursion folds position sets across the AND of the
516/// chain (a fully general n-gram match in a single pass).
517#[must_use]
518pub fn ts_query_matches(vec: &[TsLexeme], query: &TsQueryAst) -> bool {
519    match query {
520        TsQueryAst::Term { word, weight_mask } => term_matches(vec, word, *weight_mask),
521        TsQueryAst::And(a, b) => ts_query_matches(vec, a) && ts_query_matches(vec, b),
522        TsQueryAst::Or(a, b) => ts_query_matches(vec, a) || ts_query_matches(vec, b),
523        TsQueryAst::Not(x) => !ts_query_matches(vec, x),
524        TsQueryAst::Phrase {
525            left,
526            right,
527            distance,
528        } => phrase_match(vec, left, right, *distance),
529    }
530}
531
532fn contains_lexeme(vec: &[TsLexeme], word: &str) -> bool {
533    vec.binary_search_by(|l| l.word.as_str().cmp(word)).is_ok()
534}
535
536/// v7.39 (round 245) — Term matching with the two mask-carried modifiers:
537/// bit 4 is the PREFIX flag (`fox:*` — any lexeme starting with the word
538/// matches) and the low four bits the accepted-weight set (`0` = any).
539fn term_matches(vec: &[TsLexeme], word: &str, mask: u8) -> bool {
540    let prefix = mask & 0x10 != 0;
541    let weights = mask & 0x0f;
542    let weight_ok = |l: &TsLexeme| weights == 0 || weights & (1 << l.weight) != 0;
543    if prefix {
544        return vec.iter().any(|l| l.word.starts_with(word) && weight_ok(l));
545    }
546    match vec.binary_search_by(|l| l.word.as_str().cmp(word)) {
547        Ok(idx) => weight_ok(&vec[idx]),
548        Err(_) => false,
549    }
550}
551
552/// Phrase positions of a sub-AST. For atomic terms returns the
553/// vector's recorded positions; for nested phrases returns the
554/// rightmost position of each surviving match. Empty positions
555/// mean "no match anywhere".
556fn phrase_positions(vec: &[TsLexeme], q: &TsQueryAst) -> Vec<u16> {
557    match q {
558        TsQueryAst::Term { word, .. } => {
559            match vec.binary_search_by(|l| l.word.as_str().cmp(word)) {
560                Ok(idx) => vec[idx].positions.clone(),
561                Err(_) => Vec::new(),
562            }
563        }
564        TsQueryAst::Phrase {
565            left,
566            right,
567            distance,
568        } => {
569            let lp = phrase_positions(vec, left);
570            let rp = phrase_positions(vec, right);
571            let mut out = Vec::new();
572            for l in &lp {
573                let target = l.saturating_add(*distance);
574                if rp.binary_search(&target).is_ok() {
575                    out.push(target);
576                }
577            }
578            out.sort_unstable();
579            out.dedup();
580            out
581        }
582        // For mixed-shape phrases (Phrase contains an AND/OR/NOT),
583        // fall back to the boolean match (no position tracking).
584        _ => {
585            if ts_query_matches(vec, q) {
586                alloc::vec![u16::MAX]
587            } else {
588                Vec::new()
589            }
590        }
591    }
592}
593
594fn phrase_match(vec: &[TsLexeme], left: &TsQueryAst, right: &TsQueryAst, distance: u16) -> bool {
595    let lp = phrase_positions(vec, left);
596    let rp = phrase_positions(vec, right);
597    lp.iter().any(|l| {
598        let target = l.saturating_add(distance);
599        rp.binary_search(&target).is_ok()
600    })
601}
602
603/// v7.12.2 — `ts_rank(vec, q)` basic form. Score is the sum of
604/// per-matched-lexeme weight factors divided by `1 + log(unique
605/// terms in query)`. Matches PG's `ts_rank` with default
606/// normalisation flag 0.
607#[must_use]
608pub fn ts_rank(weights: &RankWeights, vec: &[TsLexeme], query: &TsQueryAst) -> f32 {
609    // v7.38 (read01, T12.1) — PG's calc_rank: an AND/PHRASE-rooted query uses
610    // the cover (distance-weighted) branch, everything else the OR branch.
611    // Normalization flag defaults to 0 (no length/uniqueness division).
612    let mut terms: Vec<&str> = Vec::new();
613    collect_query_terms(query, &mut terms);
614    if terms.is_empty() {
615        return 0.0;
616    }
617    let and_rooted = matches!(query, TsQueryAst::And(..) | TsQueryAst::Phrase { .. });
618    // calc_rank_and delegates to calc_rank_or for a single distinct term.
619    if and_rooted && terms.len() >= 2 {
620        calc_rank_and(vec, &terms, weights)
621    } else {
622        calc_rank_or(vec, &terms, weights)
623    }
624}
625
626/// v7.12.2 — `ts_rank_cd(vec, q)` cover-density variant. Higher
627/// score when matched lexemes cluster closer together; defaults
628/// to a per-lexeme contribution divided by the average gap
629/// between matched positions. Returns 0 when no terms match.
630#[must_use]
631pub fn ts_rank_cd(weights: &RankWeights, vec: &[TsLexeme], query: &TsQueryAst) -> f32 {
632    // v7.38 (read01, T12.1) — PG's calc_rank_cd cover density. Sum, over each
633    // minimal cover (window containing every distinct query term), a
634    // per-cover weight `Cpos = (#entries / Σ 1/weight) / (noise + 1)`, where
635    // noise is the extra positional span beyond the matched entries. Default
636    // normalization flag 0 (no division).
637    let mut terms: Vec<&str> = Vec::new();
638    collect_query_terms(query, &mut terms);
639    if terms.is_empty() {
640        return 0.0;
641    }
642    // doc = (position, term-index, weight), sorted by position.
643    let mut doc: Vec<(u16, usize, u8)> = Vec::new();
644    for (t, word) in terms.iter().enumerate() {
645        if let Ok(idx) = vec.binary_search_by(|l| l.word.as_str().cmp(word)) {
646            for &pos in &vec[idx].positions {
647                doc.push((pos, t, vec[idx].weight));
648            }
649        }
650    }
651    doc.sort_unstable();
652    let nterms = terms.len();
653    let mut wdoc = 0.0f32;
654    let mut start = 0usize;
655    while start < doc.len() {
656        // Grow a window from `start` until every distinct term is present.
657        let mut seen = alloc::vec![false; nterms];
658        let mut cnt = 0usize;
659        let mut end = start;
660        while end < doc.len() {
661            if !seen[doc[end].1] {
662                seen[doc[end].1] = true;
663                cnt += 1;
664            }
665            if cnt == nterms {
666                break;
667            }
668            end += 1;
669        }
670        if cnt < nterms {
671            break; // no further cover
672        }
673        // Shrink from the left to the minimal cover.
674        let mut begin = start;
675        while begin < end {
676            let bt = doc[begin].1;
677            if doc[begin + 1..=end].iter().any(|d| d.1 == bt) {
678                begin += 1;
679            } else {
680                break;
681            }
682        }
683        let p = doc[begin].0;
684        let q = doc[end].0;
685        let inv_sum: f32 = doc[begin..=end]
686            .iter()
687            .map(|d| 1.0 / weight_factor(d.2, weights))
688            .sum();
689        let mut cpos = ((end - begin + 1) as f32) / inv_sum;
690        let nnoise = (i32::from(q) - i32::from(p)) - (end as i32 - begin as i32);
691        if nnoise > 0 {
692            cpos /= (nnoise + 1) as f32;
693        }
694        wdoc += cpos;
695        start = begin + 1;
696    }
697    wdoc
698}
699
700/// v7.38 (read01, T12.1) — apply PG's ranking normalization bitmask to a raw
701/// rank. Flags are applied in PG's order over the tsvector's total position
702/// count (`len`) and distinct-lexeme count (`uniq`):
703///   1 → /log2(len+1) · 2 → /len · 8 → /uniq · 16 → /log2(uniq+1) · 32 → r/(r+1)
704/// (flag 4, the cover-extent distance, is cover-density only and handled by the
705/// caller.) Verified against live PG 18.4.
706#[must_use]
707pub fn apply_rank_norm(mut rank: f32, norm: i64, vec: &[TsLexeme]) -> f32 {
708    let len: usize = vec.iter().map(|l| l.positions.len()).sum();
709    let uniq = vec.len();
710    if norm & 1 != 0 && len > 0 {
711        rank /= log2_approx((len + 1) as f32);
712    }
713    if norm & 2 != 0 && len > 0 {
714        rank /= len as f32;
715    }
716    if norm & 8 != 0 && uniq > 0 {
717        rank /= uniq as f32;
718    }
719    if norm & 16 != 0 {
720        rank /= log2_approx((uniq + 1) as f32);
721    }
722    if norm & 32 != 0 {
723        rank /= rank + 1.0;
724    }
725    rank
726}
727
728fn log2_approx(x: f32) -> f32 {
729    ln_approx(x) / core::f32::consts::LN_2
730}
731
732/// `f32::ln` is std-only; spg-engine is no_std. Reuse the bit-
733/// trick decomposition the spg-storage bloom filter uses
734/// (precision ≈ 1e-7, ample for ranking).
735fn ln_approx(x: f32) -> f32 {
736    if x <= 0.0 {
737        return 0.0;
738    }
739    let xd = f64::from(x);
740    let bits = xd.to_bits();
741    let exponent_raw = ((bits >> 52) & 0x7ff) as i64;
742    let exponent = exponent_raw - 1023;
743    let mantissa_bits = (bits & 0x000f_ffff_ffff_ffff) | 0x3ff0_0000_0000_0000;
744    let mantissa = f64::from_bits(mantissa_bits);
745    let t = (mantissa - 1.0) / (mantissa + 1.0);
746    let t2 = t * t;
747    let ln_mantissa = 2.0 * (t + t2 * t / 3.0 + t2 * t2 * t / 5.0 + t2 * t2 * t2 * t / 7.0);
748    let ln = (exponent as f64) * core::f64::consts::LN_2 + ln_mantissa;
749    ln as f32
750}
751
752/// v7.38 (read01, T12.1) — a ts_rank weight array in PG order `[D, C, B, A]`.
753pub type RankWeights = [f32; 4];
754/// PG default weights: D=0.1, C=0.2, B=0.4, A=1.0.
755pub const DEFAULT_RANK_WEIGHTS: RankWeights = [0.1, 0.2, 0.4, 1.0];
756
757fn weight_factor(w: u8, weights: &RankWeights) -> f32 {
758    // Weight byte: D=0, C=1, B=2, A=3 — a direct index into the PG-order array.
759    weights[(w as usize).min(3)]
760}
761
762/// v7.38 (read01, T12.1) — no_std `exp`, sibling of `ln_approx`. Range-reduce
763/// `x = k·ln2 + r` and evaluate `2^k · e^r` with a Taylor series on the small
764/// remainder; saturate the far tails.
765fn exp_approx(x: f32) -> f32 {
766    if x > 88.0 {
767        return f32::INFINITY;
768    }
769    if x < -88.0 {
770        return 0.0;
771    }
772    let xd = f64::from(x);
773    let k = (xd / core::f64::consts::LN_2).round();
774    let r = xd - k * core::f64::consts::LN_2;
775    // e^r, r in [-ln2/2, ln2/2]; 7 Taylor terms.
776    let mut term = 1.0f64;
777    let mut er = 1.0f64;
778    for i in 1..8 {
779        term *= r / f64::from(i);
780        er += term;
781    }
782    (er * libm_exp2(k)) as f32
783}
784
785/// 2^k for an integer-valued `k` (built from the f64 exponent field).
786fn libm_exp2(k: f64) -> f64 {
787    let ki = k as i64;
788    f64::from_bits((((ki + 1023) as u64) & 0x7ff) << 52)
789}
790
791/// v7.38 (read01, T12.1) — PG `word_distance`: how much a lexeme gap of `d`
792/// positions dampens an AND cover's contribution. Only `calc_rank_and` uses it.
793fn word_distance(d: u32) -> f32 {
794    1.0 / (1.005 + 0.05 * exp_approx((d as f32) / 1.5 - 2.0))
795}
796
797/// A matched query-term occurrence: which query term, its position, its weight.
798struct RankEntry {
799    term: usize,
800    pos: u16,
801    w: f32,
802}
803
804/// Collect the distinct query-term words (in first-seen order).
805fn collect_query_terms<'a>(query: &'a TsQueryAst, out: &mut Vec<&'a str>) {
806    match query {
807        TsQueryAst::Term { word, .. } => {
808            if !out.iter().any(|t| *t == word.as_str()) {
809                out.push(word.as_str());
810            }
811        }
812        TsQueryAst::And(a, b) | TsQueryAst::Or(a, b) => {
813            collect_query_terms(a, out);
814            collect_query_terms(b, out);
815        }
816        TsQueryAst::Phrase { left, right, .. } => {
817            collect_query_terms(left, out);
818            collect_query_terms(right, out);
819        }
820        TsQueryAst::Not(_) => {}
821    }
822}
823
824/// PG `calc_rank_or`: sum a per-term contribution over the term's positions,
825/// then divide by the number of distinct query terms.
826fn calc_rank_or(vec: &[TsLexeme], terms: &[&str], weights: &RankWeights) -> f32 {
827    let mut res = 0.0f32;
828    for word in terms {
829        if let Ok(idx) = vec.binary_search_by(|l| l.word.as_str().cmp(word)) {
830            let wpos = weight_factor(vec[idx].weight, weights);
831            let (mut resj, mut wjm, mut jm) = (0.0f32, 0.0f32, 0usize);
832            for (j, _pos) in vec[idx].positions.iter().enumerate() {
833                let denom = ((j + 1) * (j + 1)) as f32;
834                resj += wpos / denom;
835                if wpos > wjm {
836                    wjm = wpos;
837                    jm = j;
838                }
839            }
840            let jm_denom = ((jm + 1) * (jm + 1)) as f32;
841            res += (wjm + resj - wjm / jm_denom) / 1.644_934;
842        }
843    }
844    res / (terms.len().max(1) as f32)
845}
846
847/// PG `calc_rank_and`: probabilistic-OR combine of every position pair from
848/// DISTINCT query terms, each weighted by the inter-position `word_distance`.
849fn calc_rank_and(vec: &[TsLexeme], terms: &[&str], weights: &RankWeights) -> f32 {
850    let mut entries: Vec<RankEntry> = Vec::new();
851    for (t, word) in terms.iter().enumerate() {
852        if let Ok(idx) = vec.binary_search_by(|l| l.word.as_str().cmp(word)) {
853            let w = weight_factor(vec[idx].weight, weights);
854            for &pos in &vec[idx].positions {
855                entries.push(RankEntry { term: t, pos, w });
856            }
857        }
858    }
859    let mut res = -1.0f32;
860    for i in 1..entries.len() {
861        for k in 0..i {
862            if entries[i].term == entries[k].term {
863                continue;
864            }
865            let mut dist = u32::from(entries[i].pos.abs_diff(entries[k].pos));
866            if dist == 0 {
867                dist = 16384; // MAXENTRYPOS
868            }
869            let curw = sqrt_approx(entries[i].w * entries[k].w * word_distance(dist));
870            res = if res < 0.0 {
871                curw
872            } else {
873                1.0 - (1.0 - res) * (1.0 - curw)
874            };
875        }
876    }
877    if res < 0.0 { 1e-20 } else { res }
878}
879
880/// no_std `sqrt` for f32 (Newton, ample precision for ranking).
881fn sqrt_approx(x: f32) -> f32 {
882    if x <= 0.0 {
883        return 0.0;
884    }
885    let mut g = f64::from(x);
886    for _ in 0..20 {
887        g = 0.5 * (g + f64::from(x) / g);
888    }
889    g as f32
890}
891
892/// Tokenise on Unicode word boundaries — anything that is not an
893/// alphanumeric scalar value (or `_`) splits the token. Lowercases
894/// each emitted token.
895/// v7.39 (round 651) — PG's token types, as `ts_token_type('default')`
896/// publishes them. Only the ones SPG's parser actually produces are
897/// here; the numbering is PG's so `pg_ts_config_map.maptokentype` and
898/// `ts_debug.alias` agree with it.
899///
900/// The four PG does NOT map to any dictionary — blank(12), tag(13),
901/// protocol(14), entity(23) — are recognised precisely so they can be
902/// DROPPED. That is the difference between indexing `<b>x</b>` as `x`,
903/// which PG does, and as `b`, `x`, `b`, which SPG did.
904#[derive(Debug, Clone, Copy, PartialEq, Eq)]
905pub enum TokenType {
906    AsciiWord = 1,
907    Word = 2,
908    NumWord = 3,
909    Email = 4,
910    Url = 5,
911    Host = 6,
912    SFloat = 7,
913    Version = 8,
914    HwordNumPart = 9,
915    HwordPart = 10,
916    HwordAsciiPart = 11,
917    Blank = 12,
918    Tag = 13,
919    Protocol = 14,
920    NumHword = 15,
921    AsciiHword = 16,
922    Hword = 17,
923    UrlPath = 18,
924    File = 19,
925    Float = 20,
926    Int = 21,
927    Uint = 22,
928    Entity = 23,
929}
930
931impl TokenType {
932    /// PG's `alias` column.
933    pub const fn alias(self) -> &'static str {
934        match self {
935            Self::AsciiWord => "asciiword",
936            Self::Word => "word",
937            Self::NumWord => "numword",
938            Self::Email => "email",
939            Self::Url => "url",
940            Self::Host => "host",
941            Self::SFloat => "sfloat",
942            Self::Version => "version",
943            Self::HwordNumPart => "hword_numpart",
944            Self::HwordPart => "hword_part",
945            Self::HwordAsciiPart => "hword_asciipart",
946            Self::Blank => "blank",
947            Self::Tag => "tag",
948            Self::Protocol => "protocol",
949            Self::NumHword => "numhword",
950            Self::AsciiHword => "asciihword",
951            Self::Hword => "hword",
952            Self::UrlPath => "url_path",
953            Self::File => "file",
954            Self::Float => "float",
955            Self::Int => "int",
956            Self::Uint => "uint",
957            Self::Entity => "entity",
958        }
959    }
960
961    /// PG's `description` column, verbatim.
962    pub const fn description(self) -> &'static str {
963        match self {
964            Self::AsciiWord => "Word, all ASCII",
965            Self::Word => "Word, all letters",
966            Self::NumWord => "Word, letters and digits",
967            Self::Email => "Email address",
968            Self::Url => "URL",
969            Self::Host => "Host",
970            Self::SFloat => "Scientific notation",
971            Self::Version => "Version number",
972            Self::HwordNumPart => "Hyphenated word part, letters and digits",
973            Self::HwordPart => "Hyphenated word part, all letters",
974            Self::HwordAsciiPart => "Hyphenated word part, all ASCII",
975            Self::Blank => "Space symbols",
976            Self::Tag => "XML tag",
977            Self::Protocol => "Protocol head",
978            Self::NumHword => "Hyphenated word, letters and digits",
979            Self::AsciiHword => "Hyphenated word, all ASCII",
980            Self::Hword => "Hyphenated word, all letters",
981            Self::UrlPath => "URL path",
982            Self::File => "File or path name",
983            Self::Float => "Decimal notation",
984            Self::Int => "Signed integer",
985            Self::Uint => "Unsigned integer",
986            Self::Entity => "XML entity",
987        }
988    }
989
990    /// Which dictionary a configuration sends this token to, or `None`
991    /// when the configuration maps it to nothing and the token produces
992    /// no lexeme at all. Read off PG18's `pg_ts_config_map`: the same
993    /// nineteen types are mapped by both `simple` and `english`, and
994    /// the four that are not are blank, tag, protocol and entity.
995    pub const fn dictionary(self, english: bool) -> Option<TsDict> {
996        match self {
997            Self::Blank | Self::Tag | Self::Protocol | Self::Entity => None,
998            // The stemmer only ever sees words; everything with digits,
999            // punctuation or structure goes to `simple` even under the
1000            // english configuration — measured, and the reason
1001            // `to_tsvector('english', '42')` is `42` and not a stem.
1002            Self::AsciiWord
1003            | Self::Word
1004            | Self::HwordPart
1005            | Self::HwordAsciiPart
1006            | Self::AsciiHword
1007            | Self::Hword
1008                if english =>
1009            {
1010                Some(TsDict::EnglishStem)
1011            }
1012            _ => Some(TsDict::Simple),
1013        }
1014    }
1015}
1016
1017/// The two dictionaries SPG has (round 650).
1018#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1019pub enum TsDict {
1020    Simple,
1021    EnglishStem,
1022}
1023
1024/// v7.39 (round 651) — a typed token, PG-shaped.
1025#[derive(Debug, Clone)]
1026pub struct Token {
1027    pub text: String,
1028    pub ty: TokenType,
1029}
1030
1031/// The old tokenizer, kept for callers that want bare words.
1032pub fn tokenize(text: &str) -> Vec<String> {
1033    tokenize_typed(text)
1034        .into_iter()
1035        .filter(|t| t.ty.dictionary(false).is_some())
1036        .map(|t| t.text)
1037        .collect()
1038}
1039
1040/// v7.39 (round 651) — split `text` the way PG's default parser does.
1041///
1042/// What this replaces was sixteen lines: "split on anything that is not
1043/// alphanumeric". Measured against PG across its 23 token types, that
1044/// agreed on FOUR — asciiword, word, numword and uint — and differed on
1045/// thirteen. `user@example.com` indexed as `user`, `example`, `com` so a
1046/// search for the address found nothing; `3.14` became `3` and `14`;
1047/// `-42` lost its sign; and `<b>x</b>` put the tag name `b` INTO the
1048/// index, twice. That last one is not a near-miss, it is markup
1049/// polluting the search results.
1050///
1051/// Recognised here, longest-shape-first because the shapes nest (an
1052/// email contains a host, a url contains a host and a path):
1053/// tag, entity, url/protocol, email, file, host, version, sfloat,
1054/// float, signed int, hyphenated word (compound AND parts, as PG emits
1055/// both), and finally plain words and unsigned integers.
1056pub fn tokenize_typed(text: &str) -> Vec<Token> {
1057    let b: Vec<char> = text.chars().collect();
1058    let mut out: Vec<Token> = Vec::new();
1059    let mut i = 0usize;
1060    let is_word = |c: char| c.is_alphanumeric() || c == '_';
1061    while i < b.len() {
1062        let c = b[i];
1063        if c.is_whitespace() {
1064            i += 1;
1065            continue;
1066        }
1067        // `<...>` — an XML tag. PG emits it as a `tag` token, which no
1068        // configuration maps, so nothing of it reaches the index.
1069        if c == '<'
1070            && let Some(end) = (i + 1..b.len()).find(|&j| b[j] == '>')
1071        {
1072            out.push(Token {
1073                text: b[i..=end].iter().collect(),
1074                ty: TokenType::Tag,
1075            });
1076            i = end + 1;
1077            continue;
1078        }
1079        // `&name;` / `&#123;` — an XML entity, likewise unmapped.
1080        if c == '&'
1081            && let Some(end) = (i + 1..b.len().min(i + 12)).find(|&j| b[j] == ';')
1082            && end > i + 1
1083        {
1084            out.push(Token {
1085                text: b[i..=end].iter().collect(),
1086                ty: TokenType::Entity,
1087            });
1088            i = end + 1;
1089            continue;
1090        }
1091        // A leading `/` belongs to the path it starts: PG's `file` token
1092        // for `/usr/local/bin` keeps it, and a token that drops it is a
1093        // different string to search for.
1094        let leading_slash = c == '/' && i + 1 < b.len() && is_word(b[i + 1]);
1095        if is_word(c) || leading_slash || (c == '-' && i + 1 < b.len() && b[i + 1].is_ascii_digit())
1096        {
1097            let start = i;
1098            // A signed integer only counts as one at the start of a run.
1099            let signed = c == '-';
1100            if signed || leading_slash {
1101                i += 1;
1102            }
1103            while i < b.len() && (is_word(b[i]) || matches!(b[i], '.' | '@' | '/' | '-' | ':')) {
1104                // Stop before a trailing separator that is really
1105                // punctuation: `end.` / `a/b,` — the separator has to be
1106                // followed by more of the token.
1107                if matches!(b[i], '.' | '@' | '/' | '-' | ':')
1108                    && (i + 1 >= b.len() || !(is_word(b[i + 1]) || b[i + 1] == '/'))
1109                {
1110                    break;
1111                }
1112                i += 1;
1113            }
1114            let raw: String = b[start..i].iter().collect();
1115            classify_into(&raw, signed, &mut out);
1116            continue;
1117        }
1118        i += 1;
1119    }
1120    out
1121}
1122
1123/// Assign a PG token type to one raw run, emitting the sub-parts PG
1124/// emits alongside a compound.
1125/// The original text for a run whose lowercased form is `lower`. Equal
1126/// lengths is the common case (ASCII); when a fold changed the byte
1127/// length the lowercased form is the honest fallback — `ts_debug`'s
1128/// `token` column would otherwise slice mid-character.
1129fn raw_of<'a>(raw: &'a str, lower: &'a impl AsRef<str>) -> &'a str {
1130    let lower = lower.as_ref();
1131    if raw.len() == lower.len() { raw } else { lower }
1132}
1133
1134/// Same, for the part of a run after an optional `proto://` head.
1135fn raw_tail<'a>(raw: &'a str, body: &'a str) -> &'a str {
1136    if raw.len() >= body.len() && raw.is_char_boundary(raw.len() - body.len()) {
1137        let t = &raw[raw.len() - body.len()..];
1138        if t.len() == body.len() { t } else { body }
1139    } else {
1140        body
1141    }
1142}
1143
1144fn classify_into(raw: &str, signed: bool, out: &mut Vec<Token>) {
1145    // Classification reads the lowercased form; what is PUSHED is the
1146    // original, so the two never disagree about which token this is
1147    // while still reporting what was written.
1148    let lower = raw.to_lowercase();
1149    let _ = &lower;
1150    // v7.39 (round 651) — the token keeps the text the PARSER saw.
1151    // Lowercasing is the DICTIONARY's job, which is why `ts_debug`'s
1152    // `token` column shows `The` while its `lexemes` shows `{}`.
1153    let push = |out: &mut Vec<Token>, t: &str, ty: TokenType| {
1154        if !t.is_empty() {
1155            out.push(Token {
1156                text: alloc::string::String::from(t),
1157                ty,
1158            });
1159        }
1160    };
1161    let ascii = lower.is_ascii();
1162    let has_alpha = lower.chars().any(char::is_alphabetic);
1163    let has_digit = lower.chars().any(|c| c.is_ascii_digit());
1164
1165    // A `proto://` head is its own token and maps to nothing; what
1166    // follows is judged on its own, exactly as the same string without
1167    // the head would be.
1168    let mut body = lower.as_str();
1169    if let Some(pos) = lower.find("://") {
1170        push(
1171            out,
1172            &alloc::format!("{}://", &lower[..pos]),
1173            TokenType::Protocol,
1174        );
1175        body = &lower[pos + 3..];
1176    }
1177    // url vs file, and the discriminator is measured rather than
1178    // guessed. `ts_debug` on PG18: `http://example.com/a/b` gives url +
1179    // host + url_path, `http://x.y/z` gives a single `file`, and
1180    // `http://x.co/z` gives url again — so what decides it is whether
1181    // the part before the first `/` looks like a HOST, and a one-letter
1182    // last label does not. An earlier version of this function emitted
1183    // host and path for every URL because the type list says those
1184    // types exist; that put `x.y` and `/z` into the index where PG has
1185    // neither.
1186    if body.contains('/') {
1187        let (head, path) = match body.find('/') {
1188            Some(p) => body.split_at(p),
1189            None => (body, ""),
1190        };
1191        let host_like = head.rsplit_once('.').is_some_and(|(pre, tld)| {
1192            !pre.is_empty() && tld.len() >= 2 && tld.chars().all(char::is_alphabetic)
1193        });
1194        if host_like && !head.is_empty() {
1195            push(out, raw_tail(raw, body), TokenType::Url);
1196            push(out, &raw_tail(raw, body)[..head.len()], TokenType::Host);
1197            push(out, &raw_tail(raw, body)[head.len()..], TokenType::UrlPath);
1198        } else {
1199            push(out, raw_tail(raw, body), TokenType::File);
1200        }
1201        return;
1202    }
1203    let lower = alloc::string::String::from(body);
1204    let lower = lower.as_str();
1205    // email: one `@`, something either side, a dot on the right
1206    if let Some(at) = lower.find('@')
1207        && at > 0
1208        && lower[at + 1..].contains('.')
1209        && !lower[at + 1..].contains('@')
1210    {
1211        push(out, raw_of(raw, &lower), TokenType::Email);
1212        return;
1213    }
1214    // hyphenated word: PG emits the compound AND each part
1215    if lower.contains('-') && has_alpha {
1216        let compound = if has_digit {
1217            TokenType::NumHword
1218        } else if ascii {
1219            TokenType::AsciiHword
1220        } else {
1221            TokenType::Hword
1222        };
1223        push(out, raw_of(raw, &lower), compound);
1224        for (part, raw_part) in lower.split('-').zip(raw_of(raw, &lower).split('-')) {
1225            if part.is_empty() {
1226                continue;
1227            }
1228            let pty = if part.chars().any(|c| c.is_ascii_digit()) {
1229                TokenType::HwordNumPart
1230            } else if part.is_ascii() {
1231                TokenType::HwordAsciiPart
1232            } else {
1233                TokenType::HwordPart
1234            };
1235            push(out, raw_part, pty);
1236        }
1237        return;
1238    }
1239    if lower.contains('.') {
1240        let dots = lower.matches('.').count();
1241        let numeric = lower.chars().all(|c| c.is_ascii_digit() || c == '.');
1242        if numeric && dots >= 2 {
1243            push(out, raw_of(raw, &lower), TokenType::Version);
1244            return;
1245        }
1246        if numeric && dots == 1 {
1247            push(out, raw_of(raw, &lower), TokenType::Float);
1248            return;
1249        }
1250        // `1.5e10` — scientific notation
1251        if dots == 1
1252            && has_digit
1253            && lower
1254                .chars()
1255                .all(|c| c.is_ascii_digit() || c == '.' || c == 'e' || c == '+' || c == '-')
1256        {
1257            push(out, raw_of(raw, &lower), TokenType::SFloat);
1258            return;
1259        }
1260        if has_alpha {
1261            push(out, raw_of(raw, &lower), TokenType::Host);
1262            return;
1263        }
1264        push(out, raw_of(raw, &lower), TokenType::Version);
1265        return;
1266    }
1267    if !has_alpha && has_digit {
1268        push(
1269            out,
1270            raw_of(raw, &lower),
1271            if signed {
1272                TokenType::Int
1273            } else {
1274                TokenType::Uint
1275            },
1276        );
1277        return;
1278    }
1279    let ty = if has_digit {
1280        TokenType::NumWord
1281    } else if ascii {
1282        TokenType::AsciiWord
1283    } else {
1284        TokenType::Word
1285    };
1286    push(out, raw_of(raw, &lower), ty);
1287}
1288
1289enum WebToken {
1290    Term(String),
1291    Phrase(Vec<String>),
1292    Or,
1293    /// v7.39 (round 756, F31-B7) — one `-` prefix. PG18-measured: a
1294    /// dash attaches ACROSS whitespace to the next word or phrase and
1295    /// STACKS (`- apple` → `!'apple'`, `-"a b"` → `!('a' <-> 'b')`,
1296    /// `--apple` / `- - apple` → `!!'apple'`); the old tokenizer only
1297    /// negated a directly-attached word and dropped the rest.
1298    Neg,
1299}
1300
1301/// websearch tokenizer — splits on whitespace, recognises quoted
1302/// phrases, leading `-` for NOT, and bare `OR` (case-insensitive).
1303fn web_tokens(text: &str) -> Vec<WebToken> {
1304    let mut out = Vec::new();
1305    let bytes = text.as_bytes();
1306    let mut i = 0;
1307    while i < bytes.len() {
1308        let b = bytes[i];
1309        if b.is_ascii_whitespace() {
1310            i += 1;
1311            continue;
1312        }
1313        if b == b'"' {
1314            i += 1;
1315            let start = i;
1316            while i < bytes.len() && bytes[i] != b'"' {
1317                i += 1;
1318            }
1319            let phrase_text = &text[start..i];
1320            let words: Vec<String> = phrase_text
1321                .split_whitespace()
1322                .map(|w| w.to_string())
1323                .collect();
1324            out.push(WebToken::Phrase(words));
1325            if i < bytes.len() {
1326                i += 1; // close quote
1327            }
1328            continue;
1329        }
1330        if b == b'-' {
1331            out.push(WebToken::Neg);
1332            i += 1;
1333            continue;
1334        }
1335        let start = i;
1336        while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'"' {
1337            i += 1;
1338        }
1339        let word = &text[start..i];
1340        if word.eq_ignore_ascii_case("or") {
1341            out.push(WebToken::Or);
1342        } else {
1343            out.push(WebToken::Term(word.to_string()));
1344        }
1345    }
1346    // v7.39 (round 756, F31-B7) — PG18-measured: the word "or" is an
1347    // OR operator only when it has a left operand and is not the last
1348    // token. At operand position (start, or right after another OR)
1349    // and at end of input it is a plain term: 'or apple' → 'or' &
1350    // 'apple', 'apple or' → 'apple' & 'or', 'or or and -' → 'or' |
1351    // 'and'. (An operator whose right side comes up empty still
1352    // vanishes with it — 'apple or -' → 'apple' — which the grouping
1353    // below already does by dropping empty OR groups.)
1354    let n = out.len();
1355    let mut at_operand_pos = true;
1356    for idx in 0..n {
1357        if matches!(out[idx], WebToken::Or) && (at_operand_pos || idx + 1 == n) {
1358            out[idx] = WebToken::Term(String::from("or"));
1359        }
1360        // A `-` prefix leaves us still waiting for the operand.
1361        at_operand_pos = match out[idx] {
1362            WebToken::Or => true,
1363            WebToken::Neg => at_operand_pos,
1364            _ => false,
1365        };
1366    }
1367    out
1368}
1369
1370/// PG's standard english stopword list (`tsearch_data/english.stop`).
1371/// Subset of the 127 words in PG 17's distribution — verbatim.
1372pub fn is_english_stopword(word: &str) -> bool {
1373    matches!(
1374        word,
1375        "i" | "me"
1376            | "my"
1377            | "myself"
1378            | "we"
1379            | "our"
1380            | "ours"
1381            | "ourselves"
1382            | "you"
1383            | "your"
1384            | "yours"
1385            | "yourself"
1386            | "yourselves"
1387            | "he"
1388            | "him"
1389            | "his"
1390            | "himself"
1391            | "she"
1392            | "her"
1393            | "hers"
1394            | "herself"
1395            | "it"
1396            | "its"
1397            | "itself"
1398            | "they"
1399            | "them"
1400            | "their"
1401            | "theirs"
1402            | "themselves"
1403            | "what"
1404            | "which"
1405            | "who"
1406            | "whom"
1407            | "this"
1408            | "that"
1409            | "these"
1410            | "those"
1411            | "am"
1412            | "is"
1413            | "are"
1414            | "was"
1415            | "were"
1416            | "be"
1417            | "been"
1418            | "being"
1419            | "have"
1420            | "has"
1421            | "had"
1422            | "having"
1423            | "do"
1424            | "does"
1425            | "did"
1426            | "doing"
1427            | "a"
1428            | "an"
1429            | "the"
1430            | "and"
1431            | "but"
1432            | "if"
1433            | "or"
1434            | "because"
1435            | "as"
1436            | "until"
1437            | "while"
1438            | "of"
1439            | "at"
1440            | "by"
1441            | "for"
1442            | "with"
1443            | "about"
1444            | "against"
1445            | "between"
1446            | "into"
1447            | "through"
1448            | "during"
1449            | "before"
1450            | "after"
1451            | "above"
1452            | "below"
1453            | "to"
1454            | "from"
1455            | "up"
1456            | "down"
1457            | "in"
1458            | "out"
1459            | "on"
1460            | "off"
1461            | "over"
1462            | "under"
1463            | "again"
1464            | "further"
1465            | "then"
1466            | "once"
1467            | "here"
1468            | "there"
1469            | "when"
1470            | "where"
1471            | "why"
1472            | "how"
1473            | "all"
1474            | "any"
1475            | "both"
1476            | "each"
1477            | "few"
1478            | "more"
1479            | "most"
1480            | "other"
1481            | "some"
1482            | "such"
1483            | "no"
1484            | "nor"
1485            | "not"
1486            | "only"
1487            | "own"
1488            | "same"
1489            | "so"
1490            | "than"
1491            | "too"
1492            | "very"
1493            | "s"
1494            | "t"
1495            | "can"
1496            | "will"
1497            | "just"
1498            | "don"
1499            | "should"
1500            | "now"
1501    )
1502}
1503
1504// --------------------------------------------------------------
1505// Porter stemmer (English, original 1980 algorithm). Operates on
1506// pure ASCII — non-ASCII input falls through unchanged.
1507// --------------------------------------------------------------
1508
1509/// v7.12.1 — Porter v1 stem. Lowercased ASCII input gives a
1510/// stemmed form; non-ASCII characters bypass the algorithm
1511/// (returned verbatim).
1512/// v7.39 (round 245) — Snowball's exceptional forms: a handful of words
1513/// whose stem the algorithm gets wrong are mapped directly (skies→sky and
1514/// friends), and a few short words are left untouched. PG's english
1515/// config is the Snowball stemmer, so these are observable in every
1516/// to_tsvector/to_tsquery differential.
1517fn stem_exception(word: &str) -> Option<&'static str> {
1518    Some(match word {
1519        "skis" => "ski",
1520        "skies" => "sky",
1521        "dying" => "die",
1522        "lying" => "lie",
1523        "tying" => "tie",
1524        "idly" => "idl",
1525        "gently" => "gentl",
1526        "ugly" => "ugli",
1527        "early" => "earli",
1528        "only" => "onli",
1529        "singly" => "singl",
1530        "sky" | "news" | "howe" | "atlas" | "cosmos" | "bias" | "andes" => {
1531            return Some(match word {
1532                "sky" => "sky",
1533                "news" => "news",
1534                "howe" => "howe",
1535                "atlas" => "atlas",
1536                "cosmos" => "cosmos",
1537                "bias" => "bias",
1538                _ => "andes",
1539            });
1540        }
1541        _ => return None,
1542    })
1543}
1544
1545pub fn porter_stem(word: &str) -> String {
1546    if let Some(fixed) = stem_exception(word) {
1547        return String::from(fixed);
1548    }
1549    if !word.is_ascii() {
1550        return word.to_string();
1551    }
1552    let bytes: Vec<u8> = word.bytes().collect();
1553    if bytes.len() <= 2 {
1554        return word.to_string();
1555    }
1556    let mut b = bytes;
1557    step1a(&mut b);
1558    step1b(&mut b);
1559    step1c(&mut b);
1560    step2(&mut b);
1561    step3(&mut b);
1562    step4(&mut b);
1563    step5a(&mut b);
1564    step5b(&mut b);
1565    // Safe: we only ever produced ASCII via the steps above.
1566    String::from_utf8(b).expect("porter stem produced non-UTF8 bytes")
1567}
1568
1569fn is_vowel(b: &[u8], i: usize) -> bool {
1570    match b[i] {
1571        b'a' | b'e' | b'i' | b'o' | b'u' => true,
1572        b'y' => i > 0 && !is_vowel(b, i - 1),
1573        _ => false,
1574    }
1575}
1576
1577/// Porter's `m` measure — the number of `[C](VC)^m[V]` units.
1578fn measure(b: &[u8]) -> usize {
1579    let mut m = 0;
1580    let mut prev_vowel = false;
1581    let mut started = false;
1582    for i in 0..b.len() {
1583        let v = is_vowel(b, i);
1584        if started && prev_vowel && !v {
1585            m += 1;
1586        }
1587        prev_vowel = v;
1588        started = true;
1589    }
1590    m
1591}
1592
1593fn has_vowel(b: &[u8]) -> bool {
1594    (0..b.len()).any(|i| is_vowel(b, i))
1595}
1596
1597fn ends_with(b: &[u8], suf: &[u8]) -> bool {
1598    b.len() >= suf.len() && &b[b.len() - suf.len()..] == suf
1599}
1600
1601fn replace_suffix(b: &mut Vec<u8>, suf_len: usize, new_suf: &[u8]) {
1602    let new_len = b.len() - suf_len;
1603    b.truncate(new_len);
1604    b.extend_from_slice(new_suf);
1605}
1606
1607fn measure_stem(b: &[u8], suf_len: usize) -> usize {
1608    measure(&b[..b.len() - suf_len])
1609}
1610
1611fn step1a(b: &mut Vec<u8>) {
1612    if ends_with(b, b"sses") {
1613        replace_suffix(b, 4, b"ss");
1614    } else if ends_with(b, b"ies") {
1615        // v7.39 (round 245) — Snowball's refinement of Porter's rule:
1616        // `ies` becomes `ie` when only one letter precedes it (dies→die,
1617        // ties→tie) and `i` otherwise (cries→cri, flies→fli). The
1618        // unconditional `i` gave PG-divergent stems for the short words.
1619        if b.len() - 3 <= 1 {
1620            replace_suffix(b, 3, b"ie");
1621        } else {
1622            replace_suffix(b, 3, b"i");
1623        }
1624    } else if ends_with(b, b"ss") {
1625        // No change.
1626    } else if ends_with(b, b"s") {
1627        replace_suffix(b, 1, b"");
1628    }
1629}
1630
1631fn step1b_post(b: &mut Vec<u8>) {
1632    if ends_with(b, b"at") {
1633        replace_suffix(b, 2, b"ate");
1634    } else if ends_with(b, b"bl") {
1635        replace_suffix(b, 2, b"ble");
1636    } else if ends_with(b, b"iz") {
1637        replace_suffix(b, 2, b"ize");
1638    } else if b.len() >= 2 && b[b.len() - 1] == b[b.len() - 2] {
1639        let last = b[b.len() - 1];
1640        if !matches!(last, b'l' | b's' | b'z') {
1641            b.pop();
1642        }
1643    } else if cvc(b) {
1644        b.extend_from_slice(b"e");
1645    }
1646}
1647
1648fn cvc(b: &[u8]) -> bool {
1649    if b.len() < 3 {
1650        return false;
1651    }
1652    let l = b.len();
1653    if !(is_vowel(b, l - 2) && !is_vowel(b, l - 3) && !is_vowel(b, l - 1)) {
1654        return false;
1655    }
1656    !matches!(b[l - 1], b'w' | b'x' | b'y')
1657}
1658
1659fn step1b(b: &mut Vec<u8>) {
1660    if ends_with(b, b"eed") {
1661        if measure_stem(b, 3) > 0 {
1662            replace_suffix(b, 3, b"ee");
1663        }
1664        return;
1665    }
1666    if ends_with(b, b"ed") {
1667        let stem_has_vowel = has_vowel(&b[..b.len() - 2]);
1668        if stem_has_vowel {
1669            replace_suffix(b, 2, b"");
1670            step1b_post(b);
1671        }
1672        return;
1673    }
1674    if ends_with(b, b"ing") {
1675        let stem_has_vowel = has_vowel(&b[..b.len() - 3]);
1676        if stem_has_vowel {
1677            replace_suffix(b, 3, b"");
1678            step1b_post(b);
1679        }
1680    }
1681}
1682
1683fn step1c(b: &mut Vec<u8>) {
1684    if ends_with(b, b"y") && has_vowel(&b[..b.len() - 1]) {
1685        replace_suffix(b, 1, b"i");
1686    }
1687}
1688
1689const STEP2_RULES: &[(&[u8], &[u8])] = &[
1690    (b"ational", b"ate"),
1691    (b"tional", b"tion"),
1692    (b"enci", b"ence"),
1693    (b"anci", b"ance"),
1694    (b"izer", b"ize"),
1695    (b"abli", b"able"),
1696    (b"alli", b"al"),
1697    (b"entli", b"ent"),
1698    (b"eli", b"e"),
1699    (b"ousli", b"ous"),
1700    (b"ization", b"ize"),
1701    (b"ation", b"ate"),
1702    (b"ator", b"ate"),
1703    (b"alism", b"al"),
1704    (b"iveness", b"ive"),
1705    (b"fulness", b"ful"),
1706    (b"ousness", b"ous"),
1707    (b"aliti", b"al"),
1708    (b"iviti", b"ive"),
1709    (b"biliti", b"ble"),
1710];
1711
1712fn step2(b: &mut Vec<u8>) {
1713    for (suf, repl) in STEP2_RULES {
1714        if ends_with(b, suf) && measure_stem(b, suf.len()) > 0 {
1715            replace_suffix(b, suf.len(), repl);
1716            return;
1717        }
1718    }
1719}
1720
1721const STEP3_RULES: &[(&[u8], &[u8])] = &[
1722    (b"icate", b"ic"),
1723    (b"ative", b""),
1724    (b"alize", b"al"),
1725    (b"iciti", b"ic"),
1726    (b"ical", b"ic"),
1727    (b"ful", b""),
1728    (b"ness", b""),
1729];
1730
1731fn step3(b: &mut Vec<u8>) {
1732    for (suf, repl) in STEP3_RULES {
1733        if ends_with(b, suf) && measure_stem(b, suf.len()) > 0 {
1734            replace_suffix(b, suf.len(), repl);
1735            return;
1736        }
1737    }
1738}
1739
1740const STEP4_RULES: &[&[u8]] = &[
1741    b"al", b"ance", b"ence", b"er", b"ic", b"able", b"ible", b"ant", b"ement", b"ment", b"ent",
1742    b"ou", b"ism", b"ate", b"iti", b"ous", b"ive", b"ize",
1743];
1744
1745fn step4(b: &mut Vec<u8>) {
1746    // Special-case `ion` — only strip when preceded by s/t.
1747    if ends_with(b, b"ion") && measure_stem(b, 3) > 1 {
1748        let stem = &b[..b.len() - 3];
1749        if matches!(stem.last(), Some(b's') | Some(b't')) {
1750            replace_suffix(b, 3, b"");
1751            return;
1752        }
1753    }
1754    for suf in STEP4_RULES {
1755        if ends_with(b, suf) && measure_stem(b, suf.len()) > 1 {
1756            replace_suffix(b, suf.len(), b"");
1757            return;
1758        }
1759    }
1760}
1761
1762fn step5a(b: &mut Vec<u8>) {
1763    if ends_with(b, b"e") {
1764        let m = measure_stem(b, 1);
1765        if m > 1 || (m == 1 && !cvc(&b[..b.len() - 1])) {
1766            replace_suffix(b, 1, b"");
1767        }
1768    }
1769}
1770
1771fn step5b(b: &mut Vec<u8>) {
1772    if b.len() >= 2 && b[b.len() - 1] == b'l' && b[b.len() - 2] == b'l' && measure(b) > 1 {
1773        b.pop();
1774    }
1775}
1776
1777#[cfg(test)]
1778mod tests {
1779
1780    use super::*;
1781
1782    #[test]
1783    fn porter_simple_cases() {
1784        assert_eq!(porter_stem("caresses"), "caress");
1785        assert_eq!(porter_stem("ponies"), "poni");
1786        // v7.39 (round 245) — Snowball's short-word rule (and PG): tie.
1787        assert_eq!(porter_stem("ties"), "tie");
1788        assert_eq!(porter_stem("cats"), "cat");
1789        assert_eq!(porter_stem("running"), "run");
1790        assert_eq!(porter_stem("happy"), "happi");
1791        assert_eq!(porter_stem("relational"), "relat");
1792        assert_eq!(porter_stem("conditional"), "condit");
1793        assert_eq!(porter_stem("hopefulness"), "hope");
1794    }
1795
1796    #[test]
1797    fn english_drops_stopwords_and_stems() {
1798        let v = to_tsvector(
1799            TsConfig::English,
1800            "The quick brown foxes are jumping over the lazy dogs",
1801        );
1802        let words: Vec<&str> = v.iter().map(|l| l.word.as_str()).collect();
1803        // Stopwords removed: the, are, over
1804        // Stems: quick → quick, brown → brown, foxes → fox,
1805        // jumping → jump, lazy → lazi, dogs → dog.
1806        assert!(words.contains(&"fox"), "expected `fox`, got {words:?}");
1807        assert!(words.contains(&"jump"), "expected `jump`, got {words:?}");
1808        assert!(words.contains(&"dog"), "expected `dog`, got {words:?}");
1809        assert!(!words.contains(&"the"), "stopword `the` leaked: {words:?}");
1810        assert!(!words.contains(&"are"), "stopword `are` leaked: {words:?}");
1811    }
1812
1813    #[test]
1814    fn simple_preserves_words() {
1815        let v = to_tsvector(TsConfig::Simple, "The Quick brown Foxes");
1816        let words: Vec<&str> = v.iter().map(|l| l.word.as_str()).collect();
1817        // Sorted ascending.
1818        assert_eq!(words, alloc::vec!["brown", "foxes", "quick", "the"]);
1819    }
1820
1821    #[test]
1822    fn plainto_tsquery_drops_stopwords() {
1823        let q = plainto_tsquery(TsConfig::English, "the quick brown fox");
1824        // Expect (quick & brown) & fox after stopword drop.
1825        let s = crate::eval::format_tsquery(&q);
1826        assert_eq!(s, "'quick' & 'brown' & 'fox'");
1827    }
1828
1829    #[test]
1830    fn to_tsquery_stems_terms() {
1831        let q = to_tsquery(TsConfig::English, "running & jumps").unwrap();
1832        let s = crate::eval::format_tsquery(&q);
1833        assert_eq!(s, "'run' & 'jump'");
1834    }
1835}