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" | "gi" | "g" | "st" 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_backslash_escape_extension() {
591        let parser = QueryParser::new(FileSearchConfig);
592        let result = parser.parse("\\*.rs foo");
593        // \*.rs should NOT be parsed as an Extension constraint
594        assert_eq!(result.constraints.len(), 0);
595        // Both tokens should be text
596        match result.fuzzy_query {
597            FuzzyQuery::Parts(parts) => {
598                assert_eq!(parts.len(), 2);
599                assert_eq!(parts[0], "\\*.rs");
600                assert_eq!(parts[1], "foo");
601            }
602            _ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
603        }
604    }
605
606    #[test]
607    fn test_backslash_escape_path_segment() {
608        let parser = QueryParser::new(FileSearchConfig);
609        let result = parser.parse("\\/src/ foo");
610        assert_eq!(result.constraints.len(), 0);
611        match result.fuzzy_query {
612            FuzzyQuery::Parts(parts) => {
613                assert_eq!(parts[0], "\\/src/");
614                assert_eq!(parts[1], "foo");
615            }
616            _ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
617        }
618    }
619
620    #[test]
621    fn test_backslash_escape_negation() {
622        let parser = QueryParser::new(FileSearchConfig);
623        let result = parser.parse("\\!test foo");
624        assert_eq!(result.constraints.len(), 0);
625    }
626
627    #[test]
628    fn test_grep_text_plain_text() {
629        // Multi-token plain text — no constraints
630        let q = QueryParser::new(GrepConfig).parse("name =");
631        assert_eq!(q.grep_text(), "name =");
632    }
633
634    #[test]
635    fn test_grep_text_strips_constraint() {
636        let q = QueryParser::new(GrepConfig).parse("name = *.rs someth");
637        assert_eq!(q.grep_text(), "name = someth");
638    }
639
640    #[test]
641    fn test_grep_text_leading_constraint() {
642        let q = QueryParser::new(GrepConfig).parse("*.rs name =");
643        assert_eq!(q.grep_text(), "name =");
644    }
645
646    #[test]
647    fn test_grep_text_only_constraints() {
648        let q = QueryParser::new(GrepConfig).parse("*.rs /src/");
649        assert_eq!(q.grep_text(), "");
650    }
651
652    #[test]
653    fn test_grep_text_path_constraint() {
654        let q = QueryParser::new(GrepConfig).parse("name /src/ value");
655        assert_eq!(q.grep_text(), "name value");
656    }
657
658    #[test]
659    fn test_grep_text_negation_constraint() {
660        let q = QueryParser::new(GrepConfig).parse("name !*.rs value");
661        assert_eq!(q.grep_text(), "name value");
662    }
663
664    #[test]
665    fn test_grep_text_backslash_escape_stripped() {
666        // \*.rs should be text with the leading \ removed
667        let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
668        assert_eq!(q.grep_text(), "*.rs foo");
669
670        let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
671        assert_eq!(q.grep_text(), "/src/ foo");
672
673        let q = QueryParser::new(GrepConfig).parse("\\!test foo");
674        assert_eq!(q.grep_text(), "!test foo");
675    }
676
677    #[test]
678    fn test_grep_text_question_mark_is_text() {
679        let q = QueryParser::new(GrepConfig).parse("foo? bar");
680        assert_eq!(q.grep_text(), "foo? bar");
681    }
682
683    #[test]
684    fn test_grep_text_bracket_is_text() {
685        let q = QueryParser::new(GrepConfig).parse("arr[0] more");
686        assert_eq!(q.grep_text(), "arr[0] more");
687    }
688
689    #[test]
690    fn test_grep_text_path_glob_is_constraint() {
691        let q = QueryParser::new(GrepConfig).parse("pattern src/**/*.rs");
692        assert_eq!(q.grep_text(), "pattern");
693    }
694
695    #[test]
696    fn test_grep_question_mark_is_text() {
697        let parser = QueryParser::new(GrepConfig);
698        let result = parser.parse("foo?");
699        assert!(result.constraints.is_empty());
700        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("foo?"));
701    }
702
703    #[test]
704    fn test_grep_bracket_is_text() {
705        let parser = QueryParser::new(GrepConfig);
706        let result = parser.parse("arr[0] something");
707        // arr[0] should NOT be a glob in grep mode
708        assert_eq!(result.constraints.len(), 0);
709    }
710
711    #[test]
712    fn test_grep_path_glob_is_constraint() {
713        let parser = QueryParser::new(GrepConfig);
714        let result = parser.parse("pattern src/**/*.rs");
715        // src/**/*.rs contains / so it should be treated as a glob
716        assert_eq!(result.constraints.len(), 1);
717        assert!(matches!(
718            result.constraints[0],
719            Constraint::Glob("src/**/*.rs")
720        ));
721    }
722
723    #[test]
724    fn test_grep_brace_is_constraint() {
725        let parser = QueryParser::new(GrepConfig);
726        let result = parser.parse("pattern {src,lib}");
727        assert_eq!(result.constraints.len(), 1);
728        assert!(matches!(
729            result.constraints[0],
730            Constraint::Glob("{src,lib}")
731        ));
732    }
733
734    #[test]
735    fn test_grep_text_preserves_backslash_escapes() {
736        // Regex patterns like \w+ and \bfoo\b must survive grep_text()
737        // The parser sees \w+ as a text token (not a constraint escape),
738        // but strip_leading_backslash was stripping the \ anyway.
739        let q = QueryParser::new(GrepConfig).parse("pub struct \\w+");
740        assert_eq!(
741            q.grep_text(),
742            "pub struct \\w+",
743            "Backslash-w in regex must be preserved"
744        );
745
746        let q = QueryParser::new(GrepConfig).parse("\\bword\\b more");
747        assert_eq!(
748            q.grep_text(),
749            "\\bword\\b more",
750            "Backslash-b word boundaries must be preserved"
751        );
752
753        // Single-token regex like "fn\\s+\\w+" returns FFFQuery with Text fuzzy query
754        let result = QueryParser::new(GrepConfig).parse("fn\\s+\\w+");
755        assert!(result.constraints.is_empty());
756        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("fn\\s+\\w+"));
757
758        // But the escaped constraint forms SHOULD still be stripped:
759        let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
760        assert_eq!(
761            q.grep_text(),
762            "*.rs foo",
763            "Escaped constraint \\*.rs should still have backslash stripped"
764        );
765
766        let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
767        assert_eq!(
768            q.grep_text(),
769            "/src/ foo",
770            "Escaped constraint \\/src/ should still have backslash stripped"
771        );
772    }
773
774    #[test]
775    fn test_grep_bare_star_is_text() {
776        let parser = QueryParser::new(GrepConfig);
777        // "a*b" contains * but no / or {} — should be text in grep mode
778        let result = parser.parse("a*b something");
779        assert_eq!(
780            result.constraints.len(),
781            0,
782            "bare * without / should be text"
783        );
784    }
785
786    #[test]
787    fn test_grep_negated_text() {
788        let parser = QueryParser::new(GrepConfig);
789        let result = parser.parse("pattern !test");
790        assert_eq!(result.constraints.len(), 1);
791        match &result.constraints[0] {
792            Constraint::Not(inner) => {
793                assert!(
794                    matches!(**inner, Constraint::Text("test")),
795                    "Expected Not(Text(\"test\")), got Not({:?})",
796                    inner
797                );
798            }
799            other => panic!("Expected Not constraint, got {:?}", other),
800        }
801    }
802
803    #[test]
804    fn test_grep_negated_path_segment() {
805        let parser = QueryParser::new(GrepConfig);
806        let result = parser.parse("pattern !/src/");
807        assert_eq!(result.constraints.len(), 1);
808        match &result.constraints[0] {
809            Constraint::Not(inner) => {
810                assert!(
811                    matches!(**inner, Constraint::PathSegment("src")),
812                    "Expected Not(PathSegment(\"src\")), got Not({:?})",
813                    inner
814                );
815            }
816            other => panic!("Expected Not constraint, got {:?}", other),
817        }
818    }
819
820    #[test]
821    fn test_grep_negated_extension() {
822        let parser = QueryParser::new(GrepConfig);
823        let result = parser.parse("pattern !*.rs");
824        assert_eq!(result.constraints.len(), 1);
825        match &result.constraints[0] {
826            Constraint::Not(inner) => {
827                assert!(
828                    matches!(**inner, Constraint::Extension("rs")),
829                    "Expected Not(Extension(\"rs\")), got Not({:?})",
830                    inner
831                );
832            }
833            other => panic!("Expected Not constraint, got {:?}", other),
834        }
835    }
836
837    #[test]
838    fn test_ai_grep_detects_file_path() {
839        use crate::AiGrepConfig;
840        let parser = QueryParser::new(AiGrepConfig);
841        let result = parser.parse("libswscale/input.c rgba32ToY");
842        assert_eq!(result.constraints.len(), 1);
843        assert!(
844            matches!(
845                result.constraints[0],
846                Constraint::FilePath("libswscale/input.c")
847            ),
848            "Expected FilePath, got {:?}",
849            result.constraints[0]
850        );
851        assert_eq!(result.grep_text(), "rgba32ToY");
852    }
853
854    #[test]
855    fn test_ai_grep_detects_nested_file_path() {
856        use crate::AiGrepConfig;
857        let parser = QueryParser::new(AiGrepConfig);
858        let result = parser.parse("src/main.rs fn main");
859        assert_eq!(result.constraints.len(), 1);
860        assert!(matches!(
861            result.constraints[0],
862            Constraint::FilePath("src/main.rs")
863        ));
864        assert_eq!(result.grep_text(), "fn main");
865    }
866
867    #[test]
868    fn test_ai_grep_no_false_positive_trailing_slash() {
869        use crate::AiGrepConfig;
870        let parser = QueryParser::new(AiGrepConfig);
871        let result = parser.parse("src/ pattern");
872        // Should be PathSegment, NOT FilePath
873        assert_eq!(result.constraints.len(), 1);
874        assert!(
875            matches!(result.constraints[0], Constraint::PathSegment("src")),
876            "Expected PathSegment, got {:?}",
877            result.constraints[0]
878        );
879    }
880
881    #[test]
882    fn test_ai_grep_bare_filename_is_file_path() {
883        use crate::AiGrepConfig;
884        let parser = QueryParser::new(AiGrepConfig);
885        let result = parser.parse("main.rs pattern");
886        // Bare filename with valid extension → FilePath constraint
887        assert_eq!(result.constraints.len(), 1);
888        assert!(
889            matches!(result.constraints[0], Constraint::FilePath("main.rs")),
890            "Expected FilePath, got {:?}",
891            result.constraints[0]
892        );
893        assert_eq!(result.grep_text(), "pattern");
894    }
895
896    #[test]
897    fn test_ai_grep_bare_filename_schema_rs() {
898        use crate::AiGrepConfig;
899        let parser = QueryParser::new(AiGrepConfig);
900        let result = parser.parse("schema.rs part_revisions");
901        assert_eq!(result.constraints.len(), 1);
902        assert!(
903            matches!(result.constraints[0], Constraint::FilePath("schema.rs")),
904            "Expected FilePath(schema.rs), got {:?}",
905            result.constraints[0]
906        );
907        assert_eq!(result.grep_text(), "part_revisions");
908    }
909
910    #[test]
911    fn test_ai_grep_bare_word_no_extension_not_constraint() {
912        use crate::AiGrepConfig;
913        let parser = QueryParser::new(AiGrepConfig);
914        let result = parser.parse("schema pattern");
915        // No extension → not a file path, just text
916        assert_eq!(result.constraints.len(), 0);
917        assert_eq!(result.grep_text(), "schema pattern");
918    }
919
920    #[test]
921    fn test_ai_grep_no_false_positive_no_extension() {
922        use crate::AiGrepConfig;
923        let parser = QueryParser::new(AiGrepConfig);
924        let result = parser.parse("src/utils pattern");
925        // No extension in last component → not a file path, just text
926        assert_eq!(result.constraints.len(), 0);
927        assert_eq!(result.grep_text(), "src/utils pattern");
928    }
929
930    #[test]
931    fn test_ai_grep_wildcard_not_filepath() {
932        use crate::AiGrepConfig;
933        let parser = QueryParser::new(AiGrepConfig);
934        let result = parser.parse("src/**/*.rs pattern");
935        // Contains wildcards → should be a Glob, not FilePath
936        assert_eq!(result.constraints.len(), 1);
937        assert!(
938            matches!(result.constraints[0], Constraint::Glob("src/**/*.rs")),
939            "Expected Glob, got {:?}",
940            result.constraints[0]
941        );
942    }
943
944    #[test]
945    fn test_ai_grep_star_text_star_is_glob() {
946        use crate::AiGrepConfig;
947        let parser = QueryParser::new(AiGrepConfig);
948        let result = parser.parse("*quote* TODO");
949        // `*quote*` should be recognised as a glob constraint in AI mode
950        assert_eq!(result.constraints.len(), 1);
951        assert!(
952            matches!(result.constraints[0], Constraint::Glob("*quote*")),
953            "Expected Glob(*quote*), got {:?}",
954            result.constraints[0]
955        );
956        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("TODO"));
957    }
958
959    #[test]
960    fn test_ai_grep_bare_star_not_glob() {
961        use crate::AiGrepConfig;
962        let parser = QueryParser::new(AiGrepConfig);
963        let result = parser.parse("* pattern");
964        // Bare `*` should NOT be treated as a glob (too broad)
965        assert!(
966            result.constraints.is_empty(),
967            "Expected no constraints, got {:?}",
968            result.constraints
969        );
970    }
971
972    #[test]
973    fn test_grep_no_location_parsing_single_token() {
974        let parser = QueryParser::new(GrepConfig);
975        // localhost:8080 should NOT be parsed as location -- it's a search pattern
976        let result = parser.parse("localhost:8080");
977        assert!(result.constraints.is_empty());
978        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("localhost:8080"));
979    }
980
981    #[test]
982    fn test_grep_no_location_parsing_multi_token() {
983        let q = QueryParser::new(GrepConfig).parse("*.rs localhost:8080");
984        assert_eq!(
985            q.grep_text(),
986            "localhost:8080",
987            "Colon-number suffix should be preserved in grep text"
988        );
989        assert!(
990            q.location.is_none(),
991            "Grep should not parse location from colon-number"
992        );
993    }
994
995    #[test]
996    fn test_grep_braces_without_comma_is_text() {
997        let parser = QueryParser::new(GrepConfig);
998        // Code patterns like format!("{}") should NOT be treated as brace expansion
999        let result = parser.parse(r#"format!("{}\\AppData", home)"#);
1000        assert!(
1001            result.constraints.is_empty(),
1002            "Braces without comma should be text, got {:?}",
1003            result.constraints
1004        );
1005        assert_eq!(result.grep_text(), r#"format!("{}\\AppData", home)"#);
1006    }
1007
1008    #[test]
1009    fn test_grep_format_braces_not_glob() {
1010        let parser = QueryParser::new(GrepConfig);
1011        // Code like format!("{}\\path", var) must not have tokens eaten as glob constraints.
1012        // The trailing comma on the first token means both { } and , are present,
1013        // but the comma is outside the braces so it should NOT trigger brace expansion.
1014        let input = "format!(\"{}\\\\AppData\", home)";
1015        let result = parser.parse(input);
1016        assert!(
1017            result.constraints.is_empty(),
1018            "format! pattern should have no constraints, got {:?}",
1019            result.constraints
1020        );
1021    }
1022
1023    #[test]
1024    fn test_grep_config_star_text_star_not_glob() {
1025        use crate::GrepConfig;
1026        let parser = QueryParser::new(GrepConfig);
1027        let result = parser.parse("*quote* TODO");
1028        // Regular grep mode should NOT treat `*quote*` as a glob
1029        assert!(
1030            result.constraints.is_empty(),
1031            "Expected no constraints in GrepConfig, got {:?}",
1032            result.constraints
1033        );
1034    }
1035
1036    #[test]
1037    fn test_file_picker_bare_filename_constraint() {
1038        let parser = QueryParser::new(FileSearchConfig);
1039        let result = parser.parse("score.rs file_picker");
1040        assert_eq!(result.constraints.len(), 1);
1041        assert!(
1042            matches!(result.constraints[0], Constraint::FilePath("score.rs")),
1043            "Expected FilePath(\"score.rs\"), got {:?}",
1044            result.constraints[0]
1045        );
1046        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("file_picker"));
1047    }
1048
1049    #[test]
1050    fn test_file_picker_path_prefixed_filename_constraint() {
1051        let parser = QueryParser::new(FileSearchConfig);
1052        let result = parser.parse("libswscale/slice.c lum_convert");
1053        assert_eq!(result.constraints.len(), 1);
1054        assert!(
1055            matches!(
1056                result.constraints[0],
1057                Constraint::FilePath("libswscale/slice.c")
1058            ),
1059            "Expected FilePath(\"libswscale/slice.c\"), got {:?}",
1060            result.constraints[0]
1061        );
1062        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("lum_convert"));
1063    }
1064
1065    #[test]
1066    fn test_file_picker_single_token_filename_stays_fuzzy() {
1067        let parser = QueryParser::new(FileSearchConfig);
1068        // Single-token filename should NOT become a constraint -- it should
1069        // return FFFQuery with Text fuzzy query so the caller uses it for fuzzy matching.
1070        let result = parser.parse("score.rs");
1071        assert!(result.constraints.is_empty());
1072        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
1073    }
1074
1075    #[test]
1076    fn test_absolute_path_with_location_not_path_segment() {
1077        let parser = QueryParser::new(FileSearchConfig);
1078        // Absolute file path with :line should parse as text + location,
1079        // NOT as a PathSegment constraint (which would eat the whole token).
1080        let result = parser.parse("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs:12");
1081        assert!(
1082            result.constraints.is_empty(),
1083            "Absolute path with location should not become a constraint, got {:?}",
1084            result.constraints
1085        );
1086        assert_eq!(
1087            result.fuzzy_query,
1088            FuzzyQuery::Text("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs")
1089        );
1090        assert_eq!(result.location, Some(Location::Line(12)));
1091    }
1092
1093    #[test]
1094    fn test_file_picker_filename_with_multiple_fuzzy_parts() {
1095        let parser = QueryParser::new(FileSearchConfig);
1096        let result = parser.parse("main.rs src components");
1097        assert_eq!(result.constraints.len(), 1);
1098        assert!(matches!(
1099            result.constraints[0],
1100            Constraint::FilePath("main.rs")
1101        ));
1102        assert_eq!(
1103            result.fuzzy_query,
1104            FuzzyQuery::Parts(vec!["src", "components"])
1105        );
1106    }
1107
1108    #[test]
1109    fn test_file_picker_version_number_not_filename() {
1110        let parser = QueryParser::new(FileSearchConfig);
1111        let result = parser.parse("v2.0 release");
1112        // v2.0 extension starts with digit → not a filename constraint
1113        assert!(
1114            result.constraints.is_empty(),
1115            "v2.0 should not be a FilePath constraint, got {:?}",
1116            result.constraints
1117        );
1118    }
1119
1120    #[test]
1121    fn test_file_picker_only_one_filepath_constraint() {
1122        let parser = QueryParser::new(FileSearchConfig);
1123        let result = parser.parse("main.rs score.rs");
1124        // Only first filename becomes a constraint; second is text
1125        assert_eq!(result.constraints.len(), 1);
1126        assert!(matches!(
1127            result.constraints[0],
1128            Constraint::FilePath("main.rs")
1129        ));
1130        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
1131    }
1132
1133    #[test]
1134    fn test_file_picker_filename_with_extension_constraint() {
1135        let parser = QueryParser::new(FileSearchConfig);
1136        let result = parser.parse("main.rs *.lua");
1137        // main.rs → FilePath, *.lua → Extension
1138        assert_eq!(result.constraints.len(), 2);
1139        assert!(matches!(
1140            result.constraints[0],
1141            Constraint::FilePath("main.rs")
1142        ));
1143        assert!(matches!(
1144            result.constraints[1],
1145            Constraint::Extension("lua")
1146        ));
1147    }
1148
1149    #[test]
1150    fn test_file_picker_dotfile_is_filename() {
1151        let parser = QueryParser::new(FileSearchConfig);
1152        let result = parser.parse(".gitignore src");
1153        assert_eq!(result.constraints.len(), 1);
1154        assert!(
1155            matches!(result.constraints[0], Constraint::FilePath(".gitignore")),
1156            "Expected FilePath(\".gitignore\"), got {:?}",
1157            result.constraints[0]
1158        );
1159        assert_eq!(result.fuzzy_query, FuzzyQuery::Text("src"));
1160    }
1161
1162    #[test]
1163    fn test_file_picker_no_extension_not_filename() {
1164        let parser = QueryParser::new(FileSearchConfig);
1165        let result = parser.parse("Makefile src");
1166        // No dot → not a filename constraint
1167        assert!(
1168            result.constraints.is_empty(),
1169            "Makefile should not be a FilePath constraint, got {:?}",
1170            result.constraints
1171        );
1172    }
1173}