Skip to main content

use_lit/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6use use_js_identifier::{JsIdentifier, JsIdentifierError};
7
8/// Validated Lit custom element name metadata.
9#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10pub struct LitElementName(String);
11
12impl LitElementName {
13    /// Creates Lit custom element name metadata.
14    ///
15    /// # Errors
16    ///
17    /// Returns [`LitNameError`] when `input` is empty, contains whitespace, or is not custom-element-shaped.
18    pub fn new(input: &str) -> Result<Self, LitNameError> {
19        let trimmed = input.trim();
20        if trimmed.is_empty() {
21            return Err(LitNameError::Empty);
22        }
23        if trimmed.chars().any(char::is_whitespace) {
24            return Err(LitNameError::ContainsWhitespace);
25        }
26        if !is_custom_element_name(trimmed) {
27            return Err(LitNameError::InvalidElementName);
28        }
29        Ok(Self(trimmed.to_string()))
30    }
31
32    /// Returns the element name.
33    #[must_use]
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37}
38
39impl fmt::Display for LitElementName {
40    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41        formatter.write_str(self.as_str())
42    }
43}
44
45impl FromStr for LitElementName {
46    type Err = LitNameError;
47
48    fn from_str(input: &str) -> Result<Self, Self::Err> {
49        Self::new(input)
50    }
51}
52
53impl TryFrom<&str> for LitElementName {
54    type Error = LitNameError;
55
56    fn try_from(value: &str) -> Result<Self, Self::Error> {
57        Self::new(value)
58    }
59}
60
61/// Validated Lit property name metadata.
62#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
63pub struct LitPropertyName(String);
64
65impl LitPropertyName {
66    /// Creates Lit property name metadata.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`LitNameError`] when `input` is not an ASCII JavaScript identifier.
71    pub fn new(input: &str) -> Result<Self, LitNameError> {
72        let identifier = JsIdentifier::new(input).map_err(LitNameError::Identifier)?;
73        Ok(Self(identifier.as_str().to_string()))
74    }
75
76    /// Returns the property name.
77    #[must_use]
78    pub fn as_str(&self) -> &str {
79        &self.0
80    }
81}
82
83impl fmt::Display for LitPropertyName {
84    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85        formatter.write_str(self.as_str())
86    }
87}
88
89impl FromStr for LitPropertyName {
90    type Err = LitNameError;
91
92    fn from_str(input: &str) -> Result<Self, Self::Err> {
93        Self::new(input)
94    }
95}
96
97impl TryFrom<&str> for LitPropertyName {
98    type Error = LitNameError;
99
100    fn try_from(value: &str) -> Result<Self, Self::Error> {
101        Self::new(value)
102    }
103}
104
105/// Validated Lit decorator name metadata.
106#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
107pub struct LitDecoratorName(String);
108
109impl LitDecoratorName {
110    /// Creates Lit decorator name metadata.
111    ///
112    /// # Errors
113    ///
114    /// Returns [`LitNameError`] when `input` is empty, contains whitespace, or has unsupported characters.
115    pub fn new(input: &str) -> Result<Self, LitNameError> {
116        let trimmed = input.trim();
117        let decorator = trimmed.strip_prefix('@').unwrap_or(trimmed);
118        if decorator.is_empty() {
119            return Err(LitNameError::Empty);
120        }
121        if decorator.chars().any(char::is_whitespace) {
122            return Err(LitNameError::ContainsWhitespace);
123        }
124        if !decorator.chars().all(is_decorator_character) {
125            return Err(LitNameError::InvalidDecoratorName);
126        }
127        Ok(Self(decorator.to_string()))
128    }
129
130    /// Returns the decorator name without a leading `@`.
131    #[must_use]
132    pub fn as_str(&self) -> &str {
133        &self.0
134    }
135}
136
137impl fmt::Display for LitDecoratorName {
138    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
139        formatter.write_str(self.as_str())
140    }
141}
142
143impl FromStr for LitDecoratorName {
144    type Err = LitNameError;
145
146    fn from_str(input: &str) -> Result<Self, Self::Err> {
147        Self::new(input)
148    }
149}
150
151impl TryFrom<&str> for LitDecoratorName {
152    type Error = LitNameError;
153
154    fn try_from(value: &str) -> Result<Self, Self::Error> {
155        Self::new(value)
156    }
157}
158
159/// Lit file-kind labels.
160#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
161pub enum LitFileKind {
162    Element,
163    Template,
164    Styles,
165    Controller,
166    Directive,
167    Decorator,
168}
169
170impl LitFileKind {
171    /// Returns the file-kind label.
172    #[must_use]
173    pub const fn as_str(self) -> &'static str {
174        match self {
175            Self::Element => "element",
176            Self::Template => "template",
177            Self::Styles => "styles",
178            Self::Controller => "controller",
179            Self::Directive => "directive",
180            Self::Decorator => "decorator",
181        }
182    }
183}
184
185impl fmt::Display for LitFileKind {
186    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
187        formatter.write_str(self.as_str())
188    }
189}
190
191impl FromStr for LitFileKind {
192    type Err = LitNameError;
193
194    fn from_str(input: &str) -> Result<Self, Self::Err> {
195        match normalized_label(input)?.as_str() {
196            "element" => Ok(Self::Element),
197            "template" => Ok(Self::Template),
198            "styles" | "style" => Ok(Self::Styles),
199            "controller" => Ok(Self::Controller),
200            "directive" => Ok(Self::Directive),
201            "decorator" => Ok(Self::Decorator),
202            _ => Err(LitNameError::UnknownLabel),
203        }
204    }
205}
206
207/// Lit template-kind labels.
208#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
209pub enum LitTemplateKind {
210    Html,
211    Svg,
212    Css,
213    StaticHtml,
214}
215
216impl LitTemplateKind {
217    /// Returns the template-kind label.
218    #[must_use]
219    pub const fn as_str(self) -> &'static str {
220        match self {
221            Self::Html => "html",
222            Self::Svg => "svg",
223            Self::Css => "css",
224            Self::StaticHtml => "static-html",
225        }
226    }
227}
228
229impl fmt::Display for LitTemplateKind {
230    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
231        formatter.write_str(self.as_str())
232    }
233}
234
235impl FromStr for LitTemplateKind {
236    type Err = LitNameError;
237
238    fn from_str(input: &str) -> Result<Self, Self::Err> {
239        match normalized_label(input)?.as_str() {
240            "html" => Ok(Self::Html),
241            "svg" => Ok(Self::Svg),
242            "css" => Ok(Self::Css),
243            "statichtml" => Ok(Self::StaticHtml),
244            _ => Err(LitNameError::UnknownLabel),
245        }
246    }
247}
248
249/// Error returned when Lit metadata is invalid.
250#[derive(Clone, Debug, Eq, PartialEq)]
251pub enum LitNameError {
252    Empty,
253    ContainsWhitespace,
254    Identifier(JsIdentifierError),
255    InvalidElementName,
256    InvalidDecoratorName,
257    UnknownLabel,
258}
259
260impl fmt::Display for LitNameError {
261    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
262        match self {
263            Self::Empty => formatter.write_str("Lit metadata text cannot be empty"),
264            Self::ContainsWhitespace => {
265                formatter.write_str("Lit metadata text cannot contain whitespace")
266            }
267            Self::Identifier(error) => write!(formatter, "{error}"),
268            Self::InvalidElementName => formatter.write_str("invalid Lit custom element name"),
269            Self::InvalidDecoratorName => formatter.write_str("invalid Lit decorator name"),
270            Self::UnknownLabel => formatter.write_str("unknown Lit metadata label"),
271        }
272    }
273}
274
275impl Error for LitNameError {
276    fn source(&self) -> Option<&(dyn Error + 'static)> {
277        match self {
278            Self::Identifier(error) => Some(error),
279            Self::Empty
280            | Self::ContainsWhitespace
281            | Self::InvalidElementName
282            | Self::InvalidDecoratorName
283            | Self::UnknownLabel => None,
284        }
285    }
286}
287
288fn is_custom_element_name(input: &str) -> bool {
289    input.contains('-')
290        && input.split('-').all(|segment| !segment.is_empty())
291        && input.chars().all(|character| {
292            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
293        })
294}
295
296const fn is_decorator_character(character: char) -> bool {
297    character.is_ascii_alphanumeric() || character == '_'
298}
299
300fn normalized_label(input: &str) -> Result<String, LitNameError> {
301    let trimmed = input.trim();
302    if trimmed.is_empty() {
303        return Err(LitNameError::Empty);
304    }
305    Ok(trimmed
306        .chars()
307        .filter(|character| !matches!(character, '-' | '_' | ' '))
308        .flat_map(char::to_lowercase)
309        .collect())
310}
311
312#[cfg(test)]
313mod tests {
314    use super::{
315        LitDecoratorName, LitElementName, LitFileKind, LitNameError, LitPropertyName,
316        LitTemplateKind,
317    };
318
319    #[test]
320    fn validates_element_names() -> Result<(), LitNameError> {
321        let element = LitElementName::new("app-shell")?;
322        assert_eq!(element.as_str(), "app-shell");
323        assert_eq!(
324            LitElementName::new("app"),
325            Err(LitNameError::InvalidElementName)
326        );
327        assert_eq!(
328            LitElementName::new("AppShell"),
329            Err(LitNameError::InvalidElementName)
330        );
331        assert_eq!(
332            LitElementName::new("app shell"),
333            Err(LitNameError::ContainsWhitespace)
334        );
335        Ok(())
336    }
337
338    #[test]
339    fn validates_property_and_decorator_names() -> Result<(), LitNameError> {
340        assert_eq!(LitPropertyName::new("isOpen")?.as_str(), "isOpen");
341        assert_eq!(LitDecoratorName::new("@property")?.as_str(), "property");
342        assert!(LitPropertyName::new("is-open").is_err());
343        assert_eq!(
344            LitDecoratorName::new("@custom-element"),
345            Err(LitNameError::InvalidDecoratorName)
346        );
347        Ok(())
348    }
349
350    #[test]
351    fn parses_labels() -> Result<(), LitNameError> {
352        assert_eq!(
353            "controller".parse::<LitFileKind>()?,
354            LitFileKind::Controller
355        );
356        assert_eq!(
357            "static-html".parse::<LitTemplateKind>()?,
358            LitTemplateKind::StaticHtml
359        );
360        assert_eq!(LitTemplateKind::Html.to_string(), "html");
361        Ok(())
362    }
363}