use std::ops::Range;
use kimun_core::note::{is_inside_code_link_or_frontmatter, is_inside_exclusion_zone};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TriggerKind {
Wikilink,
Hashtag,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriggerContext {
pub kind: TriggerKind,
pub query: String,
pub replace_range: Range<usize>,
pub anchor_col: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct TriggerOptions {
pub disambiguate_header: bool,
pub apply_exclusion_zone: bool,
}
impl Default for TriggerOptions {
fn default() -> Self {
Self {
disambiguate_header: true,
apply_exclusion_zone: true,
}
}
}
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> {
if cursor > text.len() || !text.is_char_boundary(cursor) {
return 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 i = cursor;
while i > 0 && (hash_possible || wikilink_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;
}
}
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 && is_inside_code_link_or_frontmatter(text, 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,
});
}
if let Some(hash) = hash_pos {
let inner_start = hash + 1;
if inner_start > cursor {
return None;
}
if opts.apply_exclusion_zone && is_inside_exclusion_zone(text, cursor) {
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,
});
}
None
}
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)
}
#[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 hashtag_closes_when_word_char_boundary_passes() {
assert!(ctx("about #proj here", 16).is_none());
}
#[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,
};
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,
};
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,
};
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,
};
let t = detect_trigger_with("`#abc", 5, opts).unwrap();
assert_eq!(t.kind, TriggerKind::Hashtag);
assert_eq!(t.query, "abc");
}
}