1use crate::error::{Result, SQLError};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum FTSTokenType {
40 Term,
41 Phrase,
42 Vector,
43 And,
44 Or,
45 Not,
46 LParen,
47 RParen,
48 Colon,
49 Eof,
50}
51
52#[derive(Debug, Clone)]
53pub struct FTSToken {
54 pub kind: FTSTokenType,
55 pub value: String,
56 pub pos: usize,
57}
58
59#[expect(
60 clippy::too_many_lines,
61 reason = "FTS parser keeps token precedence in one state machine"
62)]
63pub fn tokenize(source: &str) -> Result<Vec<FTSToken>> {
64 let mut tokens: Vec<FTSToken> = Vec::new();
65 let bytes: Vec<char> = source.chars().collect();
66 let n = bytes.len();
67 let mut i = 0_usize;
68
69 while i < n {
70 let ch = bytes[i];
71 if ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' {
72 i += 1;
73 continue;
74 }
75 match ch {
76 '(' => {
77 tokens.push(FTSToken {
78 kind: FTSTokenType::LParen,
79 value: "(".into(),
80 pos: i,
81 });
82 i += 1;
83 continue;
84 }
85 ')' => {
86 tokens.push(FTSToken {
87 kind: FTSTokenType::RParen,
88 value: ")".into(),
89 pos: i,
90 });
91 i += 1;
92 continue;
93 }
94 ':' => {
95 tokens.push(FTSToken {
96 kind: FTSTokenType::Colon,
97 value: ":".into(),
98 pos: i,
99 });
100 i += 1;
101 continue;
102 }
103 '"' => {
104 let start = i;
105 i += 1;
106 let body_start = i;
107 while i < n && bytes[i] != '"' {
108 i += 1;
109 }
110 if i >= n {
111 return Err(SQLError::TypeMismatch(format!(
112 "Unterminated quoted phrase starting at position {start}"
113 )));
114 }
115 let phrase: String = bytes[body_start..i].iter().collect();
116 tokens.push(FTSToken {
117 kind: FTSTokenType::Phrase,
118 value: phrase,
119 pos: start,
120 });
121 i += 1;
122 continue;
123 }
124 '[' => {
125 let start = i;
126 i += 1;
127 let body_start = i;
128 while i < n && bytes[i] != ']' {
129 i += 1;
130 }
131 if i >= n {
132 return Err(SQLError::TypeMismatch(format!(
133 "Unterminated vector literal starting at position {start}"
134 )));
135 }
136 let content: String = bytes[body_start..i].iter().collect();
137 tokens.push(FTSToken {
138 kind: FTSTokenType::Vector,
139 value: content,
140 pos: start,
141 });
142 i += 1;
143 continue;
144 }
145 _ => {}
146 }
147 if is_word_char(ch) {
148 let start = i;
149 while i < n && is_word_char(bytes[i]) {
150 i += 1;
151 }
152 let word: String = bytes[start..i].iter().collect();
153 let lower = word.to_ascii_lowercase();
154 let kind = match lower.as_str() {
155 "and" => FTSTokenType::And,
156 "or" => FTSTokenType::Or,
157 "not" => FTSTokenType::Not,
158 _ => FTSTokenType::Term,
159 };
160 tokens.push(FTSToken {
161 kind,
162 value: word,
163 pos: start,
164 });
165 continue;
166 }
167 return Err(SQLError::TypeMismatch(format!(
168 "Unexpected character {ch:?} at position {i}"
169 )));
170 }
171
172 tokens.push(FTSToken {
173 kind: FTSTokenType::Eof,
174 value: String::new(),
175 pos: n,
176 });
177 Ok(tokens)
178}
179
180fn is_word_char(ch: char) -> bool {
181 !matches!(
182 ch,
183 ' ' | '\t' | '\n' | '\r' | '(' | ')' | ':' | '"' | '[' | ']'
184 )
185}
186
187#[derive(Debug, Clone)]
192pub enum FTSNode {
193 Term {
194 field: Option<String>,
195 term: String,
196 },
197 Phrase {
198 field: Option<String>,
199 phrase: String,
200 },
201 Vector {
202 field: Option<String>,
203 values: Vec<f32>,
204 },
205 And(Box<FTSNode>, Box<FTSNode>),
206 Or(Box<FTSNode>, Box<FTSNode>),
207 Not(Box<FTSNode>),
208}
209
210pub struct FTSParser {
215 tokens: Vec<FTSToken>,
216 pos: usize,
217}
218
219impl FTSParser {
220 pub fn new(tokens: Vec<FTSToken>) -> Self {
221 Self { tokens, pos: 0 }
222 }
223
224 pub fn parse(mut self) -> Result<FTSNode> {
225 if self.peek().kind == FTSTokenType::Eof {
226 return Err(SQLError::TypeMismatch("Empty query".into()));
227 }
228 let node = self.or_expr()?;
229 if self.peek().kind != FTSTokenType::Eof {
230 let tok = self.peek();
231 return Err(SQLError::TypeMismatch(format!(
232 "Unexpected token {:?} at position {}",
233 tok.value, tok.pos
234 )));
235 }
236 Ok(node)
237 }
238
239 fn peek(&self) -> &FTSToken {
240 &self.tokens[self.pos]
241 }
242
243 fn advance(&mut self) -> FTSToken {
244 let tok = self.tokens[self.pos].clone();
245 self.pos += 1;
246 tok
247 }
248
249 fn expect(&mut self, kind: FTSTokenType) -> Result<FTSToken> {
250 let tok = self.advance();
251 if tok.kind != kind {
252 return Err(SQLError::TypeMismatch(format!(
253 "Expected {:?}, got {:?} ({:?}) at position {}",
254 kind, tok.kind, tok.value, tok.pos
255 )));
256 }
257 Ok(tok)
258 }
259
260 fn or_expr(&mut self) -> Result<FTSNode> {
261 let mut left = self.and_expr()?;
262 while self.peek().kind == FTSTokenType::Or {
263 self.advance();
264 let right = self.and_expr()?;
265 left = FTSNode::Or(Box::new(left), Box::new(right));
266 }
267 Ok(left)
268 }
269
270 fn and_expr(&mut self) -> Result<FTSNode> {
271 let mut left = self.unary()?;
272 loop {
273 let kind = self.peek().kind;
274 if kind == FTSTokenType::And {
275 self.advance();
276 let right = self.unary()?;
277 left = FTSNode::And(Box::new(left), Box::new(right));
278 } else if matches!(
279 kind,
280 FTSTokenType::Term
281 | FTSTokenType::Phrase
282 | FTSTokenType::Vector
283 | FTSTokenType::LParen
284 | FTSTokenType::Not
285 ) {
286 let right = self.unary()?;
287 left = FTSNode::And(Box::new(left), Box::new(right));
288 } else {
289 break;
290 }
291 }
292 Ok(left)
293 }
294
295 fn unary(&mut self) -> Result<FTSNode> {
296 if self.peek().kind == FTSTokenType::Not {
297 self.advance();
298 let operand = self.unary()?;
299 return Ok(FTSNode::Not(Box::new(operand)));
300 }
301 self.primary()
302 }
303
304 fn primary(&mut self) -> Result<FTSNode> {
305 let kind = self.peek().kind;
306 if kind == FTSTokenType::LParen {
307 self.advance();
308 let node = self.or_expr()?;
309 self.expect(FTSTokenType::RParen)?;
310 return Ok(node);
311 }
312 if kind == FTSTokenType::Phrase {
313 let tok = self.advance();
314 return Ok(FTSNode::Phrase {
315 field: None,
316 phrase: tok.value,
317 });
318 }
319 if kind == FTSTokenType::Vector {
320 let tok = self.advance();
321 return Ok(FTSNode::Vector {
322 field: None,
323 values: parse_vector_literal(&tok.value)?,
324 });
325 }
326 if kind == FTSTokenType::Term {
327 let tok = self.advance();
328 if self.peek().kind == FTSTokenType::Colon {
329 self.advance();
330 let next = self.peek().clone();
331 match next.kind {
332 FTSTokenType::Phrase => {
333 self.advance();
334 return Ok(FTSNode::Phrase {
335 field: Some(tok.value),
336 phrase: next.value,
337 });
338 }
339 FTSTokenType::Vector => {
340 self.advance();
341 return Ok(FTSNode::Vector {
342 field: Some(tok.value),
343 values: parse_vector_literal(&next.value)?,
344 });
345 }
346 FTSTokenType::Term => {
347 self.advance();
348 return Ok(FTSNode::Term {
349 field: Some(tok.value),
350 term: next.value,
351 });
352 }
353 other => {
354 return Err(SQLError::TypeMismatch(format!(
355 "Expected term, phrase, or vector after ':', got {other:?} at position {}",
356 next.pos
357 )));
358 }
359 }
360 }
361 return Ok(FTSNode::Term {
362 field: None,
363 term: tok.value,
364 });
365 }
366 let tok = self.peek().clone();
367 Err(SQLError::TypeMismatch(format!(
368 "Unexpected token {:?} ({:?}) at position {}",
369 tok.kind, tok.value, tok.pos
370 )))
371 }
372}
373
374fn parse_vector_literal(content: &str) -> Result<Vec<f32>> {
375 let trimmed = content.trim();
376 if trimmed.is_empty() {
377 return Err(SQLError::TypeMismatch("Empty vector literal".into()));
378 }
379 trimmed
380 .split(',')
381 .map(|s| {
382 s.trim()
383 .parse::<f32>()
384 .map_err(|e| SQLError::TypeMismatch(format!("Malformed vector literal: {e}")))
385 })
386 .collect()
387}
388
389pub fn parse_query_string(query_string: &str) -> Result<FTSNode> {
392 let tokens = tokenize(query_string)?;
393 FTSParser::new(tokens).parse()
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn tokenize_terms_and_phrase() {
402 let tokens = tokenize(r#"hello "world today" foo"#).unwrap();
403 let kinds: Vec<_> = tokens.iter().map(|t| t.kind).collect();
404 assert_eq!(
405 kinds,
406 vec![
407 FTSTokenType::Term,
408 FTSTokenType::Phrase,
409 FTSTokenType::Term,
410 FTSTokenType::Eof,
411 ]
412 );
413 assert_eq!(tokens[1].value, "world today");
414 }
415
416 #[test]
417 fn tokenize_vector() {
418 let tokens = tokenize("[0.1, 0.2, 0.3]").unwrap();
419 assert_eq!(tokens[0].kind, FTSTokenType::Vector);
420 assert_eq!(tokens[0].value, "0.1, 0.2, 0.3");
421 }
422
423 #[test]
424 fn parse_implicit_and() {
425 let tokens = tokenize("rust ferris").unwrap();
426 let ast = FTSParser::new(tokens).parse().unwrap();
427 assert!(matches!(ast, FTSNode::And(_, _)));
428 }
429
430 #[test]
431 fn parse_field_qualified_term() {
432 let tokens = tokenize("title:rust").unwrap();
433 let ast = FTSParser::new(tokens).parse().unwrap();
434 match ast {
435 FTSNode::Term { field, term } => {
436 assert_eq!(field.as_deref(), Some("title"));
437 assert_eq!(term, "rust");
438 }
439 _ => panic!("expected Term"),
440 }
441 }
442
443 #[test]
444 fn parse_not_precedence() {
445 let tokens = tokenize("NOT foo OR bar").unwrap();
446 let ast = FTSParser::new(tokens).parse().unwrap();
447 match ast {
449 FTSNode::Or(left, _) => match *left {
450 FTSNode::Not(_) => {}
451 _ => panic!("expected NOT on left"),
452 },
453 _ => panic!("expected OR"),
454 }
455 }
456
457 #[test]
458 fn parse_query_string_stops_at_syntax_ast() {
459 let ast = parse_query_string("body:rust OR embedding:[1, 0]").unwrap();
460 assert!(matches!(ast, FTSNode::Or(_, _)));
461 }
462}