Skip to main content

interscript_rs/
stdlib.rs

1//! `Interscript::Stdlib` parity: alias fragments, boundary semantics
2//! and the stage-level functions (`upcase`, `separate`, `compose`, ...).
3
4use crate::error::{Error, Result};
5use unicode_normalization::UnicodeNormalization;
6
7/// Zero-width and character-class primitives reachable from the ISC
8/// grammar (`zw_primitive` rule) plus the Stdlib aliases they resolve
9/// to. Values mirror `Interscript::Stdlib::ALIASES`.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Prim {
12    /// \b — Unicode-aware word boundary (Ruby \b is Unicode; \w is not).
13    Boundary,
14    /// \B
15    NonWordBoundary,
16    /// ^ — beginning of line.
17    LineStart,
18    /// $ — end of line.
19    LineEnd,
20    /// " " — a literal space (not re-only).
21    Space,
22    /// "none" — empty string (not re-only).
23    None_,
24    /// any_character — Ruby regex dot: any char except newline.
25    AnyCharacter,
26    /// \w — ASCII-only [A-Za-z0-9_].
27    Word,
28    /// \W
29    NotWord,
30    /// [a-zA-Z]
31    Alpha,
32    /// [^a-zA-Z]
33    NotAlpha,
34    /// \d — ASCII-only [0-9].
35    Digit,
36    /// \D
37    NotDigit,
38}
39
40impl Prim {
41    pub fn from_name(name: &str) -> Option<Prim> {
42        Some(match name {
43            "boundary" => Prim::Boundary,
44            "non_boundary" | "non_word_boundary" => Prim::NonWordBoundary,
45            "line_start" => Prim::LineStart,
46            "line_end" => Prim::LineEnd,
47            "space" => Prim::Space,
48            "none" => Prim::None_,
49            "any_character" => Prim::AnyCharacter,
50            "word" => Prim::Word,
51            "not_word" => Prim::NotWord,
52            "alpha" => Prim::Alpha,
53            "not_alpha" => Prim::NotAlpha,
54            "digit" => Prim::Digit,
55            "not_digit" => Prim::NotDigit,
56            _ => return None,
57        })
58    }
59
60    pub fn name(&self) -> &'static str {
61        match self {
62            Prim::Boundary => "boundary",
63            Prim::NonWordBoundary => "non_word_boundary",
64            Prim::LineStart => "line_start",
65            Prim::LineEnd => "line_end",
66            Prim::Space => "space",
67            Prim::None_ => "none",
68            Prim::AnyCharacter => "any_character",
69            Prim::Word => "word",
70            Prim::NotWord => "not_word",
71            Prim::Alpha => "alpha",
72            Prim::NotAlpha => "not_alpha",
73            Prim::Digit => "digit",
74            Prim::NotDigit => "not_digit",
75        }
76    }
77
78    /// `Interscript::Stdlib.re_only_alias?` — can the primitive appear
79    /// in a string / parallel (replacement) context? Only none/space.
80    pub fn re_only(&self) -> bool {
81        !matches!(self, Prim::None_ | Prim::Space)
82    }
83
84    /// Match as a character class at `ch`. `None` = zero-width (not a
85    /// char matcher) or not applicable.
86    pub fn matches_char(&self, ch: char) -> Option<bool> {
87        Some(match self {
88            Prim::Space => ch == ' ',
89            Prim::None_ => false, // zero-width, handled before
90            Prim::AnyCharacter => ch != '\n',
91            Prim::Word => ch.is_ascii_alphanumeric() || ch == '_',
92            Prim::NotWord => !(ch.is_ascii_alphanumeric() || ch == '_'),
93            Prim::Alpha => ch.is_ascii_alphabetic(),
94            Prim::NotAlpha => !ch.is_ascii_alphabetic(),
95            Prim::Digit => ch.is_ascii_digit(),
96            Prim::NotDigit => !ch.is_ascii_digit(),
97            Prim::Boundary | Prim::NonWordBoundary | Prim::LineStart | Prim::LineEnd => return None,
98        })
99    }
100
101    /// Zero-width assertions return tri-state: Some(true/false) decided
102    /// at a position, None when the primitive is a char matcher.
103    pub fn matches_zero_width(&self, chars: &[char], pos: usize) -> Option<bool> {
104        Some(match self {
105            Prim::Boundary => is_word_boundary(chars, pos),
106            Prim::NonWordBoundary => !is_word_boundary(chars, pos),
107            Prim::LineStart => pos == 0 || chars[pos - 1] == '\n',
108            Prim::LineEnd => pos == chars.len() || chars[pos] == '\n',
109            _ => return None,
110        })
111    }
112}
113
114/// Ruby `\b`: Unicode-aware. A position is a boundary when one side is
115/// a word char (Unicode alphanumeric or underscore) and the other is
116/// not (or is edge-of-string).
117fn is_word_boundary(chars: &[char], pos: usize) -> bool {
118    let before = pos > 0 && is_ruby_word_char(chars[pos - 1]);
119    let after = pos < chars.len() && is_ruby_word_char(chars[pos]);
120    before != after
121}
122
123/// Onigmo's UTF-8 word-char definition (what Ruby `\b` and `\B`
124/// consult — NOT `\w`, which is ASCII-only): the Unicode Word
125/// property = Alphabetic (L* + Nl) | Mark (Mn/Mc/Me) | Nd | Pc,
126/// PLUS the explicit ZWNJ/ZWJ additions (U+200C, U+200D) Onigmo
127/// special-cases — verified empirically against Ruby (a
128/// letter+ZWNJ pair is NOT a boundary). `std` exposes no general
129/// categories, hence unicode-general-category.
130pub fn is_ruby_word_char(ch: char) -> bool {
131    if matches!(ch, '\u{200C}' | '\u{200D}') {
132        return true;
133    }
134    use unicode_general_category::{get_general_category, GeneralCategory as Gc};
135    matches!(
136        get_general_category(ch),
137        Gc::UppercaseLetter
138            | Gc::LowercaseLetter
139            | Gc::TitlecaseLetter
140            | Gc::ModifierLetter
141            | Gc::OtherLetter
142            | Gc::LetterNumber
143            | Gc::DecimalNumber
144            | Gc::NonspacingMark
145            | Gc::SpacingMark
146            | Gc::EnclosingMark
147            | Gc::ConnectorPunctuation
148    )
149}
150
151/// Stage-level function calls (`rule(:string_case_directive)`,
152/// `separate_directive`, ...). `to`-side `upcase`/`downcase` in sub
153/// rules are handled by the matcher, not here.
154#[derive(Debug, Clone)]
155pub struct Funcall {
156    pub name: FuncallName,
157    pub kwargs: Vec<(String, String)>,
158    /// Ruby `reverse_run:` (skipped forward / run reversed).
159    pub reverse_run: Option<bool>,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum FuncallName {
164    Upcase,
165    Downcase,
166    TitleCase,
167    Separate,
168    Unseparate,
169    Compose,
170    Decompose,
171}
172
173impl FuncallName {
174    pub fn from_name(name: &str) -> Option<FuncallName> {
175        Some(match name {
176            "upcase" => FuncallName::Upcase,
177            "downcase" => FuncallName::Downcase,
178            "title_case" => FuncallName::TitleCase,
179            "separate" => FuncallName::Separate,
180            "unseparate" => FuncallName::Unseparate,
181            "compose" => FuncallName::Compose,
182            "decompose" => FuncallName::Decompose,
183            _ => return None,
184        })
185    }
186
187    /// `Interscript::Stdlib.reverse_function`: the inverse funcall
188    /// used by `Funcall#reverse`. `upcase` has no entry (the Ruby map
189    /// omits it, so reversing an upcase funcall crashes there).
190    pub fn reverse_name(&self) -> Option<FuncallName> {
191        Some(match self {
192            FuncallName::TitleCase => FuncallName::Downcase, // best-effort per Ruby
193            FuncallName::Downcase => FuncallName::TitleCase, // best-effort per Ruby
194            FuncallName::Compose => FuncallName::Decompose,
195            FuncallName::Decompose => FuncallName::Compose,
196            FuncallName::Separate => FuncallName::Unseparate,
197            FuncallName::Unseparate => FuncallName::Separate,
198            FuncallName::Upcase => return None,
199        })
200    }
201
202    pub fn apply(&self, s: String, kwargs: &[(String, String)]) -> Result<String> {
203        let kw = |name: &str| kwargs.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone());
204        match self {
205            FuncallName::Upcase => Ok(s.to_uppercase()),
206            FuncallName::Downcase => match kw("word_separator") {
207                Some(sep) => Ok(downcase_per_word(s, &sep)),
208                None => Ok(s.to_lowercase()),
209            },
210            FuncallName::TitleCase => {
211                let sep = kw("word_separator").unwrap_or_else(|| " ".to_string());
212                Ok(title_case(s, &sep))
213            }
214            FuncallName::Separate => {
215                let sep = kw("separator").unwrap_or_else(|| " ".to_string());
216                let mut out = String::with_capacity(s.len() * 2);
217                for (i, c) in s.chars().enumerate() {
218                    if i > 0 {
219                        out.push_str(&sep);
220                    }
221                    out.push(c);
222                }
223                Ok(out)
224            }
225            FuncallName::Unseparate => {
226                let sep = kw("separator").unwrap_or_else(|| " ".to_string());
227                Ok(s.split(&sep).collect::<String>())
228            }
229            // Ruby String#unicode_normalize(:nfc / :nfd)
230            FuncallName::Compose => Ok(s.nfc().collect()),
231            FuncallName::Decompose => Ok(s.nfd().collect()),
232        }
233    }
234}
235
236/// `Interscript::Stdlib::Functions.title_case`: `gsub(/^(.)/, &:upcase)`
237/// then `gsub(/#{word_separator}(.)/, &:upcase)` — the block upcases
238/// the WHOLE match (separator included), so the replacement for a
239/// match `sep + ch` is `(sep + ch).to_uppercase`.
240pub fn title_case(s: String, word_separator: &str) -> String {
241    let out = upcase_first(s);
242    if word_separator.is_empty() {
243        return out;
244    }
245    let sep: Vec<char> = word_separator.chars().collect();
246    let chars: Vec<char> = out.chars().collect();
247    let mut result = String::new();
248    let mut i = 0usize;
249    while i < chars.len() {
250        if i + sep.len() < chars.len() && chars[i..i + sep.len()] == sep[..] {
251            let mut m: String = sep.iter().collect();
252            m.extend(chars[i + sep.len()].to_uppercase());
253            result.push_str(&m);
254            i += sep.len() + 1;
255        } else {
256            result.push(chars[i]);
257            i += 1;
258        }
259    }
260    result
261}
262
263/// `Interscript::Stdlib::Functions.downcase` with word_separator —
264/// same whole-match downcasing.
265pub fn downcase_per_word(s: String, word_separator: &str) -> String {
266    let out = downcase_first(s);
267    if word_separator.is_empty() {
268        return out;
269    }
270    let sep: Vec<char> = word_separator.chars().collect();
271    let chars: Vec<char> = out.chars().collect();
272    let mut result = String::new();
273    let mut i = 0usize;
274    while i < chars.len() {
275        if i + sep.len() < chars.len() && chars[i..i + sep.len()] == sep[..] {
276            let mut m: String = sep.iter().collect();
277            m.extend(chars[i + sep.len()].to_lowercase());
278            result.push_str(&m);
279            i += sep.len() + 1;
280        } else {
281            result.push(chars[i]);
282            i += 1;
283        }
284    }
285    result
286}
287
288fn upcase_first(s: String) -> String {
289    let mut chars = s.chars();
290    match chars.next() {
291        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
292        None => s,
293    }
294}
295
296fn downcase_first(s: String) -> String {
297    let mut chars = s.chars();
298    match chars.next() {
299        Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
300        None => s,
301    }
302}
303
304/// The funcalls that need external engines; unsupported in this slice.
305pub fn unsupported_funcall(name: &str) -> Error {
306    Error::Unsupported(format!(
307        "funcall {name:?} (rababa/secryst need external ML engines; not in slice 1)"
308    ))
309}