use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SpecialCharMatch {
pub start: usize,
#[serde(rename = "triggerLen")]
pub trigger_len: usize,
pub replacement: String,
}
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();
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));
}
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));
}
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));
}
let extras: &[(&str, &str)] = &[
("(주)", "㈜"),
("(c)", "©"),
("(C)", "©"),
("(r)", "®"),
("(R)", "®"),
("(tm)", "™"),
("(TM)", "™"),
("->", "→"),
("<-", "←"),
("=>", "⇒"),
("←>", "↔"),
("←-", "⟵"),
("<=>", "⇔"),
("-->", "⟶"),
("+-", "±"),
("-+", "∓"),
("~=", "≅"),
("!=", "≠"),
(".=.", "≒"),
("...", "…"),
("<<", "«"),
(">>", "»"),
];
out.extend_from_slice(extras);
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();
dedup.sort_by_key(|entry| std::cmp::Reverse(entry.0.len()));
dedup
})
}
#[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)
}
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, "㈜"); 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() {
expect_match("-->", 3, "⟶");
expect_match("->", 2, "→");
}
#[test]
fn tilde_equals_reserved_for_strike() {
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()); assert!(match_special_char("(aa)", 4).is_none()); }
#[test]
fn cursor_mid_text_matches_just_the_prefix() {
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, "→");
}
}