1use std::ops::Range;
2
3use kimun_core::note::{
4 ExclusionZones, is_inside_code_link_or_frontmatter, is_inside_exclusion_zone,
5};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum TriggerKind {
9 Wikilink,
10 Hashtag,
11 LinkFilter,
12 SavedSearch,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct TriggerContext {
19 pub kind: TriggerKind,
20 pub query: String,
23 pub replace_range: Range<usize>,
26 pub anchor_col: usize,
29 pub opener: Option<char>,
33}
34
35#[derive(Debug, Clone, Copy)]
37pub struct TriggerOptions {
38 pub disambiguate_header: bool,
44 pub apply_exclusion_zone: bool,
51 pub allow_saved_search: bool,
58}
59
60impl Default for TriggerOptions {
61 fn default() -> Self {
62 Self {
63 disambiguate_header: true,
64 apply_exclusion_zone: true,
65 allow_saved_search: false,
66 }
67 }
68}
69
70pub fn detect_trigger(text: &str, cursor: usize) -> Option<TriggerContext> {
87 detect_trigger_with(text, cursor, TriggerOptions::default())
88}
89
90pub fn detect_trigger_with(
94 text: &str,
95 cursor: usize,
96 opts: TriggerOptions,
97) -> Option<TriggerContext> {
98 detect_trigger_with_zones(text, cursor, opts, None)
99}
100
101pub trait ZoneOracle {
107 fn contains(&mut self, cursor: usize) -> bool;
109 fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool;
112}
113
114struct PrecomputedOracle<'a>(&'a ExclusionZones);
116impl ZoneOracle for PrecomputedOracle<'_> {
117 fn contains(&mut self, cursor: usize) -> bool {
118 self.0.contains(cursor)
119 }
120 fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool {
121 self.0.contains_code_link_or_frontmatter(cursor)
122 }
123}
124
125struct RecomputeOracle<'t>(&'t str);
130impl ZoneOracle for RecomputeOracle<'_> {
131 fn contains(&mut self, cursor: usize) -> bool {
132 is_inside_exclusion_zone(self.0, cursor)
133 }
134 fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool {
135 is_inside_code_link_or_frontmatter(self.0, cursor)
136 }
137}
138
139pub fn detect_trigger_with_zones(
144 text: &str,
145 cursor: usize,
146 opts: TriggerOptions,
147 zones: Option<&ExclusionZones>,
148) -> Option<TriggerContext> {
149 match zones {
150 Some(z) => detect_trigger_with_oracle(text, cursor, opts, &mut PrecomputedOracle(z)),
151 None => detect_trigger_with_oracle(text, cursor, opts, &mut RecomputeOracle(text)),
152 }
153}
154
155pub fn detect_trigger_with_oracle(
160 text: &str,
161 cursor: usize,
162 opts: TriggerOptions,
163 oracle: &mut dyn ZoneOracle,
164) -> Option<TriggerContext> {
165 if cursor > text.len() || !text.is_char_boundary(cursor) {
166 return None;
167 }
168
169 if opts.allow_saved_search
175 && let Some(q_pos) = text.find('?')
176 && text[..q_pos].bytes().all(|b| b == b' ' || b == b'\t')
177 {
178 let inner_start = q_pos + 1;
179 if inner_start <= cursor {
182 return Some(TriggerContext {
183 kind: TriggerKind::SavedSearch,
184 query: text[inner_start..cursor].to_string(),
185 replace_range: inner_start..cursor,
186 anchor_col: inner_start,
187 opener: None,
188 });
189 }
190 }
191
192 let mut hash_pos: Option<usize> = None;
215 let mut hash_possible = true;
216 let mut wikilink_pos: Option<usize> = None;
217 let mut wikilink_possible = true;
218 let mut pipe_seen = false;
219 let mut prev_was_bracket = false;
220 let mut link_filter_pos: Option<usize> = None;
221 let mut link_filter_possible = true;
222
223 let mut i = cursor;
224 while i > 0 && (hash_possible || wikilink_possible || link_filter_possible) {
225 let prev = prev_char_boundary(text, i);
226 let c = text[prev..i].chars().next()?;
227
228 if c == '\n' || c == '\r' {
229 break;
230 }
231
232 if wikilink_possible {
233 match c {
234 ']' => wikilink_possible = false,
235 '|' => pipe_seen = true,
236 '[' if prev_was_bracket => {
237 wikilink_pos = Some(prev);
238 break;
239 }
240 _ => {}
241 }
242 }
243
244 if hash_possible && hash_pos.is_none() {
245 if c == '#' {
246 hash_pos = Some(prev);
247 } else if !(c.is_ascii_alphanumeric() || c == '_') {
248 hash_possible = false;
249 }
250 }
251
252 if link_filter_possible && link_filter_pos.is_none() {
253 if is_link_filter_opener(c) {
254 link_filter_pos = Some(prev);
255 link_filter_possible = false;
261 } else if !is_link_filter_target_char(c) {
262 link_filter_possible = false;
263 }
264 }
265
266 prev_was_bracket = c == '[';
267 i = prev;
268 }
269
270 if let Some(open) = wikilink_pos {
274 if pipe_seen {
275 return None;
276 }
277 let inner_start = open + 2;
278 if inner_start > cursor {
279 return None;
280 }
281 if opts.apply_exclusion_zone && oracle.contains_code_link_or_frontmatter(cursor) {
287 return None;
288 }
289 let query = text[inner_start..cursor].to_string();
290 return Some(TriggerContext {
291 kind: TriggerKind::Wikilink,
292 query,
293 replace_range: inner_start..cursor,
294 anchor_col: inner_start,
295 opener: None,
296 });
297 }
298
299 if let Some(hash) = hash_pos {
300 let inner_start = hash + 1;
301 if inner_start > cursor {
302 return None;
303 }
304
305 if opts.apply_exclusion_zone && oracle.contains(cursor) {
313 return None;
314 }
315
316 if hash > 0 {
325 let preceding_blocks_label = text[..hash]
326 .chars()
327 .next_back()
328 .map(|c| c.is_alphanumeric() || c == '_' || c == '#')
329 .unwrap_or(false);
330 if preceding_blocks_label {
331 return None;
332 }
333 }
334 let bytes = text.as_bytes();
335 let mut word_end = inner_start;
336 while word_end < bytes.len() {
337 let b = bytes[word_end];
338 if b.is_ascii_alphanumeric() || b == b'_' {
339 word_end += 1;
340 } else {
341 break;
342 }
343 }
344 let following_blocks_label = text[word_end..]
345 .chars()
346 .next()
347 .map(|c| c.is_alphanumeric() || c == '_' || c == '#')
348 .unwrap_or(false);
349 if following_blocks_label {
350 return None;
351 }
352
353 if opts.disambiguate_header {
360 let at_line_start = hash == 0 || text.as_bytes().get(hash - 1) == Some(&b'\n');
361 if at_line_start {
362 if cursor == inner_start {
363 return None;
364 }
365 let next_char = text[inner_start..].chars().next();
366 if next_char == Some(' ') {
367 return None;
368 }
369 }
370 }
371
372 let query = text[inner_start..cursor].to_string();
373 return Some(TriggerContext {
374 kind: TriggerKind::Hashtag,
375 query,
376 replace_range: inner_start..cursor,
377 anchor_col: inner_start,
378 opener: None,
379 });
380 }
381
382 if let Some(gt) = link_filter_pos {
390 let inner_start = gt + 1; if inner_start > cursor {
392 return None;
393 }
394
395 let token_start = if gt == 0 {
397 true } else {
399 let before_gt = text[..gt].chars().next_back().unwrap();
400 if before_gt.is_whitespace() {
401 true } else if before_gt == '-' {
403 let dash_pos = gt - before_gt.len_utf8();
406 dash_pos == 0
407 || text[..dash_pos]
408 .chars()
409 .next_back()
410 .map(|c| c.is_whitespace())
411 .unwrap_or(false)
412 } else {
413 false
414 }
415 };
416 if !token_start {
417 return None;
418 }
419
420 if opts.apply_exclusion_zone && oracle.contains(cursor) {
421 return None;
422 }
423
424 let opener = text[gt..inner_start].chars().next();
427
428 let query = text[inner_start..cursor].to_string();
429 return Some(TriggerContext {
430 kind: TriggerKind::LinkFilter,
431 query,
432 replace_range: inner_start..cursor,
433 anchor_col: inner_start,
434 opener,
435 });
436 }
437
438 None
439}
440
441fn is_link_filter_opener(c: char) -> bool {
446 matches!(c, '<' | '>' | '=')
447}
448
449fn is_link_filter_target_char(c: char) -> bool {
456 c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '*' | '{' | '}')
457}
458
459fn prev_char_boundary(text: &str, i: usize) -> usize {
460 (0..i)
461 .rev()
462 .find(|&p| text.is_char_boundary(p))
463 .unwrap_or(0)
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 fn ctx(text: &str, cursor: usize) -> Option<TriggerContext> {
471 detect_trigger(text, cursor)
472 }
473
474 fn ctx_ss(text: &str, cursor: usize) -> Option<TriggerContext> {
476 detect_trigger_with(
477 text,
478 cursor,
479 TriggerOptions {
480 allow_saved_search: true,
481 ..TriggerOptions::default()
482 },
483 )
484 }
485
486 struct CountingOracle {
488 calls: usize,
489 }
490 impl ZoneOracle for CountingOracle {
491 fn contains(&mut self, _: usize) -> bool {
492 self.calls += 1;
493 false
494 }
495 fn contains_code_link_or_frontmatter(&mut self, _: usize) -> bool {
496 self.calls += 1;
497 false
498 }
499 }
500
501 #[test]
504 fn oracle_untouched_without_trigger_candidate() {
505 let mut o = CountingOracle { calls: 0 };
509 let r = detect_trigger_with_oracle("hello world", 11, TriggerOptions::default(), &mut o);
510 assert!(r.is_none());
511 assert_eq!(o.calls, 0, "no opener must not consult the zone oracle");
512 }
513
514 #[test]
515 fn oracle_consulted_for_hashtag_candidate() {
516 let mut o = CountingOracle { calls: 0 };
517 let _ = detect_trigger_with_oracle("#tag", 4, TriggerOptions::default(), &mut o);
518 assert!(o.calls >= 1, "a # candidate must consult the veto oracle");
519 }
520
521 #[test]
522 fn oracle_consulted_for_wikilink_candidate() {
523 let mut o = CountingOracle { calls: 0 };
524 let _ = detect_trigger_with_oracle("[[me", 4, TriggerOptions::default(), &mut o);
525 assert!(o.calls >= 1, "a [[ candidate must consult the veto oracle");
526 }
527
528 #[test]
529 fn oracle_untouched_when_exclusion_disabled() {
530 let opts = TriggerOptions {
533 apply_exclusion_zone: false,
534 ..TriggerOptions::default()
535 };
536 let mut o = CountingOracle { calls: 0 };
537 let _ = detect_trigger_with_oracle("#tag", 4, opts, &mut o);
538 assert_eq!(
539 o.calls, 0,
540 "apply_exclusion_zone=false must skip the oracle entirely"
541 );
542 }
543
544 #[test]
547 fn wikilink_opens_with_empty_query() {
548 let t = ctx("[[", 2).unwrap();
549 assert_eq!(t.kind, TriggerKind::Wikilink);
550 assert_eq!(t.query, "");
551 assert_eq!(t.replace_range, 2..2);
552 assert_eq!(t.anchor_col, 2);
553 }
554
555 #[test]
556 fn wikilink_filters_by_typed_prefix() {
557 let t = ctx("see [[foo", 9).unwrap();
558 assert_eq!(t.kind, TriggerKind::Wikilink);
559 assert_eq!(t.query, "foo");
560 assert_eq!(t.replace_range, 6..9);
561 }
562
563 #[test]
564 fn wikilink_with_pipe_alias_does_not_trigger() {
565 assert!(ctx("[[target|al", 11).is_none());
567 }
568
569 #[test]
570 fn wikilink_after_closing_brackets_is_not_a_trigger() {
571 assert!(ctx("[[done]] more", 13).is_none());
572 }
573
574 #[test]
575 fn wikilink_with_newline_inside_does_not_trigger() {
576 assert!(ctx("[[foo\nbar", 9).is_none());
577 }
578
579 #[test]
580 fn lone_single_bracket_does_not_trigger() {
581 assert!(ctx("[foo", 4).is_none());
582 }
583
584 #[test]
587 fn hashtag_mid_line_opens_immediately() {
588 let t = ctx("some note #", 11).unwrap();
589 assert_eq!(t.kind, TriggerKind::Hashtag);
590 assert_eq!(t.query, "");
591 assert_eq!(t.replace_range, 11..11);
592 }
593
594 #[test]
595 fn hashtag_with_typed_query() {
596 let t = ctx("about #pro", 10).unwrap();
597 assert_eq!(t.kind, TriggerKind::Hashtag);
598 assert_eq!(t.query, "pro");
599 assert_eq!(t.replace_range, 7..10);
600 assert_eq!(t.anchor_col, 7);
601 }
602
603 #[test]
606 fn saved_search_opens_on_leading_question_mark() {
607 let t = ctx_ss("?to", 3).unwrap();
608 assert_eq!(t.kind, TriggerKind::SavedSearch);
609 assert_eq!(t.query, "to");
610 assert_eq!(t.replace_range, 1..3);
611 assert_eq!(t.anchor_col, 1);
612 }
613
614 #[test]
615 fn saved_search_opens_with_empty_query() {
616 let t = ctx_ss("?", 1).unwrap();
617 assert_eq!(t.kind, TriggerKind::SavedSearch);
618 assert_eq!(t.query, "");
619 assert_eq!(t.replace_range, 1..1);
620 }
621
622 #[test]
623 fn saved_search_not_triggered_when_not_leading() {
624 assert_ne!(
626 ctx_ss("#a ?to", 6).map(|t| t.kind),
627 Some(TriggerKind::SavedSearch)
628 );
629 assert_ne!(
630 ctx_ss("note ?x", 7).map(|t| t.kind),
631 Some(TriggerKind::SavedSearch)
632 );
633 }
634
635 #[test]
636 fn saved_search_off_by_default() {
637 assert_eq!(ctx("?to", 3), None);
640 }
641
642 #[test]
643 fn hashtag_closes_when_word_char_boundary_passes() {
644 assert!(ctx("about #proj here", 16).is_none());
646 }
647
648 #[test]
649 fn hash_mid_word_does_not_trigger() {
650 assert!(ctx("hello#", 6).is_none());
652 }
653
654 #[test]
655 fn hash_mid_word_with_query_does_not_trigger() {
656 assert!(ctx("hello#tag", 9).is_none());
658 }
659
660 #[test]
661 fn hash_after_digit_does_not_trigger() {
662 assert!(ctx("abc123#tag", 10).is_none());
663 }
664
665 #[test]
666 fn hash_after_underscore_does_not_trigger() {
667 assert!(ctx("foo_#tag", 8).is_none());
668 }
669
670 #[test]
671 fn double_hash_does_not_trigger() {
672 assert!(ctx("##tag", 5).is_none());
674 }
675
676 #[test]
677 fn triple_hash_does_not_trigger() {
678 assert!(ctx("###tag", 6).is_none());
679 }
680
681 #[test]
682 fn double_hash_mid_line_does_not_trigger() {
683 assert!(ctx("hello ##tag", 11).is_none());
684 }
685
686 #[test]
687 fn hash_between_double_hash_at_start_does_not_trigger() {
688 assert!(ctx("##tag", 1).is_none());
691 }
692
693 #[test]
694 fn adjacent_hash_at_cursor_does_not_trigger() {
695 assert!(ctx("#tag#more", 4).is_none());
698 }
699
700 #[test]
701 fn adjacent_hash_with_cursor_inside_tag_does_not_trigger() {
702 assert!(ctx("#tag#more", 3).is_none());
705 }
706
707 #[test]
708 fn trailing_hash_after_tag_does_not_trigger() {
709 assert!(ctx("#draft#", 6).is_none());
711 }
712
713 #[test]
714 fn search_box_double_hash_at_start_does_not_trigger() {
715 let opts = TriggerOptions {
719 disambiguate_header: false,
720 apply_exclusion_zone: false,
721 allow_saved_search: false,
722 };
723 assert!(detect_trigger_with("##tag", 1, opts).is_none());
724 assert!(detect_trigger_with("##", 1, opts).is_none());
725 }
726
727 #[test]
728 fn hash_after_space_then_hash_triggers() {
729 let t = ctx("# #tag", 6).unwrap();
731 assert_eq!(t.kind, TriggerKind::Hashtag);
732 assert_eq!(t.query, "tag");
733 }
734
735 #[test]
736 fn hash_after_punctuation_triggers() {
737 let t = ctx("hi,#tag", 7).unwrap();
739 assert_eq!(t.kind, TriggerKind::Hashtag);
740 assert_eq!(t.query, "tag");
741 }
742
743 #[test]
746 fn hash_alone_at_start_of_line_does_not_trigger() {
747 assert!(ctx("#", 1).is_none());
748 }
749
750 #[test]
751 fn hash_then_space_at_start_of_line_is_header() {
752 assert!(ctx("# ", 2).is_none());
753 }
754
755 #[test]
756 fn hash_then_letter_at_start_of_line_opens_popup() {
757 let t = ctx("#p", 2).unwrap();
758 assert_eq!(t.kind, TriggerKind::Hashtag);
759 assert_eq!(t.query, "p");
760 assert_eq!(t.replace_range, 1..2);
761 }
762
763 #[test]
764 fn hash_then_letter_after_newline_opens_popup() {
765 let t = ctx("para\n#p", 7).unwrap();
766 assert_eq!(t.kind, TriggerKind::Hashtag);
767 assert_eq!(t.query, "p");
768 }
769
770 #[test]
771 fn hash_then_space_after_newline_is_header() {
772 assert!(ctx("para\n# ", 7).is_none());
773 }
774
775 #[test]
778 fn wikilink_outer_wins_over_inner_hash() {
779 let t = ctx("[[#foo", 6).unwrap();
782 assert_eq!(t.kind, TriggerKind::Wikilink);
783 assert_eq!(t.query, "#foo");
784 }
785
786 #[test]
789 fn hash_inside_inline_code_does_not_trigger() {
790 assert!(ctx("here `#tag`", 9).is_none());
792 }
793
794 #[test]
795 fn hash_inside_fenced_code_does_not_trigger() {
796 let text = "para\n\n```\n#tag\n```\nafter";
797 let cursor = text.find("#tag").unwrap() + 4;
798 assert!(ctx(text, cursor).is_none());
799 }
800
801 #[test]
802 fn hash_inside_frontmatter_does_not_trigger() {
803 let text = "---\ntitle: Hi #tag\n---\nbody";
804 let cursor = text.find("#tag").unwrap() + 4;
805 assert!(ctx(text, cursor).is_none());
806 }
807
808 #[test]
811 fn cursor_at_zero_never_triggers() {
812 assert!(ctx("", 0).is_none());
813 assert!(ctx("anything", 0).is_none());
814 }
815
816 #[test]
817 fn cursor_past_end_returns_none() {
818 assert!(ctx("short", 100).is_none());
819 }
820
821 #[test]
822 fn cursor_not_on_char_boundary_returns_none() {
823 assert!(ctx("é", 1).is_none());
825 }
826
827 #[test]
830 fn trigger_active_at_every_cursor_position_inside_target() {
831 let text = "see [[foo";
832 for cursor in 6..=9 {
835 let t = ctx(text, cursor).unwrap();
836 assert_eq!(t.kind, TriggerKind::Wikilink);
837 assert_eq!(t.query, &text[6..cursor]);
838 }
839 }
840
841 #[test]
842 fn trigger_cleared_when_cursor_moves_before_opener() {
843 assert!(ctx("see [[foo", 5).is_none());
845 }
846
847 #[test]
850 fn crlf_line_treated_like_lf_for_column_0() {
851 let text = "para\r\n#p";
854 let cursor = text.len();
855 let t = ctx(text, cursor).unwrap();
856 assert_eq!(t.kind, TriggerKind::Hashtag);
857 assert_eq!(t.query, "p");
858 }
859
860 #[test]
861 fn crlf_just_after_hash_at_start_of_line_defers() {
862 let text = "para\r\n#";
863 assert!(ctx(text, text.len()).is_none());
864 }
865
866 #[test]
869 fn search_box_opts_hash_alone_at_start_opens_immediately() {
870 let opts = TriggerOptions {
871 disambiguate_header: false,
872 apply_exclusion_zone: true,
873 allow_saved_search: false,
874 };
875 let t = detect_trigger_with("#", 1, opts).unwrap();
876 assert_eq!(t.kind, TriggerKind::Hashtag);
877 assert_eq!(t.query, "");
878 }
879
880 #[test]
881 fn search_box_opts_hash_then_space_at_start_still_opens() {
882 let opts = TriggerOptions {
886 disambiguate_header: false,
887 apply_exclusion_zone: true,
888 allow_saved_search: false,
889 };
890 let t = detect_trigger_with("#", 1, opts);
891 assert!(t.is_some());
892 }
893
894 #[test]
895 fn search_box_opts_mid_line_unchanged() {
896 let opts = TriggerOptions {
898 disambiguate_header: false,
899 apply_exclusion_zone: true,
900 allow_saved_search: false,
901 };
902 let t = detect_trigger_with("foo #pro", 8, opts).unwrap();
903 assert_eq!(t.kind, TriggerKind::Hashtag);
904 assert_eq!(t.query, "pro");
905 }
906
907 #[test]
908 fn wikilink_inside_fenced_code_does_not_trigger() {
909 let text = "para\n\n```\n[[note\n```\nafter";
910 let cursor = text.find("[[note").unwrap() + 6;
911 assert!(ctx(text, cursor).is_none());
912 }
913
914 #[test]
915 fn wikilink_inside_frontmatter_does_not_trigger() {
916 let text = "---\ntitle: see [[me\n---\nbody";
917 let cursor = text.find("[[me").unwrap() + 4;
918 assert!(ctx(text, cursor).is_none());
919 }
920
921 #[test]
922 fn wikilink_reopen_mid_existing_target_still_works() {
923 let text = "see [[foo]]";
928 let t = ctx(text, 7).unwrap(); assert_eq!(t.kind, TriggerKind::Wikilink);
930 }
931
932 #[test]
933 fn search_box_opts_backtick_does_not_suppress_hashtag() {
934 let opts = TriggerOptions {
938 disambiguate_header: false,
939 apply_exclusion_zone: false,
940 allow_saved_search: false,
941 };
942 let t = detect_trigger_with("`#abc", 5, opts).unwrap();
943 assert_eq!(t.kind, TriggerKind::Hashtag);
944 assert_eq!(t.query, "abc");
945 }
946
947 #[test]
950 fn detects_link_filter_trigger() {
951 let t = detect_trigger(">pro", 4).expect("should detect");
953 assert_eq!(t.kind, TriggerKind::LinkFilter);
954 assert_eq!(t.query, "pro");
955 }
956
957 #[test]
958 fn detects_excluded_link_filter_trigger() {
959 let t = detect_trigger("->dra", 5).expect("should detect");
960 assert_eq!(t.kind, TriggerKind::LinkFilter);
961 assert_eq!(t.query, "dra");
962 }
963
964 #[test]
965 fn link_filter_only_at_token_start() {
966 let t = detect_trigger("a>b", 3);
969 assert!(t.is_none() || t.unwrap().kind != TriggerKind::LinkFilter);
970 }
971
972 #[test]
973 fn detects_backlink_filter_trigger() {
974 let t = detect_trigger("<pro", 4).expect("should detect");
975 assert_eq!(t.kind, TriggerKind::LinkFilter);
976 assert_eq!(t.query, "pro");
977 assert_eq!(t.opener, Some('<'));
978 }
979
980 #[test]
981 fn detects_forward_link_filter_trigger() {
982 let t = detect_trigger(">pro", 4).expect("should detect");
983 assert_eq!(t.kind, TriggerKind::LinkFilter);
984 assert_eq!(t.query, "pro");
985 assert_eq!(t.opener, Some('>'));
986 }
987
988 #[test]
989 fn detects_note_name_filter_trigger() {
990 let t = detect_trigger("=pro", 4).expect("should detect");
991 assert_eq!(t.kind, TriggerKind::LinkFilter);
992 assert_eq!(t.query, "pro");
993 assert_eq!(t.opener, Some('='));
994 }
995
996 #[test]
997 fn excluded_link_filter_captures_inner_opener() {
998 for (q, op) in [("-<dra", '<'), ("->dra", '>'), ("-=dra", '=')] {
1001 let t = detect_trigger(q, q.len()).expect("should detect");
1002 assert_eq!(t.kind, TriggerKind::LinkFilter, "{q}");
1003 assert_eq!(t.opener, Some(op), "{q}");
1004 }
1005 }
1006
1007 #[test]
1008 fn wikilink_and_hashtag_have_no_opener() {
1009 assert_eq!(ctx("[[foo", 5).unwrap().opener, None);
1010 assert_eq!(ctx("a #foo", 6).unwrap().opener, None);
1011 }
1012
1013 #[test]
1014 fn detects_excluded_forms() {
1015 for q in ["-<dra", "->dra", "-=dra"] {
1016 let t = detect_trigger(q, q.len()).expect("should detect");
1017 assert_eq!(t.kind, TriggerKind::LinkFilter, "{q}");
1018 assert_eq!(t.query, "dra", "{q}");
1019 }
1020 }
1021
1022 #[test]
1023 fn link_filter_new_openers_only_at_token_start() {
1024 let t = detect_trigger("a<b", 3);
1026 assert!(t.is_none() || t.unwrap().kind != TriggerKind::LinkFilter);
1027 }
1028}