Skip to main content

use_preact/
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 Preact component name metadata.
9#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10pub struct PreactComponentName(String);
11
12impl PreactComponentName {
13    /// Creates a `PascalCase` ASCII Preact component name.
14    ///
15    /// # Errors
16    ///
17    /// Returns [`PreactNameError`] when `input` is not an ASCII identifier or is not `PascalCase`-shaped.
18    pub fn new(input: &str) -> Result<Self, PreactNameError> {
19        validate_pascal_case(input).map(Self)
20    }
21
22    /// Returns the component name.
23    #[must_use]
24    pub fn as_str(&self) -> &str {
25        &self.0
26    }
27}
28
29impl fmt::Display for PreactComponentName {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        formatter.write_str(self.as_str())
32    }
33}
34
35impl FromStr for PreactComponentName {
36    type Err = PreactNameError;
37
38    fn from_str(input: &str) -> Result<Self, Self::Err> {
39        Self::new(input)
40    }
41}
42
43impl TryFrom<&str> for PreactComponentName {
44    type Error = PreactNameError;
45
46    fn try_from(value: &str) -> Result<Self, Self::Error> {
47        Self::new(value)
48    }
49}
50
51/// Validated Preact hook name metadata.
52#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
53pub struct PreactHookName(String);
54
55impl PreactHookName {
56    /// Creates a lightly validated Preact hook name.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`PreactNameError`] when `input` is not an ASCII identifier or does not start with `use` plus a suffix.
61    pub fn new(input: &str) -> Result<Self, PreactNameError> {
62        let identifier = JsIdentifier::new(input).map_err(PreactNameError::Identifier)?;
63        let Some(suffix) = identifier.as_str().strip_prefix("use") else {
64            return Err(PreactNameError::NotHookName);
65        };
66        if suffix.is_empty() {
67            return Err(PreactNameError::NotHookName);
68        }
69        Ok(Self(identifier.as_str().to_string()))
70    }
71
72    /// Returns the hook name.
73    #[must_use]
74    pub fn as_str(&self) -> &str {
75        &self.0
76    }
77
78    /// Returns whether the hook uses the common `use` + uppercase convention.
79    #[must_use]
80    pub fn has_canonical_suffix(&self) -> bool {
81        self.0
82            .chars()
83            .nth(3)
84            .is_some_and(|character| character.is_ascii_uppercase())
85    }
86}
87
88impl fmt::Display for PreactHookName {
89    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90        formatter.write_str(self.as_str())
91    }
92}
93
94impl FromStr for PreactHookName {
95    type Err = PreactNameError;
96
97    fn from_str(input: &str) -> Result<Self, Self::Err> {
98        Self::new(input)
99    }
100}
101
102impl TryFrom<&str> for PreactHookName {
103    type Error = PreactNameError;
104
105    fn try_from(value: &str) -> Result<Self, Self::Error> {
106        Self::new(value)
107    }
108}
109
110/// Preact JSX runtime labels.
111#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
112pub enum PreactJsxRuntime {
113    Classic,
114    Automatic,
115}
116
117impl PreactJsxRuntime {
118    /// Returns the JSX runtime label.
119    #[must_use]
120    pub const fn as_str(self) -> &'static str {
121        match self {
122            Self::Classic => "classic",
123            Self::Automatic => "automatic",
124        }
125    }
126}
127
128impl fmt::Display for PreactJsxRuntime {
129    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
130        formatter.write_str(self.as_str())
131    }
132}
133
134impl FromStr for PreactJsxRuntime {
135    type Err = PreactNameError;
136
137    fn from_str(input: &str) -> Result<Self, Self::Err> {
138        match normalized_label(input)?.as_str() {
139            "classic" => Ok(Self::Classic),
140            "automatic" | "auto" => Ok(Self::Automatic),
141            _ => Err(PreactNameError::UnknownLabel),
142        }
143    }
144}
145
146/// Preact file-kind labels.
147#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
148pub enum PreactFileKind {
149    Component,
150    Hook,
151    Context,
152    Provider,
153    Page,
154    Layout,
155}
156
157impl PreactFileKind {
158    /// Returns the file-kind label.
159    #[must_use]
160    pub const fn as_str(self) -> &'static str {
161        match self {
162            Self::Component => "component",
163            Self::Hook => "hook",
164            Self::Context => "context",
165            Self::Provider => "provider",
166            Self::Page => "page",
167            Self::Layout => "layout",
168        }
169    }
170}
171
172impl fmt::Display for PreactFileKind {
173    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
174        formatter.write_str(self.as_str())
175    }
176}
177
178impl FromStr for PreactFileKind {
179    type Err = PreactNameError;
180
181    fn from_str(input: &str) -> Result<Self, Self::Err> {
182        match normalized_label(input)?.as_str() {
183            "component" => Ok(Self::Component),
184            "hook" => Ok(Self::Hook),
185            "context" => Ok(Self::Context),
186            "provider" => Ok(Self::Provider),
187            "page" => Ok(Self::Page),
188            "layout" => Ok(Self::Layout),
189            _ => Err(PreactNameError::UnknownLabel),
190        }
191    }
192}
193
194/// Preact compatibility mode labels.
195#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
196pub enum PreactCompatMode {
197    Native,
198    Compat,
199}
200
201impl PreactCompatMode {
202    /// Returns the compatibility mode label.
203    #[must_use]
204    pub const fn as_str(self) -> &'static str {
205        match self {
206            Self::Native => "native",
207            Self::Compat => "compat",
208        }
209    }
210}
211
212impl fmt::Display for PreactCompatMode {
213    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214        formatter.write_str(self.as_str())
215    }
216}
217
218impl FromStr for PreactCompatMode {
219    type Err = PreactNameError;
220
221    fn from_str(input: &str) -> Result<Self, Self::Err> {
222        match normalized_label(input)?.as_str() {
223            "native" => Ok(Self::Native),
224            "compat" | "preactcompat" => Ok(Self::Compat),
225            _ => Err(PreactNameError::UnknownLabel),
226        }
227    }
228}
229
230/// Error returned when Preact metadata is invalid.
231#[derive(Clone, Debug, Eq, PartialEq)]
232pub enum PreactNameError {
233    Identifier(JsIdentifierError),
234    NotPascalCase,
235    NotHookName,
236    Empty,
237    UnknownLabel,
238}
239
240impl fmt::Display for PreactNameError {
241    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
242        match self {
243            Self::Identifier(error) => write!(formatter, "{error}"),
244            Self::NotPascalCase => {
245                formatter.write_str("Preact component name must be `PascalCase`-shaped")
246            }
247            Self::NotHookName => {
248                formatter.write_str("Preact hook name must start with `use` and include a suffix")
249            }
250            Self::Empty => formatter.write_str("Preact metadata label cannot be empty"),
251            Self::UnknownLabel => formatter.write_str("unknown Preact metadata label"),
252        }
253    }
254}
255
256impl Error for PreactNameError {
257    fn source(&self) -> Option<&(dyn Error + 'static)> {
258        match self {
259            Self::Identifier(error) => Some(error),
260            Self::NotPascalCase | Self::NotHookName | Self::Empty | Self::UnknownLabel => None,
261        }
262    }
263}
264
265fn validate_pascal_case(input: &str) -> Result<String, PreactNameError> {
266    let identifier = JsIdentifier::new(input).map_err(PreactNameError::Identifier)?;
267    if !identifier
268        .as_str()
269        .chars()
270        .next()
271        .is_some_and(|character| character.is_ascii_uppercase())
272    {
273        return Err(PreactNameError::NotPascalCase);
274    }
275    Ok(identifier.as_str().to_string())
276}
277
278fn normalized_label(input: &str) -> Result<String, PreactNameError> {
279    let trimmed = input.trim();
280    if trimmed.is_empty() {
281        return Err(PreactNameError::Empty);
282    }
283    Ok(trimmed
284        .chars()
285        .filter(|character| !matches!(character, '-' | '_' | ' '))
286        .flat_map(char::to_lowercase)
287        .collect())
288}
289
290#[cfg(test)]
291mod tests {
292    use super::{
293        PreactCompatMode, PreactComponentName, PreactFileKind, PreactHookName, PreactJsxRuntime,
294        PreactNameError,
295    };
296
297    #[test]
298    fn validates_component_names() -> Result<(), PreactNameError> {
299        let component = PreactComponentName::new("AppShell")?;
300        assert_eq!(component.as_str(), "AppShell");
301        assert_eq!(
302            PreactComponentName::new("appShell"),
303            Err(PreactNameError::NotPascalCase)
304        );
305        assert!(PreactComponentName::new("app-shell").is_err());
306        Ok(())
307    }
308
309    #[test]
310    fn validates_hook_names() -> Result<(), PreactNameError> {
311        let hook = PreactHookName::new("useSignal")?;
312        assert_eq!(hook.as_str(), "useSignal");
313        assert!(hook.has_canonical_suffix());
314        assert_eq!(
315            PreactHookName::new("signal"),
316            Err(PreactNameError::NotHookName)
317        );
318        assert_eq!(
319            PreactHookName::new("use"),
320            Err(PreactNameError::NotHookName)
321        );
322        Ok(())
323    }
324
325    #[test]
326    fn parses_labels() -> Result<(), PreactNameError> {
327        assert_eq!(
328            "automatic".parse::<PreactJsxRuntime>()?,
329            PreactJsxRuntime::Automatic
330        );
331        assert_eq!(
332            "provider".parse::<PreactFileKind>()?,
333            PreactFileKind::Provider
334        );
335        assert_eq!(
336            "compat".parse::<PreactCompatMode>()?,
337            PreactCompatMode::Compat
338        );
339        assert_eq!(PreactCompatMode::Native.to_string(), "native");
340        Ok(())
341    }
342}