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 pub raw_query: &'a str,
19 pub constraints: ConstraintVec<'a>,
21 pub fuzzy_query: FuzzyQuery<'a>,
22 pub location: Option<Location>,
24}
25
26#[derive(Debug)]
28pub struct QueryParser<C: ParserConfig> {
29 config: C,
30}
31
32impl<C: ParserConfig> QueryParser<C> {
33 pub fn new(config: C) -> Self {
34 Self { config }
35 }
36
37 pub fn parse<'a>(&self, query: &'a str) -> FFFQuery<'a> {
38 let raw_query = query;
39 let config: &C = &self.config;
40 let mut constraints = ConstraintVec::new();
41 let query = query.trim();
42
43 let whitespace_count = query.chars().filter(|c| c.is_whitespace()).count();
44
45 if whitespace_count == 0 {
47 if let Some(constraint) = parse_token(query, config) {
49 let has_location_suffix = matches!(constraint, Constraint::PathSegment(_))
58 && query.bytes().any(|b| b == b':')
59 && query
60 .bytes()
61 .rev()
62 .take_while(|&b| b != b':')
63 .all(|b| b.is_ascii_digit());
64 if !matches!(constraint, Constraint::FilePath(_)) && !has_location_suffix {
65 constraints.push(constraint);
66 return FFFQuery {
67 raw_query,
68 constraints,
69 fuzzy_query: FuzzyQuery::Empty,
70 location: None,
71 };
72 }
73 }
74
75 if config.enable_location() {
77 let (query_without_loc, location) = parse_location(query);
78 if location.is_some() {
79 return FFFQuery {
80 raw_query,
81 constraints,
82 fuzzy_query: FuzzyQuery::Text(query_without_loc),
83 location,
84 };
85 }
86 }
87
88 return FFFQuery {
90 raw_query,
91 constraints,
92 fuzzy_query: if query.is_empty() {
93 FuzzyQuery::Empty
94 } else {
95 FuzzyQuery::Text(query)
96 },
97 location: None,
98 };
99 }
100
101 let mut text_parts = TextPartsBuffer::new();
102 let tokens = query.split_whitespace();
103
104 let mut has_file_path = false;
105 for token in tokens {
106 match parse_token(token, config) {
107 Some(Constraint::FilePath(_)) => {
108 if has_file_path {
109 text_parts.push(token);
113 } else {
114 constraints.push(Constraint::FilePath(token));
115 has_file_path = true;
116 }
117 }
118 Some(constraint) => {
119 constraints.push(constraint);
120 }
121 None => {
122 text_parts.push(token);
123 }
124 }
125 }
126
127 let location = if config.enable_location() && !text_parts.is_empty() {
130 let last_idx = text_parts.len() - 1;
131 let (without_loc, loc) = parse_location(text_parts[last_idx]);
132 if loc.is_some() {
133 text_parts[last_idx] = without_loc;
135 loc
136 } else {
137 None
138 }
139 } else {
140 None
141 };
142
143 let fuzzy_query = if text_parts.is_empty() {
144 FuzzyQuery::Empty
145 } else if text_parts.len() == 1 {
146 if text_parts[0].is_empty() {
148 FuzzyQuery::Empty
149 } else {
150 FuzzyQuery::Text(text_parts[0])
151 }
152 } else {
153 if text_parts.iter().all(|p| p.is_empty()) {
155 FuzzyQuery::Empty
156 } else {
157 FuzzyQuery::Parts(text_parts)
158 }
159 };
160
161 FFFQuery {
162 raw_query,
163 constraints,
164 fuzzy_query,
165 location,
166 }
167 }
168}
169
170impl<'a> FFFQuery<'a> {
171 pub fn grep_text(&self) -> String {
181 match &self.fuzzy_query {
182 FuzzyQuery::Empty => String::new(),
183 FuzzyQuery::Text(t) => strip_leading_backslash(t).to_string(),
184 FuzzyQuery::Parts(parts) => parts
185 .iter()
186 .map(|t| strip_leading_backslash(t))
187 .collect::<Vec<_>>()
188 .join(" "),
189 }
190 }
191}
192
193#[inline]
200fn strip_leading_backslash(token: &str) -> &str {
201 if token.len() > 1 && token.starts_with('\\') {
202 let next = token.as_bytes()[1];
203 if next == b'*' || next == b'/' || next == b'!' {
205 return &token[1..];
206 }
207 }
208 token
209}
210
211impl Default for QueryParser<crate::FilePickerConfig> {
212 fn default() -> Self {
213 Self::new(crate::FilePickerConfig)
214 }
215}
216
217#[inline]
218fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constraint<'a>> {
219 if token.starts_with('\\') && token.len() > 1 {
222 return None;
223 }
224
225 let first_byte = token.as_bytes().first()?;
226
227 match first_byte {
228 b'*' if config.enable_extension() => {
229 if token == "*" || token == "*." {
231 return None;
232 }
233
234 if let Some(constraint) = parse_extension(token) {
236 let ext_part = &token[2..];
239 if !has_wildcards(ext_part) {
240 return Some(constraint);
241 }
242 }
243 if config.enable_glob() && config.is_glob_pattern(token) {
245 return Some(Constraint::Glob(token));
246 }
247 None
248 }
249 b'!' if config.enable_exclude() => parse_negation(token, config),
250 b'/' if config.enable_path_segments() => parse_path_segment(token),
251 _ if config.enable_path_segments() && token.ends_with('/') => {
252 parse_path_segment_trailing(token)
254 }
255 _ => {
256 if config.enable_glob() && config.is_glob_pattern(token) {
258 return Some(Constraint::Glob(token));
259 }
260
261 if let Some(colon_idx) = memchr(b':', token.as_bytes()) {
263 let (key, value_with_colon) = token.split_at(colon_idx);
264 let value = &value_with_colon[1..]; match key {
267 "type" if config.enable_type_filter() => {
268 return Some(Constraint::FileType(value));
269 }
270 "status" | "st" | "g" | "git" if config.enable_git_status() => {
271 return parse_git_status(value);
272 }
273 _ => {}
274 }
275 }
276
277 config.parse_custom(token)
279 }
280 }
281}
282
283#[inline]
285fn memchr(needle: u8, haystack: &[u8]) -> Option<usize> {
286 haystack.iter().position(|&b| b == needle)
287}
288
289#[inline]
291fn parse_extension(token: &str) -> Option<Constraint<'_>> {
292 if token.len() > 2 && token.starts_with("*.") {
293 Some(Constraint::Extension(&token[2..]))
294 } else {
295 None
296 }
297}
298
299#[inline]
302fn parse_negation<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constraint<'a>> {
303 if token.len() <= 1 {
304 return None;
305 }
306
307 let inner_token = &token[1..];
308
309 if let Some(inner_constraint) = parse_token_without_negation(inner_token, config) {
311 return Some(Constraint::Not(Box::new(inner_constraint)));
313 }
314
315 Some(Constraint::Not(Box::new(Constraint::Text(inner_token))))
318}
319
320#[inline]
322fn parse_token_without_negation<'a, C: ParserConfig>(
323 token: &'a str,
324 config: &C,
325) -> Option<Constraint<'a>> {
326 if token.starts_with('\\') && token.len() > 1 {
328 return None;
329 }
330
331 let first_byte = token.as_bytes().first()?;
332
333 match first_byte {
334 b'*' if config.enable_extension() => {
335 if let Some(constraint) = parse_extension(token) {
337 let ext_part = &token[2..];
338 if !has_wildcards(ext_part) {
339 return Some(constraint);
340 }
341 }
342 if config.enable_glob() && config.is_glob_pattern(token) {
344 return Some(Constraint::Glob(token));
345 }
346 None
347 }
348 b'/' if config.enable_path_segments() => parse_path_segment(token),
349 _ if config.enable_path_segments() && token.ends_with('/') => {
350 parse_path_segment_trailing(token)
352 }
353 _ => {
354 if config.enable_glob() && config.is_glob_pattern(token) {
356 return Some(Constraint::Glob(token));
357 }
358
359 if let Some(colon_idx) = memchr(b':', token.as_bytes()) {
361 let (key, value_with_colon) = token.split_at(colon_idx);
362 let value = &value_with_colon[1..]; match key {
365 "type" if config.enable_type_filter() => {
366 return Some(Constraint::FileType(value));
367 }
368 "status" | "gi" | "g" | "st" if config.enable_git_status() => {
369 return parse_git_status(value);
370 }
371 _ => {}
372 }
373 }
374
375 config.parse_custom(token)
376 }
377 }
378}
379
380#[inline]
382fn parse_path_segment(token: &str) -> Option<Constraint<'_>> {
383 if token.len() > 1 && token.starts_with('/') {
384 let segment = token.trim_start_matches('/').trim_end_matches('/');
385 if !segment.is_empty() {
386 Some(Constraint::PathSegment(segment))
387 } else {
388 None
389 }
390 } else {
391 None
392 }
393}
394
395#[inline]
398fn parse_path_segment_trailing(token: &str) -> Option<Constraint<'_>> {
399 if token.len() > 1 && token.ends_with('/') {
400 let segment = token.trim_end_matches('/');
401 if !segment.is_empty() {
402 Some(Constraint::PathSegment(segment))
403 } else {
404 None
405 }
406 } else {
407 None
408 }
409}
410
411#[inline]
413fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
414 if value == "*" {
415 return None;
416 }
417
418 if "modified".starts_with(value) {
419 return Some(Constraint::GitStatus(GitStatusFilter::Modified));
420 }
421
422 if "untracked".starts_with(value) {
423 return Some(Constraint::GitStatus(GitStatusFilter::Untracked));
424 }
425
426 if "staged".starts_with(value) {
427 return Some(Constraint::GitStatus(GitStatusFilter::Staged));
428 }
429
430 if "clean".starts_with(value) {
431 return Some(Constraint::GitStatus(GitStatusFilter::Unmodified));
432 }
433
434 None
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::{FilePickerConfig, GrepConfig};
441
442 #[test]
443 fn test_parse_extension() {
444 assert_eq!(parse_extension("*.rs"), Some(Constraint::Extension("rs")));
445 assert_eq!(
446 parse_extension("*.toml"),
447 Some(Constraint::Extension("toml"))
448 );
449 assert_eq!(parse_extension("*"), None);
450 assert_eq!(parse_extension("*."), None);
451 }
452
453 #[test]
454 fn test_incomplete_patterns_ignored() {
455 let config = FilePickerConfig;
456 assert_eq!(parse_token("*", &config), None);
458 assert_eq!(parse_token("*.", &config), None);
459 }
460
461 #[test]
462 fn test_parse_path_segment() {
463 assert_eq!(
464 parse_path_segment("/src/"),
465 Some(Constraint::PathSegment("src"))
466 );
467 assert_eq!(
468 parse_path_segment("/lib"),
469 Some(Constraint::PathSegment("lib"))
470 );
471 assert_eq!(parse_path_segment("/"), None);
472 }
473
474 #[test]
475 fn test_parse_path_segment_trailing() {
476 assert_eq!(
477 parse_path_segment_trailing("www/"),
478 Some(Constraint::PathSegment("www"))
479 );
480 assert_eq!(
481 parse_path_segment_trailing("src/"),
482 Some(Constraint::PathSegment("src"))
483 );
484 assert_eq!(
486 parse_path_segment_trailing("src/lib/"),
487 Some(Constraint::PathSegment("src/lib"))
488 );
489 assert_eq!(
490 parse_path_segment_trailing("libswscale/aarch64/"),
491 Some(Constraint::PathSegment("libswscale/aarch64"))
492 );
493 assert_eq!(parse_path_segment_trailing("www"), None);
495 }
496
497 #[test]
498 fn test_trailing_slash_in_query() {
499 let parser = QueryParser::new(FilePickerConfig);
500 let result = parser.parse("www/ test");
501 assert_eq!(result.constraints.len(), 1);
502 assert!(matches!(
503 result.constraints[0],
504 Constraint::PathSegment("www")
505 ));
506 assert!(matches!(result.fuzzy_query, FuzzyQuery::Text("test")));
507 }
508
509 #[test]
510 fn test_parse_git_status() {
511 assert_eq!(
512 parse_git_status("modified"),
513 Some(Constraint::GitStatus(GitStatusFilter::Modified))
514 );
515 assert_eq!(
516 parse_git_status("m"),
517 Some(Constraint::GitStatus(GitStatusFilter::Modified))
518 );
519 assert_eq!(
520 parse_git_status("untracked"),
521 Some(Constraint::GitStatus(GitStatusFilter::Untracked))
522 );
523 assert_eq!(parse_git_status("invalid"), None);
524 }
525
526 #[test]
527 fn test_memchr() {
528 assert_eq!(memchr(b':', b"type:rust"), Some(4));
529 assert_eq!(memchr(b':', b"nocolon"), None);
530 assert_eq!(memchr(b':', b":start"), Some(0));
531 }
532
533 #[test]
534 fn test_negation_text() {
535 let parser = QueryParser::new(FilePickerConfig);
536 let result = parser.parse("!test foo");
538 assert_eq!(result.constraints.len(), 1);
539 match &result.constraints[0] {
540 Constraint::Not(inner) => {
541 assert!(matches!(**inner, Constraint::Text("test")));
542 }
543 _ => panic!("Expected Not constraint"),
544 }
545 }
546
547 #[test]
548 fn test_negation_extension() {
549 let parser = QueryParser::new(FilePickerConfig);
550 let result = parser.parse("!*.rs foo");
551 assert_eq!(result.constraints.len(), 1);
552 match &result.constraints[0] {
553 Constraint::Not(inner) => {
554 assert!(matches!(**inner, Constraint::Extension("rs")));
555 }
556 _ => panic!("Expected Not(Extension) constraint"),
557 }
558 }
559
560 #[test]
561 fn test_negation_path_segment() {
562 let parser = QueryParser::new(FilePickerConfig);
563 let result = parser.parse("!/src/ foo");
564 assert_eq!(result.constraints.len(), 1);
565 match &result.constraints[0] {
566 Constraint::Not(inner) => {
567 assert!(matches!(**inner, Constraint::PathSegment("src")));
568 }
569 _ => panic!("Expected Not(PathSegment) constraint"),
570 }
571 }
572
573 #[test]
574 fn test_negation_git_status() {
575 let parser = QueryParser::new(FilePickerConfig);
576 let result = parser.parse("!status:modified foo");
577 assert_eq!(result.constraints.len(), 1);
578 match &result.constraints[0] {
579 Constraint::Not(inner) => {
580 assert!(matches!(
581 **inner,
582 Constraint::GitStatus(GitStatusFilter::Modified)
583 ));
584 }
585 _ => panic!("Expected Not(GitStatus) constraint"),
586 }
587 }
588
589 #[test]
590 fn test_backslash_escape_extension() {
591 let parser = QueryParser::new(FilePickerConfig);
592 let result = parser.parse("\\*.rs foo");
593 assert_eq!(result.constraints.len(), 0);
595 match result.fuzzy_query {
597 FuzzyQuery::Parts(parts) => {
598 assert_eq!(parts.len(), 2);
599 assert_eq!(parts[0], "\\*.rs");
600 assert_eq!(parts[1], "foo");
601 }
602 _ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
603 }
604 }
605
606 #[test]
607 fn test_backslash_escape_path_segment() {
608 let parser = QueryParser::new(FilePickerConfig);
609 let result = parser.parse("\\/src/ foo");
610 assert_eq!(result.constraints.len(), 0);
611 match result.fuzzy_query {
612 FuzzyQuery::Parts(parts) => {
613 assert_eq!(parts[0], "\\/src/");
614 assert_eq!(parts[1], "foo");
615 }
616 _ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
617 }
618 }
619
620 #[test]
621 fn test_backslash_escape_negation() {
622 let parser = QueryParser::new(FilePickerConfig);
623 let result = parser.parse("\\!test foo");
624 assert_eq!(result.constraints.len(), 0);
625 }
626
627 #[test]
628 fn test_grep_text_plain_text() {
629 let q = QueryParser::new(GrepConfig).parse("name =");
631 assert_eq!(q.grep_text(), "name =");
632 }
633
634 #[test]
635 fn test_grep_text_strips_constraint() {
636 let q = QueryParser::new(GrepConfig).parse("name = *.rs someth");
637 assert_eq!(q.grep_text(), "name = someth");
638 }
639
640 #[test]
641 fn test_grep_text_leading_constraint() {
642 let q = QueryParser::new(GrepConfig).parse("*.rs name =");
643 assert_eq!(q.grep_text(), "name =");
644 }
645
646 #[test]
647 fn test_grep_text_only_constraints() {
648 let q = QueryParser::new(GrepConfig).parse("*.rs /src/");
649 assert_eq!(q.grep_text(), "");
650 }
651
652 #[test]
653 fn test_grep_text_path_constraint() {
654 let q = QueryParser::new(GrepConfig).parse("name /src/ value");
655 assert_eq!(q.grep_text(), "name value");
656 }
657
658 #[test]
659 fn test_grep_text_negation_constraint() {
660 let q = QueryParser::new(GrepConfig).parse("name !*.rs value");
661 assert_eq!(q.grep_text(), "name value");
662 }
663
664 #[test]
665 fn test_grep_text_backslash_escape_stripped() {
666 let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
668 assert_eq!(q.grep_text(), "*.rs foo");
669
670 let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
671 assert_eq!(q.grep_text(), "/src/ foo");
672
673 let q = QueryParser::new(GrepConfig).parse("\\!test foo");
674 assert_eq!(q.grep_text(), "!test foo");
675 }
676
677 #[test]
678 fn test_grep_text_question_mark_is_text() {
679 let q = QueryParser::new(GrepConfig).parse("foo? bar");
680 assert_eq!(q.grep_text(), "foo? bar");
681 }
682
683 #[test]
684 fn test_grep_text_bracket_is_text() {
685 let q = QueryParser::new(GrepConfig).parse("arr[0] more");
686 assert_eq!(q.grep_text(), "arr[0] more");
687 }
688
689 #[test]
690 fn test_grep_text_path_glob_is_constraint() {
691 let q = QueryParser::new(GrepConfig).parse("pattern src/**/*.rs");
692 assert_eq!(q.grep_text(), "pattern");
693 }
694
695 #[test]
696 fn test_grep_question_mark_is_text() {
697 let parser = QueryParser::new(GrepConfig);
698 let result = parser.parse("foo?");
699 assert!(result.constraints.is_empty());
700 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("foo?"));
701 }
702
703 #[test]
704 fn test_grep_bracket_is_text() {
705 let parser = QueryParser::new(GrepConfig);
706 let result = parser.parse("arr[0] something");
707 assert_eq!(result.constraints.len(), 0);
709 }
710
711 #[test]
712 fn test_grep_path_glob_is_constraint() {
713 let parser = QueryParser::new(GrepConfig);
714 let result = parser.parse("pattern src/**/*.rs");
715 assert_eq!(result.constraints.len(), 1);
717 assert!(matches!(
718 result.constraints[0],
719 Constraint::Glob("src/**/*.rs")
720 ));
721 }
722
723 #[test]
724 fn test_grep_brace_is_constraint() {
725 let parser = QueryParser::new(GrepConfig);
726 let result = parser.parse("pattern {src,lib}");
727 assert_eq!(result.constraints.len(), 1);
728 assert!(matches!(
729 result.constraints[0],
730 Constraint::Glob("{src,lib}")
731 ));
732 }
733
734 #[test]
735 fn test_grep_text_preserves_backslash_escapes() {
736 let q = QueryParser::new(GrepConfig).parse("pub struct \\w+");
740 assert_eq!(
741 q.grep_text(),
742 "pub struct \\w+",
743 "Backslash-w in regex must be preserved"
744 );
745
746 let q = QueryParser::new(GrepConfig).parse("\\bword\\b more");
747 assert_eq!(
748 q.grep_text(),
749 "\\bword\\b more",
750 "Backslash-b word boundaries must be preserved"
751 );
752
753 let result = QueryParser::new(GrepConfig).parse("fn\\s+\\w+");
755 assert!(result.constraints.is_empty());
756 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("fn\\s+\\w+"));
757
758 let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
760 assert_eq!(
761 q.grep_text(),
762 "*.rs foo",
763 "Escaped constraint \\*.rs should still have backslash stripped"
764 );
765
766 let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
767 assert_eq!(
768 q.grep_text(),
769 "/src/ foo",
770 "Escaped constraint \\/src/ should still have backslash stripped"
771 );
772 }
773
774 #[test]
775 fn test_grep_bare_star_is_text() {
776 let parser = QueryParser::new(GrepConfig);
777 let result = parser.parse("a*b something");
779 assert_eq!(
780 result.constraints.len(),
781 0,
782 "bare * without / should be text"
783 );
784 }
785
786 #[test]
787 fn test_grep_negated_text() {
788 let parser = QueryParser::new(GrepConfig);
789 let result = parser.parse("pattern !test");
790 assert_eq!(result.constraints.len(), 1);
791 match &result.constraints[0] {
792 Constraint::Not(inner) => {
793 assert!(
794 matches!(**inner, Constraint::Text("test")),
795 "Expected Not(Text(\"test\")), got Not({:?})",
796 inner
797 );
798 }
799 other => panic!("Expected Not constraint, got {:?}", other),
800 }
801 }
802
803 #[test]
804 fn test_grep_negated_path_segment() {
805 let parser = QueryParser::new(GrepConfig);
806 let result = parser.parse("pattern !/src/");
807 assert_eq!(result.constraints.len(), 1);
808 match &result.constraints[0] {
809 Constraint::Not(inner) => {
810 assert!(
811 matches!(**inner, Constraint::PathSegment("src")),
812 "Expected Not(PathSegment(\"src\")), got Not({:?})",
813 inner
814 );
815 }
816 other => panic!("Expected Not constraint, got {:?}", other),
817 }
818 }
819
820 #[test]
821 fn test_grep_negated_extension() {
822 let parser = QueryParser::new(GrepConfig);
823 let result = parser.parse("pattern !*.rs");
824 assert_eq!(result.constraints.len(), 1);
825 match &result.constraints[0] {
826 Constraint::Not(inner) => {
827 assert!(
828 matches!(**inner, Constraint::Extension("rs")),
829 "Expected Not(Extension(\"rs\")), got Not({:?})",
830 inner
831 );
832 }
833 other => panic!("Expected Not constraint, got {:?}", other),
834 }
835 }
836
837 #[test]
840 fn test_ai_grep_detects_file_path() {
841 use crate::AiGrepConfig;
842 let parser = QueryParser::new(AiGrepConfig);
843 let result = parser.parse("libswscale/input.c rgba32ToY");
844 assert_eq!(result.constraints.len(), 1);
845 assert!(
846 matches!(
847 result.constraints[0],
848 Constraint::FilePath("libswscale/input.c")
849 ),
850 "Expected FilePath, got {:?}",
851 result.constraints[0]
852 );
853 assert_eq!(result.grep_text(), "rgba32ToY");
854 }
855
856 #[test]
857 fn test_ai_grep_detects_nested_file_path() {
858 use crate::AiGrepConfig;
859 let parser = QueryParser::new(AiGrepConfig);
860 let result = parser.parse("src/main.rs fn main");
861 assert_eq!(result.constraints.len(), 1);
862 assert!(matches!(
863 result.constraints[0],
864 Constraint::FilePath("src/main.rs")
865 ));
866 assert_eq!(result.grep_text(), "fn main");
867 }
868
869 #[test]
870 fn test_ai_grep_no_false_positive_trailing_slash() {
871 use crate::AiGrepConfig;
872 let parser = QueryParser::new(AiGrepConfig);
873 let result = parser.parse("src/ pattern");
874 assert_eq!(result.constraints.len(), 1);
876 assert!(
877 matches!(result.constraints[0], Constraint::PathSegment("src")),
878 "Expected PathSegment, got {:?}",
879 result.constraints[0]
880 );
881 }
882
883 #[test]
884 fn test_ai_grep_bare_filename_is_file_path() {
885 use crate::AiGrepConfig;
886 let parser = QueryParser::new(AiGrepConfig);
887 let result = parser.parse("main.rs pattern");
888 assert_eq!(result.constraints.len(), 1);
890 assert!(
891 matches!(result.constraints[0], Constraint::FilePath("main.rs")),
892 "Expected FilePath, got {:?}",
893 result.constraints[0]
894 );
895 assert_eq!(result.grep_text(), "pattern");
896 }
897
898 #[test]
899 fn test_ai_grep_bare_filename_schema_rs() {
900 use crate::AiGrepConfig;
901 let parser = QueryParser::new(AiGrepConfig);
902 let result = parser.parse("schema.rs part_revisions");
903 assert_eq!(result.constraints.len(), 1);
904 assert!(
905 matches!(result.constraints[0], Constraint::FilePath("schema.rs")),
906 "Expected FilePath(schema.rs), got {:?}",
907 result.constraints[0]
908 );
909 assert_eq!(result.grep_text(), "part_revisions");
910 }
911
912 #[test]
913 fn test_ai_grep_bare_word_no_extension_not_constraint() {
914 use crate::AiGrepConfig;
915 let parser = QueryParser::new(AiGrepConfig);
916 let result = parser.parse("schema pattern");
917 assert_eq!(result.constraints.len(), 0);
919 assert_eq!(result.grep_text(), "schema pattern");
920 }
921
922 #[test]
923 fn test_ai_grep_no_false_positive_no_extension() {
924 use crate::AiGrepConfig;
925 let parser = QueryParser::new(AiGrepConfig);
926 let result = parser.parse("src/utils pattern");
927 assert_eq!(result.constraints.len(), 0);
929 assert_eq!(result.grep_text(), "src/utils pattern");
930 }
931
932 #[test]
933 fn test_ai_grep_wildcard_not_filepath() {
934 use crate::AiGrepConfig;
935 let parser = QueryParser::new(AiGrepConfig);
936 let result = parser.parse("src/**/*.rs pattern");
937 assert_eq!(result.constraints.len(), 1);
939 assert!(
940 matches!(result.constraints[0], Constraint::Glob("src/**/*.rs")),
941 "Expected Glob, got {:?}",
942 result.constraints[0]
943 );
944 }
945
946 #[test]
947 fn test_ai_grep_star_text_star_is_glob() {
948 use crate::AiGrepConfig;
949 let parser = QueryParser::new(AiGrepConfig);
950 let result = parser.parse("*quote* TODO");
951 assert_eq!(result.constraints.len(), 1);
953 assert!(
954 matches!(result.constraints[0], Constraint::Glob("*quote*")),
955 "Expected Glob(*quote*), got {:?}",
956 result.constraints[0]
957 );
958 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("TODO"));
959 }
960
961 #[test]
962 fn test_ai_grep_bare_star_not_glob() {
963 use crate::AiGrepConfig;
964 let parser = QueryParser::new(AiGrepConfig);
965 let result = parser.parse("* pattern");
966 assert!(
968 result.constraints.is_empty(),
969 "Expected no constraints, got {:?}",
970 result.constraints
971 );
972 }
973
974 #[test]
975 fn test_grep_no_location_parsing_single_token() {
976 let parser = QueryParser::new(GrepConfig);
977 let result = parser.parse("localhost:8080");
979 assert!(result.constraints.is_empty());
980 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("localhost:8080"));
981 }
982
983 #[test]
984 fn test_grep_no_location_parsing_multi_token() {
985 let q = QueryParser::new(GrepConfig).parse("*.rs localhost:8080");
986 assert_eq!(
987 q.grep_text(),
988 "localhost:8080",
989 "Colon-number suffix should be preserved in grep text"
990 );
991 assert!(
992 q.location.is_none(),
993 "Grep should not parse location from colon-number"
994 );
995 }
996
997 #[test]
998 fn test_grep_braces_without_comma_is_text() {
999 let parser = QueryParser::new(GrepConfig);
1000 let result = parser.parse(r#"format!("{}\\AppData", home)"#);
1002 assert!(
1003 result.constraints.is_empty(),
1004 "Braces without comma should be text, got {:?}",
1005 result.constraints
1006 );
1007 assert_eq!(result.grep_text(), r#"format!("{}\\AppData", home)"#);
1008 }
1009
1010 #[test]
1011 fn test_grep_format_braces_not_glob() {
1012 let parser = QueryParser::new(GrepConfig);
1013 let input = "format!(\"{}\\\\AppData\", home)";
1017 let result = parser.parse(input);
1018 assert!(
1019 result.constraints.is_empty(),
1020 "format! pattern should have no constraints, got {:?}",
1021 result.constraints
1022 );
1023 }
1024
1025 #[test]
1026 fn test_grep_config_star_text_star_not_glob() {
1027 use crate::GrepConfig;
1028 let parser = QueryParser::new(GrepConfig);
1029 let result = parser.parse("*quote* TODO");
1030 assert!(
1032 result.constraints.is_empty(),
1033 "Expected no constraints in GrepConfig, got {:?}",
1034 result.constraints
1035 );
1036 }
1037
1038 #[test]
1041 fn test_file_picker_bare_filename_constraint() {
1042 let parser = QueryParser::new(FilePickerConfig);
1043 let result = parser.parse("score.rs file_picker");
1044 assert_eq!(result.constraints.len(), 1);
1045 assert!(
1046 matches!(result.constraints[0], Constraint::FilePath("score.rs")),
1047 "Expected FilePath(\"score.rs\"), got {:?}",
1048 result.constraints[0]
1049 );
1050 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("file_picker"));
1051 }
1052
1053 #[test]
1054 fn test_file_picker_path_prefixed_filename_constraint() {
1055 let parser = QueryParser::new(FilePickerConfig);
1056 let result = parser.parse("libswscale/slice.c lum_convert");
1057 assert_eq!(result.constraints.len(), 1);
1058 assert!(
1059 matches!(
1060 result.constraints[0],
1061 Constraint::FilePath("libswscale/slice.c")
1062 ),
1063 "Expected FilePath(\"libswscale/slice.c\"), got {:?}",
1064 result.constraints[0]
1065 );
1066 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("lum_convert"));
1067 }
1068
1069 #[test]
1070 fn test_file_picker_single_token_filename_stays_fuzzy() {
1071 let parser = QueryParser::new(FilePickerConfig);
1072 let result = parser.parse("score.rs");
1075 assert!(result.constraints.is_empty());
1076 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
1077 }
1078
1079 #[test]
1080 fn test_absolute_path_with_location_not_path_segment() {
1081 let parser = QueryParser::new(FilePickerConfig);
1082 let result = parser.parse("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs:12");
1085 assert!(
1086 result.constraints.is_empty(),
1087 "Absolute path with location should not become a constraint, got {:?}",
1088 result.constraints
1089 );
1090 assert_eq!(
1091 result.fuzzy_query,
1092 FuzzyQuery::Text("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs")
1093 );
1094 assert_eq!(result.location, Some(Location::Line(12)));
1095 }
1096
1097 #[test]
1098 fn test_file_picker_filename_with_multiple_fuzzy_parts() {
1099 let parser = QueryParser::new(FilePickerConfig);
1100 let result = parser.parse("main.rs src components");
1101 assert_eq!(result.constraints.len(), 1);
1102 assert!(matches!(
1103 result.constraints[0],
1104 Constraint::FilePath("main.rs")
1105 ));
1106 assert_eq!(
1107 result.fuzzy_query,
1108 FuzzyQuery::Parts(smallvec::smallvec!["src", "components"])
1109 );
1110 }
1111
1112 #[test]
1113 fn test_file_picker_version_number_not_filename() {
1114 let parser = QueryParser::new(FilePickerConfig);
1115 let result = parser.parse("v2.0 release");
1116 assert!(
1118 result.constraints.is_empty(),
1119 "v2.0 should not be a FilePath constraint, got {:?}",
1120 result.constraints
1121 );
1122 }
1123
1124 #[test]
1125 fn test_file_picker_only_one_filepath_constraint() {
1126 let parser = QueryParser::new(FilePickerConfig);
1127 let result = parser.parse("main.rs score.rs");
1128 assert_eq!(result.constraints.len(), 1);
1130 assert!(matches!(
1131 result.constraints[0],
1132 Constraint::FilePath("main.rs")
1133 ));
1134 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
1135 }
1136
1137 #[test]
1138 fn test_file_picker_filename_with_extension_constraint() {
1139 let parser = QueryParser::new(FilePickerConfig);
1140 let result = parser.parse("main.rs *.lua");
1141 assert_eq!(result.constraints.len(), 2);
1143 assert!(matches!(
1144 result.constraints[0],
1145 Constraint::FilePath("main.rs")
1146 ));
1147 assert!(matches!(
1148 result.constraints[1],
1149 Constraint::Extension("lua")
1150 ));
1151 }
1152
1153 #[test]
1154 fn test_file_picker_dotfile_is_filename() {
1155 let parser = QueryParser::new(FilePickerConfig);
1156 let result = parser.parse(".gitignore src");
1157 assert_eq!(result.constraints.len(), 1);
1158 assert!(
1159 matches!(result.constraints[0], Constraint::FilePath(".gitignore")),
1160 "Expected FilePath(\".gitignore\"), got {:?}",
1161 result.constraints[0]
1162 );
1163 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("src"));
1164 }
1165
1166 #[test]
1167 fn test_file_picker_no_extension_not_filename() {
1168 let parser = QueryParser::new(FilePickerConfig);
1169 let result = parser.parse("Makefile src");
1170 assert!(
1172 result.constraints.is_empty(),
1173 "Makefile should not be a FilePath constraint, got {:?}",
1174 result.constraints
1175 );
1176 }
1177}