Skip to main content

use_svelte/
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 Svelte component name metadata.
9#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10pub struct SvelteComponentName(String);
11
12impl SvelteComponentName {
13    /// Creates a `PascalCase` ASCII Svelte component name.
14    ///
15    /// # Errors
16    ///
17    /// Returns [`SvelteNameError`] when `input` is not an ASCII identifier or is not `PascalCase`-shaped.
18    pub fn new(input: &str) -> Result<Self, SvelteNameError> {
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 SvelteComponentName {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        formatter.write_str(self.as_str())
32    }
33}
34
35impl FromStr for SvelteComponentName {
36    type Err = SvelteNameError;
37
38    fn from_str(input: &str) -> Result<Self, Self::Err> {
39        Self::new(input)
40    }
41}
42
43impl TryFrom<&str> for SvelteComponentName {
44    type Error = SvelteNameError;
45
46    fn try_from(value: &str) -> Result<Self, Self::Error> {
47        Self::new(value)
48    }
49}
50
51/// Validated Svelte directive name metadata.
52#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
53pub struct SvelteDirectiveName(String);
54
55impl SvelteDirectiveName {
56    /// Creates a lightly validated Svelte directive name.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`SvelteNameError`] when `input` is empty, contains whitespace, or has unsupported characters.
61    pub fn new(input: &str) -> Result<Self, SvelteNameError> {
62        let trimmed = input.trim();
63        if trimmed.is_empty() {
64            return Err(SvelteNameError::Empty);
65        }
66        if trimmed.chars().any(char::is_whitespace) {
67            return Err(SvelteNameError::ContainsWhitespace);
68        }
69        if !trimmed.chars().all(is_directive_character) || trimmed.split(':').any(str::is_empty) {
70            return Err(SvelteNameError::InvalidDirective);
71        }
72        Ok(Self(trimmed.to_string()))
73    }
74
75    /// Returns the directive name.
76    #[must_use]
77    pub fn as_str(&self) -> &str {
78        &self.0
79    }
80}
81
82impl fmt::Display for SvelteDirectiveName {
83    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84        formatter.write_str(self.as_str())
85    }
86}
87
88impl FromStr for SvelteDirectiveName {
89    type Err = SvelteNameError;
90
91    fn from_str(input: &str) -> Result<Self, Self::Err> {
92        Self::new(input)
93    }
94}
95
96impl TryFrom<&str> for SvelteDirectiveName {
97    type Error = SvelteNameError;
98
99    fn try_from(value: &str) -> Result<Self, Self::Error> {
100        Self::new(value)
101    }
102}
103
104/// Svelte file-kind labels.
105#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
106pub enum SvelteFileKind {
107    Component,
108    Page,
109    Layout,
110    Error,
111    Server,
112    Config,
113}
114
115impl SvelteFileKind {
116    /// Returns the file-kind label.
117    #[must_use]
118    pub const fn as_str(self) -> &'static str {
119        match self {
120            Self::Component => "component",
121            Self::Page => "page",
122            Self::Layout => "layout",
123            Self::Error => "error",
124            Self::Server => "server",
125            Self::Config => "config",
126        }
127    }
128}
129
130impl fmt::Display for SvelteFileKind {
131    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132        formatter.write_str(self.as_str())
133    }
134}
135
136impl FromStr for SvelteFileKind {
137    type Err = SvelteNameError;
138
139    fn from_str(input: &str) -> Result<Self, Self::Err> {
140        match normalized_label(input)?.as_str() {
141            "component" => Ok(Self::Component),
142            "page" => Ok(Self::Page),
143            "layout" => Ok(Self::Layout),
144            "error" => Ok(Self::Error),
145            "server" => Ok(Self::Server),
146            "config" => Ok(Self::Config),
147            _ => Err(SvelteNameError::UnknownLabel),
148        }
149    }
150}
151
152/// `SvelteKit` directory labels.
153#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
154pub enum SvelteKitDirectoryKind {
155    Routes,
156    Lib,
157    Static,
158    Params,
159    Hooks,
160    Server,
161}
162
163impl SvelteKitDirectoryKind {
164    /// Returns the directory label.
165    #[must_use]
166    pub const fn as_str(self) -> &'static str {
167        match self {
168            Self::Routes => "routes",
169            Self::Lib => "lib",
170            Self::Static => "static",
171            Self::Params => "params",
172            Self::Hooks => "hooks",
173            Self::Server => "server",
174        }
175    }
176}
177
178impl fmt::Display for SvelteKitDirectoryKind {
179    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
180        formatter.write_str(self.as_str())
181    }
182}
183
184impl FromStr for SvelteKitDirectoryKind {
185    type Err = SvelteNameError;
186
187    fn from_str(input: &str) -> Result<Self, Self::Err> {
188        match normalized_label(input)?.as_str() {
189            "routes" => Ok(Self::Routes),
190            "lib" => Ok(Self::Lib),
191            "static" => Ok(Self::Static),
192            "params" => Ok(Self::Params),
193            "hooks" => Ok(Self::Hooks),
194            "server" => Ok(Self::Server),
195            _ => Err(SvelteNameError::UnknownLabel),
196        }
197    }
198}
199
200/// `SvelteKit` rendering mode labels.
201#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
202pub enum SvelteKitRenderingMode {
203    Ssr,
204    Spa,
205    Static,
206    Hybrid,
207}
208
209impl SvelteKitRenderingMode {
210    /// Returns the rendering mode label.
211    #[must_use]
212    pub const fn as_str(self) -> &'static str {
213        match self {
214            Self::Ssr => "ssr",
215            Self::Spa => "spa",
216            Self::Static => "static",
217            Self::Hybrid => "hybrid",
218        }
219    }
220}
221
222impl fmt::Display for SvelteKitRenderingMode {
223    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
224        formatter.write_str(self.as_str())
225    }
226}
227
228impl FromStr for SvelteKitRenderingMode {
229    type Err = SvelteNameError;
230
231    fn from_str(input: &str) -> Result<Self, Self::Err> {
232        match normalized_label(input)?.as_str() {
233            "ssr" => Ok(Self::Ssr),
234            "spa" => Ok(Self::Spa),
235            "static" => Ok(Self::Static),
236            "hybrid" => Ok(Self::Hybrid),
237            _ => Err(SvelteNameError::UnknownLabel),
238        }
239    }
240}
241
242/// Common Svelte config file labels.
243#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
244pub enum SvelteConfigFile {
245    SvelteConfigJs,
246    SvelteConfigTs,
247}
248
249impl SvelteConfigFile {
250    /// Returns the config file label.
251    #[must_use]
252    pub const fn as_str(self) -> &'static str {
253        match self {
254            Self::SvelteConfigJs => "svelte.config.js",
255            Self::SvelteConfigTs => "svelte.config.ts",
256        }
257    }
258}
259
260impl fmt::Display for SvelteConfigFile {
261    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
262        formatter.write_str(self.as_str())
263    }
264}
265
266impl FromStr for SvelteConfigFile {
267    type Err = SvelteNameError;
268
269    fn from_str(input: &str) -> Result<Self, Self::Err> {
270        match normalized_label(input)?.as_str() {
271            "svelteconfigjs" | "svelte.config.js" => Ok(Self::SvelteConfigJs),
272            "svelteconfigts" | "svelte.config.ts" => Ok(Self::SvelteConfigTs),
273            _ => Err(SvelteNameError::UnknownLabel),
274        }
275    }
276}
277
278/// Error returned when Svelte metadata is invalid.
279#[derive(Clone, Debug, Eq, PartialEq)]
280pub enum SvelteNameError {
281    Empty,
282    ContainsWhitespace,
283    Identifier(JsIdentifierError),
284    NotPascalCase,
285    InvalidDirective,
286    UnknownLabel,
287}
288
289impl fmt::Display for SvelteNameError {
290    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
291        match self {
292            Self::Empty => formatter.write_str("Svelte metadata text cannot be empty"),
293            Self::ContainsWhitespace => {
294                formatter.write_str("Svelte metadata text cannot contain whitespace")
295            }
296            Self::Identifier(error) => write!(formatter, "{error}"),
297            Self::NotPascalCase => {
298                formatter.write_str("Svelte component name must be `PascalCase`-shaped")
299            }
300            Self::InvalidDirective => formatter.write_str("invalid Svelte directive name"),
301            Self::UnknownLabel => formatter.write_str("unknown Svelte metadata label"),
302        }
303    }
304}
305
306impl Error for SvelteNameError {
307    fn source(&self) -> Option<&(dyn Error + 'static)> {
308        match self {
309            Self::Identifier(error) => Some(error),
310            Self::Empty
311            | Self::ContainsWhitespace
312            | Self::NotPascalCase
313            | Self::InvalidDirective
314            | Self::UnknownLabel => None,
315        }
316    }
317}
318
319fn validate_pascal_case(input: &str) -> Result<String, SvelteNameError> {
320    let identifier = JsIdentifier::new(input).map_err(SvelteNameError::Identifier)?;
321    if !identifier
322        .as_str()
323        .chars()
324        .next()
325        .is_some_and(|character| character.is_ascii_uppercase())
326    {
327        return Err(SvelteNameError::NotPascalCase);
328    }
329    Ok(identifier.as_str().to_string())
330}
331
332const fn is_directive_character(character: char) -> bool {
333    character.is_ascii_alphanumeric() || matches!(character, ':' | '_' | '-')
334}
335
336fn normalized_label(input: &str) -> Result<String, SvelteNameError> {
337    let trimmed = input.trim();
338    if trimmed.is_empty() {
339        return Err(SvelteNameError::Empty);
340    }
341    Ok(trimmed
342        .chars()
343        .filter(|character| !matches!(character, '-' | '_' | ' '))
344        .flat_map(char::to_lowercase)
345        .collect())
346}
347
348#[cfg(test)]
349mod tests {
350    use super::{
351        SvelteComponentName, SvelteConfigFile, SvelteDirectiveName, SvelteFileKind,
352        SvelteKitDirectoryKind, SvelteKitRenderingMode, SvelteNameError,
353    };
354
355    #[test]
356    fn validates_component_names() -> Result<(), SvelteNameError> {
357        let component = SvelteComponentName::new("AppShell")?;
358        assert_eq!(component.as_str(), "AppShell");
359        assert_eq!(
360            SvelteComponentName::new("appShell"),
361            Err(SvelteNameError::NotPascalCase)
362        );
363        assert!(SvelteComponentName::new("app-shell").is_err());
364        Ok(())
365    }
366
367    #[test]
368    fn validates_directive_names() -> Result<(), SvelteNameError> {
369        let directive = SvelteDirectiveName::new("on:click")?;
370        assert_eq!(directive.as_str(), "on:click");
371        assert_eq!(SvelteDirectiveName::new(""), Err(SvelteNameError::Empty));
372        assert_eq!(
373            SvelteDirectiveName::new("on click"),
374            Err(SvelteNameError::ContainsWhitespace)
375        );
376        assert_eq!(
377            SvelteDirectiveName::new("on:"),
378            Err(SvelteNameError::InvalidDirective)
379        );
380        Ok(())
381    }
382
383    #[test]
384    fn parses_labels() -> Result<(), SvelteNameError> {
385        assert_eq!("page".parse::<SvelteFileKind>()?, SvelteFileKind::Page);
386        assert_eq!(
387            "routes".parse::<SvelteKitDirectoryKind>()?,
388            SvelteKitDirectoryKind::Routes
389        );
390        assert_eq!(
391            "ssr".parse::<SvelteKitRenderingMode>()?,
392            SvelteKitRenderingMode::Ssr
393        );
394        assert_eq!(
395            "svelte.config.ts".parse::<SvelteConfigFile>()?,
396            SvelteConfigFile::SvelteConfigTs
397        );
398        assert_eq!(SvelteKitRenderingMode::Hybrid.to_string(), "hybrid");
399        Ok(())
400    }
401}