Skip to main content

omena_syntax/
ident.rs

1use std::borrow::Cow;
2
3/// A CSS class name with authored and decoded spellings.
4///
5/// Equality is intentionally available only through [`ClassNameV0::same_as`].
6/// This keeps raw spelling equality from becoming an accidental join key.
7///
8/// ```compile_fail,E0369
9/// use omena_syntax::ident::ClassNameV0;
10///
11/// fn raw_structural_equality(left: &ClassNameV0, right: &ClassNameV0) -> bool {
12///     left == right
13/// }
14/// ```
15#[derive(Debug, Clone)]
16pub struct ClassNameV0 {
17    raw: String,
18    decoded: Option<String>,
19}
20
21impl ClassNameV0 {
22    pub fn new(raw: impl Into<String>) -> Self {
23        let raw = raw.into();
24        let decoded = match decode_css_identifier_escapes(&raw) {
25            Cow::Borrowed(_) => None,
26            Cow::Owned(decoded) => Some(decoded),
27        };
28        Self { raw, decoded }
29    }
30
31    pub fn raw(&self) -> &str {
32        &self.raw
33    }
34
35    pub fn decoded(&self) -> &str {
36        self.decoded.as_deref().unwrap_or(&self.raw)
37    }
38
39    pub fn into_raw(self) -> String {
40        self.raw
41    }
42
43    pub fn same_as(&self, other: &Self) -> bool {
44        self.decoded() == other.decoded()
45    }
46
47    pub fn canonical_key(self) -> CanonicalClassKeyV0 {
48        let decoded = self.decoded.unwrap_or(self.raw);
49        CanonicalClassKeyV0(decoded, CanonicalClassKeySealV0(()))
50    }
51
52    fn from_plain(raw: &str) -> Self {
53        Self {
54            raw: raw.to_owned(),
55            decoded: None,
56        }
57    }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
61struct CanonicalClassKeySealV0(());
62
63#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
64pub struct CanonicalClassKeyV0(String, CanonicalClassKeySealV0);
65
66impl CanonicalClassKeyV0 {
67    pub fn as_str(&self) -> &str {
68        &self.0
69    }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct ClassSelectorPositionV0 {
74    pub start: usize,
75    pub end: usize,
76}
77
78#[derive(Debug, Clone)]
79pub struct ClassSelectorNameV0 {
80    pub name: ClassNameV0,
81    pub position: ClassSelectorPositionV0,
82}
83
84/// Returns whether a decoded character can start a CSS identifier name.
85pub fn is_css_name_start(ch: char) -> bool {
86    ch == '-' || ch == '_' || ch.is_ascii_alphabetic() || !ch.is_ascii()
87}
88
89/// Returns whether a decoded character can continue a CSS identifier name.
90pub fn is_css_name_continue(ch: char) -> bool {
91    is_css_name_start(ch) || ch.is_ascii_digit()
92}
93
94/// Returns whether a character belongs to the deliberately narrow ASCII word
95/// used by completion and hover cursor boundaries.
96///
97/// This is not the CSS identifier grammar. Widening it would change which word
98/// an editor request claims under the cursor.
99pub fn is_ascii_word_continue(ch: char) -> bool {
100    ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
101}
102
103pub fn is_safe_css_identifier(value: &str) -> bool {
104    let mut characters = value.chars();
105    let Some(first) = characters.next() else {
106        return false;
107    };
108    match first {
109        character
110            if character == '_' || character.is_ascii_alphabetic() || !character.is_ascii() => {}
111        '-' => {
112            let Some(second) = characters.next() else {
113                return false;
114            };
115            if !(second == '-'
116                || second == '_'
117                || second.is_ascii_alphabetic()
118                || !second.is_ascii())
119            {
120                return false;
121            }
122        }
123        _ => return false,
124    }
125    characters.all(is_css_name_continue)
126}
127
128pub fn decode_css_identifier_escapes(text: &str) -> Cow<'_, str> {
129    if !text.contains('\\') {
130        return Cow::Borrowed(text);
131    }
132
133    let mut output = String::with_capacity(text.len());
134    let mut index = 0usize;
135    while index < text.len() {
136        let Some(ch) = text[index..].chars().next() else {
137            break;
138        };
139        if ch != '\\' {
140            output.push(ch);
141            index += ch.len_utf8();
142            continue;
143        }
144
145        let escape_start = index;
146        index += ch.len_utf8();
147        let Some(next) = text[index..].chars().next() else {
148            output.push(char::REPLACEMENT_CHARACTER);
149            break;
150        };
151        if is_css_newline(next) {
152            output.push_str(&text[escape_start..index + next.len_utf8()]);
153            index += next.len_utf8();
154            continue;
155        }
156        if next.is_ascii_hexdigit() {
157            let hex_start = index;
158            let mut hex_end = index;
159            let mut digit_count = 0usize;
160            while hex_end < text.len() && digit_count < 6 {
161                let Some(candidate) = text[hex_end..].chars().next() else {
162                    break;
163                };
164                if !candidate.is_ascii_hexdigit() {
165                    break;
166                }
167                hex_end += candidate.len_utf8();
168                digit_count += 1;
169            }
170            let codepoint = u32::from_str_radix(&text[hex_start..hex_end], 16).ok();
171            output.push(
172                codepoint
173                    .filter(|value| *value != 0)
174                    .and_then(char::from_u32)
175                    .unwrap_or(char::REPLACEMENT_CHARACTER),
176            );
177            index = hex_end;
178            if let Some(terminator) = text[index..].chars().next()
179                && terminator.is_ascii_whitespace()
180            {
181                index += terminator.len_utf8();
182            }
183            continue;
184        }
185
186        output.push(next);
187        index += next.len_utf8();
188    }
189
190    Cow::Owned(output)
191}
192
193pub fn class_selector_name_end(text: &str, start: usize) -> Option<usize> {
194    let first = text.get(start..)?.chars().next()?;
195    let mut index = if first == '\\' {
196        css_identifier_escape_sequence_end(text, start)?
197    } else if is_css_name_start(first) {
198        start + first.len_utf8()
199    } else {
200        return None;
201    };
202
203    while index < text.len() {
204        let Some(ch) = text[index..].chars().next() else {
205            break;
206        };
207        if ch == '\\' {
208            let Some(end) = css_identifier_escape_sequence_end(text, index) else {
209                break;
210            };
211            index = end;
212        } else if is_css_name_continue(ch) {
213            index += ch.len_utf8();
214        } else {
215            break;
216        }
217    }
218    Some(index)
219}
220
221pub fn class_selector_names(selector: &str) -> Vec<ClassSelectorNameV0> {
222    if let Some(names) = ascii_class_selector_names(selector) {
223        return names;
224    }
225    general_class_selector_names(selector)
226}
227
228fn ascii_class_selector_names(selector: &str) -> Option<Vec<ClassSelectorNameV0>> {
229    let bytes = selector.as_bytes();
230    let mut names = Vec::new();
231    let mut index = 0usize;
232    let mut paren_depth = 0usize;
233    let mut bracket_depth = 0usize;
234    let mut quote = None;
235
236    while index < bytes.len() {
237        let byte = bytes[index];
238        if !byte.is_ascii() || byte == b'\\' {
239            return None;
240        }
241        if let Some(active_quote) = quote {
242            if byte == active_quote {
243                quote = None;
244            }
245            index += 1;
246            continue;
247        }
248        match byte {
249            b'"' | b'\'' => quote = Some(byte),
250            b'(' => paren_depth += 1,
251            b')' => paren_depth = paren_depth.saturating_sub(1),
252            b'[' => bracket_depth += 1,
253            b']' => bracket_depth = bracket_depth.saturating_sub(1),
254            b'.' if paren_depth == 0 && bracket_depth == 0 => {
255                let start = index + 1;
256                let Some(first) = bytes.get(start).copied() else {
257                    index += 1;
258                    continue;
259                };
260                if !ascii_css_name_start(first) {
261                    index += 1;
262                    continue;
263                }
264                let mut end = start + 1;
265                while end < bytes.len() && ascii_css_name_continue(bytes[end]) {
266                    end += 1;
267                }
268                names.push(ClassSelectorNameV0 {
269                    name: ClassNameV0::from_plain(&selector[start..end]),
270                    position: ClassSelectorPositionV0 { start, end },
271                });
272                index = end;
273                continue;
274            }
275            _ => {}
276        }
277        index += 1;
278    }
279    Some(names)
280}
281
282fn ascii_css_name_start(byte: u8) -> bool {
283    matches!(byte, b'-' | b'_') || byte.is_ascii_alphabetic()
284}
285
286fn ascii_css_name_continue(byte: u8) -> bool {
287    ascii_css_name_start(byte) || byte.is_ascii_digit()
288}
289
290fn general_class_selector_names(selector: &str) -> Vec<ClassSelectorNameV0> {
291    let mut names = Vec::new();
292    let mut index = 0usize;
293    let mut paren_depth = 0usize;
294    let mut bracket_depth = 0usize;
295    let mut quote = None;
296
297    while index < selector.len() {
298        let Some(ch) = selector[index..].chars().next() else {
299            break;
300        };
301        if ch == '\\' {
302            index = css_identifier_escape_sequence_end(selector, index)
303                .unwrap_or(index + ch.len_utf8());
304            continue;
305        }
306        if let Some(active_quote) = quote {
307            if ch == active_quote {
308                quote = None;
309            }
310            index += ch.len_utf8();
311            continue;
312        }
313        match ch {
314            '"' | '\'' => quote = Some(ch),
315            '(' => paren_depth += 1,
316            ')' => paren_depth = paren_depth.saturating_sub(1),
317            '[' => bracket_depth += 1,
318            ']' => bracket_depth = bracket_depth.saturating_sub(1),
319            '.' if paren_depth == 0 && bracket_depth == 0 => {
320                let start = index + ch.len_utf8();
321                if let Some(end) = class_selector_name_end(selector, start) {
322                    names.push(ClassSelectorNameV0 {
323                        name: ClassNameV0::new(&selector[start..end]),
324                        position: ClassSelectorPositionV0 { start, end },
325                    });
326                    index = end;
327                    continue;
328                }
329            }
330            _ => {}
331        }
332        index += ch.len_utf8();
333    }
334
335    names
336}
337
338/// Returns the byte immediately after a valid CSS identifier escape.
339///
340/// A newline or end-of-input after the reverse solidus is not a valid escape.
341pub fn css_identifier_escape_sequence_end(text: &str, slash_index: usize) -> Option<usize> {
342    if text[slash_index..].chars().next()? != '\\' {
343        return None;
344    }
345    let mut index = slash_index + '\\'.len_utf8();
346    let next = text[index..].chars().next()?;
347    if is_css_newline(next) {
348        return None;
349    }
350    if !next.is_ascii_hexdigit() {
351        return Some(index + next.len_utf8());
352    }
353
354    let mut digit_count = 0usize;
355    while index < text.len() && digit_count < 6 {
356        let Some(candidate) = text[index..].chars().next() else {
357            break;
358        };
359        if !candidate.is_ascii_hexdigit() {
360            break;
361        }
362        index += candidate.len_utf8();
363        digit_count += 1;
364    }
365    if let Some(terminator) = text[index..].chars().next()
366        && terminator.is_ascii_whitespace()
367    {
368        index += terminator.len_utf8();
369    }
370    Some(index)
371}
372
373fn is_css_newline(ch: char) -> bool {
374    matches!(ch, '\n' | '\r' | '\u{c}')
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn decodes_css_escapes_without_changing_plain_names() {
383        // These assertions fail on the supplied escape spellings, all of which
384        // the public decoder accepts directly from CSS source.
385        assert!(matches!(
386            decode_css_identifier_escapes("plain"),
387            Cow::Borrowed("plain")
388        ));
389        assert_eq!(decode_css_identifier_escapes(r"a\.b"), "a.b");
390        assert_eq!(decode_css_identifier_escapes(r"\31 23"), "123");
391        assert_eq!(decode_css_identifier_escapes(r"\0"), "\u{fffd}");
392        assert_eq!(decode_css_identifier_escapes("\\"), "\u{fffd}");
393        assert_eq!(decode_css_identifier_escapes("\\\n"), "\\\n");
394    }
395
396    #[test]
397    fn identifier_escape_boundaries_reject_newline_and_end_of_input() {
398        assert_eq!(css_identifier_escape_sequence_end(r"\31 23", 0), Some(4));
399        assert_eq!(css_identifier_escape_sequence_end(r"\:", 0), Some(2));
400        assert_eq!(css_identifier_escape_sequence_end("\\\n", 0), None);
401        assert_eq!(css_identifier_escape_sequence_end("\\", 0), None);
402    }
403
404    #[test]
405    fn class_name_identity_is_decoded_but_raw_text_is_preserved() {
406        let escaped = ClassNameV0::new(r"a\.b");
407        let plain = ClassNameV0::new("a.b");
408
409        // A decoder or key regression makes these source-producible spellings
410        // unequal or mutates the raw spelling retained for egress.
411        assert!(escaped.same_as(&plain));
412        assert_eq!(escaped.raw(), r"a\.b");
413        assert_eq!(escaped.canonical_key().as_str(), "a.b");
414    }
415
416    #[test]
417    fn ascii_class_scanner_matches_the_general_authority() {
418        let selector = r#".card .title[data-x="a.b"]:is(.nested).plain"#;
419        let summarize = |names: Vec<ClassSelectorNameV0>| {
420            names
421                .into_iter()
422                .map(|entry| {
423                    (
424                        entry.name.into_raw(),
425                        entry.position.start,
426                        entry.position.end,
427                    )
428                })
429                .collect::<Vec<_>>()
430        };
431
432        let fast = ascii_class_selector_names(selector);
433        assert!(fast.is_some(), "fixture must stay on the fast path");
434        if let Some(fast) = fast {
435            assert_eq!(
436                summarize(fast),
437                summarize(general_class_selector_names(selector))
438            );
439        }
440    }
441
442    #[test]
443    fn extracts_top_level_class_names_with_byte_positions() {
444        let selector = r#".card .title[data-x="a.b"]:is(.nested).a\.b.\31 23.카드.café"#;
445        let names = class_selector_names(selector);
446        let raw = names
447            .iter()
448            .map(|entry| entry.name.raw())
449            .collect::<Vec<_>>();
450
451        // The single selector exercises every branch and is itself a valid
452        // scanner input, so omitting or splitting any name falsifies the row.
453        assert_eq!(
454            raw,
455            vec!["card", "title", r"a\.b", r"\31 23", "카드", "café"]
456        );
457        assert_eq!(
458            names
459                .iter()
460                .find(|entry| entry.name.raw() == "café")
461                .map(|entry| entry.name.decoded().chars().count()),
462            Some(4)
463        );
464        for entry in names {
465            assert_eq!(
466                &selector[entry.position.start..entry.position.end],
467                entry.name.raw()
468            );
469        }
470    }
471
472    #[test]
473    fn distinguishes_css_name_and_ascii_word_boundaries() {
474        // Each character and identifier is accepted directly by the relevant
475        // public predicate; swapping or merging the two grammars falsifies it.
476        assert!(is_css_name_start('카'));
477        assert!(is_css_name_continue('é'));
478        assert!(!is_ascii_word_continue('카'));
479        assert!(is_ascii_word_continue('9'));
480        assert!(is_safe_css_identifier("카드"));
481        assert!(is_safe_css_identifier("--token"));
482        assert!(!is_safe_css_identifier("-9token"));
483        assert!(!is_safe_css_identifier("9token"));
484    }
485}