1use 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
17pub(super) fn fts_ts_rank(args: &[Value<'_>]) -> Result<Value<'static>, EvalError> {
22 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 let r = crate::fts::apply_rank_norm(crate::fts::ts_rank(&weights, &v, &q), norm, &v);
37 Ok(Value::Real(r))
40 }
41 }
42}
43
44pub(super) fn fts_ts_rank_cd(args: &[Value<'_>]) -> Result<Value<'static>, EvalError> {
45 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}
65type RankArgs = (
68 crate::fts::RankWeights,
69 Option<Vec<spg_storage::TsLexeme>>,
70 Option<spg_storage::TsQueryAst>,
71 i64,
72);
73
74fn parse_rank_args(name: &str, args: &[Value<'_>]) -> Result<RankArgs, EvalError> {
80 let mut rest = args;
82 let mut weights = crate::fts::DEFAULT_RANK_WEIGHTS;
83 if matches!(
84 rest.first(),
85 Some(
86 Value::FloatArray(_)
87 | Value::RealArray(_)
88 | Value::NumericArray(_)
89 | Value::IntArray(_)
90 | Value::SmallIntArray(_)
91 )
92 ) {
93 weights = parse_weight_array(name, &rest[0])?;
94 rest = &rest[1..];
95 } else if args.len() >= 3
96 && let Some(Value::Text(s)) = rest.first()
97 && s.trim_start().starts_with('{')
98 {
99 let inner = s.trim().trim_start_matches('{').trim_end_matches('}');
102 let parsed: Result<Vec<f64>, _> =
103 inner.split(',').map(|x| x.trim().parse::<f64>()).collect();
104 let vals = parsed.map_err(|_| EvalError::TypeMismatch {
105 detail: format!("{name}(): invalid weight array literal {s:?}"),
106 })?;
107 weights = parse_weight_array(
108 name,
109 &Value::FloatArray(vals.into_iter().map(Some).collect()),
110 )?;
111 rest = &rest[1..];
112 }
113 let norm = match rest.last() {
115 Some(Value::Int(n)) => Some(i64::from(*n)),
116 Some(Value::BigInt(n)) => Some(*n),
117 _ => None,
118 };
119 if norm.is_some() {
120 rest = &rest[..rest.len() - 1];
121 }
122 let norm = norm.unwrap_or(0);
123 if norm & !0x3F != 0 {
124 return Err(EvalError::TypeMismatch {
125 detail: format!("{name}(): unknown normalization flag bits in {norm}"),
126 });
127 }
128 if rest.len() != 2 {
129 return Err(EvalError::TypeMismatch {
130 detail: format!(
131 "{name}() takes (vec, query) optionally wrapped by a weight array and a norm flag"
132 ),
133 });
134 }
135 let vec = match &rest[0] {
136 Value::Null => None,
137 Value::TsVector(v) => Some(v.clone()),
138 other => {
139 return Err(EvalError::TypeMismatch {
140 detail: format!(
141 "{name}() vector arg must be tsvector, got {}",
142 crate::conversions::pg_type_name_for_error_opt(other.data_type())
143 ),
144 });
145 }
146 };
147 let query = match &rest[1] {
148 Value::Null => None,
149 Value::TsQuery(q) => Some(q.clone()),
150 other => {
151 return Err(EvalError::TypeMismatch {
152 detail: format!(
153 "{name}() query arg must be tsquery, got {}",
154 crate::conversions::pg_type_name_for_error_opt(other.data_type())
155 ),
156 });
157 }
158 };
159 Ok((weights, vec, query, norm))
160}
161
162fn parse_weight_array(name: &str, v: &Value<'_>) -> Result<crate::fts::RankWeights, EvalError> {
164 let vals: Vec<f32> = match v {
165 Value::FloatArray(a) => a.iter().map(|o| o.unwrap_or(0.0) as f32).collect(),
166 Value::RealArray(a) => a.iter().map(|o| o.unwrap_or(0.0)).collect(),
170 Value::IntArray(a) => a.iter().map(|o| o.unwrap_or(0) as f32).collect(),
171 Value::SmallIntArray(a) => a.iter().map(|o| f32::from(o.unwrap_or(0))).collect(),
172 Value::NumericArray(a) => a
173 .iter()
174 .map(|o| o.map_or(0.0, |(m, s)| (m as f64 / 10f64.powi(i32::from(s))) as f32))
175 .collect(),
176 _ => {
177 return Err(EvalError::TypeMismatch {
178 detail: format!("{name}() weight argument must be a numeric array"),
179 });
180 }
181 };
182 if vals.len() != 4 {
183 return Err(EvalError::TypeMismatch {
184 detail: format!(
185 "{name}() weight array must have 4 elements [D, C, B, A], got {}",
186 vals.len()
187 ),
188 });
189 }
190 Ok([vals[0], vals[1], vals[2], vals[3]])
191}
192
193pub(super) fn ts_match(l: Value, r: Value) -> Result<Value<'static>, EvalError> {
198 let (vec, query) = match (l, r) {
199 (Value::Null, _) | (_, Value::Null) => return Ok(Value::Null),
200 (Value::TsVector(v), Value::TsQuery(q)) => (v, q),
201 (Value::TsQuery(q), Value::TsVector(v)) => (v, q),
202 (Value::TsVector(v), Value::Text(q)) => {
207 (v, crate::eval::decode_tsquery_external(q.as_ref())?)
208 }
209 (Value::Text(q), Value::TsVector(v)) => {
210 (v, crate::eval::decode_tsquery_external(q.as_ref())?)
211 }
212 (l, r) => {
213 return Err(EvalError::TypeMismatch {
214 detail: format!(
215 "@@ requires (tsvector, tsquery), got ({:?}, {:?})",
216 l.data_type(),
217 r.data_type()
218 ),
219 });
220 }
221 };
222 Ok(Value::Bool(crate::fts::ts_query_matches(&vec, &query)))
223}
224
225pub(super) fn fts_to_tsvector(
230 args: &[Value<'_>],
231 ctx: &EvalContext<'_>,
232) -> Result<Value<'static>, EvalError> {
233 let (config, text) = parse_fts_args("to_tsvector", args, ctx)?;
234 match text {
235 None => Ok(Value::Null),
236 Some(t) => Ok(Value::TsVector(crate::fts::to_tsvector(config, &t))),
237 }
238}
239
240pub(super) fn fts_setweight(args: &[Value<'_>]) -> Result<Value<'static>, EvalError> {
243 if args.len() != 2 && args.len() != 3 {
244 return Err(crate::eval::functions::wrong_arity("setweight", args));
245 }
246 let (vec_arg, weight_arg, only) = match args {
250 [v, w] => (v, w, None),
251 [v, w, l] => (v, w, Some(l)),
252 _ => {
253 return Err(EvalError::TypeMismatch {
254 detail: alloc::format!("setweight expects 2 or 3 arguments, got {}", args.len()),
255 });
256 }
257 };
258 if matches!(vec_arg, Value::Null) || matches!(weight_arg, Value::Null) {
259 return Ok(Value::Null);
260 }
261 let Value::TsVector(lexemes) = vec_arg else {
262 return Err(EvalError::TypeMismatch {
263 detail: alloc::format!(
264 "setweight expects a tsvector, got {}",
265 crate::conversions::pg_type_name_for_error_opt(vec_arg.data_type())
266 ),
267 });
268 };
269 let Value::Text(w) = weight_arg else {
270 return Err(EvalError::TypeMismatch {
271 detail: alloc::format!(
272 "setweight expects a weight letter, got {}",
273 crate::conversions::pg_type_name_for_error_opt(weight_arg.data_type())
274 ),
275 });
276 };
277 let weight = match w.to_ascii_uppercase().as_str() {
278 "A" => 3,
279 "B" => 2,
280 "C" => 1,
281 "D" => 0,
282 other => {
283 return Err(EvalError::TypeMismatch {
284 detail: alloc::format!("unrecognized weight: {other:?} (expected A, B, C or D)"),
285 });
286 }
287 };
288 let selected: Option<alloc::vec::Vec<String>> = match only {
291 None => None,
292 Some(Value::Null) => return Ok(Value::Null),
293 Some(v) => {
294 let t = crate::eval::value_to_text(v);
295 let inner = t.trim().trim_start_matches('{').trim_end_matches('}');
296 Some(
297 inner
298 .split(',')
299 .map(|x| x.trim().trim_matches('"').to_string())
300 .filter(|x| !x.is_empty())
301 .collect(),
302 )
303 }
304 };
305 let mut out = lexemes.clone();
306 for lex in &mut out {
307 let hit = selected
308 .as_ref()
309 .is_none_or(|names| names.iter().any(|n| *n == lex.word));
310 if hit {
311 lex.weight = weight;
312 }
313 }
314 Ok(Value::TsVector(out))
315}
316
317pub(super) fn fts_plainto_tsquery(
318 args: &[Value<'_>],
319 ctx: &EvalContext<'_>,
320) -> Result<Value<'static>, EvalError> {
321 let (config, text) = parse_fts_args("plainto_tsquery", args, ctx)?;
322 match text {
323 None => Ok(Value::Null),
324 Some(t) => Ok(Value::TsQuery(crate::fts::plainto_tsquery(config, &t))),
325 }
326}
327
328pub(super) fn fts_phraseto_tsquery(
329 args: &[Value<'_>],
330 ctx: &EvalContext<'_>,
331) -> Result<Value<'static>, EvalError> {
332 let (config, text) = parse_fts_args("phraseto_tsquery", args, ctx)?;
333 match text {
334 None => Ok(Value::Null),
335 Some(t) => Ok(Value::TsQuery(crate::fts::phraseto_tsquery(config, &t))),
336 }
337}
338
339pub(super) fn fts_websearch_to_tsquery(
340 args: &[Value<'_>],
341 ctx: &EvalContext<'_>,
342) -> Result<Value<'static>, EvalError> {
343 let (config, text) = parse_fts_args("websearch_to_tsquery", args, ctx)?;
344 match text {
345 None => Ok(Value::Null),
346 Some(t) => Ok(Value::TsQuery(crate::fts::websearch_to_tsquery(config, &t))),
347 }
348}
349
350pub(super) fn fts_to_tsquery(
351 args: &[Value<'_>],
352 ctx: &EvalContext<'_>,
353) -> Result<Value<'static>, EvalError> {
354 let (config, text) = parse_fts_args("to_tsquery", args, ctx)?;
355 match text {
356 None => Ok(Value::Null),
357 Some(t) => Ok(Value::TsQuery(crate::fts::to_tsquery(config, &t)?)),
358 }
359}
360
361fn parse_fts_args(
366 name: &str,
367 args: &[Value<'_>],
368 ctx: &EvalContext<'_>,
369) -> Result<(crate::fts::TsConfig, Option<String>), EvalError> {
370 if args.len() != 1 && args.len() != 2 {
371 return Err(EvalError::WrongArity {
372 name: alloc::string::String::from(name),
373 types: crate::eval::functions::arg_type_list(args),
374 });
375 }
376 let (config_arg, text_arg) = match args {
377 [t] => (None, t),
378 [c, t] => (Some(c), t),
379 _ => {
380 return Err(EvalError::TypeMismatch {
381 detail: format!("{name}() takes 1 or 2 args, got {}", args.len()),
382 });
383 }
384 };
385 let config = match config_arg {
386 None => match ctx.default_text_search_config {
387 Some(name_str) => crate::fts::TsConfig::from_name(name_str).ok_or_else(|| {
388 EvalError::TypeMismatch {
389 detail: format!(
390 "text search config not implemented: {name_str:?} (supported: simple, english)"
391 ),
392 }
393 })?,
394 None => crate::fts::TsConfig::English,
398 },
399 Some(Value::Null) => return Ok((crate::fts::TsConfig::Simple, None)),
400 Some(Value::Text(name_str)) => crate::fts::TsConfig::from_name(name_str).ok_or_else(|| {
401 EvalError::TypeMismatch {
402 detail: format!(
403 "text search config not implemented: {name_str:?} (supported: simple, english)"
404 ),
405 }
406 })?,
407 Some(other) => {
408 return Err(EvalError::TypeMismatch {
409 detail: format!(
410 "{name}() config arg must be text, got {}",
411 crate::conversions::pg_type_name_for_error_opt(other.data_type())
412 ),
413 });
414 }
415 };
416 let text = match text_arg {
417 Value::Null => None,
418 Value::Text(s) => Some(s.to_string()),
419 other => {
420 return Err(EvalError::TypeMismatch {
421 detail: format!(
422 "{name}() text arg must be text, got {}",
423 crate::conversions::pg_type_name_for_error_opt(other.data_type())
424 ),
425 });
426 }
427 };
428 Ok((config, text))
429}
430
431pub fn format_tsvector(lexs: &[TsLexeme]) -> String {
437 let mut out = String::with_capacity(lexs.len() * 12);
438 for (i, l) in lexs.iter().enumerate() {
439 if i > 0 {
440 out.push(' ');
441 }
442 out.push('\'');
443 for c in l.word.chars() {
444 if c == '\'' {
445 out.push('\'');
446 }
447 out.push(c);
448 }
449 out.push('\'');
450 if !l.positions.is_empty() {
451 for (pi, p) in l.positions.iter().enumerate() {
452 out.push(if pi == 0 { ':' } else { ',' });
453 out.push_str(&p.to_string());
454 }
455 match l.weight {
460 3 => out.push('A'),
461 2 => out.push('B'),
462 1 => out.push('C'),
463 _ => {}
464 }
465 }
466 }
467 out
468}
469
470pub fn format_tsquery(ast: &TsQueryAst) -> String {
473 fn go(ast: &TsQueryAst, parent_prec: u8, out: &mut String) {
474 let (own_prec, write_self): (u8, &dyn Fn(&mut String)) = match ast {
476 TsQueryAst::Or(_, _) => (1, &|_| {}),
477 TsQueryAst::And(_, _) | TsQueryAst::Phrase { .. } => (2, &|_| {}),
478 TsQueryAst::Not(_) => (3, &|_| {}),
479 TsQueryAst::Term { .. } => (4, &|_| {}),
480 };
481 let need_parens = own_prec < parent_prec;
482 if need_parens {
483 out.push_str("( ");
485 }
486 match ast {
487 TsQueryAst::Term { word, weight_mask } => {
488 out.push('\'');
489 for c in word.chars() {
490 if c == '\'' {
491 out.push('\'');
492 }
493 out.push(c);
494 }
495 out.push('\'');
496 if *weight_mask != 0 {
499 out.push(':');
500 if weight_mask & 0x10 != 0 {
501 out.push('*');
502 }
503 for (bit, ch) in [(3u8, 'A'), (2, 'B'), (1, 'C'), (0, 'D')] {
504 if weight_mask & (1 << bit) != 0 {
505 out.push(ch);
506 }
507 }
508 }
509 }
510 TsQueryAst::And(a, b) => {
511 go(a, own_prec, out);
512 out.push_str(" & ");
513 go(b, own_prec, out);
514 }
515 TsQueryAst::Or(a, b) => {
516 go(a, own_prec, out);
517 out.push_str(" | ");
518 go(b, own_prec, out);
519 }
520 TsQueryAst::Not(x) => {
521 out.push('!');
522 go(x, own_prec, out);
523 }
524 TsQueryAst::Phrase {
525 left,
526 right,
527 distance,
528 } => {
529 go(left, own_prec, out);
530 if *distance == 1 {
533 out.push_str(" <-> ");
534 } else {
535 out.push_str(&alloc::format!(" <{distance}> "));
536 }
537 go(right, own_prec, out);
538 }
539 }
540 write_self(out);
541 if need_parens {
542 out.push_str(" )");
543 }
544 }
545 let mut out = String::new();
546 go(ast, 0, &mut out);
547 out
548}
549
550pub fn decode_tsvector_external(s: &str) -> Result<Vec<TsLexeme>, EvalError> {
559 let mut out: Vec<TsLexeme> = Vec::new();
560 let mut i = 0;
561 let bytes = s.as_bytes();
562 while i < bytes.len() {
563 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
564 i += 1;
565 }
566 if i >= bytes.len() {
567 break;
568 }
569 let word = if bytes[i] == b'\'' {
572 i += 1;
573 let mut w = String::new();
574 loop {
575 if i >= bytes.len() {
576 return Err(EvalError::TypeMismatch {
577 detail: "tsvector literal: unterminated quoted lexeme".into(),
578 });
579 }
580 let b = bytes[i];
581 if b == b'\'' {
582 if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
583 w.push('\'');
584 i += 2;
585 } else {
586 i += 1;
587 break;
588 }
589 } else {
590 w.push(b as char);
591 i += 1;
592 }
593 }
594 w
595 } else {
596 let start = i;
598 while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b':' {
599 i += 1;
600 }
601 core::str::from_utf8(&bytes[start..i])
602 .map_err(|_| EvalError::TypeMismatch {
603 detail: "tsvector literal: non-UTF-8 lexeme".into(),
604 })?
605 .to_string()
606 };
607 if word.is_empty() {
608 return Err(EvalError::TypeMismatch {
609 detail: "tsvector literal: empty lexeme".into(),
610 });
611 }
612 let mut positions: Vec<u16> = Vec::new();
615 let mut weight: u8 = 0;
616 if i < bytes.len() && bytes[i] == b':' {
617 i += 1;
618 loop {
619 let start = i;
620 while i < bytes.len() && bytes[i].is_ascii_digit() {
621 i += 1;
622 }
623 if start == i {
624 return Err(EvalError::TypeMismatch {
625 detail: "tsvector literal: expected digit after ':'".into(),
626 });
627 }
628 let num: u16 = core::str::from_utf8(&bytes[start..i])
629 .expect("ascii digits")
630 .parse()
631 .map_err(|_| EvalError::TypeMismatch {
632 detail: alloc::format!(
633 "tsvector literal: position {} overflows u16",
634 core::str::from_utf8(&bytes[start..i]).unwrap_or("?")
635 ),
636 })?;
637 positions.push(num);
638 if i < bytes.len() {
639 let w = bytes[i];
640 if matches!(w, b'A' | b'B' | b'C' | b'D') {
641 weight = match w {
642 b'A' => 3,
643 b'B' => 2,
644 b'C' => 1,
645 _ => 0,
646 };
647 i += 1;
648 }
649 }
650 if i < bytes.len() && bytes[i] == b',' {
651 i += 1;
652 continue;
653 }
654 break;
655 }
656 }
657 positions.sort_unstable();
658 positions.dedup();
659 match out.binary_search_by(|l| l.word.as_str().cmp(word.as_str())) {
662 Ok(idx) => {
663 for p in positions {
664 if !out[idx].positions.contains(&p) {
665 out[idx].positions.push(p);
666 }
667 }
668 out[idx].positions.sort_unstable();
669 if weight != 0 {
670 out[idx].weight = weight;
671 }
672 }
673 Err(idx) => {
674 out.insert(
675 idx,
676 TsLexeme {
677 word,
678 positions,
679 weight,
680 },
681 );
682 }
683 }
684 }
685 Ok(out)
686}
687
688pub fn decode_tsquery_external(s: &str) -> Result<TsQueryAst, EvalError> {
694 let mut p = TsQueryParser {
695 bytes: s.as_bytes(),
696 pos: 0,
697 };
698 p.skip_ws();
699 if p.pos >= p.bytes.len() {
700 return Err(EvalError::TypeMismatch {
701 detail: "tsquery literal: empty".into(),
702 });
703 }
704 let ast = p.parse_or()?;
705 p.skip_ws();
706 if p.pos < p.bytes.len() {
707 return Err(EvalError::TypeMismatch {
708 detail: alloc::format!("tsquery literal: trailing garbage at offset {}", p.pos),
709 });
710 }
711 Ok(ast)
712}
713
714struct TsQueryParser<'a> {
715 bytes: &'a [u8],
716 pos: usize,
717}
718
719impl<'a> TsQueryParser<'a> {
720 fn skip_ws(&mut self) {
721 while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_whitespace() {
722 self.pos += 1;
723 }
724 }
725 fn peek(&self) -> Option<u8> {
726 self.bytes.get(self.pos).copied()
727 }
728 fn parse_or(&mut self) -> Result<TsQueryAst, EvalError> {
729 let mut lhs = self.parse_and()?;
730 loop {
731 self.skip_ws();
732 if self.peek() != Some(b'|') {
733 return Ok(lhs);
734 }
735 self.pos += 1;
736 let rhs = self.parse_and()?;
737 lhs = TsQueryAst::Or(Box::new(lhs), Box::new(rhs));
738 }
739 }
740 fn parse_and(&mut self) -> Result<TsQueryAst, EvalError> {
741 let mut lhs = self.parse_unary()?;
742 loop {
743 self.skip_ws();
744 match self.peek() {
745 Some(b'&') => {
746 self.pos += 1;
747 let rhs = self.parse_unary()?;
748 lhs = TsQueryAst::And(Box::new(lhs), Box::new(rhs));
749 }
750 Some(b'<') => {
751 self.pos += 1;
754 let n: u16 = if self.peek() == Some(b'-')
755 && self.bytes.get(self.pos + 1) == Some(&b'>')
756 {
757 self.pos += 2; 1
759 } else {
760 let start = self.pos;
761 while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
762 self.pos += 1;
763 }
764 if start == self.pos || self.peek() != Some(b'>') {
765 return Err(EvalError::TypeMismatch {
766 detail: "tsquery literal: malformed <N> / <-> phrase operator"
767 .into(),
768 });
769 }
770 let val = core::str::from_utf8(&self.bytes[start..self.pos])
771 .expect("ascii digits")
772 .parse()
773 .map_err(|_| EvalError::TypeMismatch {
774 detail: "tsquery literal: phrase distance overflows u16".into(),
775 })?;
776 self.pos += 1; val
778 };
779 let rhs = self.parse_unary()?;
780 lhs = TsQueryAst::Phrase {
781 left: Box::new(lhs),
782 right: Box::new(rhs),
783 distance: n,
784 };
785 }
786 _ => return Ok(lhs),
787 }
788 }
789 }
790 fn parse_unary(&mut self) -> Result<TsQueryAst, EvalError> {
791 self.skip_ws();
792 if self.peek() == Some(b'!') {
793 self.pos += 1;
794 let inner = self.parse_unary()?;
795 return Ok(TsQueryAst::Not(Box::new(inner)));
796 }
797 self.parse_atom()
798 }
799 fn parse_atom(&mut self) -> Result<TsQueryAst, EvalError> {
800 self.skip_ws();
801 match self.peek() {
802 Some(b'(') => {
803 self.pos += 1;
804 let inner = self.parse_or()?;
805 self.skip_ws();
806 if self.peek() != Some(b')') {
807 return Err(EvalError::TypeMismatch {
808 detail: "tsquery literal: missing ')'".into(),
809 });
810 }
811 self.pos += 1;
812 Ok(inner)
813 }
814 Some(b'\'') => {
815 self.pos += 1;
816 let mut w = String::new();
817 loop {
818 match self.peek() {
819 None => {
820 return Err(EvalError::TypeMismatch {
821 detail: "tsquery literal: unterminated quoted lexeme".into(),
822 });
823 }
824 Some(b'\'') => {
825 if self.bytes.get(self.pos + 1) == Some(&b'\'') {
826 w.push('\'');
827 self.pos += 2;
828 } else {
829 self.pos += 1;
830 break;
831 }
832 }
833 Some(b) => {
834 w.push(b as char);
835 self.pos += 1;
836 }
837 }
838 }
839 let weight_mask = self.skip_weight_suffix();
842 Ok(TsQueryAst::Term {
843 word: w,
844 weight_mask,
845 })
846 }
847 Some(b) if b.is_ascii_alphanumeric() || b == b'_' => {
848 let start = self.pos;
849 while self.pos < self.bytes.len() {
850 let c = self.bytes[self.pos];
851 if c.is_ascii_alphanumeric() || c == b'_' {
852 self.pos += 1;
853 } else {
854 break;
855 }
856 }
857 let w = core::str::from_utf8(&self.bytes[start..self.pos])
858 .map_err(|_| EvalError::TypeMismatch {
859 detail: "tsquery literal: non-UTF-8 lexeme".into(),
860 })?
861 .to_string();
862 let weight_mask = self.skip_weight_suffix();
863 Ok(TsQueryAst::Term {
864 word: w,
865 weight_mask,
866 })
867 }
868 Some(b) => Err(EvalError::TypeMismatch {
869 detail: alloc::format!(
870 "tsquery literal: unexpected byte {:?} at offset {}",
871 b as char,
872 self.pos
873 ),
874 }),
875 None => Err(EvalError::TypeMismatch {
876 detail: "tsquery literal: expected term".into(),
877 }),
878 }
879 }
880 fn skip_weight_suffix(&mut self) -> u8 {
885 if self.peek() != Some(b':') {
886 return 0;
887 }
888 self.pos += 1;
889 let mut mask: u8 = 0;
890 while let Some(b) = self.peek() {
891 match b {
892 b'A' | b'a' => mask |= 1 << 3,
893 b'B' | b'b' => mask |= 1 << 2,
894 b'C' | b'c' => mask |= 1 << 1,
895 b'D' | b'd' => mask |= 1,
896 b'*' => mask |= 0x10,
897 _ if b.is_ascii_digit() => {}
898 _ => break,
899 }
900 self.pos += 1;
901 }
902 mask
903 }
904}
905
906pub(super) fn tsvector_concat(
907 l: &[spg_storage::TsLexeme],
908 r: &[spg_storage::TsLexeme],
909) -> Value<'static> {
910 let shift = l
911 .iter()
912 .flat_map(|x| x.positions.iter().copied())
913 .max()
914 .unwrap_or(0);
915 let mut out: Vec<spg_storage::TsLexeme> = l.to_vec();
916 for lex in r {
917 let shifted: Vec<u16> = lex
918 .positions
919 .iter()
920 .map(|p| p.saturating_add(shift))
921 .collect();
922 if let Some(existing) = out.iter_mut().find(|x| x.word == lex.word) {
923 existing.positions.extend(shifted);
924 existing.positions.sort_unstable();
925 existing.weight = existing.weight.max(lex.weight);
926 } else {
927 out.push(spg_storage::TsLexeme {
928 word: lex.word.clone(),
929 positions: shifted,
930 weight: lex.weight,
931 });
932 }
933 }
934 out.sort_by(|a, b| a.word.cmp(&b.word));
935 Value::TsVector(out)
936}
937
938pub(super) fn fts_ts_headline(
947 args: &[Value<'_>],
948 ctx: &EvalContext<'_>,
949) -> Result<Value<'static>, EvalError> {
950 if args.len() < 2 || args.len() > 4 {
951 return Err(crate::eval::functions::wrong_arity("ts_headline", args));
952 }
953 let is_queryish = |v: &Value<'_>| matches!(v, Value::TsQuery(_));
955 let (config_arg, doc_arg, query_arg, opts_arg) = match args {
956 [d, q] => (None, d, q, None),
957 [d, q, o] if is_queryish(q) => (None, d, q, Some(o)),
958 [c, d, q] => (Some(c), d, q, None),
959 [c, d, q, o] => (Some(c), d, q, Some(o)),
960 _ => {
961 return Err(EvalError::TypeMismatch {
962 detail: format!("ts_headline() takes 2 to 4 args, got {}", args.len()),
963 });
964 }
965 };
966 if matches!(doc_arg, Value::Null) || matches!(query_arg, Value::Null) {
967 return Ok(Value::Null);
968 }
969 let config = match config_arg {
970 None => match ctx.default_text_search_config {
971 Some(name_str) => crate::fts::TsConfig::from_name(name_str).ok_or_else(|| {
972 EvalError::TypeMismatch {
973 detail: format!(
974 "text search config not implemented: {name_str:?} (supported: simple, english)"
975 ),
976 }
977 })?,
978 None => crate::fts::TsConfig::English,
979 },
980 Some(Value::Text(name_str)) => {
981 crate::fts::TsConfig::from_name(name_str).ok_or_else(|| EvalError::TypeMismatch {
982 detail: format!(
983 "text search config not implemented: {name_str:?} (supported: simple, english)"
984 ),
985 })?
986 }
987 Some(other) => {
988 return Err(EvalError::TypeMismatch {
989 detail: format!(
990 "ts_headline() config must be text, got {}",
991 crate::conversions::pg_type_name_for_error_opt(other.data_type())
992 ),
993 });
994 }
995 };
996 let doc = match doc_arg {
997 Value::Text(s) => s.as_ref(),
998 other => {
999 return Err(EvalError::TypeMismatch {
1000 detail: format!(
1001 "ts_headline() document must be text, got {}",
1002 crate::conversions::pg_type_name_for_error_opt(other.data_type())
1003 ),
1004 });
1005 }
1006 };
1007 let query = match query_arg {
1008 Value::TsQuery(q) => q.clone(),
1009 Value::Text(s) => crate::fts::to_tsquery(config, s)?,
1012 other => {
1013 return Err(EvalError::TypeMismatch {
1014 detail: format!(
1015 "ts_headline() query must be tsquery, got {}",
1016 crate::conversions::pg_type_name_for_error_opt(other.data_type())
1017 ),
1018 });
1019 }
1020 };
1021 let mut start_sel = String::from("<b>");
1025 let mut stop_sel = String::from("</b>");
1026 let mut max_words: usize = 35;
1027 let mut min_words: usize = 15;
1028 let mut max_fragments: usize = 0;
1029 let mut frag_delim = String::from(" ... ");
1030 let mut highlight_all = false;
1031 let mut short_word: usize = 3;
1032 if let Some(opts_v) = opts_arg {
1033 let opts = match opts_v {
1034 Value::Null => "",
1035 Value::Text(s) => s.as_ref(),
1036 other => {
1037 return Err(EvalError::TypeMismatch {
1038 detail: format!(
1039 "ts_headline() options must be text, got {}",
1040 crate::conversions::pg_type_name_for_error_opt(other.data_type())
1041 ),
1042 });
1043 }
1044 };
1045 let parse_int = |v: &str| -> Result<i64, EvalError> {
1050 v.parse::<i64>().map_err(|_| EvalError::TypeMismatch {
1051 detail: alloc::format!("invalid input syntax for type integer: {v:?}"),
1052 })
1053 };
1054 let mut short_word_i: i64 = short_word as i64;
1055 let mut max_fragments_i: i64 = 0;
1056 let mut min_words_i: i64 = min_words as i64;
1057 let mut max_words_i: i64 = max_words as i64;
1058 for pair in opts.split(',') {
1059 if pair.trim().is_empty() {
1060 continue;
1061 }
1062 let Some((k, v)) = pair.split_once('=') else {
1063 return Err(EvalError::TypeMismatch {
1064 detail: alloc::format!("invalid parameter list format: {:?}", pair.trim()),
1065 });
1066 };
1067 let v = v.trim().trim_matches('"');
1068 if v.is_empty() {
1069 return Err(EvalError::TypeMismatch {
1070 detail: alloc::format!("invalid parameter list format: {:?}", pair.trim()),
1071 });
1072 }
1073 match k.trim().to_ascii_lowercase().as_str() {
1074 "startsel" => start_sel = v.to_string(),
1075 "stopsel" => stop_sel = v.to_string(),
1076 "maxwords" => max_words_i = parse_int(v)?,
1077 "minwords" => min_words_i = parse_int(v)?,
1078 "maxfragments" => max_fragments_i = parse_int(v)?,
1079 "shortword" => short_word_i = parse_int(v)?,
1080 "fragmentdelimiter" => frag_delim = v.to_string(),
1081 "highlightall" => {
1084 highlight_all = matches!(
1085 v.to_ascii_lowercase().as_str(),
1086 "1" | "on" | "t" | "true" | "y" | "yes"
1087 );
1088 }
1089 _ => {
1090 return Err(EvalError::TypeMismatch {
1091 detail: alloc::format!("unrecognized headline parameter: {:?}", k.trim()),
1092 });
1093 }
1094 }
1095 }
1096 if min_words_i >= max_words_i {
1099 return Err(EvalError::TypeMismatch {
1100 detail: "MinWords must be less than MaxWords".into(),
1101 });
1102 }
1103 if min_words_i <= 0 {
1104 return Err(EvalError::TypeMismatch {
1105 detail: "MinWords must be positive".into(),
1106 });
1107 }
1108 if short_word_i < 0 {
1109 return Err(EvalError::TypeMismatch {
1110 detail: "ShortWord must be >= 0".into(),
1111 });
1112 }
1113 if max_fragments_i < 0 {
1114 return Err(EvalError::TypeMismatch {
1115 detail: "MaxFragments must be >= 0".into(),
1116 });
1117 }
1118 max_words = max_words_i as usize;
1119 min_words = min_words_i as usize;
1120 short_word = short_word_i as usize;
1121 max_fragments = max_fragments_i as usize;
1122 }
1123 fn collect_positive(ast: &spg_storage::TsQueryAst, out: &mut Vec<String>) {
1125 match ast {
1126 spg_storage::TsQueryAst::Term { word, .. } => {
1127 if !word.is_empty() {
1128 out.push(word.clone());
1129 }
1130 }
1131 spg_storage::TsQueryAst::And(l, r) | spg_storage::TsQueryAst::Or(l, r) => {
1132 collect_positive(l, out);
1133 collect_positive(r, out);
1134 }
1135 spg_storage::TsQueryAst::Not(_) => {}
1136 spg_storage::TsQueryAst::Phrase { left, right, .. } => {
1137 collect_positive(left, out);
1138 collect_positive(right, out);
1139 }
1140 }
1141 }
1142 let mut terms: Vec<String> = Vec::new();
1143 collect_positive(&query, &mut terms);
1144 struct HlToken {
1149 word: String,
1150 lex: String,
1151 sep_after: String,
1152 is_match: bool,
1153 }
1154 let mut tokens: Vec<HlToken> = Vec::new();
1155 let mut leading_sep = String::new();
1156 let mut word = String::new();
1157 let mut push_word = |word: &mut String, tokens: &mut Vec<HlToken>| {
1158 if word.is_empty() {
1159 return;
1160 }
1161 let lowered: String = word.chars().flat_map(|c| c.to_lowercase()).collect();
1162 let lex = match config {
1163 crate::fts::TsConfig::Simple => lowered,
1164 crate::fts::TsConfig::English => crate::fts::porter_stem(&lowered),
1165 crate::fts::TsConfig::Spanish => crate::fts_es::stem_es(&lowered),
1166 crate::fts::TsConfig::French => crate::fts_fr::stem_fr(&lowered),
1167 crate::fts::TsConfig::German => crate::fts_de::stem_de(&lowered),
1168 };
1169 let is_match = terms.iter().any(|t| *t == lex);
1170 tokens.push(HlToken {
1171 word: core::mem::take(word),
1172 lex,
1173 sep_after: String::new(),
1174 is_match,
1175 });
1176 };
1177 for c in doc.chars() {
1178 if c.is_alphanumeric() || c == '_' {
1179 word.push(c);
1180 } else {
1181 push_word(&mut word, &mut tokens);
1182 match tokens.last_mut() {
1183 Some(t) => t.sep_after.push(c),
1184 None => leading_sep.push(c),
1185 }
1186 }
1187 }
1188 push_word(&mut word, &mut tokens);
1189 let render = |lo: usize, hi: usize| -> String {
1193 let mut out = String::new();
1194 for (i, t) in tokens[lo..hi].iter().enumerate() {
1195 if t.is_match {
1196 out.push_str(&start_sel);
1197 out.push_str(&t.word);
1198 out.push_str(&stop_sel);
1199 } else {
1200 out.push_str(&t.word);
1201 }
1202 if lo + i + 1 < hi {
1203 out.push_str(&t.sep_after);
1204 }
1205 }
1206 out
1207 };
1208 let n = tokens.len();
1209 let match_pos: Vec<usize> = tokens
1210 .iter()
1211 .enumerate()
1212 .filter_map(|(i, t)| t.is_match.then_some(i))
1213 .collect();
1214 if highlight_all || n <= min_words.max(1) {
1217 let mut out = leading_sep;
1218 out.push_str(&render(0, n));
1219 if let Some(t) = tokens.last() {
1220 out.push_str(&t.sep_after);
1221 }
1222 return Ok(Value::text(out));
1223 }
1224 if match_pos.is_empty() {
1228 return Ok(Value::text(render(0, min_words.max(1).min(n))));
1229 }
1230 if max_fragments > 0 {
1231 let interesting: Vec<bool> = tokens.iter().map(|t| t.is_match).collect();
1254 let is_bad_endpoint = |i: usize| -> bool {
1255 if interesting[i] {
1256 return false;
1257 }
1258 let w = &tokens[i].word;
1259 w.chars().count() <= short_word || w.chars().all(|c| c.is_ascii_digit())
1260 };
1261 fn and_groups(ast: &spg_storage::TsQueryAst, out: &mut Vec<Vec<String>>) {
1264 match ast {
1265 spg_storage::TsQueryAst::And(l, r) => {
1266 and_groups(l, out);
1267 and_groups(r, out);
1268 }
1269 spg_storage::TsQueryAst::Not(_) => {}
1270 other => {
1271 let mut g = Vec::new();
1272 fn collect(ast: &spg_storage::TsQueryAst, out: &mut Vec<String>) {
1274 match ast {
1275 spg_storage::TsQueryAst::Term { word, .. } => {
1276 if !word.is_empty() {
1277 out.push(word.clone());
1278 }
1279 }
1280 spg_storage::TsQueryAst::And(l, r)
1281 | spg_storage::TsQueryAst::Or(l, r) => {
1282 collect(l, out);
1283 collect(r, out);
1284 }
1285 spg_storage::TsQueryAst::Not(_) => {}
1286 spg_storage::TsQueryAst::Phrase { left, right, .. } => {
1287 collect(left, out);
1288 collect(right, out);
1289 }
1290 }
1291 }
1292 collect(other, &mut g);
1293 if !g.is_empty() {
1294 out.push(g);
1295 }
1296 }
1297 }
1298 }
1299 let mut groups: Vec<Vec<String>> = Vec::new();
1300 and_groups(&query, &mut groups);
1301 let group_pos: Vec<Vec<usize>> = groups
1302 .iter()
1303 .map(|g| {
1304 tokens
1305 .iter()
1306 .enumerate()
1307 .filter(|(_, t)| g.iter().any(|term| *term == t.lex))
1308 .map(|(i, _)| i)
1309 .collect()
1310 })
1311 .collect();
1312 struct Cand {
1314 st: usize,
1315 en: usize,
1316 curlen: usize,
1317 poslen: usize,
1318 chosen: bool,
1319 excluded: bool,
1320 }
1321 let mut cands: Vec<Cand> = Vec::new();
1322 if !group_pos.is_empty() && group_pos.iter().all(|ps| !ps.is_empty()) {
1323 let mut nextpos = 0usize;
1324 loop {
1325 let mut pose = 0usize;
1328 let mut dead = false;
1329 for ps in &group_pos {
1330 match ps.iter().find(|&&p| p >= nextpos) {
1331 Some(&p) => pose = pose.max(p),
1332 None => {
1333 dead = true;
1334 break;
1335 }
1336 }
1337 }
1338 if dead {
1339 break;
1340 }
1341 let mut posb = usize::MAX;
1342 for ps in &group_pos {
1343 if let Some(&p) = ps.iter().rev().find(|&&p| p <= pose) {
1344 posb = posb.min(p);
1345 }
1346 }
1347 let posb = posb.max(nextpos);
1348 let (mut st, en_cover) = (posb, pose);
1351 while st <= en_cover {
1352 let mut i = st;
1354 while i < en_cover && !interesting[i] {
1355 i += 1;
1356 }
1357 st = i;
1358 let mut curlen = 0usize;
1359 let mut poslen = 0usize;
1360 i = st;
1361 while i <= en_cover && curlen < max_words.max(1) {
1362 curlen += 1;
1363 if interesting[i] {
1364 poslen += 1;
1365 }
1366 i += 1;
1367 }
1368 let mut en = i - 1;
1370 if en < en_cover {
1371 while en > st && !interesting[en] {
1372 curlen -= 1;
1373 en -= 1;
1374 }
1375 }
1376 cands.push(Cand {
1377 st,
1378 en,
1379 curlen,
1380 poslen,
1381 chosen: false,
1382 excluded: false,
1383 });
1384 st = en + 1;
1385 }
1386 nextpos = posb + 1;
1387 }
1388 }
1389 let mut in_frag: Vec<bool> = alloc::vec![false; n];
1391 let mut picked = 0usize;
1392 for _ in 0..max_fragments {
1393 let mut best: Option<usize> = None;
1394 for (i, c) in cands.iter().enumerate() {
1395 if c.chosen || c.excluded {
1396 continue;
1397 }
1398 let better = match best {
1399 None => true,
1400 Some(b) => {
1401 c.poslen > cands[b].poslen
1402 || (c.poslen == cands[b].poslen && c.curlen < cands[b].curlen)
1403 }
1404 };
1405 if better {
1406 best = Some(i);
1407 }
1408 }
1409 let Some(bi) = best else { break };
1410 let (mut st, mut en, mut curlen) = (cands[bi].st, cands[bi].en, cands[bi].curlen);
1411 if curlen < max_words {
1412 let maxstretch = (max_words - curlen) / 2;
1415 let mut stretch = 0usize;
1416 let mut posmarker = st;
1417 let mut i = st;
1418 while i > 0 && stretch < maxstretch && !in_frag[i - 1] {
1419 i -= 1;
1420 curlen += 1;
1421 stretch += 1;
1422 posmarker = i;
1423 }
1424 let mut i = posmarker;
1426 while i < st && is_bad_endpoint(i) {
1427 curlen -= 1;
1428 i += 1;
1429 }
1430 st = i;
1431 let mut posmarker = en;
1433 let mut i = en + 1;
1434 while i < n && curlen < max_words && !in_frag[i] {
1435 curlen += 1;
1436 posmarker = i;
1437 i += 1;
1438 }
1439 let mut i = posmarker;
1441 while i > en && is_bad_endpoint(i) {
1442 curlen -= 1;
1443 i -= 1;
1444 }
1445 en = i;
1446 }
1447 cands[bi].st = st;
1448 cands[bi].en = en;
1449 cands[bi].curlen = curlen;
1450 cands[bi].chosen = true;
1451 for k in st..=en {
1452 in_frag[k] = true;
1453 }
1454 picked += 1;
1455 for (i, c) in cands.iter_mut().enumerate() {
1456 if i != bi
1457 && ((c.st >= st && c.st <= en)
1458 || (c.en >= st && c.en <= en)
1459 || (c.st < st && c.en > en))
1460 {
1461 c.excluded = true;
1462 }
1463 }
1464 }
1465 if picked == 0 {
1466 let hi = min_words.max(1).min(n);
1467 return Ok(Value::text(render(0, hi)));
1468 }
1469 let mut chosen: Vec<(usize, usize)> = cands
1470 .iter()
1471 .filter(|c| c.chosen)
1472 .map(|c| (c.st, c.en))
1473 .collect();
1474 chosen.sort_unstable();
1475 let parts: Vec<String> = chosen.iter().map(|&(st, en)| render(st, en + 1)).collect();
1476 return Ok(Value::text(parts.join(&frag_delim)));
1477 }
1478 let first = match_pos[0];
1483 let last = *match_pos.last().expect("non-empty");
1484 let mut lo = first;
1485 let mut hi = (last + 1).min(lo + max_words.max(1)).min(n);
1486 while hi - lo < min_words.max(1) && hi < n {
1487 hi += 1;
1488 }
1489 while hi - lo < min_words.max(1) && lo > 0 {
1490 lo -= 1;
1491 }
1492 Ok(Value::text(render(lo, hi)))
1493}
1494
1495pub(super) fn fts_ts_rewrite(
1503 args: &[Value<'_>],
1504 ctx: &EvalContext<'_>,
1505) -> Result<Value<'static>, EvalError> {
1506 if args.len() != 3 {
1507 return Err(EvalError::WrongArity {
1508 name: alloc::string::String::from("ts_rewrite"),
1509 types: crate::eval::functions::arg_type_list(args),
1510 });
1511 }
1512 if args.iter().any(|a| matches!(a, Value::Null)) {
1513 return Ok(Value::Null);
1514 }
1515 let config = match ctx.default_text_search_config {
1516 Some(name_str) => {
1517 crate::fts::TsConfig::from_name(name_str).unwrap_or(crate::fts::TsConfig::English)
1518 }
1519 None => crate::fts::TsConfig::English,
1520 };
1521 let as_query = |v: &Value<'_>, which: &str| -> Result<spg_storage::TsQueryAst, EvalError> {
1522 match v {
1523 Value::TsQuery(q) => Ok(q.clone()),
1524 Value::Text(s) => crate::fts::to_tsquery(config, s),
1527 other => Err(EvalError::TypeMismatch {
1528 detail: format!(
1529 "ts_rewrite() {which} must be tsquery, got {}",
1530 crate::conversions::pg_type_name_for_error_opt(other.data_type())
1531 ),
1532 }),
1533 }
1534 };
1535 let query = as_query(&args[0], "query")?;
1536 let target = as_query(&args[1], "target")?;
1537 let substitute = as_query(&args[2], "substitute")?;
1538 fn rewrite(
1539 node: &spg_storage::TsQueryAst,
1540 target: &spg_storage::TsQueryAst,
1541 substitute: &spg_storage::TsQueryAst,
1542 ) -> spg_storage::TsQueryAst {
1543 if node == target {
1544 return substitute.clone();
1545 }
1546 use spg_storage::TsQueryAst as A;
1547 match node {
1548 A::Term { .. } => node.clone(),
1549 A::And(l, r) => A::And(
1550 Box::new(rewrite(l, target, substitute)),
1551 Box::new(rewrite(r, target, substitute)),
1552 ),
1553 A::Or(l, r) => A::Or(
1554 Box::new(rewrite(l, target, substitute)),
1555 Box::new(rewrite(r, target, substitute)),
1556 ),
1557 A::Not(x) => A::Not(Box::new(rewrite(x, target, substitute))),
1558 A::Phrase {
1559 left,
1560 right,
1561 distance,
1562 } => A::Phrase {
1563 left: Box::new(rewrite(left, target, substitute)),
1564 right: Box::new(rewrite(right, target, substitute)),
1565 distance: *distance,
1566 },
1567 }
1568 }
1569 Ok(Value::TsQuery(rewrite(&query, &target, &substitute)))
1570}
1571
1572pub(super) fn fts_tsquery_bool(
1578 args: &[Value<'_>],
1579 ctx: &EvalContext<'_>,
1580 op: &str,
1581) -> Result<Value<'static>, EvalError> {
1582 let arity = if op == "not" { 1 } else { 2 };
1583 if args.len() != arity {
1584 return Err(EvalError::WrongArity {
1585 name: alloc::format!("tsquery_{op}"),
1586 types: crate::eval::functions::arg_type_list(args),
1587 });
1588 }
1589 if args.iter().any(|a| matches!(a, Value::Null)) {
1590 return Ok(Value::Null);
1591 }
1592 let config = match ctx.default_text_search_config {
1593 Some(name_str) => {
1594 crate::fts::TsConfig::from_name(name_str).unwrap_or(crate::fts::TsConfig::English)
1595 }
1596 None => crate::fts::TsConfig::English,
1597 };
1598 let as_query = |v: &Value<'_>| -> Result<spg_storage::TsQueryAst, EvalError> {
1599 match v {
1600 Value::TsQuery(q) => Ok(q.clone()),
1601 Value::Text(s) => crate::fts::to_tsquery(config, s),
1602 other => Err(EvalError::TypeMismatch {
1603 detail: format!(
1604 "tsquery_{op}() arguments must be tsquery, got {}",
1605 crate::conversions::pg_type_name_for_error_opt(other.data_type())
1606 ),
1607 }),
1608 }
1609 };
1610 use spg_storage::TsQueryAst as A;
1611 let out = match op {
1612 "and" => A::And(Box::new(as_query(&args[0])?), Box::new(as_query(&args[1])?)),
1613 "or" => A::Or(Box::new(as_query(&args[0])?), Box::new(as_query(&args[1])?)),
1614 _ => A::Not(Box::new(as_query(&args[0])?)),
1615 };
1616 Ok(Value::TsQuery(out))
1617}