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    let (vec, query) = parse_rank_args("ts_rank", args)?;
23    match (vec, query) {
24        (None, _) | (_, None) => Ok(Value::Null),
25        (Some(v), Some(q)) => Ok(Value::Float(f64::from(crate::fts::ts_rank(&v, &q)))),
26    }
27}
28
29pub(super) fn fts_ts_rank_cd(args: &[Value<'_>]) -> Result<Value<'static>, EvalError> {
30    let (vec, query) = parse_rank_args("ts_rank_cd", args)?;
31    match (vec, query) {
32        (None, _) | (_, None) => Ok(Value::Null),
33        (Some(v), Some(q)) => Ok(Value::Float(f64::from(crate::fts::ts_rank_cd(&v, &q)))),
34    }
35}
36
37fn parse_rank_args(
38    name: &str,
39    args: &[Value<'_>],
40) -> Result<
41    (
42        Option<Vec<spg_storage::TsLexeme>>,
43        Option<spg_storage::TsQueryAst>,
44    ),
45    EvalError,
46> {
47    if args.len() != 2 {
48        return Err(EvalError::TypeMismatch {
49            detail: format!(
50                "{name}() takes 2 args in v7.12.2 (weights array + normalisation flag are v7.12.x carve-out), got {}",
51                args.len()
52            ),
53        });
54    }
55    let vec = match &args[0] {
56        Value::Null => None,
57        Value::TsVector(v) => Some(v.clone()),
58        other => {
59            return Err(EvalError::TypeMismatch {
60                detail: format!(
61                    "{name}() first arg must be tsvector, got {:?}",
62                    other.data_type()
63                ),
64            });
65        }
66    };
67    let query = match &args[1] {
68        Value::Null => None,
69        Value::TsQuery(q) => Some(q.clone()),
70        other => {
71            return Err(EvalError::TypeMismatch {
72                detail: format!(
73                    "{name}() second arg must be tsquery, got {:?}",
74                    other.data_type()
75                ),
76            });
77        }
78    };
79    Ok((vec, query))
80}
81
82/// v7.12.2 — `tsvector @@ tsquery` match operator. Either
83/// ordering accepted (PG semantics). NULL on either side → NULL.
84/// Anything that isn't tsvector/tsquery on either side is a type
85/// mismatch. Returns BOOL.
86pub(super) fn ts_match(l: Value, r: Value) -> Result<Value<'static>, EvalError> {
87    let (vec, query) = match (l, r) {
88        (Value::Null, _) | (_, Value::Null) => return Ok(Value::Null),
89        (Value::TsVector(v), Value::TsQuery(q)) => (v, q),
90        (Value::TsQuery(q), Value::TsVector(v)) => (v, q),
91        (l, r) => {
92            return Err(EvalError::TypeMismatch {
93                detail: format!(
94                    "@@ requires (tsvector, tsquery), got ({:?}, {:?})",
95                    l.data_type(),
96                    r.data_type()
97                ),
98            });
99        }
100    };
101    Ok(Value::Bool(crate::fts::ts_query_matches(&vec, &query)))
102}
103
104/// v7.12.1 — `to_tsvector([config,] text)`. With one arg the
105/// session-resolved `default_text_search_config` is used (defaults
106/// to `simple` when unset); with two args the first picks the
107/// config. NULL text → NULL.
108pub(super) fn fts_to_tsvector(
109    args: &[Value<'_>],
110    ctx: &EvalContext<'_>,
111) -> Result<Value<'static>, EvalError> {
112    let (config, text) = parse_fts_args("to_tsvector", args, ctx)?;
113    match text {
114        None => Ok(Value::Null),
115        Some(t) => Ok(Value::TsVector(crate::fts::to_tsvector(config, &t))),
116    }
117}
118
119/// v7.24 (round-16 C) — `setweight(tsvector, "char")`. Relabels
120/// every lexeme with the given PG weight letter (A=3 B=2 C=1 D=0).
121pub(super) fn fts_setweight(args: &[Value<'_>]) -> Result<Value<'static>, EvalError> {
122    let [vec_arg, weight_arg] = args else {
123        return Err(EvalError::TypeMismatch {
124            detail: alloc::format!("setweight expects 2 arguments, got {}", args.len()),
125        });
126    };
127    if matches!(vec_arg, Value::Null) || matches!(weight_arg, Value::Null) {
128        return Ok(Value::Null);
129    }
130    let Value::TsVector(lexemes) = vec_arg else {
131        return Err(EvalError::TypeMismatch {
132            detail: alloc::format!(
133                "setweight expects a tsvector, got {:?}",
134                vec_arg.data_type()
135            ),
136        });
137    };
138    let Value::Text(w) = weight_arg else {
139        return Err(EvalError::TypeMismatch {
140            detail: alloc::format!(
141                "setweight expects a weight letter, got {:?}",
142                weight_arg.data_type()
143            ),
144        });
145    };
146    let weight = match w.to_ascii_uppercase().as_str() {
147        "A" => 3,
148        "B" => 2,
149        "C" => 1,
150        "D" => 0,
151        other => {
152            return Err(EvalError::TypeMismatch {
153                detail: alloc::format!("unrecognized weight: {other:?} (expected A, B, C or D)"),
154            });
155        }
156    };
157    let mut out = lexemes.clone();
158    for lex in &mut out {
159        lex.weight = weight;
160    }
161    Ok(Value::TsVector(out))
162}
163
164pub(super) fn fts_plainto_tsquery(
165    args: &[Value<'_>],
166    ctx: &EvalContext<'_>,
167) -> Result<Value<'static>, EvalError> {
168    let (config, text) = parse_fts_args("plainto_tsquery", args, ctx)?;
169    match text {
170        None => Ok(Value::Null),
171        Some(t) => Ok(Value::TsQuery(crate::fts::plainto_tsquery(config, &t))),
172    }
173}
174
175pub(super) fn fts_phraseto_tsquery(
176    args: &[Value<'_>],
177    ctx: &EvalContext<'_>,
178) -> Result<Value<'static>, EvalError> {
179    let (config, text) = parse_fts_args("phraseto_tsquery", args, ctx)?;
180    match text {
181        None => Ok(Value::Null),
182        Some(t) => Ok(Value::TsQuery(crate::fts::phraseto_tsquery(config, &t))),
183    }
184}
185
186pub(super) fn fts_websearch_to_tsquery(
187    args: &[Value<'_>],
188    ctx: &EvalContext<'_>,
189) -> Result<Value<'static>, EvalError> {
190    let (config, text) = parse_fts_args("websearch_to_tsquery", args, ctx)?;
191    match text {
192        None => Ok(Value::Null),
193        Some(t) => Ok(Value::TsQuery(crate::fts::websearch_to_tsquery(config, &t))),
194    }
195}
196
197pub(super) fn fts_to_tsquery(
198    args: &[Value<'_>],
199    ctx: &EvalContext<'_>,
200) -> Result<Value<'static>, EvalError> {
201    let (config, text) = parse_fts_args("to_tsquery", args, ctx)?;
202    match text {
203        None => Ok(Value::Null),
204        Some(t) => Ok(Value::TsQuery(crate::fts::to_tsquery(config, &t)?)),
205    }
206}
207
208/// Parse the `(config, text)` / `(text)` argument pair shared by
209/// all FTS builders. Returns the resolved config + the text
210/// payload (None when text is NULL). The one-arg form pulls the
211/// config from the session's `default_text_search_config`.
212fn parse_fts_args(
213    name: &str,
214    args: &[Value<'_>],
215    ctx: &EvalContext<'_>,
216) -> Result<(crate::fts::TsConfig, Option<String>), EvalError> {
217    let (config_arg, text_arg) = match args {
218        [t] => (None, t),
219        [c, t] => (Some(c), t),
220        _ => {
221            return Err(EvalError::TypeMismatch {
222                detail: format!("{name}() takes 1 or 2 args, got {}", args.len()),
223            });
224        }
225    };
226    let config = match config_arg {
227        None => match ctx.default_text_search_config {
228            Some(name_str) => crate::fts::TsConfig::from_name(name_str).ok_or_else(|| {
229                EvalError::TypeMismatch {
230                    detail: format!(
231                        "text search config not implemented: {name_str:?} (supported: simple, english)"
232                    ),
233                }
234            })?,
235            None => crate::fts::TsConfig::Simple,
236        },
237        Some(Value::Null) => return Ok((crate::fts::TsConfig::Simple, None)),
238        Some(Value::Text(name_str)) => crate::fts::TsConfig::from_name(name_str).ok_or_else(|| {
239            EvalError::TypeMismatch {
240                detail: format!(
241                    "text search config not implemented: {name_str:?} (supported: simple, english)"
242                ),
243            }
244        })?,
245        Some(other) => {
246            return Err(EvalError::TypeMismatch {
247                detail: format!(
248                    "{name}() config arg must be text, got {:?}",
249                    other.data_type()
250                ),
251            });
252        }
253    };
254    let text = match text_arg {
255        Value::Null => None,
256        Value::Text(s) => Some(s.to_string()),
257        other => {
258            return Err(EvalError::TypeMismatch {
259                detail: format!(
260                    "{name}() text arg must be text, got {:?}",
261                    other.data_type()
262                ),
263            });
264        }
265    };
266    Ok((config, text))
267}
268
269/// v7.12.0 — render a `tsvector` in PG's external form:
270/// `'lex':1,2A 'word':3` (single-quoted lexemes, optional
271/// `:positions`, optional weight letter `A/B/C/D` per position).
272/// Lexemes already arrive sorted + deduped from the engine. Used
273/// by the wire layer (OID 3614) and by SELECT-text output.
274pub fn format_tsvector(lexs: &[TsLexeme]) -> String {
275    let mut out = String::with_capacity(lexs.len() * 12);
276    for (i, l) in lexs.iter().enumerate() {
277        if i > 0 {
278            out.push(' ');
279        }
280        out.push('\'');
281        for c in l.word.chars() {
282            if c == '\'' {
283                out.push('\'');
284            }
285            out.push(c);
286        }
287        out.push('\'');
288        if !l.positions.is_empty() {
289            for (pi, p) in l.positions.iter().enumerate() {
290                out.push(if pi == 0 { ':' } else { ',' });
291                out.push_str(&p.to_string());
292            }
293            // v7.12.0 — weight is per-lexeme (the v7.12 design
294            // collapses PG's per-position weight into one letter).
295            // Emit once after the last position; default `D`
296            // (weight=0) stays implicit.
297            match l.weight {
298                3 => out.push('A'),
299                2 => out.push('B'),
300                1 => out.push('C'),
301                _ => {}
302            }
303        }
304    }
305    out
306}
307
308/// v7.12.0 — render a `tsquery` in PG's external form. Operator
309/// precedence: `!` > `&` > `|`. Phrase distance shown as `<N>`.
310pub fn format_tsquery(ast: &TsQueryAst) -> String {
311    fn go(ast: &TsQueryAst, parent_prec: u8, out: &mut String) {
312        // 0 = top, 1 = OR, 2 = AND, 3 = NOT/Phrase, 4 = atom.
313        let (own_prec, write_self): (u8, &dyn Fn(&mut String)) = match ast {
314            TsQueryAst::Or(_, _) => (1, &|_| {}),
315            TsQueryAst::And(_, _) | TsQueryAst::Phrase { .. } => (2, &|_| {}),
316            TsQueryAst::Not(_) => (3, &|_| {}),
317            TsQueryAst::Term { .. } => (4, &|_| {}),
318        };
319        let need_parens = own_prec < parent_prec;
320        if need_parens {
321            out.push('(');
322        }
323        match ast {
324            TsQueryAst::Term { word, .. } => {
325                out.push('\'');
326                for c in word.chars() {
327                    if c == '\'' {
328                        out.push('\'');
329                    }
330                    out.push(c);
331                }
332                out.push('\'');
333            }
334            TsQueryAst::And(a, b) => {
335                go(a, own_prec, out);
336                out.push_str(" & ");
337                go(b, own_prec, out);
338            }
339            TsQueryAst::Or(a, b) => {
340                go(a, own_prec, out);
341                out.push_str(" | ");
342                go(b, own_prec, out);
343            }
344            TsQueryAst::Not(x) => {
345                out.push('!');
346                go(x, own_prec, out);
347            }
348            TsQueryAst::Phrase {
349                left,
350                right,
351                distance,
352            } => {
353                go(left, own_prec, out);
354                out.push_str(&alloc::format!(" <{distance}> "));
355                go(right, own_prec, out);
356            }
357        }
358        write_self(out);
359        if need_parens {
360            out.push(')');
361        }
362    }
363    let mut out = String::new();
364    go(ast, 0, &mut out);
365    out
366}
367
368/// v7.12.0 — decode PG external form `'word':1,2A 'other':3` into
369/// a `Vec<TsLexeme>`. Lexemes are sorted ascending by `word` (with
370/// duplicates merged on positions) so the output matches the
371/// engine invariant. Empty input yields an empty vector.
372///
373/// v7.12.0 only ships the cast-literal entry. Full `to_tsvector`
374/// (Unicode word-split + Porter stemming + stopwords) lands in
375/// v7.12.1.
376pub fn decode_tsvector_external(s: &str) -> Result<Vec<TsLexeme>, EvalError> {
377    let mut out: Vec<TsLexeme> = Vec::new();
378    let mut i = 0;
379    let bytes = s.as_bytes();
380    while i < bytes.len() {
381        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
382            i += 1;
383        }
384        if i >= bytes.len() {
385            break;
386        }
387        // Quoted form `'word'` (with embedded `''` for a literal
388        // single quote, mirroring PG).
389        let word = if bytes[i] == b'\'' {
390            i += 1;
391            let mut w = String::new();
392            loop {
393                if i >= bytes.len() {
394                    return Err(EvalError::TypeMismatch {
395                        detail: "tsvector literal: unterminated quoted lexeme".into(),
396                    });
397                }
398                let b = bytes[i];
399                if b == b'\'' {
400                    if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
401                        w.push('\'');
402                        i += 2;
403                    } else {
404                        i += 1;
405                        break;
406                    }
407                } else {
408                    w.push(b as char);
409                    i += 1;
410                }
411            }
412            w
413        } else {
414            // Bare form — read until whitespace, ':' or end.
415            let start = i;
416            while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b':' {
417                i += 1;
418            }
419            core::str::from_utf8(&bytes[start..i])
420                .map_err(|_| EvalError::TypeMismatch {
421                    detail: "tsvector literal: non-UTF-8 lexeme".into(),
422                })?
423                .to_string()
424        };
425        if word.is_empty() {
426            return Err(EvalError::TypeMismatch {
427                detail: "tsvector literal: empty lexeme".into(),
428            });
429        }
430        // Optional `:pos[,pos][,pos]`. Each position is u16; each
431        // may carry a trailing weight letter A/B/C/D.
432        let mut positions: Vec<u16> = Vec::new();
433        let mut weight: u8 = 0;
434        if i < bytes.len() && bytes[i] == b':' {
435            i += 1;
436            loop {
437                let start = i;
438                while i < bytes.len() && bytes[i].is_ascii_digit() {
439                    i += 1;
440                }
441                if start == i {
442                    return Err(EvalError::TypeMismatch {
443                        detail: "tsvector literal: expected digit after ':'".into(),
444                    });
445                }
446                let num: u16 = core::str::from_utf8(&bytes[start..i])
447                    .expect("ascii digits")
448                    .parse()
449                    .map_err(|_| EvalError::TypeMismatch {
450                        detail: alloc::format!(
451                            "tsvector literal: position {} overflows u16",
452                            core::str::from_utf8(&bytes[start..i]).unwrap_or("?")
453                        ),
454                    })?;
455                positions.push(num);
456                if i < bytes.len() {
457                    let w = bytes[i];
458                    if matches!(w, b'A' | b'B' | b'C' | b'D') {
459                        weight = match w {
460                            b'A' => 3,
461                            b'B' => 2,
462                            b'C' => 1,
463                            _ => 0,
464                        };
465                        i += 1;
466                    }
467                }
468                if i < bytes.len() && bytes[i] == b',' {
469                    i += 1;
470                    continue;
471                }
472                break;
473            }
474        }
475        positions.sort_unstable();
476        positions.dedup();
477        // Merge into the output vector — sorted insert by word,
478        // duplicate words merge positions.
479        match out.binary_search_by(|l| l.word.as_str().cmp(word.as_str())) {
480            Ok(idx) => {
481                for p in positions {
482                    if !out[idx].positions.contains(&p) {
483                        out[idx].positions.push(p);
484                    }
485                }
486                out[idx].positions.sort_unstable();
487                if weight != 0 {
488                    out[idx].weight = weight;
489                }
490            }
491            Err(idx) => {
492                out.insert(
493                    idx,
494                    TsLexeme {
495                        word,
496                        positions,
497                        weight,
498                    },
499                );
500            }
501        }
502    }
503    Ok(out)
504}
505
506/// v7.12.0 — decode PG external form `'foo' & 'bar' | !'baz'`
507/// into a `TsQueryAst`. v7.12.0 supports the canonical
508/// `to_tsquery` surface: single-quoted lexemes, `&` / `|` / `!`,
509/// parens, and phrase `<N>`. Bare lexemes are accepted too. Full
510/// `plainto_tsquery` / `websearch_to_tsquery` arrive in v7.12.1.
511pub fn decode_tsquery_external(s: &str) -> Result<TsQueryAst, EvalError> {
512    let mut p = TsQueryParser {
513        bytes: s.as_bytes(),
514        pos: 0,
515    };
516    p.skip_ws();
517    if p.pos >= p.bytes.len() {
518        return Err(EvalError::TypeMismatch {
519            detail: "tsquery literal: empty".into(),
520        });
521    }
522    let ast = p.parse_or()?;
523    p.skip_ws();
524    if p.pos < p.bytes.len() {
525        return Err(EvalError::TypeMismatch {
526            detail: alloc::format!("tsquery literal: trailing garbage at offset {}", p.pos),
527        });
528    }
529    Ok(ast)
530}
531
532struct TsQueryParser<'a> {
533    bytes: &'a [u8],
534    pos: usize,
535}
536
537impl<'a> TsQueryParser<'a> {
538    fn skip_ws(&mut self) {
539        while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_whitespace() {
540            self.pos += 1;
541        }
542    }
543    fn peek(&self) -> Option<u8> {
544        self.bytes.get(self.pos).copied()
545    }
546    fn parse_or(&mut self) -> Result<TsQueryAst, EvalError> {
547        let mut lhs = self.parse_and()?;
548        loop {
549            self.skip_ws();
550            if self.peek() != Some(b'|') {
551                return Ok(lhs);
552            }
553            self.pos += 1;
554            let rhs = self.parse_and()?;
555            lhs = TsQueryAst::Or(Box::new(lhs), Box::new(rhs));
556        }
557    }
558    fn parse_and(&mut self) -> Result<TsQueryAst, EvalError> {
559        let mut lhs = self.parse_unary()?;
560        loop {
561            self.skip_ws();
562            match self.peek() {
563                Some(b'&') => {
564                    self.pos += 1;
565                    let rhs = self.parse_unary()?;
566                    lhs = TsQueryAst::And(Box::new(lhs), Box::new(rhs));
567                }
568                Some(b'<') => {
569                    // Phrase distance `<N>`.
570                    self.pos += 1;
571                    let start = self.pos;
572                    while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
573                        self.pos += 1;
574                    }
575                    if start == self.pos || self.peek() != Some(b'>') {
576                        return Err(EvalError::TypeMismatch {
577                            detail: "tsquery literal: malformed <N> phrase operator".into(),
578                        });
579                    }
580                    let n: u16 = core::str::from_utf8(&self.bytes[start..self.pos])
581                        .expect("ascii digits")
582                        .parse()
583                        .map_err(|_| EvalError::TypeMismatch {
584                            detail: "tsquery literal: phrase distance overflows u16".into(),
585                        })?;
586                    self.pos += 1; // consume '>'
587                    let rhs = self.parse_unary()?;
588                    lhs = TsQueryAst::Phrase {
589                        left: Box::new(lhs),
590                        right: Box::new(rhs),
591                        distance: n,
592                    };
593                }
594                _ => return Ok(lhs),
595            }
596        }
597    }
598    fn parse_unary(&mut self) -> Result<TsQueryAst, EvalError> {
599        self.skip_ws();
600        if self.peek() == Some(b'!') {
601            self.pos += 1;
602            let inner = self.parse_unary()?;
603            return Ok(TsQueryAst::Not(Box::new(inner)));
604        }
605        self.parse_atom()
606    }
607    fn parse_atom(&mut self) -> Result<TsQueryAst, EvalError> {
608        self.skip_ws();
609        match self.peek() {
610            Some(b'(') => {
611                self.pos += 1;
612                let inner = self.parse_or()?;
613                self.skip_ws();
614                if self.peek() != Some(b')') {
615                    return Err(EvalError::TypeMismatch {
616                        detail: "tsquery literal: missing ')'".into(),
617                    });
618                }
619                self.pos += 1;
620                Ok(inner)
621            }
622            Some(b'\'') => {
623                self.pos += 1;
624                let mut w = String::new();
625                loop {
626                    match self.peek() {
627                        None => {
628                            return Err(EvalError::TypeMismatch {
629                                detail: "tsquery literal: unterminated quoted lexeme".into(),
630                            });
631                        }
632                        Some(b'\'') => {
633                            if self.bytes.get(self.pos + 1) == Some(&b'\'') {
634                                w.push('\'');
635                                self.pos += 2;
636                            } else {
637                                self.pos += 1;
638                                break;
639                            }
640                        }
641                        Some(b) => {
642                            w.push(b as char);
643                            self.pos += 1;
644                        }
645                    }
646                }
647                // Optional `:WEIGHT_MASK` (digit-mask) — v7.12.0
648                // accepts but always stores 0 (any).
649                self.skip_weight_suffix();
650                Ok(TsQueryAst::Term {
651                    word: w,
652                    weight_mask: 0,
653                })
654            }
655            Some(b) if b.is_ascii_alphanumeric() || b == b'_' => {
656                let start = self.pos;
657                while self.pos < self.bytes.len() {
658                    let c = self.bytes[self.pos];
659                    if c.is_ascii_alphanumeric() || c == b'_' {
660                        self.pos += 1;
661                    } else {
662                        break;
663                    }
664                }
665                let w = core::str::from_utf8(&self.bytes[start..self.pos])
666                    .map_err(|_| EvalError::TypeMismatch {
667                        detail: "tsquery literal: non-UTF-8 lexeme".into(),
668                    })?
669                    .to_string();
670                self.skip_weight_suffix();
671                Ok(TsQueryAst::Term {
672                    word: w,
673                    weight_mask: 0,
674                })
675            }
676            Some(b) => Err(EvalError::TypeMismatch {
677                detail: alloc::format!(
678                    "tsquery literal: unexpected byte {:?} at offset {}",
679                    b as char,
680                    self.pos
681                ),
682            }),
683            None => Err(EvalError::TypeMismatch {
684                detail: "tsquery literal: expected term".into(),
685            }),
686        }
687    }
688    fn skip_weight_suffix(&mut self) {
689        if self.peek() != Some(b':') {
690            return;
691        }
692        self.pos += 1;
693        while let Some(b) = self.peek() {
694            if matches!(
695                b,
696                b'A' | b'B' | b'C' | b'D' | b'a' | b'b' | b'c' | b'd' | b'*'
697            ) || b.is_ascii_digit()
698            {
699                self.pos += 1;
700            } else {
701                break;
702            }
703        }
704    }
705}
706
707pub(super) fn tsvector_concat(
708    l: &[spg_storage::TsLexeme],
709    r: &[spg_storage::TsLexeme],
710) -> Value<'static> {
711    let shift = l
712        .iter()
713        .flat_map(|x| x.positions.iter().copied())
714        .max()
715        .unwrap_or(0);
716    let mut out: Vec<spg_storage::TsLexeme> = l.to_vec();
717    for lex in r {
718        let shifted: Vec<u16> = lex
719            .positions
720            .iter()
721            .map(|p| p.saturating_add(shift))
722            .collect();
723        if let Some(existing) = out.iter_mut().find(|x| x.word == lex.word) {
724            existing.positions.extend(shifted);
725            existing.positions.sort_unstable();
726            existing.weight = existing.weight.max(lex.weight);
727        } else {
728            out.push(spg_storage::TsLexeme {
729                word: lex.word.clone(),
730                positions: shifted,
731                weight: lex.weight,
732            });
733        }
734    }
735    out.sort_by(|a, b| a.word.cmp(&b.word));
736    Value::TsVector(out)
737}