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}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct TriggerContext {
15    pub kind: TriggerKind,
16    /// The text already typed between the trigger sigil (`[[` or `#`) and
17    /// the cursor — used as the prefix for the core suggestion query.
18    pub query: String,
19    /// Byte range that will be replaced when the user accepts a suggestion.
20    /// Starts immediately after the sigil and ends at the cursor.
21    pub replace_range: Range<usize>,
22    /// Byte offset of `replace_range.start`, kept as a separate field so the
23    /// host can map it to a screen anchor without re-parsing.
24    pub anchor_col: usize,
25}
26
27/// Per-call knobs for `detect_trigger_with`.
28#[derive(Debug, Clone, Copy)]
29pub struct TriggerOptions {
30    /// When `true`, a `#` at the start of a line defers (and is
31    /// suppressed when followed by a space) so Markdown headers don't
32    /// inadvertently open the hashtag popup. Editor uses `true`; the
33    /// search box uses `false` because its input has no Markdown
34    /// headers.
35    pub disambiguate_header: bool,
36    /// When `true`, suppress hashtag triggers inside code spans,
37    /// fenced blocks, frontmatter, link bodies, or closed wikilinks
38    /// (via `core::note::is_inside_exclusion_zone`). Editor uses
39    /// `true`; the search box uses `false` because its input is plain
40    /// text and the markdown parser would falsely classify literal
41    /// backticks / brackets as code or link spans.
42    pub apply_exclusion_zone: bool,
43}
44
45impl Default for TriggerOptions {
46    fn default() -> Self {
47        Self {
48            disambiguate_header: true,
49            apply_exclusion_zone: true,
50        }
51    }
52}
53
54/// Inspect `text` at `cursor` (a byte offset) and decide whether an
55/// autocomplete popup should be active.
56///
57/// Returns `Some(TriggerContext)` when the cursor sits inside an open
58/// wikilink target (`[[…|`) or an open hashtag word (`#…`). Returns `None`
59/// otherwise — including when the cursor is inside a code span, fenced
60/// block, frontmatter, or already-closed wikilink/markdown link (delegated
61/// to `kimun_core::note::content_extractor::is_inside_exclusion_zone`).
62///
63/// Disambiguation rules in play:
64/// - **Hashtag vs. Markdown header**: a `#` at the start of a line only
65///   triggers the popup once the user has typed the next character AND
66///   that character is not a space (a space means `# Heading`).
67/// - **Wikilink target vs. alias**: in `[[target|alias]]`, only the
68///   `target` portion triggers; the cursor crossing the `|` deactivates
69///   the popup.
70pub fn detect_trigger(text: &str, cursor: usize) -> Option<TriggerContext> {
71    detect_trigger_with(text, cursor, TriggerOptions::default())
72}
73
74/// Variant of [`detect_trigger`] that takes explicit options. Used by
75/// the search-box controller to suppress the column-0 `#` header
76/// disambiguation, which only matters in the Markdown editor.
77pub fn detect_trigger_with(
78    text: &str,
79    cursor: usize,
80    opts: TriggerOptions,
81) -> Option<TriggerContext> {
82    detect_trigger_with_zones(text, cursor, opts, None)
83}
84
85/// Lazily answers the two exclusion-zone queries the trigger veto
86/// needs. Consulted ONLY after a wikilink/hashtag opener is found in the
87/// local backward scan, so a keystroke with no trigger candidate never
88/// pays for the full-buffer `ExclusionZones` scan (pulldown + regex).
89/// Implementors decide whether to precompute, recompute, or memoize.
90pub trait ZoneOracle {
91    /// Mirror of `ExclusionZones::contains` — the hashtag veto.
92    fn contains(&mut self, cursor: usize) -> bool;
93    /// Mirror of `ExclusionZones::contains_code_link_or_frontmatter` —
94    /// the wikilink veto.
95    fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool;
96}
97
98/// Oracle backed by a precomputed `ExclusionZones`.
99struct PrecomputedOracle<'a>(&'a ExclusionZones);
100impl ZoneOracle for PrecomputedOracle<'_> {
101    fn contains(&mut self, cursor: usize) -> bool {
102        self.0.contains(cursor)
103    }
104    fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool {
105        self.0.contains_code_link_or_frontmatter(cursor)
106    }
107}
108
109/// Oracle that recomputes per query from `text`. Used by the no-cache
110/// convenience paths (`detect_trigger`, `detect_trigger_with`) and
111/// tests; at most one veto query runs per call, so the recompute is
112/// paid only when a candidate opener is actually present.
113struct RecomputeOracle<'t>(&'t str);
114impl ZoneOracle for RecomputeOracle<'_> {
115    fn contains(&mut self, cursor: usize) -> bool {
116        is_inside_exclusion_zone(self.0, cursor)
117    }
118    fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool {
119        is_inside_code_link_or_frontmatter(self.0, cursor)
120    }
121}
122
123/// Variant of [`detect_trigger_with`] that accepts a precomputed
124/// `ExclusionZones` for the same `text`. When `zones` is `None`, the
125/// exclusion check recomputes per call (used by tests and the no-cache
126/// convenience function). Thin wrapper over [`detect_trigger_with_oracle`].
127pub fn detect_trigger_with_zones(
128    text: &str,
129    cursor: usize,
130    opts: TriggerOptions,
131    zones: Option<&ExclusionZones>,
132) -> Option<TriggerContext> {
133    match zones {
134        Some(z) => detect_trigger_with_oracle(text, cursor, opts, &mut PrecomputedOracle(z)),
135        None => detect_trigger_with_oracle(text, cursor, opts, &mut RecomputeOracle(text)),
136    }
137}
138
139/// Core trigger detection. The local backward scan from `cursor` runs
140/// unconditionally; `oracle` is consulted only at the exclusion veto,
141/// reached only once a `[[`/`#` opener has been found — so the caller's
142/// zone computation can stay lazy.
143pub fn detect_trigger_with_oracle(
144    text: &str,
145    cursor: usize,
146    opts: TriggerOptions,
147    oracle: &mut dyn ZoneOracle,
148) -> Option<TriggerContext> {
149    if cursor > text.len() || !text.is_char_boundary(cursor) {
150        return None;
151    }
152    // The exclusion-zone check is applied selectively below — only for
153    // hashtags. A wikilink trigger inside an already-closed `[[foo]]`
154    // means the user is editing the target portion, which the spec
155    // explicitly supports (see "Suggestion acceptance" — alias-suffix
156    // preservation). Applying exclusion up-front here would block that
157    // reopen-mid-edit flow.
158
159    // Walk backwards from the cursor, tracking the two possible trigger
160    // contexts in parallel:
161    //
162    // - **hashtag**: only word chars `[A-Za-z0-9_]` may sit between the
163    //   `#` and the cursor (matches the hashtag regex in
164    //   `core::note::content_extractor`). Any other char before we hit
165    //   `#` makes a hashtag impossible.
166    // - **wikilink**: any char except `]`, `\n`, `\r`, or a `|` already
167    //   seen on the way back. A `]` closes a prior wikilink so we are not
168    //   inside one; a `|` means the cursor is in the alias portion, which
169    //   we don't autocomplete.
170    //
171    // The first context that hits its opener wins. Wikilink opener is
172    // `[[`; when both `#` and `[[` are present, we keep scanning past `#`
173    // and prefer `[[` (the outer context).
174    let mut hash_pos: Option<usize> = None;
175    let mut hash_possible = true;
176    let mut wikilink_pos: Option<usize> = None;
177    let mut wikilink_possible = true;
178    let mut pipe_seen = false;
179    let mut prev_was_bracket = false;
180
181    let mut i = cursor;
182    while i > 0 && (hash_possible || wikilink_possible) {
183        let prev = prev_char_boundary(text, i);
184        let c = text[prev..i].chars().next()?;
185
186        if c == '\n' || c == '\r' {
187            break;
188        }
189
190        if wikilink_possible {
191            match c {
192                ']' => wikilink_possible = false,
193                '|' => pipe_seen = true,
194                '[' if prev_was_bracket => {
195                    wikilink_pos = Some(prev);
196                    break;
197                }
198                _ => {}
199            }
200        }
201
202        if hash_possible && hash_pos.is_none() {
203            if c == '#' {
204                hash_pos = Some(prev);
205            } else if !(c.is_ascii_alphanumeric() || c == '_') {
206                hash_possible = false;
207            }
208        }
209
210        prev_was_bracket = c == '[';
211        i = prev;
212    }
213
214    // Wikilink takes precedence when both are detected — it is the outer
215    // context. A wikilink with a `|` between the opener and the cursor
216    // means we are in the alias portion; bail.
217    if let Some(open) = wikilink_pos {
218        if pipe_seen {
219            return None;
220        }
221        let inner_start = open + 2;
222        if inner_start > cursor {
223            return None;
224        }
225        // Suppress inside code, markdown link bodies, frontmatter —
226        // but NOT inside an already-closed `[[…]]` (that is the
227        // reopen-mid-target case the spec wants to support). Only
228        // applied when the caller is editing Markdown (search box
229        // disables this).
230        if opts.apply_exclusion_zone && oracle.contains_code_link_or_frontmatter(cursor) {
231            return None;
232        }
233        let query = text[inner_start..cursor].to_string();
234        return Some(TriggerContext {
235            kind: TriggerKind::Wikilink,
236            query,
237            replace_range: inner_start..cursor,
238            anchor_col: inner_start,
239        });
240    }
241
242    if let Some(hash) = hash_pos {
243        let inner_start = hash + 1;
244        if inner_start > cursor {
245            return None;
246        }
247
248        // Hashtag-only: suppress inside code spans, fenced blocks,
249        // frontmatter, markdown links, or already-closed wikilinks /
250        // markdown link bodies — but only when the caller is editing
251        // Markdown. The search box turns this off because its input is
252        // plain text. Checked before the word-boundary guard so a future
253        // relaxation of the boundary rule cannot accidentally let popups
254        // leak into excluded regions.
255        if opts.apply_exclusion_zone && oracle.contains(cursor) {
256            return None;
257        }
258
259        // Word-boundary guard — mirrors `core::note::content_extractor::
260        // label_matches_inner`. The tag region runs from `#` through the
261        // contiguous `[A-Za-z0-9_]+` word that follows it; reject if the
262        // character on EITHER side of that region is alphanumeric, `_`, or
263        // another `#`. Both sides are required because the popup may open
264        // when the cursor is inside an existing tag (e.g. `#tag#more`
265        // cursor between `g` and the second `#`) — checking only the
266        // preceding char would suggest a label the indexer then rejects.
267        if hash > 0 {
268            let preceding_blocks_label = text[..hash]
269                .chars()
270                .next_back()
271                .map(|c| c.is_alphanumeric() || c == '_' || c == '#')
272                .unwrap_or(false);
273            if preceding_blocks_label {
274                return None;
275            }
276        }
277        let bytes = text.as_bytes();
278        let mut word_end = inner_start;
279        while word_end < bytes.len() {
280            let b = bytes[word_end];
281            if b.is_ascii_alphanumeric() || b == b'_' {
282                word_end += 1;
283            } else {
284                break;
285            }
286        }
287        let following_blocks_label = text[word_end..]
288            .chars()
289            .next()
290            .map(|c| c.is_alphanumeric() || c == '_' || c == '#')
291            .unwrap_or(false);
292        if following_blocks_label {
293            return None;
294        }
295
296        // Column-0 disambiguation: defer the trigger when the user has
297        // just typed `#` at the start of a line, since the next keystroke
298        // tells us whether this is a hashtag (anything non-space) or a
299        // Markdown header (space). Only active in contexts that actually
300        // support Markdown headers (the editor); the search box turns
301        // this off via `TriggerOptions`.
302        if opts.disambiguate_header {
303            let at_line_start = hash == 0 || text.as_bytes().get(hash - 1) == Some(&b'\n');
304            if at_line_start {
305                if cursor == inner_start {
306                    return None;
307                }
308                let next_char = text[inner_start..].chars().next();
309                if next_char == Some(' ') {
310                    return None;
311                }
312            }
313        }
314
315        let query = text[inner_start..cursor].to_string();
316        return Some(TriggerContext {
317            kind: TriggerKind::Hashtag,
318            query,
319            replace_range: inner_start..cursor,
320            anchor_col: inner_start,
321        });
322    }
323
324    None
325}
326
327fn prev_char_boundary(text: &str, i: usize) -> usize {
328    (0..i)
329        .rev()
330        .find(|&p| text.is_char_boundary(p))
331        .unwrap_or(0)
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    fn ctx(text: &str, cursor: usize) -> Option<TriggerContext> {
339        detect_trigger(text, cursor)
340    }
341
342    /// Records how many times the exclusion veto consulted the oracle.
343    struct CountingOracle {
344        calls: usize,
345    }
346    impl ZoneOracle for CountingOracle {
347        fn contains(&mut self, _: usize) -> bool {
348            self.calls += 1;
349            false
350        }
351        fn contains_code_link_or_frontmatter(&mut self, _: usize) -> bool {
352            self.calls += 1;
353            false
354        }
355    }
356
357    // ---- Lazy exclusion oracle ----
358
359    #[test]
360    fn oracle_untouched_without_trigger_candidate() {
361        // Plain prose, caret at end: the local backward scan finds no
362        // `[[`/`#` opener, so the veto is never reached and the
363        // (expensive in the real impl) zone query must not run.
364        let mut o = CountingOracle { calls: 0 };
365        let r = detect_trigger_with_oracle("hello world", 11, TriggerOptions::default(), &mut o);
366        assert!(r.is_none());
367        assert_eq!(o.calls, 0, "no opener must not consult the zone oracle");
368    }
369
370    #[test]
371    fn oracle_consulted_for_hashtag_candidate() {
372        let mut o = CountingOracle { calls: 0 };
373        let _ = detect_trigger_with_oracle("#tag", 4, TriggerOptions::default(), &mut o);
374        assert!(o.calls >= 1, "a # candidate must consult the veto oracle");
375    }
376
377    #[test]
378    fn oracle_consulted_for_wikilink_candidate() {
379        let mut o = CountingOracle { calls: 0 };
380        let _ = detect_trigger_with_oracle("[[me", 4, TriggerOptions::default(), &mut o);
381        assert!(o.calls >= 1, "a [[ candidate must consult the veto oracle");
382    }
383
384    #[test]
385    fn oracle_untouched_when_exclusion_disabled() {
386        // Search box (apply_exclusion_zone = false) must never consult
387        // the oracle even with an opener present.
388        let opts = TriggerOptions {
389            apply_exclusion_zone: false,
390            ..TriggerOptions::default()
391        };
392        let mut o = CountingOracle { calls: 0 };
393        let _ = detect_trigger_with_oracle("#tag", 4, opts, &mut o);
394        assert_eq!(
395            o.calls, 0,
396            "apply_exclusion_zone=false must skip the oracle entirely"
397        );
398    }
399
400    // ---- Wikilink trigger ----
401
402    #[test]
403    fn wikilink_opens_with_empty_query() {
404        let t = ctx("[[", 2).unwrap();
405        assert_eq!(t.kind, TriggerKind::Wikilink);
406        assert_eq!(t.query, "");
407        assert_eq!(t.replace_range, 2..2);
408        assert_eq!(t.anchor_col, 2);
409    }
410
411    #[test]
412    fn wikilink_filters_by_typed_prefix() {
413        let t = ctx("see [[foo", 9).unwrap();
414        assert_eq!(t.kind, TriggerKind::Wikilink);
415        assert_eq!(t.query, "foo");
416        assert_eq!(t.replace_range, 6..9);
417    }
418
419    #[test]
420    fn wikilink_with_pipe_alias_does_not_trigger() {
421        // Cursor inside alias portion.
422        assert!(ctx("[[target|al", 11).is_none());
423    }
424
425    #[test]
426    fn wikilink_after_closing_brackets_is_not_a_trigger() {
427        assert!(ctx("[[done]] more", 13).is_none());
428    }
429
430    #[test]
431    fn wikilink_with_newline_inside_does_not_trigger() {
432        assert!(ctx("[[foo\nbar", 9).is_none());
433    }
434
435    #[test]
436    fn lone_single_bracket_does_not_trigger() {
437        assert!(ctx("[foo", 4).is_none());
438    }
439
440    // ---- Hashtag trigger (mid-line) ----
441
442    #[test]
443    fn hashtag_mid_line_opens_immediately() {
444        let t = ctx("some note #", 11).unwrap();
445        assert_eq!(t.kind, TriggerKind::Hashtag);
446        assert_eq!(t.query, "");
447        assert_eq!(t.replace_range, 11..11);
448    }
449
450    #[test]
451    fn hashtag_with_typed_query() {
452        let t = ctx("about #pro", 10).unwrap();
453        assert_eq!(t.kind, TriggerKind::Hashtag);
454        assert_eq!(t.query, "pro");
455        assert_eq!(t.replace_range, 7..10);
456        assert_eq!(t.anchor_col, 7);
457    }
458
459    #[test]
460    fn hashtag_closes_when_word_char_boundary_passes() {
461        // A space after `#proj` breaks the hashtag context.
462        assert!(ctx("about #proj here", 16).is_none());
463    }
464
465    #[test]
466    fn hash_mid_word_does_not_trigger() {
467        // `hello#` — `#` immediately follows a letter, so it is not a label.
468        assert!(ctx("hello#", 6).is_none());
469    }
470
471    #[test]
472    fn hash_mid_word_with_query_does_not_trigger() {
473        // `hello#tag` — still mid-word, popup must not open.
474        assert!(ctx("hello#tag", 9).is_none());
475    }
476
477    #[test]
478    fn hash_after_digit_does_not_trigger() {
479        assert!(ctx("abc123#tag", 10).is_none());
480    }
481
482    #[test]
483    fn hash_after_underscore_does_not_trigger() {
484        assert!(ctx("foo_#tag", 8).is_none());
485    }
486
487    #[test]
488    fn double_hash_does_not_trigger() {
489        // `##tag` — second `#` immediately follows first `#`, not a label.
490        assert!(ctx("##tag", 5).is_none());
491    }
492
493    #[test]
494    fn triple_hash_does_not_trigger() {
495        assert!(ctx("###tag", 6).is_none());
496    }
497
498    #[test]
499    fn double_hash_mid_line_does_not_trigger() {
500        assert!(ctx("hello ##tag", 11).is_none());
501    }
502
503    #[test]
504    fn hash_between_double_hash_at_start_does_not_trigger() {
505        // `##tag` with cursor between the two `#`s — the column-0 case the
506        // earlier `if hash > 0` gate let through.
507        assert!(ctx("##tag", 1).is_none());
508    }
509
510    #[test]
511    fn adjacent_hash_at_cursor_does_not_trigger() {
512        // `#tag#more` with cursor right after `g` — popup must not open
513        // because the indexer will reject both `#tag` and `#more`.
514        assert!(ctx("#tag#more", 4).is_none());
515    }
516
517    #[test]
518    fn adjacent_hash_with_cursor_inside_tag_does_not_trigger() {
519        // Cursor mid-tag (`#ta|g#more`) — the following `#` still
520        // invalidates the tag region.
521        assert!(ctx("#tag#more", 3).is_none());
522    }
523
524    #[test]
525    fn trailing_hash_after_tag_does_not_trigger() {
526        // `#draft#` cursor between `t` and trailing `#`.
527        assert!(ctx("#draft#", 6).is_none());
528    }
529
530    #[test]
531    fn search_box_double_hash_at_start_does_not_trigger() {
532        // Same column-0 `##` case under search-box opts — the guard now
533        // catches it via the following-char check (the original gate
534        // skipped it when `disambiguate_header=false`).
535        let opts = TriggerOptions {
536            disambiguate_header: false,
537            apply_exclusion_zone: false,
538        };
539        assert!(detect_trigger_with("##tag", 1, opts).is_none());
540        assert!(detect_trigger_with("##", 1, opts).is_none());
541    }
542
543    #[test]
544    fn hash_after_space_then_hash_triggers() {
545        // `# #tag` — space breaks the `##` run, second `#` is a valid label start.
546        let t = ctx("# #tag", 6).unwrap();
547        assert_eq!(t.kind, TriggerKind::Hashtag);
548        assert_eq!(t.query, "tag");
549    }
550
551    #[test]
552    fn hash_after_punctuation_triggers() {
553        // Punctuation is not a label char, so `#tag` after `,` is a valid hashtag.
554        let t = ctx("hi,#tag", 7).unwrap();
555        assert_eq!(t.kind, TriggerKind::Hashtag);
556        assert_eq!(t.query, "tag");
557    }
558
559    // ---- Hashtag vs. header disambiguation at start of line ----
560
561    #[test]
562    fn hash_alone_at_start_of_line_does_not_trigger() {
563        assert!(ctx("#", 1).is_none());
564    }
565
566    #[test]
567    fn hash_then_space_at_start_of_line_is_header() {
568        assert!(ctx("# ", 2).is_none());
569    }
570
571    #[test]
572    fn hash_then_letter_at_start_of_line_opens_popup() {
573        let t = ctx("#p", 2).unwrap();
574        assert_eq!(t.kind, TriggerKind::Hashtag);
575        assert_eq!(t.query, "p");
576        assert_eq!(t.replace_range, 1..2);
577    }
578
579    #[test]
580    fn hash_then_letter_after_newline_opens_popup() {
581        let t = ctx("para\n#p", 7).unwrap();
582        assert_eq!(t.kind, TriggerKind::Hashtag);
583        assert_eq!(t.query, "p");
584    }
585
586    #[test]
587    fn hash_then_space_after_newline_is_header() {
588        assert!(ctx("para\n# ", 7).is_none());
589    }
590
591    // ---- Wikilink wins over hashtag when both present ----
592
593    #[test]
594    fn wikilink_outer_wins_over_inner_hash() {
595        // User typed `[[#foo`; we are inside the wikilink, so the popup is
596        // wikilink-flavoured with `#foo` as the query.
597        let t = ctx("[[#foo", 6).unwrap();
598        assert_eq!(t.kind, TriggerKind::Wikilink);
599        assert_eq!(t.query, "#foo");
600    }
601
602    // ---- Exclusion zones (delegate to core) ----
603
604    #[test]
605    fn hash_inside_inline_code_does_not_trigger() {
606        // `#tag` is inside the backticks — exclusion zone.
607        assert!(ctx("here `#tag`", 9).is_none());
608    }
609
610    #[test]
611    fn hash_inside_fenced_code_does_not_trigger() {
612        let text = "para\n\n```\n#tag\n```\nafter";
613        let cursor = text.find("#tag").unwrap() + 4;
614        assert!(ctx(text, cursor).is_none());
615    }
616
617    #[test]
618    fn hash_inside_frontmatter_does_not_trigger() {
619        let text = "---\ntitle: Hi #tag\n---\nbody";
620        let cursor = text.find("#tag").unwrap() + 4;
621        assert!(ctx(text, cursor).is_none());
622    }
623
624    // ---- Cursor edge cases ----
625
626    #[test]
627    fn cursor_at_zero_never_triggers() {
628        assert!(ctx("", 0).is_none());
629        assert!(ctx("anything", 0).is_none());
630    }
631
632    #[test]
633    fn cursor_past_end_returns_none() {
634        assert!(ctx("short", 100).is_none());
635    }
636
637    #[test]
638    fn cursor_not_on_char_boundary_returns_none() {
639        // "é" is 2 bytes (0xc3 0xa9); cursor=1 is not a char boundary.
640        assert!(ctx("é", 1).is_none());
641    }
642
643    // ---- Trigger preserved across cursor moves that stay in range ----
644
645    #[test]
646    fn trigger_active_at_every_cursor_position_inside_target() {
647        let text = "see [[foo";
648        // From just-after-`[[` through end of typed text, every position
649        // yields a valid wikilink trigger with the appropriate query.
650        for cursor in 6..=9 {
651            let t = ctx(text, cursor).unwrap();
652            assert_eq!(t.kind, TriggerKind::Wikilink);
653            assert_eq!(t.query, &text[6..cursor]);
654        }
655    }
656
657    #[test]
658    fn trigger_cleared_when_cursor_moves_before_opener() {
659        // Cursor at 5 sits on the first `[`; the user is now outside.
660        assert!(ctx("see [[foo", 5).is_none());
661    }
662
663    // ---- CRLF handling ----
664
665    #[test]
666    fn crlf_line_treated_like_lf_for_column_0() {
667        // `\r\n` before `#`: the line starts at the byte right after `\n`,
668        // matching how `at_line_start` is computed.
669        let text = "para\r\n#p";
670        let cursor = text.len();
671        let t = ctx(text, cursor).unwrap();
672        assert_eq!(t.kind, TriggerKind::Hashtag);
673        assert_eq!(t.query, "p");
674    }
675
676    #[test]
677    fn crlf_just_after_hash_at_start_of_line_defers() {
678        let text = "para\r\n#";
679        assert!(ctx(text, text.len()).is_none());
680    }
681
682    // ---- TriggerOptions: header disambiguation disabled (search-box) ----
683
684    #[test]
685    fn search_box_opts_hash_alone_at_start_opens_immediately() {
686        let opts = TriggerOptions {
687            disambiguate_header: false,
688            apply_exclusion_zone: true,
689        };
690        let t = detect_trigger_with("#", 1, opts).unwrap();
691        assert_eq!(t.kind, TriggerKind::Hashtag);
692        assert_eq!(t.query, "");
693    }
694
695    #[test]
696    fn search_box_opts_hash_then_space_at_start_still_opens() {
697        // No Markdown headers in the search input, so `# ` is a no-op
698        // hashtag-with-empty-query — but the rule lets it through, and
699        // the popup will close on the next typed char if no match.
700        let opts = TriggerOptions {
701            disambiguate_header: false,
702            apply_exclusion_zone: true,
703        };
704        let t = detect_trigger_with("#", 1, opts);
705        assert!(t.is_some());
706    }
707
708    #[test]
709    fn search_box_opts_mid_line_unchanged() {
710        // The disambiguation flag has no effect on mid-line `#`.
711        let opts = TriggerOptions {
712            disambiguate_header: false,
713            apply_exclusion_zone: true,
714        };
715        let t = detect_trigger_with("foo #pro", 8, opts).unwrap();
716        assert_eq!(t.kind, TriggerKind::Hashtag);
717        assert_eq!(t.query, "pro");
718    }
719
720    #[test]
721    fn wikilink_inside_fenced_code_does_not_trigger() {
722        let text = "para\n\n```\n[[note\n```\nafter";
723        let cursor = text.find("[[note").unwrap() + 6;
724        assert!(ctx(text, cursor).is_none());
725    }
726
727    #[test]
728    fn wikilink_inside_frontmatter_does_not_trigger() {
729        let text = "---\ntitle: see [[me\n---\nbody";
730        let cursor = text.find("[[me").unwrap() + 4;
731        assert!(ctx(text, cursor).is_none());
732    }
733
734    #[test]
735    fn wikilink_reopen_mid_existing_target_still_works() {
736        // The spec carve-out: cursor inside an already-closed `[[foo]]`
737        // STILL triggers (so the user can edit the target). The new
738        // exclusion-zone check excludes only code/link/frontmatter,
739        // NOT closed wikilinks.
740        let text = "see [[foo]]";
741        let t = ctx(text, 7).unwrap(); // cursor between `o` and `o`
742        assert_eq!(t.kind, TriggerKind::Wikilink);
743    }
744
745    #[test]
746    fn search_box_opts_backtick_does_not_suppress_hashtag() {
747        // With apply_exclusion_zone=false (search-box mode), a literal
748        // backtick in the query does not falsely classify the cursor
749        // as being inside a code span.
750        let opts = TriggerOptions {
751            disambiguate_header: false,
752            apply_exclusion_zone: false,
753        };
754        let t = detect_trigger_with("`#abc", 5, opts).unwrap();
755        assert_eq!(t.kind, TriggerKind::Hashtag);
756        assert_eq!(t.query, "abc");
757    }
758}