Skip to main content

siglus_scene_vm/runtime/
string_semantics.rs

1//! String operations with the same indexing model as the original Windows
2//! `TSTR` implementation.
3//!
4//! Siglus uses `std::basic_string<TCHAR>` and indexes strings by UTF-16 code
5//! units in its Unicode build. Rust strings are UTF-8, so byte offsets and
6//! Unicode-scalar indexes are not interchangeable with script-visible TSTR
7//! indexes.
8
9#[inline]
10pub fn ascii_lower(s: &str) -> String {
11    s.chars().map(|c| c.to_ascii_lowercase()).collect()
12}
13
14#[inline]
15pub fn ascii_upper(s: &str) -> String {
16    s.chars().map(|c| c.to_ascii_uppercase()).collect()
17}
18
19#[inline]
20pub fn utf16_len(s: &str) -> usize {
21    s.encode_utf16().count()
22}
23
24pub fn utf16_slice(s: &str, start: usize, len: Option<usize>) -> String {
25    let units: Vec<u16> = s.encode_utf16().collect();
26    let start = start.min(units.len());
27    let end = len
28        .map(|n| start.saturating_add(n).min(units.len()))
29        .unwrap_or(units.len());
30    String::from_utf16_lossy(&units[start..end])
31}
32
33pub fn utf16_left(s: &str, len: usize) -> String {
34    utf16_slice(s, 0, Some(len))
35}
36
37pub fn utf16_right(s: &str, len: usize) -> String {
38    let total = utf16_len(s);
39    utf16_slice(s, total.saturating_sub(len), None)
40}
41
42pub fn utf16_code_unit(s: &str, index: usize) -> Option<u16> {
43    s.encode_utf16().nth(index)
44}
45
46fn utf16_index_for_byte_offset(s: &str, byte_offset: usize) -> usize {
47    s.get(..byte_offset)
48        .map(utf16_len)
49        .unwrap_or_else(|| utf16_len(s))
50}
51
52pub fn search_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
53    // tona3's str_to_lower() only folds ASCII A-Z, so this transformation is
54    // length preserving in UTF-8 as well as UTF-16.
55    let hay = ascii_lower(haystack);
56    let needle = ascii_lower(needle);
57    hay.find(&needle)
58        .map(|byte_offset| utf16_index_for_byte_offset(&hay, byte_offset))
59}
60
61pub fn rsearch_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
62    let hay = ascii_lower(haystack);
63    let needle = ascii_lower(needle);
64    hay.rfind(&needle)
65        .map(|byte_offset| utf16_index_for_byte_offset(&hay, byte_offset))
66}
67
68#[inline]
69pub fn is_hankaku(ch: char) -> bool {
70    // tona3 classifies characters by their CP932 multibyte width. These are
71    // the script-relevant single-column ranges: ASCII and halfwidth katakana.
72    ch.is_ascii() || matches!(ch as u32, 0x00A5 | 0x203E | 0xFF61..=0xFF9F)
73}
74
75#[inline]
76pub fn display_width_char(ch: char) -> usize {
77    if is_hankaku(ch) { 1 } else { 2 }
78}
79
80pub fn display_width(s: &str) -> usize {
81    s.chars().map(display_width_char).sum()
82}
83
84pub fn left_by_display_width(s: &str, limit: usize) -> String {
85    let mut width = 0usize;
86    let mut out = String::new();
87    for ch in s.chars() {
88        let w = display_width_char(ch);
89        if width + w > limit {
90            break;
91        }
92        width += w;
93        out.push(ch);
94    }
95    out
96}
97
98pub fn right_by_display_width(s: &str, limit: usize) -> String {
99    let mut width = 0usize;
100    let mut out = Vec::new();
101    for ch in s.chars().rev() {
102        let w = display_width_char(ch);
103        if width + w > limit {
104            break;
105        }
106        width += w;
107        out.push(ch);
108    }
109    out.into_iter().rev().collect()
110}
111
112pub fn mid_by_display_width(s: &str, start_width: usize, len_width: Option<usize>) -> String {
113    let mut width = 0usize;
114    let mut out = String::new();
115    let end_width = len_width.map(|len| start_width.saturating_add(len));
116    for ch in s.chars() {
117        let w = display_width_char(ch);
118        if width >= start_width {
119            width = width.saturating_add(w);
120            if end_width.is_some_and(|end| width > end) {
121                break;
122            }
123            out.push(ch);
124        } else {
125            width = width.saturating_add(w);
126        }
127    }
128    out
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn utf16_script_indexes_are_not_utf8_offsets() {
137        assert_eq!(search_ascii_case_insensitive("あいう", "う"), Some(2));
138        assert_eq!(utf16_len("A😀B"), 4);
139        assert_eq!(utf16_code_unit("A😀B", 1), Some(0xD83D));
140        assert_eq!(utf16_code_unit("A😀B", 2), Some(0xDE00));
141    }
142
143    #[test]
144    fn halfwidth_katakana_counts_as_one_column() {
145        assert_eq!(display_width("Aアあ"), 4);
146    }
147
148    #[test]
149    fn mid_len_uses_absolute_display_positions_like_tstr() {
150        assert_eq!(mid_by_display_width("あいう", 1, Some(2)), "");
151        assert_eq!(mid_by_display_width("あいう", 2, Some(2)), "い");
152    }
153}