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_constraints<'a>(&self, query: &'a str) -> ConstraintVec<'a> {
54 query
55 .split_whitespace()
56 .filter_map(|token| parse_token(token, &self.config))
57 .collect()
58 }
59
60 pub fn parse<'a>(&self, query: &'a str) -> FFFQuery<'a> {
61 let raw_query = query;
62 let config: &C = &self.config;
63 let mut constraints = ConstraintVec::new();
64 let query = query.trim();
65
66 let whitespace_count = query.chars().filter(|c| c.is_whitespace()).count();
67
68 if whitespace_count == 0 {
70 if let Some(constraint) = parse_token(query, config) {
72 let has_location_suffix = matches!(constraint, Constraint::PathSegment(_))
81 && query.bytes().any(|b| b == b':')
82 && query
83 .bytes()
84 .rev()
85 .take_while(|&b| b != b':')
86 .all(|b| b.is_ascii_digit());
87
88 let treat_as_text = matches!(constraint, Constraint::PathSegment(_))
90 && config.treat_lone_path_as_text();
91
92 if !matches!(constraint, Constraint::FilePath(_))
93 && !has_location_suffix
94 && !treat_as_text
95 {
96 constraints.push(constraint);
97 return FFFQuery {
98 raw_query,
99 constraints,
100 fuzzy_query: FuzzyQuery::Empty,
101 location: None,
102 };
103 }
104 }
105
106 if config.enable_location() {
108 let (query_without_loc, location) = parse_location(query);
109 if location.is_some() {
110 return FFFQuery {
111 raw_query,
112 constraints,
113 fuzzy_query: FuzzyQuery::Text(query_without_loc),
114 location,
115 };
116 }
117 }
118
119 return FFFQuery {
121 raw_query,
122 constraints,
123 fuzzy_query: if query.is_empty() {
124 FuzzyQuery::Empty
125 } else {
126 FuzzyQuery::Text(query)
127 },
128 location: None,
129 };
130 }
131
132 let mut text_parts = TextPartsBuffer::new();
133 let tokens = query.split_whitespace();
134
135 let mut has_file_path = false;
136 let mut file_path_constraint_idx: Option<usize> = None;
139 let mut file_path_token: Option<&str> = None;
140 for token in tokens {
141 match parse_token(token, config) {
142 Some(Constraint::FilePath(_)) => {
143 if has_file_path {
144 text_parts.push(token);
148 } else {
149 file_path_constraint_idx = Some(constraints.len());
150 file_path_token = Some(token);
151 constraints.push(Constraint::FilePath(token));
152 has_file_path = true;
153 }
154 }
155 Some(constraint) => {
156 constraints.push(constraint);
157 }
158 None => {
159 text_parts.push(token);
160 }
161 }
162 }
163
164 if text_parts.is_empty()
172 && let Some(idx) = file_path_constraint_idx
173 && let Some(tok) = file_path_token
174 {
175 constraints.remove(idx);
176 text_parts.push(tok);
177 }
178
179 let location = if config.enable_location() && !text_parts.is_empty() {
182 let last_idx = text_parts.len() - 1;
183 let (without_loc, loc) = parse_location(text_parts[last_idx]);
184 if loc.is_some() {
185 text_parts[last_idx] = without_loc;
187 loc
188 } else {
189 None
190 }
191 } else {
192 None
193 };
194
195 let fuzzy_query = if text_parts.is_empty() {
196 FuzzyQuery::Empty
197 } else if text_parts.len() == 1 {
198 if text_parts[0].is_empty() {
200 FuzzyQuery::Empty
201 } else {
202 FuzzyQuery::Text(text_parts[0])
203 }
204 } else {
205 if text_parts.iter().all(|p| p.is_empty()) {
207 FuzzyQuery::Empty
208 } else {
209 FuzzyQuery::Parts(text_parts)
210 }
211 };
212
213 FFFQuery {
214 raw_query,
215 constraints,
216 fuzzy_query,
217 location,
218 }
219 }
220}
221
222impl<'a> FFFQuery<'a> {
223 pub fn grep_text(&self) -> String {
233 match &self.fuzzy_query {
234 FuzzyQuery::Empty => String::new(),
235 FuzzyQuery::Text(t) => strip_leading_backslash(t).to_string(),
236 FuzzyQuery::Parts(parts) => parts
237 .iter()
238 .map(|t| strip_leading_backslash(t))
239 .collect::<Vec<_>>()
240 .join(" "),
241 }
242 }
243}
244
245#[inline]
252fn strip_leading_backslash(token: &str) -> &str {
253 if token.len() > 1 && token.starts_with('\\') {
254 let next = token.as_bytes()[1];
255 if next == b'*' || next == b'/' || next == b'!' {
257 return &token[1..];
258 }
259 }
260 token
261}
262
263impl Default for QueryParser<crate::FileSearchConfig> {
264 fn default() -> Self {
265 Self::new(crate::FileSearchConfig)
266 }
267}
268
269#[inline]
270fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constraint<'a>> {
271 if token.starts_with('\\') && token.len() > 1 {
274 return None;
275 }
276
277 let first_byte = token.as_bytes().first()?;
278
279 match first_byte {
280 b'*' if config.enable_extension() => {
281 if token == "*" || token == "*." {
283 return None;
284 }
285
286 if let Some(constraint) = parse_extension(token) {
288 let ext_part = &token[2..];
291 if !has_wildcards(ext_part) {
292 return Some(constraint);
293 }
294 }
295 if config.enable_glob() && config.is_glob_pattern(token) {
297 return Some(Constraint::Glob(token));
298 }
299 None
300 }
301 b'!' if config.enable_exclude() => parse_negation(token, config),
302 b'/' if config.enable_path_segments() => parse_path_segment(token),
303 _ if config.enable_path_segments() && token.ends_with('/') => {
305 parse_path_segment_trailing(token)
306 }
307 _ if config.enable_filename_constraint()
309 && !token.ends_with('/')
310 && Constraint::is_filename_constraint_token(token) =>
311 {
312 Some(Constraint::FilePath(token))
313 }
314 _ => {
315 if config.enable_glob() && config.is_glob_pattern(token) {
317 return Some(Constraint::Glob(token));
318 }
319
320 if let Some(colon_idx) = memchr(b':', token.as_bytes()) {
322 let (key, value_with_colon) = token.split_at(colon_idx);
323 let value = &value_with_colon[1..]; match key {
326 "type" if config.enable_type_filter() => {
327 return Some(Constraint::FileType(value));
328 }
329 "status" | "st" | "g" | "git" if config.enable_git_status() => {
330 return parse_git_status(value);
331 }
332 _ => {}
333 }
334 }
335
336 config.parse_custom(token)
338 }
339 }
340}
341
342#[inline]
345fn memchr(needle: u8, haystack: &[u8]) -> Option<usize> {
346 haystack.iter().position(|&b| b == needle)
347}
348
349#[inline]
351fn parse_extension(token: &str) -> Option<Constraint<'_>> {
352 if token.len() > 2 && token.starts_with("*.") {
353 Some(Constraint::Extension(&token[2..]))
354 } else {
355 None
356 }
357}
358
359#[inline]
362fn parse_negation<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constraint<'a>> {
363 if token.len() <= 1 {
364 return None;
365 }
366
367 let inner_token = &token[1..];
368
369 if let Some(inner_constraint) = parse_token_without_negation(inner_token, config) {
371 return Some(Constraint::Not(Box::new(inner_constraint)));
373 }
374
375 if inner_token.len() < 3 || !inner_token.chars().any(|c| c.is_alphanumeric()) {
378 return None;
379 }
380 Some(Constraint::Not(Box::new(Constraint::Text(inner_token))))
381}
382
383#[inline]
385fn parse_token_without_negation<'a, C: ParserConfig>(
386 token: &'a str,
387 config: &C,
388) -> Option<Constraint<'a>> {
389 if token.starts_with('\\') && token.len() > 1 {
391 return None;
392 }
393
394 let first_byte = token.as_bytes().first()?;
395
396 match first_byte {
397 b'*' if config.enable_extension() => {
398 if let Some(constraint) = parse_extension(token) {
400 let ext_part = &token[2..];
401 if !has_wildcards(ext_part) {
402 return Some(constraint);
403 }
404 }
405 if config.enable_glob() && config.is_glob_pattern(token) {
407 return Some(Constraint::Glob(token));
408 }
409 None
410 }
411 b'/' if config.enable_path_segments() => parse_path_segment(token),
412 _ if config.enable_path_segments() && token.ends_with('/') => {
413 parse_path_segment_trailing(token)
415 }
416 _ => {
417 if config.enable_glob() && config.is_glob_pattern(token) {
419 return Some(Constraint::Glob(token));
420 }
421
422 if let Some(colon_idx) = memchr(b':', token.as_bytes()) {
424 let (key, value_with_colon) = token.split_at(colon_idx);
425 let value = &value_with_colon[1..]; match key {
428 "type" if config.enable_type_filter() => {
429 return Some(Constraint::FileType(value));
430 }
431 "status" | "st" | "g" | "git" if config.enable_git_status() => {
432 return parse_git_status(value);
433 }
434 _ => {}
435 }
436 }
437
438 if config.enable_filename_constraint()
439 && Constraint::is_filename_constraint_token(token)
440 {
441 return Some(Constraint::FilePath(token));
442 }
443
444 config.parse_custom(token)
445 }
446 }
447}
448
449#[inline]
451fn parse_path_segment(token: &str) -> Option<Constraint<'_>> {
452 if token.len() > 1 && token.starts_with('/') {
453 let segment = token.trim_start_matches('/').trim_end_matches('/');
454 if !segment.is_empty() {
455 Some(Constraint::PathSegment(segment))
456 } else {
457 None
458 }
459 } else {
460 None
461 }
462}
463
464#[inline]
467fn parse_path_segment_trailing(token: &str) -> Option<Constraint<'_>> {
468 if token.len() > 1 && token.ends_with('/') {
469 let segment = token.trim_end_matches('/');
470 if !segment.is_empty() {
471 Some(Constraint::PathSegment(segment))
472 } else {
473 None
474 }
475 } else {
476 None
477 }
478}
479
480#[inline]
482fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
483 if value == "*" {
484 return None;
485 }
486
487 if "modified".starts_with(value) {
488 return Some(Constraint::GitStatus(GitStatusFilter::Modified));
489 }
490
491 if "untracked".starts_with(value) {
492 return Some(Constraint::GitStatus(GitStatusFilter::Untracked));
493 }
494
495 if "staged".starts_with(value) {
496 return Some(Constraint::GitStatus(GitStatusFilter::Staged));
497 }
498
499 if "clean".starts_with(value) {
500 return Some(Constraint::GitStatus(GitStatusFilter::Unmodified));
501 }
502
503 None
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::{AiGrepConfig, FileSearchConfig, GrepConfig};
510
511 struct FilenameConstraintConfig;
514
515 impl ParserConfig for FilenameConstraintConfig {
516 fn enable_filename_constraint(&self) -> bool {
517 true
518 }
519 }
520
521 #[test]
522 fn test_parse_extension() {
523 assert_eq!(parse_extension("*.rs"), Some(Constraint::Extension("rs")));
524 assert_eq!(
525 parse_extension("*.toml"),
526 Some(Constraint::Extension("toml"))
527 );
528 assert_eq!(parse_extension("*"), None);
529 assert_eq!(parse_extension("*."), None);
530 }
531
532 #[test]
533 fn test_incomplete_patterns_ignored() {
534 let config = FileSearchConfig;
535 assert_eq!(parse_token("*", &config), None);
537 assert_eq!(parse_token("*.", &config), None);
538 }
539
540 #[test]
541 fn test_parse_path_segment() {
542 assert_eq!(
543 parse_path_segment("/src/"),
544 Some(Constraint::PathSegment("src"))
545 );
546 assert_eq!(
547 parse_path_segment("/lib"),
548 Some(Constraint::PathSegment("lib"))
549 );
550 assert_eq!(parse_path_segment("/"), None);
551 }
552
553 #[test]
554 fn test_parse_path_segment_trailing() {
555 assert_eq!(
556 parse_path_segment_trailing("www/"),
557 Some(Constraint::PathSegment("www"))
558 );
559 assert_eq!(
560 parse_path_segment_trailing("src/"),
561 Some(Constraint::PathSegment("src"))
562 );
563 assert_eq!(
565 parse_path_segment_trailing("src/lib/"),
566 Some(Constraint::PathSegment("src/lib"))
567 );
568 assert_eq!(
569 parse_path_segment_trailing("libswscale/aarch64/"),
570 Some(Constraint::PathSegment("libswscale/aarch64"))
571 );
572 assert_eq!(parse_path_segment_trailing("www"), None);
574 }
575
576 #[test]
577 fn test_trailing_slash_in_query() {
578 let parser = QueryParser::new(FileSearchConfig);
579 let result = parser.parse("www/ test");
580 assert_eq!(result.constraints.len(), 1);
581 assert!(matches!(
582 result.constraints[0],
583 Constraint::PathSegment("www")
584 ));
585 assert!(matches!(result.fuzzy_query, FuzzyQuery::Text("test")));
586 }
587
588 #[test]
589 fn test_parse_git_status() {
590 assert_eq!(
591 parse_git_status("modified"),
592 Some(Constraint::GitStatus(GitStatusFilter::Modified))
593 );
594 assert_eq!(
595 parse_git_status("m"),
596 Some(Constraint::GitStatus(GitStatusFilter::Modified))
597 );
598 assert_eq!(
599 parse_git_status("untracked"),
600 Some(Constraint::GitStatus(GitStatusFilter::Untracked))
601 );
602 assert_eq!(parse_git_status("invalid"), None);
603 }
604
605 #[test]
606 fn test_memchr() {
607 assert_eq!(memchr(b':', b"type:rust"), Some(4));
608 assert_eq!(memchr(b':', b"nocolon"), None);
609 assert_eq!(memchr(b':', b":start"), Some(0));
610 }
611
612 #[test]
613 fn test_negation_text() {
614 let parser = QueryParser::new(FileSearchConfig);
615 let result = parser.parse("!test foo");
617 assert_eq!(result.constraints.len(), 1);
618 match &result.constraints[0] {
619 Constraint::Not(inner) => {
620 assert!(matches!(**inner, Constraint::Text("test")));
621 }
622 _ => panic!("Expected Not constraint"),
623 }
624 }
625
626 #[test]
627 fn test_negation_operators_stay_literal() {
628 let parser = QueryParser::new(GrepConfig);
629 for query in [
631 "Ordering::Acquire) != delivery.epoch",
632 "a !== b",
633 "x !! y",
634 "foo !~ bar",
635 ] {
636 let result = parser.parse(query);
637 assert!(
638 result.constraints.is_empty(),
639 "{query:?} produced constraints {:?}",
640 result.constraints
641 );
642 assert_eq!(result.grep_text(), query, "grep text must equal raw query");
643 }
644 }
645
646 #[test]
647 fn test_negation_short_text_stays_literal() {
648 let parser = QueryParser::new(FileSearchConfig);
649 let result = parser.parse("!ab foo");
651 assert!(result.constraints.is_empty());
652 let result = parser.parse("!abc foo");
654 assert_eq!(result.constraints.len(), 1);
655 assert!(matches!(&result.constraints[0], Constraint::Not(_)));
656 }
657
658 #[test]
659 fn test_negation_extension() {
660 let parser = QueryParser::new(FileSearchConfig);
661 let result = parser.parse("!*.rs foo");
662 assert_eq!(result.constraints.len(), 1);
663 match &result.constraints[0] {
664 Constraint::Not(inner) => {
665 assert!(matches!(**inner, Constraint::Extension("rs")));
666 }
667 _ => panic!("Expected Not(Extension) constraint"),
668 }
669 }
670
671 #[test]
672 fn test_negation_path_segment() {
673 let parser = QueryParser::new(FileSearchConfig);
674 let result = parser.parse("!/src/ foo");
675 assert_eq!(result.constraints.len(), 1);
676 match &result.constraints[0] {
677 Constraint::Not(inner) => {
678 assert!(matches!(**inner, Constraint::PathSegment("src")));
679 }
680 _ => panic!("Expected Not(PathSegment) constraint"),
681 }
682 }
683
684 #[test]
685 fn test_negation_git_status() {
686 let parser = QueryParser::new(FileSearchConfig);
687 let result = parser.parse("!status:modified foo");
688 assert_eq!(result.constraints.len(), 1);
689 match &result.constraints[0] {
690 Constraint::Not(inner) => {
691 assert!(matches!(
692 **inner,
693 Constraint::GitStatus(GitStatusFilter::Modified)
694 ));
695 }
696 _ => panic!("Expected Not(GitStatus) constraint"),
697 }
698 }
699
700 #[test]
701 fn test_negation_git_status_all_key_aliases() {
702 let parser = QueryParser::new(FileSearchConfig);
703 for key in ["status", "st", "g", "git"] {
704 let query = format!("!{key}:modified foo");
705 let result = parser.parse(&query);
706 assert_eq!(
707 result.constraints.len(),
708 1,
709 "!{key}:modified should produce exactly one constraint"
710 );
711 match &result.constraints[0] {
712 Constraint::Not(inner) => assert!(
713 matches!(**inner, Constraint::GitStatus(GitStatusFilter::Modified)),
714 "!{key}:modified expected Not(GitStatus(Modified)), got Not({inner:?})"
715 ),
716 other => {
717 panic!("!{key}:modified expected Not(GitStatus), got {other:?}")
718 }
719 }
720 }
721 }
722
723 #[test]
724 fn test_backslash_escape_extension() {
725 let parser = QueryParser::new(FileSearchConfig);
726 let result = parser.parse("\\*.rs foo");
727 assert_eq!(result.constraints.len(), 0);
729 match result.fuzzy_query {
731 FuzzyQuery::Parts(parts) => {
732 assert_eq!(parts.len(), 2);
733 assert_eq!(parts[0], "\\*.rs");
734 assert_eq!(parts[1], "foo");
735 }
736 _ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
737 }
738 }
739
740 #[test]
741 fn test_backslash_escape_path_segment() {
742 let parser = QueryParser::new(FileSearchConfig);
743 let result = parser.parse("\\/src/ foo");
744 assert_eq!(result.constraints.len(), 0);
745 match result.fuzzy_query {
746 FuzzyQuery::Parts(parts) => {
747 assert_eq!(parts[0], "\\/src/");
748 assert_eq!(parts[1], "foo");
749 }
750 _ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
751 }
752 }
753
754 #[test]
755 fn test_backslash_escape_negation() {
756 let parser = QueryParser::new(FileSearchConfig);
757 let result = parser.parse("\\!test foo");
758 assert_eq!(result.constraints.len(), 0);
759 }
760
761 #[test]
762 fn test_grep_text_plain_text() {
763 let q = QueryParser::new(GrepConfig).parse("name =");
765 assert_eq!(q.grep_text(), "name =");
766 }
767
768 #[test]
769 fn test_grep_text_strips_constraint() {
770 let q = QueryParser::new(GrepConfig).parse("name = *.rs someth");
771 assert_eq!(q.grep_text(), "name = someth");
772 }
773
774 #[test]
775 fn test_grep_text_leading_constraint() {
776 let q = QueryParser::new(GrepConfig).parse("*.rs name =");
777 assert_eq!(q.grep_text(), "name =");
778 }
779
780 #[test]
781 fn test_grep_text_only_constraints() {
782 let q = QueryParser::new(GrepConfig).parse("*.rs /src/");
783 assert_eq!(q.grep_text(), "");
784 }
785
786 #[test]
787 fn test_grep_text_path_constraint() {
788 let q = QueryParser::new(GrepConfig).parse("name /src/ value");
789 assert_eq!(q.grep_text(), "name value");
790 }
791
792 #[test]
793 fn test_grep_text_negation_constraint() {
794 let q = QueryParser::new(GrepConfig).parse("name !*.rs value");
795 assert_eq!(q.grep_text(), "name value");
796 }
797
798 #[test]
799 fn test_grep_text_backslash_escape_stripped() {
800 let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
802 assert_eq!(q.grep_text(), "*.rs foo");
803
804 let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
805 assert_eq!(q.grep_text(), "/src/ foo");
806
807 let q = QueryParser::new(GrepConfig).parse("\\!test foo");
808 assert_eq!(q.grep_text(), "!test foo");
809 }
810
811 #[test]
812 fn test_grep_text_question_mark_is_text() {
813 let q = QueryParser::new(GrepConfig).parse("foo? bar");
814 assert_eq!(q.grep_text(), "foo? bar");
815 }
816
817 #[test]
818 fn test_grep_text_bracket_is_text() {
819 let q = QueryParser::new(GrepConfig).parse("arr[0] more");
820 assert_eq!(q.grep_text(), "arr[0] more");
821 }
822
823 #[test]
824 fn test_grep_text_path_glob_is_constraint() {
825 let q = QueryParser::new(GrepConfig).parse("pattern src/**/*.rs");
826 assert_eq!(q.grep_text(), "pattern");
827 }
828
829 #[test]
830 fn test_grep_question_mark_is_text() {
831 let parser = QueryParser::new(GrepConfig);
832 let result = parser.parse("foo?");
833 assert!(result.constraints.is_empty());
834 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("foo?"));
835 }
836
837 #[test]
838 fn test_grep_bracket_is_text() {
839 let parser = QueryParser::new(GrepConfig);
840 let result = parser.parse("arr[0] something");
841 assert_eq!(result.constraints.len(), 0);
843 }
844
845 #[test]
846 fn test_grep_path_glob_is_constraint() {
847 let parser = QueryParser::new(GrepConfig);
848 let result = parser.parse("pattern src/**/*.rs");
849 assert_eq!(result.constraints.len(), 1);
851 assert!(matches!(
852 result.constraints[0],
853 Constraint::Glob("src/**/*.rs")
854 ));
855 }
856
857 #[test]
858 fn test_grep_brace_is_constraint() {
859 let parser = QueryParser::new(GrepConfig);
860 let result = parser.parse("pattern {src,lib}");
861 assert_eq!(result.constraints.len(), 1);
862 assert!(matches!(
863 result.constraints[0],
864 Constraint::Glob("{src,lib}")
865 ));
866 }
867
868 #[test]
869 fn test_grep_text_preserves_backslash_escapes() {
870 let q = QueryParser::new(GrepConfig).parse("pub struct \\w+");
874 assert_eq!(
875 q.grep_text(),
876 "pub struct \\w+",
877 "Backslash-w in regex must be preserved"
878 );
879
880 let q = QueryParser::new(GrepConfig).parse("\\bword\\b more");
881 assert_eq!(
882 q.grep_text(),
883 "\\bword\\b more",
884 "Backslash-b word boundaries must be preserved"
885 );
886
887 let result = QueryParser::new(GrepConfig).parse("fn\\s+\\w+");
889 assert!(result.constraints.is_empty());
890 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("fn\\s+\\w+"));
891
892 let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
894 assert_eq!(
895 q.grep_text(),
896 "*.rs foo",
897 "Escaped constraint \\*.rs should still have backslash stripped"
898 );
899
900 let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
901 assert_eq!(
902 q.grep_text(),
903 "/src/ foo",
904 "Escaped constraint \\/src/ should still have backslash stripped"
905 );
906 }
907
908 #[test]
909 fn test_grep_bare_star_is_text() {
910 let parser = QueryParser::new(GrepConfig);
911 let result = parser.parse("a*b something");
913 assert_eq!(
914 result.constraints.len(),
915 0,
916 "bare * without / should be text"
917 );
918 }
919
920 #[test]
921 fn test_grep_negated_text() {
922 let parser = QueryParser::new(GrepConfig);
923 let result = parser.parse("pattern !test");
924 assert_eq!(result.constraints.len(), 1);
925 match &result.constraints[0] {
926 Constraint::Not(inner) => {
927 assert!(
928 matches!(**inner, Constraint::Text("test")),
929 "Expected Not(Text(\"test\")), got Not({:?})",
930 inner
931 );
932 }
933 other => panic!("Expected Not constraint, got {:?}", other),
934 }
935 }
936
937 #[test]
938 fn test_grep_negated_path_segment() {
939 let parser = QueryParser::new(GrepConfig);
940 let result = parser.parse("pattern !/src/");
941 assert_eq!(result.constraints.len(), 1);
942 match &result.constraints[0] {
943 Constraint::Not(inner) => {
944 assert!(
945 matches!(**inner, Constraint::PathSegment("src")),
946 "Expected Not(PathSegment(\"src\")), got Not({:?})",
947 inner
948 );
949 }
950 other => panic!("Expected Not constraint, got {:?}", other),
951 }
952 }
953
954 #[test]
955 fn test_grep_negated_extension() {
956 let parser = QueryParser::new(GrepConfig);
957 let result = parser.parse("pattern !*.rs");
958 assert_eq!(result.constraints.len(), 1);
959 match &result.constraints[0] {
960 Constraint::Not(inner) => {
961 assert!(
962 matches!(**inner, Constraint::Extension("rs")),
963 "Expected Not(Extension(\"rs\")), got Not({:?})",
964 inner
965 );
966 }
967 other => panic!("Expected Not constraint, got {:?}", other),
968 }
969 }
970
971 #[test]
972 fn test_ai_grep_detects_file_path() {
973 use crate::AiGrepConfig;
974 let parser = QueryParser::new(AiGrepConfig);
975 let result = parser.parse("libswscale/input.c rgba32ToY");
976 assert_eq!(result.constraints.len(), 1);
977 assert!(
978 matches!(
979 result.constraints[0],
980 Constraint::FilePath("libswscale/input.c")
981 ),
982 "Expected FilePath, got {:?}",
983 result.constraints[0]
984 );
985 assert_eq!(result.grep_text(), "rgba32ToY");
986 }
987
988 #[test]
989 fn test_ai_grep_detects_nested_file_path() {
990 use crate::AiGrepConfig;
991 let parser = QueryParser::new(AiGrepConfig);
992 let result = parser.parse("src/main.rs fn main");
993 assert_eq!(result.constraints.len(), 1);
994 assert!(matches!(
995 result.constraints[0],
996 Constraint::FilePath("src/main.rs")
997 ));
998 assert_eq!(result.grep_text(), "fn main");
999 }
1000
1001 #[test]
1002 fn test_ai_grep_no_false_positive_trailing_slash() {
1003 use crate::AiGrepConfig;
1004 let parser = QueryParser::new(AiGrepConfig);
1005 let result = parser.parse("src/ pattern");
1006 assert_eq!(result.constraints.len(), 1);
1008 assert!(
1009 matches!(result.constraints[0], Constraint::PathSegment("src")),
1010 "Expected PathSegment, got {:?}",
1011 result.constraints[0]
1012 );
1013 }
1014
1015 #[test]
1016 fn test_ai_grep_bare_filename_is_file_path() {
1017 use crate::AiGrepConfig;
1018 let parser = QueryParser::new(AiGrepConfig);
1019 let result = parser.parse("main.rs pattern");
1020 assert_eq!(result.constraints.len(), 1);
1022 assert!(
1023 matches!(result.constraints[0], Constraint::FilePath("main.rs")),
1024 "Expected FilePath, got {:?}",
1025 result.constraints[0]
1026 );
1027 assert_eq!(result.grep_text(), "pattern");
1028 }
1029
1030 #[test]
1031 fn test_standalone_constraints_preserve_directory() {
1032 let result = QueryParser::new(AiGrepConfig).parse_constraints("scope-a/");
1033 assert_eq!(result.as_slice(), &[Constraint::PathSegment("scope-a")]);
1034 }
1035
1036 #[test]
1037 fn test_plain_constraints_preserve_directory_only() {
1038 let directory = QueryParser::new(GrepConfig).parse_constraints("scope-a/");
1039 assert_eq!(directory.as_slice(), &[Constraint::PathSegment("scope-a")]);
1040
1041 let file = QueryParser::new(GrepConfig).parse_constraints("scope-a/one.txt");
1042 assert!(file.is_empty());
1043 }
1044
1045 #[test]
1046 fn test_standalone_constraints_preserve_file() {
1047 let result = QueryParser::new(AiGrepConfig).parse_constraints("scope-a/one.txt");
1048 assert_eq!(
1049 result.as_slice(),
1050 &[Constraint::FilePath("scope-a/one.txt")]
1051 );
1052 }
1053
1054 #[test]
1055 fn test_standalone_constraints_preserve_file_without_search_text() {
1056 let result = QueryParser::new(AiGrepConfig).parse_constraints("scope-a/ scope-a/one.txt");
1057 assert_eq!(
1058 result.as_slice(),
1059 &[
1060 Constraint::PathSegment("scope-a"),
1061 Constraint::FilePath("scope-a/one.txt")
1062 ]
1063 );
1064 }
1065
1066 #[test]
1067 fn test_ai_grep_filename_with_pathsegment_only_promotes_to_text() {
1068 use crate::AiGrepConfig;
1073 let parser = QueryParser::new(AiGrepConfig);
1074 let result = parser.parse("chrome/browser/profiles/ profile.h");
1075 assert_eq!(result.constraints.len(), 1);
1076 assert!(
1077 matches!(
1078 result.constraints[0],
1079 Constraint::PathSegment("chrome/browser/profiles")
1080 ),
1081 "Expected single PathSegment, got {:?}",
1082 result.constraints
1083 );
1084 assert_eq!(result.grep_text(), "profile.h");
1085 }
1086
1087 #[test]
1088 fn test_ai_grep_leading_slash_path_alone_is_text_not_path_segment() {
1089 use crate::AiGrepConfig;
1094 let parser = QueryParser::new(AiGrepConfig);
1095
1096 let result = parser.parse("/api/tests/");
1098 assert_eq!(
1099 result.constraints.len(),
1100 0,
1101 "Expected no constraints for '/api/tests/', got {:?}",
1102 result.constraints
1103 );
1104 assert!(
1105 matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests/")),
1106 "Expected FuzzyQuery::Text, got {:?}",
1107 result.fuzzy_query
1108 );
1109
1110 let result = parser.parse("/api/tests");
1112 assert_eq!(
1113 result.constraints.len(),
1114 0,
1115 "Expected no constraints for '/api/tests', got {:?}",
1116 result.constraints
1117 );
1118 assert!(
1119 matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests")),
1120 "Expected FuzzyQuery::Text, got {:?}",
1121 result.fuzzy_query
1122 );
1123 }
1124
1125 #[test]
1126 fn test_grep_leading_slash_path_alone_is_text_not_path_segment() {
1127 let parser = QueryParser::new(GrepConfig);
1130
1131 let result = parser.parse("/api/tests/");
1132 assert_eq!(
1133 result.constraints.len(),
1134 0,
1135 "GrepConfig: expected no constraints for '/api/tests/', got {:?}",
1136 result.constraints
1137 );
1138 assert!(
1139 matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests/")),
1140 "GrepConfig: expected FuzzyQuery::Text, got {:?}",
1141 result.fuzzy_query
1142 );
1143
1144 let result = parser.parse("/api/tests");
1145 assert_eq!(
1146 result.constraints.len(),
1147 0,
1148 "GrepConfig: expected no constraints for '/api/tests', got {:?}",
1149 result.constraints
1150 );
1151 assert!(
1152 matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests")),
1153 "GrepConfig: expected FuzzyQuery::Text, got {:?}",
1154 result.fuzzy_query
1155 );
1156 }
1157
1158 #[test]
1159 fn test_ai_grep_filename_with_extension_only_promotes_to_text() {
1160 use crate::AiGrepConfig;
1163 let parser = QueryParser::new(AiGrepConfig);
1164 let result = parser.parse("*.h profile.h");
1165 assert_eq!(result.constraints.len(), 1);
1166 assert!(
1167 matches!(result.constraints[0], Constraint::Extension("h")),
1168 "Expected Extension, got {:?}",
1169 result.constraints
1170 );
1171 assert_eq!(result.grep_text(), "profile.h");
1172 }
1173
1174 #[test]
1175 fn test_ai_grep_filename_with_other_text_keeps_filepath() {
1176 use crate::AiGrepConfig;
1179 let parser = QueryParser::new(AiGrepConfig);
1180 let result = parser.parse("main.rs pattern");
1181 assert_eq!(result.constraints.len(), 1);
1182 assert!(
1183 matches!(result.constraints[0], Constraint::FilePath("main.rs")),
1184 "Expected FilePath, got {:?}",
1185 result.constraints
1186 );
1187 assert_eq!(result.grep_text(), "pattern");
1188 }
1189
1190 #[test]
1191 fn test_ai_grep_bare_filename_schema_rs() {
1192 use crate::AiGrepConfig;
1193 let parser = QueryParser::new(AiGrepConfig);
1194 let result = parser.parse("schema.rs part_revisions");
1195 assert_eq!(result.constraints.len(), 1);
1196 assert!(
1197 matches!(result.constraints[0], Constraint::FilePath("schema.rs")),
1198 "Expected FilePath(schema.rs), got {:?}",
1199 result.constraints[0]
1200 );
1201 assert_eq!(result.grep_text(), "part_revisions");
1202 }
1203
1204 #[test]
1205 fn test_ai_grep_bare_word_no_extension_not_constraint() {
1206 use crate::AiGrepConfig;
1207 let parser = QueryParser::new(AiGrepConfig);
1208 let result = parser.parse("schema pattern");
1209 assert_eq!(result.constraints.len(), 0);
1211 assert_eq!(result.grep_text(), "schema pattern");
1212 }
1213
1214 #[test]
1215 fn test_ai_grep_no_false_positive_no_extension() {
1216 use crate::AiGrepConfig;
1217 let parser = QueryParser::new(AiGrepConfig);
1218 let result = parser.parse("src/utils pattern");
1219 assert_eq!(result.constraints.len(), 0);
1221 assert_eq!(result.grep_text(), "src/utils pattern");
1222 }
1223
1224 #[test]
1225 fn test_ai_grep_wildcard_not_filepath() {
1226 use crate::AiGrepConfig;
1227 let parser = QueryParser::new(AiGrepConfig);
1228 let result = parser.parse("src/**/*.rs pattern");
1229 assert_eq!(result.constraints.len(), 1);
1231 assert!(
1232 matches!(result.constraints[0], Constraint::Glob("src/**/*.rs")),
1233 "Expected Glob, got {:?}",
1234 result.constraints[0]
1235 );
1236 }
1237
1238 #[test]
1239 fn test_ai_grep_star_text_star_is_glob() {
1240 use crate::AiGrepConfig;
1241 let parser = QueryParser::new(AiGrepConfig);
1242 let result = parser.parse("*quote* TODO");
1243 assert_eq!(result.constraints.len(), 1);
1245 assert!(
1246 matches!(result.constraints[0], Constraint::Glob("*quote*")),
1247 "Expected Glob(*quote*), got {:?}",
1248 result.constraints[0]
1249 );
1250 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("TODO"));
1251 }
1252
1253 #[test]
1254 fn test_ai_grep_bare_star_not_glob() {
1255 use crate::AiGrepConfig;
1256 let parser = QueryParser::new(AiGrepConfig);
1257 let result = parser.parse("* pattern");
1258 assert!(
1260 result.constraints.is_empty(),
1261 "Expected no constraints, got {:?}",
1262 result.constraints
1263 );
1264 }
1265
1266 #[test]
1267 fn test_grep_no_location_parsing_single_token() {
1268 let parser = QueryParser::new(GrepConfig);
1269 let result = parser.parse("localhost:8080");
1271 assert!(result.constraints.is_empty());
1272 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("localhost:8080"));
1273 }
1274
1275 #[test]
1276 fn test_grep_no_location_parsing_multi_token() {
1277 let q = QueryParser::new(GrepConfig).parse("*.rs localhost:8080");
1278 assert_eq!(
1279 q.grep_text(),
1280 "localhost:8080",
1281 "Colon-number suffix should be preserved in grep text"
1282 );
1283 assert!(
1284 q.location.is_none(),
1285 "Grep should not parse location from colon-number"
1286 );
1287 }
1288
1289 #[test]
1290 fn test_grep_reversed_braces_does_not_panic() {
1291 for query in [
1294 "}{",
1295 "}{ foo",
1296 "foo }{",
1297 "a}{b",
1298 "}}{{",
1299 "} something {{{ {}}}d{ {}}}}{{{ }}}}}d{d something {{}}}}}}",
1300 ] {
1301 let result = QueryParser::new(GrepConfig).parse(query);
1302 assert!(
1305 !result
1306 .constraints
1307 .iter()
1308 .any(|c| matches!(c, Constraint::Glob(_))),
1309 "GrepConfig: {query:?} produced a Glob constraint, got {:?}",
1310 result.constraints
1311 );
1312
1313 let result = QueryParser::new(crate::AiGrepConfig).parse(query);
1314 assert!(
1315 !result
1316 .constraints
1317 .iter()
1318 .any(|c| matches!(c, Constraint::Glob(_))),
1319 "AiGrepConfig: {query:?} produced a Glob constraint, got {:?}",
1320 result.constraints
1321 );
1322 }
1323 }
1324
1325 #[test]
1326 fn test_grep_braces_without_comma_is_text() {
1327 let parser = QueryParser::new(GrepConfig);
1328 let result = parser.parse(r#"format!("{}\\AppData", home)"#);
1330 assert!(
1331 result.constraints.is_empty(),
1332 "Braces without comma should be text, got {:?}",
1333 result.constraints
1334 );
1335 assert_eq!(result.grep_text(), r#"format!("{}\\AppData", home)"#);
1336 }
1337
1338 #[test]
1339 fn test_grep_valid_brace_expansion_amid_junk_braces() {
1340 let parser = QueryParser::new(GrepConfig);
1345 let result = parser.parse("}{ {{}} }}{{ {} {src,lib} pattern");
1346
1347 let glob_constraints: Vec<&str> = result
1348 .constraints
1349 .iter()
1350 .filter_map(|c| match c {
1351 Constraint::Glob(p) => Some(*p),
1352 _ => None,
1353 })
1354 .collect();
1355 assert_eq!(
1356 glob_constraints,
1357 vec!["{src,lib}"],
1358 "Expected exactly one Glob({{src,lib}}), got {:?}",
1359 result.constraints
1360 );
1361
1362 let parser = QueryParser::new(crate::AiGrepConfig);
1364 let result = parser.parse("}{ {{}} }}{{ {} {src,lib} pattern");
1365 let glob_constraints: Vec<&str> = result
1366 .constraints
1367 .iter()
1368 .filter_map(|c| match c {
1369 Constraint::Glob(p) => Some(*p),
1370 _ => None,
1371 })
1372 .collect();
1373 assert_eq!(
1374 glob_constraints,
1375 vec!["{src,lib}"],
1376 "AiGrepConfig: expected Glob({{src,lib}}), got {:?}",
1377 result.constraints
1378 );
1379 }
1380
1381 #[test]
1382 fn test_grep_format_braces_not_glob() {
1383 let parser = QueryParser::new(GrepConfig);
1384 let input = "format!(\"{}\\\\AppData\", home)";
1388 let result = parser.parse(input);
1389 assert!(
1390 result.constraints.is_empty(),
1391 "format! pattern should have no constraints, got {:?}",
1392 result.constraints
1393 );
1394 }
1395
1396 #[test]
1397 fn test_grep_config_star_text_star_not_glob() {
1398 use crate::GrepConfig;
1399 let parser = QueryParser::new(GrepConfig);
1400 let result = parser.parse("*quote* TODO");
1401 assert!(
1403 result.constraints.is_empty(),
1404 "Expected no constraints in GrepConfig, got {:?}",
1405 result.constraints
1406 );
1407 }
1408
1409 #[test]
1410 fn test_file_picker_bare_filename_constraint() {
1411 let parser = QueryParser::new(FilenameConstraintConfig);
1412 let result = parser.parse("score.rs file_picker");
1413 assert_eq!(result.constraints.len(), 1);
1414 assert!(
1415 matches!(result.constraints[0], Constraint::FilePath("score.rs")),
1416 "Expected FilePath(\"score.rs\"), got {:?}",
1417 result.constraints[0]
1418 );
1419 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("file_picker"));
1420 }
1421
1422 #[test]
1423 fn test_file_picker_path_prefixed_filename_constraint() {
1424 let parser = QueryParser::new(FilenameConstraintConfig);
1425 let result = parser.parse("libswscale/slice.c lum_convert");
1426 assert_eq!(result.constraints.len(), 1);
1427 assert!(
1428 matches!(
1429 result.constraints[0],
1430 Constraint::FilePath("libswscale/slice.c")
1431 ),
1432 "Expected FilePath(\"libswscale/slice.c\"), got {:?}",
1433 result.constraints[0]
1434 );
1435 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("lum_convert"));
1436 }
1437
1438 #[test]
1439 fn test_file_picker_single_token_filename_stays_fuzzy() {
1440 let parser = QueryParser::new(FileSearchConfig);
1441 let result = parser.parse("score.rs");
1444 assert!(result.constraints.is_empty());
1445 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
1446 }
1447
1448 #[test]
1449 fn test_absolute_path_with_location_not_path_segment() {
1450 let parser = QueryParser::new(FileSearchConfig);
1451 let result = parser.parse("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs:12");
1454 assert!(
1455 result.constraints.is_empty(),
1456 "Absolute path with location should not become a constraint, got {:?}",
1457 result.constraints
1458 );
1459 assert_eq!(
1460 result.fuzzy_query,
1461 FuzzyQuery::Text("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs")
1462 );
1463 assert_eq!(result.location, Some(Location::Line(12)));
1464 }
1465
1466 #[test]
1467 fn test_file_picker_filename_with_multiple_fuzzy_parts() {
1468 let parser = QueryParser::new(FilenameConstraintConfig);
1469 let result = parser.parse("main.rs src components");
1470 assert_eq!(result.constraints.len(), 1);
1471 assert!(matches!(
1472 result.constraints[0],
1473 Constraint::FilePath("main.rs")
1474 ));
1475 assert_eq!(
1476 result.fuzzy_query,
1477 FuzzyQuery::Parts(vec!["src", "components"])
1478 );
1479 }
1480
1481 #[test]
1482 fn test_file_picker_version_number_not_filename() {
1483 let parser = QueryParser::new(FileSearchConfig);
1484 let result = parser.parse("v2.0 release");
1485 assert!(
1487 result.constraints.is_empty(),
1488 "v2.0 should not be a FilePath constraint, got {:?}",
1489 result.constraints
1490 );
1491 }
1492
1493 #[test]
1494 fn test_file_picker_only_one_filepath_constraint() {
1495 let parser = QueryParser::new(FilenameConstraintConfig);
1496 let result = parser.parse("main.rs score.rs");
1497 assert_eq!(result.constraints.len(), 1);
1499 assert!(matches!(
1500 result.constraints[0],
1501 Constraint::FilePath("main.rs")
1502 ));
1503 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
1504 }
1505
1506 #[test]
1507 fn test_file_picker_filename_with_extension_constraint() {
1508 let parser = QueryParser::new(FileSearchConfig);
1509 let result = parser.parse("main.rs *.lua");
1510 assert_eq!(result.constraints.len(), 1);
1515 assert!(matches!(
1516 result.constraints[0],
1517 Constraint::Extension("lua")
1518 ));
1519 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("main.rs"));
1520 }
1521
1522 #[test]
1523 fn test_file_picker_dotfile_is_filename() {
1524 let parser = QueryParser::new(FilenameConstraintConfig);
1525 let result = parser.parse(".gitignore src");
1526 assert_eq!(result.constraints.len(), 1);
1527 assert!(
1528 matches!(result.constraints[0], Constraint::FilePath(".gitignore")),
1529 "Expected FilePath(\".gitignore\"), got {:?}",
1530 result.constraints[0]
1531 );
1532 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("src"));
1533 }
1534
1535 #[test]
1536 fn test_file_picker_no_extension_not_filename() {
1537 let parser = QueryParser::new(FileSearchConfig);
1538 let result = parser.parse("Makefile src");
1539 assert!(
1541 result.constraints.is_empty(),
1542 "Makefile should not be a FilePath constraint, got {:?}",
1543 result.constraints
1544 );
1545 }
1546}