Skip to main content

fff_query_parser/
parser.rs

1use crate::ConstraintVec;
2use crate::config::ParserConfig;
3use crate::constraints::{Constraint, GitStatusFilter, TextPartsBuffer};
4use crate::glob_detect::has_wildcards;
5use crate::location::{Location, parse_location};
6
7#[derive(Debug, Clone, PartialEq)]
8#[allow(clippy::large_enum_variant)]
9pub enum FuzzyQuery<'a> {
10    Parts(TextPartsBuffer<'a>),
11    Text(&'a str),
12    Empty,
13}
14
15#[derive(Debug, Clone, PartialEq)]
16pub struct FFFQuery<'a> {
17    /// The original raw query string before parsing
18    pub raw_query: &'a str,
19    /// Parsed constraints (stack-allocated for ≤8 constraints)
20    pub constraints: ConstraintVec<'a>,
21    pub fuzzy_query: FuzzyQuery<'a>,
22    /// Parsed location (e.g., file:12:4 -> line 12, col 4)
23    pub location: Option<Location>,
24}
25
26/// Main query parser - zero-cost wrapper around configuration
27#[derive(Debug)]
28pub struct QueryParser<C: ParserConfig> {
29    config: C,
30}
31
32impl<C: ParserConfig> QueryParser<C> {
33    pub fn new(config: C) -> Self {
34        Self { config }
35    }
36
37    pub fn parse<'a>(&self, query: &'a str) -> FFFQuery<'a> {
38        let raw_query = query;
39        let config: &C = &self.config;
40        let mut constraints = ConstraintVec::new();
41        let query = query.trim();
42
43        let whitespace_count = query.chars().filter(|c| c.is_whitespace()).count();
44
45        // Single token - check if it's a constraint or plain text
46        if whitespace_count == 0 {
47            // Try to parse as constraint first
48            if let Some(constraint) = parse_token(query, config) {
49                // Don't treat filename tokens (FilePath) as constraints in single-token
50                // queries — the user is fuzzy-searching, not filtering. FilePath constraints
51                // are only useful as filters in multi-token queries like "score.rs search".
52                //
53                // Also skip PathSegment constraints when the token looks like an absolute
54                // file path with a location suffix (e.g. /Users/.../file.rs:12). Without
55                // this, the leading `/` causes the entire path to be consumed as a
56                // PathSegment, preventing location parsing from running.
57                let has_location_suffix = matches!(constraint, Constraint::PathSegment(_))
58                    && query.bytes().any(|b| b == b':')
59                    && query
60                        .bytes()
61                        .rev()
62                        .take_while(|&b| b != b':')
63                        .all(|b| b.is_ascii_digit());
64                if !matches!(constraint, Constraint::FilePath(_)) && !has_location_suffix {
65                    constraints.push(constraint);
66                    return FFFQuery {
67                        raw_query,
68                        constraints,
69                        fuzzy_query: FuzzyQuery::Empty,
70                        location: None,
71                    };
72                }
73            }
74
75            // Try to extract location from single token (e.g., "file:12")
76            if config.enable_location() {
77                let (query_without_loc, location) = parse_location(query);
78                if location.is_some() {
79                    return FFFQuery {
80                        raw_query,
81                        constraints,
82                        fuzzy_query: FuzzyQuery::Text(query_without_loc),
83                        location,
84                    };
85                }
86            }
87
88            // Plain text single token
89            return FFFQuery {
90                raw_query,
91                constraints,
92                fuzzy_query: if query.is_empty() {
93                    FuzzyQuery::Empty
94                } else {
95                    FuzzyQuery::Text(query)
96                },
97                location: None,
98            };
99        }
100
101        let mut text_parts = TextPartsBuffer::new();
102        let tokens = query.split_whitespace();
103
104        let mut has_file_path = false;
105        for token in tokens {
106            match parse_token(token, config) {
107                Some(Constraint::FilePath(_)) => {
108                    if has_file_path {
109                        // Only one FilePath constraint allowed; treat extra path
110                        // tokens as literal text (e.g. an import path the user is
111                        // searching for).
112                        text_parts.push(token);
113                    } else {
114                        constraints.push(Constraint::FilePath(token));
115                        has_file_path = true;
116                    }
117                }
118                Some(constraint) => {
119                    constraints.push(constraint);
120                }
121                None => {
122                    text_parts.push(token);
123                }
124            }
125        }
126
127        // Try to extract location from the last fuzzy token
128        // e.g., "search file:12" -> fuzzy="search file", location=Line(12)
129        let location = if config.enable_location() && !text_parts.is_empty() {
130            let last_idx = text_parts.len() - 1;
131            let (without_loc, loc) = parse_location(text_parts[last_idx]);
132            if loc.is_some() {
133                // Update the last part to be without the location suffix
134                text_parts[last_idx] = without_loc;
135                loc
136            } else {
137                None
138            }
139        } else {
140            None
141        };
142
143        let fuzzy_query = if text_parts.is_empty() {
144            FuzzyQuery::Empty
145        } else if text_parts.len() == 1 {
146            // If the only remaining text is empty after location extraction, treat as Empty
147            if text_parts[0].is_empty() {
148                FuzzyQuery::Empty
149            } else {
150                FuzzyQuery::Text(text_parts[0])
151            }
152        } else {
153            // Filter out empty parts that might result from location extraction
154            if text_parts.iter().all(|p| p.is_empty()) {
155                FuzzyQuery::Empty
156            } else {
157                FuzzyQuery::Parts(text_parts)
158            }
159        };
160
161        FFFQuery {
162            raw_query,
163            constraints,
164            fuzzy_query,
165            location,
166        }
167    }
168}
169
170impl<'a> FFFQuery<'a> {
171    /// Returns the grep search text by joining all non-constraint text tokens.
172    ///
173    /// Backslash-escaped tokens (e.g. `\*.rs`) are included as literal text
174    /// with the leading `\` stripped, since the backslash is only an escape
175    /// signal to the parser and should not appear in the final pattern.
176    ///
177    /// `FuzzyQuery::Empty` → empty string
178    /// `FuzzyQuery::Text("foo")` → `"foo"`
179    /// `FuzzyQuery::Parts(["a", "\\*.rs", "b"])` → `"a *.rs b"`
180    pub fn grep_text(&self) -> String {
181        match &self.fuzzy_query {
182            FuzzyQuery::Empty => String::new(),
183            FuzzyQuery::Text(t) => strip_leading_backslash(t).to_string(),
184            FuzzyQuery::Parts(parts) => parts
185                .iter()
186                .map(|t| strip_leading_backslash(t))
187                .collect::<Vec<_>>()
188                .join(" "),
189        }
190    }
191}
192
193/// Strip the leading `\` from a backslash-escaped constraint token only.
194///
195/// We strip the backslash when the next character is a constraint trigger
196/// (`*`, `/`, `!`) — the user typed `\*.rs` to mean literal `*.rs`, not an
197/// extension constraint. For regex escape sequences like `\w`, `\b`, `\d`,
198/// `\s`, `\n` etc., the backslash is preserved so regex mode works correctly.
199#[inline]
200fn strip_leading_backslash(token: &str) -> &str {
201    if token.len() > 1 && token.starts_with('\\') {
202        let next = token.as_bytes()[1];
203        // Only strip if the backslash is escaping a constraint trigger character
204        if next == b'*' || next == b'/' || next == b'!' {
205            return &token[1..];
206        }
207    }
208    token
209}
210
211impl Default for QueryParser<crate::FileSearchConfig> {
212    fn default() -> Self {
213        Self::new(crate::FileSearchConfig)
214    }
215}
216
217#[inline]
218fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constraint<'a>> {
219    // Backslash escape: \token → treat as literal text, skip all constraint parsing.
220    // The leading \ is stripped by the caller when building the search text.
221    if token.starts_with('\\') && token.len() > 1 {
222        return None;
223    }
224
225    let first_byte = token.as_bytes().first()?;
226
227    match first_byte {
228        b'*' if config.enable_extension() => {
229            // Ignore incomplete patterns like "*" or "*."
230            if token == "*" || token == "*." {
231                return None;
232            }
233
234            // Try extension first (*.rs) - simple patterns without additional wildcards
235            if let Some(constraint) = parse_extension(token) {
236                // Only return Extension if the rest doesn't have wildcards
237                // e.g., *.rs is Extension, but *.test.* should be Glob
238                let ext_part = &token[2..];
239                if !has_wildcards(ext_part) {
240                    return Some(constraint);
241                }
242            }
243            // Has wildcards -> use config-specific glob detection
244            if config.enable_glob() && config.is_glob_pattern(token) {
245                return Some(Constraint::Glob(token));
246            }
247            None
248        }
249        b'!' if config.enable_exclude() => parse_negation(token, config),
250        b'/' if config.enable_path_segments() => parse_path_segment(token),
251        _ if config.enable_path_segments() && token.ends_with('/') => {
252            // Handle trailing slash syntax: www/ -> PathSegment("www")
253            parse_path_segment_trailing(token)
254        }
255        _ => {
256            // Check for glob patterns using config-specific detection
257            if config.enable_glob() && config.is_glob_pattern(token) {
258                return Some(Constraint::Glob(token));
259            }
260
261            // Check for key:value patterns
262            if let Some(colon_idx) = memchr(b':', token.as_bytes()) {
263                let (key, value_with_colon) = token.split_at(colon_idx);
264                let value = &value_with_colon[1..]; // Skip the colon
265
266                match key {
267                    "type" if config.enable_type_filter() => {
268                        return Some(Constraint::FileType(value));
269                    }
270                    "status" | "st" | "g" | "git" if config.enable_git_status() => {
271                        return parse_git_status(value);
272                    }
273                    _ => {}
274                }
275            }
276
277            // Try custom parsers
278            config.parse_custom(token)
279        }
280    }
281}
282
283/// Find first occurrence of byte in slice (fast memchr-like implementation)
284#[inline]
285fn memchr(needle: u8, haystack: &[u8]) -> Option<usize> {
286    haystack.iter().position(|&b| b == needle)
287}
288
289/// Parse extension pattern: *.rs -> Extension("rs")
290#[inline]
291fn parse_extension(token: &str) -> Option<Constraint<'_>> {
292    if token.len() > 2 && token.starts_with("*.") {
293        Some(Constraint::Extension(&token[2..]))
294    } else {
295        None
296    }
297}
298
299/// Parse negation pattern: !*.rs -> Not(Extension("rs")), !test -> Not(Text("test"))
300/// This allows negating any constraint type
301#[inline]
302fn parse_negation<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constraint<'a>> {
303    if token.len() <= 1 {
304        return None;
305    }
306
307    let inner_token = &token[1..];
308
309    // Try to parse the inner token as any constraint
310    if let Some(inner_constraint) = parse_token_without_negation(inner_token, config) {
311        // Wrap it in a Not constraint
312        return Some(Constraint::Not(Box::new(inner_constraint)));
313    }
314
315    // If it's not a special constraint, treat it as negated text
316    // For backward compatibility with !test syntax
317    Some(Constraint::Not(Box::new(Constraint::Text(inner_token))))
318}
319
320/// Parse a token without checking for negation (to avoid infinite recursion)
321#[inline]
322fn parse_token_without_negation<'a, C: ParserConfig>(
323    token: &'a str,
324    config: &C,
325) -> Option<Constraint<'a>> {
326    // Backslash escape applies here too
327    if token.starts_with('\\') && token.len() > 1 {
328        return None;
329    }
330
331    let first_byte = token.as_bytes().first()?;
332
333    match first_byte {
334        b'*' if config.enable_extension() => {
335            // Try extension first (*.rs) - simple patterns without additional wildcards
336            if let Some(constraint) = parse_extension(token) {
337                let ext_part = &token[2..];
338                if !has_wildcards(ext_part) {
339                    return Some(constraint);
340                }
341            }
342            // Has wildcards -> use config-specific glob detection
343            if config.enable_glob() && config.is_glob_pattern(token) {
344                return Some(Constraint::Glob(token));
345            }
346            None
347        }
348        b'/' if config.enable_path_segments() => parse_path_segment(token),
349        _ if config.enable_path_segments() && token.ends_with('/') => {
350            // Handle trailing slash syntax: www/ -> PathSegment("www")
351            parse_path_segment_trailing(token)
352        }
353        _ => {
354            // Check for glob patterns using config-specific detection
355            if config.enable_glob() && config.is_glob_pattern(token) {
356                return Some(Constraint::Glob(token));
357            }
358
359            // Check for key:value patterns
360            if let Some(colon_idx) = memchr(b':', token.as_bytes()) {
361                let (key, value_with_colon) = token.split_at(colon_idx);
362                let value = &value_with_colon[1..]; // Skip the colon
363
364                match key {
365                    "type" if config.enable_type_filter() => {
366                        return Some(Constraint::FileType(value));
367                    }
368                    "status" | "st" | "g" | "git" if config.enable_git_status() => {
369                        return parse_git_status(value);
370                    }
371                    _ => {}
372                }
373            }
374
375            config.parse_custom(token)
376        }
377    }
378}
379
380/// Parse path segment: /src/ -> PathSegment("src")
381#[inline]
382fn parse_path_segment(token: &str) -> Option<Constraint<'_>> {
383    if token.len() > 1 && token.starts_with('/') {
384        let segment = token.trim_start_matches('/').trim_end_matches('/');
385        if !segment.is_empty() {
386            Some(Constraint::PathSegment(segment))
387        } else {
388            None
389        }
390    } else {
391        None
392    }
393}
394
395/// Parse path segment with trailing slash: www/ -> PathSegment("www")
396/// Also supports multi-segment paths: libswscale/aarch64/ -> PathSegment("libswscale/aarch64")
397#[inline]
398fn parse_path_segment_trailing(token: &str) -> Option<Constraint<'_>> {
399    if token.len() > 1 && token.ends_with('/') {
400        let segment = token.trim_end_matches('/');
401        if !segment.is_empty() {
402            Some(Constraint::PathSegment(segment))
403        } else {
404            None
405        }
406    } else {
407        None
408    }
409}
410
411/// Parse git status filter: modified|m|untracked|u|staged|s
412#[inline]
413fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
414    if value == "*" {
415        return None;
416    }
417
418    if "modified".starts_with(value) {
419        return Some(Constraint::GitStatus(GitStatusFilter::Modified));
420    }
421
422    if "untracked".starts_with(value) {
423        return Some(Constraint::GitStatus(GitStatusFilter::Untracked));
424    }
425
426    if "staged".starts_with(value) {
427        return Some(Constraint::GitStatus(GitStatusFilter::Staged));
428    }
429
430    if "clean".starts_with(value) {
431        return Some(Constraint::GitStatus(GitStatusFilter::Unmodified));
432    }
433
434    None
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::{FileSearchConfig, GrepConfig};
441
442    #[test]
443    fn test_parse_extension() {
444        assert_eq!(parse_extension("*.rs"), Some(Constraint::Extension("rs")));
445        assert_eq!(
446            parse_extension("*.toml"),
447            Some(Constraint::Extension("toml"))
448        );
449        assert_eq!(parse_extension("*"), None);
450        assert_eq!(parse_extension("*."), None);
451    }
452
453    #[test]
454    fn test_incomplete_patterns_ignored() {
455        let config = FileSearchConfig;
456        // Incomplete patterns should return None and be treated as noise
457        assert_eq!(parse_token("*", &config), None);
458        assert_eq!(parse_token("*.", &config), None);
459    }
460
461    #[test]
462    fn test_parse_path_segment() {
463        assert_eq!(
464            parse_path_segment("/src/"),
465            Some(Constraint::PathSegment("src"))
466        );
467        assert_eq!(
468            parse_path_segment("/lib"),
469            Some(Constraint::PathSegment("lib"))
470        );
471        assert_eq!(parse_path_segment("/"), None);
472    }
473
474    #[test]
475    fn test_parse_path_segment_trailing() {
476        assert_eq!(
477            parse_path_segment_trailing("www/"),
478            Some(Constraint::PathSegment("www"))
479        );
480        assert_eq!(
481            parse_path_segment_trailing("src/"),
482            Some(Constraint::PathSegment("src"))
483        );
484        // Multi-segment paths should work
485        assert_eq!(
486            parse_path_segment_trailing("src/lib/"),
487            Some(Constraint::PathSegment("src/lib"))
488        );
489        assert_eq!(
490            parse_path_segment_trailing("libswscale/aarch64/"),
491            Some(Constraint::PathSegment("libswscale/aarch64"))
492        );
493        // Should not match without trailing slash
494        assert_eq!(parse_path_segment_trailing("www"), None);
495    }
496
497    #[test]
498    fn test_trailing_slash_in_query() {
499        let parser = QueryParser::new(FileSearchConfig);
500        let result = parser.parse("www/ test");
501        assert_eq!(result.constraints.len(), 1);
502        assert!(matches!(
503            result.constraints[0],
504            Constraint::PathSegment("www")
505        ));
506        assert!(matches!(result.fuzzy_query, FuzzyQuery::Text("test")));
507    }
508
509    #[test]
510    fn test_parse_git_status() {
511        assert_eq!(
512            parse_git_status("modified"),
513            Some(Constraint::GitStatus(GitStatusFilter::Modified))
514        );
515        assert_eq!(
516            parse_git_status("m"),
517            Some(Constraint::GitStatus(GitStatusFilter::Modified))
518        );
519        assert_eq!(
520            parse_git_status("untracked"),
521            Some(Constraint::GitStatus(GitStatusFilter::Untracked))
522        );
523        assert_eq!(parse_git_status("invalid"), None);
524    }
525
526    #[test]
527    fn test_memchr() {
528        assert_eq!(memchr(b':', b"type:rust"), Some(4));
529        assert_eq!(memchr(b':', b"nocolon"), None);
530        assert_eq!(memchr(b':', b":start"), Some(0));
531    }
532
533    #[test]
534    fn test_negation_text() {
535        let parser = QueryParser::new(FileSearchConfig);
536        // Need two tokens for parsing to return Some
537        let result = parser.parse("!test foo");
538        assert_eq!(result.constraints.len(), 1);
539        match &result.constraints[0] {
540            Constraint::Not(inner) => {
541                assert!(matches!(**inner, Constraint::Text("test")));
542            }
543            _ => panic!("Expected Not constraint"),
544        }
545    }
546
547    #[test]
548    fn test_negation_extension() {
549        let parser = QueryParser::new(FileSearchConfig);
550        let result = parser.parse("!*.rs foo");
551        assert_eq!(result.constraints.len(), 1);
552        match &result.constraints[0] {
553            Constraint::Not(inner) => {
554                assert!(matches!(**inner, Constraint::Extension("rs")));
555            }
556            _ => panic!("Expected Not(Extension) constraint"),
557        }
558    }
559
560    #[test]
561    fn test_negation_path_segment() {
562        let parser = QueryParser::new(FileSearchConfig);
563        let result = parser.parse("!/src/ foo");
564        assert_eq!(result.constraints.len(), 1);
565        match &result.constraints[0] {
566            Constraint::Not(inner) => {
567                assert!(matches!(**inner, Constraint::PathSegment("src")));
568            }
569            _ => panic!("Expected Not(PathSegment) constraint"),
570        }
571    }
572
573    #[test]
574    fn test_negation_git_status() {
575        let parser = QueryParser::new(FileSearchConfig);
576        let result = parser.parse("!status:modified foo");
577        assert_eq!(result.constraints.len(), 1);
578        match &result.constraints[0] {
579            Constraint::Not(inner) => {
580                assert!(matches!(
581                    **inner,
582                    Constraint::GitStatus(GitStatusFilter::Modified)
583                ));
584            }
585            _ => panic!("Expected Not(GitStatus) constraint"),
586        }
587    }
588
589    #[test]
590    fn test_negation_git_status_all_key_aliases() {
591        let parser = QueryParser::new(FileSearchConfig);
592        for key in ["status", "st", "g", "git"] {
593            let query = format!("!{key}:modified foo");
594            let result = parser.parse(&query);
595            assert_eq!(
596                result.constraints.len(),
597                1,
598                "!{key}:modified should produce exactly one constraint"
599            );
600            match &result.constraints[0] {
601                Constraint::Not(inner) => assert!(
602                    matches!(**inner, Constraint::GitStatus(GitStatusFilter::Modified)),
603                    "!{key}:modified expected Not(GitStatus(Modified)), got Not({inner:?})"
604                ),
605                other => {
606                    panic!("!{key}:modified expected Not(GitStatus), got {other:?}")
607                }
608            }
609        }
610    }
611
612    #[test]
613    fn test_backslash_escape_extension() {
614        let parser = QueryParser::new(FileSearchConfig);
615        let result = parser.parse("\\*.rs foo");
616        // \*.rs should NOT be parsed as an Extension constraint
617        assert_eq!(result.constraints.len(), 0);
618        // Both tokens should be text
619        match result.fuzzy_query {
620            FuzzyQuery::Parts(parts) => {
621                assert_eq!(parts.len(), 2);
622                assert_eq!(parts[0], "\\*.rs");
623                assert_eq!(parts[1], "foo");
624            }
625            _ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
626        }
627    }
628
629    #[test]
630    fn test_backslash_escape_path_segment() {
631        let parser = QueryParser::new(FileSearchConfig);
632        let result = parser.parse("\\/src/ foo");
633        assert_eq!(result.constraints.len(), 0);
634        match result.fuzzy_query {
635            FuzzyQuery::Parts(parts) => {
636                assert_eq!(parts[0], "\\/src/");
637                assert_eq!(parts[1], "foo");
638            }
639            _ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
640        }
641    }
642
643    #[test]
644    fn test_backslash_escape_negation() {
645        let parser = QueryParser::new(FileSearchConfig);
646        let result = parser.parse("\\!test foo");
647        assert_eq!(result.constraints.len(), 0);
648    }
649
650    #[test]
651    fn test_grep_text_plain_text() {
652        // Multi-token plain text — no constraints
653        let q = QueryParser::new(GrepConfig).parse("name =");
654        assert_eq!(q.grep_text(), "name =");
655    }
656
657    #[test]
658    fn test_grep_text_strips_constraint() {
659        let q = QueryParser::new(GrepConfig).parse("name = *.rs someth");
660        assert_eq!(q.grep_text(), "name = someth");
661    }
662
663    #[test]
664    fn test_grep_text_leading_constraint() {
665        let q = QueryParser::new(GrepConfig).parse("*.rs name =");
666        assert_eq!(q.grep_text(), "name =");
667    }
668
669    #[test]
670    fn test_grep_text_only_constraints() {
671        let q = QueryParser::new(GrepConfig).parse("*.rs /src/");
672        assert_eq!(q.grep_text(), "");
673    }
674
675    #[test]
676    fn test_grep_text_path_constraint() {
677        let q = QueryParser::new(GrepConfig).parse("name /src/ value");
678        assert_eq!(q.grep_text(), "name value");
679    }
680
681    #[test]
682    fn test_grep_text_negation_constraint() {
683        let q = QueryParser::new(GrepConfig).parse("name !*.rs value");
684        assert_eq!(q.grep_text(), "name value");
685    }
686
687    #[test]
688    fn test_grep_text_backslash_escape_stripped() {
689        // \*.rs should be text with the leading \ removed
690        let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
691        assert_eq!(q.grep_text(), "*.rs foo");
692
693        let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
694        assert_eq!(q.grep_text(), "/src/ foo");
695
696        let q = QueryParser::new(GrepConfig).parse("\\!test foo");
697        assert_eq!(q.grep_text(), "!test foo");
698    }
699
700    #[test]
701    fn test_grep_text_question_mark_is_text() {
702        let q = QueryParser::new(GrepConfig).parse("foo? bar");
703        assert_eq!(q.grep_text(), "foo? bar");
704    }
705
706    #[test]
707    fn test_grep_text_bracket_is_text() {
708        let q = QueryParser::new(GrepConfig).parse("arr[0] more");
709        assert_eq!(q.grep_text(), "arr[0] more");
710    }
711
712    #[test]
713    fn test_grep_text_path_glob_is_constraint() {
714        let q = QueryParser::new(GrepConfig).parse("pattern src/**/*.rs");
715        assert_eq!(q.grep_text(), "pattern");
716    }
717
718    #[test]
719    fn test_grep_question_mark_is_text() {
720        let parser = QueryParser::new(GrepConfig);
721        let result = parser.parse("foo?");
722        assert!(result.constraints.is_empty());
723        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("foo?"));
724    }
725
726    #[test]
727    fn test_grep_bracket_is_text() {
728        let parser = QueryParser::new(GrepConfig);
729        let result = parser.parse("arr[0] something");
730        // arr[0] should NOT be a glob in grep mode
731        assert_eq!(result.constraints.len(), 0);
732    }
733
734    #[test]
735    fn test_grep_path_glob_is_constraint() {
736        let parser = QueryParser::new(GrepConfig);
737        let result = parser.parse("pattern src/**/*.rs");
738        // src/**/*.rs contains / so it should be treated as a glob
739        assert_eq!(result.constraints.len(), 1);
740        assert!(matches!(
741            result.constraints[0],
742            Constraint::Glob("src/**/*.rs")
743        ));
744    }
745
746    #[test]
747    fn test_grep_brace_is_constraint() {
748        let parser = QueryParser::new(GrepConfig);
749        let result = parser.parse("pattern {src,lib}");
750        assert_eq!(result.constraints.len(), 1);
751        assert!(matches!(
752            result.constraints[0],
753            Constraint::Glob("{src,lib}")
754        ));
755    }
756
757    #[test]
758    fn test_grep_text_preserves_backslash_escapes() {
759        // Regex patterns like \w+ and \bfoo\b must survive grep_text()
760        // The parser sees \w+ as a text token (not a constraint escape),
761        // but strip_leading_backslash was stripping the \ anyway.
762        let q = QueryParser::new(GrepConfig).parse("pub struct \\w+");
763        assert_eq!(
764            q.grep_text(),
765            "pub struct \\w+",
766            "Backslash-w in regex must be preserved"
767        );
768
769        let q = QueryParser::new(GrepConfig).parse("\\bword\\b more");
770        assert_eq!(
771            q.grep_text(),
772            "\\bword\\b more",
773            "Backslash-b word boundaries must be preserved"
774        );
775
776        // Single-token regex like "fn\\s+\\w+" returns FFFQuery with Text fuzzy query
777        let result = QueryParser::new(GrepConfig).parse("fn\\s+\\w+");
778        assert!(result.constraints.is_empty());
779        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("fn\\s+\\w+"));
780
781        // But the escaped constraint forms SHOULD still be stripped:
782        let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
783        assert_eq!(
784            q.grep_text(),
785            "*.rs foo",
786            "Escaped constraint \\*.rs should still have backslash stripped"
787        );
788
789        let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
790        assert_eq!(
791            q.grep_text(),
792            "/src/ foo",
793            "Escaped constraint \\/src/ should still have backslash stripped"
794        );
795    }
796
797    #[test]
798    fn test_grep_bare_star_is_text() {
799        let parser = QueryParser::new(GrepConfig);
800        // "a*b" contains * but no / or {} — should be text in grep mode
801        let result = parser.parse("a*b something");
802        assert_eq!(
803            result.constraints.len(),
804            0,
805            "bare * without / should be text"
806        );
807    }
808
809    #[test]
810    fn test_grep_negated_text() {
811        let parser = QueryParser::new(GrepConfig);
812        let result = parser.parse("pattern !test");
813        assert_eq!(result.constraints.len(), 1);
814        match &result.constraints[0] {
815            Constraint::Not(inner) => {
816                assert!(
817                    matches!(**inner, Constraint::Text("test")),
818                    "Expected Not(Text(\"test\")), got Not({:?})",
819                    inner
820                );
821            }
822            other => panic!("Expected Not constraint, got {:?}", other),
823        }
824    }
825
826    #[test]
827    fn test_grep_negated_path_segment() {
828        let parser = QueryParser::new(GrepConfig);
829        let result = parser.parse("pattern !/src/");
830        assert_eq!(result.constraints.len(), 1);
831        match &result.constraints[0] {
832            Constraint::Not(inner) => {
833                assert!(
834                    matches!(**inner, Constraint::PathSegment("src")),
835                    "Expected Not(PathSegment(\"src\")), got Not({:?})",
836                    inner
837                );
838            }
839            other => panic!("Expected Not constraint, got {:?}", other),
840        }
841    }
842
843    #[test]
844    fn test_grep_negated_extension() {
845        let parser = QueryParser::new(GrepConfig);
846        let result = parser.parse("pattern !*.rs");
847        assert_eq!(result.constraints.len(), 1);
848        match &result.constraints[0] {
849            Constraint::Not(inner) => {
850                assert!(
851                    matches!(**inner, Constraint::Extension("rs")),
852                    "Expected Not(Extension(\"rs\")), got Not({:?})",
853                    inner
854                );
855            }
856            other => panic!("Expected Not constraint, got {:?}", other),
857        }
858    }
859
860    #[test]
861    fn test_ai_grep_detects_file_path() {
862        use crate::AiGrepConfig;
863        let parser = QueryParser::new(AiGrepConfig);
864        let result = parser.parse("libswscale/input.c rgba32ToY");
865        assert_eq!(result.constraints.len(), 1);
866        assert!(
867            matches!(
868                result.constraints[0],
869                Constraint::FilePath("libswscale/input.c")
870            ),
871            "Expected FilePath, got {:?}",
872            result.constraints[0]
873        );
874        assert_eq!(result.grep_text(), "rgba32ToY");
875    }
876
877    #[test]
878    fn test_ai_grep_detects_nested_file_path() {
879        use crate::AiGrepConfig;
880        let parser = QueryParser::new(AiGrepConfig);
881        let result = parser.parse("src/main.rs fn main");
882        assert_eq!(result.constraints.len(), 1);
883        assert!(matches!(
884            result.constraints[0],
885            Constraint::FilePath("src/main.rs")
886        ));
887        assert_eq!(result.grep_text(), "fn main");
888    }
889
890    #[test]
891    fn test_ai_grep_no_false_positive_trailing_slash() {
892        use crate::AiGrepConfig;
893        let parser = QueryParser::new(AiGrepConfig);
894        let result = parser.parse("src/ pattern");
895        // Should be PathSegment, NOT FilePath
896        assert_eq!(result.constraints.len(), 1);
897        assert!(
898            matches!(result.constraints[0], Constraint::PathSegment("src")),
899            "Expected PathSegment, got {:?}",
900            result.constraints[0]
901        );
902    }
903
904    #[test]
905    fn test_ai_grep_bare_filename_is_file_path() {
906        use crate::AiGrepConfig;
907        let parser = QueryParser::new(AiGrepConfig);
908        let result = parser.parse("main.rs pattern");
909        // Bare filename with valid extension → FilePath constraint
910        assert_eq!(result.constraints.len(), 1);
911        assert!(
912            matches!(result.constraints[0], Constraint::FilePath("main.rs")),
913            "Expected FilePath, got {:?}",
914            result.constraints[0]
915        );
916        assert_eq!(result.grep_text(), "pattern");
917    }
918
919    #[test]
920    fn test_ai_grep_bare_filename_schema_rs() {
921        use crate::AiGrepConfig;
922        let parser = QueryParser::new(AiGrepConfig);
923        let result = parser.parse("schema.rs part_revisions");
924        assert_eq!(result.constraints.len(), 1);
925        assert!(
926            matches!(result.constraints[0], Constraint::FilePath("schema.rs")),
927            "Expected FilePath(schema.rs), got {:?}",
928            result.constraints[0]
929        );
930        assert_eq!(result.grep_text(), "part_revisions");
931    }
932
933    #[test]
934    fn test_ai_grep_bare_word_no_extension_not_constraint() {
935        use crate::AiGrepConfig;
936        let parser = QueryParser::new(AiGrepConfig);
937        let result = parser.parse("schema pattern");
938        // No extension → not a file path, just text
939        assert_eq!(result.constraints.len(), 0);
940        assert_eq!(result.grep_text(), "schema pattern");
941    }
942
943    #[test]
944    fn test_ai_grep_no_false_positive_no_extension() {
945        use crate::AiGrepConfig;
946        let parser = QueryParser::new(AiGrepConfig);
947        let result = parser.parse("src/utils pattern");
948        // No extension in last component → not a file path, just text
949        assert_eq!(result.constraints.len(), 0);
950        assert_eq!(result.grep_text(), "src/utils pattern");
951    }
952
953    #[test]
954    fn test_ai_grep_wildcard_not_filepath() {
955        use crate::AiGrepConfig;
956        let parser = QueryParser::new(AiGrepConfig);
957        let result = parser.parse("src/**/*.rs pattern");
958        // Contains wildcards → should be a Glob, not FilePath
959        assert_eq!(result.constraints.len(), 1);
960        assert!(
961            matches!(result.constraints[0], Constraint::Glob("src/**/*.rs")),
962            "Expected Glob, got {:?}",
963            result.constraints[0]
964        );
965    }
966
967    #[test]
968    fn test_ai_grep_star_text_star_is_glob() {
969        use crate::AiGrepConfig;
970        let parser = QueryParser::new(AiGrepConfig);
971        let result = parser.parse("*quote* TODO");
972        // `*quote*` should be recognised as a glob constraint in AI mode
973        assert_eq!(result.constraints.len(), 1);
974        assert!(
975            matches!(result.constraints[0], Constraint::Glob("*quote*")),
976            "Expected Glob(*quote*), got {:?}",
977            result.constraints[0]
978        );
979        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("TODO"));
980    }
981
982    #[test]
983    fn test_ai_grep_bare_star_not_glob() {
984        use crate::AiGrepConfig;
985        let parser = QueryParser::new(AiGrepConfig);
986        let result = parser.parse("* pattern");
987        // Bare `*` should NOT be treated as a glob (too broad)
988        assert!(
989            result.constraints.is_empty(),
990            "Expected no constraints, got {:?}",
991            result.constraints
992        );
993    }
994
995    #[test]
996    fn test_grep_no_location_parsing_single_token() {
997        let parser = QueryParser::new(GrepConfig);
998        // localhost:8080 should NOT be parsed as location -- it's a search pattern
999        let result = parser.parse("localhost:8080");
1000        assert!(result.constraints.is_empty());
1001        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("localhost:8080"));
1002    }
1003
1004    #[test]
1005    fn test_grep_no_location_parsing_multi_token() {
1006        let q = QueryParser::new(GrepConfig).parse("*.rs localhost:8080");
1007        assert_eq!(
1008            q.grep_text(),
1009            "localhost:8080",
1010            "Colon-number suffix should be preserved in grep text"
1011        );
1012        assert!(
1013            q.location.is_none(),
1014            "Grep should not parse location from colon-number"
1015        );
1016    }
1017
1018    #[test]
1019    fn test_grep_braces_without_comma_is_text() {
1020        let parser = QueryParser::new(GrepConfig);
1021        // Code patterns like format!("{}") should NOT be treated as brace expansion
1022        let result = parser.parse(r#"format!("{}\\AppData", home)"#);
1023        assert!(
1024            result.constraints.is_empty(),
1025            "Braces without comma should be text, got {:?}",
1026            result.constraints
1027        );
1028        assert_eq!(result.grep_text(), r#"format!("{}\\AppData", home)"#);
1029    }
1030
1031    #[test]
1032    fn test_grep_format_braces_not_glob() {
1033        let parser = QueryParser::new(GrepConfig);
1034        // Code like format!("{}\\path", var) must not have tokens eaten as glob constraints.
1035        // The trailing comma on the first token means both { } and , are present,
1036        // but the comma is outside the braces so it should NOT trigger brace expansion.
1037        let input = "format!(\"{}\\\\AppData\", home)";
1038        let result = parser.parse(input);
1039        assert!(
1040            result.constraints.is_empty(),
1041            "format! pattern should have no constraints, got {:?}",
1042            result.constraints
1043        );
1044    }
1045
1046    #[test]
1047    fn test_grep_config_star_text_star_not_glob() {
1048        use crate::GrepConfig;
1049        let parser = QueryParser::new(GrepConfig);
1050        let result = parser.parse("*quote* TODO");
1051        // Regular grep mode should NOT treat `*quote*` as a glob
1052        assert!(
1053            result.constraints.is_empty(),
1054            "Expected no constraints in GrepConfig, got {:?}",
1055            result.constraints
1056        );
1057    }
1058
1059    #[test]
1060    fn test_file_picker_bare_filename_constraint() {
1061        let parser = QueryParser::new(FileSearchConfig);
1062        let result = parser.parse("score.rs file_picker");
1063        assert_eq!(result.constraints.len(), 1);
1064        assert!(
1065            matches!(result.constraints[0], Constraint::FilePath("score.rs")),
1066            "Expected FilePath(\"score.rs\"), got {:?}",
1067            result.constraints[0]
1068        );
1069        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("file_picker"));
1070    }
1071
1072    #[test]
1073    fn test_file_picker_path_prefixed_filename_constraint() {
1074        let parser = QueryParser::new(FileSearchConfig);
1075        let result = parser.parse("libswscale/slice.c lum_convert");
1076        assert_eq!(result.constraints.len(), 1);
1077        assert!(
1078            matches!(
1079                result.constraints[0],
1080                Constraint::FilePath("libswscale/slice.c")
1081            ),
1082            "Expected FilePath(\"libswscale/slice.c\"), got {:?}",
1083            result.constraints[0]
1084        );
1085        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("lum_convert"));
1086    }
1087
1088    #[test]
1089    fn test_file_picker_single_token_filename_stays_fuzzy() {
1090        let parser = QueryParser::new(FileSearchConfig);
1091        // Single-token filename should NOT become a constraint -- it should
1092        // return FFFQuery with Text fuzzy query so the caller uses it for fuzzy matching.
1093        let result = parser.parse("score.rs");
1094        assert!(result.constraints.is_empty());
1095        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
1096    }
1097
1098    #[test]
1099    fn test_absolute_path_with_location_not_path_segment() {
1100        let parser = QueryParser::new(FileSearchConfig);
1101        // Absolute file path with :line should parse as text + location,
1102        // NOT as a PathSegment constraint (which would eat the whole token).
1103        let result = parser.parse("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs:12");
1104        assert!(
1105            result.constraints.is_empty(),
1106            "Absolute path with location should not become a constraint, got {:?}",
1107            result.constraints
1108        );
1109        assert_eq!(
1110            result.fuzzy_query,
1111            FuzzyQuery::Text("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs")
1112        );
1113        assert_eq!(result.location, Some(Location::Line(12)));
1114    }
1115
1116    #[test]
1117    fn test_file_picker_filename_with_multiple_fuzzy_parts() {
1118        let parser = QueryParser::new(FileSearchConfig);
1119        let result = parser.parse("main.rs src components");
1120        assert_eq!(result.constraints.len(), 1);
1121        assert!(matches!(
1122            result.constraints[0],
1123            Constraint::FilePath("main.rs")
1124        ));
1125        assert_eq!(
1126            result.fuzzy_query,
1127            FuzzyQuery::Parts(vec!["src", "components"])
1128        );
1129    }
1130
1131    #[test]
1132    fn test_file_picker_version_number_not_filename() {
1133        let parser = QueryParser::new(FileSearchConfig);
1134        let result = parser.parse("v2.0 release");
1135        // v2.0 extension starts with digit → not a filename constraint
1136        assert!(
1137            result.constraints.is_empty(),
1138            "v2.0 should not be a FilePath constraint, got {:?}",
1139            result.constraints
1140        );
1141    }
1142
1143    #[test]
1144    fn test_file_picker_only_one_filepath_constraint() {
1145        let parser = QueryParser::new(FileSearchConfig);
1146        let result = parser.parse("main.rs score.rs");
1147        // Only first filename becomes a constraint; second is text
1148        assert_eq!(result.constraints.len(), 1);
1149        assert!(matches!(
1150            result.constraints[0],
1151            Constraint::FilePath("main.rs")
1152        ));
1153        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
1154    }
1155
1156    #[test]
1157    fn test_file_picker_filename_with_extension_constraint() {
1158        let parser = QueryParser::new(FileSearchConfig);
1159        let result = parser.parse("main.rs *.lua");
1160        // main.rs → FilePath, *.lua → Extension
1161        assert_eq!(result.constraints.len(), 2);
1162        assert!(matches!(
1163            result.constraints[0],
1164            Constraint::FilePath("main.rs")
1165        ));
1166        assert!(matches!(
1167            result.constraints[1],
1168            Constraint::Extension("lua")
1169        ));
1170    }
1171
1172    #[test]
1173    fn test_file_picker_dotfile_is_filename() {
1174        let parser = QueryParser::new(FileSearchConfig);
1175        let result = parser.parse(".gitignore src");
1176        assert_eq!(result.constraints.len(), 1);
1177        assert!(
1178            matches!(result.constraints[0], Constraint::FilePath(".gitignore")),
1179            "Expected FilePath(\".gitignore\"), got {:?}",
1180            result.constraints[0]
1181        );
1182        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("src"));
1183    }
1184
1185    #[test]
1186    fn test_file_picker_no_extension_not_filename() {
1187        let parser = QueryParser::new(FileSearchConfig);
1188        let result = parser.parse("Makefile src");
1189        // No dot → not a filename constraint
1190        assert!(
1191            result.constraints.is_empty(),
1192            "Makefile should not be a FilePath constraint, got {:?}",
1193            result.constraints
1194        );
1195    }
1196}