Skip to main content

reddb_rql/parser/
search_commands.rs

1//! Search Command Parser: SEARCH SIMILAR | TEXT | HYBRID
2
3use super::error::ParseError;
4use super::Parser;
5use crate::ast::{QueryExpr, SearchCommand};
6use crate::lexer::Token;
7use reddb_types::types::Value;
8
9impl<'a> Parser<'a> {
10    /// Parse: SEARCH subcommand ...
11    pub fn parse_search_command(&mut self) -> Result<QueryExpr, ParseError> {
12        self.expect(Token::Search)?;
13        match self.peek().clone() {
14            Token::Similar => self.parse_search_similar(),
15            Token::Text => self.parse_search_text(),
16            Token::Hybrid => self.parse_search_hybrid(),
17            Token::Index => self.parse_search_index(),
18            Token::Ident(name) if name.eq_ignore_ascii_case("MULTIMODAL") => {
19                self.parse_search_multimodal()
20            }
21            Token::Ident(name) if name.eq_ignore_ascii_case("CONTEXT") => {
22                self.parse_search_context()
23            }
24            Token::Ident(name) if name.eq_ignore_ascii_case("SPATIAL") => {
25                self.parse_search_spatial()
26            }
27            _ => Err(ParseError::expected(
28                vec![
29                    "SIMILAR",
30                    "TEXT",
31                    "HYBRID",
32                    "MULTIMODAL",
33                    "INDEX",
34                    "CONTEXT",
35                    "SPATIAL",
36                ],
37                self.peek(),
38                self.position(),
39            )),
40        }
41    }
42
43    /// Parse: SEARCH SIMILAR ([v1, v2] | TEXT 'query') COLLECTION col [LIMIT n] [MIN_SCORE f] [USING provider]
44    fn parse_search_similar(&mut self) -> Result<QueryExpr, ParseError> {
45        self.advance()?; // consume SIMILAR
46
47        // Parse vector literal OR text for semantic search OR positional placeholder.
48        let mut vector_param: Option<usize> = None;
49        let mut text_param: Option<usize> = None;
50        let (vector, text) = if self.consume(&Token::Text)? {
51            // SEARCH SIMILAR TEXT ('query' | $N) — semantic search
52            if matches!(self.peek(), Token::Dollar | Token::Question) {
53                text_param = Some(self.parse_param_slot("SEARCH SIMILAR TEXT")?);
54                (Vec::new(), None)
55            } else {
56                let query_text = self.parse_string()?;
57                (Vec::new(), Some(query_text))
58            }
59        } else if matches!(self.peek(), Token::Dollar | Token::Question) {
60            // SEARCH SIMILAR $N / ? / ?N — parameterized vector
61            vector_param = Some(self.parse_param_slot("SEARCH SIMILAR vector")?);
62            (Vec::new(), None)
63        } else {
64            // SEARCH SIMILAR [0.1, 0.2] — classic vector search
65            (self.parse_vector_literal()?, None)
66        };
67
68        // Parse COLLECTION
69        self.expect(Token::Collection)?;
70        let collection = self.expect_ident()?;
71
72        // Optional LIMIT — accepts an integer literal or `$N` placeholder (#361).
73        let mut limit_param: Option<usize> = None;
74        let limit = if self.consume(&Token::Limit)? {
75            if matches!(self.peek(), Token::Dollar | Token::Question) {
76                limit_param = Some(self.parse_param_slot("LIMIT")?);
77                0
78            } else {
79                self.parse_integer()? as usize
80            }
81        } else {
82            10
83        };
84
85        // Optional MIN_SCORE — accepts a float literal or `$N` placeholder (#361).
86        let mut min_score_param: Option<usize> = None;
87        let min_score = if self.consume(&Token::MinScore)? {
88            if matches!(self.peek(), Token::Dollar | Token::Question) {
89                min_score_param = Some(self.parse_param_slot("MIN_SCORE")?);
90                0.0
91            } else {
92                self.parse_float()? as f32
93            }
94        } else {
95            0.0
96        };
97
98        // Optional USING provider. `USING` is a reserved keyword
99        // (`Token::Using`), so `consume_search_ident` (which only
100        // matches `Token::Ident`) would never fire. Use the typed
101        // consumer. See bug #108.
102        let provider = if self.consume(&Token::Using)? {
103            Some(self.expect_ident()?)
104        } else {
105            None
106        };
107
108        Ok(QueryExpr::SearchCommand(SearchCommand::Similar {
109            vector,
110            text,
111            provider,
112            collection,
113            limit,
114            min_score,
115            vector_param,
116            limit_param,
117            min_score_param,
118            text_param,
119        }))
120    }
121
122    /// Parse: SEARCH TEXT 'query string' [COLLECTION|IN col] [LIMIT n] [FUZZY]
123    fn parse_search_text(&mut self) -> Result<QueryExpr, ParseError> {
124        self.advance()?; // consume TEXT
125
126        let query = self.parse_string()?;
127
128        // Optional COLLECTION
129        let collection = if self.consume(&Token::Collection)? || self.consume(&Token::In)? {
130            Some(self.expect_ident()?)
131        } else {
132            None
133        };
134
135        // Optional LIMIT — accepts an integer literal or `$N` placeholder (#361).
136        let mut limit_param: Option<usize> = None;
137        let limit = if self.consume(&Token::Limit)? {
138            if matches!(self.peek(), Token::Dollar | Token::Question) {
139                limit_param = Some(self.parse_param_slot("LIMIT")?);
140                0
141            } else {
142                self.parse_integer()? as usize
143            }
144        } else {
145            10
146        };
147
148        // Optional FUZZY
149        let fuzzy = self.consume(&Token::Fuzzy)?;
150
151        Ok(QueryExpr::SearchCommand(SearchCommand::Text {
152            query,
153            collection,
154            limit,
155            fuzzy,
156            limit_param,
157        }))
158    }
159
160    /// Parse: SEARCH HYBRID [SIMILAR|VECTOR [v1, v2, ...]] [TEXT 'query'] COLLECTION|IN col [LIMIT|K n]
161    fn parse_search_hybrid(&mut self) -> Result<QueryExpr, ParseError> {
162        self.advance()?; // consume HYBRID
163
164        let mut vector = None;
165        let mut query = None;
166
167        loop {
168            if self.consume(&Token::Similar)? || self.consume(&Token::Vector)? {
169                vector = Some(self.parse_vector_literal()?);
170            } else if self.consume(&Token::Text)? {
171                query = Some(self.parse_string()?);
172            } else {
173                break;
174            }
175        }
176
177        // Require at least one of vector or text
178        if vector.is_none() && query.is_none() {
179            return Err(ParseError::new(
180                "SEARCH HYBRID requires at least SIMILAR or TEXT".to_string(),
181                self.position(),
182            ));
183        }
184
185        // Parse COLLECTION/IN — tolerate collection names that collide
186        // with reserved keywords (e.g. `data`, `text`, `nodes`) by
187        // falling back to `expect_ident_or_keyword` and lowercasing the
188        // keyword form so the stored name matches the source casing.
189        if !(self.consume(&Token::Collection)? || self.consume(&Token::In)?) {
190            return Err(ParseError::expected(
191                vec!["COLLECTION", "IN"],
192                self.peek(),
193                self.position(),
194            ));
195        }
196        let collection = self.expect_collection_name()?;
197
198        // Optional LIMIT / K — accepts an integer literal or `$N` placeholder (#361).
199        let mut limit_param: Option<usize> = None;
200        let limit = if self.consume(&Token::Limit)? || self.consume(&Token::K)? {
201            let _ = self.consume(&Token::Eq)?;
202            if matches!(self.peek(), Token::Dollar | Token::Question) {
203                limit_param = Some(self.parse_param_slot("LIMIT")?);
204                0
205            } else {
206                self.parse_integer()? as usize
207            }
208        } else {
209            10
210        };
211
212        Ok(QueryExpr::SearchCommand(SearchCommand::Hybrid {
213            vector,
214            query,
215            collection,
216            limit,
217            limit_param,
218        }))
219    }
220
221    /// Parse: SEARCH MULTIMODAL 'query' [COLLECTION col] [LIMIT n]
222    fn parse_search_multimodal(&mut self) -> Result<QueryExpr, ParseError> {
223        self.advance()?; // consume MULTIMODAL identifier
224
225        let query = self.parse_string()?;
226
227        let collection = if self.consume(&Token::Collection)? {
228            Some(self.expect_ident()?)
229        } else {
230            None
231        };
232
233        // Optional LIMIT — accepts an integer literal or `$N` placeholder (#361).
234        let mut limit_param: Option<usize> = None;
235        let limit = if self.consume(&Token::Limit)? {
236            if matches!(self.peek(), Token::Dollar | Token::Question) {
237                limit_param = Some(self.parse_param_slot("LIMIT")?);
238                0
239            } else {
240                self.parse_integer()? as usize
241            }
242        } else {
243            25
244        };
245
246        Ok(QueryExpr::SearchCommand(SearchCommand::Multimodal {
247            query,
248            collection,
249            limit,
250            limit_param,
251        }))
252    }
253
254    /// Parse: SEARCH INDEX index VALUE 'value' [COLLECTION col] [LIMIT n] [EXACT|FUZZY]
255    fn parse_search_index(&mut self) -> Result<QueryExpr, ParseError> {
256        self.advance()?; // consume INDEX keyword
257
258        let index = self.expect_ident()?;
259        self.expect_search_ident("VALUE")?;
260        let value = self.parse_string()?;
261
262        let collection = if self.consume(&Token::Collection)? {
263            Some(self.expect_ident()?)
264        } else {
265            None
266        };
267
268        // Optional LIMIT — accepts an integer literal or `$N` placeholder (#361).
269        let mut limit_param: Option<usize> = None;
270        let limit = if self.consume(&Token::Limit)? {
271            if matches!(self.peek(), Token::Dollar | Token::Question) {
272                limit_param = Some(self.parse_param_slot("LIMIT")?);
273                0
274            } else {
275                self.parse_integer()? as usize
276            }
277        } else {
278            25
279        };
280
281        let fuzzy = self.consume(&Token::Fuzzy)? || self.consume_search_ident("FUZZY")?;
282        if !fuzzy {
283            let _ = self.consume_search_ident("EXACT")?;
284        }
285        let exact = !fuzzy;
286
287        Ok(QueryExpr::SearchCommand(SearchCommand::Index {
288            index,
289            value,
290            collection,
291            limit,
292            exact,
293            limit_param,
294        }))
295    }
296
297    /// Collection/index names frequently collide with reserved words
298    /// (`data`, `text`, `nodes`, `edges`, …). Accept either a plain
299    /// identifier or a keyword, lowercasing the keyword form so the
300    /// stored name matches the source spelling.
301    fn expect_collection_name(&mut self) -> Result<String, ParseError> {
302        let was_ident = matches!(self.peek(), Token::Ident(_));
303        let raw = self.expect_ident_or_keyword()?;
304        Ok(if was_ident {
305            raw
306        } else {
307            raw.to_ascii_lowercase()
308        })
309    }
310
311    fn expect_search_ident(&mut self, expected: &str) -> Result<(), ParseError> {
312        if self.consume_search_ident(expected)? {
313            Ok(())
314        } else {
315            Err(ParseError::expected(
316                vec![expected],
317                self.peek(),
318                self.position(),
319            ))
320        }
321    }
322
323    fn consume_search_ident(&mut self, expected: &str) -> Result<bool, ParseError> {
324        match self.peek().clone() {
325            Token::Ident(name) if name.eq_ignore_ascii_case(expected) => {
326                self.advance()?;
327                Ok(true)
328            }
329            _ => Ok(false),
330        }
331    }
332
333    /// Parse: SEARCH CONTEXT 'query' [FIELD field] [COLLECTION col] [DEPTH n] [LIMIT n]
334    fn parse_search_context(&mut self) -> Result<QueryExpr, ParseError> {
335        self.advance()?; // consume CONTEXT keyword
336
337        let query = self.parse_string()?;
338
339        let field = if self.consume_search_ident("FIELD")? {
340            Some(self.expect_ident()?)
341        } else {
342            None
343        };
344
345        let collection = if self.consume(&Token::Collection)? {
346            Some(self.expect_ident()?)
347        } else {
348            None
349        };
350
351        // Parse optional clauses in any order
352        let mut limit = 25usize;
353        let mut depth = 1usize;
354        let mut limit_param: Option<usize> = None;
355        for _ in 0..2 {
356            if self.consume(&Token::Limit)? {
357                if matches!(self.peek(), Token::Dollar | Token::Question) {
358                    limit_param = Some(self.parse_param_slot("LIMIT")?);
359                    limit = 0;
360                } else {
361                    limit = self.parse_integer()? as usize;
362                }
363            } else if self.consume(&Token::Depth)? {
364                depth = self.parse_integer()? as usize;
365            }
366        }
367
368        Ok(QueryExpr::SearchCommand(SearchCommand::Context {
369            query,
370            field,
371            collection,
372            limit,
373            depth,
374            limit_param,
375        }))
376    }
377
378    /// Parse: SEARCH SPATIAL (RADIUS | BBOX | WITHIN POLYGON | NEAREST) ...
379    ///
380    /// Syntax:
381    /// - SEARCH SPATIAL RADIUS lat lon radius_km COLLECTION col COLUMN col [LIMIT n]
382    /// - SEARCH SPATIAL BBOX min_lat min_lon max_lat max_lon COLLECTION col COLUMN col [LIMIT n]
383    /// - SEARCH SPATIAL WITHIN POLYGON ((lat lon), ...) COLLECTION col COLUMN col [LIMIT n]
384    /// - SEARCH SPATIAL NEAREST lat lon K n COLLECTION col COLUMN col
385    fn parse_search_spatial(&mut self) -> Result<QueryExpr, ParseError> {
386        self.advance()?; // consume SPATIAL
387
388        match self.peek().clone() {
389            Token::Ident(ref name) if name.eq_ignore_ascii_case("RADIUS") => {
390                self.advance()?; // consume RADIUS
391                let lat_pos = self.position();
392                let center_lat = self.parse_float()?;
393                if !(-90.0..=90.0).contains(&center_lat) {
394                    return Err(ParseError::value_out_of_range(
395                        "lat",
396                        "must be in -90.0..=90.0",
397                        lat_pos,
398                    ));
399                }
400                let lon_pos = self.position();
401                let center_lon = self.parse_float()?;
402                if !(-180.0..=180.0).contains(&center_lon) {
403                    return Err(ParseError::value_out_of_range(
404                        "lon",
405                        "must be in -180.0..=180.0",
406                        lon_pos,
407                    ));
408                }
409                let r_pos = self.position();
410                let radius_km = self.parse_float()?;
411                if radius_km.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
412                    return Err(ParseError::value_out_of_range(
413                        "radius",
414                        "must be a positive number",
415                        r_pos,
416                    ));
417                }
418
419                self.expect(Token::Collection)?;
420                let collection = self.expect_ident()?;
421
422                let _ = self.consume(&Token::Column)? || self.consume_search_ident("COLUMN")?;
423                let column = self.parse_search_spatial_column()?;
424
425                let mut limit_param: Option<usize> = None;
426                let limit = if self.consume(&Token::Limit)? {
427                    if matches!(self.peek(), Token::Dollar | Token::Question) {
428                        limit_param = Some(self.parse_param_slot("LIMIT")?);
429                        0
430                    } else {
431                        self.parse_integer()? as usize
432                    }
433                } else {
434                    100
435                };
436
437                Ok(QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
438                    center_lat,
439                    center_lon,
440                    radius_km,
441                    collection,
442                    column,
443                    limit,
444                    limit_param,
445                }))
446            }
447            Token::Ident(ref name) if name.eq_ignore_ascii_case("BBOX") => {
448                self.advance()?; // consume BBOX
449                let p = self.position();
450                let min_lat = self.parse_float()?;
451                if !(-90.0..=90.0).contains(&min_lat) {
452                    return Err(ParseError::value_out_of_range(
453                        "lat",
454                        "must be in -90.0..=90.0",
455                        p,
456                    ));
457                }
458                let p = self.position();
459                let min_lon = self.parse_float()?;
460                if !(-180.0..=180.0).contains(&min_lon) {
461                    return Err(ParseError::value_out_of_range(
462                        "lon",
463                        "must be in -180.0..=180.0",
464                        p,
465                    ));
466                }
467                let p = self.position();
468                let max_lat = self.parse_float()?;
469                if !(-90.0..=90.0).contains(&max_lat) {
470                    return Err(ParseError::value_out_of_range(
471                        "lat",
472                        "must be in -90.0..=90.0",
473                        p,
474                    ));
475                }
476                let p = self.position();
477                let max_lon = self.parse_float()?;
478                if !(-180.0..=180.0).contains(&max_lon) {
479                    return Err(ParseError::value_out_of_range(
480                        "lon",
481                        "must be in -180.0..=180.0",
482                        p,
483                    ));
484                }
485
486                self.expect(Token::Collection)?;
487                let collection = self.expect_ident()?;
488
489                let _ = self.consume(&Token::Column)? || self.consume_search_ident("COLUMN")?;
490                let column = self.parse_search_spatial_column()?;
491
492                let mut limit_param: Option<usize> = None;
493                let limit = if self.consume(&Token::Limit)? {
494                    if matches!(self.peek(), Token::Dollar | Token::Question) {
495                        limit_param = Some(self.parse_param_slot("LIMIT")?);
496                        0
497                    } else {
498                        self.parse_integer()? as usize
499                    }
500                } else {
501                    100
502                };
503
504                Ok(QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
505                    min_lat,
506                    min_lon,
507                    max_lat,
508                    max_lon,
509                    collection,
510                    column,
511                    limit,
512                    limit_param,
513                }))
514            }
515            Token::Ident(ref name) if name.eq_ignore_ascii_case("WITHIN") => {
516                self.advance()?; // consume WITHIN
517                self.expect_search_ident("POLYGON")?;
518                let vertices = self.parse_search_spatial_polygon_vertices()?;
519                validate_search_spatial_polygon(&vertices, self.position())?;
520
521                self.expect(Token::Collection)?;
522                let collection = self.expect_ident()?;
523
524                let _ = self.consume(&Token::Column)? || self.consume_search_ident("COLUMN")?;
525                let column = self.parse_search_spatial_column()?;
526
527                let mut limit_param: Option<usize> = None;
528                let limit = if self.consume(&Token::Limit)? {
529                    if matches!(self.peek(), Token::Dollar | Token::Question) {
530                        limit_param = Some(self.parse_param_slot("LIMIT")?);
531                        0
532                    } else {
533                        self.parse_integer()? as usize
534                    }
535                } else {
536                    100
537                };
538
539                Ok(QueryExpr::SearchCommand(
540                    SearchCommand::SpatialWithinPolygon {
541                        vertices,
542                        collection,
543                        column,
544                        limit,
545                        limit_param,
546                    },
547                ))
548            }
549            Token::Ident(ref name) if name.eq_ignore_ascii_case("NEAREST") => {
550                self.advance()?; // consume NEAREST
551                let lat_pos = self.position();
552                let lat = self.parse_float()?;
553                if !(-90.0..=90.0).contains(&lat) {
554                    return Err(ParseError::value_out_of_range(
555                        "lat",
556                        "must be in -90.0..=90.0",
557                        lat_pos,
558                    ));
559                }
560                let lon_pos = self.position();
561                let lon = self.parse_float()?;
562                if !(-180.0..=180.0).contains(&lon) {
563                    return Err(ParseError::value_out_of_range(
564                        "lon",
565                        "must be in -180.0..=180.0",
566                        lon_pos,
567                    ));
568                }
569
570                self.expect(Token::K)?;
571                // K accepts a positive integer literal OR `$N` placeholder (#361).
572                let mut k_param: Option<usize> = None;
573                let k = if matches!(self.peek(), Token::Dollar | Token::Question) {
574                    k_param = Some(self.parse_param_slot("K")?);
575                    0
576                } else {
577                    self.parse_positive_integer("K")? as usize
578                };
579
580                self.expect(Token::Collection)?;
581                let collection = self.expect_ident()?;
582
583                let _ = self.consume(&Token::Column)? || self.consume_search_ident("COLUMN")?;
584                let column = self.parse_search_spatial_column()?;
585
586                Ok(QueryExpr::SearchCommand(SearchCommand::SpatialNearest {
587                    lat,
588                    lon,
589                    k,
590                    collection,
591                    column,
592                    k_param,
593                }))
594            }
595            _ => Err(ParseError::expected(
596                vec!["RADIUS", "BBOX", "WITHIN", "NEAREST"],
597                self.peek(),
598                self.position(),
599            )),
600        }
601    }
602
603    fn parse_search_spatial_polygon_vertices(&mut self) -> Result<Vec<(f64, f64)>, ParseError> {
604        self.expect(Token::LParen)?;
605        let mut vertices = Vec::new();
606        loop {
607            self.expect(Token::LParen)?;
608            let lat = self.parse_float()?;
609            let lon = self.parse_float()?;
610            self.expect(Token::RParen)?;
611            vertices.push((lat, lon));
612            if !self.consume(&Token::Comma)? {
613                break;
614            }
615        }
616        self.expect(Token::RParen)?;
617        Ok(vertices)
618    }
619
620    fn parse_search_spatial_column(&mut self) -> Result<String, ParseError> {
621        let mut segments = vec![self.expect_spatial_column_segment()?];
622        while self.consume(&Token::Dot)? {
623            segments.push(self.expect_spatial_column_segment()?);
624        }
625        Ok(segments.join("."))
626    }
627
628    /// One dotted-path segment of a `SEARCH SPATIAL ... COLUMN` argument.
629    ///
630    /// Column names are user data, not grammar: a document field named
631    /// `current` lexes (case-insensitively) into the window-frame keyword
632    /// token, whose display form would silently rewrite the column to
633    /// `CURRENT` and break the case-sensitive body-field lookup. Recover
634    /// the typed spelling by slicing the token's source span; only
635    /// word-shaped tokens qualify as segments.
636    ///
637    /// Shared with the `GEO_WITHIN(col, POLYGON(...))` predicate, whose
638    /// first argument names the same kind of geo column.
639    pub(crate) fn expect_spatial_column_segment(&mut self) -> Result<String, ParseError> {
640        if let Token::Ident(name) = &self.current.token {
641            let name = name.clone();
642            self.advance()?;
643            return Ok(name);
644        }
645        let start = self.current.start.offset as usize;
646        let end = self.current.end.offset as usize;
647        let raw = self.lexer.source().get(start..end).unwrap_or("");
648        let word_shaped = !raw.is_empty()
649            && raw.chars().all(|c| c.is_alphanumeric() || c == '_')
650            && !raw.chars().next().is_some_and(|c| c.is_ascii_digit());
651        if word_shaped {
652            let name = raw.to_string();
653            self.advance()?;
654            return Ok(name);
655        }
656        Err(ParseError::new(
657            format!("expected column name, found {}", self.current.token),
658            self.position(),
659        ))
660    }
661
662    /// Parse a vector literal: [0.1, 0.2, 0.3]
663    fn parse_vector_literal(&mut self) -> Result<Vec<f32>, ParseError> {
664        self.expect(Token::LBracket)?;
665        let mut items = Vec::new();
666        if !self.check(&Token::RBracket) {
667            loop {
668                let val = self.parse_float()? as f32;
669                items.push(val);
670                if !self.consume(&Token::Comma)? {
671                    break;
672                }
673            }
674        }
675        self.expect(Token::RBracket)?;
676        Ok(items)
677    }
678}
679
680fn validate_search_spatial_polygon(
681    vertices: &[(f64, f64)],
682    pos: crate::lexer::Position,
683) -> Result<(), ParseError> {
684    validate_polygon_vertices(vertices, "SEARCH SPATIAL WITHIN POLYGON", pos)
685}
686
687/// Polygon input rules shared by every surface that accepts a literal
688/// polygon: the `SEARCH SPATIAL WITHIN POLYGON` verb and the
689/// `GEO_WITHIN(col, POLYGON(...))` predicate. Keeping one validator
690/// keeps the two surfaces from drifting on what a legal polygon is.
691///
692/// `surface` names the caller in the error text so the user sees the
693/// construct they actually wrote.
694pub(crate) fn validate_polygon_vertices(
695    vertices: &[(f64, f64)],
696    surface: &str,
697    pos: crate::lexer::Position,
698) -> Result<(), ParseError> {
699    if vertices.len() < 3 {
700        return Err(ParseError::new(
701            format!("{surface} requires at least 3 vertices"),
702            pos,
703        ));
704    }
705    for (lat, lon) in vertices {
706        if !lat.is_finite() || !(-90.0..=90.0).contains(lat) {
707            return Err(ParseError::value_out_of_range(
708                "lat",
709                "must be in -90.0..=90.0",
710                pos,
711            ));
712        }
713        if !lon.is_finite() || !(-180.0..=180.0).contains(lon) {
714            return Err(ParseError::value_out_of_range(
715                "lon",
716                "must be in -180.0..=180.0",
717                pos,
718            ));
719        }
720    }
721    let (min_lon, max_lon) = vertices.iter().map(|(_, lon)| *lon).fold(
722        (f64::INFINITY, f64::NEG_INFINITY),
723        |(min_lon, max_lon), lon| (min_lon.min(lon), max_lon.max(lon)),
724    );
725    if max_lon - min_lon > 180.0 {
726        return Err(ParseError::new(
727            format!("{surface} does not support polygons crossing the antimeridian"),
728            pos,
729        ));
730    }
731    Ok(())
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737
738    fn parse_query(input: &str) -> QueryExpr {
739        crate::parser::parse(input).unwrap().query
740    }
741
742    fn assert_parse_err(input: &str) {
743        assert!(crate::parser::parse(input).is_err(), "{input}");
744    }
745
746    #[test]
747    fn parses_search_similar_text_vector_and_limit_parameters() {
748        let query = parse_query(
749            "SEARCH SIMILAR TEXT 'semantic query' COLLECTION docs LIMIT 7 MIN_SCORE 0.42 USING openai",
750        );
751        let QueryExpr::SearchCommand(SearchCommand::Similar {
752            vector,
753            text,
754            provider,
755            collection,
756            limit,
757            min_score,
758            vector_param,
759            limit_param,
760            min_score_param,
761            text_param,
762        }) = query
763        else {
764            panic!("Expected SearchCommand::Similar");
765        };
766        assert!(vector.is_empty());
767        assert_eq!(text, Some("semantic query".to_string()));
768        assert_eq!(provider, Some("openai".to_string()));
769        assert_eq!(collection, "docs");
770        assert_eq!(limit, 7);
771        assert!((min_score - 0.42).abs() < 0.01);
772        assert_eq!(vector_param, None);
773        assert_eq!(limit_param, None);
774        assert_eq!(min_score_param, None);
775        assert_eq!(text_param, None);
776
777        let query = parse_query("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT $2 MIN_SCORE $3");
778        let QueryExpr::SearchCommand(SearchCommand::Similar {
779            vector,
780            text,
781            limit,
782            min_score,
783            vector_param,
784            limit_param,
785            min_score_param,
786            text_param,
787            ..
788        }) = query
789        else {
790            panic!("Expected parameterized SearchCommand::Similar");
791        };
792        assert!(vector.is_empty());
793        assert_eq!(text, None);
794        assert_eq!(limit, 0);
795        assert!((min_score).abs() < 0.01);
796        assert_eq!(vector_param, None);
797        assert_eq!(limit_param, Some(1));
798        assert_eq!(min_score_param, Some(2));
799        assert_eq!(text_param, Some(0));
800
801        let query = parse_query("SEARCH SIMILAR $1 COLLECTION embeddings");
802        let QueryExpr::SearchCommand(SearchCommand::Similar {
803            vector,
804            vector_param,
805            limit,
806            min_score,
807            ..
808        }) = query
809        else {
810            panic!("Expected vector parameter SearchCommand::Similar");
811        };
812        assert!(vector.is_empty());
813        assert_eq!(vector_param, Some(0));
814        assert_eq!(limit, 10);
815        assert!((min_score).abs() < 0.01);
816    }
817
818    #[test]
819    fn parses_search_text_in_collection_with_question_limit() {
820        let query = parse_query("SEARCH TEXT 'needle' IN docs LIMIT ? FUZZY");
821        let QueryExpr::SearchCommand(SearchCommand::Text {
822            query,
823            collection,
824            limit,
825            fuzzy,
826            limit_param,
827        }) = query
828        else {
829            panic!("Expected SearchCommand::Text");
830        };
831        assert_eq!(query, "needle");
832        assert_eq!(collection, Some("docs".to_string()));
833        assert_eq!(limit, 0);
834        assert!(fuzzy);
835        assert_eq!(limit_param, Some(0));
836    }
837
838    #[test]
839    fn parses_search_hybrid_vector_keyword_k_equals_and_keyword_collection() {
840        let query = parse_query("SEARCH HYBRID VECTOR [1, 2] TEXT 'needle' IN TEXT K = $1");
841        let QueryExpr::SearchCommand(SearchCommand::Hybrid {
842            vector,
843            query,
844            collection,
845            limit,
846            limit_param,
847        }) = query
848        else {
849            panic!("Expected SearchCommand::Hybrid");
850        };
851        assert_eq!(vector, Some(vec![1.0, 2.0]));
852        assert_eq!(query, Some("needle".to_string()));
853        assert_eq!(collection, "text");
854        assert_eq!(limit, 0);
855        assert_eq!(limit_param, Some(0));
856    }
857
858    #[test]
859    fn parses_multimodal_and_index_parameterized_limits() {
860        let query = parse_query("SEARCH MULTIMODAL 'image query' COLLECTION assets LIMIT $1");
861        let QueryExpr::SearchCommand(SearchCommand::Multimodal {
862            query,
863            collection,
864            limit,
865            limit_param,
866        }) = query
867        else {
868            panic!("Expected SearchCommand::Multimodal");
869        };
870        assert_eq!(query, "image query");
871        assert_eq!(collection, Some("assets".to_string()));
872        assert_eq!(limit, 0);
873        assert_eq!(limit_param, Some(0));
874
875        let query = parse_query(
876            "SEARCH INDEX email VALUE 'a@example.test' COLLECTION users LIMIT $1 EXACT",
877        );
878        let QueryExpr::SearchCommand(SearchCommand::Index {
879            index,
880            value,
881            collection,
882            limit,
883            exact,
884            limit_param,
885        }) = query
886        else {
887            panic!("Expected SearchCommand::Index");
888        };
889        assert_eq!(index, "email");
890        assert_eq!(value, "a@example.test");
891        assert_eq!(collection, Some("users".to_string()));
892        assert_eq!(limit, 0);
893        assert!(exact);
894        assert_eq!(limit_param, Some(0));
895    }
896
897    #[test]
898    fn parses_search_context_depth_before_parameterized_limit() {
899        let query =
900            parse_query("SEARCH CONTEXT 'who' FIELD subject COLLECTION docs DEPTH 3 LIMIT $1");
901        let QueryExpr::SearchCommand(SearchCommand::Context {
902            query,
903            field,
904            collection,
905            limit,
906            depth,
907            limit_param,
908        }) = query
909        else {
910            panic!("Expected SearchCommand::Context");
911        };
912        assert_eq!(query, "who");
913        assert_eq!(field, Some("subject".to_string()));
914        assert_eq!(collection, Some("docs".to_string()));
915        assert_eq!(limit, 0);
916        assert_eq!(depth, 3);
917        assert_eq!(limit_param, Some(0));
918    }
919
920    #[test]
921    fn parses_search_spatial_bbox_and_nearest_parameters() {
922        let query =
923            parse_query("SEARCH SPATIAL BBOX -10 -20 10 20 COLLECTION sites COLUMN geog LIMIT $1");
924        let QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
925            min_lat,
926            min_lon,
927            max_lat,
928            max_lon,
929            collection,
930            column,
931            limit,
932            limit_param,
933        }) = query
934        else {
935            panic!("Expected SearchCommand::SpatialBbox");
936        };
937        assert!((min_lat + 10.0).abs() < 0.001);
938        assert!((min_lon + 20.0).abs() < 0.001);
939        assert!((max_lat - 10.0).abs() < 0.001);
940        assert!((max_lon - 20.0).abs() < 0.001);
941        assert_eq!(collection, "sites");
942        assert_eq!(column, "geog");
943        assert_eq!(limit, 0);
944        assert_eq!(limit_param, Some(0));
945
946        let query =
947            parse_query("SEARCH SPATIAL NEAREST 48.85 2.35 K ?2 COLLECTION sites COLUMN geog");
948        let QueryExpr::SearchCommand(SearchCommand::SpatialNearest {
949            lat,
950            lon,
951            k,
952            collection,
953            column,
954            k_param,
955        }) = query
956        else {
957            panic!("Expected SearchCommand::SpatialNearest");
958        };
959        assert!((lat - 48.85).abs() < 0.001);
960        assert!((lon - 2.35).abs() < 0.001);
961        assert_eq!(k, 0);
962        assert_eq!(collection, "sites");
963        assert_eq!(column, "geog");
964        assert_eq!(k_param, Some(1));
965    }
966
967    #[test]
968    fn rejects_invalid_search_command_forms() {
969        for input in [
970            "SEARCH AUDIO 'needle'",
971            "SEARCH HYBRID TEXT 'needle'",
972            "SEARCH SPATIAL WITHIN 0 0 COLLECTION sites COLUMN geog",
973            "SEARCH SPATIAL RADIUS 91 0 1 COLLECTION sites COLUMN geog",
974            "SEARCH SPATIAL RADIUS 45 181 1 COLLECTION sites COLUMN geog",
975            "SEARCH SPATIAL RADIUS 45 90 0 COLLECTION sites COLUMN geog",
976            "SEARCH SPATIAL BBOX -91 0 1 1 COLLECTION sites COLUMN geog",
977            "SEARCH SPATIAL BBOX 0 -181 1 1 COLLECTION sites COLUMN geog",
978            "SEARCH SPATIAL BBOX 0 0 91 1 COLLECTION sites COLUMN geog",
979            "SEARCH SPATIAL BBOX 0 0 1 181 COLLECTION sites COLUMN geog",
980            "SEARCH SPATIAL NEAREST 91 0 K 1 COLLECTION sites COLUMN geog",
981            "SEARCH SPATIAL NEAREST 0 181 K 1 COLLECTION sites COLUMN geog",
982            "SEARCH SPATIAL NEAREST 0 0 K 0 COLLECTION sites COLUMN geog",
983        ] {
984            assert_parse_err(input);
985        }
986    }
987}