1use std::ops::Range;
2
3use kimun_core::note::scan::{
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;
214 let mut hash_possible = true;
215 let mut wikilink_pos: Option<usize> = None;
216 let mut wikilink_possible = true;
217 let mut pipe_seen = false;
218 let mut prev_was_bracket = false;
219 let mut link_filter_pos: Option<usize> = None;
220 let mut link_filter_possible = true;
221
222 let mut i = cursor;
223 while i > 0 && (hash_possible || wikilink_possible || link_filter_possible) {
224 let prev = prev_char_boundary(text, i);
225 let c = text[prev..i].chars().next()?;
226
227 if c == '\n' || c == '\r' {
228 break;
229 }
230
231 if wikilink_possible {
232 match c {
233 ']' => wikilink_possible = false,
234 '|' => pipe_seen = true,
235 '[' if prev_was_bracket => {
236 wikilink_pos = Some(prev);
237 break;
238 }
239 _ => {}
240 }
241 }
242
243 if hash_possible && hash_pos.is_none() {
244 if c == '#' {
245 hash_pos = Some(prev);
246 } else if !(c.is_ascii_alphanumeric() || c == '_') {
247 hash_possible = false;
248 }
249 }
250
251 if link_filter_possible && link_filter_pos.is_none() {
252 if is_link_filter_opener(c) {
253 link_filter_pos = Some(prev);
254 link_filter_possible = false;
260 } else if !is_link_filter_target_char(c) {
261 link_filter_possible = false;
262 }
263 }
264
265 prev_was_bracket = c == '[';
266 i = prev;
267 }
268
269 if let Some(open) = wikilink_pos {
273 if pipe_seen {
274 return None;
275 }
276 let inner_start = open + 2;
277 if inner_start > cursor {
278 return None;
279 }
280 if opts.apply_exclusion_zone && oracle.contains_code_link_or_frontmatter(cursor) {
286 return None;
287 }
288 let query = text[inner_start..cursor].to_string();
289 return Some(TriggerContext {
290 kind: TriggerKind::Wikilink,
291 query,
292 replace_range: inner_start..cursor,
293 anchor_col: inner_start,
294 opener: None,
295 });
296 }
297
298 if let Some(hash) = hash_pos {
299 let inner_start = hash + 1;
300 if inner_start > cursor {
301 return None;
302 }
303
304 if opts.apply_exclusion_zone && oracle.contains(cursor) {
312 return None;
313 }
314
315 if hash > 0 {
324 let preceding_blocks_label = text[..hash]
325 .chars()
326 .next_back()
327 .map(|c| c.is_alphanumeric() || c == '_' || c == '#')
328 .unwrap_or(false);
329 if preceding_blocks_label {
330 return None;
331 }
332 }
333 let bytes = text.as_bytes();
334 let mut word_end = inner_start;
335 while word_end < bytes.len() {
336 let b = bytes[word_end];
337 if b.is_ascii_alphanumeric() || b == b'_' {
338 word_end += 1;
339 } else {
340 break;
341 }
342 }
343 let following_blocks_label = text[word_end..]
344 .chars()
345 .next()
346 .map(|c| c.is_alphanumeric() || c == '_' || c == '#')
347 .unwrap_or(false);
348 if following_blocks_label {
349 return None;
350 }
351
352 if opts.disambiguate_header {
359 let at_line_start = hash == 0 || text.as_bytes().get(hash - 1) == Some(&b'\n');
360 if at_line_start {
361 if cursor == inner_start {
362 return None;
363 }
364 let next_char = text[inner_start..].chars().next();
365 if next_char == Some(' ') {
366 return None;
367 }
368 }
369 }
370
371 let query = text[inner_start..cursor].to_string();
372 return Some(TriggerContext {
373 kind: TriggerKind::Hashtag,
374 query,
375 replace_range: inner_start..cursor,
376 anchor_col: inner_start,
377 opener: None,
378 });
379 }
380
381 if let Some(gt) = link_filter_pos {
389 let inner_start = gt + 1; if inner_start > cursor {
391 return None;
392 }
393
394 let token_start = if gt == 0 {
396 true } else {
398 let before_gt = text[..gt].chars().next_back().unwrap();
399 if before_gt.is_whitespace() {
400 true } else if before_gt == '-' {
402 let dash_pos = gt - before_gt.len_utf8();
405 dash_pos == 0
406 || text[..dash_pos]
407 .chars()
408 .next_back()
409 .map(|c| c.is_whitespace())
410 .unwrap_or(false)
411 } else {
412 false
413 }
414 };
415 if !token_start {
416 return None;
417 }
418
419 if opts.apply_exclusion_zone && oracle.contains(cursor) {
420 return None;
421 }
422
423 let opener = text[gt..inner_start].chars().next();
426
427 let query = text[inner_start..cursor].to_string();
428 return Some(TriggerContext {
429 kind: TriggerKind::LinkFilter,
430 query,
431 replace_range: inner_start..cursor,
432 anchor_col: inner_start,
433 opener,
434 });
435 }
436
437 None
438}
439
440fn is_link_filter_opener(c: char) -> bool {
445 matches!(c, '<' | '>' | '=')
446}
447
448fn is_link_filter_target_char(c: char) -> bool {
455 c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '*' | '{' | '}')
456}
457
458fn prev_char_boundary(text: &str, i: usize) -> usize {
459 (0..i)
460 .rev()
461 .find(|&p| text.is_char_boundary(p))
462 .unwrap_or(0)
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 fn ctx(text: &str, cursor: usize) -> Option<TriggerContext> {
470 detect_trigger(text, cursor)
471 }
472
473 fn ctx_ss(text: &str, cursor: usize) -> Option<TriggerContext> {
475 detect_trigger_with(
476 text,
477 cursor,
478 TriggerOptions {
479 allow_saved_search: true,
480 ..TriggerOptions::default()
481 },
482 )
483 }
484
485 struct CountingOracle {
487 calls: usize,
488 }
489 impl ZoneOracle for CountingOracle {
490 fn contains(&mut self, _: usize) -> bool {
491 self.calls += 1;
492 false
493 }
494 fn contains_code_link_or_frontmatter(&mut self, _: usize) -> bool {
495 self.calls += 1;
496 false
497 }
498 }
499
500 #[test]
503 fn oracle_untouched_without_trigger_candidate() {
504 let mut o = CountingOracle { calls: 0 };
508 let r = detect_trigger_with_oracle("hello world", 11, TriggerOptions::default(), &mut o);
509 assert!(r.is_none());
510 assert_eq!(o.calls, 0, "no opener must not consult the zone oracle");
511 }
512
513 #[test]
514 fn oracle_consulted_for_hashtag_candidate() {
515 let mut o = CountingOracle { calls: 0 };
516 let _ = detect_trigger_with_oracle("#tag", 4, TriggerOptions::default(), &mut o);
517 assert!(o.calls >= 1, "a # candidate must consult the veto oracle");
518 }
519
520 #[test]
521 fn oracle_consulted_for_wikilink_candidate() {
522 let mut o = CountingOracle { calls: 0 };
523 let _ = detect_trigger_with_oracle("[[me", 4, TriggerOptions::default(), &mut o);
524 assert!(o.calls >= 1, "a [[ candidate must consult the veto oracle");
525 }
526
527 #[test]
528 fn oracle_untouched_when_exclusion_disabled() {
529 let opts = TriggerOptions {
532 apply_exclusion_zone: false,
533 ..TriggerOptions::default()
534 };
535 let mut o = CountingOracle { calls: 0 };
536 let _ = detect_trigger_with_oracle("#tag", 4, opts, &mut o);
537 assert_eq!(
538 o.calls, 0,
539 "apply_exclusion_zone=false must skip the oracle entirely"
540 );
541 }
542
543 #[test]
546 fn wikilink_opens_with_empty_query() {
547 let t = ctx("[[", 2).unwrap();
548 assert_eq!(t.kind, TriggerKind::Wikilink);
549 assert_eq!(t.query, "");
550 assert_eq!(t.replace_range, 2..2);
551 assert_eq!(t.anchor_col, 2);
552 }
553
554 #[test]
555 fn wikilink_filters_by_typed_prefix() {
556 let t = ctx("see [[foo", 9).unwrap();
557 assert_eq!(t.kind, TriggerKind::Wikilink);
558 assert_eq!(t.query, "foo");
559 assert_eq!(t.replace_range, 6..9);
560 }
561
562 #[test]
563 fn wikilink_with_pipe_alias_does_not_trigger() {
564 assert!(ctx("[[target|al", 11).is_none());
566 }
567
568 #[test]
569 fn wikilink_after_closing_brackets_is_not_a_trigger() {
570 assert!(ctx("[[done]] more", 13).is_none());
571 }
572
573 #[test]
574 fn wikilink_with_newline_inside_does_not_trigger() {
575 assert!(ctx("[[foo\nbar", 9).is_none());
576 }
577
578 #[test]
579 fn lone_single_bracket_does_not_trigger() {
580 assert!(ctx("[foo", 4).is_none());
581 }
582
583 #[test]
586 fn hashtag_mid_line_opens_immediately() {
587 let t = ctx("some note #", 11).unwrap();
588 assert_eq!(t.kind, TriggerKind::Hashtag);
589 assert_eq!(t.query, "");
590 assert_eq!(t.replace_range, 11..11);
591 }
592
593 #[test]
594 fn hashtag_with_typed_query() {
595 let t = ctx("about #pro", 10).unwrap();
596 assert_eq!(t.kind, TriggerKind::Hashtag);
597 assert_eq!(t.query, "pro");
598 assert_eq!(t.replace_range, 7..10);
599 assert_eq!(t.anchor_col, 7);
600 }
601
602 #[test]
605 fn saved_search_opens_on_leading_question_mark() {
606 let t = ctx_ss("?to", 3).unwrap();
607 assert_eq!(t.kind, TriggerKind::SavedSearch);
608 assert_eq!(t.query, "to");
609 assert_eq!(t.replace_range, 1..3);
610 assert_eq!(t.anchor_col, 1);
611 }
612
613 #[test]
614 fn saved_search_opens_with_empty_query() {
615 let t = ctx_ss("?", 1).unwrap();
616 assert_eq!(t.kind, TriggerKind::SavedSearch);
617 assert_eq!(t.query, "");
618 assert_eq!(t.replace_range, 1..1);
619 }
620
621 #[test]
622 fn saved_search_not_triggered_when_not_leading() {
623 assert_ne!(
625 ctx_ss("#a ?to", 6).map(|t| t.kind),
626 Some(TriggerKind::SavedSearch)
627 );
628 assert_ne!(
629 ctx_ss("note ?x", 7).map(|t| t.kind),
630 Some(TriggerKind::SavedSearch)
631 );
632 }
633
634 #[test]
635 fn saved_search_off_by_default() {
636 assert_eq!(ctx("?to", 3), None);
639 }
640
641 #[test]
642 fn hashtag_closes_when_word_char_boundary_passes() {
643 assert!(ctx("about #proj here", 16).is_none());
645 }
646
647 #[test]
648 fn hash_mid_word_does_not_trigger() {
649 assert!(ctx("hello#", 6).is_none());
651 }
652
653 #[test]
654 fn hash_mid_word_with_query_does_not_trigger() {
655 assert!(ctx("hello#tag", 9).is_none());
657 }
658
659 #[test]
660 fn hash_after_digit_does_not_trigger() {
661 assert!(ctx("abc123#tag", 10).is_none());
662 }
663
664 #[test]
665 fn hash_after_underscore_does_not_trigger() {
666 assert!(ctx("foo_#tag", 8).is_none());
667 }
668
669 #[test]
670 fn double_hash_does_not_trigger() {
671 assert!(ctx("##tag", 5).is_none());
673 }
674
675 #[test]
676 fn triple_hash_does_not_trigger() {
677 assert!(ctx("###tag", 6).is_none());
678 }
679
680 #[test]
681 fn double_hash_mid_line_does_not_trigger() {
682 assert!(ctx("hello ##tag", 11).is_none());
683 }
684
685 #[test]
686 fn hash_between_double_hash_at_start_does_not_trigger() {
687 assert!(ctx("##tag", 1).is_none());
690 }
691
692 #[test]
693 fn adjacent_hash_at_cursor_does_not_trigger() {
694 assert!(ctx("#tag#more", 4).is_none());
697 }
698
699 #[test]
700 fn adjacent_hash_with_cursor_inside_tag_does_not_trigger() {
701 assert!(ctx("#tag#more", 3).is_none());
704 }
705
706 #[test]
707 fn trailing_hash_after_tag_does_not_trigger() {
708 assert!(ctx("#draft#", 6).is_none());
710 }
711
712 #[test]
713 fn search_box_double_hash_at_start_does_not_trigger() {
714 let opts = TriggerOptions {
718 disambiguate_header: false,
719 apply_exclusion_zone: false,
720 allow_saved_search: false,
721 };
722 assert!(detect_trigger_with("##tag", 1, opts).is_none());
723 assert!(detect_trigger_with("##", 1, opts).is_none());
724 }
725
726 #[test]
727 fn hash_after_space_then_hash_triggers() {
728 let t = ctx("# #tag", 6).unwrap();
730 assert_eq!(t.kind, TriggerKind::Hashtag);
731 assert_eq!(t.query, "tag");
732 }
733
734 #[test]
735 fn hash_after_punctuation_triggers() {
736 let t = ctx("hi,#tag", 7).unwrap();
738 assert_eq!(t.kind, TriggerKind::Hashtag);
739 assert_eq!(t.query, "tag");
740 }
741
742 #[test]
745 fn hash_alone_at_start_of_line_does_not_trigger() {
746 assert!(ctx("#", 1).is_none());
747 }
748
749 #[test]
750 fn hash_then_space_at_start_of_line_is_header() {
751 assert!(ctx("# ", 2).is_none());
752 }
753
754 #[test]
755 fn hash_then_letter_at_start_of_line_opens_popup() {
756 let t = ctx("#p", 2).unwrap();
757 assert_eq!(t.kind, TriggerKind::Hashtag);
758 assert_eq!(t.query, "p");
759 assert_eq!(t.replace_range, 1..2);
760 }
761
762 #[test]
763 fn hash_then_letter_after_newline_opens_popup() {
764 let t = ctx("para\n#p", 7).unwrap();
765 assert_eq!(t.kind, TriggerKind::Hashtag);
766 assert_eq!(t.query, "p");
767 }
768
769 #[test]
770 fn hash_then_space_after_newline_is_header() {
771 assert!(ctx("para\n# ", 7).is_none());
772 }
773
774 #[test]
777 fn wikilink_outer_wins_over_inner_hash() {
778 let t = ctx("[[#foo", 6).unwrap();
781 assert_eq!(t.kind, TriggerKind::Wikilink);
782 assert_eq!(t.query, "#foo");
783 }
784
785 #[test]
788 fn hash_inside_inline_code_does_not_trigger() {
789 assert!(ctx("here `#tag`", 9).is_none());
791 }
792
793 #[test]
794 fn hash_inside_fenced_code_does_not_trigger() {
795 let text = "para\n\n```\n#tag\n```\nafter";
796 let cursor = text.find("#tag").unwrap() + 4;
797 assert!(ctx(text, cursor).is_none());
798 }
799
800 #[test]
801 fn hash_inside_frontmatter_does_not_trigger() {
802 let text = "---\ntitle: Hi #tag\n---\nbody";
803 let cursor = text.find("#tag").unwrap() + 4;
804 assert!(ctx(text, cursor).is_none());
805 }
806
807 #[test]
810 fn cursor_at_zero_never_triggers() {
811 assert!(ctx("", 0).is_none());
812 assert!(ctx("anything", 0).is_none());
813 }
814
815 #[test]
816 fn cursor_past_end_returns_none() {
817 assert!(ctx("short", 100).is_none());
818 }
819
820 #[test]
821 fn cursor_not_on_char_boundary_returns_none() {
822 assert!(ctx("é", 1).is_none());
824 }
825
826 #[test]
829 fn trigger_active_at_every_cursor_position_inside_target() {
830 let text = "see [[foo";
831 for cursor in 6..=9 {
834 let t = ctx(text, cursor).unwrap();
835 assert_eq!(t.kind, TriggerKind::Wikilink);
836 assert_eq!(t.query, &text[6..cursor]);
837 }
838 }
839
840 #[test]
841 fn trigger_cleared_when_cursor_moves_before_opener() {
842 assert!(ctx("see [[foo", 5).is_none());
844 }
845
846 #[test]
849 fn crlf_line_treated_like_lf_for_column_0() {
850 let text = "para\r\n#p";
853 let cursor = text.len();
854 let t = ctx(text, cursor).unwrap();
855 assert_eq!(t.kind, TriggerKind::Hashtag);
856 assert_eq!(t.query, "p");
857 }
858
859 #[test]
860 fn crlf_just_after_hash_at_start_of_line_defers() {
861 let text = "para\r\n#";
862 assert!(ctx(text, text.len()).is_none());
863 }
864
865 #[test]
868 fn search_box_opts_hash_alone_at_start_opens_immediately() {
869 let opts = TriggerOptions {
870 disambiguate_header: false,
871 apply_exclusion_zone: true,
872 allow_saved_search: false,
873 };
874 let t = detect_trigger_with("#", 1, opts).unwrap();
875 assert_eq!(t.kind, TriggerKind::Hashtag);
876 assert_eq!(t.query, "");
877 }
878
879 #[test]
880 fn search_box_opts_hash_then_space_at_start_still_opens() {
881 let opts = TriggerOptions {
885 disambiguate_header: false,
886 apply_exclusion_zone: true,
887 allow_saved_search: false,
888 };
889 let t = detect_trigger_with("#", 1, opts);
890 assert!(t.is_some());
891 }
892
893 #[test]
894 fn search_box_opts_mid_line_unchanged() {
895 let opts = TriggerOptions {
897 disambiguate_header: false,
898 apply_exclusion_zone: true,
899 allow_saved_search: false,
900 };
901 let t = detect_trigger_with("foo #pro", 8, opts).unwrap();
902 assert_eq!(t.kind, TriggerKind::Hashtag);
903 assert_eq!(t.query, "pro");
904 }
905
906 #[test]
907 fn wikilink_inside_fenced_code_does_not_trigger() {
908 let text = "para\n\n```\n[[note\n```\nafter";
909 let cursor = text.find("[[note").unwrap() + 6;
910 assert!(ctx(text, cursor).is_none());
911 }
912
913 #[test]
914 fn wikilink_inside_frontmatter_does_not_trigger() {
915 let text = "---\ntitle: see [[me\n---\nbody";
916 let cursor = text.find("[[me").unwrap() + 4;
917 assert!(ctx(text, cursor).is_none());
918 }
919
920 #[test]
921 fn wikilink_reopen_mid_existing_target_still_works() {
922 let text = "see [[foo]]";
927 let t = ctx(text, 7).unwrap(); assert_eq!(t.kind, TriggerKind::Wikilink);
929 }
930
931 #[test]
932 fn search_box_opts_backtick_does_not_suppress_hashtag() {
933 let opts = TriggerOptions {
937 disambiguate_header: false,
938 apply_exclusion_zone: false,
939 allow_saved_search: false,
940 };
941 let t = detect_trigger_with("`#abc", 5, opts).unwrap();
942 assert_eq!(t.kind, TriggerKind::Hashtag);
943 assert_eq!(t.query, "abc");
944 }
945
946 #[test]
949 fn detects_link_filter_trigger() {
950 let t = detect_trigger(">pro", 4).expect("should detect");
952 assert_eq!(t.kind, TriggerKind::LinkFilter);
953 assert_eq!(t.query, "pro");
954 }
955
956 #[test]
957 fn detects_excluded_link_filter_trigger() {
958 let t = detect_trigger("->dra", 5).expect("should detect");
959 assert_eq!(t.kind, TriggerKind::LinkFilter);
960 assert_eq!(t.query, "dra");
961 }
962
963 #[test]
964 fn link_filter_only_at_token_start() {
965 let t = detect_trigger("a>b", 3);
968 assert!(t.is_none() || t.unwrap().kind != TriggerKind::LinkFilter);
969 }
970
971 #[test]
972 fn detects_backlink_filter_trigger() {
973 let t = detect_trigger("<pro", 4).expect("should detect");
974 assert_eq!(t.kind, TriggerKind::LinkFilter);
975 assert_eq!(t.query, "pro");
976 assert_eq!(t.opener, Some('<'));
977 }
978
979 #[test]
980 fn detects_forward_link_filter_trigger() {
981 let t = detect_trigger(">pro", 4).expect("should detect");
982 assert_eq!(t.kind, TriggerKind::LinkFilter);
983 assert_eq!(t.query, "pro");
984 assert_eq!(t.opener, Some('>'));
985 }
986
987 #[test]
988 fn detects_note_name_filter_trigger() {
989 let t = detect_trigger("=pro", 4).expect("should detect");
990 assert_eq!(t.kind, TriggerKind::LinkFilter);
991 assert_eq!(t.query, "pro");
992 assert_eq!(t.opener, Some('='));
993 }
994
995 #[test]
996 fn excluded_link_filter_captures_inner_opener() {
997 for (q, op) in [("-<dra", '<'), ("->dra", '>'), ("-=dra", '=')] {
1000 let t = detect_trigger(q, q.len()).expect("should detect");
1001 assert_eq!(t.kind, TriggerKind::LinkFilter, "{q}");
1002 assert_eq!(t.opener, Some(op), "{q}");
1003 }
1004 }
1005
1006 #[test]
1007 fn wikilink_and_hashtag_have_no_opener() {
1008 assert_eq!(ctx("[[foo", 5).unwrap().opener, None);
1009 assert_eq!(ctx("a #foo", 6).unwrap().opener, None);
1010 }
1011
1012 #[test]
1013 fn detects_excluded_forms() {
1014 for q in ["-<dra", "->dra", "-=dra"] {
1015 let t = detect_trigger(q, q.len()).expect("should detect");
1016 assert_eq!(t.kind, TriggerKind::LinkFilter, "{q}");
1017 assert_eq!(t.query, "dra", "{q}");
1018 }
1019 }
1020
1021 #[test]
1022 fn link_filter_new_openers_only_at_token_start() {
1023 let t = detect_trigger("a<b", 3);
1025 assert!(t.is_none() || t.unwrap().kind != TriggerKind::LinkFilter);
1026 }
1027}