Skip to main content

leptos_classes/
class_name.rs

1use std::borrow::Cow;
2use std::ops::Deref;
3
4/// Possible reasons why a candidate name is not a valid `class` name.
5#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
6pub enum InvalidClassName {
7    /// The name was empty or contained only whitespace (any Unicode whitespace character,
8    /// including ASCII space/tab/newline and characters such as the non-breaking space
9    /// `U+00A0`).
10    #[error("Class name is empty or whitespace-only: '{name}'")]
11    Empty {
12        /// The offending input (itself blank).
13        name: Cow<'static, str>,
14    },
15
16    /// The name contained whitespace, suggesting the caller passed a whitespace-separated
17    /// class list rather than a single token. "Whitespace" here is the Unicode definition
18    /// (`char::is_whitespace`), which covers ASCII space/tab/newline and characters such as
19    /// the non-breaking space `U+00A0` that paste-from-rich-text sources sometimes inject.
20    #[error("Class names must not be whitespace-separated. Got: '{name}'.")]
21    ContainsWhitespace {
22        /// The offending input.
23        name: Cow<'static, str>,
24    },
25}
26
27/// A validated single CSS class token.
28///
29/// A `ClassName` always holds a non-empty string with no whitespace (Unicode definition: any
30/// character for which [`char::is_whitespace`] returns `true`). Use [`ClassName::try_new`] for
31/// runtime input you want to handle without panicking, or one of the `From` impls, which always
32/// panic on invalid input, for known-valid literals.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct ClassName(Cow<'static, str>);
35
36impl ClassName {
37    /// Validates `name` and returns a [`ClassName`] on success.
38    ///
39    /// Returns [`InvalidClassName::Empty`] if `name` is empty or only whitespace, and
40    /// [`InvalidClassName::ContainsWhitespace`] if it contains any whitespace. Both checks use
41    /// the Unicode definition of whitespace ([`char::is_whitespace`]), so non-breaking spaces
42    /// (`U+00A0`) and line/paragraph separators are rejected just like ASCII whitespace.
43    pub fn try_new(name: impl Into<Cow<'static, str>>) -> Result<Self, InvalidClassName> {
44        let name = name.into();
45        if name.trim().is_empty() {
46            return Err(InvalidClassName::Empty { name });
47        }
48        if name.chars().any(char::is_whitespace) {
49            return Err(InvalidClassName::ContainsWhitespace { name });
50        }
51        Ok(Self(name))
52    }
53
54    /// Borrows the underlying class token as a string slice.
55    #[must_use]
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59}
60
61impl Deref for ClassName {
62    type Target = str;
63
64    fn deref(&self) -> &str {
65        self.as_str()
66    }
67}
68
69impl AsRef<str> for ClassName {
70    fn as_ref(&self) -> &str {
71        self.as_str()
72    }
73}
74
75impl From<&'static str> for ClassName {
76    fn from(s: &'static str) -> Self {
77        Self::try_new(s).unwrap_or_else(|err| panic!("{err}"))
78    }
79}
80
81impl From<String> for ClassName {
82    fn from(s: String) -> Self {
83        Self::try_new(s).unwrap_or_else(|err| panic!("{err}"))
84    }
85}
86
87impl From<Cow<'static, str>> for ClassName {
88    fn from(s: Cow<'static, str>) -> Self {
89        Self::try_new(s).unwrap_or_else(|err| panic!("{err}"))
90    }
91}
92
93#[cfg(test)]
94mod try_new {
95    use assertr::prelude::*;
96
97    use super::{ClassName, InvalidClassName};
98
99    #[test]
100    fn accepts_plain_ascii_token() {
101        let name = ClassName::try_new("btn-primary").unwrap();
102        assert_that!(name.as_str()).is_equal_to("btn-primary");
103    }
104
105    #[test]
106    fn accepts_non_whitespace_unicode_token() {
107        // CSS3 allows Unicode identifiers; tokens with non-whitespace Unicode letters are valid.
108        let name = ClassName::try_new("h\u{00E9}ros").unwrap();
109        assert_that!(name.as_str()).is_equal_to("h\u{00E9}ros");
110    }
111
112    #[test]
113    fn rejects_empty_input() {
114        assert_that!(ClassName::try_new("")).is_err();
115    }
116
117    #[test]
118    fn rejects_ascii_whitespace_only_input() {
119        let result = ClassName::try_new(" \t\n");
120        assert_that!(result)
121            .is_err()
122            .is_equal_to(InvalidClassName::Empty {
123                name: " \t\n".into(),
124            });
125    }
126
127    #[test]
128    fn rejects_unicode_whitespace_only_input() {
129        // U+00A0 NO-BREAK SPACE alone should classify as empty/blank.
130        let result = ClassName::try_new("\u{00A0}\u{00A0}");
131        assert_that!(result)
132            .is_err()
133            .is_equal_to(InvalidClassName::Empty {
134                name: "\u{00A0}\u{00A0}".into(),
135            });
136    }
137
138    #[test]
139    fn rejects_token_with_ascii_whitespace_in_middle() {
140        let result = ClassName::try_new("foo bar");
141        assert_that!(result)
142            .is_err()
143            .is_equal_to(InvalidClassName::ContainsWhitespace {
144                name: "foo bar".into(),
145            });
146    }
147
148    #[test]
149    fn rejects_token_with_non_breaking_space_in_middle() {
150        // Defends the contract change: NBSP between letters is whitespace under the Unicode
151        // definition and must fail validation even though its bytes are not ASCII whitespace.
152        let result = ClassName::try_new("foo\u{00A0}bar");
153        assert_that!(result)
154            .is_err()
155            .is_equal_to(InvalidClassName::ContainsWhitespace {
156                name: "foo\u{00A0}bar".into(),
157            });
158    }
159
160    #[test]
161    fn rejects_token_with_line_separator_in_middle() {
162        // U+2028 LINE SEPARATOR is Unicode-whitespace.
163        let result = ClassName::try_new("foo\u{2028}bar");
164        assert_that!(result)
165            .is_err()
166            .is_equal_to(InvalidClassName::ContainsWhitespace {
167                name: "foo\u{2028}bar".into(),
168            });
169    }
170}