1use doing_config::SearchConfig;
2use doing_taskpaper::Entry;
3use regex::Regex;
4use sublime_fuzzy::best_match;
5
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub enum CaseSensitivity {
9 Ignore,
10 Sensitive,
11}
12
13#[derive(Clone, Debug, Eq, PartialEq)]
15pub enum PatternToken {
16 Exclude(String),
18 Include(String),
20 Phrase(String),
22}
23
24#[derive(Clone, Debug)]
26pub enum SearchMode {
27 Exact(String),
29 Fuzzy(String, u32),
31 Pattern(Vec<PatternToken>),
33 Regex(Regex),
35}
36
37pub fn matches(text: &str, mode: &SearchMode, case: CaseSensitivity) -> bool {
39 match mode {
40 SearchMode::Exact(literal) => matches_exact(text, literal, case),
41 SearchMode::Fuzzy(pattern, distance) => matches_fuzzy(text, pattern, *distance, case),
42 SearchMode::Pattern(tokens) => matches_pattern(text, tokens, case),
43 SearchMode::Regex(rx) => rx.is_match(text),
44 }
45}
46
47pub fn matches_entry(entry: &Entry, mode: &SearchMode, case: CaseSensitivity, include_notes: bool) -> bool {
52 if matches(entry.title(), mode, case) {
53 return true;
54 }
55
56 for tag in entry.tags().iter() {
57 if matches(tag.name(), mode, case) {
58 return true;
59 }
60 }
61
62 if include_notes {
63 let note_text = entry.note().lines().join(" ");
64 if !note_text.is_empty() && matches(¬e_text, mode, case) {
65 return true;
66 }
67 }
68
69 false
70}
71
72pub fn parse_query(query: &str, config: &SearchConfig) -> Option<(SearchMode, CaseSensitivity)> {
74 let query = query.trim();
75 if query.is_empty() {
76 return None;
77 }
78
79 let case = resolve_case(query, config);
80 let mode = detect_mode(query, config);
81
82 Some((mode, case))
83}
84
85fn build_regex(pattern: &str, original_query: &str, config: &SearchConfig) -> Result<Regex, regex::Error> {
87 let case = resolve_case(original_query, config);
88 let full_pattern = match case {
89 CaseSensitivity::Ignore => format!("(?i){pattern}"),
90 CaseSensitivity::Sensitive => pattern.to_string(),
91 };
92 Regex::new(&full_pattern)
93}
94
95fn detect_mode(query: &str, config: &SearchConfig) -> SearchMode {
103 if let Some(literal) = query.strip_prefix('\'') {
104 return SearchMode::Exact(literal.to_string());
105 }
106
107 if let Some(inner) = try_extract_regex(query)
108 && let Ok(rx) = build_regex(&inner, query, config)
109 {
110 return SearchMode::Regex(rx);
111 }
112
113 if config.matching == "fuzzy" {
114 return SearchMode::Fuzzy(query.to_string(), config.distance);
115 }
116
117 SearchMode::Pattern(parse_pattern_tokens(query))
118}
119
120fn matches_exact(text: &str, literal: &str, case: CaseSensitivity) -> bool {
122 match case {
123 CaseSensitivity::Sensitive => text.contains(literal),
124 CaseSensitivity::Ignore => text.to_lowercase().contains(&literal.to_lowercase()),
125 }
126}
127
128fn matches_fuzzy(text: &str, pattern: &str, distance: u32, case: CaseSensitivity) -> bool {
134 let (haystack, needle) = match case {
135 CaseSensitivity::Sensitive => (text.to_string(), pattern.to_string()),
136 CaseSensitivity::Ignore => (text.to_lowercase(), pattern.to_lowercase()),
137 };
138
139 let result = match best_match(&needle, &haystack) {
140 Some(m) => m,
141 None => return false,
142 };
143
144 if distance == 0 {
145 return true;
146 }
147
148 let positions: Vec<usize> = result
149 .continuous_matches()
150 .flat_map(|cm| cm.start()..cm.start() + cm.len())
151 .collect();
152 positions.windows(2).all(|w| (w[1] - w[0] - 1) as u32 <= distance)
153}
154
155fn matches_pattern(text: &str, tokens: &[PatternToken], case: CaseSensitivity) -> bool {
161 let lowered;
163 let haystack = match case {
164 CaseSensitivity::Ignore => {
165 lowered = text.to_lowercase();
166 &lowered
167 }
168 CaseSensitivity::Sensitive => text,
169 };
170
171 for token in tokens {
172 match token {
173 PatternToken::Exclude(word) => {
174 let needle = match case {
175 CaseSensitivity::Ignore => word.to_lowercase(),
176 CaseSensitivity::Sensitive => word.clone(),
177 };
178 if haystack.contains(&needle) {
179 return false;
180 }
181 }
182 PatternToken::Include(word) => {
183 let needle = match case {
184 CaseSensitivity::Ignore => word.to_lowercase(),
185 CaseSensitivity::Sensitive => word.clone(),
186 };
187 if !haystack.contains(&needle) {
188 return false;
189 }
190 }
191 PatternToken::Phrase(phrase) => {
192 let needle = match case {
193 CaseSensitivity::Ignore => phrase.to_lowercase(),
194 CaseSensitivity::Sensitive => phrase.clone(),
195 };
196 if !haystack.contains(&needle) {
197 return false;
198 }
199 }
200 }
201 }
202 true
203}
204
205fn parse_pattern_tokens(query: &str) -> Vec<PatternToken> {
213 let mut tokens = Vec::new();
214 let mut chars = query.chars().peekable();
215
216 while let Some(&c) = chars.peek() {
217 if c.is_whitespace() {
218 chars.next();
219 continue;
220 }
221
222 if c == '"' {
223 chars.next(); let phrase: String = chars.by_ref().take_while(|&ch| ch != '"').collect();
225 if !phrase.is_empty() {
226 tokens.push(PatternToken::Phrase(phrase));
227 }
228 } else if c == '+' {
229 chars.next(); let word: String = chars.by_ref().take_while(|ch| !ch.is_whitespace()).collect();
231 if !word.is_empty() {
232 tokens.push(PatternToken::Include(word));
233 }
234 } else if c == '-' {
235 chars.next(); let word: String = chars.by_ref().take_while(|ch| !ch.is_whitespace()).collect();
237 if !word.is_empty() {
238 tokens.push(PatternToken::Exclude(word));
239 }
240 } else {
241 let word: String = chars.by_ref().take_while(|ch| !ch.is_whitespace()).collect();
242 if !word.is_empty() {
243 tokens.push(PatternToken::Include(word));
244 }
245 }
246 }
247
248 tokens
249}
250
251fn resolve_case(query: &str, config: &SearchConfig) -> CaseSensitivity {
256 match config.case.as_str() {
257 "sensitive" => CaseSensitivity::Sensitive,
258 "ignore" => CaseSensitivity::Ignore,
259 _ => {
260 if query.chars().any(|c| c.is_uppercase()) {
262 CaseSensitivity::Sensitive
263 } else {
264 CaseSensitivity::Ignore
265 }
266 }
267 }
268}
269
270fn try_extract_regex(query: &str) -> Option<String> {
272 let rest = query.strip_prefix('/')?;
273 let inner = rest.strip_suffix('/')?;
274 if inner.is_empty() {
275 return None;
276 }
277 Some(inner.to_string())
278}
279
280#[cfg(test)]
281mod test {
282 use super::*;
283
284 fn contains_word(text: &str, word: &str, case: CaseSensitivity) -> bool {
285 match case {
286 CaseSensitivity::Sensitive => text.contains(word),
287 CaseSensitivity::Ignore => text.to_lowercase().contains(&word.to_lowercase()),
288 }
289 }
290
291 fn default_config() -> SearchConfig {
292 SearchConfig::default()
293 }
294
295 fn fuzzy_config() -> SearchConfig {
296 SearchConfig {
297 matching: "fuzzy".into(),
298 ..SearchConfig::default()
299 }
300 }
301
302 mod contains_word {
303 use super::*;
304
305 #[test]
306 fn it_finds_case_insensitive_match() {
307 assert!(super::contains_word("Hello World", "hello", CaseSensitivity::Ignore));
308 }
309
310 #[test]
311 fn it_finds_case_sensitive_match() {
312 assert!(super::contains_word("Hello World", "Hello", CaseSensitivity::Sensitive));
313 }
314
315 #[test]
316 fn it_rejects_case_mismatch_when_sensitive() {
317 assert!(!super::contains_word(
318 "Hello World",
319 "hello",
320 CaseSensitivity::Sensitive
321 ));
322 }
323 }
324
325 mod detect_mode {
326 use super::*;
327
328 #[test]
329 fn it_detects_exact_mode_with_quote_prefix() {
330 let mode = super::super::detect_mode("'exact match", &default_config());
331
332 assert!(matches!(mode, SearchMode::Exact(s) if s == "exact match"));
333 }
334
335 #[test]
336 fn it_detects_fuzzy_mode_from_config() {
337 let mode = super::super::detect_mode("some query", &fuzzy_config());
338
339 assert!(matches!(mode, SearchMode::Fuzzy(s, 3) if s == "some query"));
340 }
341
342 #[test]
343 fn it_detects_pattern_mode_by_default() {
344 let mode = super::super::detect_mode("hello world", &default_config());
345
346 assert!(matches!(mode, SearchMode::Pattern(_)));
347 }
348
349 #[test]
350 fn it_detects_regex_mode_with_slashes() {
351 let mode = super::super::detect_mode("/foo.*bar/", &default_config());
352
353 assert!(matches!(mode, SearchMode::Regex(_)));
354 }
355 }
356
357 mod matches_entry {
358 use chrono::{Local, TimeZone};
359 use doing_taskpaper::{Note, Tag, Tags};
360
361 use super::*;
362
363 fn sample_entry() -> Entry {
364 Entry::new(
365 Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(),
366 "Working on search feature",
367 Tags::new(),
368 Note::from_text("Added fuzzy matching\nFixed regex parsing"),
369 "Currently",
370 None::<String>,
371 )
372 }
373
374 fn tagged_entry() -> Entry {
375 Entry::new(
376 Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(),
377 "Working on project",
378 Tags::from_iter(vec![
379 Tag::new("coding", None::<String>),
380 Tag::new("rust", None::<String>),
381 ]),
382 Note::new(),
383 "Currently",
384 None::<String>,
385 )
386 }
387
388 #[test]
389 fn it_does_not_duplicate_results_for_title_and_tag_match() {
390 let entry = Entry::new(
391 Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(),
392 "coding session",
393 Tags::from_iter(vec![Tag::new("coding", None::<String>)]),
394 Note::new(),
395 "Currently",
396 None::<String>,
397 );
398 let mode = SearchMode::Pattern(vec![PatternToken::Include("coding".into())]);
399
400 assert!(super::super::matches_entry(
401 &entry,
402 &mode,
403 CaseSensitivity::Ignore,
404 false,
405 ));
406 }
407
408 #[test]
409 fn it_matches_note_when_include_notes_enabled() {
410 let mode = SearchMode::Pattern(vec![PatternToken::Include("fuzzy".into())]);
411
412 assert!(super::super::matches_entry(
413 &sample_entry(),
414 &mode,
415 CaseSensitivity::Ignore,
416 true,
417 ));
418 }
419
420 #[test]
421 fn it_does_not_match_across_tag_boundaries() {
422 let entry = Entry::new(
424 Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(),
425 "Some task",
426 Tags::from_iter(vec![Tag::new("co", None::<String>), Tag::new("ding", None::<String>)]),
427 Note::new(),
428 "Currently",
429 None::<String>,
430 );
431 let mode = SearchMode::Pattern(vec![PatternToken::Include("co ding".into())]);
432
433 assert!(!super::super::matches_entry(
434 &entry,
435 &mode,
436 CaseSensitivity::Ignore,
437 false
438 ));
439 }
440
441 #[test]
442 fn it_does_not_match_tag_spanning_two_tags() {
443 let entry = Entry::new(
445 Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(),
446 "Some task",
447 Tags::from_iter(vec![Tag::new("co", None::<String>), Tag::new("ding", None::<String>)]),
448 Note::new(),
449 "Currently",
450 None::<String>,
451 );
452 let mode = SearchMode::Pattern(vec![PatternToken::Include("coding".into())]);
453
454 assert!(!super::super::matches_entry(
455 &entry,
456 &mode,
457 CaseSensitivity::Ignore,
458 false
459 ));
460 }
461
462 #[test]
463 fn it_matches_tag_name() {
464 let mode = SearchMode::Pattern(vec![PatternToken::Include("coding".into())]);
465
466 assert!(super::super::matches_entry(
467 &tagged_entry(),
468 &mode,
469 CaseSensitivity::Ignore,
470 false,
471 ));
472 }
473
474 #[test]
475 fn it_matches_tag_name_without_at_prefix() {
476 let mode = SearchMode::Pattern(vec![PatternToken::Include("rust".into())]);
477
478 assert!(super::super::matches_entry(
479 &tagged_entry(),
480 &mode,
481 CaseSensitivity::Ignore,
482 false,
483 ));
484 }
485
486 #[test]
487 fn it_matches_title() {
488 let mode = SearchMode::Pattern(vec![PatternToken::Include("search".into())]);
489
490 assert!(super::super::matches_entry(
491 &sample_entry(),
492 &mode,
493 CaseSensitivity::Ignore,
494 false,
495 ));
496 }
497
498 #[test]
499 fn it_returns_false_when_nothing_matches() {
500 let mode = SearchMode::Pattern(vec![PatternToken::Include("nonexistent".into())]);
501
502 assert!(!super::super::matches_entry(
503 &sample_entry(),
504 &mode,
505 CaseSensitivity::Ignore,
506 true,
507 ));
508 }
509
510 #[test]
511 fn it_skips_note_when_include_notes_disabled() {
512 let mode = SearchMode::Pattern(vec![PatternToken::Include("fuzzy".into())]);
513
514 assert!(!super::super::matches_entry(
515 &sample_entry(),
516 &mode,
517 CaseSensitivity::Ignore,
518 false,
519 ));
520 }
521 }
522
523 mod matches_exact {
524 use super::*;
525
526 #[test]
527 fn it_matches_case_insensitive_substring() {
528 assert!(super::super::matches_exact(
529 "Working on Project",
530 "on project",
531 CaseSensitivity::Ignore,
532 ));
533 }
534
535 #[test]
536 fn it_matches_case_sensitive_substring() {
537 assert!(super::super::matches_exact(
538 "Working on Project",
539 "on Project",
540 CaseSensitivity::Sensitive,
541 ));
542 }
543
544 #[test]
545 fn it_rejects_missing_substring() {
546 assert!(!super::super::matches_exact(
547 "Working on Project",
548 "missing",
549 CaseSensitivity::Ignore,
550 ));
551 }
552 }
553
554 mod matches_fuzzy {
555 use super::*;
556
557 #[test]
558 fn it_matches_characters_in_order_with_gaps() {
559 assert!(super::super::matches_fuzzy(
560 "Working on project",
561 "wop",
562 0,
563 CaseSensitivity::Ignore
564 ));
565 }
566
567 #[test]
568 fn it_matches_when_gap_within_distance() {
569 assert!(super::super::matches_fuzzy("a__b", "ab", 3, CaseSensitivity::Sensitive));
570 }
571
572 #[test]
573 fn it_rejects_characters_out_of_order() {
574 assert!(!super::super::matches_fuzzy(
575 "abc",
576 "cab",
577 0,
578 CaseSensitivity::Sensitive
579 ));
580 }
581
582 #[test]
583 fn it_rejects_when_gap_exceeds_distance() {
584 assert!(!super::super::matches_fuzzy(
585 "a____b",
586 "ab",
587 2,
588 CaseSensitivity::Sensitive
589 ));
590 }
591
592 #[test]
593 fn it_skips_distance_check_when_zero() {
594 assert!(super::super::matches_fuzzy(
595 "a______________b",
596 "ab",
597 0,
598 CaseSensitivity::Sensitive
599 ));
600 }
601 }
602
603 mod matches_pattern {
604 use super::*;
605
606 #[test]
607 fn it_matches_all_include_tokens() {
608 let tokens = vec![
609 PatternToken::Include("hello".into()),
610 PatternToken::Include("world".into()),
611 ];
612
613 assert!(super::super::matches_pattern(
614 "hello beautiful world",
615 &tokens,
616 CaseSensitivity::Ignore,
617 ));
618 }
619
620 #[test]
621 fn it_matches_quoted_phrase() {
622 let tokens = vec![PatternToken::Phrase("hello world".into())];
623
624 assert!(super::super::matches_pattern(
625 "say hello world today",
626 &tokens,
627 CaseSensitivity::Ignore,
628 ));
629 }
630
631 #[test]
632 fn it_rejects_when_exclude_token_found() {
633 let tokens = vec![
634 PatternToken::Include("hello".into()),
635 PatternToken::Exclude("world".into()),
636 ];
637
638 assert!(!super::super::matches_pattern(
639 "hello world",
640 &tokens,
641 CaseSensitivity::Ignore,
642 ));
643 }
644
645 #[test]
646 fn it_rejects_when_include_token_missing() {
647 let tokens = vec![PatternToken::Include("missing".into())];
648
649 assert!(!super::super::matches_pattern(
650 "hello world",
651 &tokens,
652 CaseSensitivity::Ignore,
653 ));
654 }
655 }
656
657 mod parse_pattern_tokens {
658 use pretty_assertions::assert_eq;
659
660 use super::*;
661
662 #[test]
663 fn it_parses_bare_words_as_include() {
664 let tokens = super::super::parse_pattern_tokens("hello world");
665
666 assert_eq!(
667 tokens,
668 vec![
669 PatternToken::Include("hello".into()),
670 PatternToken::Include("world".into()),
671 ]
672 );
673 }
674
675 #[test]
676 fn it_parses_exclude_tokens() {
677 let tokens = super::super::parse_pattern_tokens("hello -world");
678
679 assert_eq!(
680 tokens,
681 vec![
682 PatternToken::Include("hello".into()),
683 PatternToken::Exclude("world".into()),
684 ]
685 );
686 }
687
688 #[test]
689 fn it_parses_include_tokens() {
690 let tokens = super::super::parse_pattern_tokens("+hello +world");
691
692 assert_eq!(
693 tokens,
694 vec![
695 PatternToken::Include("hello".into()),
696 PatternToken::Include("world".into()),
697 ]
698 );
699 }
700
701 #[test]
702 fn it_parses_mixed_tokens() {
703 let tokens = super::super::parse_pattern_tokens("+required -excluded bare \"exact phrase\"");
704
705 assert_eq!(
706 tokens,
707 vec![
708 PatternToken::Include("required".into()),
709 PatternToken::Exclude("excluded".into()),
710 PatternToken::Include("bare".into()),
711 PatternToken::Phrase("exact phrase".into()),
712 ]
713 );
714 }
715
716 #[test]
717 fn it_parses_quoted_phrases() {
718 let tokens = super::super::parse_pattern_tokens("\"hello world\"");
719
720 assert_eq!(tokens, vec![PatternToken::Phrase("hello world".into())]);
721 }
722 }
723
724 mod parse_query {
725 use super::*;
726
727 #[test]
728 fn it_returns_none_for_empty_query() {
729 assert!(super::super::parse_query("", &default_config()).is_none());
730 }
731
732 #[test]
733 fn it_returns_none_for_whitespace_query() {
734 assert!(super::super::parse_query(" ", &default_config()).is_none());
735 }
736
737 #[test]
738 fn it_returns_pattern_mode_by_default() {
739 let (mode, _) = super::super::parse_query("hello", &default_config()).unwrap();
740
741 assert!(matches!(mode, SearchMode::Pattern(_)));
742 }
743 }
744
745 mod resolve_case {
746 use pretty_assertions::assert_eq;
747
748 use super::*;
749
750 #[test]
751 fn it_returns_ignore_for_all_lowercase() {
752 let case = super::super::resolve_case("hello world", &default_config());
753
754 assert_eq!(case, CaseSensitivity::Ignore);
755 }
756
757 #[test]
758 fn it_returns_ignore_when_config_is_ignore() {
759 let config = SearchConfig {
760 case: "ignore".into(),
761 ..SearchConfig::default()
762 };
763
764 let case = super::super::resolve_case("Hello", &config);
765
766 assert_eq!(case, CaseSensitivity::Ignore);
767 }
768
769 #[test]
770 fn it_returns_sensitive_for_mixed_case() {
771 let case = super::super::resolve_case("Hello world", &default_config());
772
773 assert_eq!(case, CaseSensitivity::Sensitive);
774 }
775
776 #[test]
777 fn it_returns_sensitive_when_config_is_sensitive() {
778 let config = SearchConfig {
779 case: "sensitive".into(),
780 ..SearchConfig::default()
781 };
782
783 let case = super::super::resolve_case("hello", &config);
784
785 assert_eq!(case, CaseSensitivity::Sensitive);
786 }
787 }
788
789 mod try_extract_regex {
790 use pretty_assertions::assert_eq;
791
792 #[test]
793 fn it_extracts_pattern_from_slashes() {
794 let result = super::super::try_extract_regex("/foo.*bar/");
795
796 assert_eq!(result, Some("foo.*bar".into()));
797 }
798
799 #[test]
800 fn it_returns_none_for_empty_pattern() {
801 assert!(super::super::try_extract_regex("//").is_none());
802 }
803
804 #[test]
805 fn it_returns_none_for_no_slashes() {
806 assert!(super::super::try_extract_regex("hello").is_none());
807 }
808
809 #[test]
810 fn it_returns_none_for_single_slash() {
811 assert!(super::super::try_extract_regex("/hello").is_none());
812 }
813 }
814}