use-svelte 0.0.1

Svelte and SvelteKit metadata primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;
use use_js_identifier::{JsIdentifier, JsIdentifierError};

/// Validated Svelte component name metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SvelteComponentName(String);

impl SvelteComponentName {
    /// Creates a `PascalCase` ASCII Svelte component name.
    ///
    /// # Errors
    ///
    /// Returns [`SvelteNameError`] when `input` is not an ASCII identifier or is not `PascalCase`-shaped.
    pub fn new(input: &str) -> Result<Self, SvelteNameError> {
        validate_pascal_case(input).map(Self)
    }

    /// Returns the component name.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl fmt::Display for SvelteComponentName {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for SvelteComponentName {
    type Err = SvelteNameError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        Self::new(input)
    }
}

impl TryFrom<&str> for SvelteComponentName {
    type Error = SvelteNameError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}

/// Validated Svelte directive name metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SvelteDirectiveName(String);

impl SvelteDirectiveName {
    /// Creates a lightly validated Svelte directive name.
    ///
    /// # Errors
    ///
    /// Returns [`SvelteNameError`] when `input` is empty, contains whitespace, or has unsupported characters.
    pub fn new(input: &str) -> Result<Self, SvelteNameError> {
        let trimmed = input.trim();
        if trimmed.is_empty() {
            return Err(SvelteNameError::Empty);
        }
        if trimmed.chars().any(char::is_whitespace) {
            return Err(SvelteNameError::ContainsWhitespace);
        }
        if !trimmed.chars().all(is_directive_character) || trimmed.split(':').any(str::is_empty) {
            return Err(SvelteNameError::InvalidDirective);
        }
        Ok(Self(trimmed.to_string()))
    }

    /// Returns the directive name.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl fmt::Display for SvelteDirectiveName {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for SvelteDirectiveName {
    type Err = SvelteNameError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        Self::new(input)
    }
}

impl TryFrom<&str> for SvelteDirectiveName {
    type Error = SvelteNameError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}

/// Svelte file-kind labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SvelteFileKind {
    Component,
    Page,
    Layout,
    Error,
    Server,
    Config,
}

impl SvelteFileKind {
    /// Returns the file-kind label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Component => "component",
            Self::Page => "page",
            Self::Layout => "layout",
            Self::Error => "error",
            Self::Server => "server",
            Self::Config => "config",
        }
    }
}

impl fmt::Display for SvelteFileKind {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for SvelteFileKind {
    type Err = SvelteNameError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        match normalized_label(input)?.as_str() {
            "component" => Ok(Self::Component),
            "page" => Ok(Self::Page),
            "layout" => Ok(Self::Layout),
            "error" => Ok(Self::Error),
            "server" => Ok(Self::Server),
            "config" => Ok(Self::Config),
            _ => Err(SvelteNameError::UnknownLabel),
        }
    }
}

/// `SvelteKit` directory labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SvelteKitDirectoryKind {
    Routes,
    Lib,
    Static,
    Params,
    Hooks,
    Server,
}

impl SvelteKitDirectoryKind {
    /// Returns the directory label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Routes => "routes",
            Self::Lib => "lib",
            Self::Static => "static",
            Self::Params => "params",
            Self::Hooks => "hooks",
            Self::Server => "server",
        }
    }
}

impl fmt::Display for SvelteKitDirectoryKind {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for SvelteKitDirectoryKind {
    type Err = SvelteNameError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        match normalized_label(input)?.as_str() {
            "routes" => Ok(Self::Routes),
            "lib" => Ok(Self::Lib),
            "static" => Ok(Self::Static),
            "params" => Ok(Self::Params),
            "hooks" => Ok(Self::Hooks),
            "server" => Ok(Self::Server),
            _ => Err(SvelteNameError::UnknownLabel),
        }
    }
}

/// `SvelteKit` rendering mode labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SvelteKitRenderingMode {
    Ssr,
    Spa,
    Static,
    Hybrid,
}

impl SvelteKitRenderingMode {
    /// Returns the rendering mode label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Ssr => "ssr",
            Self::Spa => "spa",
            Self::Static => "static",
            Self::Hybrid => "hybrid",
        }
    }
}

impl fmt::Display for SvelteKitRenderingMode {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for SvelteKitRenderingMode {
    type Err = SvelteNameError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        match normalized_label(input)?.as_str() {
            "ssr" => Ok(Self::Ssr),
            "spa" => Ok(Self::Spa),
            "static" => Ok(Self::Static),
            "hybrid" => Ok(Self::Hybrid),
            _ => Err(SvelteNameError::UnknownLabel),
        }
    }
}

/// Common Svelte config file labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum SvelteConfigFile {
    SvelteConfigJs,
    SvelteConfigTs,
}

impl SvelteConfigFile {
    /// Returns the config file label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::SvelteConfigJs => "svelte.config.js",
            Self::SvelteConfigTs => "svelte.config.ts",
        }
    }
}

impl fmt::Display for SvelteConfigFile {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

impl FromStr for SvelteConfigFile {
    type Err = SvelteNameError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        match normalized_label(input)?.as_str() {
            "svelteconfigjs" | "svelte.config.js" => Ok(Self::SvelteConfigJs),
            "svelteconfigts" | "svelte.config.ts" => Ok(Self::SvelteConfigTs),
            _ => Err(SvelteNameError::UnknownLabel),
        }
    }
}

/// Error returned when Svelte metadata is invalid.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SvelteNameError {
    Empty,
    ContainsWhitespace,
    Identifier(JsIdentifierError),
    NotPascalCase,
    InvalidDirective,
    UnknownLabel,
}

impl fmt::Display for SvelteNameError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Svelte metadata text cannot be empty"),
            Self::ContainsWhitespace => {
                formatter.write_str("Svelte metadata text cannot contain whitespace")
            }
            Self::Identifier(error) => write!(formatter, "{error}"),
            Self::NotPascalCase => {
                formatter.write_str("Svelte component name must be `PascalCase`-shaped")
            }
            Self::InvalidDirective => formatter.write_str("invalid Svelte directive name"),
            Self::UnknownLabel => formatter.write_str("unknown Svelte metadata label"),
        }
    }
}

impl Error for SvelteNameError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::Identifier(error) => Some(error),
            Self::Empty
            | Self::ContainsWhitespace
            | Self::NotPascalCase
            | Self::InvalidDirective
            | Self::UnknownLabel => None,
        }
    }
}

fn validate_pascal_case(input: &str) -> Result<String, SvelteNameError> {
    let identifier = JsIdentifier::new(input).map_err(SvelteNameError::Identifier)?;
    if !identifier
        .as_str()
        .chars()
        .next()
        .is_some_and(|character| character.is_ascii_uppercase())
    {
        return Err(SvelteNameError::NotPascalCase);
    }
    Ok(identifier.as_str().to_string())
}

const fn is_directive_character(character: char) -> bool {
    character.is_ascii_alphanumeric() || matches!(character, ':' | '_' | '-')
}

fn normalized_label(input: &str) -> Result<String, SvelteNameError> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err(SvelteNameError::Empty);
    }
    Ok(trimmed
        .chars()
        .filter(|character| !matches!(character, '-' | '_' | ' '))
        .flat_map(char::to_lowercase)
        .collect())
}

#[cfg(test)]
mod tests {
    use super::{
        SvelteComponentName, SvelteConfigFile, SvelteDirectiveName, SvelteFileKind,
        SvelteKitDirectoryKind, SvelteKitRenderingMode, SvelteNameError,
    };

    #[test]
    fn validates_component_names() -> Result<(), SvelteNameError> {
        let component = SvelteComponentName::new("AppShell")?;
        assert_eq!(component.as_str(), "AppShell");
        assert_eq!(
            SvelteComponentName::new("appShell"),
            Err(SvelteNameError::NotPascalCase)
        );
        assert!(SvelteComponentName::new("app-shell").is_err());
        Ok(())
    }

    #[test]
    fn validates_directive_names() -> Result<(), SvelteNameError> {
        let directive = SvelteDirectiveName::new("on:click")?;
        assert_eq!(directive.as_str(), "on:click");
        assert_eq!(SvelteDirectiveName::new(""), Err(SvelteNameError::Empty));
        assert_eq!(
            SvelteDirectiveName::new("on click"),
            Err(SvelteNameError::ContainsWhitespace)
        );
        assert_eq!(
            SvelteDirectiveName::new("on:"),
            Err(SvelteNameError::InvalidDirective)
        );
        Ok(())
    }

    #[test]
    fn parses_labels() -> Result<(), SvelteNameError> {
        assert_eq!("page".parse::<SvelteFileKind>()?, SvelteFileKind::Page);
        assert_eq!(
            "routes".parse::<SvelteKitDirectoryKind>()?,
            SvelteKitDirectoryKind::Routes
        );
        assert_eq!(
            "ssr".parse::<SvelteKitRenderingMode>()?,
            SvelteKitRenderingMode::Ssr
        );
        assert_eq!(
            "svelte.config.ts".parse::<SvelteConfigFile>()?,
            SvelteConfigFile::SvelteConfigTs
        );
        assert_eq!(SvelteKitRenderingMode::Hybrid.to_string(), "hybrid");
        Ok(())
    }
}