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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10pub struct PreactComponentName(String);
11
12impl PreactComponentName {
13 pub fn new(input: &str) -> Result<Self, PreactNameError> {
19 validate_pascal_case(input).map(Self)
20 }
21
22 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
53pub struct PreactHookName(String);
54
55impl PreactHookName {
56 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 #[must_use]
74 pub fn as_str(&self) -> &str {
75 &self.0
76 }
77
78 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
112pub enum PreactJsxRuntime {
113 Classic,
114 Automatic,
115}
116
117impl PreactJsxRuntime {
118 #[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#[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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
196pub enum PreactCompatMode {
197 Native,
198 Compat,
199}
200
201impl PreactCompatMode {
202 #[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#[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}