use-js-module 0.0.1

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

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

/// JavaScript module-system kind.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum JsModuleKind {
    Esm,
    CommonJs,
    Umd,
    Amd,
    System,
    Iife,
}

impl JsModuleKind {
    /// Returns the lowercase module kind label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Esm => "esm",
            Self::CommonJs => "commonjs",
            Self::Umd => "umd",
            Self::Amd => "amd",
            Self::System => "system",
            Self::Iife => "iife",
        }
    }
}

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

impl FromStr for JsModuleKind {
    type Err = JsModuleKindParseError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        let trimmed = input.trim();
        if trimmed.is_empty() {
            return Err(JsModuleKindParseError::Empty);
        }

        match trimmed.to_ascii_lowercase().as_str() {
            "esm" | "esmodule" | "module" => Ok(Self::Esm),
            "commonjs" | "cjs" => Ok(Self::CommonJs),
            "umd" => Ok(Self::Umd),
            "amd" => Ok(Self::Amd),
            "system" | "systemjs" => Ok(Self::System),
            "iife" => Ok(Self::Iife),
            _ => Err(JsModuleKindParseError::Unknown),
        }
    }
}

/// Error returned when a module kind is not recognized.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum JsModuleKindParseError {
    Empty,
    Unknown,
}

impl fmt::Display for JsModuleKindParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("module kind cannot be empty"),
            Self::Unknown => formatter.write_str("unknown module kind"),
        }
    }
}

impl Error for JsModuleKindParseError {}

/// Validated JavaScript module specifier.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct JsModuleSpecifier(String);

impl JsModuleSpecifier {
    /// Creates a non-empty module specifier.
    ///
    /// # Errors
    ///
    /// Returns [`JsModuleSpecifierError::Empty`] when `input` is empty after trimming.
    pub fn new(input: &str) -> Result<Self, JsModuleSpecifierError> {
        let trimmed = input.trim();
        if trimmed.is_empty() {
            return Err(JsModuleSpecifierError::Empty);
        }
        Ok(Self(trimmed.to_string()))
    }

    /// Returns the specifier as a string slice.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Returns whether this specifier is relative.
    #[must_use]
    pub fn is_relative(&self) -> bool {
        self.0.starts_with("./") || self.0.starts_with("../")
    }
}

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

impl FromStr for JsModuleSpecifier {
    type Err = JsModuleSpecifierError;

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

impl TryFrom<&str> for JsModuleSpecifier {
    type Error = JsModuleSpecifierError;

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

/// Error returned when a module specifier is invalid.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum JsModuleSpecifierError {
    Empty,
}

impl fmt::Display for JsModuleSpecifierError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("module specifier cannot be empty")
    }
}

impl Error for JsModuleSpecifierError {}

/// Simple module format metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct JsModuleFormat {
    kind: JsModuleKind,
    extension: Option<String>,
}

impl JsModuleFormat {
    /// Creates module format metadata for a kind.
    #[must_use]
    pub const fn new(kind: JsModuleKind) -> Self {
        Self {
            kind,
            extension: None,
        }
    }

    /// Adds a file extension label such as `mjs` or `cjs`.
    #[must_use]
    pub fn with_extension(mut self, extension: &str) -> Self {
        let trimmed = extension.trim().trim_start_matches('.');
        self.extension = (!trimmed.is_empty()).then(|| trimmed.to_string());
        self
    }

    /// Returns the module kind.
    #[must_use]
    pub const fn kind(&self) -> JsModuleKind {
        self.kind
    }

    /// Returns the optional extension label.
    #[must_use]
    pub fn extension(&self) -> Option<&str> {
        self.extension.as_deref()
    }
}

#[cfg(test)]
mod tests {
    use super::{
        JsModuleFormat, JsModuleKind, JsModuleKindParseError, JsModuleSpecifier,
        JsModuleSpecifierError,
    };

    #[test]
    fn parses_module_kinds() -> Result<(), JsModuleKindParseError> {
        assert_eq!("esm".parse::<JsModuleKind>()?, JsModuleKind::Esm);
        assert_eq!("cjs".parse::<JsModuleKind>()?, JsModuleKind::CommonJs);
        assert_eq!(JsModuleKind::Iife.to_string(), "iife");
        Ok(())
    }

    #[test]
    fn validates_specifiers() -> Result<(), JsModuleSpecifierError> {
        let specifier = JsModuleSpecifier::new(" ./app.js ")?;
        assert_eq!(specifier.as_str(), "./app.js");
        assert!(specifier.is_relative());
        assert_eq!(
            JsModuleSpecifier::new("  "),
            Err(JsModuleSpecifierError::Empty)
        );
        Ok(())
    }

    #[test]
    fn stores_format_metadata() {
        let format = JsModuleFormat::new(JsModuleKind::Esm).with_extension(".mjs");
        assert_eq!(format.kind(), JsModuleKind::Esm);
        assert_eq!(format.extension(), Some("mjs"));
    }
}