devup-editor-input-rules 1.0.23

Markdown shortcut + special-char + inline-mark matchers for devup-editor's input-rule pipeline
Documentation
//! Special-character replacement matcher.
//!
//! Mirrors `SPECIAL_CHAR_MAP` + `SPECIAL_CHAR_TRIGGERS` +
//! `specialCharReplaceRule.apply()` from the React plugin. Triggers
//! are sorted by length descending so longest match wins (e.g. `<=>`
//! beats `<=`).

use std::sync::OnceLock;

use serde::{Deserialize, Serialize};

/// Matched trigger and its replacement, ready for the React side to
/// dispatch `setSelection(start..start+trigger_len)` + `Backspace` +
/// `InsertText(replacement)`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SpecialCharMatch {
    /// UTF-16 offset where the matched trigger starts in `block_text`.
    pub start: usize,
    /// UTF-16 length of the trigger (the range selected by
    /// `setSelection`).
    #[serde(rename = "triggerLen")]
    pub trigger_len: usize,
    /// Replacement string to emit via `InsertText`.
    pub replacement: String,
}

/// Build the `(trigger, replacement)` table. Ordered by trigger length
/// descending so the first hit wins — matches `SPECIAL_CHAR_TRIGGERS`
/// in the TS source.
fn entries() -> &'static [(&'static str, &'static str)] {
    static TABLE: OnceLock<Vec<(&'static str, &'static str)>> = OnceLock::new();
    TABLE.get_or_init(|| {
        let mut out: Vec<(&'static str, &'static str)> = Vec::new();

        // Circled numbers 1..=20 — (1) → ① = U+2460, (2) → ② = U+2461, …
        // We need owned strings here because the char ranges are built
        // at runtime. Leak them so the entries can hold `'static`
        // references (cheap: 102 strings total, ~2 KB).
        for i in 1u32..=20 {
            let trig = Box::leak(format!("({i})").into_boxed_str()) as &str;
            let ch = char::from_u32(0x2460 + i - 1).unwrap();
            let rep = Box::leak(ch.to_string().into_boxed_str()) as &str;
            out.push((trig, rep));
        }
        // Circled lowercase letters a..=z — (a) → ⓐ = U+24D0, (b) → ⓑ = U+24D1
        for i in 0u32..26 {
            let c = char::from_u32(u32::from(b'a') + i).unwrap();
            let trig = Box::leak(format!("({c})").into_boxed_str()) as &str;
            let ch = char::from_u32(0x24d0 + i).unwrap();
            let rep = Box::leak(ch.to_string().into_boxed_str()) as &str;
            out.push((trig, rep));
        }
        // Circled uppercase letters A..=Z — (A) → Ⓐ = U+24B6, …
        for i in 0u32..26 {
            let c = char::from_u32(u32::from(b'A') + i).unwrap();
            let trig = Box::leak(format!("({c})").into_boxed_str()) as &str;
            let ch = char::from_u32(0x24b6 + i).unwrap();
            let rep = Box::leak(ch.to_string().into_boxed_str()) as &str;
            out.push((trig, rep));
        }

        // Static extras in exactly the same order as the TS `Object.assign`
        // call so longest-first sorting picks the same winner on ties.
        let extras: &[(&str, &str)] = &[
            ("(주)", "㈜"),
            ("(c)", "©"),
            ("(C)", "©"),
            ("(r)", "®"),
            ("(R)", "®"),
            ("(tm)", "™"),
            ("(TM)", "™"),
            ("->", "→"),
            ("<-", "←"),
            ("=>", "⇒"),
            ("←>", "↔"),
            ("←-", "⟵"),
            ("<=>", "⇔"),
            ("-->", "⟶"),
            ("+-", "±"),
            ("-+", "∓"),
            // NOTE: '~~' is intentionally OMITTED — reserved for
            // strikeTildeRule. Re-adding it would swallow the second `~`
            // before strike could match.
            ("~=", "≅"),
            ("!=", "≠"),
            (".=.", "≒"),
            ("...", "…"),
            ("<<", "«"),
            (">>", "»"),
        ];
        out.extend_from_slice(extras);

        // De-dup in "last write wins" order so the static `extras` block
        // overrides any duplicate key produced by the generated loop —
        // matches the TS `Object.assign(map, extras)` semantics (e.g.
        // `(c)` must map to © not ⓒ).
        let mut seen = std::collections::HashSet::new();
        let mut dedup: Vec<(&'static str, &'static str)> = Vec::with_capacity(out.len());
        for entry in out.into_iter().rev() {
            if seen.insert(entry.0) {
                dedup.push(entry);
            }
        }
        dedup.reverse();

        // Sort by trigger length descending — matches the TS
        // `Object.keys(...).sort((a, b) => b.length - a.length)`.
        dedup.sort_by_key(|entry| std::cmp::Reverse(entry.0.len()));
        dedup
    })
}

/// Scan `block_text[..cursor_offset]` for a trailing trigger and
/// return the replacement info, or `None` when nothing matched.
///
/// Offsets are UTF-16 code units to match the React engine selection
/// contract.
#[must_use]
pub fn match_special_char(block_text: &str, cursor_offset: usize) -> Option<SpecialCharMatch> {
    let (byte_end, utf16_len) = resolve_utf16(block_text, cursor_offset);
    let prefix = &block_text[..byte_end];
    for (trigger, replacement) in entries() {
        let trig_utf16 = utf16_chars_len(trigger);
        if trig_utf16 > utf16_len {
            continue;
        }
        if !prefix.ends_with(trigger) {
            continue;
        }
        return Some(SpecialCharMatch {
            start: utf16_len - trig_utf16,
            trigger_len: trig_utf16,
            replacement: (*replacement).to_string(),
        });
    }
    None
}

fn utf16_chars_len(s: &str) -> usize {
    devup_editor_core::model::inline::utf16_len(s)
}

/// Find the byte index corresponding to `cursor_offset` UTF-16 code
/// units in `s`, plus the actual UTF-16 length consumed (which may
/// clip short if `s` is shorter than requested).
///
/// Thin composition of the shared UTF-16 helpers in
/// `devup-editor-core`; the previous open-coded loop was a byte-for-
/// byte duplicate of [`utf16_to_byte`](devup_editor_core::model::inline::utf16_to_byte).
fn resolve_utf16(s: &str, cursor_offset: usize) -> (usize, usize) {
    use devup_editor_core::model::inline::{utf16_len, utf16_to_byte};
    let total = utf16_len(s);
    let clamped = cursor_offset.min(total);
    (utf16_to_byte(s, clamped), clamped)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn expect_match(text: &str, cursor: usize, replacement: &str) {
        let m = match_special_char(text, cursor)
            .unwrap_or_else(|| panic!("no match for {text:?} @ {cursor}"));
        assert_eq!(m.replacement, replacement, "for {text:?}");
    }

    #[test]
    fn circled_numbers_1_to_20() {
        expect_match("(1)", 3, "①");
        expect_match("(7)", 3, "⑦");
        expect_match("(20)", 4, "⑳");
    }

    #[test]
    fn circled_latin_lower_and_upper() {
        expect_match("(a)", 3, "ⓐ");
        expect_match("(z)", 3, "ⓩ");
        expect_match("(A)", 3, "Ⓐ");
        expect_match("(Z)", 3, "Ⓩ");
    }

    #[test]
    fn korean_trademark_copyright() {
        expect_match("(주)", 4, "㈜"); // 주 = 2 UTF-16 units
        expect_match("(c)", 3, "©");
        expect_match("(C)", 3, "©");
        expect_match("(r)", 3, "®");
        expect_match("(tm)", 4, "™");
        expect_match("(TM)", 4, "™");
    }

    #[test]
    fn arrows_math_quotes() {
        expect_match("->", 2, "→");
        expect_match("=>", 2, "⇒");
        expect_match("-->", 3, "⟶");
        expect_match("<=>", 3, "⇔");
        expect_match("!=", 2, "≠");
        expect_match("+-", 2, "±");
        expect_match("...", 3, "…");
        expect_match(".=.", 3, "≒");
        expect_match("<<", 2, "«");
        expect_match(">>", 2, "»");
    }

    #[test]
    fn longest_match_wins() {
        // "<=>" must beat "<=" — sort-by-length guarantees that.
        // (We don't have "<=" in the table, but test the longest-first
        // principle with "-->" vs "->".)
        expect_match("-->", 3, "⟶");
        // And "->" alone still works
        expect_match("->", 2, "→");
    }

    #[test]
    fn tilde_equals_reserved_for_strike() {
        // `~~` is intentionally NOT in the map — strikeTildeRule owns it.
        assert!(match_special_char("~~", 2).is_none());
    }

    #[test]
    fn no_match_without_trigger_suffix() {
        assert!(match_special_char("hello", 5).is_none());
        assert!(match_special_char("(99)", 4).is_none()); // out of range
        assert!(match_special_char("(aa)", 4).is_none()); // not a single letter
    }

    #[test]
    fn cursor_mid_text_matches_just_the_prefix() {
        // `(1)extra` with cursor at 3 should still match `(1)` → ①.
        expect_match("(1)extra", 3, "①");
    }

    #[test]
    fn start_and_trigger_len_are_correct() {
        let m = match_special_char("abc->", 5).unwrap();
        assert_eq!(m.start, 3);
        assert_eq!(m.trigger_len, 2);
        assert_eq!(m.replacement, "→");
    }
}