Skip to main content

devup_editor_input_rules/
mark.rs

1//! Inline mark matchers for markdown shortcuts (bold, italic, strike,
2//! underline, highlight, code, link).
3//!
4//! Each matcher mirrors the corresponding `*Rule.apply()` from the
5//! React plugin exactly: it inspects the last character(s) typed,
6//! walks back to find the opening delimiter, and returns the range +
7//! inner text so the React side can dispatch the right command batch.
8//!
9//! All matchers operate on UTF-16 code-unit offsets to match the
10//! engine's selection contract. They never touch the editor state.
11
12use serde::{Deserialize, Serialize};
13
14/// The eight kinds of inline-mark matchers exposed to callers. Using
15/// an enum (rather than eight separate free functions) keeps the WASM
16/// boundary narrow — JS passes a tag string, Rust dispatches.
17#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "kebab-case")]
19pub enum MarkKind {
20    /// `**bold**`
21    Bold,
22    /// `*italic*` (must run AFTER `Bold` so the closing `*` in
23    /// `**bold**` hits bold first).
24    ItalicAsterisk,
25    /// `_italic_` (must run AFTER `Underline` for the same reason).
26    ItalicUnderscore,
27    /// `~~strike~~`
28    Strike,
29    /// `__underline__`
30    Underline,
31    /// `==highlight==`
32    Highlight,
33    /// `` `code` ``
34    Code,
35    /// `[text](url)` — returns a richer `LinkMatch` (two text ranges).
36    Link,
37}
38
39/// Match payload for the seven delimiter-pair marks (bold, italic *2,
40/// strike, underline, highlight, code).
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub struct InlineMatch {
43    /// UTF-16 offset of the opening delimiter's first character.
44    #[serde(rename = "openIdx")]
45    pub open_idx: usize,
46    /// UTF-16 offset of the closing delimiter's last character
47    /// (inclusive, so `close_idx + 1` is the exclusive end for
48    /// `setSelection`).
49    #[serde(rename = "closeIdx")]
50    pub close_idx: usize,
51    /// Text between the delimiters (without them).
52    pub inner: String,
53}
54
55/// Match payload specifically for `[text](url)`. Carries both the
56/// text and the URL so the React side can store `href` on the mark.
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58pub struct LinkMatch {
59    #[serde(rename = "openIdx")]
60    pub open_idx: usize,
61    #[serde(rename = "closeIdx")]
62    pub close_idx: usize,
63    #[serde(rename = "innerText")]
64    pub inner_text: String,
65    pub url: String,
66}
67
68/// Unified return type — simpler API shape for the WASM layer.
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(untagged)]
71pub enum AnyInlineMatch {
72    Link(LinkMatch),
73    Inline(InlineMatch),
74}
75
76/// Run the matcher associated with `kind` against `block_text` up to
77/// `cursor_offset` (UTF-16 units). Returns `None` when no match.
78#[must_use]
79pub fn match_inline_mark(
80    kind: MarkKind,
81    block_text: &str,
82    cursor_offset: usize,
83) -> Option<AnyInlineMatch> {
84    // Work on a char-indexed buffer so all offsets are UTF-16 units
85    // matching the engine's selection contract.
86    let chars: Vec<(usize, char)> = utf16_indexed_chars(block_text);
87    if cursor_offset == 0 {
88        return None;
89    }
90    match kind {
91        MarkKind::Bold => {
92            match_double(&chars, cursor_offset, '*', true).map(AnyInlineMatch::Inline)
93        }
94        MarkKind::Strike => {
95            match_double(&chars, cursor_offset, '~', true).map(AnyInlineMatch::Inline)
96        }
97        MarkKind::Underline => {
98            match_double(&chars, cursor_offset, '_', true).map(AnyInlineMatch::Inline)
99        }
100        MarkKind::Highlight => {
101            match_double(&chars, cursor_offset, '=', true).map(AnyInlineMatch::Inline)
102        }
103        MarkKind::Code => match_code(&chars, cursor_offset).map(AnyInlineMatch::Inline),
104        MarkKind::ItalicAsterisk => {
105            match_single(&chars, cursor_offset, '*').map(AnyInlineMatch::Inline)
106        }
107        MarkKind::ItalicUnderscore => {
108            match_single(&chars, cursor_offset, '_').map(AnyInlineMatch::Inline)
109        }
110        MarkKind::Link => match_link(&chars, cursor_offset).map(AnyInlineMatch::Link),
111    }
112}
113
114/// Walk `block_text` into a table `(utf16_offset, char)` so subsequent
115/// lookups are O(1) by UTF-16 offset without repeatedly counting surrogate
116/// pairs. The final entry's `utf16_offset` equals the total length.
117fn utf16_indexed_chars(s: &str) -> Vec<(usize, char)> {
118    let mut out = Vec::with_capacity(s.len());
119    let mut units = 0usize;
120    for ch in s.chars() {
121        out.push((units, ch));
122        units += ch.len_utf16();
123    }
124    // Sentinel at end so callers can use `chars[close_idx+1..]` safely
125    // via indexing by utf-16 offset.
126    out.push((units, '\0'));
127    out
128}
129
130fn char_at(chars: &[(usize, char)], utf16_offset: usize) -> Option<char> {
131    chars
132        .iter()
133        .find(|(off, _)| *off == utf16_offset)
134        .map(|(_, c)| *c)
135        .filter(|c| *c != '\0')
136}
137
138/// Extract the substring between UTF-16 offsets `[from, to)` from the
139/// chars table. Returns an empty string when ranges are empty or
140/// inverted.
141fn slice_chars(chars: &[(usize, char)], from: usize, to: usize) -> String {
142    if from >= to {
143        return String::new();
144    }
145    chars
146        .iter()
147        .filter(|(off, c)| *c != '\0' && *off >= from && *off < to)
148        .map(|(_, c)| *c)
149        .collect()
150}
151
152/// Find the UTF-16 offset of the last occurrence of `delim delim`
153/// strictly before `end_exclusive`. Returns the offset of the FIRST
154/// delimiter character.
155fn last_double_before(chars: &[(usize, char)], delim: char, end_exclusive: usize) -> Option<usize> {
156    let mut candidate: Option<usize> = None;
157    for window in chars.windows(2) {
158        let (off_a, ca) = window[0];
159        let (_off_b, cb) = window[1];
160        if cb == '\0' {
161            break;
162        }
163        if off_a + 2 > end_exclusive {
164            break;
165        }
166        if ca == delim && cb == delim {
167            candidate = Some(off_a);
168        }
169    }
170    candidate
171}
172
173/// Find the UTF-16 offset of the last single `delim` strictly before
174/// `end_exclusive`, excluding positions that are part of a `delim delim`
175/// pair (e.g. single `*` that's not part of `**`).
176fn last_single_before(chars: &[(usize, char)], delim: char, end_exclusive: usize) -> Option<usize> {
177    let mut best: Option<usize> = None;
178    for i in (0..chars.len().saturating_sub(1)).rev() {
179        let (off, c) = chars[i];
180        if c == '\0' {
181            continue;
182        }
183        if off >= end_exclusive {
184            continue;
185        }
186        if c != delim {
187            continue;
188        }
189        // Check neighbouring chars to reject positions that form `delim delim`.
190        let prev = if i > 0 { Some(chars[i - 1].1) } else { None };
191        let next = chars.get(i + 1).map(|(_, c)| *c);
192        if prev == Some(delim) || next == Some(delim) {
193            continue;
194        }
195        best = Some(off);
196        break;
197    }
198    best
199}
200
201/// Double-delimiter matcher used by bold/strike/underline/highlight.
202/// `require_no_inner_dup`: if true, inner text containing the delimiter
203/// is rejected (matches TS).
204fn match_double(
205    chars: &[(usize, char)],
206    cursor_offset: usize,
207    delim: char,
208    require_no_inner_dup: bool,
209) -> Option<InlineMatch> {
210    let close_idx = cursor_offset.checked_sub(1)?;
211    if char_at(chars, close_idx)? != delim {
212        return None;
213    }
214    if char_at(chars, close_idx.checked_sub(1)?)? != delim {
215        return None;
216    }
217    let search_end = close_idx - 1; // exclusive
218    let open_idx = last_double_before(chars, delim, search_end)?;
219    let inner_start = open_idx + 2;
220    let inner_end = close_idx - 1;
221    if inner_start >= inner_end {
222        return None;
223    }
224    let inner = slice_chars(chars, inner_start, inner_end);
225    if inner.is_empty() || inner.contains('\n') {
226        return None;
227    }
228    if require_no_inner_dup && inner.contains(delim) {
229        return None;
230    }
231    Some(InlineMatch {
232        open_idx,
233        close_idx,
234        inner,
235    })
236}
237
238/// Single-delimiter matcher used by italic (`*` and `_` flavours).
239/// Rejects the closing char when it's the second of a doubled pair.
240fn match_single(chars: &[(usize, char)], cursor_offset: usize, delim: char) -> Option<InlineMatch> {
241    let close_idx = cursor_offset.checked_sub(1)?;
242    if char_at(chars, close_idx)? != delim {
243        return None;
244    }
245    // Skip `**` / `__` signature — the double-delim rule claims these.
246    if let Some(prev_idx) = close_idx.checked_sub(1)
247        && char_at(chars, prev_idx) == Some(delim)
248    {
249        return None;
250    }
251    let open_idx = last_single_before(chars, delim, close_idx)?;
252    let inner_start = open_idx + 1;
253    let inner_end = close_idx;
254    if inner_start >= inner_end {
255        return None;
256    }
257    let inner = slice_chars(chars, inner_start, inner_end);
258    if inner.is_empty() || inner.contains('\n') || inner.contains(delim) {
259        return None;
260    }
261    Some(InlineMatch {
262        open_idx,
263        close_idx,
264        inner,
265    })
266}
267
268/// Backtick code — last-`` ` `` via simple single-delim scan (no
269/// doubling variant). Matches `backtickCodeRule.apply()`.
270fn match_code(chars: &[(usize, char)], cursor_offset: usize) -> Option<InlineMatch> {
271    let close_idx = cursor_offset.checked_sub(1)?;
272    if char_at(chars, close_idx)? != '`' {
273        return None;
274    }
275    // Find the last `` ` `` strictly before close — unlike italic, the
276    // TS rule doesn't skip doubled backticks (no `` `` `` concept here).
277    let open_idx = {
278        let mut best: Option<usize> = None;
279        for i in (0..chars.len().saturating_sub(1)).rev() {
280            let (off, c) = chars[i];
281            if c == '\0' {
282                continue;
283            }
284            if off >= close_idx {
285                continue;
286            }
287            if c == '`' {
288                best = Some(off);
289                break;
290            }
291        }
292        best?
293    };
294    let inner = slice_chars(chars, open_idx + 1, close_idx);
295    if inner.is_empty() || inner.contains('\n') {
296        return None;
297    }
298    Some(InlineMatch {
299        open_idx,
300        close_idx,
301        inner,
302    })
303}
304
305/// `[text](url)` matcher — walks back from `)` to find `](` then `[`.
306fn match_link(chars: &[(usize, char)], cursor_offset: usize) -> Option<LinkMatch> {
307    let close_idx = cursor_offset.checked_sub(1)?;
308    if char_at(chars, close_idx)? != ')' {
309        return None;
310    }
311    // Last `](` strictly before close
312    let mid_idx = {
313        let mut best: Option<usize> = None;
314        for i in (0..chars.len().saturating_sub(1)).rev() {
315            let (off, c) = chars[i];
316            if c == '\0' {
317                continue;
318            }
319            if off >= close_idx {
320                continue;
321            }
322            let next_c = chars.get(i + 1).map(|(_, c)| *c);
323            if c == ']' && next_c == Some('(') {
324                best = Some(off);
325                break;
326            }
327        }
328        best?
329    };
330    // Last `[` strictly before mid
331    let open_idx = {
332        let mut best: Option<usize> = None;
333        for i in (0..chars.len().saturating_sub(1)).rev() {
334            let (off, c) = chars[i];
335            if c == '\0' {
336                continue;
337            }
338            if off >= mid_idx {
339                continue;
340            }
341            if c == '[' {
342                best = Some(off);
343                break;
344            }
345        }
346        best?
347    };
348
349    let inner_text = slice_chars(chars, open_idx + 1, mid_idx);
350    let url = slice_chars(chars, mid_idx + 2, close_idx);
351
352    if inner_text.is_empty() || url.is_empty() {
353        return None;
354    }
355    if inner_text.contains('\n') || url.contains('\n') {
356        return None;
357    }
358    if inner_text.contains('[') || inner_text.contains(']') {
359        return None;
360    }
361    if url.contains('(') || url.contains(')') {
362        return None;
363    }
364
365    Some(LinkMatch {
366        open_idx,
367        close_idx,
368        inner_text,
369        url,
370    })
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    fn inner_of(m: AnyInlineMatch) -> InlineMatch {
378        match m {
379            AnyInlineMatch::Inline(im) => im,
380            AnyInlineMatch::Link(_) => panic!("expected InlineMatch"),
381        }
382    }
383
384    fn link_of(m: AnyInlineMatch) -> LinkMatch {
385        match m {
386            AnyInlineMatch::Link(lm) => lm,
387            AnyInlineMatch::Inline(_) => panic!("expected LinkMatch"),
388        }
389    }
390
391    // ── Bold (`**…**`) ──
392
393    #[test]
394    fn bold_basic() {
395        let m = inner_of(match_inline_mark(MarkKind::Bold, "**hi**", 6).unwrap());
396        assert_eq!(m.open_idx, 0);
397        assert_eq!(m.close_idx, 5);
398        assert_eq!(m.inner, "hi");
399    }
400
401    #[test]
402    fn bold_with_leading_text() {
403        let m = inner_of(match_inline_mark(MarkKind::Bold, "abc **yo**", 10).unwrap());
404        assert_eq!(m.open_idx, 4);
405        assert_eq!(m.inner, "yo");
406    }
407
408    #[test]
409    fn bold_rejects_empty_inner() {
410        assert!(match_inline_mark(MarkKind::Bold, "****", 4).is_none());
411    }
412
413    #[test]
414    fn bold_rejects_inner_with_asterisk() {
415        assert!(match_inline_mark(MarkKind::Bold, "***foo***", 9).is_none());
416    }
417
418    #[test]
419    fn bold_rejects_multiline() {
420        assert!(match_inline_mark(MarkKind::Bold, "**a\nb**", 7).is_none());
421    }
422
423    // ── Italic asterisk (`*…*`) ──
424
425    #[test]
426    fn italic_asterisk_basic() {
427        let m = inner_of(match_inline_mark(MarkKind::ItalicAsterisk, "*x*", 3).unwrap());
428        assert_eq!(m.inner, "x");
429    }
430
431    #[test]
432    fn italic_asterisk_rejects_double_signature() {
433        // Final char is `*` but prev is also `*` → bold territory, skip.
434        assert!(match_inline_mark(MarkKind::ItalicAsterisk, "**hi**", 6).is_none());
435    }
436
437    #[test]
438    fn italic_asterisk_skips_double_on_opening_search() {
439        // The `**` pair should be skipped, so only the standalone
440        // `*` after can open italic.
441        let m = inner_of(
442            match_inline_mark(MarkKind::ItalicAsterisk, "**bold** and *italic*", 21).unwrap(),
443        );
444        assert_eq!(m.inner, "italic");
445    }
446
447    // ── Italic underscore (`_…_`) ──
448
449    #[test]
450    fn italic_underscore_basic() {
451        let m = inner_of(match_inline_mark(MarkKind::ItalicUnderscore, "_x_", 3).unwrap());
452        assert_eq!(m.inner, "x");
453    }
454
455    #[test]
456    fn italic_underscore_rejects_double_signature() {
457        assert!(match_inline_mark(MarkKind::ItalicUnderscore, "__hi__", 6).is_none());
458    }
459
460    // ── Strike (`~~…~~`) ──
461
462    #[test]
463    fn strike_basic() {
464        let m = inner_of(match_inline_mark(MarkKind::Strike, "~~x~~", 5).unwrap());
465        assert_eq!(m.inner, "x");
466    }
467
468    #[test]
469    fn strike_rejects_empty() {
470        assert!(match_inline_mark(MarkKind::Strike, "~~~~", 4).is_none());
471    }
472
473    // ── Underline (`__…__`) ──
474
475    #[test]
476    fn underline_basic() {
477        let m = inner_of(match_inline_mark(MarkKind::Underline, "__x__", 5).unwrap());
478        assert_eq!(m.inner, "x");
479    }
480
481    // ── Highlight (`==…==`) ──
482
483    #[test]
484    fn highlight_basic() {
485        let m = inner_of(match_inline_mark(MarkKind::Highlight, "==yo==", 6).unwrap());
486        assert_eq!(m.inner, "yo");
487    }
488
489    // ── Code (`` `…` ``) ──
490
491    #[test]
492    fn code_basic() {
493        let m = inner_of(match_inline_mark(MarkKind::Code, "`foo`", 5).unwrap());
494        assert_eq!(m.inner, "foo");
495    }
496
497    #[test]
498    fn code_rejects_empty() {
499        assert!(match_inline_mark(MarkKind::Code, "``", 2).is_none());
500    }
501
502    // ── Link (`[text](url)`) ──
503
504    #[test]
505    fn link_basic() {
506        let m =
507            link_of(match_inline_mark(MarkKind::Link, "[devup](https://devup.kr)", 25).unwrap());
508        assert_eq!(m.inner_text, "devup");
509        assert_eq!(m.url, "https://devup.kr");
510    }
511
512    #[test]
513    fn link_rejects_empty_text_or_url() {
514        assert!(match_inline_mark(MarkKind::Link, "[](url)", 7).is_none());
515        assert!(match_inline_mark(MarkKind::Link, "[text]()", 8).is_none());
516    }
517
518    #[test]
519    fn link_rejects_nested_brackets() {
520        assert!(match_inline_mark(MarkKind::Link, "[te[x]t](url)", 13).is_none());
521    }
522
523    #[test]
524    fn link_rejects_nested_parens_in_url() {
525        assert!(match_inline_mark(MarkKind::Link, "[t](u(r)l)", 10).is_none());
526    }
527
528    // ── Cross-cutting ──
529
530    #[test]
531    fn all_reject_when_cursor_zero() {
532        assert!(match_inline_mark(MarkKind::Bold, "", 0).is_none());
533        assert!(match_inline_mark(MarkKind::Code, "`", 0).is_none());
534        assert!(match_inline_mark(MarkKind::Link, ")", 0).is_none());
535    }
536
537    #[test]
538    fn utf16_offsets_respected_with_surrogate_pair() {
539        // "😀" is 2 UTF-16 units, so "*😀*" has cursor at 4.
540        let m = inner_of(match_inline_mark(MarkKind::ItalicAsterisk, "*😀*", 4).unwrap());
541        assert_eq!(m.inner, "😀");
542        assert_eq!(m.open_idx, 0);
543        assert_eq!(m.close_idx, 3);
544    }
545}