use-lit 0.0.1

Lit web 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 Lit custom element name metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct LitElementName(String);

impl LitElementName {
    /// Creates Lit custom element name metadata.
    ///
    /// # Errors
    ///
    /// Returns [`LitNameError`] when `input` is empty, contains whitespace, or is not custom-element-shaped.
    pub fn new(input: &str) -> Result<Self, LitNameError> {
        let trimmed = input.trim();
        if trimmed.is_empty() {
            return Err(LitNameError::Empty);
        }
        if trimmed.chars().any(char::is_whitespace) {
            return Err(LitNameError::ContainsWhitespace);
        }
        if !is_custom_element_name(trimmed) {
            return Err(LitNameError::InvalidElementName);
        }
        Ok(Self(trimmed.to_string()))
    }

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

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

impl FromStr for LitElementName {
    type Err = LitNameError;

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

impl TryFrom<&str> for LitElementName {
    type Error = LitNameError;

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

/// Validated Lit property name metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct LitPropertyName(String);

impl LitPropertyName {
    /// Creates Lit property name metadata.
    ///
    /// # Errors
    ///
    /// Returns [`LitNameError`] when `input` is not an ASCII JavaScript identifier.
    pub fn new(input: &str) -> Result<Self, LitNameError> {
        let identifier = JsIdentifier::new(input).map_err(LitNameError::Identifier)?;
        Ok(Self(identifier.as_str().to_string()))
    }

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

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

impl FromStr for LitPropertyName {
    type Err = LitNameError;

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

impl TryFrom<&str> for LitPropertyName {
    type Error = LitNameError;

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

/// Validated Lit decorator name metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct LitDecoratorName(String);

impl LitDecoratorName {
    /// Creates Lit decorator name metadata.
    ///
    /// # Errors
    ///
    /// Returns [`LitNameError`] when `input` is empty, contains whitespace, or has unsupported characters.
    pub fn new(input: &str) -> Result<Self, LitNameError> {
        let trimmed = input.trim();
        let decorator = trimmed.strip_prefix('@').unwrap_or(trimmed);
        if decorator.is_empty() {
            return Err(LitNameError::Empty);
        }
        if decorator.chars().any(char::is_whitespace) {
            return Err(LitNameError::ContainsWhitespace);
        }
        if !decorator.chars().all(is_decorator_character) {
            return Err(LitNameError::InvalidDecoratorName);
        }
        Ok(Self(decorator.to_string()))
    }

    /// Returns the decorator name without a leading `@`.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

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

impl FromStr for LitDecoratorName {
    type Err = LitNameError;

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

impl TryFrom<&str> for LitDecoratorName {
    type Error = LitNameError;

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

/// Lit file-kind labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum LitFileKind {
    Element,
    Template,
    Styles,
    Controller,
    Directive,
    Decorator,
}

impl LitFileKind {
    /// Returns the file-kind label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Element => "element",
            Self::Template => "template",
            Self::Styles => "styles",
            Self::Controller => "controller",
            Self::Directive => "directive",
            Self::Decorator => "decorator",
        }
    }
}

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

impl FromStr for LitFileKind {
    type Err = LitNameError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        match normalized_label(input)?.as_str() {
            "element" => Ok(Self::Element),
            "template" => Ok(Self::Template),
            "styles" | "style" => Ok(Self::Styles),
            "controller" => Ok(Self::Controller),
            "directive" => Ok(Self::Directive),
            "decorator" => Ok(Self::Decorator),
            _ => Err(LitNameError::UnknownLabel),
        }
    }
}

/// Lit template-kind labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum LitTemplateKind {
    Html,
    Svg,
    Css,
    StaticHtml,
}

impl LitTemplateKind {
    /// Returns the template-kind label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Html => "html",
            Self::Svg => "svg",
            Self::Css => "css",
            Self::StaticHtml => "static-html",
        }
    }
}

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

impl FromStr for LitTemplateKind {
    type Err = LitNameError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        match normalized_label(input)?.as_str() {
            "html" => Ok(Self::Html),
            "svg" => Ok(Self::Svg),
            "css" => Ok(Self::Css),
            "statichtml" => Ok(Self::StaticHtml),
            _ => Err(LitNameError::UnknownLabel),
        }
    }
}

/// Error returned when Lit metadata is invalid.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum LitNameError {
    Empty,
    ContainsWhitespace,
    Identifier(JsIdentifierError),
    InvalidElementName,
    InvalidDecoratorName,
    UnknownLabel,
}

impl fmt::Display for LitNameError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Lit metadata text cannot be empty"),
            Self::ContainsWhitespace => {
                formatter.write_str("Lit metadata text cannot contain whitespace")
            }
            Self::Identifier(error) => write!(formatter, "{error}"),
            Self::InvalidElementName => formatter.write_str("invalid Lit custom element name"),
            Self::InvalidDecoratorName => formatter.write_str("invalid Lit decorator name"),
            Self::UnknownLabel => formatter.write_str("unknown Lit metadata label"),
        }
    }
}

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

fn is_custom_element_name(input: &str) -> bool {
    input.contains('-')
        && input.split('-').all(|segment| !segment.is_empty())
        && input.chars().all(|character| {
            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
        })
}

const fn is_decorator_character(character: char) -> bool {
    character.is_ascii_alphanumeric() || character == '_'
}

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

#[cfg(test)]
mod tests {
    use super::{
        LitDecoratorName, LitElementName, LitFileKind, LitNameError, LitPropertyName,
        LitTemplateKind,
    };

    #[test]
    fn validates_element_names() -> Result<(), LitNameError> {
        let element = LitElementName::new("app-shell")?;
        assert_eq!(element.as_str(), "app-shell");
        assert_eq!(
            LitElementName::new("app"),
            Err(LitNameError::InvalidElementName)
        );
        assert_eq!(
            LitElementName::new("AppShell"),
            Err(LitNameError::InvalidElementName)
        );
        assert_eq!(
            LitElementName::new("app shell"),
            Err(LitNameError::ContainsWhitespace)
        );
        Ok(())
    }

    #[test]
    fn validates_property_and_decorator_names() -> Result<(), LitNameError> {
        assert_eq!(LitPropertyName::new("isOpen")?.as_str(), "isOpen");
        assert_eq!(LitDecoratorName::new("@property")?.as_str(), "property");
        assert!(LitPropertyName::new("is-open").is_err());
        assert_eq!(
            LitDecoratorName::new("@custom-element"),
            Err(LitNameError::InvalidDecoratorName)
        );
        Ok(())
    }

    #[test]
    fn parses_labels() -> Result<(), LitNameError> {
        assert_eq!(
            "controller".parse::<LitFileKind>()?,
            LitFileKind::Controller
        );
        assert_eq!(
            "static-html".parse::<LitTemplateKind>()?,
            LitTemplateKind::StaticHtml
        );
        assert_eq!(LitTemplateKind::Html.to_string(), "html");
        Ok(())
    }
}