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 ReactComponentName(String);
11
12impl ReactComponentName {
13 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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub struct ReactHookName(String);
55
56impl ReactHookName {
57 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 #[must_use]
75 pub fn as_str(&self) -> &str {
76 &self.0
77 }
78
79 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
91pub enum ReactJsxRuntime {
92 Classic,
93 Automatic,
94}
95
96impl ReactJsxRuntime {
97 #[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#[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#[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}