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