Skip to main content

use_react/
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 React component name metadata.
9#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10pub struct ReactComponentName(String);
11
12impl ReactComponentName {
13    /// Creates a `PascalCase` ASCII React component name.
14    ///
15    /// # Errors
16    ///
17    /// Returns [`ReactNameError`] when `input` is not an ASCII identifier or is not `PascalCase`-shaped.
18    pub fn new(input: &str) -> Result<Self, ReactNameError> {
19        let identifier = JsIdentifier::new(input).map_err(ReactNameError::Identifier)?;
20        if !identifier
21            .as_str()
22            .chars()
23            .next()
24            .is_some_and(|character| character.is_ascii_uppercase())
25        {
26            return Err(ReactNameError::NotPascalCase);
27        }
28        Ok(Self(identifier.as_str().to_string()))
29    }
30
31    /// Returns the component name.
32    #[must_use]
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36}
37
38impl fmt::Display for ReactComponentName {
39    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40        formatter.write_str(self.as_str())
41    }
42}
43
44impl FromStr for ReactComponentName {
45    type Err = ReactNameError;
46
47    fn from_str(input: &str) -> Result<Self, Self::Err> {
48        Self::new(input)
49    }
50}
51
52/// Validated React hook name metadata.
53#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub struct ReactHookName(String);
55
56impl ReactHookName {
57    /// Creates a lightly validated React hook name.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`ReactNameError`] when `input` is not an ASCII identifier or does not start with `use` plus a suffix.
62    pub fn new(input: &str) -> Result<Self, ReactNameError> {
63        let identifier = JsIdentifier::new(input).map_err(ReactNameError::Identifier)?;
64        let Some(suffix) = identifier.as_str().strip_prefix("use") else {
65            return Err(ReactNameError::NotHookName);
66        };
67        if suffix.is_empty() {
68            return Err(ReactNameError::NotHookName);
69        }
70        Ok(Self(identifier.as_str().to_string()))
71    }
72
73    /// Returns the hook name.
74    #[must_use]
75    pub fn as_str(&self) -> &str {
76        &self.0
77    }
78
79    /// Returns whether the hook uses the common `use` + uppercase convention.
80    #[must_use]
81    pub fn has_canonical_suffix(&self) -> bool {
82        self.0
83            .chars()
84            .nth(3)
85            .is_some_and(|character| character.is_ascii_uppercase())
86    }
87}
88
89/// React JSX runtime labels.
90#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
91pub enum ReactJsxRuntime {
92    Classic,
93    Automatic,
94}
95
96impl ReactJsxRuntime {
97    /// Returns the JSX runtime label.
98    #[must_use]
99    pub const fn as_str(self) -> &'static str {
100        match self {
101            Self::Classic => "classic",
102            Self::Automatic => "automatic",
103        }
104    }
105}
106
107/// React file-kind metadata.
108#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
109pub enum ReactFileKind {
110    Component,
111    Hook,
112    Context,
113    Provider,
114    Page,
115    Layout,
116}
117
118/// Error returned when React name metadata is invalid.
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub enum ReactNameError {
121    Identifier(JsIdentifierError),
122    NotPascalCase,
123    NotHookName,
124}
125
126impl fmt::Display for ReactNameError {
127    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            Self::Identifier(error) => write!(formatter, "invalid JavaScript identifier: {error}"),
130            Self::NotPascalCase => {
131                formatter.write_str("React component name must be PascalCase-shaped")
132            }
133            Self::NotHookName => {
134                formatter.write_str("React hook name must start with use and include a suffix")
135            }
136        }
137    }
138}
139
140impl Error for ReactNameError {
141    fn source(&self) -> Option<&(dyn Error + 'static)> {
142        match self {
143            Self::Identifier(error) => Some(error),
144            Self::NotPascalCase | Self::NotHookName => None,
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::{ReactComponentName, ReactHookName, ReactJsxRuntime, ReactNameError};
152
153    #[test]
154    fn validates_component_names() -> Result<(), ReactNameError> {
155        let component = ReactComponentName::new("AppShell")?;
156        assert_eq!(component.as_str(), "AppShell");
157        assert_eq!(
158            ReactComponentName::new("appShell"),
159            Err(ReactNameError::NotPascalCase)
160        );
161        Ok(())
162    }
163
164    #[test]
165    fn validates_hook_names() -> Result<(), ReactNameError> {
166        let hook = ReactHookName::new("useSession")?;
167        assert_eq!(hook.as_str(), "useSession");
168        assert!(hook.has_canonical_suffix());
169        assert_eq!(ReactHookName::new("use"), Err(ReactNameError::NotHookName));
170        assert_eq!(ReactJsxRuntime::Automatic.as_str(), "automatic");
171        Ok(())
172    }
173}