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