use std::ops::Range;
use kimun_core::note::scan::{
ExclusionZones, is_inside_code_link_or_frontmatter, is_inside_exclusion_zone,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TriggerKind {
Wikilink,
Hashtag,
LinkFilter,
SavedSearch,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriggerContext {
pub kind: TriggerKind,
pub query: String,
pub replace_range: Range<usize>,
pub anchor_col: usize,
pub opener: Option<char>,
}
#[derive(Debug, Clone, Copy)]
pub struct TriggerOptions {
pub disambiguate_header: bool,
pub apply_exclusion_zone: bool,
pub allow_saved_search: bool,
}
impl Default for TriggerOptions {
fn default() -> Self {
Self {
disambiguate_header: true,
apply_exclusion_zone: true,
allow_saved_search: false,
}
}
}
pub fn detect_trigger(text: &str, cursor: usize) -> Option<TriggerContext> {
detect_trigger_with(text, cursor, TriggerOptions::default())
}
pub fn detect_trigger_with(
text: &str,
cursor: usize,
opts: TriggerOptions,
) -> Option<TriggerContext> {
detect_trigger_with_zones(text, cursor, opts, None)
}
pub trait ZoneOracle {
fn contains(&mut self, cursor: usize) -> bool;
fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool;
}
struct PrecomputedOracle<'a>(&'a ExclusionZones);
impl ZoneOracle for PrecomputedOracle<'_> {
fn contains(&mut self, cursor: usize) -> bool {
self.0.contains(cursor)
}
fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool {
self.0.contains_code_link_or_frontmatter(cursor)
}
}
struct RecomputeOracle<'t>(&'t str);
impl ZoneOracle for RecomputeOracle<'_> {
fn contains(&mut self, cursor: usize) -> bool {
is_inside_exclusion_zone(self.0, cursor)
}
fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool {
is_inside_code_link_or_frontmatter(self.0, cursor)
}
}
pub fn detect_trigger_with_zones(
text: &str,
cursor: usize,
opts: TriggerOptions,
zones: Option<&ExclusionZones>,
) -> Option<TriggerContext> {
match zones {
Some(z) => detect_trigger_with_oracle(text, cursor, opts, &mut PrecomputedOracle(z)),
None => detect_trigger_with_oracle(text, cursor, opts, &mut RecomputeOracle(text)),
}
}
pub fn detect_trigger_with_oracle(
text: &str,
cursor: usize,
opts: TriggerOptions,
oracle: &mut dyn ZoneOracle,
) -> Option<TriggerContext> {
if cursor > text.len() || !text.is_char_boundary(cursor) {
return None;
}
if opts.allow_saved_search
&& let Some(q_pos) = text.find('?')
&& text[..q_pos].bytes().all(|b| b == b' ' || b == b'\t')
{
let inner_start = q_pos + 1;
if inner_start <= cursor {
return Some(TriggerContext {
kind: TriggerKind::SavedSearch,
query: text[inner_start..cursor].to_string(),
replace_range: inner_start..cursor,
anchor_col: inner_start,
opener: None,
});
}
}
let mut hash_pos: Option<usize> = None;
let mut hash_possible = true;
let mut wikilink_pos: Option<usize> = None;
let mut wikilink_possible = true;
let mut pipe_seen = false;
let mut prev_was_bracket = false;
let mut link_filter_pos: Option<usize> = None;
let mut link_filter_possible = true;
let mut i = cursor;
while i > 0 && (hash_possible || wikilink_possible || link_filter_possible) {
let prev = prev_char_boundary(text, i);
let c = text[prev..i].chars().next()?;
if c == '\n' || c == '\r' {
break;
}
if wikilink_possible {
match c {
']' => wikilink_possible = false,
'|' => pipe_seen = true,
'[' if prev_was_bracket => {
wikilink_pos = Some(prev);
break;
}
_ => {}
}
}
if hash_possible && hash_pos.is_none() {
if c == '#' {
hash_pos = Some(prev);
} else if !(c.is_ascii_alphanumeric() || c == '_') {
hash_possible = false;
}
}
if link_filter_possible && link_filter_pos.is_none() {
if is_link_filter_opener(c) {
link_filter_pos = Some(prev);
link_filter_possible = false;
} else if !is_link_filter_target_char(c) {
link_filter_possible = false;
}
}
prev_was_bracket = c == '[';
i = prev;
}
if let Some(open) = wikilink_pos {
if pipe_seen {
return None;
}
let inner_start = open + 2;
if inner_start > cursor {
return None;
}
if opts.apply_exclusion_zone && oracle.contains_code_link_or_frontmatter(cursor) {
return None;
}
let query = text[inner_start..cursor].to_string();
return Some(TriggerContext {
kind: TriggerKind::Wikilink,
query,
replace_range: inner_start..cursor,
anchor_col: inner_start,
opener: None,
});
}
if let Some(hash) = hash_pos {
let inner_start = hash + 1;
if inner_start > cursor {
return None;
}
if opts.apply_exclusion_zone && oracle.contains(cursor) {
return None;
}
if hash > 0 {
let preceding_blocks_label = text[..hash]
.chars()
.next_back()
.map(|c| c.is_alphanumeric() || c == '_' || c == '#')
.unwrap_or(false);
if preceding_blocks_label {
return None;
}
}
let bytes = text.as_bytes();
let mut word_end = inner_start;
while word_end < bytes.len() {
let b = bytes[word_end];
if b.is_ascii_alphanumeric() || b == b'_' {
word_end += 1;
} else {
break;
}
}
let following_blocks_label = text[word_end..]
.chars()
.next()
.map(|c| c.is_alphanumeric() || c == '_' || c == '#')
.unwrap_or(false);
if following_blocks_label {
return None;
}
if opts.disambiguate_header {
let at_line_start = hash == 0 || text.as_bytes().get(hash - 1) == Some(&b'\n');
if at_line_start {
if cursor == inner_start {
return None;
}
let next_char = text[inner_start..].chars().next();
if next_char == Some(' ') {
return None;
}
}
}
let query = text[inner_start..cursor].to_string();
return Some(TriggerContext {
kind: TriggerKind::Hashtag,
query,
replace_range: inner_start..cursor,
anchor_col: inner_start,
opener: None,
});
}
if let Some(gt) = link_filter_pos {
let inner_start = gt + 1; if inner_start > cursor {
return None;
}
let token_start = if gt == 0 {
true } else {
let before_gt = text[..gt].chars().next_back().unwrap();
if before_gt.is_whitespace() {
true } else if before_gt == '-' {
let dash_pos = gt - before_gt.len_utf8();
dash_pos == 0
|| text[..dash_pos]
.chars()
.next_back()
.map(|c| c.is_whitespace())
.unwrap_or(false)
} else {
false
}
};
if !token_start {
return None;
}
if opts.apply_exclusion_zone && oracle.contains(cursor) {
return None;
}
let opener = text[gt..inner_start].chars().next();
let query = text[inner_start..cursor].to_string();
return Some(TriggerContext {
kind: TriggerKind::LinkFilter,
query,
replace_range: inner_start..cursor,
anchor_col: inner_start,
opener,
});
}
None
}
fn is_link_filter_opener(c: char) -> bool {
matches!(c, '<' | '>' | '=')
}
fn is_link_filter_target_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '*' | '{' | '}')
}
fn prev_char_boundary(text: &str, i: usize) -> usize {
(0..i)
.rev()
.find(|&p| text.is_char_boundary(p))
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(text: &str, cursor: usize) -> Option<TriggerContext> {
detect_trigger(text, cursor)
}
fn ctx_ss(text: &str, cursor: usize) -> Option<TriggerContext> {
detect_trigger_with(
text,
cursor,
TriggerOptions {
allow_saved_search: true,
..TriggerOptions::default()
},
)
}
struct CountingOracle {
calls: usize,
}
impl ZoneOracle for CountingOracle {
fn contains(&mut self, _: usize) -> bool {
self.calls += 1;
false
}
fn contains_code_link_or_frontmatter(&mut self, _: usize) -> bool {
self.calls += 1;
false
}
}
#[test]
fn oracle_untouched_without_trigger_candidate() {
let mut o = CountingOracle { calls: 0 };
let r = detect_trigger_with_oracle("hello world", 11, TriggerOptions::default(), &mut o);
assert!(r.is_none());
assert_eq!(o.calls, 0, "no opener must not consult the zone oracle");
}
#[test]
fn oracle_consulted_for_hashtag_candidate() {
let mut o = CountingOracle { calls: 0 };
let _ = detect_trigger_with_oracle("#tag", 4, TriggerOptions::default(), &mut o);
assert!(o.calls >= 1, "a # candidate must consult the veto oracle");
}
#[test]
fn oracle_consulted_for_wikilink_candidate() {
let mut o = CountingOracle { calls: 0 };
let _ = detect_trigger_with_oracle("[[me", 4, TriggerOptions::default(), &mut o);
assert!(o.calls >= 1, "a [[ candidate must consult the veto oracle");
}
#[test]
fn oracle_untouched_when_exclusion_disabled() {
let opts = TriggerOptions {
apply_exclusion_zone: false,
..TriggerOptions::default()
};
let mut o = CountingOracle { calls: 0 };
let _ = detect_trigger_with_oracle("#tag", 4, opts, &mut o);
assert_eq!(
o.calls, 0,
"apply_exclusion_zone=false must skip the oracle entirely"
);
}
#[test]
fn wikilink_opens_with_empty_query() {
let t = ctx("[[", 2).unwrap();
assert_eq!(t.kind, TriggerKind::Wikilink);
assert_eq!(t.query, "");
assert_eq!(t.replace_range, 2..2);
assert_eq!(t.anchor_col, 2);
}
#[test]
fn wikilink_filters_by_typed_prefix() {
let t = ctx("see [[foo", 9).unwrap();
assert_eq!(t.kind, TriggerKind::Wikilink);
assert_eq!(t.query, "foo");
assert_eq!(t.replace_range, 6..9);
}
#[test]
fn wikilink_with_pipe_alias_does_not_trigger() {
assert!(ctx("[[target|al", 11).is_none());
}
#[test]
fn wikilink_after_closing_brackets_is_not_a_trigger() {
assert!(ctx("[[done]] more", 13).is_none());
}
#[test]
fn wikilink_with_newline_inside_does_not_trigger() {
assert!(ctx("[[foo\nbar", 9).is_none());
}
#[test]
fn lone_single_bracket_does_not_trigger() {
assert!(ctx("[foo", 4).is_none());
}
#[test]
fn hashtag_mid_line_opens_immediately() {
let t = ctx("some note #", 11).unwrap();
assert_eq!(t.kind, TriggerKind::Hashtag);
assert_eq!(t.query, "");
assert_eq!(t.replace_range, 11..11);
}
#[test]
fn hashtag_with_typed_query() {
let t = ctx("about #pro", 10).unwrap();
assert_eq!(t.kind, TriggerKind::Hashtag);
assert_eq!(t.query, "pro");
assert_eq!(t.replace_range, 7..10);
assert_eq!(t.anchor_col, 7);
}
#[test]
fn saved_search_opens_on_leading_question_mark() {
let t = ctx_ss("?to", 3).unwrap();
assert_eq!(t.kind, TriggerKind::SavedSearch);
assert_eq!(t.query, "to");
assert_eq!(t.replace_range, 1..3);
assert_eq!(t.anchor_col, 1);
}
#[test]
fn saved_search_opens_with_empty_query() {
let t = ctx_ss("?", 1).unwrap();
assert_eq!(t.kind, TriggerKind::SavedSearch);
assert_eq!(t.query, "");
assert_eq!(t.replace_range, 1..1);
}
#[test]
fn saved_search_not_triggered_when_not_leading() {
assert_ne!(
ctx_ss("#a ?to", 6).map(|t| t.kind),
Some(TriggerKind::SavedSearch)
);
assert_ne!(
ctx_ss("note ?x", 7).map(|t| t.kind),
Some(TriggerKind::SavedSearch)
);
}
#[test]
fn saved_search_off_by_default() {
assert_eq!(ctx("?to", 3), None);
}
#[test]
fn hashtag_closes_when_word_char_boundary_passes() {
assert!(ctx("about #proj here", 16).is_none());
}
#[test]
fn hash_mid_word_does_not_trigger() {
assert!(ctx("hello#", 6).is_none());
}
#[test]
fn hash_mid_word_with_query_does_not_trigger() {
assert!(ctx("hello#tag", 9).is_none());
}
#[test]
fn hash_after_digit_does_not_trigger() {
assert!(ctx("abc123#tag", 10).is_none());
}
#[test]
fn hash_after_underscore_does_not_trigger() {
assert!(ctx("foo_#tag", 8).is_none());
}
#[test]
fn double_hash_does_not_trigger() {
assert!(ctx("##tag", 5).is_none());
}
#[test]
fn triple_hash_does_not_trigger() {
assert!(ctx("###tag", 6).is_none());
}
#[test]
fn double_hash_mid_line_does_not_trigger() {
assert!(ctx("hello ##tag", 11).is_none());
}
#[test]
fn hash_between_double_hash_at_start_does_not_trigger() {
assert!(ctx("##tag", 1).is_none());
}
#[test]
fn adjacent_hash_at_cursor_does_not_trigger() {
assert!(ctx("#tag#more", 4).is_none());
}
#[test]
fn adjacent_hash_with_cursor_inside_tag_does_not_trigger() {
assert!(ctx("#tag#more", 3).is_none());
}
#[test]
fn trailing_hash_after_tag_does_not_trigger() {
assert!(ctx("#draft#", 6).is_none());
}
#[test]
fn search_box_double_hash_at_start_does_not_trigger() {
let opts = TriggerOptions {
disambiguate_header: false,
apply_exclusion_zone: false,
allow_saved_search: false,
};
assert!(detect_trigger_with("##tag", 1, opts).is_none());
assert!(detect_trigger_with("##", 1, opts).is_none());
}
#[test]
fn hash_after_space_then_hash_triggers() {
let t = ctx("# #tag", 6).unwrap();
assert_eq!(t.kind, TriggerKind::Hashtag);
assert_eq!(t.query, "tag");
}
#[test]
fn hash_after_punctuation_triggers() {
let t = ctx("hi,#tag", 7).unwrap();
assert_eq!(t.kind, TriggerKind::Hashtag);
assert_eq!(t.query, "tag");
}
#[test]
fn hash_alone_at_start_of_line_does_not_trigger() {
assert!(ctx("#", 1).is_none());
}
#[test]
fn hash_then_space_at_start_of_line_is_header() {
assert!(ctx("# ", 2).is_none());
}
#[test]
fn hash_then_letter_at_start_of_line_opens_popup() {
let t = ctx("#p", 2).unwrap();
assert_eq!(t.kind, TriggerKind::Hashtag);
assert_eq!(t.query, "p");
assert_eq!(t.replace_range, 1..2);
}
#[test]
fn hash_then_letter_after_newline_opens_popup() {
let t = ctx("para\n#p", 7).unwrap();
assert_eq!(t.kind, TriggerKind::Hashtag);
assert_eq!(t.query, "p");
}
#[test]
fn hash_then_space_after_newline_is_header() {
assert!(ctx("para\n# ", 7).is_none());
}
#[test]
fn wikilink_outer_wins_over_inner_hash() {
let t = ctx("[[#foo", 6).unwrap();
assert_eq!(t.kind, TriggerKind::Wikilink);
assert_eq!(t.query, "#foo");
}
#[test]
fn hash_inside_inline_code_does_not_trigger() {
assert!(ctx("here `#tag`", 9).is_none());
}
#[test]
fn hash_inside_fenced_code_does_not_trigger() {
let text = "para\n\n```\n#tag\n```\nafter";
let cursor = text.find("#tag").unwrap() + 4;
assert!(ctx(text, cursor).is_none());
}
#[test]
fn hash_inside_frontmatter_does_not_trigger() {
let text = "---\ntitle: Hi #tag\n---\nbody";
let cursor = text.find("#tag").unwrap() + 4;
assert!(ctx(text, cursor).is_none());
}
#[test]
fn cursor_at_zero_never_triggers() {
assert!(ctx("", 0).is_none());
assert!(ctx("anything", 0).is_none());
}
#[test]
fn cursor_past_end_returns_none() {
assert!(ctx("short", 100).is_none());
}
#[test]
fn cursor_not_on_char_boundary_returns_none() {
assert!(ctx("é", 1).is_none());
}
#[test]
fn trigger_active_at_every_cursor_position_inside_target() {
let text = "see [[foo";
for cursor in 6..=9 {
let t = ctx(text, cursor).unwrap();
assert_eq!(t.kind, TriggerKind::Wikilink);
assert_eq!(t.query, &text[6..cursor]);
}
}
#[test]
fn trigger_cleared_when_cursor_moves_before_opener() {
assert!(ctx("see [[foo", 5).is_none());
}
#[test]
fn crlf_line_treated_like_lf_for_column_0() {
let text = "para\r\n#p";
let cursor = text.len();
let t = ctx(text, cursor).unwrap();
assert_eq!(t.kind, TriggerKind::Hashtag);
assert_eq!(t.query, "p");
}
#[test]
fn crlf_just_after_hash_at_start_of_line_defers() {
let text = "para\r\n#";
assert!(ctx(text, text.len()).is_none());
}
#[test]
fn search_box_opts_hash_alone_at_start_opens_immediately() {
let opts = TriggerOptions {
disambiguate_header: false,
apply_exclusion_zone: true,
allow_saved_search: false,
};
let t = detect_trigger_with("#", 1, opts).unwrap();
assert_eq!(t.kind, TriggerKind::Hashtag);
assert_eq!(t.query, "");
}
#[test]
fn search_box_opts_hash_then_space_at_start_still_opens() {
let opts = TriggerOptions {
disambiguate_header: false,
apply_exclusion_zone: true,
allow_saved_search: false,
};
let t = detect_trigger_with("#", 1, opts);
assert!(t.is_some());
}
#[test]
fn search_box_opts_mid_line_unchanged() {
let opts = TriggerOptions {
disambiguate_header: false,
apply_exclusion_zone: true,
allow_saved_search: false,
};
let t = detect_trigger_with("foo #pro", 8, opts).unwrap();
assert_eq!(t.kind, TriggerKind::Hashtag);
assert_eq!(t.query, "pro");
}
#[test]
fn wikilink_inside_fenced_code_does_not_trigger() {
let text = "para\n\n```\n[[note\n```\nafter";
let cursor = text.find("[[note").unwrap() + 6;
assert!(ctx(text, cursor).is_none());
}
#[test]
fn wikilink_inside_frontmatter_does_not_trigger() {
let text = "---\ntitle: see [[me\n---\nbody";
let cursor = text.find("[[me").unwrap() + 4;
assert!(ctx(text, cursor).is_none());
}
#[test]
fn wikilink_reopen_mid_existing_target_still_works() {
let text = "see [[foo]]";
let t = ctx(text, 7).unwrap(); assert_eq!(t.kind, TriggerKind::Wikilink);
}
#[test]
fn search_box_opts_backtick_does_not_suppress_hashtag() {
let opts = TriggerOptions {
disambiguate_header: false,
apply_exclusion_zone: false,
allow_saved_search: false,
};
let t = detect_trigger_with("`#abc", 5, opts).unwrap();
assert_eq!(t.kind, TriggerKind::Hashtag);
assert_eq!(t.query, "abc");
}
#[test]
fn detects_link_filter_trigger() {
let t = detect_trigger(">pro", 4).expect("should detect");
assert_eq!(t.kind, TriggerKind::LinkFilter);
assert_eq!(t.query, "pro");
}
#[test]
fn detects_excluded_link_filter_trigger() {
let t = detect_trigger("->dra", 5).expect("should detect");
assert_eq!(t.kind, TriggerKind::LinkFilter);
assert_eq!(t.query, "dra");
}
#[test]
fn link_filter_only_at_token_start() {
let t = detect_trigger("a>b", 3);
assert!(t.is_none() || t.unwrap().kind != TriggerKind::LinkFilter);
}
#[test]
fn detects_backlink_filter_trigger() {
let t = detect_trigger("<pro", 4).expect("should detect");
assert_eq!(t.kind, TriggerKind::LinkFilter);
assert_eq!(t.query, "pro");
assert_eq!(t.opener, Some('<'));
}
#[test]
fn detects_forward_link_filter_trigger() {
let t = detect_trigger(">pro", 4).expect("should detect");
assert_eq!(t.kind, TriggerKind::LinkFilter);
assert_eq!(t.query, "pro");
assert_eq!(t.opener, Some('>'));
}
#[test]
fn detects_note_name_filter_trigger() {
let t = detect_trigger("=pro", 4).expect("should detect");
assert_eq!(t.kind, TriggerKind::LinkFilter);
assert_eq!(t.query, "pro");
assert_eq!(t.opener, Some('='));
}
#[test]
fn excluded_link_filter_captures_inner_opener() {
for (q, op) in [("-<dra", '<'), ("->dra", '>'), ("-=dra", '=')] {
let t = detect_trigger(q, q.len()).expect("should detect");
assert_eq!(t.kind, TriggerKind::LinkFilter, "{q}");
assert_eq!(t.opener, Some(op), "{q}");
}
}
#[test]
fn wikilink_and_hashtag_have_no_opener() {
assert_eq!(ctx("[[foo", 5).unwrap().opener, None);
assert_eq!(ctx("a #foo", 6).unwrap().opener, None);
}
#[test]
fn detects_excluded_forms() {
for q in ["-<dra", "->dra", "-=dra"] {
let t = detect_trigger(q, q.len()).expect("should detect");
assert_eq!(t.kind, TriggerKind::LinkFilter, "{q}");
assert_eq!(t.query, "dra", "{q}");
}
}
#[test]
fn link_filter_new_openers_only_at_token_start() {
let t = detect_trigger("a<b", 3);
assert!(t.is_none() || t.unwrap().kind != TriggerKind::LinkFilter);
}
}