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            crate::fts::TsConfig::Spanish => crate::fts_es::stem_es(&lowered),
1149            crate::fts::TsConfig::French => crate::fts_fr::stem_fr(&lowered),
1150            crate::fts::TsConfig::German => crate::fts_de::stem_de(&lowered),
1151        };
1152        let is_match = terms.iter().any(|t| *t == lex);
1153        tokens.push(HlToken {
1154            word: core::mem::take(word),
1155            lex,
1156            sep_after: String::new(),
1157            is_match,
1158        });
1159    };
1160    for c in doc.chars() {
1161        if c.is_alphanumeric() || c == '_' {
1162            word.push(c);
1163        } else {
1164            push_word(&mut word, &mut tokens);
1165            match tokens.last_mut() {
1166                Some(t) => t.sep_after.push(c),
1167                None => leading_sep.push(c),
1168            }
1169        }
1170    }
1171    push_word(&mut word, &mut tokens);
1172    // Render a [lo, hi) token window with highlighting; the final
1173    // token's separator is dropped (window edges never carry
1174    // trailing punctuation/whitespace).
1175    let render = |lo: usize, hi: usize| -> String {
1176        let mut out = String::new();
1177        for (i, t) in tokens[lo..hi].iter().enumerate() {
1178            if t.is_match {
1179                out.push_str(&start_sel);
1180                out.push_str(&t.word);
1181                out.push_str(&stop_sel);
1182            } else {
1183                out.push_str(&t.word);
1184            }
1185            if lo + i + 1 < hi {
1186                out.push_str(&t.sep_after);
1187            }
1188        }
1189        out
1190    };
1191    let n = tokens.len();
1192    let match_pos: Vec<usize> = tokens
1193        .iter()
1194        .enumerate()
1195        .filter_map(|(i, t)| t.is_match.then_some(i))
1196        .collect();
1197    // HighlightAll / short documents: whole text with its original
1198    // separators (including the edges).
1199    if highlight_all || n <= min_words.max(1) {
1200        let mut out = leading_sep;
1201        out.push_str(&render(0, n));
1202        if let Some(t) = tokens.last() {
1203            out.push_str(&t.sep_after);
1204        }
1205        return Ok(Value::text(out));
1206    }
1207    // v7.39 (FTS 研读轮) — an unmatched LONG document shows its first
1208    // MinWords words in both selector modes (PG18 differential; the
1209    // old whole-text answer was locked against short documents only).
1210    if match_pos.is_empty() {
1211        return Ok(Value::text(render(0, min_words.max(1).min(n))));
1212    }
1213    if max_fragments > 0 {
1214        // v7.39 (FTS mark_hl_fragments 研读轮) — PG's MaxFragments
1215        // selector, clean-room from the studied behaviour of
1216        // wparser_def.c's mark_hl_fragments/hlCover/get_next_fragment
1217        // (read01 dir-tsearch note + PG18 source study):
1218        //   1. hlCover walks minimal windows that contain every
1219        //      top-level AND branch of the query (an OR branch matches
1220        //      at any of its terms' positions).
1221        //   2. Each cover splits into fragments of at most MaxWords
1222        //      whose both ends are query words.
1223        //   3. Greedy pick: most interesting words, ties to fewer
1224        //      words, MaxFragments times; each pick stretches — left
1225        //      by at most (MaxWords - len) / 2, right with the whole
1226        //      remainder — never crossing an already-chosen fragment,
1227        //      then shrinks both ends off BAD endpoints (a short word
1228        //      of <= ShortWord chars or an all-digit word, unless it
1229        //      is itself a query word). Overlapping candidates are
1230        //      excluded, chosen fragments render in document order.
1231        //   4. No cover at all -> the first MinWords words (the only
1232        //      place MinWords matters in fragment mode).
1233        // SPG's token stream has no SPACE/TAG tokens (separators ride
1234        // on the preceding word), so PG's NONWORDTOKEN skips collapse
1235        // away and every token counts as one word.
1236        let interesting: Vec<bool> = tokens.iter().map(|t| t.is_match).collect();
1237        let is_bad_endpoint = |i: usize| -> bool {
1238            if interesting[i] {
1239                return false;
1240            }
1241            let w = &tokens[i].word;
1242            w.chars().count() <= short_word || w.chars().all(|c| c.is_ascii_digit())
1243        };
1244        // Top-level AND groups; each group's positions are the union
1245        // of its terms' matches.
1246        fn and_groups(ast: &spg_storage::TsQueryAst, out: &mut Vec<Vec<String>>) {
1247            match ast {
1248                spg_storage::TsQueryAst::And(l, r) => {
1249                    and_groups(l, out);
1250                    and_groups(r, out);
1251                }
1252                spg_storage::TsQueryAst::Not(_) => {}
1253                other => {
1254                    let mut g = Vec::new();
1255                    // reuse the positive-term collector on the branch
1256                    fn collect(ast: &spg_storage::TsQueryAst, out: &mut Vec<String>) {
1257                        match ast {
1258                            spg_storage::TsQueryAst::Term { word, .. } => {
1259                                if !word.is_empty() {
1260                                    out.push(word.clone());
1261                                }
1262                            }
1263                            spg_storage::TsQueryAst::And(l, r)
1264                            | spg_storage::TsQueryAst::Or(l, r) => {
1265                                collect(l, out);
1266                                collect(r, out);
1267                            }
1268                            spg_storage::TsQueryAst::Not(_) => {}
1269                            spg_storage::TsQueryAst::Phrase { left, right, .. } => {
1270                                collect(left, out);
1271                                collect(right, out);
1272                            }
1273                        }
1274                    }
1275                    collect(other, &mut g);
1276                    if !g.is_empty() {
1277                        out.push(g);
1278                    }
1279                }
1280            }
1281        }
1282        let mut groups: Vec<Vec<String>> = Vec::new();
1283        and_groups(&query, &mut groups);
1284        let group_pos: Vec<Vec<usize>> = groups
1285            .iter()
1286            .map(|g| {
1287                tokens
1288                    .iter()
1289                    .enumerate()
1290                    .filter(|(_, t)| g.iter().any(|term| *term == t.lex))
1291                    .map(|(i, _)| i)
1292                    .collect()
1293            })
1294            .collect();
1295        // Candidate fragments: (startpos, endpos inclusive, words, interesting).
1296        struct Cand {
1297            st: usize,
1298            en: usize,
1299            curlen: usize,
1300            poslen: usize,
1301            chosen: bool,
1302            excluded: bool,
1303        }
1304        let mut cands: Vec<Cand> = Vec::new();
1305        if !group_pos.is_empty() && group_pos.iter().all(|ps| !ps.is_empty()) {
1306            let mut nextpos = 0usize;
1307            loop {
1308                // earliest window at/after nextpos containing one
1309                // position from every group
1310                let mut pose = 0usize;
1311                let mut dead = false;
1312                for ps in &group_pos {
1313                    match ps.iter().find(|&&p| p >= nextpos) {
1314                        Some(&p) => pose = pose.max(p),
1315                        None => {
1316                            dead = true;
1317                            break;
1318                        }
1319                    }
1320                }
1321                if dead {
1322                    break;
1323                }
1324                let mut posb = usize::MAX;
1325                for ps in &group_pos {
1326                    if let Some(&p) = ps.iter().rev().find(|&&p| p <= pose) {
1327                        posb = posb.min(p);
1328                    }
1329                }
1330                let posb = posb.max(nextpos);
1331                // split [posb, pose] into fragments of <= MaxWords with
1332                // query words at both ends
1333                let (mut st, en_cover) = (posb, pose);
1334                while st <= en_cover {
1335                    // advance st to an interesting word
1336                    let mut i = st;
1337                    while i < en_cover && !interesting[i] {
1338                        i += 1;
1339                    }
1340                    st = i;
1341                    let mut curlen = 0usize;
1342                    let mut poslen = 0usize;
1343                    i = st;
1344                    while i <= en_cover && curlen < max_words.max(1) {
1345                        curlen += 1;
1346                        if interesting[i] {
1347                            poslen += 1;
1348                        }
1349                        i += 1;
1350                    }
1351                    // if the cover was cut, back the end up to a query word
1352                    let mut en = i - 1;
1353                    if en < en_cover {
1354                        while en > st && !interesting[en] {
1355                            curlen -= 1;
1356                            en -= 1;
1357                        }
1358                    }
1359                    cands.push(Cand {
1360                        st,
1361                        en,
1362                        curlen,
1363                        poslen,
1364                        chosen: false,
1365                        excluded: false,
1366                    });
1367                    st = en + 1;
1368                }
1369                nextpos = posb + 1;
1370            }
1371        }
1372        // Greedy selection + stretch + overlap exclusion.
1373        let mut in_frag: Vec<bool> = alloc::vec![false; n];
1374        let mut picked = 0usize;
1375        for _ in 0..max_fragments {
1376            let mut best: Option<usize> = None;
1377            for (i, c) in cands.iter().enumerate() {
1378                if c.chosen || c.excluded {
1379                    continue;
1380                }
1381                let better = match best {
1382                    None => true,
1383                    Some(b) => {
1384                        c.poslen > cands[b].poslen
1385                            || (c.poslen == cands[b].poslen && c.curlen < cands[b].curlen)
1386                    }
1387                };
1388                if better {
1389                    best = Some(i);
1390                }
1391            }
1392            let Some(bi) = best else { break };
1393            let (mut st, mut en, mut curlen) = (cands[bi].st, cands[bi].en, cands[bi].curlen);
1394            if curlen < max_words {
1395                // stretch left by at most half the remainder, never
1396                // crossing an already-chosen fragment
1397                let maxstretch = (max_words - curlen) / 2;
1398                let mut stretch = 0usize;
1399                let mut posmarker = st;
1400                let mut i = st;
1401                while i > 0 && stretch < maxstretch && !in_frag[i - 1] {
1402                    i -= 1;
1403                    curlen += 1;
1404                    stretch += 1;
1405                    posmarker = i;
1406                }
1407                // shrink back off bad endpoints
1408                let mut i = posmarker;
1409                while i < st && is_bad_endpoint(i) {
1410                    curlen -= 1;
1411                    i += 1;
1412                }
1413                st = i;
1414                // stretch right with the whole remainder
1415                let mut posmarker = en;
1416                let mut i = en + 1;
1417                while i < n && curlen < max_words && !in_frag[i] {
1418                    curlen += 1;
1419                    posmarker = i;
1420                    i += 1;
1421                }
1422                // shrink back off bad endpoints
1423                let mut i = posmarker;
1424                while i > en && is_bad_endpoint(i) {
1425                    curlen -= 1;
1426                    i -= 1;
1427                }
1428                en = i;
1429            }
1430            cands[bi].st = st;
1431            cands[bi].en = en;
1432            cands[bi].curlen = curlen;
1433            cands[bi].chosen = true;
1434            for k in st..=en {
1435                in_frag[k] = true;
1436            }
1437            picked += 1;
1438            for (i, c) in cands.iter_mut().enumerate() {
1439                if i != bi
1440                    && ((c.st >= st && c.st <= en)
1441                        || (c.en >= st && c.en <= en)
1442                        || (c.st < st && c.en > en))
1443                {
1444                    c.excluded = true;
1445                }
1446            }
1447        }
1448        if picked == 0 {
1449            let hi = min_words.max(1).min(n);
1450            return Ok(Value::text(render(0, hi)));
1451        }
1452        let mut chosen: Vec<(usize, usize)> = cands
1453            .iter()
1454            .filter(|c| c.chosen)
1455            .map(|c| (c.st, c.en))
1456            .collect();
1457        chosen.sort_unstable();
1458        let parts: Vec<String> = chosen.iter().map(|&(st, en)| render(st, en + 1)).collect();
1459        return Ok(Value::text(parts.join(&frag_delim)));
1460    }
1461    // Window mode: the cover is the smallest span holding every
1462    // matched position (capped at MaxWords from its start), then
1463    // extended to MinWords — rightward first, leftward for the
1464    // remainder (differential-locked against PG18).
1465    let first = match_pos[0];
1466    let last = *match_pos.last().expect("non-empty");
1467    let mut lo = first;
1468    let mut hi = (last + 1).min(lo + max_words.max(1)).min(n);
1469    while hi - lo < min_words.max(1) && hi < n {
1470        hi += 1;
1471    }
1472    while hi - lo < min_words.max(1) && lo > 0 {
1473        lo -= 1;
1474    }
1475    Ok(Value::text(render(lo, hi)))
1476}
1477
1478/// v7.37.17 (17.6 siblings) — `ts_rewrite(query, target,
1479/// substitute)`: replaces every occurrence of the `target` subtree
1480/// inside `query` with `substitute` — the synonym-expansion
1481/// primitive (`ts_rewrite('a & b', 'a', 'foo|bar')`). Structural
1482/// subtree equality; the SELECT-driven catalog form
1483/// (`ts_rewrite(query, 'SELECT t, s FROM aliases')`) is not
1484/// supported — it needs a query-in-function executor.
1485pub(super) fn fts_ts_rewrite(
1486    args: &[Value<'_>],
1487    ctx: &EvalContext<'_>,
1488) -> Result<Value<'static>, EvalError> {
1489    if args.len() != 3 {
1490        return Err(EvalError::TypeMismatch {
1491            detail: format!(
1492                "ts_rewrite() takes 3 args (query, target, substitute), got {}",
1493                args.len()
1494            ),
1495        });
1496    }
1497    if args.iter().any(|a| matches!(a, Value::Null)) {
1498        return Ok(Value::Null);
1499    }
1500    let config = match ctx.default_text_search_config {
1501        Some(name_str) => {
1502            crate::fts::TsConfig::from_name(name_str).unwrap_or(crate::fts::TsConfig::English)
1503        }
1504        None => crate::fts::TsConfig::English,
1505    };
1506    let as_query = |v: &Value<'_>, which: &str| -> Result<spg_storage::TsQueryAst, EvalError> {
1507        match v {
1508            Value::TsQuery(q) => Ok(q.clone()),
1509            // Unknown string literals resolve through the tsquery
1510            // input parser, as in PG.
1511            Value::Text(s) => crate::fts::to_tsquery(config, s),
1512            other => Err(EvalError::TypeMismatch {
1513                detail: format!(
1514                    "ts_rewrite() {which} must be tsquery, got {}",
1515                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
1516                ),
1517            }),
1518        }
1519    };
1520    let query = as_query(&args[0], "query")?;
1521    let target = as_query(&args[1], "target")?;
1522    let substitute = as_query(&args[2], "substitute")?;
1523    fn rewrite(
1524        node: &spg_storage::TsQueryAst,
1525        target: &spg_storage::TsQueryAst,
1526        substitute: &spg_storage::TsQueryAst,
1527    ) -> spg_storage::TsQueryAst {
1528        if node == target {
1529            return substitute.clone();
1530        }
1531        use spg_storage::TsQueryAst as A;
1532        match node {
1533            A::Term { .. } => node.clone(),
1534            A::And(l, r) => A::And(
1535                Box::new(rewrite(l, target, substitute)),
1536                Box::new(rewrite(r, target, substitute)),
1537            ),
1538            A::Or(l, r) => A::Or(
1539                Box::new(rewrite(l, target, substitute)),
1540                Box::new(rewrite(r, target, substitute)),
1541            ),
1542            A::Not(x) => A::Not(Box::new(rewrite(x, target, substitute))),
1543            A::Phrase {
1544                left,
1545                right,
1546                distance,
1547            } => A::Phrase {
1548                left: Box::new(rewrite(left, target, substitute)),
1549                right: Box::new(rewrite(right, target, substitute)),
1550                distance: *distance,
1551            },
1552        }
1553    }
1554    Ok(Value::TsQuery(rewrite(&query, &target, &substitute)))
1555}
1556
1557/// v7.37.17 (17.6 siblings) — the tsquery boolean catalog
1558/// functions: tsquery_and / tsquery_or (2-arg) and tsquery_not
1559/// (1-arg) are the function forms of the && / || / !! operators.
1560/// Unknown string literals resolve through the tsquery input
1561/// parser, as everywhere else in the FTS surface.
1562pub(super) fn fts_tsquery_bool(
1563    args: &[Value<'_>],
1564    ctx: &EvalContext<'_>,
1565    op: &str,
1566) -> Result<Value<'static>, EvalError> {
1567    let arity = if op == "not" { 1 } else { 2 };
1568    if args.len() != arity {
1569        return Err(EvalError::TypeMismatch {
1570            detail: format!("tsquery_{op}() takes {arity} arg(s), got {}", args.len()),
1571        });
1572    }
1573    if args.iter().any(|a| matches!(a, Value::Null)) {
1574        return Ok(Value::Null);
1575    }
1576    let config = match ctx.default_text_search_config {
1577        Some(name_str) => {
1578            crate::fts::TsConfig::from_name(name_str).unwrap_or(crate::fts::TsConfig::English)
1579        }
1580        None => crate::fts::TsConfig::English,
1581    };
1582    let as_query = |v: &Value<'_>| -> Result<spg_storage::TsQueryAst, EvalError> {
1583        match v {
1584            Value::TsQuery(q) => Ok(q.clone()),
1585            Value::Text(s) => crate::fts::to_tsquery(config, s),
1586            other => Err(EvalError::TypeMismatch {
1587                detail: format!(
1588                    "tsquery_{op}() arguments must be tsquery, got {}",
1589                    crate::conversions::pg_type_name_for_error_opt(other.data_type())
1590                ),
1591            }),
1592        }
1593    };
1594    use spg_storage::TsQueryAst as A;
1595    let out = match op {
1596        "and" => A::And(Box::new(as_query(&args[0])?), Box::new(as_query(&args[1])?)),
1597        "or" => A::Or(Box::new(as_query(&args[0])?), Box::new(as_query(&args[1])?)),
1598        _ => A::Not(Box::new(as_query(&args[0])?)),
1599    };
1600    Ok(Value::TsQuery(out))
1601}