use-ts 0.0.1

TypeScript version and option primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

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

/// TypeScript semantic version metadata.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct TypeScriptVersion {
    major: u16,
    minor: Option<u16>,
    patch: Option<u16>,
}

impl TypeScriptVersion {
    /// Creates TypeScript version metadata.
    ///
    /// # Errors
    ///
    /// Returns [`TypeScriptVersionParseError::InvalidVersion`] when the major version is zero
    /// or a patch is provided without a minor version.
    pub const fn new(
        major: u16,
        minor: Option<u16>,
        patch: Option<u16>,
    ) -> Result<Self, TypeScriptVersionParseError> {
        if major == 0 || (minor.is_none() && patch.is_some()) {
            Err(TypeScriptVersionParseError::InvalidVersion)
        } else {
            Ok(Self {
                major,
                minor,
                patch,
            })
        }
    }

    /// Returns the major version.
    #[must_use]
    pub const fn major(self) -> u16 {
        self.major
    }

    /// Returns the optional minor version.
    #[must_use]
    pub const fn minor(self) -> Option<u16> {
        self.minor
    }

    /// Returns the optional patch version.
    #[must_use]
    pub const fn patch(self) -> Option<u16> {
        self.patch
    }
}

impl fmt::Display for TypeScriptVersion {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match (self.minor, self.patch) {
            (Some(minor), Some(patch)) => write!(formatter, "{}.{}.{}", self.major, minor, patch),
            (Some(minor), None) => write!(formatter, "{}.{}", self.major, minor),
            (None, _) => write!(formatter, "{}", self.major),
        }
    }
}

impl FromStr for TypeScriptVersion {
    type Err = TypeScriptVersionParseError;

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

        let parts = trimmed.split('.').collect::<Vec<_>>();
        if parts.len() > 3 || parts.iter().any(|part| part.is_empty()) {
            return Err(TypeScriptVersionParseError::InvalidVersion);
        }

        let major = parse_version_part(parts[0])?;
        let minor = parse_optional_version_part(parts.get(1).copied())?;
        let patch = parse_optional_version_part(parts.get(2).copied())?;
        Self::new(major, minor, patch)
    }
}

/// Error returned while parsing a TypeScript version.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TypeScriptVersionParseError {
    Empty,
    InvalidVersion,
}

impl fmt::Display for TypeScriptVersionParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("TypeScript version cannot be empty"),
            Self::InvalidVersion => formatter.write_str("invalid TypeScript version"),
        }
    }
}

impl Error for TypeScriptVersionParseError {}

/// TypeScript module resolution labels.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum TsModuleResolution {
    Classic,
    Node,
    Node10,
    Node16,
    NodeNext,
    Bundler,
}

impl fmt::Display for TsModuleResolution {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::Classic => "classic",
            Self::Node => "node",
            Self::Node10 => "node10",
            Self::Node16 => "node16",
            Self::NodeNext => "nodenext",
            Self::Bundler => "bundler",
        })
    }
}

impl FromStr for TsModuleResolution {
    type Err = TsOptionParseError;

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

        match trimmed.to_ascii_lowercase().as_str() {
            "classic" => Ok(Self::Classic),
            "node" | "nodejs" => Ok(Self::Node),
            "node10" => Ok(Self::Node10),
            "node16" => Ok(Self::Node16),
            "nodenext" | "node_next" | "node-next" => Ok(Self::NodeNext),
            "bundler" => Ok(Self::Bundler),
            _ => Err(TsOptionParseError::Unknown),
        }
    }
}

/// TypeScript target metadata.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum TsTarget {
    EcmaScript(EcmaScriptTarget),
    Latest,
}

impl fmt::Display for TsTarget {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::EcmaScript(target) => fmt::Display::fmt(target, formatter),
            Self::Latest => formatter.write_str("latest"),
        }
    }
}

impl From<EcmaScriptTarget> for TsTarget {
    fn from(value: EcmaScriptTarget) -> Self {
        Self::EcmaScript(value)
    }
}

impl FromStr for TsTarget {
    type Err = TsTargetParseError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        let trimmed = input.trim();
        if trimmed.is_empty() {
            return Err(TsTargetParseError::Empty);
        }
        if trimmed.eq_ignore_ascii_case("latest") {
            return Ok(Self::Latest);
        }
        trimmed
            .parse::<EcmaScriptTarget>()
            .map(Self::EcmaScript)
            .map_err(TsTargetParseError::EcmaScript)
    }
}

/// TypeScript strictness metadata.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum TsStrictness {
    Loose,
    Strict,
}

impl fmt::Display for TsStrictness {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::Loose => "loose",
            Self::Strict => "strict",
        })
    }
}

impl FromStr for TsStrictness {
    type Err = TsOptionParseError;

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

        match trimmed.to_ascii_lowercase().as_str() {
            "loose" | "false" | "off" => Ok(Self::Loose),
            "strict" | "true" | "on" => Ok(Self::Strict),
            _ => Err(TsOptionParseError::Unknown),
        }
    }
}

/// Error returned while parsing TypeScript option labels.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TsOptionParseError {
    Empty,
    Unknown,
}

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

impl Error for TsOptionParseError {}

/// Error returned while parsing TypeScript targets.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TsTargetParseError {
    Empty,
    EcmaScript(EcmaScriptParseError),
}

impl fmt::Display for TsTargetParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("TypeScript target cannot be empty"),
            Self::EcmaScript(error) => write!(formatter, "invalid ECMAScript target: {error}"),
        }
    }
}

impl Error for TsTargetParseError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::Empty => None,
            Self::EcmaScript(error) => Some(error),
        }
    }
}

fn parse_version_part(input: &str) -> Result<u16, TypeScriptVersionParseError> {
    input
        .parse::<u16>()
        .map_err(|_error| TypeScriptVersionParseError::InvalidVersion)
}

fn parse_optional_version_part(
    input: Option<&str>,
) -> Result<Option<u16>, TypeScriptVersionParseError> {
    input.map(parse_version_part).transpose()
}

#[cfg(test)]
mod tests {
    use super::{TsModuleResolution, TsStrictness, TsTarget, TypeScriptVersion};

    #[test]
    fn parses_versions() -> Result<(), Box<dyn std::error::Error>> {
        let version: TypeScriptVersion = "v5.4.2".parse()?;
        assert_eq!(version.major(), 5);
        assert_eq!(version.minor(), Some(4));
        assert_eq!(version.patch(), Some(2));
        assert_eq!(version.to_string(), "5.4.2");
        Ok(())
    }

    #[test]
    fn parses_options() -> Result<(), Box<dyn std::error::Error>> {
        assert_eq!(
            "nodenext".parse::<TsModuleResolution>()?,
            TsModuleResolution::NodeNext
        );
        assert_eq!("es2022".parse::<TsTarget>()?.to_string(), "ES2022");
        assert_eq!("strict".parse::<TsStrictness>()?, TsStrictness::Strict);
        Ok(())
    }
}