Skip to main content

kimun_notes/components/autocomplete/
trigger.rs

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    /// A leading `?` in a query input: autocompletes saved-search names.
13    /// Accepting expands to the search's stored query.
14    SavedSearch,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct TriggerContext {
19    pub kind: TriggerKind,
20    /// The text already typed between the trigger sigil (`[[` or `#`) and
21    /// the cursor — used as the prefix for the core suggestion query.
22    pub query: String,
23    /// Byte range that will be replaced when the user accepts a suggestion.
24    /// Starts immediately after the sigil and ends at the cursor.
25    pub replace_range: Range<usize>,
26    /// Byte offset of `replace_range.start`, kept as a separate field so the
27    /// host can map it to a screen anchor without re-parsing.
28    pub anchor_col: usize,
29    /// For a `LinkFilter` trigger, the operator char that opened it (`<`,
30    /// `>`, or `=`) so the popup can render the correct sigil. `None` for
31    /// `Wikilink` and `Hashtag` triggers, which have fixed sigils.
32    pub opener: Option<char>,
33}
34
35/// Per-call knobs for `detect_trigger_with`.
36#[derive(Debug, Clone, Copy)]
37pub struct TriggerOptions {
38    /// When `true`, a `#` at the start of a line defers (and is
39    /// suppressed when followed by a space) so Markdown headers don't
40    /// inadvertently open the hashtag popup. Editor uses `true`; the
41    /// search box uses `false` because its input has no Markdown
42    /// headers.
43    pub disambiguate_header: bool,
44    /// When `true`, suppress hashtag triggers inside code spans,
45    /// fenced blocks, frontmatter, link bodies, or closed wikilinks
46    /// (via `core::note::scan::is_inside_exclusion_zone`). Editor uses
47    /// `true`; the search box uses `false` because its input is plain
48    /// text and the markdown parser would falsely classify literal
49    /// backticks / brackets as code or link spans.
50    pub apply_exclusion_zone: bool,
51    /// When `true`, a leading `?` opens a [`TriggerKind::SavedSearch`]
52    /// trigger. Only the search-query box enables it; the editor leaves it
53    /// `false` so a note that opens with `?` cannot shadow the `#`/`[[`
54    /// triggers the backward scan would otherwise find. Gating here (rather
55    /// than dropping the trigger after the fact) keeps detection the single
56    /// authority on whether `?` is special.
57    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
70/// Inspect `text` at `cursor` (a byte offset) and decide whether an
71/// autocomplete popup should be active.
72///
73/// Returns `Some(TriggerContext)` when the cursor sits inside an open
74/// wikilink target (`[[…|`) or an open hashtag word (`#…`). Returns `None`
75/// otherwise — including when the cursor is inside a code span, fenced
76/// block, frontmatter, or already-closed wikilink/markdown link (delegated
77/// to `kimun_core::note::scan::is_inside_exclusion_zone`).
78///
79/// Disambiguation rules in play:
80/// - **Hashtag vs. Markdown header**: a `#` at the start of a line only
81///   triggers the popup once the user has typed the next character AND
82///   that character is not a space (a space means `# Heading`).
83/// - **Wikilink target vs. alias**: in `[[target|alias]]`, only the
84///   `target` portion triggers; the cursor crossing the `|` deactivates
85///   the popup.
86pub fn detect_trigger(text: &str, cursor: usize) -> Option<TriggerContext> {
87    detect_trigger_with(text, cursor, TriggerOptions::default())
88}
89
90/// Variant of [`detect_trigger`] that takes explicit options. Used by
91/// the search-box controller to suppress the column-0 `#` header
92/// disambiguation, which only matters in the Markdown editor.
93pub 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
101/// Lazily answers the two exclusion-zone queries the trigger veto
102/// needs. Consulted ONLY after a wikilink/hashtag opener is found in the
103/// local backward scan, so a keystroke with no trigger candidate never
104/// pays for the full-buffer `ExclusionZones` scan (pulldown + regex).
105/// Implementors decide whether to precompute, recompute, or memoize.
106pub trait ZoneOracle {
107    /// Mirror of `ExclusionZones::contains` — the hashtag veto.
108    fn contains(&mut self, cursor: usize) -> bool;
109    /// Mirror of `ExclusionZones::contains_code_link_or_frontmatter` —
110    /// the wikilink veto.
111    fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool;
112}
113
114/// Oracle backed by a precomputed `ExclusionZones`.
115struct 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
125/// Oracle that recomputes per query from `text`. Used by the no-cache
126/// convenience paths (`detect_trigger`, `detect_trigger_with`) and
127/// tests; at most one veto query runs per call, so the recompute is
128/// paid only when a candidate opener is actually present.
129struct 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
139/// Variant of [`detect_trigger_with`] that accepts a precomputed
140/// `ExclusionZones` for the same `text`. When `zones` is `None`, the
141/// exclusion check recomputes per call (used by tests and the no-cache
142/// convenience function). Thin wrapper over [`detect_trigger_with_oracle`].
143pub 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
155/// Core trigger detection. The local backward scan from `cursor` runs
156/// unconditionally; `oracle` is consulted only at the exclusion veto,
157/// reached only once a `[[`/`#` opener has been found — so the caller's
158/// zone computation can stay lazy.
159pub 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    // SavedSearch: a leading `?` (only blanks before it, on the first line)
170    // owns the whole query. Checked before the backward scan because `?` is
171    // not an opener for any other trigger. A saved-search name may contain
172    // spaces, so the prefix runs from just after `?` to the cursor. Only the
173    // search-query box opts in (`allow_saved_search`).
174    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        // `inner_start > cursor` when the caret sits on/before the `?`
180        // (e.g. text "?x", cursor 0) — no prefix yet, so don't trigger.
181        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    // The exclusion-zone check is applied selectively below — only for
193    // hashtags. A wikilink trigger inside an already-closed `[[foo]]`
194    // means the user is editing the target portion, which is
195    // explicitly supported (alias-suffix preservation on acceptance). Applying exclusion up-front here would block that
196    // reopen-mid-edit flow.
197
198    // Walk backwards from the cursor, tracking the two possible trigger
199    // contexts in parallel:
200    //
201    // - **hashtag**: only word chars `[A-Za-z0-9_]` may sit between the
202    //   `#` and the cursor (matches the hashtag regex in
203    //   `core::note::scan`). Any other char before we hit
204    //   `#` makes a hashtag impossible.
205    // - **wikilink**: any char except `]`, `\n`, `\r`, or a `|` already
206    //   seen on the way back. A `]` closes a prior wikilink so we are not
207    //   inside one; a `|` means the cursor is in the alias portion, which
208    //   we don't autocomplete.
209    //
210    // The first context that hits its opener wins. Wikilink opener is
211    // `[[`; when both `#` and `[[` are present, we keep scanning past `#`
212    // and prefer `[[` (the outer context).
213    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                // Opener found: the link-filter state is resolved, so stop
255                // scanning for it. The loop continues only if another state
256                // machine (wikilink/hashtag) is still live — mirroring how
257                // `wikilink_possible` / `hash_possible` stop driving the loop
258                // once their openers/stoppers are hit.
259                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    // Wikilink takes precedence when both are detected — it is the outer
270    // context. A wikilink with a `|` between the opener and the cursor
271    // means we are in the alias portion; bail.
272    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        // Suppress inside code, markdown link bodies, frontmatter —
281        // but NOT inside an already-closed `[[…]]` (that is the
282        // reopen-mid-target case that is supported). Only
283        // applied when the caller is editing Markdown (search box
284        // disables this).
285        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        // Hashtag-only: suppress inside code spans, fenced blocks,
305        // frontmatter, markdown links, or already-closed wikilinks /
306        // markdown link bodies — but only when the caller is editing
307        // Markdown. The search box turns this off because its input is
308        // plain text. Checked before the word-boundary guard so a future
309        // relaxation of the boundary rule cannot accidentally let popups
310        // leak into excluded regions.
311        if opts.apply_exclusion_zone && oracle.contains(cursor) {
312            return None;
313        }
314
315        // Word-boundary guard — mirrors `core::note::scan::
316        // label_matches_inner`. The tag region runs from `#` through the
317        // contiguous `[A-Za-z0-9_]+` word that follows it; reject if the
318        // character on EITHER side of that region is alphanumeric, `_`, or
319        // another `#`. Both sides are required because the popup may open
320        // when the cursor is inside an existing tag (e.g. `#tag#more`
321        // cursor between `g` and the second `#`) — checking only the
322        // preceding char would suggest a label the indexer then rejects.
323        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        // Column-0 disambiguation: defer the trigger when the user has
353        // just typed `#` at the start of a line, since the next keystroke
354        // tells us whether this is a hashtag (anything non-space) or a
355        // Markdown header (space). Only active in contexts that actually
356        // support Markdown headers (the editor); the search box turns
357        // this off via `TriggerOptions`.
358        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    // LinkFilter trigger: a note-name operator (`<`, `>`, `=`) followed by
382    // a target, optionally in its exclusion form (`-<`, `->`, `-=`). All
383    // three operators take a note-name argument (backlinks / forward links
384    // / note name) and share the same suggestion list.
385    // The opener must be at a token start — i.e. preceded by nothing,
386    // whitespace, or a `-` that is itself at string-start or preceded by
387    // whitespace (the exclusion form).
388    if let Some(gt) = link_filter_pos {
389        let inner_start = gt + 1; // byte just after the opener
390        if inner_start > cursor {
391            return None;
392        }
393
394        // Token-start guard: what sits immediately before the opener?
395        let token_start = if gt == 0 {
396            true // `>` at string start
397        } else {
398            let before_gt = text[..gt].chars().next_back().unwrap();
399            if before_gt.is_whitespace() {
400                true // `>` after whitespace
401            } else if before_gt == '-' {
402                // Allow only `->` where `-` itself is at string-start or
403                // preceded by whitespace.
404                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        // The opener char sits at byte `gt`; capture it so the popup can
424        // render the matching sigil (`<`, `>`, or `=`).
425        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
440/// Returns `true` for the note-name operators that open a link-filter
441/// trigger: `<` (backlinks), `>` (forward links), `=` (note name) — the
442/// alphabet operators that take a note-name argument. `@`
443/// (section), `#` (label) and `/` (path) are intentionally excluded.
444fn is_link_filter_opener(c: char) -> bool {
445    matches!(c, '<' | '>' | '=')
446}
447
448/// Returns `true` for characters that may appear in a link-filter target
449/// prefix (everything between the opener and the cursor). Mirrors the valid
450/// characters for note names / paths: letters, digits, `_`, `-`, `.`, `/`,
451/// `*`, `{`, `}`. Spaces are intentionally excluded because they are
452/// query-token separators — the scan stops at a space, so a `>` preceded
453/// only by a space is considered a valid token-start.
454fn 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    /// `ctx` with the search-box opt-in for the leading-`?` SavedSearch trigger.
474    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    /// Records how many times the exclusion veto consulted the oracle.
486    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    // ---- Lazy exclusion oracle ----
501
502    #[test]
503    fn oracle_untouched_without_trigger_candidate() {
504        // Plain prose, caret at end: the local backward scan finds no
505        // `[[`/`#` opener, so the veto is never reached and the
506        // (expensive in the real impl) zone query must not run.
507        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        // Search box (apply_exclusion_zone = false) must never consult
530        // the oracle even with an opener present.
531        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    // ---- Wikilink trigger ----
544
545    #[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        // Cursor inside alias portion.
565        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    // ---- Hashtag trigger (mid-line) ----
584
585    #[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    // ---- SavedSearch trigger ----
603
604    #[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        // `?` is only special as the leading (blank-prefixed) char.
624        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        // Without the opt-in (the editor's default), a leading `?` is inert,
637        // so it can't shadow the hashtag/wikilink scan.
638        assert_eq!(ctx("?to", 3), None);
639    }
640
641    #[test]
642    fn hashtag_closes_when_word_char_boundary_passes() {
643        // A space after `#proj` breaks the hashtag context.
644        assert!(ctx("about #proj here", 16).is_none());
645    }
646
647    #[test]
648    fn hash_mid_word_does_not_trigger() {
649        // `hello#` — `#` immediately follows a letter, so it is not a label.
650        assert!(ctx("hello#", 6).is_none());
651    }
652
653    #[test]
654    fn hash_mid_word_with_query_does_not_trigger() {
655        // `hello#tag` — still mid-word, popup must not open.
656        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        // `##tag` — second `#` immediately follows first `#`, not a label.
672        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        // `##tag` with cursor between the two `#`s — the column-0 case the
688        // earlier `if hash > 0` gate let through.
689        assert!(ctx("##tag", 1).is_none());
690    }
691
692    #[test]
693    fn adjacent_hash_at_cursor_does_not_trigger() {
694        // `#tag#more` with cursor right after `g` — popup must not open
695        // because the indexer will reject both `#tag` and `#more`.
696        assert!(ctx("#tag#more", 4).is_none());
697    }
698
699    #[test]
700    fn adjacent_hash_with_cursor_inside_tag_does_not_trigger() {
701        // Cursor mid-tag (`#ta|g#more`) — the following `#` still
702        // invalidates the tag region.
703        assert!(ctx("#tag#more", 3).is_none());
704    }
705
706    #[test]
707    fn trailing_hash_after_tag_does_not_trigger() {
708        // `#draft#` cursor between `t` and trailing `#`.
709        assert!(ctx("#draft#", 6).is_none());
710    }
711
712    #[test]
713    fn search_box_double_hash_at_start_does_not_trigger() {
714        // Same column-0 `##` case under search-box opts — the guard now
715        // catches it via the following-char check (the original gate
716        // skipped it when `disambiguate_header=false`).
717        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        // `# #tag` — space breaks the `##` run, second `#` is a valid label start.
729        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        // Punctuation is not a label char, so `#tag` after `,` is a valid hashtag.
737        let t = ctx("hi,#tag", 7).unwrap();
738        assert_eq!(t.kind, TriggerKind::Hashtag);
739        assert_eq!(t.query, "tag");
740    }
741
742    // ---- Hashtag vs. header disambiguation at start of line ----
743
744    #[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    // ---- Wikilink wins over hashtag when both present ----
775
776    #[test]
777    fn wikilink_outer_wins_over_inner_hash() {
778        // User typed `[[#foo`; we are inside the wikilink, so the popup is
779        // wikilink-flavoured with `#foo` as the query.
780        let t = ctx("[[#foo", 6).unwrap();
781        assert_eq!(t.kind, TriggerKind::Wikilink);
782        assert_eq!(t.query, "#foo");
783    }
784
785    // ---- Exclusion zones (delegate to core) ----
786
787    #[test]
788    fn hash_inside_inline_code_does_not_trigger() {
789        // `#tag` is inside the backticks — exclusion zone.
790        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    // ---- Cursor edge cases ----
808
809    #[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        // "é" is 2 bytes (0xc3 0xa9); cursor=1 is not a char boundary.
823        assert!(ctx("é", 1).is_none());
824    }
825
826    // ---- Trigger preserved across cursor moves that stay in range ----
827
828    #[test]
829    fn trigger_active_at_every_cursor_position_inside_target() {
830        let text = "see [[foo";
831        // From just-after-`[[` through end of typed text, every position
832        // yields a valid wikilink trigger with the appropriate query.
833        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        // Cursor at 5 sits on the first `[`; the user is now outside.
843        assert!(ctx("see [[foo", 5).is_none());
844    }
845
846    // ---- CRLF handling ----
847
848    #[test]
849    fn crlf_line_treated_like_lf_for_column_0() {
850        // `\r\n` before `#`: the line starts at the byte right after `\n`,
851        // matching how `at_line_start` is computed.
852        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    // ---- TriggerOptions: header disambiguation disabled (search-box) ----
866
867    #[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        // No Markdown headers in the search input, so `# ` is a no-op
882        // hashtag-with-empty-query — but the rule lets it through, and
883        // the popup will close on the next typed char if no match.
884        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        // The disambiguation flag has no effect on mid-line `#`.
896        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        // The carve-out: cursor inside an already-closed `[[foo]]`
923        // STILL triggers (so the user can edit the target). The new
924        // exclusion-zone check excludes only code/link/frontmatter,
925        // NOT closed wikilinks.
926        let text = "see [[foo]]";
927        let t = ctx(text, 7).unwrap(); // cursor between `o` and `o`
928        assert_eq!(t.kind, TriggerKind::Wikilink);
929    }
930
931    #[test]
932    fn search_box_opts_backtick_does_not_suppress_hashtag() {
933        // With apply_exclusion_zone=false (search-box mode), a literal
934        // backtick in the query does not falsely classify the cursor
935        // as being inside a code span.
936        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    // ---- LinkFilter trigger (`>` / `->`) ----
947
948    #[test]
949    fn detects_link_filter_trigger() {
950        // `>` at the start of a query token opens a LinkFilter trigger.
951        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        // A `>` that is not at a token boundary (e.g. inside a word) must NOT
966        // trigger. Here `a>b` — the `>` is preceded by a non-space word char.
967        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        // The exclusion form `-<` / `->` / `-=` must capture the operator
998        // char (not the `-`) so the popup renders the right sigil.
999        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        // not at a token boundary -> no LinkFilter trigger
1024        let t = detect_trigger("a<b", 3);
1025        assert!(t.is_none() || t.unwrap().kind != TriggerKind::LinkFilter);
1026    }
1027}