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