Skip to main content

ferroui_base/utilities/
character_reader.rs

1//! Forward-only character reader used by the hand-written grammars (binding
2//! expressions, selectors, property paths, container queries).
3//!
4//! The reader works on Unicode scalar values (`char`) over a UTF-8 `&str`.
5//!
6//! Differences from the upstream reader, which works on UTF-16 code units:
7//!
8//! * [`CharacterReader::position`] and the `count` arguments are measured in
9//!   `char`s. For text in the Basic Multilingual Plane this is identical to
10//!   upstream; a supplementary-plane character counts as one instead of two.
11//! * Reading at the end of the input never fails: [`CharacterReader::peek`] and
12//!   [`CharacterReader::take`] return `None` and the `take_if*` methods return
13//!   `false`, where upstream raises an index-out-of-range error. Grammars turn
14//!   the resulting state into a parse error.
15//!
16//! The module also hosts the character classification helpers the grammars
17//! share ([`is_white_space`], [`is_letter`], [`is_digit`]).
18
19/// Returns `true` when `c` is in the Basic Multilingual Plane.
20///
21/// Upstream classifies UTF-16 code units, so a supplementary-plane character
22/// (a surrogate pair there) is never a letter, digit or mark.
23#[inline]
24fn is_bmp(c: char) -> bool {
25    (c as u32) < 0x1_0000
26}
27
28/// White space as defined by the upstream runtime: the Unicode `White_Space`
29/// property plus the information separators U+001C..U+001F.
30#[inline]
31pub fn is_white_space(c: char) -> bool {
32    c.is_whitespace() || matches!(c, '\u{1C}'..='\u{1F}')
33}
34
35/// Letter test (Unicode general categories Lu, Ll, Lt, Lm, Lo).
36///
37/// The standard library exposes the `Alphabetic` property rather than general
38/// categories. Letter numbers (Nl) and the circled Latin letters (So) are
39/// removed from it here; the remaining deviation is that combining marks
40/// carrying the `Other_Alphabetic` property (mostly dependent vowel signs)
41/// are reported as letters.
42#[inline]
43pub fn is_letter(c: char) -> bool {
44    if c.is_ascii() {
45        return c.is_ascii_alphabetic();
46    }
47    is_bmp(c) && c.is_alphabetic() && !c.is_numeric() && !matches!(c, '\u{24B6}'..='\u{24E9}')
48}
49
50/// Decimal digit test (Unicode general category Nd).
51///
52/// Deviation: the standard library cannot tell Nd from No, so non-ASCII
53/// "other number" characters (superscripts, fractions, circled numbers) are
54/// reported as digits too.
55#[inline]
56pub fn is_digit(c: char) -> bool {
57    if c.is_ascii() {
58        return c.is_ascii_digit();
59    }
60    is_bmp(c) && c.is_numeric() && !c.is_alphabetic()
61}
62
63/// Connector punctuation (Unicode general category Pc), exact for the BMP.
64#[inline]
65fn is_connector_punctuation(c: char) -> bool {
66    matches!(
67        c,
68        '_' | '\u{203F}'
69            | '\u{2040}'
70            | '\u{2054}'
71            | '\u{FE33}'
72            | '\u{FE34}'
73            | '\u{FE4D}'..='\u{FE4F}'
74            | '\u{FF3F}'
75    )
76}
77
78/// Format characters (Unicode general category Cf), exact for the BMP.
79#[inline]
80fn is_format(c: char) -> bool {
81    matches!(
82        c,
83        '\u{00AD}'
84            | '\u{0600}'..='\u{0605}'
85            | '\u{061C}'
86            | '\u{06DD}'
87            | '\u{070F}'
88            | '\u{0890}'..='\u{0891}'
89            | '\u{08E2}'
90            | '\u{180E}'
91            | '\u{200B}'..='\u{200F}'
92            | '\u{202A}'..='\u{202E}'
93            | '\u{2060}'..='\u{2064}'
94            | '\u{2066}'..='\u{206F}'
95            | '\u{FEFF}'
96            | '\u{FFF9}'..='\u{FFFB}'
97    )
98}
99
100/// Tests the categories that may continue (but not start) an identifier:
101/// non-spacing mark, spacing combining mark, connector punctuation, format
102/// and decimal digit.
103///
104/// Deviation: of the marks (Mn, Mc) only the combining diacritical marks block
105/// U+0300..U+036F is recognised here; marks with the `Other_Alphabetic`
106/// property are accepted through [`is_letter`], other marks are not accepted.
107#[inline]
108pub(crate) fn is_identifier_part_category(c: char) -> bool {
109    if c.is_ascii() {
110        return c.is_ascii_digit() || c == '_';
111    }
112    matches!(c, '\u{0300}'..='\u{036F}')
113        || is_connector_punctuation(c)
114        || is_format(c)
115        || is_digit(c)
116}
117
118/// A forward-only reader over a string.
119#[derive(Clone, Debug)]
120pub struct CharacterReader<'a> {
121    s: &'a str,
122    position: i32,
123}
124
125impl<'a> CharacterReader<'a> {
126    /// Creates a reader positioned at the start of `s`.
127    #[inline]
128    pub fn new(s: &'a str) -> Self {
129        Self { s, position: 0 }
130    }
131
132    /// Gets a value indicating whether the whole input has been consumed.
133    #[inline]
134    pub fn end(&self) -> bool {
135        self.s.is_empty()
136    }
137
138    /// Gets the next character without consuming it, or `None` at the end.
139    #[inline]
140    pub fn peek(&self) -> Option<char> {
141        self.s.chars().next()
142    }
143
144    /// Gets the number of characters consumed so far.
145    ///
146    /// As upstream, [`Self::skip`] does not advance the position.
147    #[inline]
148    pub fn position(&self) -> i32 {
149        self.position
150    }
151
152    /// Consumes and returns the next character, or returns `None` at the end.
153    #[inline]
154    pub fn take(&mut self) -> Option<char> {
155        let taken = self.peek()?;
156        self.position += 1;
157        self.s = &self.s[taken.len_utf8()..];
158        Some(taken)
159    }
160
161    /// Consumes leading white space.
162    pub fn skip_whitespace(&mut self) {
163        let len = self.peek_whitespace().len();
164        self.advance(len);
165    }
166
167    /// Consumes the next character if it equals `c`.
168    #[inline]
169    pub fn take_if(&mut self, c: char) -> bool {
170        if self.peek() == Some(c) {
171            self.take();
172            true
173        } else {
174            false
175        }
176    }
177
178    /// Consumes `s` if the remaining input starts with it.
179    pub fn take_if_str(&mut self, s: &str) -> bool {
180        if self.s.starts_with(s) {
181            self.advance(s.len());
182            true
183        } else {
184            false
185        }
186    }
187
188    /// Consumes the next character if it satisfies `condition`.
189    #[inline]
190    pub fn take_if_fn(&mut self, condition: impl Fn(char) -> bool) -> bool {
191        match self.peek() {
192            Some(c) if condition(c) => {
193                self.take();
194                true
195            }
196            _ => false,
197        }
198    }
199
200    /// Consumes and returns everything up to (not including) the first `c`,
201    /// or the rest of the input when `c` does not occur.
202    pub fn take_until(&mut self, c: char) -> &'a str {
203        let len = self.s.find(c).unwrap_or(self.s.len());
204        self.advance(len)
205    }
206
207    /// Consumes and returns the longest prefix whose characters all satisfy
208    /// `condition`.
209    pub fn take_while(&mut self, condition: impl Fn(char) -> bool) -> &'a str {
210        let len = self
211            .s
212            .char_indices()
213            .find(|&(_, c)| !condition(c))
214            .map_or(self.s.len(), |(i, _)| i);
215        self.advance(len)
216    }
217
218    /// Returns the next `count` characters without consuming them, or an empty
219    /// string when fewer than `count` characters remain.
220    pub fn try_peek(&self, count: usize) -> &'a str {
221        match self.byte_offset(count) {
222            Some(len) => &self.s[..len],
223            None => "",
224        }
225    }
226
227    /// Returns the leading white space without consuming it.
228    pub fn peek_whitespace(&self) -> &'a str {
229        let trimmed = self.s.trim_start_matches(is_white_space);
230        &self.s[..self.s.len() - trimmed.len()]
231    }
232
233    /// Consumes `count` characters without advancing [`Self::position`]
234    /// (as upstream).
235    ///
236    /// # Panics
237    ///
238    /// Panics when fewer than `count` characters remain; callers skip only
239    /// what they have peeked.
240    pub fn skip(&mut self, count: usize) {
241        match self.byte_offset(count) {
242            Some(len) => self.s = &self.s[len..],
243            None => panic!("CharacterReader::skip: count is past the end of the input"),
244        }
245    }
246
247    /// Byte offset of the character with index `count`, `None` when the
248    /// remaining input is shorter than `count` characters.
249    fn byte_offset(&self, count: usize) -> Option<usize> {
250        if count == 0 {
251            return Some(0);
252        }
253        self.s
254            .char_indices()
255            .nth(count - 1)
256            .map(|(i, c)| i + c.len_utf8())
257    }
258
259    /// Consumes `len` bytes (a char boundary) and advances the position by the
260    /// number of characters consumed.
261    fn advance(&mut self, len: usize) -> &'a str {
262        let (taken, rest) = self.s.split_at(len);
263        self.position += taken.chars().count() as i32;
264        self.s = rest;
265        taken
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    #[test]
274    fn peek_take_and_position() {
275        let mut r = CharacterReader::new("aé𝒜");
276        assert!(!r.end());
277        assert_eq!(r.peek(), Some('a'));
278        assert_eq!(r.take(), Some('a'));
279        assert_eq!(r.position(), 1);
280        assert_eq!(r.take(), Some('é'));
281        assert_eq!(r.take(), Some('𝒜'));
282        assert_eq!(r.position(), 3);
283        assert!(r.end());
284        assert_eq!(r.peek(), None);
285        assert_eq!(r.take(), None);
286        assert_eq!(r.position(), 3);
287    }
288
289    #[test]
290    fn take_if_variants() {
291        let mut r = CharacterReader::new("?.ab");
292        assert!(!r.take_if('.'));
293        assert!(!r.take_if_str("?!"));
294        assert!(r.take_if_str("?."));
295        assert_eq!(r.position(), 2);
296        assert!(r.take_if('a'));
297        assert!(!r.take_if_fn(|c| c.is_ascii_digit()));
298        assert!(r.take_if_fn(|c| c == 'b'));
299        assert!(r.end());
300        assert!(!r.take_if('a'));
301        assert!(!r.take_if_fn(|_| true));
302        assert!(!r.take_if_str("a"));
303        assert!(r.take_if_str(""));
304    }
305
306    #[test]
307    fn take_until_and_take_while() {
308        let mut r = CharacterReader::new("żółw]rest");
309        assert_eq!(r.take_until(']'), "żółw");
310        assert_eq!(r.position(), 4);
311        assert_eq!(r.take(), Some(']'));
312        assert_eq!(r.take_while(|c| c != 's'), "re");
313        assert_eq!(r.take_until('#'), "st");
314        assert!(r.end());
315        assert_eq!(r.take_until('#'), "");
316        assert_eq!(r.take_while(|_| true), "");
317        assert_eq!(r.position(), 9);
318    }
319
320    #[test]
321    fn whitespace() {
322        let mut r = CharacterReader::new(" \t\u{00A0}\u{1F}x ");
323        assert_eq!(r.peek_whitespace(), " \t\u{00A0}\u{1F}");
324        assert_eq!(r.position(), 0);
325        r.skip_whitespace();
326        assert_eq!(r.position(), 4);
327        assert_eq!(r.peek(), Some('x'));
328        assert_eq!(r.peek_whitespace(), "");
329        r.skip_whitespace();
330        assert_eq!(r.position(), 4);
331    }
332
333    #[test]
334    fn try_peek_and_skip() {
335        let mut r = CharacterReader::new("aéb");
336        assert_eq!(r.try_peek(0), "");
337        assert_eq!(r.try_peek(2), "aé");
338        assert_eq!(r.try_peek(3), "aéb");
339        assert_eq!(r.try_peek(4), "");
340        r.skip(2);
341        assert_eq!(r.peek(), Some('b'));
342        // Upstream quirk: skipping does not move the position.
343        assert_eq!(r.position(), 0);
344    }
345
346    #[test]
347    #[should_panic]
348    fn skip_past_end_panics() {
349        CharacterReader::new("a").skip(2);
350    }
351
352    #[test]
353    fn classification() {
354        assert!(is_letter('a') && is_letter('Z') && is_letter('ż') && is_letter('λ') && is_letter('中'));
355        assert!(!is_letter('_') && !is_letter('1') && !is_letter('Ⅷ') && !is_letter('Ⓐ'));
356        assert!(!is_letter('𝒜'));
357        assert!(is_digit('7') && is_digit('٣') && !is_digit('a') && !is_digit('Ⅷ'));
358        assert!(is_white_space(' ') && is_white_space('\u{2003}') && !is_white_space('x'));
359        assert!(is_identifier_part_category('\u{0301}'));
360        assert!(is_identifier_part_category('\u{203F}'));
361        assert!(is_identifier_part_category('\u{200D}'));
362        assert!(!is_identifier_part_category('-') && !is_identifier_part_category('a'));
363    }
364}