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}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct TriggerContext {
15 pub kind: TriggerKind,
16 pub query: String,
19 pub replace_range: Range<usize>,
22 pub anchor_col: usize,
25}
26
27#[derive(Debug, Clone, Copy)]
29pub struct TriggerOptions {
30 pub disambiguate_header: bool,
36 pub apply_exclusion_zone: bool,
43}
44
45impl Default for TriggerOptions {
46 fn default() -> Self {
47 Self {
48 disambiguate_header: true,
49 apply_exclusion_zone: true,
50 }
51 }
52}
53
54pub fn detect_trigger(text: &str, cursor: usize) -> Option<TriggerContext> {
71 detect_trigger_with(text, cursor, TriggerOptions::default())
72}
73
74pub fn detect_trigger_with(
78 text: &str,
79 cursor: usize,
80 opts: TriggerOptions,
81) -> Option<TriggerContext> {
82 detect_trigger_with_zones(text, cursor, opts, None)
83}
84
85pub trait ZoneOracle {
91 fn contains(&mut self, cursor: usize) -> bool;
93 fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool;
96}
97
98struct PrecomputedOracle<'a>(&'a ExclusionZones);
100impl ZoneOracle for PrecomputedOracle<'_> {
101 fn contains(&mut self, cursor: usize) -> bool {
102 self.0.contains(cursor)
103 }
104 fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool {
105 self.0.contains_code_link_or_frontmatter(cursor)
106 }
107}
108
109struct RecomputeOracle<'t>(&'t str);
114impl ZoneOracle for RecomputeOracle<'_> {
115 fn contains(&mut self, cursor: usize) -> bool {
116 is_inside_exclusion_zone(self.0, cursor)
117 }
118 fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool {
119 is_inside_code_link_or_frontmatter(self.0, cursor)
120 }
121}
122
123pub fn detect_trigger_with_zones(
128 text: &str,
129 cursor: usize,
130 opts: TriggerOptions,
131 zones: Option<&ExclusionZones>,
132) -> Option<TriggerContext> {
133 match zones {
134 Some(z) => detect_trigger_with_oracle(text, cursor, opts, &mut PrecomputedOracle(z)),
135 None => detect_trigger_with_oracle(text, cursor, opts, &mut RecomputeOracle(text)),
136 }
137}
138
139pub fn detect_trigger_with_oracle(
144 text: &str,
145 cursor: usize,
146 opts: TriggerOptions,
147 oracle: &mut dyn ZoneOracle,
148) -> Option<TriggerContext> {
149 if cursor > text.len() || !text.is_char_boundary(cursor) {
150 return None;
151 }
152 let mut hash_pos: Option<usize> = None;
175 let mut hash_possible = true;
176 let mut wikilink_pos: Option<usize> = None;
177 let mut wikilink_possible = true;
178 let mut pipe_seen = false;
179 let mut prev_was_bracket = false;
180
181 let mut i = cursor;
182 while i > 0 && (hash_possible || wikilink_possible) {
183 let prev = prev_char_boundary(text, i);
184 let c = text[prev..i].chars().next()?;
185
186 if c == '\n' || c == '\r' {
187 break;
188 }
189
190 if wikilink_possible {
191 match c {
192 ']' => wikilink_possible = false,
193 '|' => pipe_seen = true,
194 '[' if prev_was_bracket => {
195 wikilink_pos = Some(prev);
196 break;
197 }
198 _ => {}
199 }
200 }
201
202 if hash_possible && hash_pos.is_none() {
203 if c == '#' {
204 hash_pos = Some(prev);
205 } else if !(c.is_ascii_alphanumeric() || c == '_') {
206 hash_possible = false;
207 }
208 }
209
210 prev_was_bracket = c == '[';
211 i = prev;
212 }
213
214 if let Some(open) = wikilink_pos {
218 if pipe_seen {
219 return None;
220 }
221 let inner_start = open + 2;
222 if inner_start > cursor {
223 return None;
224 }
225 if opts.apply_exclusion_zone && oracle.contains_code_link_or_frontmatter(cursor) {
231 return None;
232 }
233 let query = text[inner_start..cursor].to_string();
234 return Some(TriggerContext {
235 kind: TriggerKind::Wikilink,
236 query,
237 replace_range: inner_start..cursor,
238 anchor_col: inner_start,
239 });
240 }
241
242 if let Some(hash) = hash_pos {
243 let inner_start = hash + 1;
244 if inner_start > cursor {
245 return None;
246 }
247
248 if opts.apply_exclusion_zone && oracle.contains(cursor) {
256 return None;
257 }
258
259 if hash > 0 {
268 let preceding_blocks_label = text[..hash]
269 .chars()
270 .next_back()
271 .map(|c| c.is_alphanumeric() || c == '_' || c == '#')
272 .unwrap_or(false);
273 if preceding_blocks_label {
274 return None;
275 }
276 }
277 let bytes = text.as_bytes();
278 let mut word_end = inner_start;
279 while word_end < bytes.len() {
280 let b = bytes[word_end];
281 if b.is_ascii_alphanumeric() || b == b'_' {
282 word_end += 1;
283 } else {
284 break;
285 }
286 }
287 let following_blocks_label = text[word_end..]
288 .chars()
289 .next()
290 .map(|c| c.is_alphanumeric() || c == '_' || c == '#')
291 .unwrap_or(false);
292 if following_blocks_label {
293 return None;
294 }
295
296 if opts.disambiguate_header {
303 let at_line_start = hash == 0 || text.as_bytes().get(hash - 1) == Some(&b'\n');
304 if at_line_start {
305 if cursor == inner_start {
306 return None;
307 }
308 let next_char = text[inner_start..].chars().next();
309 if next_char == Some(' ') {
310 return None;
311 }
312 }
313 }
314
315 let query = text[inner_start..cursor].to_string();
316 return Some(TriggerContext {
317 kind: TriggerKind::Hashtag,
318 query,
319 replace_range: inner_start..cursor,
320 anchor_col: inner_start,
321 });
322 }
323
324 None
325}
326
327fn prev_char_boundary(text: &str, i: usize) -> usize {
328 (0..i)
329 .rev()
330 .find(|&p| text.is_char_boundary(p))
331 .unwrap_or(0)
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 fn ctx(text: &str, cursor: usize) -> Option<TriggerContext> {
339 detect_trigger(text, cursor)
340 }
341
342 struct CountingOracle {
344 calls: usize,
345 }
346 impl ZoneOracle for CountingOracle {
347 fn contains(&mut self, _: usize) -> bool {
348 self.calls += 1;
349 false
350 }
351 fn contains_code_link_or_frontmatter(&mut self, _: usize) -> bool {
352 self.calls += 1;
353 false
354 }
355 }
356
357 #[test]
360 fn oracle_untouched_without_trigger_candidate() {
361 let mut o = CountingOracle { calls: 0 };
365 let r = detect_trigger_with_oracle("hello world", 11, TriggerOptions::default(), &mut o);
366 assert!(r.is_none());
367 assert_eq!(o.calls, 0, "no opener must not consult the zone oracle");
368 }
369
370 #[test]
371 fn oracle_consulted_for_hashtag_candidate() {
372 let mut o = CountingOracle { calls: 0 };
373 let _ = detect_trigger_with_oracle("#tag", 4, TriggerOptions::default(), &mut o);
374 assert!(o.calls >= 1, "a # candidate must consult the veto oracle");
375 }
376
377 #[test]
378 fn oracle_consulted_for_wikilink_candidate() {
379 let mut o = CountingOracle { calls: 0 };
380 let _ = detect_trigger_with_oracle("[[me", 4, TriggerOptions::default(), &mut o);
381 assert!(o.calls >= 1, "a [[ candidate must consult the veto oracle");
382 }
383
384 #[test]
385 fn oracle_untouched_when_exclusion_disabled() {
386 let opts = TriggerOptions {
389 apply_exclusion_zone: false,
390 ..TriggerOptions::default()
391 };
392 let mut o = CountingOracle { calls: 0 };
393 let _ = detect_trigger_with_oracle("#tag", 4, opts, &mut o);
394 assert_eq!(
395 o.calls, 0,
396 "apply_exclusion_zone=false must skip the oracle entirely"
397 );
398 }
399
400 #[test]
403 fn wikilink_opens_with_empty_query() {
404 let t = ctx("[[", 2).unwrap();
405 assert_eq!(t.kind, TriggerKind::Wikilink);
406 assert_eq!(t.query, "");
407 assert_eq!(t.replace_range, 2..2);
408 assert_eq!(t.anchor_col, 2);
409 }
410
411 #[test]
412 fn wikilink_filters_by_typed_prefix() {
413 let t = ctx("see [[foo", 9).unwrap();
414 assert_eq!(t.kind, TriggerKind::Wikilink);
415 assert_eq!(t.query, "foo");
416 assert_eq!(t.replace_range, 6..9);
417 }
418
419 #[test]
420 fn wikilink_with_pipe_alias_does_not_trigger() {
421 assert!(ctx("[[target|al", 11).is_none());
423 }
424
425 #[test]
426 fn wikilink_after_closing_brackets_is_not_a_trigger() {
427 assert!(ctx("[[done]] more", 13).is_none());
428 }
429
430 #[test]
431 fn wikilink_with_newline_inside_does_not_trigger() {
432 assert!(ctx("[[foo\nbar", 9).is_none());
433 }
434
435 #[test]
436 fn lone_single_bracket_does_not_trigger() {
437 assert!(ctx("[foo", 4).is_none());
438 }
439
440 #[test]
443 fn hashtag_mid_line_opens_immediately() {
444 let t = ctx("some note #", 11).unwrap();
445 assert_eq!(t.kind, TriggerKind::Hashtag);
446 assert_eq!(t.query, "");
447 assert_eq!(t.replace_range, 11..11);
448 }
449
450 #[test]
451 fn hashtag_with_typed_query() {
452 let t = ctx("about #pro", 10).unwrap();
453 assert_eq!(t.kind, TriggerKind::Hashtag);
454 assert_eq!(t.query, "pro");
455 assert_eq!(t.replace_range, 7..10);
456 assert_eq!(t.anchor_col, 7);
457 }
458
459 #[test]
460 fn hashtag_closes_when_word_char_boundary_passes() {
461 assert!(ctx("about #proj here", 16).is_none());
463 }
464
465 #[test]
466 fn hash_mid_word_does_not_trigger() {
467 assert!(ctx("hello#", 6).is_none());
469 }
470
471 #[test]
472 fn hash_mid_word_with_query_does_not_trigger() {
473 assert!(ctx("hello#tag", 9).is_none());
475 }
476
477 #[test]
478 fn hash_after_digit_does_not_trigger() {
479 assert!(ctx("abc123#tag", 10).is_none());
480 }
481
482 #[test]
483 fn hash_after_underscore_does_not_trigger() {
484 assert!(ctx("foo_#tag", 8).is_none());
485 }
486
487 #[test]
488 fn double_hash_does_not_trigger() {
489 assert!(ctx("##tag", 5).is_none());
491 }
492
493 #[test]
494 fn triple_hash_does_not_trigger() {
495 assert!(ctx("###tag", 6).is_none());
496 }
497
498 #[test]
499 fn double_hash_mid_line_does_not_trigger() {
500 assert!(ctx("hello ##tag", 11).is_none());
501 }
502
503 #[test]
504 fn hash_between_double_hash_at_start_does_not_trigger() {
505 assert!(ctx("##tag", 1).is_none());
508 }
509
510 #[test]
511 fn adjacent_hash_at_cursor_does_not_trigger() {
512 assert!(ctx("#tag#more", 4).is_none());
515 }
516
517 #[test]
518 fn adjacent_hash_with_cursor_inside_tag_does_not_trigger() {
519 assert!(ctx("#tag#more", 3).is_none());
522 }
523
524 #[test]
525 fn trailing_hash_after_tag_does_not_trigger() {
526 assert!(ctx("#draft#", 6).is_none());
528 }
529
530 #[test]
531 fn search_box_double_hash_at_start_does_not_trigger() {
532 let opts = TriggerOptions {
536 disambiguate_header: false,
537 apply_exclusion_zone: false,
538 };
539 assert!(detect_trigger_with("##tag", 1, opts).is_none());
540 assert!(detect_trigger_with("##", 1, opts).is_none());
541 }
542
543 #[test]
544 fn hash_after_space_then_hash_triggers() {
545 let t = ctx("# #tag", 6).unwrap();
547 assert_eq!(t.kind, TriggerKind::Hashtag);
548 assert_eq!(t.query, "tag");
549 }
550
551 #[test]
552 fn hash_after_punctuation_triggers() {
553 let t = ctx("hi,#tag", 7).unwrap();
555 assert_eq!(t.kind, TriggerKind::Hashtag);
556 assert_eq!(t.query, "tag");
557 }
558
559 #[test]
562 fn hash_alone_at_start_of_line_does_not_trigger() {
563 assert!(ctx("#", 1).is_none());
564 }
565
566 #[test]
567 fn hash_then_space_at_start_of_line_is_header() {
568 assert!(ctx("# ", 2).is_none());
569 }
570
571 #[test]
572 fn hash_then_letter_at_start_of_line_opens_popup() {
573 let t = ctx("#p", 2).unwrap();
574 assert_eq!(t.kind, TriggerKind::Hashtag);
575 assert_eq!(t.query, "p");
576 assert_eq!(t.replace_range, 1..2);
577 }
578
579 #[test]
580 fn hash_then_letter_after_newline_opens_popup() {
581 let t = ctx("para\n#p", 7).unwrap();
582 assert_eq!(t.kind, TriggerKind::Hashtag);
583 assert_eq!(t.query, "p");
584 }
585
586 #[test]
587 fn hash_then_space_after_newline_is_header() {
588 assert!(ctx("para\n# ", 7).is_none());
589 }
590
591 #[test]
594 fn wikilink_outer_wins_over_inner_hash() {
595 let t = ctx("[[#foo", 6).unwrap();
598 assert_eq!(t.kind, TriggerKind::Wikilink);
599 assert_eq!(t.query, "#foo");
600 }
601
602 #[test]
605 fn hash_inside_inline_code_does_not_trigger() {
606 assert!(ctx("here `#tag`", 9).is_none());
608 }
609
610 #[test]
611 fn hash_inside_fenced_code_does_not_trigger() {
612 let text = "para\n\n```\n#tag\n```\nafter";
613 let cursor = text.find("#tag").unwrap() + 4;
614 assert!(ctx(text, cursor).is_none());
615 }
616
617 #[test]
618 fn hash_inside_frontmatter_does_not_trigger() {
619 let text = "---\ntitle: Hi #tag\n---\nbody";
620 let cursor = text.find("#tag").unwrap() + 4;
621 assert!(ctx(text, cursor).is_none());
622 }
623
624 #[test]
627 fn cursor_at_zero_never_triggers() {
628 assert!(ctx("", 0).is_none());
629 assert!(ctx("anything", 0).is_none());
630 }
631
632 #[test]
633 fn cursor_past_end_returns_none() {
634 assert!(ctx("short", 100).is_none());
635 }
636
637 #[test]
638 fn cursor_not_on_char_boundary_returns_none() {
639 assert!(ctx("é", 1).is_none());
641 }
642
643 #[test]
646 fn trigger_active_at_every_cursor_position_inside_target() {
647 let text = "see [[foo";
648 for cursor in 6..=9 {
651 let t = ctx(text, cursor).unwrap();
652 assert_eq!(t.kind, TriggerKind::Wikilink);
653 assert_eq!(t.query, &text[6..cursor]);
654 }
655 }
656
657 #[test]
658 fn trigger_cleared_when_cursor_moves_before_opener() {
659 assert!(ctx("see [[foo", 5).is_none());
661 }
662
663 #[test]
666 fn crlf_line_treated_like_lf_for_column_0() {
667 let text = "para\r\n#p";
670 let cursor = text.len();
671 let t = ctx(text, cursor).unwrap();
672 assert_eq!(t.kind, TriggerKind::Hashtag);
673 assert_eq!(t.query, "p");
674 }
675
676 #[test]
677 fn crlf_just_after_hash_at_start_of_line_defers() {
678 let text = "para\r\n#";
679 assert!(ctx(text, text.len()).is_none());
680 }
681
682 #[test]
685 fn search_box_opts_hash_alone_at_start_opens_immediately() {
686 let opts = TriggerOptions {
687 disambiguate_header: false,
688 apply_exclusion_zone: true,
689 };
690 let t = detect_trigger_with("#", 1, opts).unwrap();
691 assert_eq!(t.kind, TriggerKind::Hashtag);
692 assert_eq!(t.query, "");
693 }
694
695 #[test]
696 fn search_box_opts_hash_then_space_at_start_still_opens() {
697 let opts = TriggerOptions {
701 disambiguate_header: false,
702 apply_exclusion_zone: true,
703 };
704 let t = detect_trigger_with("#", 1, opts);
705 assert!(t.is_some());
706 }
707
708 #[test]
709 fn search_box_opts_mid_line_unchanged() {
710 let opts = TriggerOptions {
712 disambiguate_header: false,
713 apply_exclusion_zone: true,
714 };
715 let t = detect_trigger_with("foo #pro", 8, opts).unwrap();
716 assert_eq!(t.kind, TriggerKind::Hashtag);
717 assert_eq!(t.query, "pro");
718 }
719
720 #[test]
721 fn wikilink_inside_fenced_code_does_not_trigger() {
722 let text = "para\n\n```\n[[note\n```\nafter";
723 let cursor = text.find("[[note").unwrap() + 6;
724 assert!(ctx(text, cursor).is_none());
725 }
726
727 #[test]
728 fn wikilink_inside_frontmatter_does_not_trigger() {
729 let text = "---\ntitle: see [[me\n---\nbody";
730 let cursor = text.find("[[me").unwrap() + 4;
731 assert!(ctx(text, cursor).is_none());
732 }
733
734 #[test]
735 fn wikilink_reopen_mid_existing_target_still_works() {
736 let text = "see [[foo]]";
741 let t = ctx(text, 7).unwrap(); assert_eq!(t.kind, TriggerKind::Wikilink);
743 }
744
745 #[test]
746 fn search_box_opts_backtick_does_not_suppress_hashtag() {
747 let opts = TriggerOptions {
751 disambiguate_header: false,
752 apply_exclusion_zone: false,
753 };
754 let t = detect_trigger_with("`#abc", 5, opts).unwrap();
755 assert_eq!(t.kind, TriggerKind::Hashtag);
756 assert_eq!(t.query, "abc");
757 }
758}