Skip to main content

omena_syntax/
ident.rs

1use std::{borrow::Cow, fmt};
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
72impl serde::Serialize for CanonicalClassKeyV0 {
73    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
74    where
75        S: serde::Serializer,
76    {
77        serializer.serialize_str(self.as_str())
78    }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
82struct CanonicalIdKeySealV0(());
83
84/// A sealed, CSS-escape-decoded id-selector identity key.
85#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
86pub struct CanonicalIdKeyV0(String, CanonicalIdKeySealV0);
87
88impl CanonicalIdKeyV0 {
89    pub fn from_authored(authored: &str) -> Self {
90        Self(
91            decode_css_identifier_escapes(authored).into_owned(),
92            CanonicalIdKeySealV0(()),
93        )
94    }
95
96    pub fn as_str(&self) -> &str {
97        &self.0
98    }
99}
100
101impl serde::Serialize for CanonicalIdKeyV0 {
102    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
103    where
104        S: serde::Serializer,
105    {
106        serializer.serialize_str(self.as_str())
107    }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
111struct CanonicalTypeSelectorKeySealV0(());
112
113/// A sealed, CSS-escape-decoded type-selector identity key.
114#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
115pub struct CanonicalTypeSelectorKeyV0(String, CanonicalTypeSelectorKeySealV0);
116
117impl CanonicalTypeSelectorKeyV0 {
118    pub fn from_authored(authored: &str) -> Self {
119        Self(
120            decode_css_identifier_escapes(authored).into_owned(),
121            CanonicalTypeSelectorKeySealV0(()),
122        )
123    }
124
125    pub fn as_str(&self) -> &str {
126        &self.0
127    }
128}
129
130impl serde::Serialize for CanonicalTypeSelectorKeyV0 {
131    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
132    where
133        S: serde::Serializer,
134    {
135        serializer.serialize_str(self.as_str())
136    }
137}
138
139/// Whether a declaration name belongs to the standard-property or custom-property
140/// identity domain.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum PropertyNameKindV0 {
143    Standard,
144    Custom,
145}
146
147/// Authored CSS property text retained only for presentation and provenance.
148///
149/// This type deliberately implements no structural equality, ordering, hashing,
150/// or raw-string borrowing. Identity-bearing carriers pair it with a sealed
151/// canonical property key and use that key for every comparison or lookup.
152/// [`AuthoredPropertyTextV0::write_into`] and serialization are the only
153/// presentation exits. Identity projections stay inside the property-name
154/// authority.
155///
156/// ```compile_fail,E0369
157/// use omena_syntax::ident::AuthoredPropertyTextV0;
158///
159/// struct Declaration {
160///     property: AuthoredPropertyTextV0,
161/// }
162///
163/// let left = Declaration { property: AuthoredPropertyTextV0::new("--Foo") };
164/// let right = Declaration { property: AuthoredPropertyTextV0::new("--foo") };
165/// let _ = left.property == right.property;
166/// ```
167///
168/// ```compile_fail,E0369
169/// use omena_syntax::ident::AuthoredPropertyTextV0;
170///
171/// struct Candidate {
172///     name: AuthoredPropertyTextV0,
173/// }
174///
175/// let left = Candidate { name: AuthoredPropertyTextV0::new("--Foo") };
176/// let right = Candidate { name: AuthoredPropertyTextV0::new("--foo") };
177/// let _ = left.name == right.name;
178/// ```
179///
180/// ```compile_fail,E0599
181/// use omena_syntax::ident::AuthoredPropertyTextV0;
182///
183/// let property = AuthoredPropertyTextV0::new("COLOR");
184/// let _ = property.to_ascii_lowercase();
185/// ```
186///
187/// ```compile_fail,E0599
188/// use omena_syntax::ident::AuthoredPropertyTextV0;
189///
190/// let property = AuthoredPropertyTextV0::new("COLOR");
191/// let _ = property.to_lowercase();
192/// ```
193///
194/// ```compile_fail,E0599
195/// use omena_syntax::ident::AuthoredPropertyTextV0;
196///
197/// let property = AuthoredPropertyTextV0::new("COLOR");
198/// let _ = property.as_str();
199/// ```
200///
201/// ```compile_fail,E0308
202/// use std::collections::HashMap;
203/// use omena_syntax::ident::AuthoredPropertyTextV0;
204///
205/// let mut values = HashMap::<String, usize>::new();
206/// values.insert(AuthoredPropertyTextV0::new("--token"), 1);
207/// ```
208///
209/// ```compile_fail,E0599
210/// use omena_syntax::ident::AuthoredPropertyTextV0;
211///
212/// let property = AuthoredPropertyTextV0::new("--token");
213/// let _ = property.to_string();
214/// ```
215///
216/// ```compile_fail,E0277
217/// use omena_syntax::ident::AuthoredPropertyTextV0;
218///
219/// let property = AuthoredPropertyTextV0::new("--token");
220/// let _ = format!("{}", property);
221/// ```
222///
223/// ```compile_fail,E0277
224/// use std::fmt::Write;
225/// use omena_syntax::ident::AuthoredPropertyTextV0;
226///
227/// let property = AuthoredPropertyTextV0::new("--token");
228/// let mut output = String::new();
229/// let _ = write!(&mut output, "{}", property);
230/// ```
231///
232/// ```compile_fail,E0277
233/// use std::fmt;
234/// use omena_syntax::ident::AuthoredPropertyTextV0;
235///
236/// struct Shown(AuthoredPropertyTextV0);
237/// impl fmt::Display for Shown {
238///     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
239///         write!(formatter, "{}", self.0)
240///     }
241/// }
242/// ```
243///
244/// ```compile_fail,E0277
245/// use omena_syntax::ident::AuthoredPropertyTextV0;
246///
247/// struct Carrier(AuthoredPropertyTextV0);
248/// let mut values = vec![Carrier(AuthoredPropertyTextV0::new("--token"))];
249/// values.sort();
250/// ```
251#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
252#[serde(transparent)]
253pub struct AuthoredPropertyTextV0(String);
254
255impl AuthoredPropertyTextV0 {
256    pub fn new(authored: impl Into<String>) -> Self {
257        Self(authored.into())
258    }
259
260    /// Writes the source spelling to a presentation sink without exposing a
261    /// borrowable raw string that could be reused as an identity key.
262    pub fn write_into(&self, out: &mut impl fmt::Write) -> fmt::Result {
263        out.write_str(&self.0)
264    }
265
266    pub fn is_empty(&self) -> bool {
267        self.0.is_empty()
268    }
269
270    pub fn to_property_name(&self) -> PropertyNameV0 {
271        PropertyNameV0::from_authored(self.0.clone())
272    }
273
274    pub fn to_custom_key(&self) -> CanonicalCustomPropertyNameV0 {
275        PropertyNameV0::canonical_custom_key(self.0.clone())
276    }
277
278    pub fn to_standard_key(&self) -> CanonicalStandardPropertyNameV0 {
279        PropertyNameV0::canonical_standard_key(self.0.clone())
280    }
281}
282
283/// Writes authored property text into an owned presentation buffer.
284///
285/// This is the named cross-crate escape for emitters that already own their
286/// output `String`; identity-bearing code must use one of the projections on
287/// [`AuthoredPropertyTextV0`] instead.
288pub fn render_authored(authored: &AuthoredPropertyTextV0, output: &mut String) -> fmt::Result {
289    authored.write_into(output)
290}
291
292/// A CSS property name with an authored spelling and one sealed canonical identity.
293///
294/// Structural equality is deliberately unavailable. Callers compare property
295/// identity through [`PropertyNameV0::same_as`] or carry the sealed key returned by
296/// [`PropertyNameV0::canonical_key`].
297///
298/// ```compile_fail,E0369
299/// use omena_syntax::ident::{PropertyNameKindV0, PropertyNameV0};
300///
301/// fn raw_structural_equality(left: &PropertyNameV0, right: &PropertyNameV0) -> bool {
302///     left == right
303/// }
304///
305/// let _ = PropertyNameV0::new("color", PropertyNameKindV0::Standard);
306/// ```
307///
308/// ```compile_fail,E0599
309/// use omena_syntax::ident::PropertyNameV0;
310///
311/// let property = PropertyNameV0::custom("--token");
312/// let _ = property.decoded();
313/// ```
314///
315/// ```compile_fail,E0616
316/// use omena_syntax::ident::PropertyNameV0;
317///
318/// let property = PropertyNameV0::custom("--token");
319/// let PropertyNameV0::Custom(payload) = property else { unreachable!() };
320/// let _ = payload.decoded;
321/// ```
322#[derive(Debug, Clone)]
323#[allow(private_interfaces)]
324pub enum PropertyNameV0 {
325    Standard(StandardPropertyNamePayloadV0),
326    Custom(CustomPropertyNamePayloadV0),
327}
328
329#[derive(Debug, Clone)]
330#[allow(dead_code)]
331struct StandardPropertyNamePayloadV0 {
332    authored: AuthoredPropertyTextV0,
333    decoded: String,
334    canonical: CanonicalStandardPropertyNameV0,
335}
336
337#[derive(Debug, Clone)]
338#[allow(dead_code)]
339struct CustomPropertyNamePayloadV0 {
340    authored: AuthoredPropertyTextV0,
341    decoded: String,
342    canonical: CanonicalCustomPropertyNameV0,
343}
344
345impl PropertyNameV0 {
346    /// Classifies a property name after CSS-escape decoding, then applies the
347    /// corresponding canonical identity rules.
348    pub fn from_authored(authored: impl Into<String>) -> Self {
349        let authored = authored.into();
350        let authored = authored.trim().to_string();
351        let decoded = decode_css_identifier_escapes(&authored).into_owned();
352        let kind = if decoded.starts_with("--") {
353            PropertyNameKindV0::Custom
354        } else {
355            PropertyNameKindV0::Standard
356        };
357        Self::from_decoded(authored, decoded, kind)
358    }
359
360    pub fn new(authored: impl Into<String>, kind: PropertyNameKindV0) -> Self {
361        let authored = authored.into();
362        let authored = authored.trim().to_string();
363        let decoded = decode_css_identifier_escapes(&authored).into_owned();
364        Self::from_decoded(authored, decoded, kind)
365    }
366
367    fn from_decoded(authored: String, decoded: String, kind: PropertyNameKindV0) -> Self {
368        match kind {
369            PropertyNameKindV0::Standard => Self::Standard(StandardPropertyNamePayloadV0 {
370                canonical: CanonicalStandardPropertyNameV0(
371                    decoded.to_ascii_lowercase(),
372                    CanonicalStandardPropertyNameSealV0(()),
373                ),
374                authored: AuthoredPropertyTextV0::new(authored),
375                decoded,
376            }),
377            PropertyNameKindV0::Custom => Self::Custom(CustomPropertyNamePayloadV0 {
378                canonical: CanonicalCustomPropertyNameV0(
379                    decoded.clone(),
380                    CanonicalCustomPropertyNameSealV0(()),
381                ),
382                authored: AuthoredPropertyTextV0::new(authored),
383                decoded,
384            }),
385        }
386    }
387
388    pub fn standard(authored: impl Into<String>) -> Self {
389        Self::new(authored, PropertyNameKindV0::Standard)
390    }
391
392    pub fn custom(authored: impl Into<String>) -> Self {
393        Self::new(authored, PropertyNameKindV0::Custom)
394    }
395
396    pub fn kind(&self) -> PropertyNameKindV0 {
397        match self {
398            Self::Standard(_) => PropertyNameKindV0::Standard,
399            Self::Custom(_) => PropertyNameKindV0::Custom,
400        }
401    }
402
403    pub fn authored_text(&self) -> AuthoredPropertyTextV0 {
404        match self {
405            Self::Standard(payload) => payload.authored.clone(),
406            Self::Custom(payload) => payload.authored.clone(),
407        }
408    }
409
410    pub fn canonical_name(&self) -> &str {
411        match self {
412            Self::Standard(payload) => payload.canonical.as_str(),
413            Self::Custom(payload) => payload.canonical.as_str(),
414        }
415    }
416
417    pub fn same_as(&self, other: &Self) -> bool {
418        match (self, other) {
419            (Self::Standard(left), Self::Standard(right)) => left.canonical == right.canonical,
420            (Self::Custom(left), Self::Custom(right)) => left.canonical == right.canonical,
421            _ => false,
422        }
423    }
424
425    pub fn canonical_key(&self) -> CanonicalPropertyKeyV0 {
426        match self {
427            Self::Standard(payload) => CanonicalPropertyKeyV0::Standard(payload.canonical.clone()),
428            Self::Custom(payload) => CanonicalPropertyKeyV0::Custom(payload.canonical.clone()),
429        }
430    }
431
432    pub fn as_custom_key(&self) -> Option<CanonicalCustomPropertyNameV0> {
433        match self {
434            Self::Custom(payload) => Some(payload.canonical.clone()),
435            Self::Standard(_) => None,
436        }
437    }
438
439    pub fn as_standard_key(&self) -> Option<&CanonicalStandardPropertyNameV0> {
440        match self {
441            Self::Standard(payload) => Some(&payload.canonical),
442            Self::Custom(_) => None,
443        }
444    }
445
446    pub fn canonical_custom_key(authored: impl Into<String>) -> CanonicalCustomPropertyNameV0 {
447        match Self::custom(authored) {
448            Self::Custom(payload) => payload.canonical,
449            Self::Standard(_) => unreachable!("custom constructor returned a standard name"),
450        }
451    }
452
453    pub fn canonical_standard_key(authored: impl Into<String>) -> CanonicalStandardPropertyNameV0 {
454        match Self::standard(authored) {
455            Self::Standard(payload) => payload.canonical,
456            Self::Custom(_) => unreachable!("standard constructor returned a custom name"),
457        }
458    }
459}
460
461#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
462struct CanonicalStandardPropertyNameSealV0(());
463
464/// A sealed standard-property lookup key.
465///
466/// Raw strings cannot borrow through this key; callers must construct a
467/// canonical key before a map lookup.
468///
469/// ```compile_fail,E0277
470/// use std::collections::BTreeMap;
471/// use omena_syntax::ident::{CanonicalStandardPropertyNameV0, PropertyNameV0};
472///
473/// let values = BTreeMap::<CanonicalStandardPropertyNameV0, usize>::new();
474/// let _ = values.get("COLOR");
475/// let _ = PropertyNameV0::canonical_standard_key("color");
476/// ```
477#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
478pub struct CanonicalStandardPropertyNameV0(String, CanonicalStandardPropertyNameSealV0);
479
480impl CanonicalStandardPropertyNameV0 {
481    pub fn as_str(&self) -> &str {
482        &self.0
483    }
484}
485
486impl serde::Serialize for CanonicalStandardPropertyNameV0 {
487    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
488    where
489        S: serde::Serializer,
490    {
491        serializer.serialize_str(self.as_str())
492    }
493}
494
495impl<'de> serde::Deserialize<'de> for CanonicalStandardPropertyNameV0 {
496    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
497    where
498        D: serde::Deserializer<'de>,
499    {
500        let authored = <String as serde::Deserialize>::deserialize(deserializer)?;
501        Ok(PropertyNameV0::canonical_standard_key(authored))
502    }
503}
504
505#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
506struct CanonicalCustomPropertyNameSealV0(());
507
508/// A sealed custom-property lookup key.
509///
510/// ```compile_fail,E0277
511/// use std::collections::BTreeMap;
512/// use omena_syntax::ident::{CanonicalCustomPropertyNameV0, PropertyNameV0};
513///
514/// let values = BTreeMap::<CanonicalCustomPropertyNameV0, usize>::new();
515/// let _ = values.get("--token");
516/// let _ = PropertyNameV0::canonical_custom_key("--token");
517/// ```
518#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
519pub struct CanonicalCustomPropertyNameV0(String, CanonicalCustomPropertyNameSealV0);
520
521impl CanonicalCustomPropertyNameV0 {
522    pub fn as_str(&self) -> &str {
523        &self.0
524    }
525}
526
527impl serde::Serialize for CanonicalCustomPropertyNameV0 {
528    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
529    where
530        S: serde::Serializer,
531    {
532        serializer.serialize_str(self.as_str())
533    }
534}
535
536impl<'de> serde::Deserialize<'de> for CanonicalCustomPropertyNameV0 {
537    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
538    where
539        D: serde::Deserializer<'de>,
540    {
541        let authored = <String as serde::Deserialize>::deserialize(deserializer)?;
542        Ok(PropertyNameV0::canonical_custom_key(authored))
543    }
544}
545
546#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
547pub enum CanonicalPropertyKeyV0 {
548    Standard(CanonicalStandardPropertyNameV0),
549    Custom(CanonicalCustomPropertyNameV0),
550}
551
552impl CanonicalPropertyKeyV0 {
553    pub fn as_str(&self) -> &str {
554        match self {
555            Self::Standard(name) => name.as_str(),
556            Self::Custom(name) => name.as_str(),
557        }
558    }
559
560    pub fn kind(&self) -> PropertyNameKindV0 {
561        match self {
562            Self::Standard(_) => PropertyNameKindV0::Standard,
563            Self::Custom(_) => PropertyNameKindV0::Custom,
564        }
565    }
566
567    pub fn as_custom(&self) -> Option<&CanonicalCustomPropertyNameV0> {
568        match self {
569            Self::Custom(name) => Some(name),
570            Self::Standard(_) => None,
571        }
572    }
573
574    pub fn as_standard(&self) -> Option<&CanonicalStandardPropertyNameV0> {
575        match self {
576            Self::Standard(name) => Some(name),
577            Self::Custom(_) => None,
578        }
579    }
580}
581
582impl serde::Serialize for CanonicalPropertyKeyV0 {
583    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
584    where
585        S: serde::Serializer,
586    {
587        serializer.serialize_str(self.as_str())
588    }
589}
590
591impl<'de> serde::Deserialize<'de> for CanonicalPropertyKeyV0 {
592    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
593    where
594        D: serde::Deserializer<'de>,
595    {
596        let authored = <String as serde::Deserialize>::deserialize(deserializer)?;
597        Ok(PropertyNameV0::from_authored(authored).canonical_key())
598    }
599}
600
601/// Compares two authored property spellings through the sole property-name
602/// identity authority.
603pub fn property_names_same(left: &str, right: &str) -> bool {
604    PropertyNameV0::from_authored(left).same_as(&PropertyNameV0::from_authored(right))
605}
606
607/// Classifies an authored property spelling through the sole property-name
608/// authority, including escaped leading hyphens.
609pub fn is_custom_property_name(authored: &str) -> bool {
610    PropertyNameV0::from_authored(authored).kind() == PropertyNameKindV0::Custom
611}
612
613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
614pub struct ClassSelectorPositionV0 {
615    pub start: usize,
616    pub end: usize,
617}
618
619#[derive(Debug, Clone)]
620pub struct ClassSelectorNameV0 {
621    pub name: ClassNameV0,
622    pub position: ClassSelectorPositionV0,
623}
624
625/// Returns whether a decoded character can start a CSS identifier name.
626pub fn is_css_name_start(ch: char) -> bool {
627    ch == '-' || ch == '_' || ch.is_ascii_alphabetic() || !ch.is_ascii()
628}
629
630/// Returns whether a decoded character can continue a CSS identifier name.
631pub fn is_css_name_continue(ch: char) -> bool {
632    is_css_name_start(ch) || ch.is_ascii_digit()
633}
634
635/// Returns whether a character belongs to the deliberately narrow ASCII word
636/// used by completion and hover cursor boundaries.
637///
638/// This is not the CSS identifier grammar. Widening it would change which word
639/// an editor request claims under the cursor.
640pub fn is_ascii_word_continue(ch: char) -> bool {
641    ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
642}
643
644pub fn is_safe_css_identifier(value: &str) -> bool {
645    let mut characters = value.chars();
646    let Some(first) = characters.next() else {
647        return false;
648    };
649    match first {
650        character
651            if character == '_' || character.is_ascii_alphabetic() || !character.is_ascii() => {}
652        '-' => {
653            let Some(second) = characters.next() else {
654                return false;
655            };
656            if !(second == '-'
657                || second == '_'
658                || second.is_ascii_alphabetic()
659                || !second.is_ascii())
660            {
661                return false;
662            }
663        }
664        _ => return false,
665    }
666    characters.all(is_css_name_continue)
667}
668
669pub fn decode_css_identifier_escapes(text: &str) -> Cow<'_, str> {
670    if !text.contains('\\') {
671        return Cow::Borrowed(text);
672    }
673
674    let mut output = String::with_capacity(text.len());
675    let mut index = 0usize;
676    while index < text.len() {
677        let Some(ch) = text[index..].chars().next() else {
678            break;
679        };
680        if ch != '\\' {
681            output.push(ch);
682            index += ch.len_utf8();
683            continue;
684        }
685
686        let escape_start = index;
687        index += ch.len_utf8();
688        let Some(next) = text[index..].chars().next() else {
689            output.push(char::REPLACEMENT_CHARACTER);
690            break;
691        };
692        if is_css_newline(next) {
693            output.push_str(&text[escape_start..index + next.len_utf8()]);
694            index += next.len_utf8();
695            continue;
696        }
697        if next.is_ascii_hexdigit() {
698            let hex_start = index;
699            let mut hex_end = index;
700            let mut digit_count = 0usize;
701            while hex_end < text.len() && digit_count < 6 {
702                let Some(candidate) = text[hex_end..].chars().next() else {
703                    break;
704                };
705                if !candidate.is_ascii_hexdigit() {
706                    break;
707                }
708                hex_end += candidate.len_utf8();
709                digit_count += 1;
710            }
711            let codepoint = u32::from_str_radix(&text[hex_start..hex_end], 16).ok();
712            output.push(
713                codepoint
714                    .filter(|value| *value != 0)
715                    .and_then(char::from_u32)
716                    .unwrap_or(char::REPLACEMENT_CHARACTER),
717            );
718            index = hex_end;
719            if let Some(terminator) = text[index..].chars().next()
720                && terminator.is_ascii_whitespace()
721            {
722                index += terminator.len_utf8();
723            }
724            continue;
725        }
726
727        output.push(next);
728        index += next.len_utf8();
729    }
730
731    Cow::Owned(output)
732}
733
734pub fn class_selector_name_end(text: &str, start: usize) -> Option<usize> {
735    let first = text.get(start..)?.chars().next()?;
736    let mut index = if first == '\\' {
737        css_identifier_escape_sequence_end(text, start)?
738    } else if is_css_name_start(first) {
739        start + first.len_utf8()
740    } else {
741        return None;
742    };
743
744    while index < text.len() {
745        let Some(ch) = text[index..].chars().next() else {
746            break;
747        };
748        if ch == '\\' {
749            let Some(end) = css_identifier_escape_sequence_end(text, index) else {
750                break;
751            };
752            index = end;
753        } else if is_css_name_continue(ch) {
754            index += ch.len_utf8();
755        } else {
756            break;
757        }
758    }
759    Some(index)
760}
761
762pub fn class_selector_names(selector: &str) -> Vec<ClassSelectorNameV0> {
763    if let Some(names) = ascii_class_selector_names(selector) {
764        return names;
765    }
766    general_class_selector_names(selector)
767}
768
769fn ascii_class_selector_names(selector: &str) -> Option<Vec<ClassSelectorNameV0>> {
770    let bytes = selector.as_bytes();
771    let mut names = Vec::new();
772    let mut index = 0usize;
773    let mut paren_depth = 0usize;
774    let mut bracket_depth = 0usize;
775    let mut quote = None;
776
777    while index < bytes.len() {
778        let byte = bytes[index];
779        if !byte.is_ascii() || byte == b'\\' {
780            return None;
781        }
782        if let Some(active_quote) = quote {
783            if byte == active_quote {
784                quote = None;
785            }
786            index += 1;
787            continue;
788        }
789        match byte {
790            b'"' | b'\'' => quote = Some(byte),
791            b'(' => paren_depth += 1,
792            b')' => paren_depth = paren_depth.saturating_sub(1),
793            b'[' => bracket_depth += 1,
794            b']' => bracket_depth = bracket_depth.saturating_sub(1),
795            b'.' if paren_depth == 0 && bracket_depth == 0 => {
796                let start = index + 1;
797                let Some(first) = bytes.get(start).copied() else {
798                    index += 1;
799                    continue;
800                };
801                if !ascii_css_name_start(first) {
802                    index += 1;
803                    continue;
804                }
805                let mut end = start + 1;
806                while end < bytes.len() && ascii_css_name_continue(bytes[end]) {
807                    end += 1;
808                }
809                names.push(ClassSelectorNameV0 {
810                    name: ClassNameV0::from_plain(&selector[start..end]),
811                    position: ClassSelectorPositionV0 { start, end },
812                });
813                index = end;
814                continue;
815            }
816            _ => {}
817        }
818        index += 1;
819    }
820    Some(names)
821}
822
823fn ascii_css_name_start(byte: u8) -> bool {
824    matches!(byte, b'-' | b'_') || byte.is_ascii_alphabetic()
825}
826
827fn ascii_css_name_continue(byte: u8) -> bool {
828    ascii_css_name_start(byte) || byte.is_ascii_digit()
829}
830
831fn general_class_selector_names(selector: &str) -> Vec<ClassSelectorNameV0> {
832    let mut names = Vec::new();
833    let mut index = 0usize;
834    let mut paren_depth = 0usize;
835    let mut bracket_depth = 0usize;
836    let mut quote = None;
837
838    while index < selector.len() {
839        let Some(ch) = selector[index..].chars().next() else {
840            break;
841        };
842        if ch == '\\' {
843            index = css_identifier_escape_sequence_end(selector, index)
844                .unwrap_or(index + ch.len_utf8());
845            continue;
846        }
847        if let Some(active_quote) = quote {
848            if ch == active_quote {
849                quote = None;
850            }
851            index += ch.len_utf8();
852            continue;
853        }
854        match ch {
855            '"' | '\'' => quote = Some(ch),
856            '(' => paren_depth += 1,
857            ')' => paren_depth = paren_depth.saturating_sub(1),
858            '[' => bracket_depth += 1,
859            ']' => bracket_depth = bracket_depth.saturating_sub(1),
860            '.' if paren_depth == 0 && bracket_depth == 0 => {
861                let start = index + ch.len_utf8();
862                if let Some(end) = class_selector_name_end(selector, start) {
863                    names.push(ClassSelectorNameV0 {
864                        name: ClassNameV0::new(&selector[start..end]),
865                        position: ClassSelectorPositionV0 { start, end },
866                    });
867                    index = end;
868                    continue;
869                }
870            }
871            _ => {}
872        }
873        index += ch.len_utf8();
874    }
875
876    names
877}
878
879/// Returns the byte immediately after a valid CSS identifier escape.
880///
881/// A newline or end-of-input after the reverse solidus is not a valid escape.
882pub fn css_identifier_escape_sequence_end(text: &str, slash_index: usize) -> Option<usize> {
883    if text[slash_index..].chars().next()? != '\\' {
884        return None;
885    }
886    let mut index = slash_index + '\\'.len_utf8();
887    let next = text[index..].chars().next()?;
888    if is_css_newline(next) {
889        return None;
890    }
891    if !next.is_ascii_hexdigit() {
892        return Some(index + next.len_utf8());
893    }
894
895    let mut digit_count = 0usize;
896    while index < text.len() && digit_count < 6 {
897        let Some(candidate) = text[index..].chars().next() else {
898            break;
899        };
900        if !candidate.is_ascii_hexdigit() {
901            break;
902        }
903        index += candidate.len_utf8();
904        digit_count += 1;
905    }
906    if let Some(terminator) = text[index..].chars().next()
907        && terminator.is_ascii_whitespace()
908    {
909        index += terminator.len_utf8();
910    }
911    Some(index)
912}
913
914fn is_css_newline(ch: char) -> bool {
915    matches!(ch, '\n' | '\r' | '\u{c}')
916}
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921
922    fn authored_text(property: &PropertyNameV0) -> String {
923        let mut output = String::new();
924        let result = property.authored_text().write_into(&mut output);
925        assert!(result.is_ok(), "writing into a String must not fail");
926        output
927    }
928
929    #[test]
930    fn decodes_css_escapes_without_changing_plain_names() {
931        // These assertions fail on the supplied escape spellings, all of which
932        // the public decoder accepts directly from CSS source.
933        assert!(matches!(
934            decode_css_identifier_escapes("plain"),
935            Cow::Borrowed("plain")
936        ));
937        assert_eq!(decode_css_identifier_escapes(r"a\.b"), "a.b");
938        assert_eq!(decode_css_identifier_escapes(r"\31 23"), "123");
939        assert_eq!(decode_css_identifier_escapes(r"\0"), "\u{fffd}");
940        assert_eq!(decode_css_identifier_escapes("\\"), "\u{fffd}");
941        assert_eq!(decode_css_identifier_escapes("\\\n"), "\\\n");
942    }
943
944    #[test]
945    fn identifier_escape_boundaries_reject_newline_and_end_of_input() {
946        assert_eq!(css_identifier_escape_sequence_end(r"\31 23", 0), Some(4));
947        assert_eq!(css_identifier_escape_sequence_end(r"\:", 0), Some(2));
948        assert_eq!(css_identifier_escape_sequence_end("\\\n", 0), None);
949        assert_eq!(css_identifier_escape_sequence_end("\\", 0), None);
950    }
951
952    #[test]
953    fn class_name_identity_is_decoded_but_raw_text_is_preserved() {
954        let escaped = ClassNameV0::new(r"a\.b");
955        let plain = ClassNameV0::new("a.b");
956
957        // A decoder or key regression makes these source-producible spellings
958        // unequal or mutates the raw spelling retained for egress.
959        assert!(escaped.same_as(&plain));
960        assert_eq!(escaped.raw(), r"a\.b");
961        assert_eq!(escaped.canonical_key().as_str(), "a.b");
962    }
963
964    #[test]
965    fn property_name_identity_preserves_custom_case_and_standard_case_folding() {
966        let custom_upper = PropertyNameV0::custom("--FOO");
967        let custom_lower = PropertyNameV0::custom("--foo");
968        let standard_upper = PropertyNameV0::standard("COLOR");
969        let standard_lower = PropertyNameV0::standard("color");
970
971        assert!(!custom_upper.same_as(&custom_lower));
972        assert!(standard_upper.same_as(&standard_lower));
973        assert_eq!(authored_text(&custom_upper), "--FOO");
974        assert_eq!(custom_upper.canonical_name(), "--FOO");
975        assert_eq!(authored_text(&standard_upper), "COLOR");
976        assert_eq!(standard_upper.canonical_name(), "color");
977    }
978
979    #[test]
980    fn custom_property_identity_decodes_escapes_without_destroying_authored_spelling() {
981        let escaped = PropertyNameV0::custom(r"--f\6f o");
982        let plain = PropertyNameV0::custom("--foo");
983
984        assert!(escaped.same_as(&plain));
985        assert_eq!(authored_text(&escaped), r"--f\6f o");
986        assert_eq!(escaped.canonical_name(), "--foo");
987        assert_eq!(
988            escaped
989                .as_custom_key()
990                .as_ref()
991                .map(CanonicalCustomPropertyNameV0::as_str),
992            Some("--foo")
993        );
994    }
995
996    #[test]
997    fn authored_text_serde_roundtrip_preserves_untrimmed_wire_bytes() {
998        let authored = serde_json::from_str::<AuthoredPropertyTextV0>(r#""  --f\\6f o  ""#);
999        assert!(
1000            authored.is_ok(),
1001            "transparent authored text should deserialize"
1002        );
1003        let Ok(authored) = authored else {
1004            return;
1005        };
1006        let serialized = serde_json::to_string(&authored);
1007        assert!(serialized.is_ok(), "authored text should serialize");
1008
1009        assert_eq!(serialized.ok().as_deref(), Some(r#""  --f\\6f o  ""#));
1010        assert_eq!(authored.to_custom_key().as_str(), "--foo");
1011    }
1012
1013    #[test]
1014    fn sealed_key_deserialization_preserves_forced_and_inferred_kind_rules() {
1015        let forced = serde_json::from_str::<CanonicalCustomPropertyNameV0>(r#""color""#);
1016        let inferred = serde_json::from_str::<CanonicalPropertyKeyV0>(r#""color""#);
1017        assert!(forced.is_ok(), "custom key should deserialize");
1018        assert!(inferred.is_ok(), "mixed key should deserialize");
1019        let (Ok(forced), Ok(inferred)) = (forced, inferred) else {
1020            return;
1021        };
1022
1023        assert_eq!(forced.as_str(), "color");
1024        assert_eq!(inferred.kind(), PropertyNameKindV0::Standard);
1025        assert_eq!(inferred.as_str(), "color");
1026    }
1027
1028    #[test]
1029    fn property_name_kind_is_classified_after_escape_decoding() {
1030        let property = PropertyNameV0::from_authored(r"\2d\2d FOO");
1031
1032        assert_eq!(property.kind(), PropertyNameKindV0::Custom);
1033        assert_eq!(authored_text(&property), r"\2d\2d FOO");
1034        assert_eq!(property.canonical_name(), "--FOO");
1035        assert!(is_custom_property_name(r"\2d\2d FOO"));
1036        assert!(!is_custom_property_name("COLOR"));
1037    }
1038
1039    #[test]
1040    fn ascii_class_scanner_matches_the_general_authority() {
1041        let selector = r#".card .title[data-x="a.b"]:is(.nested).plain"#;
1042        let summarize = |names: Vec<ClassSelectorNameV0>| {
1043            names
1044                .into_iter()
1045                .map(|entry| {
1046                    (
1047                        entry.name.into_raw(),
1048                        entry.position.start,
1049                        entry.position.end,
1050                    )
1051                })
1052                .collect::<Vec<_>>()
1053        };
1054
1055        let fast = ascii_class_selector_names(selector);
1056        assert!(fast.is_some(), "fixture must stay on the fast path");
1057        if let Some(fast) = fast {
1058            assert_eq!(
1059                summarize(fast),
1060                summarize(general_class_selector_names(selector))
1061            );
1062        }
1063    }
1064
1065    #[test]
1066    fn extracts_top_level_class_names_with_byte_positions() {
1067        let selector = r#".card .title[data-x="a.b"]:is(.nested).a\.b.\31 23.카드.café"#;
1068        let names = class_selector_names(selector);
1069        let raw = names
1070            .iter()
1071            .map(|entry| entry.name.raw())
1072            .collect::<Vec<_>>();
1073
1074        // The single selector exercises every branch and is itself a valid
1075        // scanner input, so omitting or splitting any name falsifies the row.
1076        assert_eq!(
1077            raw,
1078            vec!["card", "title", r"a\.b", r"\31 23", "카드", "café"]
1079        );
1080        assert_eq!(
1081            names
1082                .iter()
1083                .find(|entry| entry.name.raw() == "café")
1084                .map(|entry| entry.name.decoded().chars().count()),
1085            Some(4)
1086        );
1087        for entry in names {
1088            assert_eq!(
1089                &selector[entry.position.start..entry.position.end],
1090                entry.name.raw()
1091            );
1092        }
1093    }
1094
1095    #[test]
1096    fn distinguishes_css_name_and_ascii_word_boundaries() {
1097        // Each character and identifier is accepted directly by the relevant
1098        // public predicate; swapping or merging the two grammars falsifies it.
1099        assert!(is_css_name_start('카'));
1100        assert!(is_css_name_continue('é'));
1101        assert!(!is_ascii_word_continue('카'));
1102        assert!(is_ascii_word_continue('9'));
1103        assert!(is_safe_css_identifier("카드"));
1104        assert!(is_safe_css_identifier("--token"));
1105        assert!(!is_safe_css_identifier("-9token"));
1106        assert!(!is_safe_css_identifier("9token"));
1107    }
1108}