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