1use std::collections::{BTreeMap, BTreeSet};
2
3const MAX_DOCUMENT_BYTES: usize = 1024 * 1024;
4const MAX_QUERY_BYTES: usize = 4096;
5const MAX_TOKENS: usize = 65_536;
6const MAX_QUERY_TERMS: usize = 1024;
7const MAX_QUERY_DEPTH: usize = 64;
8
9pub const INDEX_FORMAT_VERSION: &str = "1";
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Token {
13 pub text: String,
14 pub position: usize,
15 pub start: usize,
16 pub end: usize,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Query {
21 Term { text: String, prefix: bool },
22 Not(Box<Query>),
23 And(Box<Query>, Box<Query>),
24 Or(Box<Query>, Box<Query>),
25 Phrase(Box<Query>, Box<Query>),
26}
27
28pub fn tokenize(config: &str, input: &str) -> Result<Vec<Token>, String> {
29 validate_config(config)?;
30 if input.len() > MAX_DOCUMENT_BYTES {
31 return Err("document exceeds 1048576 bytes".into());
32 }
33 let mut output = Vec::new();
34 let mut start = None;
35 for (offset, ch) in input.char_indices() {
36 if ch.is_alphanumeric() || ch == '_' {
37 start.get_or_insert(offset);
38 } else if let Some(token_start) = start.take() {
39 push_token(config, input, token_start, offset, &mut output)?;
40 }
41 }
42 if let Some(token_start) = start {
43 push_token(config, input, token_start, input.len(), &mut output)?;
44 }
45 Ok(output)
46}
47
48fn push_token(
49 config: &str,
50 input: &str,
51 start: usize,
52 end: usize,
53 output: &mut Vec<Token>,
54) -> Result<(), String> {
55 if output.len() >= MAX_TOKENS {
56 return Err("document exceeds 65536 tokens".into());
57 }
58 output.push(Token {
59 text: normalize(config, &input[start..end]),
60 position: output.len() + 1,
61 start,
62 end,
63 });
64 Ok(())
65}
66
67fn validate_config(config: &str) -> Result<(), String> {
68 if matches!(config.to_ascii_lowercase().as_str(), "simple" | "english") {
69 Ok(())
70 } else {
71 Err(format!("unsupported text search configuration '{config}'"))
72 }
73}
74
75fn normalize(config: &str, token: &str) -> String {
76 let mut token = token.to_lowercase();
77 if config.eq_ignore_ascii_case("english") && token.is_ascii() {
78 for suffix in ["ing", "ed", "es", "s"] {
79 if token.len() > suffix.len() + 2 && token.ends_with(suffix) {
80 token.truncate(token.len() - suffix.len());
81 break;
82 }
83 }
84 }
85 token
86}
87
88pub fn to_tsvector(config: &str, document: &str) -> Result<String, String> {
89 let mut terms = BTreeMap::<String, Vec<usize>>::new();
90 for token in tokenize(config, document)? {
91 terms.entry(token.text).or_default().push(token.position);
92 }
93 Ok(terms
94 .into_iter()
95 .map(|(term, positions)| {
96 format!(
97 "'{}':{}",
98 term.replace('\'', "''"),
99 positions
100 .into_iter()
101 .map(|position| position.to_string())
102 .collect::<Vec<_>>()
103 .join(",")
104 )
105 })
106 .collect::<Vec<_>>()
107 .join(" "))
108}
109
110pub fn parse_tsquery(config: &str, input: &str) -> Result<Query, String> {
111 validate_config(config)?;
112 validate_query_input(input)?;
113 let tokens = lex_query(config, input)?;
114 if tokens.is_empty() {
115 return Err("TSQUERY is empty".into());
116 }
117 if tokens
118 .iter()
119 .filter(|token| matches!(token, QueryToken::Term(_, _)))
120 .count()
121 > MAX_QUERY_TERMS
122 {
123 return Err("TSQUERY exceeds 1024 terms".into());
124 }
125 let mut parser = QueryParser { tokens, index: 0 };
126 let query = parser.parse_or(0)?;
127 if parser.index != parser.tokens.len() {
128 return Err("TSQUERY contains an unexpected token".into());
129 }
130 Ok(query)
131}
132
133pub fn plainto_tsquery(config: &str, input: &str) -> Result<Query, String> {
134 validate_query_input(input)?;
135 let tokens = tokenize(config, input)?;
136 if tokens.len() > MAX_QUERY_TERMS {
137 return Err("TSQUERY exceeds 1024 terms".into());
138 }
139 terms_to_and(tokens.into_iter().map(|token| token.text))
140}
141
142pub fn websearch_to_tsquery(config: &str, input: &str) -> Result<Query, String> {
143 validate_config(config)?;
144 validate_query_input(input)?;
145 let mut groups = Vec::<Vec<Query>>::new();
146 let mut current = Vec::new();
147 let mut chars = input.char_indices().peekable();
148 while let Some((offset, ch)) = chars.next() {
149 if ch.is_whitespace() {
150 continue;
151 }
152 if matches!(ch, 'O' | 'o')
153 && input[offset..]
154 .get(..2)
155 .is_some_and(|value| value.eq_ignore_ascii_case("OR"))
156 && input[offset + 2..]
157 .chars()
158 .next()
159 .is_none_or(char::is_whitespace)
160 {
161 chars.next();
162 if current.is_empty() {
163 return Err("TSQUERY has an empty OR branch".into());
164 }
165 groups.push(std::mem::take(&mut current));
166 continue;
167 }
168 let negated = ch == '-';
169 let first = if negated {
170 chars
171 .next()
172 .ok_or_else(|| "TSQUERY has a dangling '-'".to_string())?
173 } else {
174 (offset, ch)
175 };
176 let query = if first.1 == '"' {
177 let start = first.0 + first.1.len_utf8();
178 let mut end = None;
179 for (position, next) in chars.by_ref() {
180 if next == '"' {
181 end = Some(position);
182 break;
183 }
184 }
185 let end = end.ok_or_else(|| "TSQUERY has an unterminated quote".to_string())?;
186 let terms = tokenize(config, &input[start..end])?;
187 terms_to_phrase(terms.into_iter().map(|token| token.text))?
188 } else {
189 let start = first.0;
190 let mut end = input.len();
191 while let Some(&(position, next)) = chars.peek() {
192 if next.is_whitespace() {
193 end = position;
194 break;
195 }
196 chars.next();
197 }
198 let terms = tokenize(config, &input[start..end])?;
199 terms_to_and(terms.into_iter().map(|token| token.text))?
200 };
201 current.push(if negated {
202 Query::Not(Box::new(query))
203 } else {
204 query
205 });
206 }
207 if !current.is_empty() {
208 groups.push(current);
209 }
210 if groups.is_empty() {
211 return Err("TSQUERY is empty".into());
212 }
213 let query = groups
214 .into_iter()
215 .map(|group| and_queries(group.into_iter()))
216 .collect::<Result<Vec<_>, _>>()?
217 .into_iter()
218 .reduce(|left, right| Query::Or(Box::new(left), Box::new(right)))
219 .ok_or_else(|| "TSQUERY is empty".to_string())?;
220 if query_term_count(&query) > MAX_QUERY_TERMS {
221 return Err("TSQUERY exceeds 1024 terms".into());
222 }
223 Ok(query)
224}
225
226fn validate_query_input(input: &str) -> Result<(), String> {
227 if input.len() > MAX_QUERY_BYTES {
228 Err("TSQUERY exceeds 4096 bytes".into())
229 } else {
230 Ok(())
231 }
232}
233
234fn query_term_count(query: &Query) -> usize {
235 match query {
236 Query::Term { .. } => 1,
237 Query::Not(query) => query_term_count(query),
238 Query::And(left, right) | Query::Or(left, right) | Query::Phrase(left, right) => {
239 query_term_count(left) + query_term_count(right)
240 }
241 }
242}
243
244fn terms_to_and(terms: impl IntoIterator<Item = String>) -> Result<Query, String> {
245 and_queries(terms.into_iter().map(|text| Query::Term {
246 text,
247 prefix: false,
248 }))
249}
250
251fn terms_to_phrase(terms: impl IntoIterator<Item = String>) -> Result<Query, String> {
252 terms
253 .into_iter()
254 .map(|text| Query::Term {
255 text,
256 prefix: false,
257 })
258 .reduce(|left, right| Query::Phrase(Box::new(left), Box::new(right)))
259 .ok_or_else(|| "TSQUERY phrase is empty".into())
260}
261
262fn and_queries(queries: impl Iterator<Item = Query>) -> Result<Query, String> {
263 queries
264 .reduce(|left, right| Query::And(Box::new(left), Box::new(right)))
265 .ok_or_else(|| "TSQUERY is empty".into())
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
269enum QueryToken {
270 Term(String, bool),
271 And,
272 Or,
273 Not,
274 Phrase,
275 Left,
276 Right,
277}
278
279fn lex_query(config: &str, input: &str) -> Result<Vec<QueryToken>, String> {
280 let mut output = Vec::new();
281 let mut index = 0;
282 while index < input.len() {
283 let ch = input[index..].chars().next().unwrap();
284 if ch.is_whitespace() {
285 index += ch.len_utf8();
286 continue;
287 }
288 let token = match ch {
289 '&' => QueryToken::And,
290 '|' => QueryToken::Or,
291 '!' => QueryToken::Not,
292 '(' => QueryToken::Left,
293 ')' => QueryToken::Right,
294 '<' if input[index..].starts_with("<->") => {
295 index += 2;
296 QueryToken::Phrase
297 }
298 _ if ch.is_alphanumeric() || ch == '_' => {
299 let start = index;
300 index += ch.len_utf8();
301 while index < input.len() {
302 let next = input[index..].chars().next().unwrap();
303 if !(next.is_alphanumeric() || next == '_') {
304 break;
305 }
306 index += next.len_utf8();
307 }
308 let end = index;
309 let prefix = input[index..].starts_with(":*");
310 if prefix {
311 index += 2;
312 }
313 output.push(QueryToken::Term(
314 normalize(config, &input[start..end]),
315 prefix,
316 ));
317 continue;
318 }
319 _ => return Err(format!("TSQUERY contains unsupported character '{ch}'")),
320 };
321 output.push(token);
322 index += ch.len_utf8();
323 }
324 Ok(output)
325}
326
327struct QueryParser {
328 tokens: Vec<QueryToken>,
329 index: usize,
330}
331
332impl QueryParser {
333 fn parse_or(&mut self, depth: usize) -> Result<Query, String> {
334 let mut query = self.parse_and(depth + 1)?;
335 while self.take(&QueryToken::Or) {
336 query = Query::Or(Box::new(query), Box::new(self.parse_and(depth + 1)?));
337 }
338 Ok(query)
339 }
340
341 fn parse_and(&mut self, depth: usize) -> Result<Query, String> {
342 let mut query = self.parse_phrase(depth + 1)?;
343 while self.take(&QueryToken::And) {
344 query = Query::And(Box::new(query), Box::new(self.parse_phrase(depth + 1)?));
345 }
346 Ok(query)
347 }
348
349 fn parse_phrase(&mut self, depth: usize) -> Result<Query, String> {
350 let mut query = self.parse_unary(depth + 1)?;
351 while self.take(&QueryToken::Phrase) {
352 query = Query::Phrase(Box::new(query), Box::new(self.parse_unary(depth + 1)?));
353 }
354 Ok(query)
355 }
356
357 fn parse_unary(&mut self, depth: usize) -> Result<Query, String> {
358 if depth > MAX_QUERY_DEPTH {
359 return Err("TSQUERY exceeds nesting depth 64".into());
360 }
361 if self.take(&QueryToken::Not) {
362 return Ok(Query::Not(Box::new(self.parse_unary(depth + 1)?)));
363 }
364 match self.tokens.get(self.index).cloned() {
365 Some(QueryToken::Term(text, prefix)) => {
366 self.index += 1;
367 Ok(Query::Term { text, prefix })
368 }
369 Some(QueryToken::Left) => {
370 self.index += 1;
371 let query = self.parse_or(depth + 1)?;
372 if !self.take(&QueryToken::Right) {
373 return Err("TSQUERY is missing ')'".into());
374 }
375 Ok(query)
376 }
377 _ => Err("TSQUERY expects a term".into()),
378 }
379 }
380
381 fn take(&mut self, expected: &QueryToken) -> bool {
382 if self.tokens.get(self.index) == Some(expected) {
383 self.index += 1;
384 true
385 } else {
386 false
387 }
388 }
389}
390
391pub fn format_query(query: &Query) -> String {
392 format_query_precedence(query, 0)
393}
394
395fn format_query_precedence(query: &Query, parent: u8) -> String {
396 let (precedence, value) = match query {
397 Query::Term { text, prefix } => (5, format!("{text}{}", if *prefix { ":*" } else { "" })),
398 Query::Not(query) => (4, format!("!{}", format_query_precedence(query, 4))),
399 Query::Phrase(left, right) => (
400 3,
401 format!(
402 "{} <-> {}",
403 format_query_precedence(left, 3),
404 format_query_precedence(right, 3)
405 ),
406 ),
407 Query::And(left, right) => (
408 2,
409 format!(
410 "{} & {}",
411 format_query_precedence(left, 2),
412 format_query_precedence(right, 2)
413 ),
414 ),
415 Query::Or(left, right) => (
416 1,
417 format!(
418 "{} | {}",
419 format_query_precedence(left, 1),
420 format_query_precedence(right, 1)
421 ),
422 ),
423 };
424 if precedence < parent {
425 format!("({value})")
426 } else {
427 value
428 }
429}
430
431pub fn matches_query(tokens: &[Token], query: &Query) -> bool {
432 !positions(tokens, query).is_empty()
433}
434
435fn positions(tokens: &[Token], query: &Query) -> BTreeSet<usize> {
436 match query {
437 Query::Term { text, prefix } => tokens
438 .iter()
439 .filter(|token| {
440 if *prefix {
441 token.text.starts_with(text)
442 } else {
443 token.text == *text
444 }
445 })
446 .map(|token| token.position)
447 .collect(),
448 Query::Not(query) => positions(tokens, query)
449 .is_empty()
450 .then_some(0)
451 .into_iter()
452 .collect(),
453 Query::And(left, right) => {
454 let left_positions = positions(tokens, left);
455 if left_positions.is_empty() || positions(tokens, right).is_empty() {
456 BTreeSet::new()
457 } else {
458 left_positions
459 }
460 }
461 Query::Or(left, right) => positions(tokens, left)
462 .union(&positions(tokens, right))
463 .copied()
464 .collect(),
465 Query::Phrase(left, right) => {
466 let left = positions(tokens, left);
467 positions(tokens, right)
468 .into_iter()
469 .filter(|position| {
470 position
471 .checked_sub(1)
472 .is_some_and(|previous| left.contains(&previous))
473 })
474 .collect()
475 }
476 }
477}
478
479pub fn index_terms(query: &Query, output: &mut BTreeSet<String>) -> bool {
480 match query {
481 Query::Term {
482 text,
483 prefix: false,
484 } => {
485 output.insert(text.clone());
486 true
487 }
488 Query::Term { prefix: true, .. } | Query::Not(_) => false,
489 Query::And(left, right) | Query::Or(left, right) | Query::Phrase(left, right) => {
490 index_terms(left, output) && index_terms(right, output)
491 }
492 }
493}
494
495pub fn rank(tokens: &[Token], query: &Query) -> f64 {
496 if !matches_query(tokens, query) || tokens.is_empty() {
497 return 0.0;
498 }
499 let matched = tokens
500 .iter()
501 .filter(|token| positive_token_match(query, &token.text))
502 .count();
503 matched as f64 / tokens.len() as f64
504}
505
506fn positive_token_match(query: &Query, token: &str) -> bool {
507 match query {
508 Query::Term { text, prefix } => {
509 if *prefix {
510 token.starts_with(text)
511 } else {
512 token == text
513 }
514 }
515 Query::Not(_) => false,
516 Query::And(left, right) | Query::Or(left, right) | Query::Phrase(left, right) => {
517 positive_token_match(left, token) || positive_token_match(right, token)
518 }
519 }
520}
521
522pub fn headline(config: &str, document: &str, query: &Query) -> Result<String, String> {
523 let tokens = tokenize(config, document)?;
524 let mut output = String::with_capacity(document.len());
525 let mut cursor = 0;
526 for token in tokens {
527 output.push_str(&document[cursor..token.start]);
528 if positive_token_match(query, &token.text) {
529 output.push_str("<b>");
530 output.push_str(&document[token.start..token.end]);
531 output.push_str("</b>");
532 } else {
533 output.push_str(&document[token.start..token.end]);
534 }
535 cursor = token.end;
536 }
537 output.push_str(&document[cursor..]);
538 if output.len() > MAX_DOCUMENT_BYTES {
539 return Err("headline exceeds 1048576 bytes".into());
540 }
541 Ok(output)
542}
543
544pub fn parse_tsvector(input: &str) -> Result<Vec<Token>, String> {
545 if input.len() > MAX_DOCUMENT_BYTES {
546 return Err("TSVECTOR exceeds 1048576 bytes".into());
547 }
548 let mut tokens = Vec::new();
549 for entry in input.split_whitespace() {
550 let (term, positions) = entry
551 .rsplit_once(':')
552 .ok_or_else(|| "invalid TSVECTOR entry".to_string())?;
553 let term = term
554 .strip_prefix('\'')
555 .and_then(|value| value.strip_suffix('\''))
556 .ok_or_else(|| "invalid TSVECTOR term".to_string())?
557 .replace("''", "'");
558 for position in positions.split(',') {
559 if tokens.len() >= MAX_TOKENS {
560 return Err("TSVECTOR exceeds 65536 positions".into());
561 }
562 let position = position
563 .parse::<usize>()
564 .map_err(|_| "invalid TSVECTOR position".to_string())?;
565 tokens.push(Token {
566 text: term.clone(),
567 position,
568 start: 0,
569 end: 0,
570 });
571 }
572 }
573 tokens.sort_by_key(|token| token.position);
574 Ok(tokens)
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 #[test]
582 fn phrase_tracks_the_rightmost_position() {
583 let tokens = tokenize("simple", "one two three").unwrap();
584 let query = parse_tsquery("simple", "one <-> two <-> three").unwrap();
585 assert!(matches_query(&tokens, &query));
586 }
587
588 #[test]
589 fn vector_round_trip_preserves_rank() {
590 let vector = to_tsvector("simple", "the quick brown fox").unwrap();
591 let query = plainto_tsquery("simple", "quick fox").unwrap();
592 assert_eq!(rank(&parse_tsvector(&vector).unwrap(), &query), 0.5);
593 }
594
595 #[test]
596 fn public_resource_limits_fail_closed() {
597 assert!(tokenize("simple", &"x".repeat(MAX_DOCUMENT_BYTES + 1)).is_err());
598 assert!(parse_tsquery("simple", &"x".repeat(MAX_QUERY_BYTES + 1)).is_err());
599 }
600}