devup_editor_input_rules/
special_char.rs1use std::sync::OnceLock;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct SpecialCharMatch {
17 pub start: usize,
19 #[serde(rename = "triggerLen")]
22 pub trigger_len: usize,
23 pub replacement: String,
25}
26
27fn 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 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 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 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 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 ("~=", "≅"),
85 ("!=", "≠"),
86 (".=.", "≒"),
87 ("...", "…"),
88 ("<<", "«"),
89 (">>", "»"),
90 ];
91 out.extend_from_slice(extras);
92
93 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 dedup.sort_by_key(|entry| std::cmp::Reverse(entry.0.len()));
109 dedup
110 })
111}
112
113#[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
143fn 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, "㈜"); 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 expect_match("-->", 3, "⟶");
212 expect_match("->", 2, "→");
214 }
215
216 #[test]
217 fn tilde_equals_reserved_for_strike() {
218 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()); assert!(match_special_char("(aa)", 4).is_none()); }
228
229 #[test]
230 fn cursor_mid_text_matches_just_the_prefix() {
231 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}