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