Skip to main content

omena_syntax/
keyword.rs

1/// Borrowed CSS keyword text compared under the ASCII case-insensitive rules
2/// used by CSS keyword grammars.
3#[derive(Debug, Clone, Copy)]
4pub struct CssKeywordText<'a> {
5    text: &'a str,
6}
7
8/// Wraps borrowed text for allocation-free CSS keyword comparisons.
9pub const fn css_keyword(text: &str) -> CssKeywordText<'_> {
10    CssKeywordText { text }
11}
12
13impl<'a> CssKeywordText<'a> {
14    /// Returns whether the complete text equals `expected` ignoring ASCII case.
15    pub fn equals(self, expected: &str) -> bool {
16        self.text.eq_ignore_ascii_case(expected)
17    }
18
19    /// Removes an ASCII case-insensitive prefix and returns the borrowed remainder.
20    pub fn strip_prefix(self, expected: &str) -> Option<&'a str> {
21        let prefix = self.text.get(..expected.len())?;
22        prefix
23            .eq_ignore_ascii_case(expected)
24            .then(|| &self.text[expected.len()..])
25    }
26
27    /// Removes an ASCII case-insensitive suffix and returns the borrowed remainder.
28    pub fn strip_suffix(self, expected: &str) -> Option<&'a str> {
29        let suffix_start = self.text.len().checked_sub(expected.len())?;
30        let suffix = self.text.get(suffix_start..)?;
31        suffix
32            .eq_ignore_ascii_case(expected)
33            .then(|| &self.text[..suffix_start])
34    }
35
36    /// Finds the first ASCII case-insensitive match and returns its byte offset.
37    pub fn find(self, expected: &str) -> Option<usize> {
38        if expected.is_empty() {
39            return Some(0);
40        }
41        self.text
42            .as_bytes()
43            .windows(expected.len())
44            .position(|candidate| candidate.eq_ignore_ascii_case(expected.as_bytes()))
45    }
46
47    /// Returns whether the text contains an ASCII case-insensitive match.
48    pub fn contains(self, expected: &str) -> bool {
49        self.find(expected).is_some()
50    }
51}
52
53#[cfg(test)]
54mod tests {
55    use super::css_keyword;
56
57    #[test]
58    fn compares_css_keywords_without_allocating_lowercase_copies() {
59        assert!(css_keyword("@LaYeR").equals("@layer"));
60        assert!(!css_keyword("@layered").equals("@layer"));
61    }
62
63    #[test]
64    fn strips_only_complete_ascii_case_insensitive_affixes() {
65        assert_eq!(
66            css_keyword("@KEYFRAMES fade").strip_prefix("@keyframes"),
67            Some(" fade")
68        );
69        assert_eq!(
70            css_keyword("red !IMPORTANT").strip_suffix("!important"),
71            Some("red ")
72        );
73        assert_eq!(css_keyword("@lay").strip_prefix("@layer"), None);
74        assert_eq!(css_keyword("@FORWARD 'x' AS ui-*").find(" as "), Some(12));
75        assert!(css_keyword("media:@SuPpOrTs").contains("@supports"));
76    }
77}