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