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
26#[derive(Debug)]
28pub struct QueryParser<C: ParserConfig> {
29 config: C,
30}
31
32impl<C: ParserConfig> QueryParser<C> {
33 pub fn new(config: C) -> Self {
34 Self { config }
35 }
36
37 pub fn parse<'a>(&self, query: &'a str) -> FFFQuery<'a> {
38 let raw_query = query;
39 let config: &C = &self.config;
40 let mut constraints = ConstraintVec::new();
41 let query = query.trim();
42
43 let whitespace_count = query.chars().filter(|c| c.is_whitespace()).count();
44
45 if whitespace_count == 0 {
47 if let Some(constraint) = parse_token(query, config) {
49 let has_location_suffix = matches!(constraint, Constraint::PathSegment(_))
58 && query.bytes().any(|b| b == b':')
59 && query
60 .bytes()
61 .rev()
62 .take_while(|&b| b != b':')
63 .all(|b| b.is_ascii_digit());
64
65 let treat_as_text = matches!(constraint, Constraint::PathSegment(_))
67 && config.treat_lone_path_as_text();
68
69 if !matches!(constraint, Constraint::FilePath(_))
70 && !has_location_suffix
71 && !treat_as_text
72 {
73 constraints.push(constraint);
74 return FFFQuery {
75 raw_query,
76 constraints,
77 fuzzy_query: FuzzyQuery::Empty,
78 location: None,
79 };
80 }
81 }
82
83 if config.enable_location() {
85 let (query_without_loc, location) = parse_location(query);
86 if location.is_some() {
87 return FFFQuery {
88 raw_query,
89 constraints,
90 fuzzy_query: FuzzyQuery::Text(query_without_loc),
91 location,
92 };
93 }
94 }
95
96 return FFFQuery {
98 raw_query,
99 constraints,
100 fuzzy_query: if query.is_empty() {
101 FuzzyQuery::Empty
102 } else {
103 FuzzyQuery::Text(query)
104 },
105 location: None,
106 };
107 }
108
109 let mut text_parts = TextPartsBuffer::new();
110 let tokens = query.split_whitespace();
111
112 let mut has_file_path = false;
113 let mut file_path_constraint_idx: Option<usize> = None;
116 let mut file_path_token: Option<&str> = None;
117 for token in tokens {
118 match parse_token(token, config) {
119 Some(Constraint::FilePath(_)) => {
120 if has_file_path {
121 text_parts.push(token);
125 } else {
126 file_path_constraint_idx = Some(constraints.len());
127 file_path_token = Some(token);
128 constraints.push(Constraint::FilePath(token));
129 has_file_path = true;
130 }
131 }
132 Some(constraint) => {
133 constraints.push(constraint);
134 }
135 None => {
136 text_parts.push(token);
137 }
138 }
139 }
140
141 if text_parts.is_empty()
149 && let Some(idx) = file_path_constraint_idx
150 && let Some(tok) = file_path_token
151 {
152 constraints.remove(idx);
153 text_parts.push(tok);
154 }
155
156 let location = if config.enable_location() && !text_parts.is_empty() {
159 let last_idx = text_parts.len() - 1;
160 let (without_loc, loc) = parse_location(text_parts[last_idx]);
161 if loc.is_some() {
162 text_parts[last_idx] = without_loc;
164 loc
165 } else {
166 None
167 }
168 } else {
169 None
170 };
171
172 let fuzzy_query = if text_parts.is_empty() {
173 FuzzyQuery::Empty
174 } else if text_parts.len() == 1 {
175 if text_parts[0].is_empty() {
177 FuzzyQuery::Empty
178 } else {
179 FuzzyQuery::Text(text_parts[0])
180 }
181 } else {
182 if text_parts.iter().all(|p| p.is_empty()) {
184 FuzzyQuery::Empty
185 } else {
186 FuzzyQuery::Parts(text_parts)
187 }
188 };
189
190 FFFQuery {
191 raw_query,
192 constraints,
193 fuzzy_query,
194 location,
195 }
196 }
197}
198
199impl<'a> FFFQuery<'a> {
200 pub fn grep_text(&self) -> String {
210 match &self.fuzzy_query {
211 FuzzyQuery::Empty => String::new(),
212 FuzzyQuery::Text(t) => strip_leading_backslash(t).to_string(),
213 FuzzyQuery::Parts(parts) => parts
214 .iter()
215 .map(|t| strip_leading_backslash(t))
216 .collect::<Vec<_>>()
217 .join(" "),
218 }
219 }
220}
221
222#[inline]
229fn strip_leading_backslash(token: &str) -> &str {
230 if token.len() > 1 && token.starts_with('\\') {
231 let next = token.as_bytes()[1];
232 if next == b'*' || next == b'/' || next == b'!' {
234 return &token[1..];
235 }
236 }
237 token
238}
239
240impl Default for QueryParser<crate::FileSearchConfig> {
241 fn default() -> Self {
242 Self::new(crate::FileSearchConfig)
243 }
244}
245
246#[inline]
247fn parse_token<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constraint<'a>> {
248 if token.starts_with('\\') && token.len() > 1 {
251 return None;
252 }
253
254 let first_byte = token.as_bytes().first()?;
255
256 match first_byte {
257 b'*' if config.enable_extension() => {
258 if token == "*" || token == "*." {
260 return None;
261 }
262
263 if let Some(constraint) = parse_extension(token) {
265 let ext_part = &token[2..];
268 if !has_wildcards(ext_part) {
269 return Some(constraint);
270 }
271 }
272 if config.enable_glob() && config.is_glob_pattern(token) {
274 return Some(Constraint::Glob(token));
275 }
276 None
277 }
278 b'!' if config.enable_exclude() => parse_negation(token, config),
279 b'/' if config.enable_path_segments() => parse_path_segment(token),
280 _ if config.enable_path_segments() && token.ends_with('/') => {
281 parse_path_segment_trailing(token)
283 }
284 _ => {
285 if config.enable_glob() && config.is_glob_pattern(token) {
287 return Some(Constraint::Glob(token));
288 }
289
290 if let Some(colon_idx) = memchr(b':', token.as_bytes()) {
292 let (key, value_with_colon) = token.split_at(colon_idx);
293 let value = &value_with_colon[1..]; match key {
296 "type" if config.enable_type_filter() => {
297 return Some(Constraint::FileType(value));
298 }
299 "status" | "st" | "g" | "git" if config.enable_git_status() => {
300 return parse_git_status(value);
301 }
302 _ => {}
303 }
304 }
305
306 config.parse_custom(token)
308 }
309 }
310}
311
312#[inline]
314fn memchr(needle: u8, haystack: &[u8]) -> Option<usize> {
315 haystack.iter().position(|&b| b == needle)
316}
317
318#[inline]
320fn parse_extension(token: &str) -> Option<Constraint<'_>> {
321 if token.len() > 2 && token.starts_with("*.") {
322 Some(Constraint::Extension(&token[2..]))
323 } else {
324 None
325 }
326}
327
328#[inline]
331fn parse_negation<'a, C: ParserConfig>(token: &'a str, config: &C) -> Option<Constraint<'a>> {
332 if token.len() <= 1 {
333 return None;
334 }
335
336 let inner_token = &token[1..];
337
338 if let Some(inner_constraint) = parse_token_without_negation(inner_token, config) {
340 return Some(Constraint::Not(Box::new(inner_constraint)));
342 }
343
344 Some(Constraint::Not(Box::new(Constraint::Text(inner_token))))
347}
348
349#[inline]
351fn parse_token_without_negation<'a, C: ParserConfig>(
352 token: &'a str,
353 config: &C,
354) -> Option<Constraint<'a>> {
355 if token.starts_with('\\') && token.len() > 1 {
357 return None;
358 }
359
360 let first_byte = token.as_bytes().first()?;
361
362 match first_byte {
363 b'*' if config.enable_extension() => {
364 if let Some(constraint) = parse_extension(token) {
366 let ext_part = &token[2..];
367 if !has_wildcards(ext_part) {
368 return Some(constraint);
369 }
370 }
371 if config.enable_glob() && config.is_glob_pattern(token) {
373 return Some(Constraint::Glob(token));
374 }
375 None
376 }
377 b'/' if config.enable_path_segments() => parse_path_segment(token),
378 _ if config.enable_path_segments() && token.ends_with('/') => {
379 parse_path_segment_trailing(token)
381 }
382 _ => {
383 if config.enable_glob() && config.is_glob_pattern(token) {
385 return Some(Constraint::Glob(token));
386 }
387
388 if let Some(colon_idx) = memchr(b':', token.as_bytes()) {
390 let (key, value_with_colon) = token.split_at(colon_idx);
391 let value = &value_with_colon[1..]; match key {
394 "type" if config.enable_type_filter() => {
395 return Some(Constraint::FileType(value));
396 }
397 "status" | "st" | "g" | "git" if config.enable_git_status() => {
398 return parse_git_status(value);
399 }
400 _ => {}
401 }
402 }
403
404 config.parse_custom(token)
405 }
406 }
407}
408
409#[inline]
411fn parse_path_segment(token: &str) -> Option<Constraint<'_>> {
412 if token.len() > 1 && token.starts_with('/') {
413 let segment = token.trim_start_matches('/').trim_end_matches('/');
414 if !segment.is_empty() {
415 Some(Constraint::PathSegment(segment))
416 } else {
417 None
418 }
419 } else {
420 None
421 }
422}
423
424#[inline]
427fn parse_path_segment_trailing(token: &str) -> Option<Constraint<'_>> {
428 if token.len() > 1 && token.ends_with('/') {
429 let segment = token.trim_end_matches('/');
430 if !segment.is_empty() {
431 Some(Constraint::PathSegment(segment))
432 } else {
433 None
434 }
435 } else {
436 None
437 }
438}
439
440#[inline]
442fn parse_git_status(value: &str) -> Option<Constraint<'_>> {
443 if value == "*" {
444 return None;
445 }
446
447 if "modified".starts_with(value) {
448 return Some(Constraint::GitStatus(GitStatusFilter::Modified));
449 }
450
451 if "untracked".starts_with(value) {
452 return Some(Constraint::GitStatus(GitStatusFilter::Untracked));
453 }
454
455 if "staged".starts_with(value) {
456 return Some(Constraint::GitStatus(GitStatusFilter::Staged));
457 }
458
459 if "clean".starts_with(value) {
460 return Some(Constraint::GitStatus(GitStatusFilter::Unmodified));
461 }
462
463 None
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use crate::{FileSearchConfig, GrepConfig};
470
471 #[test]
472 fn test_parse_extension() {
473 assert_eq!(parse_extension("*.rs"), Some(Constraint::Extension("rs")));
474 assert_eq!(
475 parse_extension("*.toml"),
476 Some(Constraint::Extension("toml"))
477 );
478 assert_eq!(parse_extension("*"), None);
479 assert_eq!(parse_extension("*."), None);
480 }
481
482 #[test]
483 fn test_incomplete_patterns_ignored() {
484 let config = FileSearchConfig;
485 assert_eq!(parse_token("*", &config), None);
487 assert_eq!(parse_token("*.", &config), None);
488 }
489
490 #[test]
491 fn test_parse_path_segment() {
492 assert_eq!(
493 parse_path_segment("/src/"),
494 Some(Constraint::PathSegment("src"))
495 );
496 assert_eq!(
497 parse_path_segment("/lib"),
498 Some(Constraint::PathSegment("lib"))
499 );
500 assert_eq!(parse_path_segment("/"), None);
501 }
502
503 #[test]
504 fn test_parse_path_segment_trailing() {
505 assert_eq!(
506 parse_path_segment_trailing("www/"),
507 Some(Constraint::PathSegment("www"))
508 );
509 assert_eq!(
510 parse_path_segment_trailing("src/"),
511 Some(Constraint::PathSegment("src"))
512 );
513 assert_eq!(
515 parse_path_segment_trailing("src/lib/"),
516 Some(Constraint::PathSegment("src/lib"))
517 );
518 assert_eq!(
519 parse_path_segment_trailing("libswscale/aarch64/"),
520 Some(Constraint::PathSegment("libswscale/aarch64"))
521 );
522 assert_eq!(parse_path_segment_trailing("www"), None);
524 }
525
526 #[test]
527 fn test_trailing_slash_in_query() {
528 let parser = QueryParser::new(FileSearchConfig);
529 let result = parser.parse("www/ test");
530 assert_eq!(result.constraints.len(), 1);
531 assert!(matches!(
532 result.constraints[0],
533 Constraint::PathSegment("www")
534 ));
535 assert!(matches!(result.fuzzy_query, FuzzyQuery::Text("test")));
536 }
537
538 #[test]
539 fn test_parse_git_status() {
540 assert_eq!(
541 parse_git_status("modified"),
542 Some(Constraint::GitStatus(GitStatusFilter::Modified))
543 );
544 assert_eq!(
545 parse_git_status("m"),
546 Some(Constraint::GitStatus(GitStatusFilter::Modified))
547 );
548 assert_eq!(
549 parse_git_status("untracked"),
550 Some(Constraint::GitStatus(GitStatusFilter::Untracked))
551 );
552 assert_eq!(parse_git_status("invalid"), None);
553 }
554
555 #[test]
556 fn test_memchr() {
557 assert_eq!(memchr(b':', b"type:rust"), Some(4));
558 assert_eq!(memchr(b':', b"nocolon"), None);
559 assert_eq!(memchr(b':', b":start"), Some(0));
560 }
561
562 #[test]
563 fn test_negation_text() {
564 let parser = QueryParser::new(FileSearchConfig);
565 let result = parser.parse("!test foo");
567 assert_eq!(result.constraints.len(), 1);
568 match &result.constraints[0] {
569 Constraint::Not(inner) => {
570 assert!(matches!(**inner, Constraint::Text("test")));
571 }
572 _ => panic!("Expected Not constraint"),
573 }
574 }
575
576 #[test]
577 fn test_negation_extension() {
578 let parser = QueryParser::new(FileSearchConfig);
579 let result = parser.parse("!*.rs foo");
580 assert_eq!(result.constraints.len(), 1);
581 match &result.constraints[0] {
582 Constraint::Not(inner) => {
583 assert!(matches!(**inner, Constraint::Extension("rs")));
584 }
585 _ => panic!("Expected Not(Extension) constraint"),
586 }
587 }
588
589 #[test]
590 fn test_negation_path_segment() {
591 let parser = QueryParser::new(FileSearchConfig);
592 let result = parser.parse("!/src/ foo");
593 assert_eq!(result.constraints.len(), 1);
594 match &result.constraints[0] {
595 Constraint::Not(inner) => {
596 assert!(matches!(**inner, Constraint::PathSegment("src")));
597 }
598 _ => panic!("Expected Not(PathSegment) constraint"),
599 }
600 }
601
602 #[test]
603 fn test_negation_git_status() {
604 let parser = QueryParser::new(FileSearchConfig);
605 let result = parser.parse("!status:modified foo");
606 assert_eq!(result.constraints.len(), 1);
607 match &result.constraints[0] {
608 Constraint::Not(inner) => {
609 assert!(matches!(
610 **inner,
611 Constraint::GitStatus(GitStatusFilter::Modified)
612 ));
613 }
614 _ => panic!("Expected Not(GitStatus) constraint"),
615 }
616 }
617
618 #[test]
619 fn test_negation_git_status_all_key_aliases() {
620 let parser = QueryParser::new(FileSearchConfig);
621 for key in ["status", "st", "g", "git"] {
622 let query = format!("!{key}:modified foo");
623 let result = parser.parse(&query);
624 assert_eq!(
625 result.constraints.len(),
626 1,
627 "!{key}:modified should produce exactly one constraint"
628 );
629 match &result.constraints[0] {
630 Constraint::Not(inner) => assert!(
631 matches!(**inner, Constraint::GitStatus(GitStatusFilter::Modified)),
632 "!{key}:modified expected Not(GitStatus(Modified)), got Not({inner:?})"
633 ),
634 other => {
635 panic!("!{key}:modified expected Not(GitStatus), got {other:?}")
636 }
637 }
638 }
639 }
640
641 #[test]
642 fn test_backslash_escape_extension() {
643 let parser = QueryParser::new(FileSearchConfig);
644 let result = parser.parse("\\*.rs foo");
645 assert_eq!(result.constraints.len(), 0);
647 match result.fuzzy_query {
649 FuzzyQuery::Parts(parts) => {
650 assert_eq!(parts.len(), 2);
651 assert_eq!(parts[0], "\\*.rs");
652 assert_eq!(parts[1], "foo");
653 }
654 _ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
655 }
656 }
657
658 #[test]
659 fn test_backslash_escape_path_segment() {
660 let parser = QueryParser::new(FileSearchConfig);
661 let result = parser.parse("\\/src/ foo");
662 assert_eq!(result.constraints.len(), 0);
663 match result.fuzzy_query {
664 FuzzyQuery::Parts(parts) => {
665 assert_eq!(parts[0], "\\/src/");
666 assert_eq!(parts[1], "foo");
667 }
668 _ => panic!("Expected Parts, got {:?}", result.fuzzy_query),
669 }
670 }
671
672 #[test]
673 fn test_backslash_escape_negation() {
674 let parser = QueryParser::new(FileSearchConfig);
675 let result = parser.parse("\\!test foo");
676 assert_eq!(result.constraints.len(), 0);
677 }
678
679 #[test]
680 fn test_grep_text_plain_text() {
681 let q = QueryParser::new(GrepConfig).parse("name =");
683 assert_eq!(q.grep_text(), "name =");
684 }
685
686 #[test]
687 fn test_grep_text_strips_constraint() {
688 let q = QueryParser::new(GrepConfig).parse("name = *.rs someth");
689 assert_eq!(q.grep_text(), "name = someth");
690 }
691
692 #[test]
693 fn test_grep_text_leading_constraint() {
694 let q = QueryParser::new(GrepConfig).parse("*.rs name =");
695 assert_eq!(q.grep_text(), "name =");
696 }
697
698 #[test]
699 fn test_grep_text_only_constraints() {
700 let q = QueryParser::new(GrepConfig).parse("*.rs /src/");
701 assert_eq!(q.grep_text(), "");
702 }
703
704 #[test]
705 fn test_grep_text_path_constraint() {
706 let q = QueryParser::new(GrepConfig).parse("name /src/ value");
707 assert_eq!(q.grep_text(), "name value");
708 }
709
710 #[test]
711 fn test_grep_text_negation_constraint() {
712 let q = QueryParser::new(GrepConfig).parse("name !*.rs value");
713 assert_eq!(q.grep_text(), "name value");
714 }
715
716 #[test]
717 fn test_grep_text_backslash_escape_stripped() {
718 let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
720 assert_eq!(q.grep_text(), "*.rs foo");
721
722 let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
723 assert_eq!(q.grep_text(), "/src/ foo");
724
725 let q = QueryParser::new(GrepConfig).parse("\\!test foo");
726 assert_eq!(q.grep_text(), "!test foo");
727 }
728
729 #[test]
730 fn test_grep_text_question_mark_is_text() {
731 let q = QueryParser::new(GrepConfig).parse("foo? bar");
732 assert_eq!(q.grep_text(), "foo? bar");
733 }
734
735 #[test]
736 fn test_grep_text_bracket_is_text() {
737 let q = QueryParser::new(GrepConfig).parse("arr[0] more");
738 assert_eq!(q.grep_text(), "arr[0] more");
739 }
740
741 #[test]
742 fn test_grep_text_path_glob_is_constraint() {
743 let q = QueryParser::new(GrepConfig).parse("pattern src/**/*.rs");
744 assert_eq!(q.grep_text(), "pattern");
745 }
746
747 #[test]
748 fn test_grep_question_mark_is_text() {
749 let parser = QueryParser::new(GrepConfig);
750 let result = parser.parse("foo?");
751 assert!(result.constraints.is_empty());
752 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("foo?"));
753 }
754
755 #[test]
756 fn test_grep_bracket_is_text() {
757 let parser = QueryParser::new(GrepConfig);
758 let result = parser.parse("arr[0] something");
759 assert_eq!(result.constraints.len(), 0);
761 }
762
763 #[test]
764 fn test_grep_path_glob_is_constraint() {
765 let parser = QueryParser::new(GrepConfig);
766 let result = parser.parse("pattern src/**/*.rs");
767 assert_eq!(result.constraints.len(), 1);
769 assert!(matches!(
770 result.constraints[0],
771 Constraint::Glob("src/**/*.rs")
772 ));
773 }
774
775 #[test]
776 fn test_grep_brace_is_constraint() {
777 let parser = QueryParser::new(GrepConfig);
778 let result = parser.parse("pattern {src,lib}");
779 assert_eq!(result.constraints.len(), 1);
780 assert!(matches!(
781 result.constraints[0],
782 Constraint::Glob("{src,lib}")
783 ));
784 }
785
786 #[test]
787 fn test_grep_text_preserves_backslash_escapes() {
788 let q = QueryParser::new(GrepConfig).parse("pub struct \\w+");
792 assert_eq!(
793 q.grep_text(),
794 "pub struct \\w+",
795 "Backslash-w in regex must be preserved"
796 );
797
798 let q = QueryParser::new(GrepConfig).parse("\\bword\\b more");
799 assert_eq!(
800 q.grep_text(),
801 "\\bword\\b more",
802 "Backslash-b word boundaries must be preserved"
803 );
804
805 let result = QueryParser::new(GrepConfig).parse("fn\\s+\\w+");
807 assert!(result.constraints.is_empty());
808 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("fn\\s+\\w+"));
809
810 let q = QueryParser::new(GrepConfig).parse("\\*.rs foo");
812 assert_eq!(
813 q.grep_text(),
814 "*.rs foo",
815 "Escaped constraint \\*.rs should still have backslash stripped"
816 );
817
818 let q = QueryParser::new(GrepConfig).parse("\\/src/ foo");
819 assert_eq!(
820 q.grep_text(),
821 "/src/ foo",
822 "Escaped constraint \\/src/ should still have backslash stripped"
823 );
824 }
825
826 #[test]
827 fn test_grep_bare_star_is_text() {
828 let parser = QueryParser::new(GrepConfig);
829 let result = parser.parse("a*b something");
831 assert_eq!(
832 result.constraints.len(),
833 0,
834 "bare * without / should be text"
835 );
836 }
837
838 #[test]
839 fn test_grep_negated_text() {
840 let parser = QueryParser::new(GrepConfig);
841 let result = parser.parse("pattern !test");
842 assert_eq!(result.constraints.len(), 1);
843 match &result.constraints[0] {
844 Constraint::Not(inner) => {
845 assert!(
846 matches!(**inner, Constraint::Text("test")),
847 "Expected Not(Text(\"test\")), got Not({:?})",
848 inner
849 );
850 }
851 other => panic!("Expected Not constraint, got {:?}", other),
852 }
853 }
854
855 #[test]
856 fn test_grep_negated_path_segment() {
857 let parser = QueryParser::new(GrepConfig);
858 let result = parser.parse("pattern !/src/");
859 assert_eq!(result.constraints.len(), 1);
860 match &result.constraints[0] {
861 Constraint::Not(inner) => {
862 assert!(
863 matches!(**inner, Constraint::PathSegment("src")),
864 "Expected Not(PathSegment(\"src\")), got Not({:?})",
865 inner
866 );
867 }
868 other => panic!("Expected Not constraint, got {:?}", other),
869 }
870 }
871
872 #[test]
873 fn test_grep_negated_extension() {
874 let parser = QueryParser::new(GrepConfig);
875 let result = parser.parse("pattern !*.rs");
876 assert_eq!(result.constraints.len(), 1);
877 match &result.constraints[0] {
878 Constraint::Not(inner) => {
879 assert!(
880 matches!(**inner, Constraint::Extension("rs")),
881 "Expected Not(Extension(\"rs\")), got Not({:?})",
882 inner
883 );
884 }
885 other => panic!("Expected Not constraint, got {:?}", other),
886 }
887 }
888
889 #[test]
890 fn test_ai_grep_detects_file_path() {
891 use crate::AiGrepConfig;
892 let parser = QueryParser::new(AiGrepConfig);
893 let result = parser.parse("libswscale/input.c rgba32ToY");
894 assert_eq!(result.constraints.len(), 1);
895 assert!(
896 matches!(
897 result.constraints[0],
898 Constraint::FilePath("libswscale/input.c")
899 ),
900 "Expected FilePath, got {:?}",
901 result.constraints[0]
902 );
903 assert_eq!(result.grep_text(), "rgba32ToY");
904 }
905
906 #[test]
907 fn test_ai_grep_detects_nested_file_path() {
908 use crate::AiGrepConfig;
909 let parser = QueryParser::new(AiGrepConfig);
910 let result = parser.parse("src/main.rs fn main");
911 assert_eq!(result.constraints.len(), 1);
912 assert!(matches!(
913 result.constraints[0],
914 Constraint::FilePath("src/main.rs")
915 ));
916 assert_eq!(result.grep_text(), "fn main");
917 }
918
919 #[test]
920 fn test_ai_grep_no_false_positive_trailing_slash() {
921 use crate::AiGrepConfig;
922 let parser = QueryParser::new(AiGrepConfig);
923 let result = parser.parse("src/ pattern");
924 assert_eq!(result.constraints.len(), 1);
926 assert!(
927 matches!(result.constraints[0], Constraint::PathSegment("src")),
928 "Expected PathSegment, got {:?}",
929 result.constraints[0]
930 );
931 }
932
933 #[test]
934 fn test_ai_grep_bare_filename_is_file_path() {
935 use crate::AiGrepConfig;
936 let parser = QueryParser::new(AiGrepConfig);
937 let result = parser.parse("main.rs pattern");
938 assert_eq!(result.constraints.len(), 1);
940 assert!(
941 matches!(result.constraints[0], Constraint::FilePath("main.rs")),
942 "Expected FilePath, got {:?}",
943 result.constraints[0]
944 );
945 assert_eq!(result.grep_text(), "pattern");
946 }
947
948 #[test]
949 fn test_ai_grep_filename_with_pathsegment_only_promotes_to_text() {
950 use crate::AiGrepConfig;
955 let parser = QueryParser::new(AiGrepConfig);
956 let result = parser.parse("chrome/browser/profiles/ profile.h");
957 assert_eq!(result.constraints.len(), 1);
958 assert!(
959 matches!(
960 result.constraints[0],
961 Constraint::PathSegment("chrome/browser/profiles")
962 ),
963 "Expected single PathSegment, got {:?}",
964 result.constraints
965 );
966 assert_eq!(result.grep_text(), "profile.h");
967 }
968
969 #[test]
970 fn test_ai_grep_leading_slash_path_alone_is_text_not_path_segment() {
971 use crate::AiGrepConfig;
976 let parser = QueryParser::new(AiGrepConfig);
977
978 let result = parser.parse("/api/tests/");
980 assert_eq!(
981 result.constraints.len(),
982 0,
983 "Expected no constraints for '/api/tests/', got {:?}",
984 result.constraints
985 );
986 assert!(
987 matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests/")),
988 "Expected FuzzyQuery::Text, got {:?}",
989 result.fuzzy_query
990 );
991
992 let result = parser.parse("/api/tests");
994 assert_eq!(
995 result.constraints.len(),
996 0,
997 "Expected no constraints for '/api/tests', got {:?}",
998 result.constraints
999 );
1000 assert!(
1001 matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests")),
1002 "Expected FuzzyQuery::Text, got {:?}",
1003 result.fuzzy_query
1004 );
1005 }
1006
1007 #[test]
1008 fn test_grep_leading_slash_path_alone_is_text_not_path_segment() {
1009 let parser = QueryParser::new(GrepConfig);
1012
1013 let result = parser.parse("/api/tests/");
1014 assert_eq!(
1015 result.constraints.len(),
1016 0,
1017 "GrepConfig: expected no constraints for '/api/tests/', got {:?}",
1018 result.constraints
1019 );
1020 assert!(
1021 matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests/")),
1022 "GrepConfig: expected FuzzyQuery::Text, got {:?}",
1023 result.fuzzy_query
1024 );
1025
1026 let result = parser.parse("/api/tests");
1027 assert_eq!(
1028 result.constraints.len(),
1029 0,
1030 "GrepConfig: expected no constraints for '/api/tests', got {:?}",
1031 result.constraints
1032 );
1033 assert!(
1034 matches!(result.fuzzy_query, FuzzyQuery::Text("/api/tests")),
1035 "GrepConfig: expected FuzzyQuery::Text, got {:?}",
1036 result.fuzzy_query
1037 );
1038 }
1039
1040 #[test]
1041 fn test_ai_grep_filename_with_extension_only_promotes_to_text() {
1042 use crate::AiGrepConfig;
1045 let parser = QueryParser::new(AiGrepConfig);
1046 let result = parser.parse("*.h profile.h");
1047 assert_eq!(result.constraints.len(), 1);
1048 assert!(
1049 matches!(result.constraints[0], Constraint::Extension("h")),
1050 "Expected Extension, got {:?}",
1051 result.constraints
1052 );
1053 assert_eq!(result.grep_text(), "profile.h");
1054 }
1055
1056 #[test]
1057 fn test_ai_grep_filename_with_other_text_keeps_filepath() {
1058 use crate::AiGrepConfig;
1061 let parser = QueryParser::new(AiGrepConfig);
1062 let result = parser.parse("main.rs pattern");
1063 assert_eq!(result.constraints.len(), 1);
1064 assert!(
1065 matches!(result.constraints[0], Constraint::FilePath("main.rs")),
1066 "Expected FilePath, got {:?}",
1067 result.constraints
1068 );
1069 assert_eq!(result.grep_text(), "pattern");
1070 }
1071
1072 #[test]
1073 fn test_ai_grep_bare_filename_schema_rs() {
1074 use crate::AiGrepConfig;
1075 let parser = QueryParser::new(AiGrepConfig);
1076 let result = parser.parse("schema.rs part_revisions");
1077 assert_eq!(result.constraints.len(), 1);
1078 assert!(
1079 matches!(result.constraints[0], Constraint::FilePath("schema.rs")),
1080 "Expected FilePath(schema.rs), got {:?}",
1081 result.constraints[0]
1082 );
1083 assert_eq!(result.grep_text(), "part_revisions");
1084 }
1085
1086 #[test]
1087 fn test_ai_grep_bare_word_no_extension_not_constraint() {
1088 use crate::AiGrepConfig;
1089 let parser = QueryParser::new(AiGrepConfig);
1090 let result = parser.parse("schema pattern");
1091 assert_eq!(result.constraints.len(), 0);
1093 assert_eq!(result.grep_text(), "schema pattern");
1094 }
1095
1096 #[test]
1097 fn test_ai_grep_no_false_positive_no_extension() {
1098 use crate::AiGrepConfig;
1099 let parser = QueryParser::new(AiGrepConfig);
1100 let result = parser.parse("src/utils pattern");
1101 assert_eq!(result.constraints.len(), 0);
1103 assert_eq!(result.grep_text(), "src/utils pattern");
1104 }
1105
1106 #[test]
1107 fn test_ai_grep_wildcard_not_filepath() {
1108 use crate::AiGrepConfig;
1109 let parser = QueryParser::new(AiGrepConfig);
1110 let result = parser.parse("src/**/*.rs pattern");
1111 assert_eq!(result.constraints.len(), 1);
1113 assert!(
1114 matches!(result.constraints[0], Constraint::Glob("src/**/*.rs")),
1115 "Expected Glob, got {:?}",
1116 result.constraints[0]
1117 );
1118 }
1119
1120 #[test]
1121 fn test_ai_grep_star_text_star_is_glob() {
1122 use crate::AiGrepConfig;
1123 let parser = QueryParser::new(AiGrepConfig);
1124 let result = parser.parse("*quote* TODO");
1125 assert_eq!(result.constraints.len(), 1);
1127 assert!(
1128 matches!(result.constraints[0], Constraint::Glob("*quote*")),
1129 "Expected Glob(*quote*), got {:?}",
1130 result.constraints[0]
1131 );
1132 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("TODO"));
1133 }
1134
1135 #[test]
1136 fn test_ai_grep_bare_star_not_glob() {
1137 use crate::AiGrepConfig;
1138 let parser = QueryParser::new(AiGrepConfig);
1139 let result = parser.parse("* pattern");
1140 assert!(
1142 result.constraints.is_empty(),
1143 "Expected no constraints, got {:?}",
1144 result.constraints
1145 );
1146 }
1147
1148 #[test]
1149 fn test_grep_no_location_parsing_single_token() {
1150 let parser = QueryParser::new(GrepConfig);
1151 let result = parser.parse("localhost:8080");
1153 assert!(result.constraints.is_empty());
1154 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("localhost:8080"));
1155 }
1156
1157 #[test]
1158 fn test_grep_no_location_parsing_multi_token() {
1159 let q = QueryParser::new(GrepConfig).parse("*.rs localhost:8080");
1160 assert_eq!(
1161 q.grep_text(),
1162 "localhost:8080",
1163 "Colon-number suffix should be preserved in grep text"
1164 );
1165 assert!(
1166 q.location.is_none(),
1167 "Grep should not parse location from colon-number"
1168 );
1169 }
1170
1171 #[test]
1172 fn test_grep_braces_without_comma_is_text() {
1173 let parser = QueryParser::new(GrepConfig);
1174 let result = parser.parse(r#"format!("{}\\AppData", home)"#);
1176 assert!(
1177 result.constraints.is_empty(),
1178 "Braces without comma should be text, got {:?}",
1179 result.constraints
1180 );
1181 assert_eq!(result.grep_text(), r#"format!("{}\\AppData", home)"#);
1182 }
1183
1184 #[test]
1185 fn test_grep_format_braces_not_glob() {
1186 let parser = QueryParser::new(GrepConfig);
1187 let input = "format!(\"{}\\\\AppData\", home)";
1191 let result = parser.parse(input);
1192 assert!(
1193 result.constraints.is_empty(),
1194 "format! pattern should have no constraints, got {:?}",
1195 result.constraints
1196 );
1197 }
1198
1199 #[test]
1200 fn test_grep_config_star_text_star_not_glob() {
1201 use crate::GrepConfig;
1202 let parser = QueryParser::new(GrepConfig);
1203 let result = parser.parse("*quote* TODO");
1204 assert!(
1206 result.constraints.is_empty(),
1207 "Expected no constraints in GrepConfig, got {:?}",
1208 result.constraints
1209 );
1210 }
1211
1212 #[test]
1213 fn test_file_picker_bare_filename_constraint() {
1214 let parser = QueryParser::new(FileSearchConfig);
1215 let result = parser.parse("score.rs file_picker");
1216 assert_eq!(result.constraints.len(), 1);
1217 assert!(
1218 matches!(result.constraints[0], Constraint::FilePath("score.rs")),
1219 "Expected FilePath(\"score.rs\"), got {:?}",
1220 result.constraints[0]
1221 );
1222 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("file_picker"));
1223 }
1224
1225 #[test]
1226 fn test_file_picker_path_prefixed_filename_constraint() {
1227 let parser = QueryParser::new(FileSearchConfig);
1228 let result = parser.parse("libswscale/slice.c lum_convert");
1229 assert_eq!(result.constraints.len(), 1);
1230 assert!(
1231 matches!(
1232 result.constraints[0],
1233 Constraint::FilePath("libswscale/slice.c")
1234 ),
1235 "Expected FilePath(\"libswscale/slice.c\"), got {:?}",
1236 result.constraints[0]
1237 );
1238 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("lum_convert"));
1239 }
1240
1241 #[test]
1242 fn test_file_picker_single_token_filename_stays_fuzzy() {
1243 let parser = QueryParser::new(FileSearchConfig);
1244 let result = parser.parse("score.rs");
1247 assert!(result.constraints.is_empty());
1248 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
1249 }
1250
1251 #[test]
1252 fn test_absolute_path_with_location_not_path_segment() {
1253 let parser = QueryParser::new(FileSearchConfig);
1254 let result = parser.parse("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs:12");
1257 assert!(
1258 result.constraints.is_empty(),
1259 "Absolute path with location should not become a constraint, got {:?}",
1260 result.constraints
1261 );
1262 assert_eq!(
1263 result.fuzzy_query,
1264 FuzzyQuery::Text("/Users/neogoose/dev/fframes/src/renderer/concatenator.rs")
1265 );
1266 assert_eq!(result.location, Some(Location::Line(12)));
1267 }
1268
1269 #[test]
1270 fn test_file_picker_filename_with_multiple_fuzzy_parts() {
1271 let parser = QueryParser::new(FileSearchConfig);
1272 let result = parser.parse("main.rs src components");
1273 assert_eq!(result.constraints.len(), 1);
1274 assert!(matches!(
1275 result.constraints[0],
1276 Constraint::FilePath("main.rs")
1277 ));
1278 assert_eq!(
1279 result.fuzzy_query,
1280 FuzzyQuery::Parts(vec!["src", "components"])
1281 );
1282 }
1283
1284 #[test]
1285 fn test_file_picker_version_number_not_filename() {
1286 let parser = QueryParser::new(FileSearchConfig);
1287 let result = parser.parse("v2.0 release");
1288 assert!(
1290 result.constraints.is_empty(),
1291 "v2.0 should not be a FilePath constraint, got {:?}",
1292 result.constraints
1293 );
1294 }
1295
1296 #[test]
1297 fn test_file_picker_only_one_filepath_constraint() {
1298 let parser = QueryParser::new(FileSearchConfig);
1299 let result = parser.parse("main.rs score.rs");
1300 assert_eq!(result.constraints.len(), 1);
1302 assert!(matches!(
1303 result.constraints[0],
1304 Constraint::FilePath("main.rs")
1305 ));
1306 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("score.rs"));
1307 }
1308
1309 #[test]
1310 fn test_file_picker_filename_with_extension_constraint() {
1311 let parser = QueryParser::new(FileSearchConfig);
1312 let result = parser.parse("main.rs *.lua");
1313 assert_eq!(result.constraints.len(), 1);
1318 assert!(matches!(
1319 result.constraints[0],
1320 Constraint::Extension("lua")
1321 ));
1322 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("main.rs"));
1323 }
1324
1325 #[test]
1326 fn test_file_picker_dotfile_is_filename() {
1327 let parser = QueryParser::new(FileSearchConfig);
1328 let result = parser.parse(".gitignore src");
1329 assert_eq!(result.constraints.len(), 1);
1330 assert!(
1331 matches!(result.constraints[0], Constraint::FilePath(".gitignore")),
1332 "Expected FilePath(\".gitignore\"), got {:?}",
1333 result.constraints[0]
1334 );
1335 assert_eq!(result.fuzzy_query, FuzzyQuery::Text("src"));
1336 }
1337
1338 #[test]
1339 fn test_file_picker_no_extension_not_filename() {
1340 let parser = QueryParser::new(FileSearchConfig);
1341 let result = parser.parse("Makefile src");
1342 assert!(
1344 result.constraints.is_empty(),
1345 "Makefile should not be a FilePath constraint, got {:?}",
1346 result.constraints
1347 );
1348 }
1349}