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
26impl<'a> FFFQuery<'a> {
27 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#[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 if whitespace_count == 0 {
62 if let Some(constraint) = parse_token(query, config) {
64 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 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 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 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 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 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 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 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 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 text_parts[0].is_empty() {
192 FuzzyQuery::Empty
193 } else {
194 FuzzyQuery::Text(text_parts[0])
195 }
196 } else {
197 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 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#[inline]
244fn strip_leading_backslash(token: &str) -> &str {
245 if token.len() > 1 && token.starts_with('\\') {
246 let next = token.as_bytes()[1];
247 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 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 if token == "*" || token == "*." {
275 return None;
276 }
277
278 if let Some(constraint) = parse_extension(token) {
280 let ext_part = &token[2..];
283 if !has_wildcards(ext_part) {
284 return Some(constraint);
285 }
286 }
287 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 _ if config.enable_path_segments() && token.ends_with('/') => {
297 parse_path_segment_trailing(token)
298 }
299 _ 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 if config.enable_glob() && config.is_glob_pattern(token) {
309 return Some(Constraint::Glob(token));
310 }
311
312 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..]; 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 config.parse_custom(token)
330 }
331 }
332}
333
334#[inline]
336fn memchr(needle: u8, haystack: &[u8]) -> Option<usize> {
337 haystack.iter().position(|&b| b == needle)
338}
339
340#[inline]
342fn parse_extension(token: &str) -> Option<Constraint<'_>> {
343 if token.len() > 2 && token.starts_with("*.") {
344 Some(Constraint::Extension(&token[2..]))
345 } else {
346 None
347 }
348}
349
350#[inline]
353fn parse_negation<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constraint<'a>> {
354 if token.len() <= 1 {
355 return None;
356 }
357
358 let inner_token = &token[1..];
359
360 if let Some(inner_constraint) = parse_token_without_negation(inner_token, config) {
362 return Some(Constraint::Not(Box::new(inner_constraint)));
364 }
365
366 Some(Constraint::Not(Box::new(Constraint::Text(inner_token))))
369}
370
371#[inline]
373fn parse_token_without_negation<'a, C: ParserConfig>(
374 token: &'a str,
375 config: &C,
376) -> Option<Constraint<'a>> {
377 if token.starts_with('\\') && token.len() > 1 {
379 return None;
380 }
381
382 let first_byte = token.as_bytes().first()?;
383
384 match first_byte {
385 b'*' if config.enable_extension() => {
386 if let Some(constraint) = parse_extension(token) {
388 let ext_part = &token[2..];
389 if !has_wildcards(ext_part) {
390 return Some(constraint);
391 }
392 }
393 if config.enable_glob() && config.is_glob_pattern(token) {
395 return Some(Constraint::Glob(token));
396 }
397 None
398 }
399 b'/' if config.enable_path_segments() => parse_path_segment(token),
400 _ if config.enable_path_segments() && token.ends_with('/') => {
401 parse_path_segment_trailing(token)
403 }
404 _ => {
405 if config.enable_glob() && config.is_glob_pattern(token) {
407 return Some(Constraint::Glob(token));
408 }
409
410 if let Some(colon_idx) = memchr(b':', token.as_bytes()) {
412 let (key, value_with_colon) = token.split_at(colon_idx);
413 let value = &value_with_colon[1..]; match key {
416 "type" if config.enable_type_filter() => {
417 return Some(Constraint::FileType(value));
418 }
419 "status" | "st" | "g" | "git" if config.enable_git_status() => {
420 return parse_git_status(value);
421 }
422 _ => {}
423 }
424 }
425
426 if config.enable_filename_constraint()
427 && Constraint::is_filename_constraint_token(token)
428 {
429 return Some(Constraint::FilePath(token));
430 }
431
432 config.parse_custom(token)
433 }
434 }
435}
436
437#[inline]
439fn parse_path_segment(token: &str) -> Option<Constraint<'_>> {
440 if token.len() > 1 && token.starts_with('/') {
441 let segment = token.trim_start_matches('/').trim_end_matches('/');
442 if !segment.is_empty() {
443 Some(Constraint::PathSegment(segment))
444 } else {
445 None
446 }
447 } else {
448 None
449 }
450}
451
452#[inline]
455fn parse_path_segment_trailing(token: &str) -> Option<Constraint<'_>> {
456 if token.len() > 1 && token.ends_with('/') {
457 let segment = token.trim_end_matches('/');
458 if !segment.is_empty() {
459 Some(Constraint::PathSegment(segment))
460 } else {
461 None
462 }
463 } else {
464 None
465 }
466}
467
468#[inline]
470fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
471 if value == "*" {
472 return None;
473 }
474
475 if "modified".starts_with(value) {
476 return Some(Constraint::GitStatus(GitStatusFilter::Modified));
477 }
478
479 if "untracked".starts_with(value) {
480 return Some(Constraint::GitStatus(GitStatusFilter::Untracked));
481 }
482
483 if "staged".starts_with(value) {
484 return Some(Constraint::GitStatus(GitStatusFilter::Staged));
485 }
486
487 if "clean".starts_with(value) {
488 return Some(Constraint::GitStatus(GitStatusFilter::Unmodified));
489 }
490
491 None
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::{FileSearchConfig, GrepConfig};
498
499 struct FilenameConstraintConfig;
502
503 impl ParserConfig for FilenameConstraintConfig {
504 fn enable_filename_constraint(&self) -> bool {
505 true
506 }
507 }
508
509 #[test]
510 fn test_parse_extension() {
511 assert_eq!(parse_extension("*.rs"), Some(Constraint::Extension("rs")));
512 assert_eq!(
513 parse_extension("*.toml"),
514 Some(Constraint::Extension("toml"))
515 );
516 assert_eq!(parse_extension("*"), None);
517 assert_eq!(parse_extension("*."), None);
518 }
519
520 #[test]
521 fn test_incomplete_patterns_ignored() {
522 let config = FileSearchConfig;
523 assert_eq!(parse_token("*", &config), None);
525 assert_eq!(parse_token("*.", &config), None);
526 }
527
528 #[test]
529 fn test_parse_path_segment() {
530 assert_eq!(
531 parse_path_segment("/src/"),
532 Some(Constraint::PathSegment("src"))
533 );
534 assert_eq!(
535 parse_path_segment("/lib"),
536 Some(Constraint::PathSegment("lib"))
537 );
538 assert_eq!(parse_path_segment("/"), None);
539 }
540
541 #[test]
542 fn test_parse_path_segment_trailing() {
543 assert_eq!(
544 parse_path_segment_trailing("www/"),
545 Some(Constraint::PathSegment("www"))
546 );
547 assert_eq!(
548 parse_path_segment_trailing("src/"),
549 Some(Constraint::PathSegment("src"))
550 );
551 assert_eq!(
553 parse_path_segment_trailing("src/lib/"),
554 Some(Constraint::PathSegment("src/lib"))
555 );
556 assert_eq!(
557 parse_path_segment_trailing("libswscale/aarch64/"),
558 Some(Constraint::PathSegment("libswscale/aarch64"))
559 );
560 assert_eq!(parse_path_segment_trailing("www"), None);
562 }
563
564 #[test]
565 fn test_trailing_slash_in_query() {
566 let parser = QueryParser::new(FileSearchConfig);
567 let result = parser.parse("www/ test");
568 assert_eq!(result.constraints.len(), 1);
569 assert!(matches!(
570 result.constraints[0],
571 Constraint::PathSegment("www")
572 ));
573 assert!(matches!(result.fuzzy_query, FuzzyQuery::Text("test")));
574 }
575
576 #[test]
577 fn test_parse_git_status() {
578 assert_eq!(
579 parse_git_status("modified"),
580 Some(Constraint::GitStatus(GitStatusFilter::Modified))
581 );
582 assert_eq!(
583 parse_git_status("m"),
584 Some(Constraint::GitStatus(GitStatusFilter::Modified))
585 );
586 assert_eq!(
587 parse_git_status("untracked"),
588 Some(Constraint::GitStatus(GitStatusFilter::Untracked))
589 );
590 assert_eq!(parse_git_status("invalid"), None);
591 }
592
593 #[test]
594 fn test_memchr() {
595 assert_eq!(memchr(b':', b"type:rust"), Some(4));
596 assert_eq!(memchr(b':', b"nocolon"), None);
597 assert_eq!(memchr(b':', b":start"), Some(0));
598 }
599
600 #[test]
601 fn test_negation_text() {
602 let parser = QueryParser::new(FileSearchConfig);
603 let result = parser.parse("!test foo");
605 assert_eq!(result.constraints.len(), 1);
606 match &result.constraints[0] {
607 Constraint::Not(inner) => {
608 assert!(matches!(**inner, Constraint::Text("test")));
609 }
610 _ => panic!("Expected Not constraint"),
611 }
612 }
613
614 #[test]
615 fn test_negation_extension() {
616 let parser = QueryParser::new(FileSearchConfig);
617 let result = parser.parse("!*.rs foo");
618 assert_eq!(result.constraints.len(), 1);
619 match &result.constraints[0] {
620 Constraint::Not(inner) => {
621 assert!(matches!(**inner, Constraint::Extension("rs")));
622 }
623 _ => panic!("Expected Not(Extension) constraint"),
624 }
625 }
626
627 #[test]
628 fn test_negation_path_segment() {
629 let parser = QueryParser::new(FileSearchConfig);
630 let result = parser.parse("!/src/ foo");
631 assert_eq!(result.constraints.len(), 1);
632 match &result.constraints[0] {
633 Constraint::Not(inner) => {
634 assert!(matches!(**inner, Constraint::PathSegment("src")));
635 }
636 _ => panic!("Expected Not(PathSegment) constraint"),
637 }
638 }
639
640 #[test]
641 fn test_negation_git_status() {
642 let parser = QueryParser::new(FileSearchConfig);
643 let result = parser.parse("!status:modified foo");
644 assert_eq!(result.constraints.len(), 1);
645 match &result.constraints[0] {
646 Constraint::Not(inner) => {
647 assert!(matches!(
648 **inner,
649 Constraint::GitStatus(GitStatusFilter::Modified)
650 ));
651 }
652 _ => panic!("Expected Not(GitStatus) constraint"),
653 }
654 }
655
656 #[test]
657 fn test_negation_git_status_all_key_aliases() {
658 let parser = QueryParser::new(FileSearchConfig);
659 for key in ["status", "st", "g", "git"] {
660 let query = format!("!{key}:modified foo");
661 let result = parser.parse(&query);
662 assert_eq!(
663 result.constraints.len(),
664 1,
665 "!{key}:modified should produce exactly one constraint"
666 );
667 match &result.constraints[0] {
668 Constraint::Not(inner) => assert!(
669 matches!(**inner, Constraint::GitStatus(GitStatusFilter::Modified)),
670 "!{key}:modified expected Not(GitStatus(Modified)), got Not({inner:?})"
671 ),
672 other => {
673 panic!("!{key}:modified expected Not(GitStatus), got {other:?}")
674 }
675 }
676 }
677 }
678
679 #[test]
680 fn test_backslash_escape_extension() {
681 let parser = QueryParser::new(FileSearchConfig);
682 let result = parser.parse("\\*.rs foo");
683 assert_eq!(result.constraints.len(), 0);
685 match result.fuzzy_query {
687 FuzzyQuery::Parts(parts) => {
688 assert_eq!(parts.len(), 2);
689 assert_eq!(parts[0], "\\*.rs");
690 assert_eq!(parts[1], "foo");
691 }
692 _ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
693 }
694 }
695
696 #[test]
697 fn test_backslash_escape_path_segment() {
698 let parser = QueryParser::new(FileSearchConfig);
699 let result = parser.parse("\\/src/ foo");
700 assert_eq!(result.constraints.len(), 0);
701 match result.fuzzy_query {
702 FuzzyQuery::Parts(parts) => {
703 assert_eq!(parts[0], "\\/src/");
704 assert_eq!(parts[1], "foo");
705 }
706 _ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
707 }
708 }
709
710 #[test]
711 fn test_backslash_escape_negation() {
712 let parser = QueryParser::new(FileSearchConfig);
713 let result = parser.parse("\\!test foo");
714 assert_eq!(result.constraints.len(), 0);
715 }
716
717 #[test]
718 fn test_grep_text_plain_text() {
719 let q = QueryParser::new(GrepConfig).parse("name =");
721 assert_eq!(q.grep_text(), "name =");
722 }
723
724 #[test]
725 fn test_grep_text_strips_constraint() {
726 let q = QueryParser::new(GrepConfig).parse("name = *.rs someth");
727 assert_eq!(q.grep_text(), "name = someth");
728 }
729
730 #[test]
731 fn test_grep_text_leading_constraint() {
732 let q = QueryParser::new(GrepConfig).parse("*.rs name =");
733 assert_eq!(q.grep_text(), "name =");
734 }
735
736 #[test]
737 fn test_grep_text_only_constraints() {
738 let q = QueryParser::new(GrepConfig).parse("*.rs /src/");
739 assert_eq!(q.grep_text(), "");
740 }
741
742 #[test]
743 fn test_grep_text_path_constraint() {
744 let q = QueryParser::new(GrepConfig).parse("name /src/ value");
745 assert_eq!(q.grep_text(), "name value");
746 }
747
748 #[test]
749 fn test_grep_text_negation_constraint() {
750 let q = QueryParser::new(GrepConfig).parse("name !*.rs value");
751 assert_eq!(q.grep_text(), "name value");
752 }
753
754 #[test]
755 fn test_grep_text_backslash_escape_stripped() {
756 let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
758 assert_eq!(q.grep_text(), "*.rs foo");
759
760 let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
761 assert_eq!(q.grep_text(), "/src/ foo");
762
763 let q = QueryParser::new(GrepConfig).parse("\\!test foo");
764 assert_eq!(q.grep_text(), "!test foo");
765 }
766
767 #[test]
768 fn test_grep_text_question_mark_is_text() {
769 let q = QueryParser::new(GrepConfig).parse("foo? bar");
770 assert_eq!(q.grep_text(), "foo? bar");
771 }
772
773 #[test]
774 fn test_grep_text_bracket_is_text() {
775 let q = QueryParser::new(GrepConfig).parse("arr[0] more");
776 assert_eq!(q.grep_text(), "arr[0] more");
777 }
778
779 #[test]
780 fn test_grep_text_path_glob_is_constraint() {
781 let q = QueryParser::new(GrepConfig).parse("pattern src/**/*.rs");
782 assert_eq!(q.grep_text(), "pattern");
783 }
784
785 #[test]
786 fn test_grep_question_mark_is_text() {
787 let parser = QueryParser::new(GrepConfig);
788 let result = parser.parse("foo?");
789 assert!(result.constraints.is_empty());
790 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("foo?"));
791 }
792
793 #[test]
794 fn test_grep_bracket_is_text() {
795 let parser = QueryParser::new(GrepConfig);
796 let result = parser.parse("arr[0] something");
797 assert_eq!(result.constraints.len(), 0);
799 }
800
801 #[test]
802 fn test_grep_path_glob_is_constraint() {
803 let parser = QueryParser::new(GrepConfig);
804 let result = parser.parse("pattern src/**/*.rs");
805 assert_eq!(result.constraints.len(), 1);
807 assert!(matches!(
808 result.constraints[0],
809 Constraint::Glob("src/**/*.rs")
810 ));
811 }
812
813 #[test]
814 fn test_grep_brace_is_constraint() {
815 let parser = QueryParser::new(GrepConfig);
816 let result = parser.parse("pattern {src,lib}");
817 assert_eq!(result.constraints.len(), 1);
818 assert!(matches!(
819 result.constraints[0],
820 Constraint::Glob("{src,lib}")
821 ));
822 }
823
824 #[test]
825 fn test_grep_text_preserves_backslash_escapes() {
826 let q = QueryParser::new(GrepConfig).parse("pub struct \\w+");
830 assert_eq!(
831 q.grep_text(),
832 "pub struct \\w+",
833 "Backslash-w in regex must be preserved"
834 );
835
836 let q = QueryParser::new(GrepConfig).parse("\\bword\\b more");
837 assert_eq!(
838 q.grep_text(),
839 "\\bword\\b more",
840 "Backslash-b word boundaries must be preserved"
841 );
842
843 let result = QueryParser::new(GrepConfig).parse("fn\\s+\\w+");
845 assert!(result.constraints.is_empty());
846 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("fn\\s+\\w+"));
847
848 let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
850 assert_eq!(
851 q.grep_text(),
852 "*.rs foo",
853 "Escaped constraint \\*.rs should still have backslash stripped"
854 );
855
856 let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
857 assert_eq!(
858 q.grep_text(),
859 "/src/ foo",
860 "Escaped constraint \\/src/ should still have backslash stripped"
861 );
862 }
863
864 #[test]
865 fn test_grep_bare_star_is_text() {
866 let parser = QueryParser::new(GrepConfig);
867 let result = parser.parse("a*b something");
869 assert_eq!(
870 result.constraints.len(),
871 0,
872 "bare * without / should be text"
873 );
874 }
875
876 #[test]
877 fn test_grep_negated_text() {
878 let parser = QueryParser::new(GrepConfig);
879 let result = parser.parse("pattern !test");
880 assert_eq!(result.constraints.len(), 1);
881 match &result.constraints[0] {
882 Constraint::Not(inner) => {
883 assert!(
884 matches!(**inner, Constraint::Text("test")),
885 "Expected Not(Text(\"test\")), got Not({:?})",
886 inner
887 );
888 }
889 other => panic!("Expected Not constraint, got {:?}", other),
890 }
891 }
892
893 #[test]
894 fn test_grep_negated_path_segment() {
895 let parser = QueryParser::new(GrepConfig);
896 let result = parser.parse("pattern !/src/");
897 assert_eq!(result.constraints.len(), 1);
898 match &result.constraints[0] {
899 Constraint::Not(inner) => {
900 assert!(
901 matches!(**inner, Constraint::PathSegment("src")),
902 "Expected Not(PathSegment(\"src\")), got Not({:?})",
903 inner
904 );
905 }
906 other => panic!("Expected Not constraint, got {:?}", other),
907 }
908 }
909
910 #[test]
911 fn test_grep_negated_extension() {
912 let parser = QueryParser::new(GrepConfig);
913 let result = parser.parse("pattern !*.rs");
914 assert_eq!(result.constraints.len(), 1);
915 match &result.constraints[0] {
916 Constraint::Not(inner) => {
917 assert!(
918 matches!(**inner, Constraint::Extension("rs")),
919 "Expected Not(Extension(\"rs\")), got Not({:?})",
920 inner
921 );
922 }
923 other => panic!("Expected Not constraint, got {:?}", other),
924 }
925 }
926
927 #[test]
928 fn test_ai_grep_detects_file_path() {
929 use crate::AiGrepConfig;
930 let parser = QueryParser::new(AiGrepConfig);
931 let result = parser.parse("libswscale/input.c rgba32ToY");
932 assert_eq!(result.constraints.len(), 1);
933 assert!(
934 matches!(
935 result.constraints[0],
936 Constraint::FilePath("libswscale/input.c")
937 ),
938 "Expected FilePath, got {:?}",
939 result.constraints[0]
940 );
941 assert_eq!(result.grep_text(), "rgba32ToY");
942 }
943
944 #[test]
945 fn test_ai_grep_detects_nested_file_path() {
946 use crate::AiGrepConfig;
947 let parser = QueryParser::new(AiGrepConfig);
948 let result = parser.parse("src/main.rs fn main");
949 assert_eq!(result.constraints.len(), 1);
950 assert!(matches!(
951 result.constraints[0],
952 Constraint::FilePath("src/main.rs")
953 ));
954 assert_eq!(result.grep_text(), "fn main");
955 }
956
957 #[test]
958 fn test_ai_grep_no_false_positive_trailing_slash() {
959 use crate::AiGrepConfig;
960 let parser = QueryParser::new(AiGrepConfig);
961 let result = parser.parse("src/ pattern");
962 assert_eq!(result.constraints.len(), 1);
964 assert!(
965 matches!(result.constraints[0], Constraint::PathSegment("src")),
966 "Expected PathSegment, got {:?}",
967 result.constraints[0]
968 );
969 }
970
971 #[test]
972 fn test_ai_grep_bare_filename_is_file_path() {
973 use crate::AiGrepConfig;
974 let parser = QueryParser::new(AiGrepConfig);
975 let result = parser.parse("main.rs pattern");
976 assert_eq!(result.constraints.len(), 1);
978 assert!(
979 matches!(result.constraints[0], Constraint::FilePath("main.rs")),
980 "Expected FilePath, got {:?}",
981 result.constraints[0]
982 );
983 assert_eq!(result.grep_text(), "pattern");
984 }
985
986 #[test]
987 fn test_ai_grep_filename_with_pathsegment_only_promotes_to_text() {
988 use crate::AiGrepConfig;
993 let parser = QueryParser::new(AiGrepConfig);
994 let result = parser.parse("chrome/browser/profiles/ profile.h");
995 assert_eq!(result.constraints.len(), 1);
996 assert!(
997 matches!(
998 result.constraints[0],
999 Constraint::PathSegment("chrome/browser/profiles")
1000 ),
1001 "Expected single PathSegment, got {:?}",
1002 result.constraints
1003 );
1004 assert_eq!(result.grep_text(), "profile.h");
1005 }
1006
1007 #[test]
1008 fn test_ai_grep_leading_slash_path_alone_is_text_not_path_segment() {
1009 use crate::AiGrepConfig;
1014 let parser = QueryParser::new(AiGrepConfig);
1015
1016 let result = parser.parse("/api/tests/");
1018 assert_eq!(
1019 result.constraints.len(),
1020 0,
1021 "Expected no constraints for '/api/tests/', got {:?}",
1022 result.constraints
1023 );
1024 assert!(
1025 matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests/")),
1026 "Expected FuzzyQuery::Text, got {:?}",
1027 result.fuzzy_query
1028 );
1029
1030 let result = parser.parse("/api/tests");
1032 assert_eq!(
1033 result.constraints.len(),
1034 0,
1035 "Expected no constraints for '/api/tests', got {:?}",
1036 result.constraints
1037 );
1038 assert!(
1039 matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests")),
1040 "Expected FuzzyQuery::Text, got {:?}",
1041 result.fuzzy_query
1042 );
1043 }
1044
1045 #[test]
1046 fn test_grep_leading_slash_path_alone_is_text_not_path_segment() {
1047 let parser = QueryParser::new(GrepConfig);
1050
1051 let result = parser.parse("/api/tests/");
1052 assert_eq!(
1053 result.constraints.len(),
1054 0,
1055 "GrepConfig: expected no constraints for '/api/tests/', got {:?}",
1056 result.constraints
1057 );
1058 assert!(
1059 matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests/")),
1060 "GrepConfig: expected FuzzyQuery::Text, got {:?}",
1061 result.fuzzy_query
1062 );
1063
1064 let result = parser.parse("/api/tests");
1065 assert_eq!(
1066 result.constraints.len(),
1067 0,
1068 "GrepConfig: expected no constraints for '/api/tests', got {:?}",
1069 result.constraints
1070 );
1071 assert!(
1072 matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests")),
1073 "GrepConfig: expected FuzzyQuery::Text, got {:?}",
1074 result.fuzzy_query
1075 );
1076 }
1077
1078 #[test]
1079 fn test_ai_grep_filename_with_extension_only_promotes_to_text() {
1080 use crate::AiGrepConfig;
1083 let parser = QueryParser::new(AiGrepConfig);
1084 let result = parser.parse("*.h profile.h");
1085 assert_eq!(result.constraints.len(), 1);
1086 assert!(
1087 matches!(result.constraints[0], Constraint::Extension("h")),
1088 "Expected Extension, got {:?}",
1089 result.constraints
1090 );
1091 assert_eq!(result.grep_text(), "profile.h");
1092 }
1093
1094 #[test]
1095 fn test_ai_grep_filename_with_other_text_keeps_filepath() {
1096 use crate::AiGrepConfig;
1099 let parser = QueryParser::new(AiGrepConfig);
1100 let result = parser.parse("main.rs pattern");
1101 assert_eq!(result.constraints.len(), 1);
1102 assert!(
1103 matches!(result.constraints[0], Constraint::FilePath("main.rs")),
1104 "Expected FilePath, got {:?}",
1105 result.constraints
1106 );
1107 assert_eq!(result.grep_text(), "pattern");
1108 }
1109
1110 #[test]
1111 fn test_ai_grep_bare_filename_schema_rs() {
1112 use crate::AiGrepConfig;
1113 let parser = QueryParser::new(AiGrepConfig);
1114 let result = parser.parse("schema.rs part_revisions");
1115 assert_eq!(result.constraints.len(), 1);
1116 assert!(
1117 matches!(result.constraints[0], Constraint::FilePath("schema.rs")),
1118 "Expected FilePath(schema.rs), got {:?}",
1119 result.constraints[0]
1120 );
1121 assert_eq!(result.grep_text(), "part_revisions");
1122 }
1123
1124 #[test]
1125 fn test_ai_grep_bare_word_no_extension_not_constraint() {
1126 use crate::AiGrepConfig;
1127 let parser = QueryParser::new(AiGrepConfig);
1128 let result = parser.parse("schema pattern");
1129 assert_eq!(result.constraints.len(), 0);
1131 assert_eq!(result.grep_text(), "schema pattern");
1132 }
1133
1134 #[test]
1135 fn test_ai_grep_no_false_positive_no_extension() {
1136 use crate::AiGrepConfig;
1137 let parser = QueryParser::new(AiGrepConfig);
1138 let result = parser.parse("src/utils pattern");
1139 assert_eq!(result.constraints.len(), 0);
1141 assert_eq!(result.grep_text(), "src/utils pattern");
1142 }
1143
1144 #[test]
1145 fn test_ai_grep_wildcard_not_filepath() {
1146 use crate::AiGrepConfig;
1147 let parser = QueryParser::new(AiGrepConfig);
1148 let result = parser.parse("src/**/*.rs pattern");
1149 assert_eq!(result.constraints.len(), 1);
1151 assert!(
1152 matches!(result.constraints[0], Constraint::Glob("src/**/*.rs")),
1153 "Expected Glob, got {:?}",
1154 result.constraints[0]
1155 );
1156 }
1157
1158 #[test]
1159 fn test_ai_grep_star_text_star_is_glob() {
1160 use crate::AiGrepConfig;
1161 let parser = QueryParser::new(AiGrepConfig);
1162 let result = parser.parse("*quote* TODO");
1163 assert_eq!(result.constraints.len(), 1);
1165 assert!(
1166 matches!(result.constraints[0], Constraint::Glob("*quote*")),
1167 "Expected Glob(*quote*), got {:?}",
1168 result.constraints[0]
1169 );
1170 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("TODO"));
1171 }
1172
1173 #[test]
1174 fn test_ai_grep_bare_star_not_glob() {
1175 use crate::AiGrepConfig;
1176 let parser = QueryParser::new(AiGrepConfig);
1177 let result = parser.parse("* pattern");
1178 assert!(
1180 result.constraints.is_empty(),
1181 "Expected no constraints, got {:?}",
1182 result.constraints
1183 );
1184 }
1185
1186 #[test]
1187 fn test_grep_no_location_parsing_single_token() {
1188 let parser = QueryParser::new(GrepConfig);
1189 let result = parser.parse("localhost:8080");
1191 assert!(result.constraints.is_empty());
1192 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("localhost:8080"));
1193 }
1194
1195 #[test]
1196 fn test_grep_no_location_parsing_multi_token() {
1197 let q = QueryParser::new(GrepConfig).parse("*.rs localhost:8080");
1198 assert_eq!(
1199 q.grep_text(),
1200 "localhost:8080",
1201 "Colon-number suffix should be preserved in grep text"
1202 );
1203 assert!(
1204 q.location.is_none(),
1205 "Grep should not parse location from colon-number"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_grep_reversed_braces_does_not_panic() {
1211 for query in [
1214 "}{",
1215 "}{ foo",
1216 "foo }{",
1217 "a}{b",
1218 "}}{{",
1219 "} something {{{ {}}}d{ {}}}}{{{ }}}}}d{d something {{}}}}}}",
1220 ] {
1221 let result = QueryParser::new(GrepConfig).parse(query);
1222 assert!(
1225 !result
1226 .constraints
1227 .iter()
1228 .any(|c| matches!(c, Constraint::Glob(_))),
1229 "GrepConfig: {query:?} produced a Glob constraint, got {:?}",
1230 result.constraints
1231 );
1232
1233 let result = QueryParser::new(crate::AiGrepConfig).parse(query);
1234 assert!(
1235 !result
1236 .constraints
1237 .iter()
1238 .any(|c| matches!(c, Constraint::Glob(_))),
1239 "AiGrepConfig: {query:?} produced a Glob constraint, got {:?}",
1240 result.constraints
1241 );
1242 }
1243 }
1244
1245 #[test]
1246 fn test_grep_braces_without_comma_is_text() {
1247 let parser = QueryParser::new(GrepConfig);
1248 let result = parser.parse(r#"format!("{}\\AppData", home)"#);
1250 assert!(
1251 result.constraints.is_empty(),
1252 "Braces without comma should be text, got {:?}",
1253 result.constraints
1254 );
1255 assert_eq!(result.grep_text(), r#"format!("{}\\AppData", home)"#);
1256 }
1257
1258 #[test]
1259 fn test_grep_valid_brace_expansion_amid_junk_braces() {
1260 let parser = QueryParser::new(GrepConfig);
1265 let result = parser.parse("}{ {{}} }}{{ {} {src,lib} pattern");
1266
1267 let glob_constraints: Vec<&str> = result
1268 .constraints
1269 .iter()
1270 .filter_map(|c| match c {
1271 Constraint::Glob(p) => Some(*p),
1272 _ => None,
1273 })
1274 .collect();
1275 assert_eq!(
1276 glob_constraints,
1277 vec!["{src,lib}"],
1278 "Expected exactly one Glob({{src,lib}}), got {:?}",
1279 result.constraints
1280 );
1281
1282 let parser = QueryParser::new(crate::AiGrepConfig);
1284 let result = parser.parse("}{ {{}} }}{{ {} {src,lib} pattern");
1285 let glob_constraints: Vec<&str> = result
1286 .constraints
1287 .iter()
1288 .filter_map(|c| match c {
1289 Constraint::Glob(p) => Some(*p),
1290 _ => None,
1291 })
1292 .collect();
1293 assert_eq!(
1294 glob_constraints,
1295 vec!["{src,lib}"],
1296 "AiGrepConfig: expected Glob({{src,lib}}), got {:?}",
1297 result.constraints
1298 );
1299 }
1300
1301 #[test]
1302 fn test_grep_format_braces_not_glob() {
1303 let parser = QueryParser::new(GrepConfig);
1304 let input = "format!(\"{}\\\\AppData\", home)";
1308 let result = parser.parse(input);
1309 assert!(
1310 result.constraints.is_empty(),
1311 "format! pattern should have no constraints, got {:?}",
1312 result.constraints
1313 );
1314 }
1315
1316 #[test]
1317 fn test_grep_config_star_text_star_not_glob() {
1318 use crate::GrepConfig;
1319 let parser = QueryParser::new(GrepConfig);
1320 let result = parser.parse("*quote* TODO");
1321 assert!(
1323 result.constraints.is_empty(),
1324 "Expected no constraints in GrepConfig, got {:?}",
1325 result.constraints
1326 );
1327 }
1328
1329 #[test]
1330 fn test_file_picker_bare_filename_constraint() {
1331 let parser = QueryParser::new(FilenameConstraintConfig);
1332 let result = parser.parse("score.rs file_picker");
1333 assert_eq!(result.constraints.len(), 1);
1334 assert!(
1335 matches!(result.constraints[0], Constraint::FilePath("score.rs")),
1336 "Expected FilePath(\"score.rs\"), got {:?}",
1337 result.constraints[0]
1338 );
1339 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("file_picker"));
1340 }
1341
1342 #[test]
1343 fn test_file_picker_path_prefixed_filename_constraint() {
1344 let parser = QueryParser::new(FilenameConstraintConfig);
1345 let result = parser.parse("libswscale/slice.c lum_convert");
1346 assert_eq!(result.constraints.len(), 1);
1347 assert!(
1348 matches!(
1349 result.constraints[0],
1350 Constraint::FilePath("libswscale/slice.c")
1351 ),
1352 "Expected FilePath(\"libswscale/slice.c\"), got {:?}",
1353 result.constraints[0]
1354 );
1355 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("lum_convert"));
1356 }
1357
1358 #[test]
1359 fn test_file_picker_single_token_filename_stays_fuzzy() {
1360 let parser = QueryParser::new(FileSearchConfig);
1361 let result = parser.parse("score.rs");
1364 assert!(result.constraints.is_empty());
1365 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
1366 }
1367
1368 #[test]
1369 fn test_absolute_path_with_location_not_path_segment() {
1370 let parser = QueryParser::new(FileSearchConfig);
1371 let result = parser.parse("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs:12");
1374 assert!(
1375 result.constraints.is_empty(),
1376 "Absolute path with location should not become a constraint, got {:?}",
1377 result.constraints
1378 );
1379 assert_eq!(
1380 result.fuzzy_query,
1381 FuzzyQuery::Text("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs")
1382 );
1383 assert_eq!(result.location, Some(Location::Line(12)));
1384 }
1385
1386 #[test]
1387 fn test_file_picker_filename_with_multiple_fuzzy_parts() {
1388 let parser = QueryParser::new(FilenameConstraintConfig);
1389 let result = parser.parse("main.rs src components");
1390 assert_eq!(result.constraints.len(), 1);
1391 assert!(matches!(
1392 result.constraints[0],
1393 Constraint::FilePath("main.rs")
1394 ));
1395 assert_eq!(
1396 result.fuzzy_query,
1397 FuzzyQuery::Parts(vec!["src", "components"])
1398 );
1399 }
1400
1401 #[test]
1402 fn test_file_picker_version_number_not_filename() {
1403 let parser = QueryParser::new(FileSearchConfig);
1404 let result = parser.parse("v2.0 release");
1405 assert!(
1407 result.constraints.is_empty(),
1408 "v2.0 should not be a FilePath constraint, got {:?}",
1409 result.constraints
1410 );
1411 }
1412
1413 #[test]
1414 fn test_file_picker_only_one_filepath_constraint() {
1415 let parser = QueryParser::new(FilenameConstraintConfig);
1416 let result = parser.parse("main.rs score.rs");
1417 assert_eq!(result.constraints.len(), 1);
1419 assert!(matches!(
1420 result.constraints[0],
1421 Constraint::FilePath("main.rs")
1422 ));
1423 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
1424 }
1425
1426 #[test]
1427 fn test_file_picker_filename_with_extension_constraint() {
1428 let parser = QueryParser::new(FileSearchConfig);
1429 let result = parser.parse("main.rs *.lua");
1430 assert_eq!(result.constraints.len(), 1);
1435 assert!(matches!(
1436 result.constraints[0],
1437 Constraint::Extension("lua")
1438 ));
1439 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("main.rs"));
1440 }
1441
1442 #[test]
1443 fn test_file_picker_dotfile_is_filename() {
1444 let parser = QueryParser::new(FilenameConstraintConfig);
1445 let result = parser.parse(".gitignore src");
1446 assert_eq!(result.constraints.len(), 1);
1447 assert!(
1448 matches!(result.constraints[0], Constraint::FilePath(".gitignore")),
1449 "Expected FilePath(\".gitignore\"), got {:?}",
1450 result.constraints[0]
1451 );
1452 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("src"));
1453 }
1454
1455 #[test]
1456 fn test_file_picker_no_extension_not_filename() {
1457 let parser = QueryParser::new(FileSearchConfig);
1458 let result = parser.parse("Makefile src");
1459 assert!(
1461 result.constraints.is_empty(),
1462 "Makefile should not be a FilePath constraint, got {:?}",
1463 result.constraints
1464 );
1465 }
1466}