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