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