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