Skip to main content

devup_editor_input_rules/
special_char.rs

1//! Special-character replacement matcher.
2//!
3//! Mirrors `SPECIAL_CHAR_MAP` + `SPECIAL_CHAR_TRIGGERS` +
4//! `specialCharReplaceRule.apply()` from the React plugin. Triggers
5//! are sorted by length descending so longest match wins (e.g. `<=>`
6//! beats `<=`).
7
8use std::sync::OnceLock;
9
10use serde::{Deserialize, Serialize};
11
12/// Matched trigger and its replacement, ready for the React side to
13/// dispatch `setSelection(start..start+trigger_len)` + `Backspace` +
14/// `InsertText(replacement)`.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct SpecialCharMatch {
17    /// UTF-16 offset where the matched trigger starts in `block_text`.
18    pub start: usize,
19    /// UTF-16 length of the trigger (the range selected by
20    /// `setSelection`).
21    #[serde(rename = "triggerLen")]
22    pub trigger_len: usize,
23    /// Replacement string to emit via `InsertText`.
24    pub replacement: String,
25}
26
27/// Build the `(trigger, replacement)` table. Ordered by trigger length
28/// descending so the first hit wins — matches `SPECIAL_CHAR_TRIGGERS`
29/// in the TS source.
30fn entries() -> &'static [(&'static str, &'static str)] {
31    static TABLE: OnceLock<Vec<(&'static str, &'static str)>> = OnceLock::new();
32    TABLE.get_or_init(|| {
33        let mut out: Vec<(&'static str, &'static str)> = Vec::new();
34
35        // Circled numbers 1..=20 — (1) → ① = U+2460, (2) → ② = U+2461, …
36        // We need owned strings here because the char ranges are built
37        // at runtime. Leak them so the entries can hold `'static`
38        // references (cheap: 102 strings total, ~2 KB).
39        for i in 1u32..=20 {
40            let trig = Box::leak(format!("({i})").into_boxed_str()) as &str;
41            let ch = char::from_u32(0x2460 + i - 1).unwrap();
42            let rep = Box::leak(ch.to_string().into_boxed_str()) as &str;
43            out.push((trig, rep));
44        }
45        // Circled lowercase letters a..=z — (a) → ⓐ = U+24D0, (b) → ⓑ = U+24D1
46        for i in 0u32..26 {
47            let c = char::from_u32(u32::from(b'a') + i).unwrap();
48            let trig = Box::leak(format!("({c})").into_boxed_str()) as &str;
49            let ch = char::from_u32(0x24d0 + i).unwrap();
50            let rep = Box::leak(ch.to_string().into_boxed_str()) as &str;
51            out.push((trig, rep));
52        }
53        // Circled uppercase letters A..=Z — (A) → Ⓐ = U+24B6, …
54        for i in 0u32..26 {
55            let c = char::from_u32(u32::from(b'A') + i).unwrap();
56            let trig = Box::leak(format!("({c})").into_boxed_str()) as &str;
57            let ch = char::from_u32(0x24b6 + i).unwrap();
58            let rep = Box::leak(ch.to_string().into_boxed_str()) as &str;
59            out.push((trig, rep));
60        }
61
62        // Static extras in exactly the same order as the TS `Object.assign`
63        // call so longest-first sorting picks the same winner on ties.
64        let extras: &[(&str, &str)] = &[
65            ("(주)", "㈜"),
66            ("(c)", "©"),
67            ("(C)", "©"),
68            ("(r)", "®"),
69            ("(R)", "®"),
70            ("(tm)", "™"),
71            ("(TM)", "™"),
72            ("->", "→"),
73            ("<-", "←"),
74            ("=>", "⇒"),
75            ("←>", "↔"),
76            ("←-", "⟵"),
77            ("<=>", "⇔"),
78            ("-->", "⟶"),
79            ("+-", "±"),
80            ("-+", "∓"),
81            // NOTE: '~~' is intentionally OMITTED — reserved for
82            // strikeTildeRule. Re-adding it would swallow the second `~`
83            // before strike could match.
84            ("~=", "≅"),
85            ("!=", "≠"),
86            (".=.", "≒"),
87            ("...", "…"),
88            ("<<", "«"),
89            (">>", "»"),
90        ];
91        out.extend_from_slice(extras);
92
93        // De-dup in "last write wins" order so the static `extras` block
94        // overrides any duplicate key produced by the generated loop —
95        // matches the TS `Object.assign(map, extras)` semantics (e.g.
96        // `(c)` must map to © not ⓒ).
97        let mut seen = std::collections::HashSet::new();
98        let mut dedup: Vec<(&'static str, &'static str)> = Vec::with_capacity(out.len());
99        for entry in out.into_iter().rev() {
100            if seen.insert(entry.0) {
101                dedup.push(entry);
102            }
103        }
104        dedup.reverse();
105
106        // Sort by trigger length descending — matches the TS
107        // `Object.keys(...).sort((a, b) => b.length - a.length)`.
108        dedup.sort_by_key(|entry| std::cmp::Reverse(entry.0.len()));
109        dedup
110    })
111}
112
113/// Scan `block_text[..cursor_offset]` for a trailing trigger and
114/// return the replacement info, or `None` when nothing matched.
115///
116/// Offsets are UTF-16 code units to match the React engine selection
117/// contract.
118#[must_use]
119pub fn match_special_char(block_text: &str, cursor_offset: usize) -> Option<SpecialCharMatch> {
120    let (byte_end, utf16_len) = resolve_utf16(block_text, cursor_offset);
121    let prefix = &block_text[..byte_end];
122    for (trigger, replacement) in entries() {
123        let trig_utf16 = utf16_chars_len(trigger);
124        if trig_utf16 > utf16_len {
125            continue;
126        }
127        if !prefix.ends_with(trigger) {
128            continue;
129        }
130        return Some(SpecialCharMatch {
131            start: utf16_len - trig_utf16,
132            trigger_len: trig_utf16,
133            replacement: (*replacement).to_string(),
134        });
135    }
136    None
137}
138
139fn utf16_chars_len(s: &str) -> usize {
140    devup_editor_core::model::inline::utf16_len(s)
141}
142
143/// Find the byte index corresponding to `cursor_offset` UTF-16 code
144/// units in `s`, plus the actual UTF-16 length consumed (which may
145/// clip short if `s` is shorter than requested).
146///
147/// Thin composition of the shared UTF-16 helpers in
148/// `devup-editor-core`; the previous open-coded loop was a byte-for-
149/// byte duplicate of [`utf16_to_byte`](devup_editor_core::model::inline::utf16_to_byte).
150fn resolve_utf16(s: &str, cursor_offset: usize) -> (usize, usize) {
151    use devup_editor_core::model::inline::{utf16_len, utf16_to_byte};
152    let total = utf16_len(s);
153    let clamped = cursor_offset.min(total);
154    (utf16_to_byte(s, clamped), clamped)
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    fn expect_match(text: &str, cursor: usize, replacement: &str) {
162        let m = match_special_char(text, cursor)
163            .unwrap_or_else(|| panic!("no match for {text:?} @ {cursor}"));
164        assert_eq!(m.replacement, replacement, "for {text:?}");
165    }
166
167    #[test]
168    fn circled_numbers_1_to_20() {
169        expect_match("(1)", 3, "①");
170        expect_match("(7)", 3, "⑦");
171        expect_match("(20)", 4, "⑳");
172    }
173
174    #[test]
175    fn circled_latin_lower_and_upper() {
176        expect_match("(a)", 3, "ⓐ");
177        expect_match("(z)", 3, "ⓩ");
178        expect_match("(A)", 3, "Ⓐ");
179        expect_match("(Z)", 3, "Ⓩ");
180    }
181
182    #[test]
183    fn korean_trademark_copyright() {
184        expect_match("(주)", 4, "㈜"); // 주 = 2 UTF-16 units
185        expect_match("(c)", 3, "©");
186        expect_match("(C)", 3, "©");
187        expect_match("(r)", 3, "®");
188        expect_match("(tm)", 4, "™");
189        expect_match("(TM)", 4, "™");
190    }
191
192    #[test]
193    fn arrows_math_quotes() {
194        expect_match("->", 2, "→");
195        expect_match("=>", 2, "⇒");
196        expect_match("-->", 3, "⟶");
197        expect_match("<=>", 3, "⇔");
198        expect_match("!=", 2, "≠");
199        expect_match("+-", 2, "±");
200        expect_match("...", 3, "…");
201        expect_match(".=.", 3, "≒");
202        expect_match("<<", 2, "«");
203        expect_match(">>", 2, "»");
204    }
205
206    #[test]
207    fn longest_match_wins() {
208        // "<=>" must beat "<=" — sort-by-length guarantees that.
209        // (We don't have "<=" in the table, but test the longest-first
210        // principle with "-->" vs "->".)
211        expect_match("-->", 3, "⟶");
212        // And "->" alone still works
213        expect_match("->", 2, "→");
214    }
215
216    #[test]
217    fn tilde_equals_reserved_for_strike() {
218        // `~~` is intentionally NOT in the map — strikeTildeRule owns it.
219        assert!(match_special_char("~~", 2).is_none());
220    }
221
222    #[test]
223    fn no_match_without_trigger_suffix() {
224        assert!(match_special_char("hello", 5).is_none());
225        assert!(match_special_char("(99)", 4).is_none()); // out of range
226        assert!(match_special_char("(aa)", 4).is_none()); // not a single letter
227    }
228
229    #[test]
230    fn cursor_mid_text_matches_just_the_prefix() {
231        // `(1)extra` with cursor at 3 should still match `(1)` → ①.
232        expect_match("(1)extra", 3, "①");
233    }
234
235    #[test]
236    fn start_and_trigger_len_are_correct() {
237        let m = match_special_char("abc->", 5).unwrap();
238        assert_eq!(m.start, 3);
239        assert_eq!(m.trigger_len, 2);
240        assert_eq!(m.replacement, "→");
241    }
242}