Skip to main content

granit_parser/
char_traits.rs

1//! Holds functions to determine if a character belongs to a specific character set.
2
3/// Check whether the character is nil (`\0`).
4#[inline]
5#[must_use]
6pub fn is_z(c: char) -> bool {
7    c == '\0'
8}
9
10/// Check whether the character is a line break (`\r` or `\n`).
11#[inline]
12#[must_use]
13pub fn is_break(c: char) -> bool {
14    c == '\n' || c == '\r'
15}
16
17/// Check whether the character is nil or a line break (`\0`, `\r`, `\n`).
18#[inline]
19#[must_use]
20pub fn is_breakz(c: char) -> bool {
21    is_break(c) || is_z(c)
22}
23
24/// Check whether the character is a whitespace (` ` or `\t`).
25#[inline]
26#[must_use]
27pub fn is_blank(c: char) -> bool {
28    c == ' ' || c == '\t'
29}
30
31/// Check whether the character is nil, a line break, or whitespace.
32///
33/// `\0`, ` `, `\t`, `\n`, `\r`
34#[inline]
35#[must_use]
36pub fn is_blank_or_breakz(c: char) -> bool {
37    is_blank(c) || is_breakz(c)
38}
39
40/// Check whether the character is an ASCII digit.
41#[inline]
42#[must_use]
43pub fn is_digit(c: char) -> bool {
44    c.is_ascii_digit()
45}
46
47/// Check whether the character is an ASCII alphanumeric, `_` or `-`.
48///
49/// This is used for scanning tag handles and similar constructs.
50/// Note: This is slightly more permissive than YAML's `ns-word-char` (which excludes `_`).
51/// For strict `ns-word-char` compliance, use `is_word_char` instead.
52///
53/// Matches: `[0-9a-zA-Z_-]`
54#[inline]
55#[must_use]
56pub fn is_alpha(c: char) -> bool {
57    matches!(c, '0'..='9' | 'a'..='z' | 'A'..='Z' | '_' | '-')
58}
59
60/// Check whether the character is a hexadecimal character (case insensitive).
61#[inline]
62#[must_use]
63pub fn is_hex(c: char) -> bool {
64    c.is_ascii_digit() || ('a'..='f').contains(&c) || ('A'..='F').contains(&c)
65}
66
67/// Convert the hexadecimal digit to an integer.
68///
69/// # Panics
70/// Panics if `c` is not an ASCII hexadecimal digit.
71#[track_caller]
72#[inline]
73#[must_use]
74pub fn as_hex(c: char) -> u32 {
75    match c {
76        '0'..='9' => (c as u32) - ('0' as u32),
77        'a'..='f' => (c as u32) - ('a' as u32) + 10,
78        'A'..='F' => (c as u32) - ('A' as u32) + 10,
79        _ => unreachable!("as_hex called with a non-hexadecimal character"),
80    }
81}
82
83/// Check whether the character is a YAML flow character (one of `,[]{}`).
84#[inline]
85#[must_use]
86pub fn is_flow(c: char) -> bool {
87    matches!(c, ',' | '[' | ']' | '{' | '}')
88}
89
90/// Check whether the character is the BOM character.
91#[inline]
92#[must_use]
93pub fn is_bom(c: char) -> bool {
94    c == '\u{FEFF}'
95}
96
97/// Check whether the character is a YAML non-breaking character.
98#[inline]
99#[must_use]
100pub fn is_yaml_non_break(c: char) -> bool {
101    is_printable(c) && !is_break(c) && !is_bom(c)
102}
103
104/// Check whether the character is a YAML printable character (`c-printable`).
105#[inline]
106#[must_use]
107pub(crate) fn is_printable(c: char) -> bool {
108    matches!(
109        c as u32,
110        0x0009
111            | 0x000A
112            | 0x000D
113            | 0x0020..=0x007E
114            | 0x0085
115            | 0x00A0..=0xD7FF
116            | 0xE000..=0xFFFD
117            | 0x10000..=0x0010_FFFF
118    )
119}
120
121const PRINTABLE_ASCII_FAST_PATH_MIN_BYTES: usize = 32;
122const BYTE_LANES_ONES: u64 = 0x0101_0101_0101_0101;
123const BYTE_LANES_HIGH_BITS: u64 = 0x8080_8080_8080_8080;
124const BYTE_LANES_TOP_THREE_BITS: u64 = 0xe0e0_e0e0_e0e0_e0e0;
125const BYTE_LANES_DEL: u64 = 0x7f7f_7f7f_7f7f_7f7f;
126
127#[inline]
128fn has_zero_byte(word: u64) -> bool {
129    word.wrapping_sub(BYTE_LANES_ONES) & !word & BYTE_LANES_HIGH_BITS != 0
130}
131
132#[inline]
133fn is_suspicious_scalar_byte(byte: u8) -> bool {
134    (byte < 0x20 && !matches!(byte, b'\t' | b'\n' | b'\r')) || byte >= 0x7f
135}
136
137/// Return the first character that is not YAML `c-printable`.
138///
139/// Character iteration is cheaper for short strings. For longer strings, inspect eight ASCII
140/// bytes at a time. Words that may contain a control, DEL, or non-ASCII byte are checked exactly;
141/// a non-ASCII suffix falls back to the canonical character predicate.
142#[inline]
143pub(crate) fn find_non_printable(s: &str) -> Option<char> {
144    if s.len() < PRINTABLE_ASCII_FAST_PATH_MIN_BYTES {
145        return s.chars().find(|&character| !is_printable(character));
146    }
147
148    let bytes = s.as_bytes();
149    let mut chunks = bytes.chunks_exact(8);
150    let mut byte_offset = 0;
151    let mut suspicious_offset = None;
152
153    for chunk in &mut chunks {
154        let word = u64::from_ne_bytes(chunk.try_into().expect("chunk length is eight"));
155        let may_have_suspicious_byte = word & BYTE_LANES_HIGH_BITS != 0
156            || has_zero_byte(word & BYTE_LANES_TOP_THREE_BITS)
157            || has_zero_byte(word ^ BYTE_LANES_DEL);
158
159        if may_have_suspicious_byte {
160            if let Some(chunk_offset) = chunk
161                .iter()
162                .position(|&byte| is_suspicious_scalar_byte(byte))
163            {
164                suspicious_offset = Some(byte_offset + chunk_offset);
165                break;
166            }
167        }
168        byte_offset += chunk.len();
169    }
170
171    let suspicious_offset = suspicious_offset.or_else(|| {
172        chunks
173            .remainder()
174            .iter()
175            .position(|&byte| is_suspicious_scalar_byte(byte))
176            .map(|remainder_offset| byte_offset + remainder_offset)
177    });
178
179    match suspicious_offset {
180        None => None,
181        Some(offset) if bytes[offset].is_ascii() => Some(char::from(bytes[offset])),
182        // All preceding bytes are printable ASCII, so this is the start of a UTF-8 character.
183        Some(offset) => s[offset..]
184            .chars()
185            .find(|&character| !is_printable(character)),
186    }
187}
188
189/// Check whether the character is NOT a YAML whitespace (` ` / `\t`).
190#[inline]
191#[must_use]
192pub fn is_yaml_non_space(c: char) -> bool {
193    is_yaml_non_break(c) && !is_blank(c)
194}
195
196/// Check whether the character is a valid YAML anchor name character.
197#[inline]
198#[must_use]
199pub fn is_anchor_char(c: char) -> bool {
200    is_yaml_non_space(c) && !is_flow(c) && !is_z(c)
201}
202
203/// Check whether the character is a valid YAML word character (`ns-word-char`).
204///
205/// Per YAML 1.2 spec: `ns-word-char ::= ns-dec-digit | ns-ascii-letter | "-"`
206///
207/// Matches: `[0-9a-zA-Z-]`
208#[inline]
209#[must_use]
210pub fn is_word_char(c: char) -> bool {
211    is_alpha(c) && c != '_'
212}
213
214/// Check whether the character is a valid URI character.
215#[inline]
216#[must_use]
217pub fn is_uri_char(c: char) -> bool {
218    is_word_char(c) || "#;/?:@&=+$,_.!~*\'()[]%".contains(c)
219}
220
221/// Check whether the character is a valid tag character.
222#[inline]
223#[must_use]
224pub fn is_tag_char(c: char) -> bool {
225    is_uri_char(c) && !is_flow(c) && c != '!'
226}
227
228#[cfg(test)]
229mod tests {
230    use alloc::string::String;
231
232    use super::*;
233
234    #[test]
235    fn printable_ranges_include_private_and_supplementary_planes() {
236        assert!(is_printable('\u{E000}'));
237        assert!(is_printable('\u{10FFFF}'));
238        assert!(is_yaml_non_break('\u{10000}'));
239        assert!(!is_yaml_non_break('\u{FEFF}'));
240        assert!(!is_yaml_non_break('\n'));
241    }
242
243    #[test]
244    fn optimized_non_printable_search_matches_yaml_boundaries() {
245        let printable = [
246            '\t',
247            '\n',
248            '\r',
249            ' ',
250            '~',
251            '\u{85}',
252            '\u{a0}',
253            '\u{d7ff}',
254            '\u{e000}',
255            '\u{feff}',
256            '\u{fffd}',
257            '\u{10000}',
258            '\u{10ffff}',
259        ];
260        for character in printable {
261            let mut short = String::from("before");
262            short.push(character);
263            short.push_str("after");
264            assert_eq!(find_non_printable(&short), None, "rejected {character:?}");
265
266            let mut long = "x".repeat(80);
267            long.push(character);
268            long.push_str("after");
269            assert_eq!(find_non_printable(&long), None, "rejected {character:?}");
270        }
271
272        let non_printable = [
273            '\0', '\u{1}', '\u{8}', '\u{b}', '\u{c}', '\u{e}', '\u{1f}', '\u{7f}', '\u{80}',
274            '\u{84}', '\u{86}', '\u{9f}', '\u{fffe}', '\u{ffff}',
275        ];
276        for character in non_printable {
277            let mut short = String::from("before");
278            short.push(character);
279            short.push_str("after");
280            assert_eq!(
281                find_non_printable(&short),
282                Some(character),
283                "accepted {character:?}",
284            );
285
286            let mut long = "x".repeat(80);
287            long.push(character);
288            long.push_str("after");
289            assert_eq!(
290                find_non_printable(&long),
291                Some(character),
292                "accepted {character:?}",
293            );
294        }
295
296        let mut multiple = "x".repeat(80);
297        multiple.push('\u{80}');
298        multiple.push('\u{7f}');
299        assert_eq!(find_non_printable(&multiple), Some('\u{80}'));
300    }
301
302    #[test]
303    fn optimized_non_printable_search_matches_reference_across_chunk_boundaries() {
304        let suffixes = [
305            "plain",
306            "\tafter",
307            "\nafter",
308            "\rafter",
309            "éafter",
310            "\u{85}after",
311            "\u{80}after",
312            "\u{7f}after",
313            "é\u{7f}after",
314            "\u{85}\u{9f}after",
315            "\u{10000}\u{ffff}after",
316        ];
317
318        for prefix_len in 56..=80 {
319            for suffix in suffixes {
320                let input = "x".repeat(prefix_len) + suffix;
321                let expected = input.chars().find(|&character| !is_printable(character));
322                assert_eq!(
323                    find_non_printable(&input),
324                    expected,
325                    "mismatch at prefix length {prefix_len} for {suffix:?}",
326                );
327            }
328        }
329    }
330
331    #[test]
332    fn word_uri_and_tag_character_sets_are_distinct() {
333        assert!(is_word_char('-'));
334        assert!(!is_word_char('_'));
335        assert!(is_uri_char('_'));
336        assert!(is_uri_char('%'));
337        assert!(!is_tag_char('!'));
338        assert!(!is_tag_char('['));
339    }
340}