use-preact 0.0.1

Preact component 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 Preact component name metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PreactComponentName(String);

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

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

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

impl FromStr for PreactComponentName {
    type Err = PreactNameError;

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

impl TryFrom<&str> for PreactComponentName {
    type Error = PreactNameError;

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

/// Validated Preact hook name metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PreactHookName(String);

impl PreactHookName {
    /// Creates a lightly validated Preact hook name.
    ///
    /// # Errors
    ///
    /// Returns [`PreactNameError`] when `input` is not an ASCII identifier or does not start with `use` plus a suffix.
    pub fn new(input: &str) -> Result<Self, PreactNameError> {
        let identifier = JsIdentifier::new(input).map_err(PreactNameError::Identifier)?;
        let Some(suffix) = identifier.as_str().strip_prefix("use") else {
            return Err(PreactNameError::NotHookName);
        };
        if suffix.is_empty() {
            return Err(PreactNameError::NotHookName);
        }
        Ok(Self(identifier.as_str().to_string()))
    }

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

    /// Returns whether the hook uses the common `use` + uppercase convention.
    #[must_use]
    pub fn has_canonical_suffix(&self) -> bool {
        self.0
            .chars()
            .nth(3)
            .is_some_and(|character| character.is_ascii_uppercase())
    }
}

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

impl FromStr for PreactHookName {
    type Err = PreactNameError;

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

impl TryFrom<&str> for PreactHookName {
    type Error = PreactNameError;

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

/// Preact JSX runtime labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PreactJsxRuntime {
    Classic,
    Automatic,
}

impl PreactJsxRuntime {
    /// Returns the JSX runtime label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Classic => "classic",
            Self::Automatic => "automatic",
        }
    }
}

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

impl FromStr for PreactJsxRuntime {
    type Err = PreactNameError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        match normalized_label(input)?.as_str() {
            "classic" => Ok(Self::Classic),
            "automatic" | "auto" => Ok(Self::Automatic),
            _ => Err(PreactNameError::UnknownLabel),
        }
    }
}

/// Preact file-kind labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PreactFileKind {
    Component,
    Hook,
    Context,
    Provider,
    Page,
    Layout,
}

impl PreactFileKind {
    /// Returns the file-kind label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Component => "component",
            Self::Hook => "hook",
            Self::Context => "context",
            Self::Provider => "provider",
            Self::Page => "page",
            Self::Layout => "layout",
        }
    }
}

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

impl FromStr for PreactFileKind {
    type Err = PreactNameError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        match normalized_label(input)?.as_str() {
            "component" => Ok(Self::Component),
            "hook" => Ok(Self::Hook),
            "context" => Ok(Self::Context),
            "provider" => Ok(Self::Provider),
            "page" => Ok(Self::Page),
            "layout" => Ok(Self::Layout),
            _ => Err(PreactNameError::UnknownLabel),
        }
    }
}

/// Preact compatibility mode labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum PreactCompatMode {
    Native,
    Compat,
}

impl PreactCompatMode {
    /// Returns the compatibility mode label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Native => "native",
            Self::Compat => "compat",
        }
    }
}

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

impl FromStr for PreactCompatMode {
    type Err = PreactNameError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        match normalized_label(input)?.as_str() {
            "native" => Ok(Self::Native),
            "compat" | "preactcompat" => Ok(Self::Compat),
            _ => Err(PreactNameError::UnknownLabel),
        }
    }
}

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

impl fmt::Display for PreactNameError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Identifier(error) => write!(formatter, "{error}"),
            Self::NotPascalCase => {
                formatter.write_str("Preact component name must be `PascalCase`-shaped")
            }
            Self::NotHookName => {
                formatter.write_str("Preact hook name must start with `use` and include a suffix")
            }
            Self::Empty => formatter.write_str("Preact metadata label cannot be empty"),
            Self::UnknownLabel => formatter.write_str("unknown Preact metadata label"),
        }
    }
}

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

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

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

#[cfg(test)]
mod tests {
    use super::{
        PreactCompatMode, PreactComponentName, PreactFileKind, PreactHookName, PreactJsxRuntime,
        PreactNameError,
    };

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

    #[test]
    fn validates_hook_names() -> Result<(), PreactNameError> {
        let hook = PreactHookName::new("useSignal")?;
        assert_eq!(hook.as_str(), "useSignal");
        assert!(hook.has_canonical_suffix());
        assert_eq!(
            PreactHookName::new("signal"),
            Err(PreactNameError::NotHookName)
        );
        assert_eq!(
            PreactHookName::new("use"),
            Err(PreactNameError::NotHookName)
        );
        Ok(())
    }

    #[test]
    fn parses_labels() -> Result<(), PreactNameError> {
        assert_eq!(
            "automatic".parse::<PreactJsxRuntime>()?,
            PreactJsxRuntime::Automatic
        );
        assert_eq!(
            "provider".parse::<PreactFileKind>()?,
            PreactFileKind::Provider
        );
        assert_eq!(
            "compat".parse::<PreactCompatMode>()?,
            PreactCompatMode::Compat
        );
        assert_eq!(PreactCompatMode::Native.to_string(), "native");
        Ok(())
    }
}