Skip to main content

spg_engine/eval/
textsearch.rs

1//! Full-text-search SQL functions and `tsvector` / `tsquery` codecs.
2//! Wraps the lexer/stemmer engine in `crate::fts`: the `to_tsvector` /
3//! `*_tsquery` / `ts_rank` / `setweight` / `@@` builtins plus the PG
4//! external-form render (`format_*`) and parse (`decode_*_external`)
5//! used by the wire layer and `::tsvector` / `::tsquery` casts.
6//! Split out of `eval.rs` (cut 26).
7
8use alloc::boxed::Box;
9use alloc::format;
10use alloc::string::{String, ToString};
11use alloc::vec::Vec;
12
13use spg_storage::{TsLexeme, TsQueryAst, Value};
14
15use super::{EvalContext, EvalError};
16
17/// v7.12.2 — `ts_rank([weights,] vec, query [, norm])`. v7.12.2
18/// supports the canonical `(vec, query)` two-arg form mailrs uses;
19/// optional weight-array / normalisation arguments error with an
20/// "unsupported" message rather than silently changing semantics.
21pub(super) fn fts_ts_rank(args: &[Value<'_>]) -> Result<Value<'static>, EvalError> {
22    // v7.39 (round 510) — strict, as PG's is. `parse_rank_args` sorts the
23    // optional weight array and norm flag out by their VALUE shape, so an
24    // all-NULL call matched neither and was reported as a bad argument list
25    // where PG simply answers NULL. Every form works with real values; it
26    // was only the NULLs that had nowhere to land.
27    if args.iter().any(|a| matches!(a, Value::Null)) {
28        return Ok(Value::Null);
29    }
30    let (weights, vec, query, norm) = parse_rank_args("ts_rank", args)?;
31    match (vec, query) {
32        (None, _) | (_, None) => Ok(Value::Null),
33        (Some(v), Some(q)) => {
34            // Flag 4 (cover-extent distance) is cover-density only — a no-op for
35            // ts_rank, matching PG.
36            let r = crate::fts::apply_rank_norm(crate::fts::ts_rank(&weights, &v, &q), norm, &v);
37            // PG ts_rank returns float4 — keep f32 so the wire text is
38            // the shortest-round-trip real form ("0.09148999").
39            Ok(Value::Real(r))
40        }
41    }
42}
43
44pub(super) fn fts_ts_rank_cd(args: &[Value<'_>]) -> Result<Value<'static>, EvalError> {
45    // Strict, for the same reason as `fts_ts_rank` above.
46    if args.iter().any(|a| matches!(a, Value::Null)) {
47        return Ok(Value::Null);
48    }
49    let (weights, vec, query, norm) = parse_rank_args("ts_rank_cd", args)?;
50    if norm & 4 != 0 {
51        return Err(EvalError::TypeMismatch {
52            detail:
53                "ts_rank_cd(): normalization flag 4 (cover-extent distance) is not yet supported"
54                    .into(),
55        });
56    }
57    match (vec, query) {
58        (None, _) | (_, None) => Ok(Value::Null),
59        (Some(v), Some(q)) => {
60            let r = crate::fts::apply_rank_norm(crate::fts::ts_rank_cd(&weights, &v, &q), norm, &v);
61            Ok(Value::Real(r))
62        }
63    }
64}
65/// v7.38 — parsed `ts_rank*` arguments:
66/// `(weights, document lexemes, query, normalisation flags)`.
67type RankArgs = (
68    crate::fts::RankWeights,
69    Option<Vec<spg_storage::TsLexeme>>,
70    Option<spg_storage::TsQueryAst>,
71    i64,
72);
73
74/// v7.38 (read01, T12.1) — parse `ts_rank[_cd]([weights,] vec, query [, norm])`.
75/// A leading weight array (PG order `[D, C, B, A]`) and a trailing integer
76/// normalization flag are both optional. Custom weights are honored; the norm
77/// flag bits 1/2/8/16/32 are applied by `apply_rank_norm` (bit 4 is cover-density
78/// only, handled by the ts_rank_cd wrapper). Unknown bits error.
79fn parse_rank_args(name: &str, args: &[Value<'_>]) -> Result<RankArgs, EvalError> {
80    // Split off an optional leading weight array and an optional trailing norm.
81    let mut rest = args;
82    let mut weights = crate::fts::DEFAULT_RANK_WEIGHTS;
83    if matches!(
84        rest.first(),
85        Some(
86            Value::FloatArray(_)
87                | Value::NumericArray(_)
88                | Value::IntArray(_)
89                | Value::SmallIntArray(_)
90        )
91    ) {
92        weights = parse_weight_array(name, &rest[0])?;
93        rest = &rest[1..];
94    } else if args.len() >= 3
95        && let Some(Value::Text(s)) = rest.first()
96        && s.trim_start().starts_with('{')
97    {
98        // v7.39 — an untyped '{0.1, 0.2, 0.4, 1.0}' literal is PG's
99        // float4[] weight array via the unknown-literal cast.
100        let inner = s.trim().trim_start_matches('{').trim_end_matches('}');
101        let parsed: Result<Vec<f64>, _> =
102            inner.split(',').map(|x| x.trim().parse::<f64>()).collect();
103        let vals = parsed.map_err(|_| EvalError::TypeMismatch {
104            detail: format!("{name}(): invalid weight array literal {s:?}"),
105        })?;
106        weights = parse_weight_array(
107            name,
108            &Value::FloatArray(vals.into_iter().map(Some).collect()),
109        )?;
110        rest = &rest[1..];
111    }
112    // A trailing integer is the normalization flag.
113    let norm = match rest.last() {
114        Some(Value::Int(n)) => Some(i64::from(*n)),
115        Some(Value::BigInt(n)) => Some(*n),
116        _ => None,
117    };
118    if norm.is_some() {
119        rest = &rest[..rest.len() - 1];
120    }
121    let norm = norm.unwrap_or(0);
122    if norm & !0x3F != 0 {
123        return Err(EvalError::TypeMismatch {
124            detail: format!("{name}(): unknown normalization flag bits in {norm}"),
125        });
126    }
127    if rest.len() != 2 {
128        return Err(EvalError::TypeMismatch {
129            detail: format!(
130                "{name}() takes (vec, query) optionally wrapped by a weight array and a norm flag"
131            ),
132        });
133    }
134    let vec = match &rest[0] {
135        Value::Null => None,
136        Value::TsVector(v) => Some(v.clone()),
137        other => {
138            return Err(EvalError::TypeMismatch {
139                detail: format!(
140                    "{name}() vector arg must be tsvector, got {}",
141                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
142                ),
143            });
144        }
145    };
146    let query = match &rest[1] {
147        Value::Null => None,
148        Value::TsQuery(q) => Some(q.clone()),
149        other => {
150            return Err(EvalError::TypeMismatch {
151                detail: format!(
152                    "{name}() query arg must be tsquery, got {}",
153                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
154                ),
155            });
156        }
157    };
158    Ok((weights, vec, query, norm))
159}
160
161/// Read a 4-element weight array in PG order `[D, C, B, A]`.
162fn parse_weight_array(name: &str, v: &Value<'_>) -> Result<crate::fts::RankWeights, EvalError> {
163    let vals: Vec<f32> = match v {
164        Value::FloatArray(a) => a.iter().map(|o| o.unwrap_or(0.0) as f32).collect(),
165        Value::IntArray(a) => a.iter().map(|o| o.unwrap_or(0) as f32).collect(),
166        Value::SmallIntArray(a) => a.iter().map(|o| f32::from(o.unwrap_or(0))).collect(),
167        Value::NumericArray(a) => a
168            .iter()
169            .map(|o| o.map_or(0.0, |(m, s)| (m as f64 / 10f64.powi(i32::from(s))) as f32))
170            .collect(),
171        _ => {
172            return Err(EvalError::TypeMismatch {
173                detail: format!("{name}() weight argument must be a numeric array"),
174            });
175        }
176    };
177    if vals.len() != 4 {
178        return Err(EvalError::TypeMismatch {
179            detail: format!(
180                "{name}() weight array must have 4 elements [D, C, B, A], got {}",
181                vals.len()
182            ),
183        });
184    }
185    Ok([vals[0], vals[1], vals[2], vals[3]])
186}
187
188/// v7.12.2 — `tsvector @@ tsquery` match operator. Either
189/// ordering accepted (PG semantics). NULL on either side → NULL.
190/// Anything that isn't tsvector/tsquery on either side is a type
191/// mismatch. Returns BOOL.
192pub(super) fn ts_match(l: Value, r: Value) -> Result<Value<'static>, EvalError> {
193    let (vec, query) = match (l, r) {
194        (Value::Null, _) | (_, Value::Null) => return Ok(Value::Null),
195        (Value::TsVector(v), Value::TsQuery(q)) => (v, q),
196        (Value::TsQuery(q), Value::TsVector(v)) => (v, q),
197        // v7.39 (read01 round 71) — `ts @@ 'a'`. PG reads the bare literal as a
198        // TSQUERY (an unknown literal takes the other operand's type), which is
199        // how the operator is actually written. Same family as the array and
200        // range coercions.
201        (Value::TsVector(v), Value::Text(q)) => {
202            (v, crate::eval::decode_tsquery_external(q.as_ref())?)
203        }
204        (Value::Text(q), Value::TsVector(v)) => {
205            (v, crate::eval::decode_tsquery_external(q.as_ref())?)
206        }
207        (l, r) => {
208            return Err(EvalError::TypeMismatch {
209                detail: format!(
210                    "@@ requires (tsvector, tsquery), got ({:?}, {:?})",
211                    l.data_type(),
212                    r.data_type()
213                ),
214            });
215        }
216    };
217    Ok(Value::Bool(crate::fts::ts_query_matches(&vec, &query)))
218}
219
220/// v7.12.1 — `to_tsvector([config,] text)`. With one arg the
221/// session-resolved `default_text_search_config` is used (defaults
222/// to `simple` when unset); with two args the first picks the
223/// config. NULL text → NULL.
224pub(super) fn fts_to_tsvector(
225    args: &[Value<'_>],
226    ctx: &EvalContext<'_>,
227) -> Result<Value<'static>, EvalError> {
228    let (config, text) = parse_fts_args("to_tsvector", args, ctx)?;
229    match text {
230        None => Ok(Value::Null),
231        Some(t) => Ok(Value::TsVector(crate::fts::to_tsvector(config, &t))),
232    }
233}
234
235/// v7.24 (round-16 C) — `setweight(tsvector, "char")`. Relabels
236/// every lexeme with the given PG weight letter (A=3 B=2 C=1 D=0).
237pub(super) fn fts_setweight(args: &[Value<'_>]) -> Result<Value<'static>, EvalError> {
238    // v7.39 (round 517) — PG's third argument names the lexemes to weight;
239    // the rest keep theirs. Measured: `setweight('cat:1 dog:2','B','{cat}')`
240    // is `'cat':1B 'dog':2`.
241    let (vec_arg, weight_arg, only) = match args {
242        [v, w] => (v, w, None),
243        [v, w, l] => (v, w, Some(l)),
244        _ => {
245            return Err(EvalError::TypeMismatch {
246                detail: alloc::format!("setweight expects 2 or 3 arguments, got {}", args.len()),
247            });
248        }
249    };
250    if matches!(vec_arg, Value::Null) || matches!(weight_arg, Value::Null) {
251        return Ok(Value::Null);
252    }
253    let Value::TsVector(lexemes) = vec_arg else {
254        return Err(EvalError::TypeMismatch {
255            detail: alloc::format!(
256                "setweight expects a tsvector, got {}",
257                crate::conversions::pg_type_name_for_error_opt(vec_arg.data_type())
258            ),
259        });
260    };
261    let Value::Text(w) = weight_arg else {
262        return Err(EvalError::TypeMismatch {
263            detail: alloc::format!(
264                "setweight expects a weight letter, got {}",
265                crate::conversions::pg_type_name_for_error_opt(weight_arg.data_type())
266            ),
267        });
268    };
269    let weight = match w.to_ascii_uppercase().as_str() {
270        "A" => 3,
271        "B" => 2,
272        "C" => 1,
273        "D" => 0,
274        other => {
275            return Err(EvalError::TypeMismatch {
276                detail: alloc::format!("unrecognized weight: {other:?} (expected A, B, C or D)"),
277            });
278        }
279    };
280    // The named set, when there is one. A NULL list weights nothing, which
281    // is what PG's strict-on-the-array behaviour comes to.
282    let selected: Option<alloc::vec::Vec<String>> = match only {
283        None => None,
284        Some(Value::Null) => return Ok(Value::Null),
285        Some(v) => {
286            let t = crate::eval::value_to_text(v);
287            let inner = t.trim().trim_start_matches('{').trim_end_matches('}');
288            Some(
289                inner
290                    .split(',')
291                    .map(|x| x.trim().trim_matches('"').to_string())
292                    .filter(|x| !x.is_empty())
293                    .collect(),
294            )
295        }
296    };
297    let mut out = lexemes.clone();
298    for lex in &mut out {
299        let hit = selected
300            .as_ref()
301            .is_none_or(|names| names.iter().any(|n| *n == lex.word));
302        if hit {
303            lex.weight = weight;
304        }
305    }
306    Ok(Value::TsVector(out))
307}
308
309pub(super) fn fts_plainto_tsquery(
310    args: &[Value<'_>],
311    ctx: &EvalContext<'_>,
312) -> Result<Value<'static>, EvalError> {
313    let (config, text) = parse_fts_args("plainto_tsquery", args, ctx)?;
314    match text {
315        None => Ok(Value::Null),
316        Some(t) => Ok(Value::TsQuery(crate::fts::plainto_tsquery(config, &t))),
317    }
318}
319
320pub(super) fn fts_phraseto_tsquery(
321    args: &[Value<'_>],
322    ctx: &EvalContext<'_>,
323) -> Result<Value<'static>, EvalError> {
324    let (config, text) = parse_fts_args("phraseto_tsquery", args, ctx)?;
325    match text {
326        None => Ok(Value::Null),
327        Some(t) => Ok(Value::TsQuery(crate::fts::phraseto_tsquery(config, &t))),
328    }
329}
330
331pub(super) fn fts_websearch_to_tsquery(
332    args: &[Value<'_>],
333    ctx: &EvalContext<'_>,
334) -> Result<Value<'static>, EvalError> {
335    let (config, text) = parse_fts_args("websearch_to_tsquery", args, ctx)?;
336    match text {
337        None => Ok(Value::Null),
338        Some(t) => Ok(Value::TsQuery(crate::fts::websearch_to_tsquery(config, &t))),
339    }
340}
341
342pub(super) fn fts_to_tsquery(
343    args: &[Value<'_>],
344    ctx: &EvalContext<'_>,
345) -> Result<Value<'static>, EvalError> {
346    let (config, text) = parse_fts_args("to_tsquery", args, ctx)?;
347    match text {
348        None => Ok(Value::Null),
349        Some(t) => Ok(Value::TsQuery(crate::fts::to_tsquery(config, &t)?)),
350    }
351}
352
353/// Parse the `(config, text)` / `(text)` argument pair shared by
354/// all FTS builders. Returns the resolved config + the text
355/// payload (None when text is NULL). The one-arg form pulls the
356/// config from the session's `default_text_search_config`.
357fn parse_fts_args(
358    name: &str,
359    args: &[Value<'_>],
360    ctx: &EvalContext<'_>,
361) -> Result<(crate::fts::TsConfig, Option<String>), EvalError> {
362    let (config_arg, text_arg) = match args {
363        [t] => (None, t),
364        [c, t] => (Some(c), t),
365        _ => {
366            return Err(EvalError::TypeMismatch {
367                detail: format!("{name}() takes 1 or 2 args, got {}", args.len()),
368            });
369        }
370    };
371    let config = match config_arg {
372        None => match ctx.default_text_search_config {
373            Some(name_str) => crate::fts::TsConfig::from_name(name_str).ok_or_else(|| {
374                EvalError::TypeMismatch {
375                    detail: format!(
376                        "text search config not implemented: {name_str:?} (supported: simple, english)"
377                    ),
378                }
379            })?,
380            // v7.39 (read01 round 44) — PG's initdb default is 'english',
381            // not 'simple': bare to_tsvector / to_tsquery stem + drop
382            // stopwords out of the box.
383            None => crate::fts::TsConfig::English,
384        },
385        Some(Value::Null) => return Ok((crate::fts::TsConfig::Simple, None)),
386        Some(Value::Text(name_str)) => crate::fts::TsConfig::from_name(name_str).ok_or_else(|| {
387            EvalError::TypeMismatch {
388                detail: format!(
389                    "text search config not implemented: {name_str:?} (supported: simple, english)"
390                ),
391            }
392        })?,
393        Some(other) => {
394            return Err(EvalError::TypeMismatch {
395                detail: format!(
396                    "{name}() config arg must be text, got {}",
397                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
398                ),
399            });
400        }
401    };
402    let text = match text_arg {
403        Value::Null => None,
404        Value::Text(s) => Some(s.to_string()),
405        other => {
406            return Err(EvalError::TypeMismatch {
407                detail: format!(
408                    "{name}() text arg must be text, got {}",
409                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
410                ),
411            });
412        }
413    };
414    Ok((config, text))
415}
416
417/// v7.12.0 — render a `tsvector` in PG's external form:
418/// `'lex':1,2A 'word':3` (single-quoted lexemes, optional
419/// `:positions`, optional weight letter `A/B/C/D` per position).
420/// Lexemes already arrive sorted + deduped from the engine. Used
421/// by the wire layer (OID 3614) and by SELECT-text output.
422pub fn format_tsvector(lexs: &[TsLexeme]) -> String {
423    let mut out = String::with_capacity(lexs.len() * 12);
424    for (i, l) in lexs.iter().enumerate() {
425        if i > 0 {
426            out.push(' ');
427        }
428        out.push('\'');
429        for c in l.word.chars() {
430            if c == '\'' {
431                out.push('\'');
432            }
433            out.push(c);
434        }
435        out.push('\'');
436        if !l.positions.is_empty() {
437            for (pi, p) in l.positions.iter().enumerate() {
438                out.push(if pi == 0 { ':' } else { ',' });
439                out.push_str(&p.to_string());
440            }
441            // v7.12.0 — weight is per-lexeme (the v7.12 design
442            // collapses PG's per-position weight into one letter).
443            // Emit once after the last position; default `D`
444            // (weight=0) stays implicit.
445            match l.weight {
446                3 => out.push('A'),
447                2 => out.push('B'),
448                1 => out.push('C'),
449                _ => {}
450            }
451        }
452    }
453    out
454}
455
456/// v7.12.0 — render a `tsquery` in PG's external form. Operator
457/// precedence: `!` > `&` > `|`. Phrase distance shown as `<N>`.
458pub fn format_tsquery(ast: &TsQueryAst) -> String {
459    fn go(ast: &TsQueryAst, parent_prec: u8, out: &mut String) {
460        // 0 = top, 1 = OR, 2 = AND, 3 = NOT/Phrase, 4 = atom.
461        let (own_prec, write_self): (u8, &dyn Fn(&mut String)) = match ast {
462            TsQueryAst::Or(_, _) => (1, &|_| {}),
463            TsQueryAst::And(_, _) | TsQueryAst::Phrase { .. } => (2, &|_| {}),
464            TsQueryAst::Not(_) => (3, &|_| {}),
465            TsQueryAst::Term { .. } => (4, &|_| {}),
466        };
467        let need_parens = own_prec < parent_prec;
468        if need_parens {
469            // PG spaces the inside of every auto-added group: `( 'a' | 'b' )`.
470            out.push_str("( ");
471        }
472        match ast {
473            TsQueryAst::Term { word, weight_mask } => {
474                out.push('\'');
475                for c in word.chars() {
476                    if c == '\'' {
477                        out.push('\'');
478                    }
479                    out.push(c);
480                }
481                out.push('\'');
482                // v7.39 (round 245) — the modifiers print back: `:*` for a
483                // prefix query, then the weight letters (PG's order).
484                if *weight_mask != 0 {
485                    out.push(':');
486                    if weight_mask & 0x10 != 0 {
487                        out.push('*');
488                    }
489                    for (bit, ch) in [(3u8, 'A'), (2, 'B'), (1, 'C'), (0, 'D')] {
490                        if weight_mask & (1 << bit) != 0 {
491                            out.push(ch);
492                        }
493                    }
494                }
495            }
496            TsQueryAst::And(a, b) => {
497                go(a, own_prec, out);
498                out.push_str(" & ");
499                go(b, own_prec, out);
500            }
501            TsQueryAst::Or(a, b) => {
502                go(a, own_prec, out);
503                out.push_str(" | ");
504                go(b, own_prec, out);
505            }
506            TsQueryAst::Not(x) => {
507                out.push('!');
508                go(x, own_prec, out);
509            }
510            TsQueryAst::Phrase {
511                left,
512                right,
513                distance,
514            } => {
515                go(left, own_prec, out);
516                // v7.37 D.51 — PG renders distance-1 phrases with the `<->`
517                // adjacency shorthand, and `<N>` for N > 1.
518                if *distance == 1 {
519                    out.push_str(" <-> ");
520                } else {
521                    out.push_str(&alloc::format!(" <{distance}> "));
522                }
523                go(right, own_prec, out);
524            }
525        }
526        write_self(out);
527        if need_parens {
528            out.push_str(" )");
529        }
530    }
531    let mut out = String::new();
532    go(ast, 0, &mut out);
533    out
534}
535
536/// v7.12.0 — decode PG external form `'word':1,2A 'other':3` into
537/// a `Vec<TsLexeme>`. Lexemes are sorted ascending by `word` (with
538/// duplicates merged on positions) so the output matches the
539/// engine invariant. Empty input yields an empty vector.
540///
541/// v7.12.0 only ships the cast-literal entry. Full `to_tsvector`
542/// (Unicode word-split + Porter stemming + stopwords) lands in
543/// v7.12.1.
544pub fn decode_tsvector_external(s: &str) -> Result<Vec<TsLexeme>, EvalError> {
545    let mut out: Vec<TsLexeme> = Vec::new();
546    let mut i = 0;
547    let bytes = s.as_bytes();
548    while i < bytes.len() {
549        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
550            i += 1;
551        }
552        if i >= bytes.len() {
553            break;
554        }
555        // Quoted form `'word'` (with embedded `''` for a literal
556        // single quote, mirroring PG).
557        let word = if bytes[i] == b'\'' {
558            i += 1;
559            let mut w = String::new();
560            loop {
561                if i >= bytes.len() {
562                    return Err(EvalError::TypeMismatch {
563                        detail: "tsvector literal: unterminated quoted lexeme".into(),
564                    });
565                }
566                let b = bytes[i];
567                if b == b'\'' {
568                    if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
569                        w.push('\'');
570                        i += 2;
571                    } else {
572                        i += 1;
573                        break;
574                    }
575                } else {
576                    w.push(b as char);
577                    i += 1;
578                }
579            }
580            w
581        } else {
582            // Bare form — read until whitespace, ':' or end.
583            let start = i;
584            while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b':' {
585                i += 1;
586            }
587            core::str::from_utf8(&bytes[start..i])
588                .map_err(|_| EvalError::TypeMismatch {
589                    detail: "tsvector literal: non-UTF-8 lexeme".into(),
590                })?
591                .to_string()
592        };
593        if word.is_empty() {
594            return Err(EvalError::TypeMismatch {
595                detail: "tsvector literal: empty lexeme".into(),
596            });
597        }
598        // Optional `:pos[,pos][,pos]`. Each position is u16; each
599        // may carry a trailing weight letter A/B/C/D.
600        let mut positions: Vec<u16> = Vec::new();
601        let mut weight: u8 = 0;
602        if i < bytes.len() && bytes[i] == b':' {
603            i += 1;
604            loop {
605                let start = i;
606                while i < bytes.len() && bytes[i].is_ascii_digit() {
607                    i += 1;
608                }
609                if start == i {
610                    return Err(EvalError::TypeMismatch {
611                        detail: "tsvector literal: expected digit after ':'".into(),
612                    });
613                }
614                let num: u16 = core::str::from_utf8(&bytes[start..i])
615                    .expect("ascii digits")
616                    .parse()
617                    .map_err(|_| EvalError::TypeMismatch {
618                        detail: alloc::format!(
619                            "tsvector literal: position {} overflows u16",
620                            core::str::from_utf8(&bytes[start..i]).unwrap_or("?")
621                        ),
622                    })?;
623                positions.push(num);
624                if i < bytes.len() {
625                    let w = bytes[i];
626                    if matches!(w, b'A' | b'B' | b'C' | b'D') {
627                        weight = match w {
628                            b'A' => 3,
629                            b'B' => 2,
630                            b'C' => 1,
631                            _ => 0,
632                        };
633                        i += 1;
634                    }
635                }
636                if i < bytes.len() && bytes[i] == b',' {
637                    i += 1;
638                    continue;
639                }
640                break;
641            }
642        }
643        positions.sort_unstable();
644        positions.dedup();
645        // Merge into the output vector — sorted insert by word,
646        // duplicate words merge positions.
647        match out.binary_search_by(|l| l.word.as_str().cmp(word.as_str())) {
648            Ok(idx) => {
649                for p in positions {
650                    if !out[idx].positions.contains(&p) {
651                        out[idx].positions.push(p);
652                    }
653                }
654                out[idx].positions.sort_unstable();
655                if weight != 0 {
656                    out[idx].weight = weight;
657                }
658            }
659            Err(idx) => {
660                out.insert(
661                    idx,
662                    TsLexeme {
663                        word,
664                        positions,
665                        weight,
666                    },
667                );
668            }
669        }
670    }
671    Ok(out)
672}
673
674/// v7.12.0 — decode PG external form `'foo' & 'bar' | !'baz'`
675/// into a `TsQueryAst`. v7.12.0 supports the canonical
676/// `to_tsquery` surface: single-quoted lexemes, `&` / `|` / `!`,
677/// parens, and phrase `<N>`. Bare lexemes are accepted too. Full
678/// `plainto_tsquery` / `websearch_to_tsquery` arrive in v7.12.1.
679pub fn decode_tsquery_external(s: &str) -> Result<TsQueryAst, EvalError> {
680    let mut p = TsQueryParser {
681        bytes: s.as_bytes(),
682        pos: 0,
683    };
684    p.skip_ws();
685    if p.pos >= p.bytes.len() {
686        return Err(EvalError::TypeMismatch {
687            detail: "tsquery literal: empty".into(),
688        });
689    }
690    let ast = p.parse_or()?;
691    p.skip_ws();
692    if p.pos < p.bytes.len() {
693        return Err(EvalError::TypeMismatch {
694            detail: alloc::format!("tsquery literal: trailing garbage at offset {}", p.pos),
695        });
696    }
697    Ok(ast)
698}
699
700struct TsQueryParser<'a> {
701    bytes: &'a [u8],
702    pos: usize,
703}
704
705impl<'a> TsQueryParser<'a> {
706    fn skip_ws(&mut self) {
707        while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_whitespace() {
708            self.pos += 1;
709        }
710    }
711    fn peek(&self) -> Option<u8> {
712        self.bytes.get(self.pos).copied()
713    }
714    fn parse_or(&mut self) -> Result<TsQueryAst, EvalError> {
715        let mut lhs = self.parse_and()?;
716        loop {
717            self.skip_ws();
718            if self.peek() != Some(b'|') {
719                return Ok(lhs);
720            }
721            self.pos += 1;
722            let rhs = self.parse_and()?;
723            lhs = TsQueryAst::Or(Box::new(lhs), Box::new(rhs));
724        }
725    }
726    fn parse_and(&mut self) -> Result<TsQueryAst, EvalError> {
727        let mut lhs = self.parse_unary()?;
728        loop {
729            self.skip_ws();
730            match self.peek() {
731                Some(b'&') => {
732                    self.pos += 1;
733                    let rhs = self.parse_unary()?;
734                    lhs = TsQueryAst::And(Box::new(lhs), Box::new(rhs));
735                }
736                Some(b'<') => {
737                    // Phrase operator `<N>` (distance N) or `<->` (v7.37 D.51 —
738                    // PG's adjacency shorthand, equivalent to `<1>`).
739                    self.pos += 1;
740                    let n: u16 = if self.peek() == Some(b'-')
741                        && self.bytes.get(self.pos + 1) == Some(&b'>')
742                    {
743                        self.pos += 2; // consume '->'
744                        1
745                    } else {
746                        let start = self.pos;
747                        while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
748                            self.pos += 1;
749                        }
750                        if start == self.pos || self.peek() != Some(b'>') {
751                            return Err(EvalError::TypeMismatch {
752                                detail: "tsquery literal: malformed <N> / <-> phrase operator"
753                                    .into(),
754                            });
755                        }
756                        let val = core::str::from_utf8(&self.bytes[start..self.pos])
757                            .expect("ascii digits")
758                            .parse()
759                            .map_err(|_| EvalError::TypeMismatch {
760                                detail: "tsquery literal: phrase distance overflows u16".into(),
761                            })?;
762                        self.pos += 1; // consume '>'
763                        val
764                    };
765                    let rhs = self.parse_unary()?;
766                    lhs = TsQueryAst::Phrase {
767                        left: Box::new(lhs),
768                        right: Box::new(rhs),
769                        distance: n,
770                    };
771                }
772                _ => return Ok(lhs),
773            }
774        }
775    }
776    fn parse_unary(&mut self) -> Result<TsQueryAst, EvalError> {
777        self.skip_ws();
778        if self.peek() == Some(b'!') {
779            self.pos += 1;
780            let inner = self.parse_unary()?;
781            return Ok(TsQueryAst::Not(Box::new(inner)));
782        }
783        self.parse_atom()
784    }
785    fn parse_atom(&mut self) -> Result<TsQueryAst, EvalError> {
786        self.skip_ws();
787        match self.peek() {
788            Some(b'(') => {
789                self.pos += 1;
790                let inner = self.parse_or()?;
791                self.skip_ws();
792                if self.peek() != Some(b')') {
793                    return Err(EvalError::TypeMismatch {
794                        detail: "tsquery literal: missing ')'".into(),
795                    });
796                }
797                self.pos += 1;
798                Ok(inner)
799            }
800            Some(b'\'') => {
801                self.pos += 1;
802                let mut w = String::new();
803                loop {
804                    match self.peek() {
805                        None => {
806                            return Err(EvalError::TypeMismatch {
807                                detail: "tsquery literal: unterminated quoted lexeme".into(),
808                            });
809                        }
810                        Some(b'\'') => {
811                            if self.bytes.get(self.pos + 1) == Some(&b'\'') {
812                                w.push('\'');
813                                self.pos += 2;
814                            } else {
815                                self.pos += 1;
816                                break;
817                            }
818                        }
819                        Some(b) => {
820                            w.push(b as char);
821                            self.pos += 1;
822                        }
823                    }
824                }
825                // Optional `:WEIGHT_MASK` (digit-mask) — v7.12.0
826                // accepts but always stores 0 (any).
827                let weight_mask = self.skip_weight_suffix();
828                Ok(TsQueryAst::Term {
829                    word: w,
830                    weight_mask,
831                })
832            }
833            Some(b) if b.is_ascii_alphanumeric() || b == b'_' => {
834                let start = self.pos;
835                while self.pos < self.bytes.len() {
836                    let c = self.bytes[self.pos];
837                    if c.is_ascii_alphanumeric() || c == b'_' {
838                        self.pos += 1;
839                    } else {
840                        break;
841                    }
842                }
843                let w = core::str::from_utf8(&self.bytes[start..self.pos])
844                    .map_err(|_| EvalError::TypeMismatch {
845                        detail: "tsquery literal: non-UTF-8 lexeme".into(),
846                    })?
847                    .to_string();
848                let weight_mask = self.skip_weight_suffix();
849                Ok(TsQueryAst::Term {
850                    word: w,
851                    weight_mask,
852                })
853            }
854            Some(b) => Err(EvalError::TypeMismatch {
855                detail: alloc::format!(
856                    "tsquery literal: unexpected byte {:?} at offset {}",
857                    b as char,
858                    self.pos
859                ),
860            }),
861            None => Err(EvalError::TypeMismatch {
862                detail: "tsquery literal: expected term".into(),
863            }),
864        }
865    }
866    /// v7.39 (round 245) — the `:` suffix now RETURNS its content as a
867    /// mask instead of discarding it: bit 4 for the `*` prefix flag,
868    /// A/B/C/D as PG's weight bits. Digits (the legacy digit-mask form)
869    /// are still skipped.
870    fn skip_weight_suffix(&mut self) -> u8 {
871        if self.peek() != Some(b':') {
872            return 0;
873        }
874        self.pos += 1;
875        let mut mask: u8 = 0;
876        while let Some(b) = self.peek() {
877            match b {
878                b'A' | b'a' => mask |= 1 << 3,
879                b'B' | b'b' => mask |= 1 << 2,
880                b'C' | b'c' => mask |= 1 << 1,
881                b'D' | b'd' => mask |= 1,
882                b'*' => mask |= 0x10,
883                _ if b.is_ascii_digit() => {}
884                _ => break,
885            }
886            self.pos += 1;
887        }
888        mask
889    }
890}
891
892pub(super) fn tsvector_concat(
893    l: &[spg_storage::TsLexeme],
894    r: &[spg_storage::TsLexeme],
895) -> Value<'static> {
896    let shift = l
897        .iter()
898        .flat_map(|x| x.positions.iter().copied())
899        .max()
900        .unwrap_or(0);
901    let mut out: Vec<spg_storage::TsLexeme> = l.to_vec();
902    for lex in r {
903        let shifted: Vec<u16> = lex
904            .positions
905            .iter()
906            .map(|p| p.saturating_add(shift))
907            .collect();
908        if let Some(existing) = out.iter_mut().find(|x| x.word == lex.word) {
909            existing.positions.extend(shifted);
910            existing.positions.sort_unstable();
911            existing.weight = existing.weight.max(lex.weight);
912        } else {
913            out.push(spg_storage::TsLexeme {
914                word: lex.word.clone(),
915                positions: shifted,
916                weight: lex.weight,
917            });
918        }
919    }
920    out.sort_by(|a, b| a.word.cmp(&b.word));
921    Value::TsVector(out)
922}
923
924/// v7.37.17 (17.6 siblings) — `ts_headline([config,] document,
925/// query [, options])`. Wraps every document word whose stemmed
926/// form appears as a positive term in the query with StartSel /
927/// StopSel (default `<b>` / `</b>`, overridable via the options
928/// string). Highlights across the whole document — PG's
929/// HighlightAll=true rendering; fragment selection (MaxWords /
930/// MinWords / MaxFragments) is accepted in the options string but
931/// not applied.
932pub(super) fn fts_ts_headline(
933    args: &[Value<'_>],
934    ctx: &EvalContext<'_>,
935) -> Result<Value<'static>, EvalError> {
936    // Disambiguate the 2-4 arg forms by where the tsquery sits.
937    let is_queryish = |v: &Value<'_>| matches!(v, Value::TsQuery(_));
938    let (config_arg, doc_arg, query_arg, opts_arg) = match args {
939        [d, q] => (None, d, q, None),
940        [d, q, o] if is_queryish(q) => (None, d, q, Some(o)),
941        [c, d, q] => (Some(c), d, q, None),
942        [c, d, q, o] => (Some(c), d, q, Some(o)),
943        _ => {
944            return Err(EvalError::TypeMismatch {
945                detail: format!("ts_headline() takes 2 to 4 args, got {}", args.len()),
946            });
947        }
948    };
949    if matches!(doc_arg, Value::Null) || matches!(query_arg, Value::Null) {
950        return Ok(Value::Null);
951    }
952    let config = match config_arg {
953        None => match ctx.default_text_search_config {
954            Some(name_str) => crate::fts::TsConfig::from_name(name_str).ok_or_else(|| {
955                EvalError::TypeMismatch {
956                    detail: format!(
957                        "text search config not implemented: {name_str:?} (supported: simple, english)"
958                    ),
959                }
960            })?,
961            None => crate::fts::TsConfig::English,
962        },
963        Some(Value::Text(name_str)) => {
964            crate::fts::TsConfig::from_name(name_str).ok_or_else(|| EvalError::TypeMismatch {
965                detail: format!(
966                    "text search config not implemented: {name_str:?} (supported: simple, english)"
967                ),
968            })?
969        }
970        Some(other) => {
971            return Err(EvalError::TypeMismatch {
972                detail: format!(
973                    "ts_headline() config must be text, got {}",
974                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
975                ),
976            });
977        }
978    };
979    let doc = match doc_arg {
980        Value::Text(s) => s.as_ref(),
981        other => {
982            return Err(EvalError::TypeMismatch {
983                detail: format!(
984                    "ts_headline() document must be text, got {}",
985                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
986                ),
987            });
988        }
989    };
990    let query = match query_arg {
991        Value::TsQuery(q) => q.clone(),
992        // An unquoted string literal reaches us as Text — PG resolves
993        // the unknown literal through the tsquery input parser.
994        Value::Text(s) => crate::fts::to_tsquery(config, s)?,
995        other => {
996            return Err(EvalError::TypeMismatch {
997                detail: format!(
998                    "ts_headline() query must be tsquery, got {}",
999                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
1000                ),
1001            });
1002        }
1003    };
1004    // v7.39 (FTS depth) — full option set: StartSel / StopSel /
1005    // MaxWords / MinWords / MaxFragments / FragmentDelimiter /
1006    // HighlightAll. PG defaults per textsearch docs.
1007    let mut start_sel = String::from("<b>");
1008    let mut stop_sel = String::from("</b>");
1009    let mut max_words: usize = 35;
1010    let mut min_words: usize = 15;
1011    let mut max_fragments: usize = 0;
1012    let mut frag_delim = String::from(" ... ");
1013    let mut highlight_all = false;
1014    let mut short_word: usize = 3;
1015    if let Some(opts_v) = opts_arg {
1016        let opts = match opts_v {
1017            Value::Null => "",
1018            Value::Text(s) => s.as_ref(),
1019            other => {
1020                return Err(EvalError::TypeMismatch {
1021                    detail: format!(
1022                        "ts_headline() options must be text, got {}",
1023                        crate::conversions::pg_type_name_for_error_opt(other.data_type())
1024                    ),
1025                });
1026            }
1027        };
1028        // v7.39 (read01, ts_headline validation) — PG validates the
1029        // option list instead of silently defaulting: malformed pairs
1030        // are 42601, unknown keys and out-of-range values are 22023,
1031        // non-integer values are 22P02 (all message-locked vs PG18).
1032        let parse_int = |v: &str| -> Result<i64, EvalError> {
1033            v.parse::<i64>().map_err(|_| EvalError::TypeMismatch {
1034                detail: alloc::format!("invalid input syntax for type integer: {v:?}"),
1035            })
1036        };
1037        let mut short_word_i: i64 = short_word as i64;
1038        let mut max_fragments_i: i64 = 0;
1039        let mut min_words_i: i64 = min_words as i64;
1040        let mut max_words_i: i64 = max_words as i64;
1041        for pair in opts.split(',') {
1042            if pair.trim().is_empty() {
1043                continue;
1044            }
1045            let Some((k, v)) = pair.split_once('=') else {
1046                return Err(EvalError::TypeMismatch {
1047                    detail: alloc::format!("invalid parameter list format: {:?}", pair.trim()),
1048                });
1049            };
1050            let v = v.trim().trim_matches('"');
1051            if v.is_empty() {
1052                return Err(EvalError::TypeMismatch {
1053                    detail: alloc::format!("invalid parameter list format: {:?}", pair.trim()),
1054                });
1055            }
1056            match k.trim().to_ascii_lowercase().as_str() {
1057                "startsel" => start_sel = v.to_string(),
1058                "stopsel" => stop_sel = v.to_string(),
1059                "maxwords" => max_words_i = parse_int(v)?,
1060                "minwords" => min_words_i = parse_int(v)?,
1061                "maxfragments" => max_fragments_i = parse_int(v)?,
1062                "shortword" => short_word_i = parse_int(v)?,
1063                "fragmentdelimiter" => frag_delim = v.to_string(),
1064                // PG's boolean reader is lenient: the true spellings
1065                // flip it on, anything else reads as false (no error).
1066                "highlightall" => {
1067                    highlight_all = matches!(
1068                        v.to_ascii_lowercase().as_str(),
1069                        "1" | "on" | "t" | "true" | "y" | "yes"
1070                    );
1071                }
1072                _ => {
1073                    return Err(EvalError::TypeMismatch {
1074                        detail: alloc::format!("unrecognized headline parameter: {:?}", k.trim()),
1075                    });
1076                }
1077            }
1078        }
1079        // PG's validation order (prsd_headline / mark_hl_fragments
1080        // observable behavior, both selector modes).
1081        if min_words_i >= max_words_i {
1082            return Err(EvalError::TypeMismatch {
1083                detail: "MinWords must be less than MaxWords".into(),
1084            });
1085        }
1086        if min_words_i <= 0 {
1087            return Err(EvalError::TypeMismatch {
1088                detail: "MinWords must be positive".into(),
1089            });
1090        }
1091        if short_word_i < 0 {
1092            return Err(EvalError::TypeMismatch {
1093                detail: "ShortWord must be >= 0".into(),
1094            });
1095        }
1096        if max_fragments_i < 0 {
1097            return Err(EvalError::TypeMismatch {
1098                detail: "MaxFragments must be >= 0".into(),
1099            });
1100        }
1101        max_words = max_words_i as usize;
1102        min_words = min_words_i as usize;
1103        short_word = short_word_i as usize;
1104        max_fragments = max_fragments_i as usize;
1105    }
1106    // Positive query lexemes — Not subtrees excluded.
1107    fn collect_positive(ast: &spg_storage::TsQueryAst, out: &mut Vec<String>) {
1108        match ast {
1109            spg_storage::TsQueryAst::Term { word, .. } => {
1110                if !word.is_empty() {
1111                    out.push(word.clone());
1112                }
1113            }
1114            spg_storage::TsQueryAst::And(l, r) | spg_storage::TsQueryAst::Or(l, r) => {
1115                collect_positive(l, out);
1116                collect_positive(r, out);
1117            }
1118            spg_storage::TsQueryAst::Not(_) => {}
1119            spg_storage::TsQueryAst::Phrase { left, right, .. } => {
1120                collect_positive(left, out);
1121                collect_positive(right, out);
1122            }
1123        }
1124    }
1125    let mut terms: Vec<String> = Vec::new();
1126    collect_positive(&query, &mut terms);
1127    // Tokenise the document into (word, trailing-separator) pairs,
1128    // marking query matches. Word runs follow the same
1129    // alphanumeric-or-underscore rule as crate::fts::tokenize so
1130    // headline matches agree with @@.
1131    struct HlToken {
1132        word: String,
1133        lex: String,
1134        sep_after: String,
1135        is_match: bool,
1136    }
1137    let mut tokens: Vec<HlToken> = Vec::new();
1138    let mut leading_sep = String::new();
1139    let mut word = String::new();
1140    let mut push_word = |word: &mut String, tokens: &mut Vec<HlToken>| {
1141        if word.is_empty() {
1142            return;
1143        }
1144        let lowered: String = word.chars().flat_map(|c| c.to_lowercase()).collect();
1145        let lex = match config {
1146            crate::fts::TsConfig::Simple => lowered,
1147            crate::fts::TsConfig::English => crate::fts::porter_stem(&lowered),
1148        };
1149        let is_match = terms.iter().any(|t| *t == lex);
1150        tokens.push(HlToken {
1151            word: core::mem::take(word),
1152            lex,
1153            sep_after: String::new(),
1154            is_match,
1155        });
1156    };
1157    for c in doc.chars() {
1158        if c.is_alphanumeric() || c == '_' {
1159            word.push(c);
1160        } else {
1161            push_word(&mut word, &mut tokens);
1162            match tokens.last_mut() {
1163                Some(t) => t.sep_after.push(c),
1164                None => leading_sep.push(c),
1165            }
1166        }
1167    }
1168    push_word(&mut word, &mut tokens);
1169    // Render a [lo, hi) token window with highlighting; the final
1170    // token's separator is dropped (window edges never carry
1171    // trailing punctuation/whitespace).
1172    let render = |lo: usize, hi: usize| -> String {
1173        let mut out = String::new();
1174        for (i, t) in tokens[lo..hi].iter().enumerate() {
1175            if t.is_match {
1176                out.push_str(&start_sel);
1177                out.push_str(&t.word);
1178                out.push_str(&stop_sel);
1179            } else {
1180                out.push_str(&t.word);
1181            }
1182            if lo + i + 1 < hi {
1183                out.push_str(&t.sep_after);
1184            }
1185        }
1186        out
1187    };
1188    let n = tokens.len();
1189    let match_pos: Vec<usize> = tokens
1190        .iter()
1191        .enumerate()
1192        .filter_map(|(i, t)| t.is_match.then_some(i))
1193        .collect();
1194    // HighlightAll / short documents: whole text with its original
1195    // separators (including the edges).
1196    if highlight_all || n <= min_words.max(1) {
1197        let mut out = leading_sep;
1198        out.push_str(&render(0, n));
1199        if let Some(t) = tokens.last() {
1200            out.push_str(&t.sep_after);
1201        }
1202        return Ok(Value::text(out));
1203    }
1204    // v7.39 (FTS 研读轮) — an unmatched LONG document shows its first
1205    // MinWords words in both selector modes (PG18 differential; the
1206    // old whole-text answer was locked against short documents only).
1207    if match_pos.is_empty() {
1208        return Ok(Value::text(render(0, min_words.max(1).min(n))));
1209    }
1210    if max_fragments > 0 {
1211        // v7.39 (FTS mark_hl_fragments 研读轮) — PG's MaxFragments
1212        // selector, clean-room from the studied behaviour of
1213        // wparser_def.c's mark_hl_fragments/hlCover/get_next_fragment
1214        // (read01 dir-tsearch note + PG18 source study):
1215        //   1. hlCover walks minimal windows that contain every
1216        //      top-level AND branch of the query (an OR branch matches
1217        //      at any of its terms' positions).
1218        //   2. Each cover splits into fragments of at most MaxWords
1219        //      whose both ends are query words.
1220        //   3. Greedy pick: most interesting words, ties to fewer
1221        //      words, MaxFragments times; each pick stretches — left
1222        //      by at most (MaxWords - len) / 2, right with the whole
1223        //      remainder — never crossing an already-chosen fragment,
1224        //      then shrinks both ends off BAD endpoints (a short word
1225        //      of <= ShortWord chars or an all-digit word, unless it
1226        //      is itself a query word). Overlapping candidates are
1227        //      excluded, chosen fragments render in document order.
1228        //   4. No cover at all -> the first MinWords words (the only
1229        //      place MinWords matters in fragment mode).
1230        // SPG's token stream has no SPACE/TAG tokens (separators ride
1231        // on the preceding word), so PG's NONWORDTOKEN skips collapse
1232        // away and every token counts as one word.
1233        let interesting: Vec<bool> = tokens.iter().map(|t| t.is_match).collect();
1234        let is_bad_endpoint = |i: usize| -> bool {
1235            if interesting[i] {
1236                return false;
1237            }
1238            let w = &tokens[i].word;
1239            w.chars().count() <= short_word || w.chars().all(|c| c.is_ascii_digit())
1240        };
1241        // Top-level AND groups; each group's positions are the union
1242        // of its terms' matches.
1243        fn and_groups(ast: &spg_storage::TsQueryAst, out: &mut Vec<Vec<String>>) {
1244            match ast {
1245                spg_storage::TsQueryAst::And(l, r) => {
1246                    and_groups(l, out);
1247                    and_groups(r, out);
1248                }
1249                spg_storage::TsQueryAst::Not(_) => {}
1250                other => {
1251                    let mut g = Vec::new();
1252                    // reuse the positive-term collector on the branch
1253                    fn collect(ast: &spg_storage::TsQueryAst, out: &mut Vec<String>) {
1254                        match ast {
1255                            spg_storage::TsQueryAst::Term { word, .. } => {
1256                                if !word.is_empty() {
1257                                    out.push(word.clone());
1258                                }
1259                            }
1260                            spg_storage::TsQueryAst::And(l, r)
1261                            | spg_storage::TsQueryAst::Or(l, r) => {
1262                                collect(l, out);
1263                                collect(r, out);
1264                            }
1265                            spg_storage::TsQueryAst::Not(_) => {}
1266                            spg_storage::TsQueryAst::Phrase { left, right, .. } => {
1267                                collect(left, out);
1268                                collect(right, out);
1269                            }
1270                        }
1271                    }
1272                    collect(other, &mut g);
1273                    if !g.is_empty() {
1274                        out.push(g);
1275                    }
1276                }
1277            }
1278        }
1279        let mut groups: Vec<Vec<String>> = Vec::new();
1280        and_groups(&query, &mut groups);
1281        let group_pos: Vec<Vec<usize>> = groups
1282            .iter()
1283            .map(|g| {
1284                tokens
1285                    .iter()
1286                    .enumerate()
1287                    .filter(|(_, t)| g.iter().any(|term| *term == t.lex))
1288                    .map(|(i, _)| i)
1289                    .collect()
1290            })
1291            .collect();
1292        // Candidate fragments: (startpos, endpos inclusive, words, interesting).
1293        struct Cand {
1294            st: usize,
1295            en: usize,
1296            curlen: usize,
1297            poslen: usize,
1298            chosen: bool,
1299            excluded: bool,
1300        }
1301        let mut cands: Vec<Cand> = Vec::new();
1302        if !group_pos.is_empty() && group_pos.iter().all(|ps| !ps.is_empty()) {
1303            let mut nextpos = 0usize;
1304            loop {
1305                // earliest window at/after nextpos containing one
1306                // position from every group
1307                let mut pose = 0usize;
1308                let mut dead = false;
1309                for ps in &group_pos {
1310                    match ps.iter().find(|&&p| p >= nextpos) {
1311                        Some(&p) => pose = pose.max(p),
1312                        None => {
1313                            dead = true;
1314                            break;
1315                        }
1316                    }
1317                }
1318                if dead {
1319                    break;
1320                }
1321                let mut posb = usize::MAX;
1322                for ps in &group_pos {
1323                    if let Some(&p) = ps.iter().rev().find(|&&p| p <= pose) {
1324                        posb = posb.min(p);
1325                    }
1326                }
1327                let posb = posb.max(nextpos);
1328                // split [posb, pose] into fragments of <= MaxWords with
1329                // query words at both ends
1330                let (mut st, en_cover) = (posb, pose);
1331                while st <= en_cover {
1332                    // advance st to an interesting word
1333                    let mut i = st;
1334                    while i < en_cover && !interesting[i] {
1335                        i += 1;
1336                    }
1337                    st = i;
1338                    let mut curlen = 0usize;
1339                    let mut poslen = 0usize;
1340                    i = st;
1341                    while i <= en_cover && curlen < max_words.max(1) {
1342                        curlen += 1;
1343                        if interesting[i] {
1344                            poslen += 1;
1345                        }
1346                        i += 1;
1347                    }
1348                    // if the cover was cut, back the end up to a query word
1349                    let mut en = i - 1;
1350                    if en < en_cover {
1351                        while en > st && !interesting[en] {
1352                            curlen -= 1;
1353                            en -= 1;
1354                        }
1355                    }
1356                    cands.push(Cand {
1357                        st,
1358                        en,
1359                        curlen,
1360                        poslen,
1361                        chosen: false,
1362                        excluded: false,
1363                    });
1364                    st = en + 1;
1365                }
1366                nextpos = posb + 1;
1367            }
1368        }
1369        // Greedy selection + stretch + overlap exclusion.
1370        let mut in_frag: Vec<bool> = alloc::vec![false; n];
1371        let mut picked = 0usize;
1372        for _ in 0..max_fragments {
1373            let mut best: Option<usize> = None;
1374            for (i, c) in cands.iter().enumerate() {
1375                if c.chosen || c.excluded {
1376                    continue;
1377                }
1378                let better = match best {
1379                    None => true,
1380                    Some(b) => {
1381                        c.poslen > cands[b].poslen
1382                            || (c.poslen == cands[b].poslen && c.curlen < cands[b].curlen)
1383                    }
1384                };
1385                if better {
1386                    best = Some(i);
1387                }
1388            }
1389            let Some(bi) = best else { break };
1390            let (mut st, mut en, mut curlen) = (cands[bi].st, cands[bi].en, cands[bi].curlen);
1391            if curlen < max_words {
1392                // stretch left by at most half the remainder, never
1393                // crossing an already-chosen fragment
1394                let maxstretch = (max_words - curlen) / 2;
1395                let mut stretch = 0usize;
1396                let mut posmarker = st;
1397                let mut i = st;
1398                while i > 0 && stretch < maxstretch && !in_frag[i - 1] {
1399                    i -= 1;
1400                    curlen += 1;
1401                    stretch += 1;
1402                    posmarker = i;
1403                }
1404                // shrink back off bad endpoints
1405                let mut i = posmarker;
1406                while i < st && is_bad_endpoint(i) {
1407                    curlen -= 1;
1408                    i += 1;
1409                }
1410                st = i;
1411                // stretch right with the whole remainder
1412                let mut posmarker = en;
1413                let mut i = en + 1;
1414                while i < n && curlen < max_words && !in_frag[i] {
1415                    curlen += 1;
1416                    posmarker = i;
1417                    i += 1;
1418                }
1419                // shrink back off bad endpoints
1420                let mut i = posmarker;
1421                while i > en && is_bad_endpoint(i) {
1422                    curlen -= 1;
1423                    i -= 1;
1424                }
1425                en = i;
1426            }
1427            cands[bi].st = st;
1428            cands[bi].en = en;
1429            cands[bi].curlen = curlen;
1430            cands[bi].chosen = true;
1431            for k in st..=en {
1432                in_frag[k] = true;
1433            }
1434            picked += 1;
1435            for (i, c) in cands.iter_mut().enumerate() {
1436                if i != bi
1437                    && ((c.st >= st && c.st <= en)
1438                        || (c.en >= st && c.en <= en)
1439                        || (c.st < st && c.en > en))
1440                {
1441                    c.excluded = true;
1442                }
1443            }
1444        }
1445        if picked == 0 {
1446            let hi = min_words.max(1).min(n);
1447            return Ok(Value::text(render(0, hi)));
1448        }
1449        let mut chosen: Vec<(usize, usize)> = cands
1450            .iter()
1451            .filter(|c| c.chosen)
1452            .map(|c| (c.st, c.en))
1453            .collect();
1454        chosen.sort_unstable();
1455        let parts: Vec<String> = chosen.iter().map(|&(st, en)| render(st, en + 1)).collect();
1456        return Ok(Value::text(parts.join(&frag_delim)));
1457    }
1458    // Window mode: the cover is the smallest span holding every
1459    // matched position (capped at MaxWords from its start), then
1460    // extended to MinWords — rightward first, leftward for the
1461    // remainder (differential-locked against PG18).
1462    let first = match_pos[0];
1463    let last = *match_pos.last().expect("non-empty");
1464    let mut lo = first;
1465    let mut hi = (last + 1).min(lo + max_words.max(1)).min(n);
1466    while hi - lo < min_words.max(1) && hi < n {
1467        hi += 1;
1468    }
1469    while hi - lo < min_words.max(1) && lo > 0 {
1470        lo -= 1;
1471    }
1472    Ok(Value::text(render(lo, hi)))
1473}
1474
1475/// v7.37.17 (17.6 siblings) — `ts_rewrite(query, target,
1476/// substitute)`: replaces every occurrence of the `target` subtree
1477/// inside `query` with `substitute` — the synonym-expansion
1478/// primitive (`ts_rewrite('a & b', 'a', 'foo|bar')`). Structural
1479/// subtree equality; the SELECT-driven catalog form
1480/// (`ts_rewrite(query, 'SELECT t, s FROM aliases')`) is not
1481/// supported — it needs a query-in-function executor.
1482pub(super) fn fts_ts_rewrite(
1483    args: &[Value<'_>],
1484    ctx: &EvalContext<'_>,
1485) -> Result<Value<'static>, EvalError> {
1486    if args.len() != 3 {
1487        return Err(EvalError::TypeMismatch {
1488            detail: format!(
1489                "ts_rewrite() takes 3 args (query, target, substitute), got {}",
1490                args.len()
1491            ),
1492        });
1493    }
1494    if args.iter().any(|a| matches!(a, Value::Null)) {
1495        return Ok(Value::Null);
1496    }
1497    let config = match ctx.default_text_search_config {
1498        Some(name_str) => {
1499            crate::fts::TsConfig::from_name(name_str).unwrap_or(crate::fts::TsConfig::English)
1500        }
1501        None => crate::fts::TsConfig::English,
1502    };
1503    let as_query = |v: &Value<'_>, which: &str| -> Result<spg_storage::TsQueryAst, EvalError> {
1504        match v {
1505            Value::TsQuery(q) => Ok(q.clone()),
1506            // Unknown string literals resolve through the tsquery
1507            // input parser, as in PG.
1508            Value::Text(s) => crate::fts::to_tsquery(config, s),
1509            other => Err(EvalError::TypeMismatch {
1510                detail: format!(
1511                    "ts_rewrite() {which} must be tsquery, got {}",
1512                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
1513                ),
1514            }),
1515        }
1516    };
1517    let query = as_query(&args[0], "query")?;
1518    let target = as_query(&args[1], "target")?;
1519    let substitute = as_query(&args[2], "substitute")?;
1520    fn rewrite(
1521        node: &spg_storage::TsQueryAst,
1522        target: &spg_storage::TsQueryAst,
1523        substitute: &spg_storage::TsQueryAst,
1524    ) -> spg_storage::TsQueryAst {
1525        if node == target {
1526            return substitute.clone();
1527        }
1528        use spg_storage::TsQueryAst as A;
1529        match node {
1530            A::Term { .. } => node.clone(),
1531            A::And(l, r) => A::And(
1532                Box::new(rewrite(l, target, substitute)),
1533                Box::new(rewrite(r, target, substitute)),
1534            ),
1535            A::Or(l, r) => A::Or(
1536                Box::new(rewrite(l, target, substitute)),
1537                Box::new(rewrite(r, target, substitute)),
1538            ),
1539            A::Not(x) => A::Not(Box::new(rewrite(x, target, substitute))),
1540            A::Phrase {
1541                left,
1542                right,
1543                distance,
1544            } => A::Phrase {
1545                left: Box::new(rewrite(left, target, substitute)),
1546                right: Box::new(rewrite(right, target, substitute)),
1547                distance: *distance,
1548            },
1549        }
1550    }
1551    Ok(Value::TsQuery(rewrite(&query, &target, &substitute)))
1552}
1553
1554/// v7.37.17 (17.6 siblings) — the tsquery boolean catalog
1555/// functions: tsquery_and / tsquery_or (2-arg) and tsquery_not
1556/// (1-arg) are the function forms of the && / || / !! operators.
1557/// Unknown string literals resolve through the tsquery input
1558/// parser, as everywhere else in the FTS surface.
1559pub(super) fn fts_tsquery_bool(
1560    args: &[Value<'_>],
1561    ctx: &EvalContext<'_>,
1562    op: &str,
1563) -> Result<Value<'static>, EvalError> {
1564    let arity = if op == "not" { 1 } else { 2 };
1565    if args.len() != arity {
1566        return Err(EvalError::TypeMismatch {
1567            detail: format!("tsquery_{op}() takes {arity} arg(s), got {}", args.len()),
1568        });
1569    }
1570    if args.iter().any(|a| matches!(a, Value::Null)) {
1571        return Ok(Value::Null);
1572    }
1573    let config = match ctx.default_text_search_config {
1574        Some(name_str) => {
1575            crate::fts::TsConfig::from_name(name_str).unwrap_or(crate::fts::TsConfig::English)
1576        }
1577        None => crate::fts::TsConfig::English,
1578    };
1579    let as_query = |v: &Value<'_>| -> Result<spg_storage::TsQueryAst, EvalError> {
1580        match v {
1581            Value::TsQuery(q) => Ok(q.clone()),
1582            Value::Text(s) => crate::fts::to_tsquery(config, s),
1583            other => Err(EvalError::TypeMismatch {
1584                detail: format!(
1585                    "tsquery_{op}() arguments must be tsquery, got {}",
1586                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
1587                ),
1588            }),
1589        }
1590    };
1591    use spg_storage::TsQueryAst as A;
1592    let out = match op {
1593        "and" => A::And(Box::new(as_query(&args[0])?), Box::new(as_query(&args[1])?)),
1594        "or" => A::Or(Box::new(as_query(&args[0])?), Box::new(as_query(&args[1])?)),
1595        _ => A::Not(Box::new(as_query(&args[0])?)),
1596    };
1597    Ok(Value::TsQuery(out))
1598}