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