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