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