use-react 0.0.1

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

impl ReactComponentName {
    /// Creates a `PascalCase` ASCII React component name.
    ///
    /// # Errors
    ///
    /// Returns [`ReactNameError`] when `input` is not an ASCII identifier or is not `PascalCase`-shaped.
    pub fn new(input: &str) -> Result<Self, ReactNameError> {
        let identifier = JsIdentifier::new(input).map_err(ReactNameError::Identifier)?;
        if !identifier
            .as_str()
            .chars()
            .next()
            .is_some_and(|character| character.is_ascii_uppercase())
        {
            return Err(ReactNameError::NotPascalCase);
        }
        Ok(Self(identifier.as_str().to_string()))
    }

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

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

impl FromStr for ReactComponentName {
    type Err = ReactNameError;

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

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

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

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

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

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

/// Error returned when React name metadata is invalid.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ReactNameError {
    Identifier(JsIdentifierError),
    NotPascalCase,
    NotHookName,
}

impl fmt::Display for ReactNameError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Identifier(error) => write!(formatter, "invalid JavaScript identifier: {error}"),
            Self::NotPascalCase => {
                formatter.write_str("React component name must be PascalCase-shaped")
            }
            Self::NotHookName => {
                formatter.write_str("React hook name must start with use and include a suffix")
            }
        }
    }
}

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

#[cfg(test)]
mod tests {
    use super::{ReactComponentName, ReactHookName, ReactJsxRuntime, ReactNameError};

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

    #[test]
    fn validates_hook_names() -> Result<(), ReactNameError> {
        let hook = ReactHookName::new("useSession")?;
        assert_eq!(hook.as_str(), "useSession");
        assert!(hook.has_canonical_suffix());
        assert_eq!(ReactHookName::new("use"), Err(ReactNameError::NotHookName));
        assert_eq!(ReactJsxRuntime::Automatic.as_str(), "automatic");
        Ok(())
    }
}