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