Skip to main content

kimun_notes/components/autocomplete/
trigger.rs

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    LinkFilter,
12    /// A leading `?` in a query input: autocompletes saved-search names.
13    /// Accepting expands to the search's stored query (see `adr/0006`).
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::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. See `adr/0006`.
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::content_extractor::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`); see `adr/0006`.
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 the spec
195    // explicitly supports (see "Suggestion acceptance" — alias-suffix
196    // preservation). Applying exclusion up-front here would block that
197    // reopen-mid-edit flow.
198
199    // Walk backwards from the cursor, tracking the two possible trigger
200    // contexts in parallel:
201    //
202    // - **hashtag**: only word chars `[A-Za-z0-9_]` may sit between the
203    //   `#` and the cursor (matches the hashtag regex in
204    //   `core::note::content_extractor`). Any other char before we hit
205    //   `#` makes a hashtag impossible.
206    // - **wikilink**: any char except `]`, `\n`, `\r`, or a `|` already
207    //   seen on the way back. A `]` closes a prior wikilink so we are not
208    //   inside one; a `|` means the cursor is in the alias portion, which
209    //   we don't autocomplete.
210    //
211    // The first context that hits its opener wins. Wikilink opener is
212    // `[[`; when both `#` and `[[` are present, we keep scanning past `#`
213    // and prefer `[[` (the outer context).
214    let mut hash_pos: Option<usize> = None;
215    let mut hash_possible = true;
216    let mut wikilink_pos: Option<usize> = None;
217    let mut wikilink_possible = true;
218    let mut pipe_seen = false;
219    let mut prev_was_bracket = false;
220    let mut link_filter_pos: Option<usize> = None;
221    let mut link_filter_possible = true;
222
223    let mut i = cursor;
224    while i > 0 && (hash_possible || wikilink_possible || link_filter_possible) {
225        let prev = prev_char_boundary(text, i);
226        let c = text[prev..i].chars().next()?;
227
228        if c == '\n' || c == '\r' {
229            break;
230        }
231
232        if wikilink_possible {
233            match c {
234                ']' => wikilink_possible = false,
235                '|' => pipe_seen = true,
236                '[' if prev_was_bracket => {
237                    wikilink_pos = Some(prev);
238                    break;
239                }
240                _ => {}
241            }
242        }
243
244        if hash_possible && hash_pos.is_none() {
245            if c == '#' {
246                hash_pos = Some(prev);
247            } else if !(c.is_ascii_alphanumeric() || c == '_') {
248                hash_possible = false;
249            }
250        }
251
252        if link_filter_possible && link_filter_pos.is_none() {
253            if is_link_filter_opener(c) {
254                link_filter_pos = Some(prev);
255                // Opener found: the link-filter state is resolved, so stop
256                // scanning for it. The loop continues only if another state
257                // machine (wikilink/hashtag) is still live — mirroring how
258                // `wikilink_possible` / `hash_possible` stop driving the loop
259                // once their openers/stoppers are hit.
260                link_filter_possible = false;
261            } else if !is_link_filter_target_char(c) {
262                link_filter_possible = false;
263            }
264        }
265
266        prev_was_bracket = c == '[';
267        i = prev;
268    }
269
270    // Wikilink takes precedence when both are detected — it is the outer
271    // context. A wikilink with a `|` between the opener and the cursor
272    // means we are in the alias portion; bail.
273    if let Some(open) = wikilink_pos {
274        if pipe_seen {
275            return None;
276        }
277        let inner_start = open + 2;
278        if inner_start > cursor {
279            return None;
280        }
281        // Suppress inside code, markdown link bodies, frontmatter —
282        // but NOT inside an already-closed `[[…]]` (that is the
283        // reopen-mid-target case the spec wants to support). Only
284        // applied when the caller is editing Markdown (search box
285        // disables this).
286        if opts.apply_exclusion_zone && oracle.contains_code_link_or_frontmatter(cursor) {
287            return None;
288        }
289        let query = text[inner_start..cursor].to_string();
290        return Some(TriggerContext {
291            kind: TriggerKind::Wikilink,
292            query,
293            replace_range: inner_start..cursor,
294            anchor_col: inner_start,
295            opener: None,
296        });
297    }
298
299    if let Some(hash) = hash_pos {
300        let inner_start = hash + 1;
301        if inner_start > cursor {
302            return None;
303        }
304
305        // Hashtag-only: suppress inside code spans, fenced blocks,
306        // frontmatter, markdown links, or already-closed wikilinks /
307        // markdown link bodies — but only when the caller is editing
308        // Markdown. The search box turns this off because its input is
309        // plain text. Checked before the word-boundary guard so a future
310        // relaxation of the boundary rule cannot accidentally let popups
311        // leak into excluded regions.
312        if opts.apply_exclusion_zone && oracle.contains(cursor) {
313            return None;
314        }
315
316        // Word-boundary guard — mirrors `core::note::content_extractor::
317        // label_matches_inner`. The tag region runs from `#` through the
318        // contiguous `[A-Za-z0-9_]+` word that follows it; reject if the
319        // character on EITHER side of that region is alphanumeric, `_`, or
320        // another `#`. Both sides are required because the popup may open
321        // when the cursor is inside an existing tag (e.g. `#tag#more`
322        // cursor between `g` and the second `#`) — checking only the
323        // preceding char would suggest a label the indexer then rejects.
324        if hash > 0 {
325            let preceding_blocks_label = text[..hash]
326                .chars()
327                .next_back()
328                .map(|c| c.is_alphanumeric() || c == '_' || c == '#')
329                .unwrap_or(false);
330            if preceding_blocks_label {
331                return None;
332            }
333        }
334        let bytes = text.as_bytes();
335        let mut word_end = inner_start;
336        while word_end < bytes.len() {
337            let b = bytes[word_end];
338            if b.is_ascii_alphanumeric() || b == b'_' {
339                word_end += 1;
340            } else {
341                break;
342            }
343        }
344        let following_blocks_label = text[word_end..]
345            .chars()
346            .next()
347            .map(|c| c.is_alphanumeric() || c == '_' || c == '#')
348            .unwrap_or(false);
349        if following_blocks_label {
350            return None;
351        }
352
353        // Column-0 disambiguation: defer the trigger when the user has
354        // just typed `#` at the start of a line, since the next keystroke
355        // tells us whether this is a hashtag (anything non-space) or a
356        // Markdown header (space). Only active in contexts that actually
357        // support Markdown headers (the editor); the search box turns
358        // this off via `TriggerOptions`.
359        if opts.disambiguate_header {
360            let at_line_start = hash == 0 || text.as_bytes().get(hash - 1) == Some(&b'\n');
361            if at_line_start {
362                if cursor == inner_start {
363                    return None;
364                }
365                let next_char = text[inner_start..].chars().next();
366                if next_char == Some(' ') {
367                    return None;
368                }
369            }
370        }
371
372        let query = text[inner_start..cursor].to_string();
373        return Some(TriggerContext {
374            kind: TriggerKind::Hashtag,
375            query,
376            replace_range: inner_start..cursor,
377            anchor_col: inner_start,
378            opener: None,
379        });
380    }
381
382    // LinkFilter trigger: a note-name operator (`<`, `>`, `=`) followed by
383    // a target, optionally in its exclusion form (`-<`, `->`, `-=`). All
384    // three operators take a note-name argument (backlinks / forward links
385    // / note name — ADR-0005 alphabet) and share the same suggestion list.
386    // The opener must be at a token start — i.e. preceded by nothing,
387    // whitespace, or a `-` that is itself at string-start or preceded by
388    // whitespace (the exclusion form).
389    if let Some(gt) = link_filter_pos {
390        let inner_start = gt + 1; // byte just after the opener
391        if inner_start > cursor {
392            return None;
393        }
394
395        // Token-start guard: what sits immediately before the opener?
396        let token_start = if gt == 0 {
397            true // `>` at string start
398        } else {
399            let before_gt = text[..gt].chars().next_back().unwrap();
400            if before_gt.is_whitespace() {
401                true // `>` after whitespace
402            } else if before_gt == '-' {
403                // Allow only `->` where `-` itself is at string-start or
404                // preceded by whitespace.
405                let dash_pos = gt - before_gt.len_utf8();
406                dash_pos == 0
407                    || text[..dash_pos]
408                        .chars()
409                        .next_back()
410                        .map(|c| c.is_whitespace())
411                        .unwrap_or(false)
412            } else {
413                false
414            }
415        };
416        if !token_start {
417            return None;
418        }
419
420        if opts.apply_exclusion_zone && oracle.contains(cursor) {
421            return None;
422        }
423
424        // The opener char sits at byte `gt`; capture it so the popup can
425        // render the matching sigil (`<`, `>`, or `=`).
426        let opener = text[gt..inner_start].chars().next();
427
428        let query = text[inner_start..cursor].to_string();
429        return Some(TriggerContext {
430            kind: TriggerKind::LinkFilter,
431            query,
432            replace_range: inner_start..cursor,
433            anchor_col: inner_start,
434            opener,
435        });
436    }
437
438    None
439}
440
441/// Returns `true` for the note-name operators that open a link-filter
442/// trigger: `<` (backlinks), `>` (forward links), `=` (note name) — the
443/// ADR-0005 alphabet operators that take a note-name argument. `@`
444/// (section), `#` (label) and `/` (path) are intentionally excluded.
445fn is_link_filter_opener(c: char) -> bool {
446    matches!(c, '<' | '>' | '=')
447}
448
449/// Returns `true` for characters that may appear in a link-filter target
450/// prefix (everything between the opener and the cursor). Mirrors the valid
451/// characters for note names / paths: letters, digits, `_`, `-`, `.`, `/`,
452/// `*`, `{`, `}`. Spaces are intentionally excluded because they are
453/// query-token separators — the scan stops at a space, so a `>` preceded
454/// only by a space is considered a valid token-start.
455fn is_link_filter_target_char(c: char) -> bool {
456    c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '*' | '{' | '}')
457}
458
459fn prev_char_boundary(text: &str, i: usize) -> usize {
460    (0..i)
461        .rev()
462        .find(|&p| text.is_char_boundary(p))
463        .unwrap_or(0)
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    fn ctx(text: &str, cursor: usize) -> Option<TriggerContext> {
471        detect_trigger(text, cursor)
472    }
473
474    /// `ctx` with the search-box opt-in for the leading-`?` SavedSearch trigger.
475    fn ctx_ss(text: &str, cursor: usize) -> Option<TriggerContext> {
476        detect_trigger_with(
477            text,
478            cursor,
479            TriggerOptions {
480                allow_saved_search: true,
481                ..TriggerOptions::default()
482            },
483        )
484    }
485
486    /// Records how many times the exclusion veto consulted the oracle.
487    struct CountingOracle {
488        calls: usize,
489    }
490    impl ZoneOracle for CountingOracle {
491        fn contains(&mut self, _: usize) -> bool {
492            self.calls += 1;
493            false
494        }
495        fn contains_code_link_or_frontmatter(&mut self, _: usize) -> bool {
496            self.calls += 1;
497            false
498        }
499    }
500
501    // ---- Lazy exclusion oracle ----
502
503    #[test]
504    fn oracle_untouched_without_trigger_candidate() {
505        // Plain prose, caret at end: the local backward scan finds no
506        // `[[`/`#` opener, so the veto is never reached and the
507        // (expensive in the real impl) zone query must not run.
508        let mut o = CountingOracle { calls: 0 };
509        let r = detect_trigger_with_oracle("hello world", 11, TriggerOptions::default(), &mut o);
510        assert!(r.is_none());
511        assert_eq!(o.calls, 0, "no opener must not consult the zone oracle");
512    }
513
514    #[test]
515    fn oracle_consulted_for_hashtag_candidate() {
516        let mut o = CountingOracle { calls: 0 };
517        let _ = detect_trigger_with_oracle("#tag", 4, TriggerOptions::default(), &mut o);
518        assert!(o.calls >= 1, "a # candidate must consult the veto oracle");
519    }
520
521    #[test]
522    fn oracle_consulted_for_wikilink_candidate() {
523        let mut o = CountingOracle { calls: 0 };
524        let _ = detect_trigger_with_oracle("[[me", 4, TriggerOptions::default(), &mut o);
525        assert!(o.calls >= 1, "a [[ candidate must consult the veto oracle");
526    }
527
528    #[test]
529    fn oracle_untouched_when_exclusion_disabled() {
530        // Search box (apply_exclusion_zone = false) must never consult
531        // the oracle even with an opener present.
532        let opts = TriggerOptions {
533            apply_exclusion_zone: false,
534            ..TriggerOptions::default()
535        };
536        let mut o = CountingOracle { calls: 0 };
537        let _ = detect_trigger_with_oracle("#tag", 4, opts, &mut o);
538        assert_eq!(
539            o.calls, 0,
540            "apply_exclusion_zone=false must skip the oracle entirely"
541        );
542    }
543
544    // ---- Wikilink trigger ----
545
546    #[test]
547    fn wikilink_opens_with_empty_query() {
548        let t = ctx("[[", 2).unwrap();
549        assert_eq!(t.kind, TriggerKind::Wikilink);
550        assert_eq!(t.query, "");
551        assert_eq!(t.replace_range, 2..2);
552        assert_eq!(t.anchor_col, 2);
553    }
554
555    #[test]
556    fn wikilink_filters_by_typed_prefix() {
557        let t = ctx("see [[foo", 9).unwrap();
558        assert_eq!(t.kind, TriggerKind::Wikilink);
559        assert_eq!(t.query, "foo");
560        assert_eq!(t.replace_range, 6..9);
561    }
562
563    #[test]
564    fn wikilink_with_pipe_alias_does_not_trigger() {
565        // Cursor inside alias portion.
566        assert!(ctx("[[target|al", 11).is_none());
567    }
568
569    #[test]
570    fn wikilink_after_closing_brackets_is_not_a_trigger() {
571        assert!(ctx("[[done]] more", 13).is_none());
572    }
573
574    #[test]
575    fn wikilink_with_newline_inside_does_not_trigger() {
576        assert!(ctx("[[foo\nbar", 9).is_none());
577    }
578
579    #[test]
580    fn lone_single_bracket_does_not_trigger() {
581        assert!(ctx("[foo", 4).is_none());
582    }
583
584    // ---- Hashtag trigger (mid-line) ----
585
586    #[test]
587    fn hashtag_mid_line_opens_immediately() {
588        let t = ctx("some note #", 11).unwrap();
589        assert_eq!(t.kind, TriggerKind::Hashtag);
590        assert_eq!(t.query, "");
591        assert_eq!(t.replace_range, 11..11);
592    }
593
594    #[test]
595    fn hashtag_with_typed_query() {
596        let t = ctx("about #pro", 10).unwrap();
597        assert_eq!(t.kind, TriggerKind::Hashtag);
598        assert_eq!(t.query, "pro");
599        assert_eq!(t.replace_range, 7..10);
600        assert_eq!(t.anchor_col, 7);
601    }
602
603    // ---- SavedSearch trigger ----
604
605    #[test]
606    fn saved_search_opens_on_leading_question_mark() {
607        let t = ctx_ss("?to", 3).unwrap();
608        assert_eq!(t.kind, TriggerKind::SavedSearch);
609        assert_eq!(t.query, "to");
610        assert_eq!(t.replace_range, 1..3);
611        assert_eq!(t.anchor_col, 1);
612    }
613
614    #[test]
615    fn saved_search_opens_with_empty_query() {
616        let t = ctx_ss("?", 1).unwrap();
617        assert_eq!(t.kind, TriggerKind::SavedSearch);
618        assert_eq!(t.query, "");
619        assert_eq!(t.replace_range, 1..1);
620    }
621
622    #[test]
623    fn saved_search_not_triggered_when_not_leading() {
624        // `?` is only special as the leading (blank-prefixed) char.
625        assert_ne!(
626            ctx_ss("#a ?to", 6).map(|t| t.kind),
627            Some(TriggerKind::SavedSearch)
628        );
629        assert_ne!(
630            ctx_ss("note ?x", 7).map(|t| t.kind),
631            Some(TriggerKind::SavedSearch)
632        );
633    }
634
635    #[test]
636    fn saved_search_off_by_default() {
637        // Without the opt-in (the editor's default), a leading `?` is inert,
638        // so it can't shadow the hashtag/wikilink scan.
639        assert_eq!(ctx("?to", 3), None);
640    }
641
642    #[test]
643    fn hashtag_closes_when_word_char_boundary_passes() {
644        // A space after `#proj` breaks the hashtag context.
645        assert!(ctx("about #proj here", 16).is_none());
646    }
647
648    #[test]
649    fn hash_mid_word_does_not_trigger() {
650        // `hello#` — `#` immediately follows a letter, so it is not a label.
651        assert!(ctx("hello#", 6).is_none());
652    }
653
654    #[test]
655    fn hash_mid_word_with_query_does_not_trigger() {
656        // `hello#tag` — still mid-word, popup must not open.
657        assert!(ctx("hello#tag", 9).is_none());
658    }
659
660    #[test]
661    fn hash_after_digit_does_not_trigger() {
662        assert!(ctx("abc123#tag", 10).is_none());
663    }
664
665    #[test]
666    fn hash_after_underscore_does_not_trigger() {
667        assert!(ctx("foo_#tag", 8).is_none());
668    }
669
670    #[test]
671    fn double_hash_does_not_trigger() {
672        // `##tag` — second `#` immediately follows first `#`, not a label.
673        assert!(ctx("##tag", 5).is_none());
674    }
675
676    #[test]
677    fn triple_hash_does_not_trigger() {
678        assert!(ctx("###tag", 6).is_none());
679    }
680
681    #[test]
682    fn double_hash_mid_line_does_not_trigger() {
683        assert!(ctx("hello ##tag", 11).is_none());
684    }
685
686    #[test]
687    fn hash_between_double_hash_at_start_does_not_trigger() {
688        // `##tag` with cursor between the two `#`s — the column-0 case the
689        // earlier `if hash > 0` gate let through.
690        assert!(ctx("##tag", 1).is_none());
691    }
692
693    #[test]
694    fn adjacent_hash_at_cursor_does_not_trigger() {
695        // `#tag#more` with cursor right after `g` — popup must not open
696        // because the indexer will reject both `#tag` and `#more`.
697        assert!(ctx("#tag#more", 4).is_none());
698    }
699
700    #[test]
701    fn adjacent_hash_with_cursor_inside_tag_does_not_trigger() {
702        // Cursor mid-tag (`#ta|g#more`) — the following `#` still
703        // invalidates the tag region.
704        assert!(ctx("#tag#more", 3).is_none());
705    }
706
707    #[test]
708    fn trailing_hash_after_tag_does_not_trigger() {
709        // `#draft#` cursor between `t` and trailing `#`.
710        assert!(ctx("#draft#", 6).is_none());
711    }
712
713    #[test]
714    fn search_box_double_hash_at_start_does_not_trigger() {
715        // Same column-0 `##` case under search-box opts — the guard now
716        // catches it via the following-char check (the original gate
717        // skipped it when `disambiguate_header=false`).
718        let opts = TriggerOptions {
719            disambiguate_header: false,
720            apply_exclusion_zone: false,
721            allow_saved_search: false,
722        };
723        assert!(detect_trigger_with("##tag", 1, opts).is_none());
724        assert!(detect_trigger_with("##", 1, opts).is_none());
725    }
726
727    #[test]
728    fn hash_after_space_then_hash_triggers() {
729        // `# #tag` — space breaks the `##` run, second `#` is a valid label start.
730        let t = ctx("# #tag", 6).unwrap();
731        assert_eq!(t.kind, TriggerKind::Hashtag);
732        assert_eq!(t.query, "tag");
733    }
734
735    #[test]
736    fn hash_after_punctuation_triggers() {
737        // Punctuation is not a label char, so `#tag` after `,` is a valid hashtag.
738        let t = ctx("hi,#tag", 7).unwrap();
739        assert_eq!(t.kind, TriggerKind::Hashtag);
740        assert_eq!(t.query, "tag");
741    }
742
743    // ---- Hashtag vs. header disambiguation at start of line ----
744
745    #[test]
746    fn hash_alone_at_start_of_line_does_not_trigger() {
747        assert!(ctx("#", 1).is_none());
748    }
749
750    #[test]
751    fn hash_then_space_at_start_of_line_is_header() {
752        assert!(ctx("# ", 2).is_none());
753    }
754
755    #[test]
756    fn hash_then_letter_at_start_of_line_opens_popup() {
757        let t = ctx("#p", 2).unwrap();
758        assert_eq!(t.kind, TriggerKind::Hashtag);
759        assert_eq!(t.query, "p");
760        assert_eq!(t.replace_range, 1..2);
761    }
762
763    #[test]
764    fn hash_then_letter_after_newline_opens_popup() {
765        let t = ctx("para\n#p", 7).unwrap();
766        assert_eq!(t.kind, TriggerKind::Hashtag);
767        assert_eq!(t.query, "p");
768    }
769
770    #[test]
771    fn hash_then_space_after_newline_is_header() {
772        assert!(ctx("para\n# ", 7).is_none());
773    }
774
775    // ---- Wikilink wins over hashtag when both present ----
776
777    #[test]
778    fn wikilink_outer_wins_over_inner_hash() {
779        // User typed `[[#foo`; we are inside the wikilink, so the popup is
780        // wikilink-flavoured with `#foo` as the query.
781        let t = ctx("[[#foo", 6).unwrap();
782        assert_eq!(t.kind, TriggerKind::Wikilink);
783        assert_eq!(t.query, "#foo");
784    }
785
786    // ---- Exclusion zones (delegate to core) ----
787
788    #[test]
789    fn hash_inside_inline_code_does_not_trigger() {
790        // `#tag` is inside the backticks — exclusion zone.
791        assert!(ctx("here `#tag`", 9).is_none());
792    }
793
794    #[test]
795    fn hash_inside_fenced_code_does_not_trigger() {
796        let text = "para\n\n```\n#tag\n```\nafter";
797        let cursor = text.find("#tag").unwrap() + 4;
798        assert!(ctx(text, cursor).is_none());
799    }
800
801    #[test]
802    fn hash_inside_frontmatter_does_not_trigger() {
803        let text = "---\ntitle: Hi #tag\n---\nbody";
804        let cursor = text.find("#tag").unwrap() + 4;
805        assert!(ctx(text, cursor).is_none());
806    }
807
808    // ---- Cursor edge cases ----
809
810    #[test]
811    fn cursor_at_zero_never_triggers() {
812        assert!(ctx("", 0).is_none());
813        assert!(ctx("anything", 0).is_none());
814    }
815
816    #[test]
817    fn cursor_past_end_returns_none() {
818        assert!(ctx("short", 100).is_none());
819    }
820
821    #[test]
822    fn cursor_not_on_char_boundary_returns_none() {
823        // "é" is 2 bytes (0xc3 0xa9); cursor=1 is not a char boundary.
824        assert!(ctx("é", 1).is_none());
825    }
826
827    // ---- Trigger preserved across cursor moves that stay in range ----
828
829    #[test]
830    fn trigger_active_at_every_cursor_position_inside_target() {
831        let text = "see [[foo";
832        // From just-after-`[[` through end of typed text, every position
833        // yields a valid wikilink trigger with the appropriate query.
834        for cursor in 6..=9 {
835            let t = ctx(text, cursor).unwrap();
836            assert_eq!(t.kind, TriggerKind::Wikilink);
837            assert_eq!(t.query, &text[6..cursor]);
838        }
839    }
840
841    #[test]
842    fn trigger_cleared_when_cursor_moves_before_opener() {
843        // Cursor at 5 sits on the first `[`; the user is now outside.
844        assert!(ctx("see [[foo", 5).is_none());
845    }
846
847    // ---- CRLF handling ----
848
849    #[test]
850    fn crlf_line_treated_like_lf_for_column_0() {
851        // `\r\n` before `#`: the line starts at the byte right after `\n`,
852        // matching how `at_line_start` is computed.
853        let text = "para\r\n#p";
854        let cursor = text.len();
855        let t = ctx(text, cursor).unwrap();
856        assert_eq!(t.kind, TriggerKind::Hashtag);
857        assert_eq!(t.query, "p");
858    }
859
860    #[test]
861    fn crlf_just_after_hash_at_start_of_line_defers() {
862        let text = "para\r\n#";
863        assert!(ctx(text, text.len()).is_none());
864    }
865
866    // ---- TriggerOptions: header disambiguation disabled (search-box) ----
867
868    #[test]
869    fn search_box_opts_hash_alone_at_start_opens_immediately() {
870        let opts = TriggerOptions {
871            disambiguate_header: false,
872            apply_exclusion_zone: true,
873            allow_saved_search: false,
874        };
875        let t = detect_trigger_with("#", 1, opts).unwrap();
876        assert_eq!(t.kind, TriggerKind::Hashtag);
877        assert_eq!(t.query, "");
878    }
879
880    #[test]
881    fn search_box_opts_hash_then_space_at_start_still_opens() {
882        // No Markdown headers in the search input, so `# ` is a no-op
883        // hashtag-with-empty-query — but the rule lets it through, and
884        // the popup will close on the next typed char if no match.
885        let opts = TriggerOptions {
886            disambiguate_header: false,
887            apply_exclusion_zone: true,
888            allow_saved_search: false,
889        };
890        let t = detect_trigger_with("#", 1, opts);
891        assert!(t.is_some());
892    }
893
894    #[test]
895    fn search_box_opts_mid_line_unchanged() {
896        // The disambiguation flag has no effect on mid-line `#`.
897        let opts = TriggerOptions {
898            disambiguate_header: false,
899            apply_exclusion_zone: true,
900            allow_saved_search: false,
901        };
902        let t = detect_trigger_with("foo #pro", 8, opts).unwrap();
903        assert_eq!(t.kind, TriggerKind::Hashtag);
904        assert_eq!(t.query, "pro");
905    }
906
907    #[test]
908    fn wikilink_inside_fenced_code_does_not_trigger() {
909        let text = "para\n\n```\n[[note\n```\nafter";
910        let cursor = text.find("[[note").unwrap() + 6;
911        assert!(ctx(text, cursor).is_none());
912    }
913
914    #[test]
915    fn wikilink_inside_frontmatter_does_not_trigger() {
916        let text = "---\ntitle: see [[me\n---\nbody";
917        let cursor = text.find("[[me").unwrap() + 4;
918        assert!(ctx(text, cursor).is_none());
919    }
920
921    #[test]
922    fn wikilink_reopen_mid_existing_target_still_works() {
923        // The spec carve-out: cursor inside an already-closed `[[foo]]`
924        // STILL triggers (so the user can edit the target). The new
925        // exclusion-zone check excludes only code/link/frontmatter,
926        // NOT closed wikilinks.
927        let text = "see [[foo]]";
928        let t = ctx(text, 7).unwrap(); // cursor between `o` and `o`
929        assert_eq!(t.kind, TriggerKind::Wikilink);
930    }
931
932    #[test]
933    fn search_box_opts_backtick_does_not_suppress_hashtag() {
934        // With apply_exclusion_zone=false (search-box mode), a literal
935        // backtick in the query does not falsely classify the cursor
936        // as being inside a code span.
937        let opts = TriggerOptions {
938            disambiguate_header: false,
939            apply_exclusion_zone: false,
940            allow_saved_search: false,
941        };
942        let t = detect_trigger_with("`#abc", 5, opts).unwrap();
943        assert_eq!(t.kind, TriggerKind::Hashtag);
944        assert_eq!(t.query, "abc");
945    }
946
947    // ---- LinkFilter trigger (`>` / `->`) ----
948
949    #[test]
950    fn detects_link_filter_trigger() {
951        // `>` at the start of a query token opens a LinkFilter trigger.
952        let t = detect_trigger(">pro", 4).expect("should detect");
953        assert_eq!(t.kind, TriggerKind::LinkFilter);
954        assert_eq!(t.query, "pro");
955    }
956
957    #[test]
958    fn detects_excluded_link_filter_trigger() {
959        let t = detect_trigger("->dra", 5).expect("should detect");
960        assert_eq!(t.kind, TriggerKind::LinkFilter);
961        assert_eq!(t.query, "dra");
962    }
963
964    #[test]
965    fn link_filter_only_at_token_start() {
966        // A `>` that is not at a token boundary (e.g. inside a word) must NOT
967        // trigger. Here `a>b` — the `>` is preceded by a non-space word char.
968        let t = detect_trigger("a>b", 3);
969        assert!(t.is_none() || t.unwrap().kind != TriggerKind::LinkFilter);
970    }
971
972    #[test]
973    fn detects_backlink_filter_trigger() {
974        let t = detect_trigger("<pro", 4).expect("should detect");
975        assert_eq!(t.kind, TriggerKind::LinkFilter);
976        assert_eq!(t.query, "pro");
977        assert_eq!(t.opener, Some('<'));
978    }
979
980    #[test]
981    fn detects_forward_link_filter_trigger() {
982        let t = detect_trigger(">pro", 4).expect("should detect");
983        assert_eq!(t.kind, TriggerKind::LinkFilter);
984        assert_eq!(t.query, "pro");
985        assert_eq!(t.opener, Some('>'));
986    }
987
988    #[test]
989    fn detects_note_name_filter_trigger() {
990        let t = detect_trigger("=pro", 4).expect("should detect");
991        assert_eq!(t.kind, TriggerKind::LinkFilter);
992        assert_eq!(t.query, "pro");
993        assert_eq!(t.opener, Some('='));
994    }
995
996    #[test]
997    fn excluded_link_filter_captures_inner_opener() {
998        // The exclusion form `-<` / `->` / `-=` must capture the operator
999        // char (not the `-`) so the popup renders the right sigil.
1000        for (q, op) in [("-<dra", '<'), ("->dra", '>'), ("-=dra", '=')] {
1001            let t = detect_trigger(q, q.len()).expect("should detect");
1002            assert_eq!(t.kind, TriggerKind::LinkFilter, "{q}");
1003            assert_eq!(t.opener, Some(op), "{q}");
1004        }
1005    }
1006
1007    #[test]
1008    fn wikilink_and_hashtag_have_no_opener() {
1009        assert_eq!(ctx("[[foo", 5).unwrap().opener, None);
1010        assert_eq!(ctx("a #foo", 6).unwrap().opener, None);
1011    }
1012
1013    #[test]
1014    fn detects_excluded_forms() {
1015        for q in ["-<dra", "->dra", "-=dra"] {
1016            let t = detect_trigger(q, q.len()).expect("should detect");
1017            assert_eq!(t.kind, TriggerKind::LinkFilter, "{q}");
1018            assert_eq!(t.query, "dra", "{q}");
1019        }
1020    }
1021
1022    #[test]
1023    fn link_filter_new_openers_only_at_token_start() {
1024        // not at a token boundary -> no LinkFilter trigger
1025        let t = detect_trigger("a<b", 3);
1026        assert!(t.is_none() || t.unwrap().kind != TriggerKind::LinkFilter);
1027    }
1028}