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