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::FileSearchConfig> {
212 fn default() -> Self {
213 Self::new(crate::FileSearchConfig)
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::{FileSearchConfig, 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 = FileSearchConfig;
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(FileSearchConfig);
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(FileSearchConfig);
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(FileSearchConfig);
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(FileSearchConfig);
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(FileSearchConfig);
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(FileSearchConfig);
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(FileSearchConfig);
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(FileSearchConfig);
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]
838 fn test_ai_grep_detects_file_path() {
839 use crate::AiGrepConfig;
840 let parser = QueryParser::new(AiGrepConfig);
841 let result = parser.parse("libswscale/input.c rgba32ToY");
842 assert_eq!(result.constraints.len(), 1);
843 assert!(
844 matches!(
845 result.constraints[0],
846 Constraint::FilePath("libswscale/input.c")
847 ),
848 "Expected FilePath, got {:?}",
849 result.constraints[0]
850 );
851 assert_eq!(result.grep_text(), "rgba32ToY");
852 }
853
854 #[test]
855 fn test_ai_grep_detects_nested_file_path() {
856 use crate::AiGrepConfig;
857 let parser = QueryParser::new(AiGrepConfig);
858 let result = parser.parse("src/main.rs fn main");
859 assert_eq!(result.constraints.len(), 1);
860 assert!(matches!(
861 result.constraints[0],
862 Constraint::FilePath("src/main.rs")
863 ));
864 assert_eq!(result.grep_text(), "fn main");
865 }
866
867 #[test]
868 fn test_ai_grep_no_false_positive_trailing_slash() {
869 use crate::AiGrepConfig;
870 let parser = QueryParser::new(AiGrepConfig);
871 let result = parser.parse("src/ pattern");
872 assert_eq!(result.constraints.len(), 1);
874 assert!(
875 matches!(result.constraints[0], Constraint::PathSegment("src")),
876 "Expected PathSegment, got {:?}",
877 result.constraints[0]
878 );
879 }
880
881 #[test]
882 fn test_ai_grep_bare_filename_is_file_path() {
883 use crate::AiGrepConfig;
884 let parser = QueryParser::new(AiGrepConfig);
885 let result = parser.parse("main.rs pattern");
886 assert_eq!(result.constraints.len(), 1);
888 assert!(
889 matches!(result.constraints[0], Constraint::FilePath("main.rs")),
890 "Expected FilePath, got {:?}",
891 result.constraints[0]
892 );
893 assert_eq!(result.grep_text(), "pattern");
894 }
895
896 #[test]
897 fn test_ai_grep_bare_filename_schema_rs() {
898 use crate::AiGrepConfig;
899 let parser = QueryParser::new(AiGrepConfig);
900 let result = parser.parse("schema.rs part_revisions");
901 assert_eq!(result.constraints.len(), 1);
902 assert!(
903 matches!(result.constraints[0], Constraint::FilePath("schema.rs")),
904 "Expected FilePath(schema.rs), got {:?}",
905 result.constraints[0]
906 );
907 assert_eq!(result.grep_text(), "part_revisions");
908 }
909
910 #[test]
911 fn test_ai_grep_bare_word_no_extension_not_constraint() {
912 use crate::AiGrepConfig;
913 let parser = QueryParser::new(AiGrepConfig);
914 let result = parser.parse("schema pattern");
915 assert_eq!(result.constraints.len(), 0);
917 assert_eq!(result.grep_text(), "schema pattern");
918 }
919
920 #[test]
921 fn test_ai_grep_no_false_positive_no_extension() {
922 use crate::AiGrepConfig;
923 let parser = QueryParser::new(AiGrepConfig);
924 let result = parser.parse("src/utils pattern");
925 assert_eq!(result.constraints.len(), 0);
927 assert_eq!(result.grep_text(), "src/utils pattern");
928 }
929
930 #[test]
931 fn test_ai_grep_wildcard_not_filepath() {
932 use crate::AiGrepConfig;
933 let parser = QueryParser::new(AiGrepConfig);
934 let result = parser.parse("src/**/*.rs pattern");
935 assert_eq!(result.constraints.len(), 1);
937 assert!(
938 matches!(result.constraints[0], Constraint::Glob("src/**/*.rs")),
939 "Expected Glob, got {:?}",
940 result.constraints[0]
941 );
942 }
943
944 #[test]
945 fn test_ai_grep_star_text_star_is_glob() {
946 use crate::AiGrepConfig;
947 let parser = QueryParser::new(AiGrepConfig);
948 let result = parser.parse("*quote* TODO");
949 assert_eq!(result.constraints.len(), 1);
951 assert!(
952 matches!(result.constraints[0], Constraint::Glob("*quote*")),
953 "Expected Glob(*quote*), got {:?}",
954 result.constraints[0]
955 );
956 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("TODO"));
957 }
958
959 #[test]
960 fn test_ai_grep_bare_star_not_glob() {
961 use crate::AiGrepConfig;
962 let parser = QueryParser::new(AiGrepConfig);
963 let result = parser.parse("* pattern");
964 assert!(
966 result.constraints.is_empty(),
967 "Expected no constraints, got {:?}",
968 result.constraints
969 );
970 }
971
972 #[test]
973 fn test_grep_no_location_parsing_single_token() {
974 let parser = QueryParser::new(GrepConfig);
975 let result = parser.parse("localhost:8080");
977 assert!(result.constraints.is_empty());
978 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("localhost:8080"));
979 }
980
981 #[test]
982 fn test_grep_no_location_parsing_multi_token() {
983 let q = QueryParser::new(GrepConfig).parse("*.rs localhost:8080");
984 assert_eq!(
985 q.grep_text(),
986 "localhost:8080",
987 "Colon-number suffix should be preserved in grep text"
988 );
989 assert!(
990 q.location.is_none(),
991 "Grep should not parse location from colon-number"
992 );
993 }
994
995 #[test]
996 fn test_grep_braces_without_comma_is_text() {
997 let parser = QueryParser::new(GrepConfig);
998 let result = parser.parse(r#"format!("{}\\AppData", home)"#);
1000 assert!(
1001 result.constraints.is_empty(),
1002 "Braces without comma should be text, got {:?}",
1003 result.constraints
1004 );
1005 assert_eq!(result.grep_text(), r#"format!("{}\\AppData", home)"#);
1006 }
1007
1008 #[test]
1009 fn test_grep_format_braces_not_glob() {
1010 let parser = QueryParser::new(GrepConfig);
1011 let input = "format!(\"{}\\\\AppData\", home)";
1015 let result = parser.parse(input);
1016 assert!(
1017 result.constraints.is_empty(),
1018 "format! pattern should have no constraints, got {:?}",
1019 result.constraints
1020 );
1021 }
1022
1023 #[test]
1024 fn test_grep_config_star_text_star_not_glob() {
1025 use crate::GrepConfig;
1026 let parser = QueryParser::new(GrepConfig);
1027 let result = parser.parse("*quote* TODO");
1028 assert!(
1030 result.constraints.is_empty(),
1031 "Expected no constraints in GrepConfig, got {:?}",
1032 result.constraints
1033 );
1034 }
1035
1036 #[test]
1037 fn test_file_picker_bare_filename_constraint() {
1038 let parser = QueryParser::new(FileSearchConfig);
1039 let result = parser.parse("score.rs file_picker");
1040 assert_eq!(result.constraints.len(), 1);
1041 assert!(
1042 matches!(result.constraints[0], Constraint::FilePath("score.rs")),
1043 "Expected FilePath(\"score.rs\"), got {:?}",
1044 result.constraints[0]
1045 );
1046 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("file_picker"));
1047 }
1048
1049 #[test]
1050 fn test_file_picker_path_prefixed_filename_constraint() {
1051 let parser = QueryParser::new(FileSearchConfig);
1052 let result = parser.parse("libswscale/slice.c lum_convert");
1053 assert_eq!(result.constraints.len(), 1);
1054 assert!(
1055 matches!(
1056 result.constraints[0],
1057 Constraint::FilePath("libswscale/slice.c")
1058 ),
1059 "Expected FilePath(\"libswscale/slice.c\"), got {:?}",
1060 result.constraints[0]
1061 );
1062 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("lum_convert"));
1063 }
1064
1065 #[test]
1066 fn test_file_picker_single_token_filename_stays_fuzzy() {
1067 let parser = QueryParser::new(FileSearchConfig);
1068 let result = parser.parse("score.rs");
1071 assert!(result.constraints.is_empty());
1072 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
1073 }
1074
1075 #[test]
1076 fn test_absolute_path_with_location_not_path_segment() {
1077 let parser = QueryParser::new(FileSearchConfig);
1078 let result = parser.parse("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs:12");
1081 assert!(
1082 result.constraints.is_empty(),
1083 "Absolute path with location should not become a constraint, got {:?}",
1084 result.constraints
1085 );
1086 assert_eq!(
1087 result.fuzzy_query,
1088 FuzzyQuery::Text("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs")
1089 );
1090 assert_eq!(result.location, Some(Location::Line(12)));
1091 }
1092
1093 #[test]
1094 fn test_file_picker_filename_with_multiple_fuzzy_parts() {
1095 let parser = QueryParser::new(FileSearchConfig);
1096 let result = parser.parse("main.rs src components");
1097 assert_eq!(result.constraints.len(), 1);
1098 assert!(matches!(
1099 result.constraints[0],
1100 Constraint::FilePath("main.rs")
1101 ));
1102 assert_eq!(
1103 result.fuzzy_query,
1104 FuzzyQuery::Parts(vec!["src", "components"])
1105 );
1106 }
1107
1108 #[test]
1109 fn test_file_picker_version_number_not_filename() {
1110 let parser = QueryParser::new(FileSearchConfig);
1111 let result = parser.parse("v2.0 release");
1112 assert!(
1114 result.constraints.is_empty(),
1115 "v2.0 should not be a FilePath constraint, got {:?}",
1116 result.constraints
1117 );
1118 }
1119
1120 #[test]
1121 fn test_file_picker_only_one_filepath_constraint() {
1122 let parser = QueryParser::new(FileSearchConfig);
1123 let result = parser.parse("main.rs score.rs");
1124 assert_eq!(result.constraints.len(), 1);
1126 assert!(matches!(
1127 result.constraints[0],
1128 Constraint::FilePath("main.rs")
1129 ));
1130 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
1131 }
1132
1133 #[test]
1134 fn test_file_picker_filename_with_extension_constraint() {
1135 let parser = QueryParser::new(FileSearchConfig);
1136 let result = parser.parse("main.rs *.lua");
1137 assert_eq!(result.constraints.len(), 2);
1139 assert!(matches!(
1140 result.constraints[0],
1141 Constraint::FilePath("main.rs")
1142 ));
1143 assert!(matches!(
1144 result.constraints[1],
1145 Constraint::Extension("lua")
1146 ));
1147 }
1148
1149 #[test]
1150 fn test_file_picker_dotfile_is_filename() {
1151 let parser = QueryParser::new(FileSearchConfig);
1152 let result = parser.parse(".gitignore src");
1153 assert_eq!(result.constraints.len(), 1);
1154 assert!(
1155 matches!(result.constraints[0], Constraint::FilePath(".gitignore")),
1156 "Expected FilePath(\".gitignore\"), got {:?}",
1157 result.constraints[0]
1158 );
1159 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("src"));
1160 }
1161
1162 #[test]
1163 fn test_file_picker_no_extension_not_filename() {
1164 let parser = QueryParser::new(FileSearchConfig);
1165 let result = parser.parse("Makefile src");
1166 assert!(
1168 result.constraints.is_empty(),
1169 "Makefile should not be a FilePath constraint, got {:?}",
1170 result.constraints
1171 );
1172 }
1173}