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