use-ecmascript 0.0.1

ECMAScript edition and target primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

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

/// ECMAScript edition numbers for commonly named targets.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum EcmaScriptEdition {
    Edition5,
    Edition6,
    Edition7,
    Edition8,
    Edition9,
    Edition10,
    Edition11,
    Edition12,
    Edition13,
    Edition14,
    Edition15,
}

impl EcmaScriptEdition {
    /// Returns the numeric ECMAScript edition.
    #[must_use]
    pub const fn number(self) -> u8 {
        match self {
            Self::Edition5 => 5,
            Self::Edition6 => 6,
            Self::Edition7 => 7,
            Self::Edition8 => 8,
            Self::Edition9 => 9,
            Self::Edition10 => 10,
            Self::Edition11 => 11,
            Self::Edition12 => 12,
            Self::Edition13 => 13,
            Self::Edition14 => 14,
            Self::Edition15 => 15,
        }
    }
}

/// Calendar year for annual ECMAScript editions.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct EcmaScriptYear(u16);

impl EcmaScriptYear {
    /// Creates a supported ECMAScript edition year.
    ///
    /// # Errors
    ///
    /// Returns [`EcmaScriptParseError::UnsupportedYear`] when `year` is outside ES2015..=ES2024.
    pub const fn new(year: u16) -> Result<Self, EcmaScriptParseError> {
        if year >= 2015 && year <= 2024 {
            Ok(Self(year))
        } else {
            Err(EcmaScriptParseError::UnsupportedYear)
        }
    }

    /// Returns the calendar year.
    #[must_use]
    pub const fn get(self) -> u16 {
        self.0
    }
}

impl fmt::Display for EcmaScriptYear {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}", self.0)
    }
}

impl FromStr for EcmaScriptYear {
    type Err = EcmaScriptParseError;

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

        let year = trimmed
            .parse::<u16>()
            .map_err(|_error| EcmaScriptParseError::UnknownTarget)?;
        Self::new(year)
    }
}

/// Common ECMAScript language target labels.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum EcmaScriptTarget {
    Es5,
    Es2015,
    Es2016,
    Es2017,
    Es2018,
    Es2019,
    Es2020,
    Es2021,
    Es2022,
    Es2023,
    Es2024,
    EsNext,
}

pub const ES5: EcmaScriptTarget = EcmaScriptTarget::Es5;
pub const ES2015: EcmaScriptTarget = EcmaScriptTarget::Es2015;
pub const ES2016: EcmaScriptTarget = EcmaScriptTarget::Es2016;
pub const ES2017: EcmaScriptTarget = EcmaScriptTarget::Es2017;
pub const ES2018: EcmaScriptTarget = EcmaScriptTarget::Es2018;
pub const ES2019: EcmaScriptTarget = EcmaScriptTarget::Es2019;
pub const ES2020: EcmaScriptTarget = EcmaScriptTarget::Es2020;
pub const ES2021: EcmaScriptTarget = EcmaScriptTarget::Es2021;
pub const ES2022: EcmaScriptTarget = EcmaScriptTarget::Es2022;
pub const ES2023: EcmaScriptTarget = EcmaScriptTarget::Es2023;
pub const ES2024: EcmaScriptTarget = EcmaScriptTarget::Es2024;
pub const ESNEXT: EcmaScriptTarget = EcmaScriptTarget::EsNext;

impl EcmaScriptTarget {
    /// Returns the annual edition year when the target has one.
    #[must_use]
    pub const fn year(self) -> Option<EcmaScriptYear> {
        match self {
            Self::Es5 | Self::EsNext => None,
            Self::Es2015 => Some(EcmaScriptYear(2015)),
            Self::Es2016 => Some(EcmaScriptYear(2016)),
            Self::Es2017 => Some(EcmaScriptYear(2017)),
            Self::Es2018 => Some(EcmaScriptYear(2018)),
            Self::Es2019 => Some(EcmaScriptYear(2019)),
            Self::Es2020 => Some(EcmaScriptYear(2020)),
            Self::Es2021 => Some(EcmaScriptYear(2021)),
            Self::Es2022 => Some(EcmaScriptYear(2022)),
            Self::Es2023 => Some(EcmaScriptYear(2023)),
            Self::Es2024 => Some(EcmaScriptYear(2024)),
        }
    }

    /// Returns the edition number when the target maps to a stable edition.
    #[must_use]
    pub const fn edition(self) -> Option<EcmaScriptEdition> {
        match self {
            Self::Es5 => Some(EcmaScriptEdition::Edition5),
            Self::Es2015 => Some(EcmaScriptEdition::Edition6),
            Self::Es2016 => Some(EcmaScriptEdition::Edition7),
            Self::Es2017 => Some(EcmaScriptEdition::Edition8),
            Self::Es2018 => Some(EcmaScriptEdition::Edition9),
            Self::Es2019 => Some(EcmaScriptEdition::Edition10),
            Self::Es2020 => Some(EcmaScriptEdition::Edition11),
            Self::Es2021 => Some(EcmaScriptEdition::Edition12),
            Self::Es2022 => Some(EcmaScriptEdition::Edition13),
            Self::Es2023 => Some(EcmaScriptEdition::Edition14),
            Self::Es2024 => Some(EcmaScriptEdition::Edition15),
            Self::EsNext => None,
        }
    }
}

impl fmt::Display for EcmaScriptTarget {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::Es5 => "ES5",
            Self::Es2015 => "ES2015",
            Self::Es2016 => "ES2016",
            Self::Es2017 => "ES2017",
            Self::Es2018 => "ES2018",
            Self::Es2019 => "ES2019",
            Self::Es2020 => "ES2020",
            Self::Es2021 => "ES2021",
            Self::Es2022 => "ES2022",
            Self::Es2023 => "ES2023",
            Self::Es2024 => "ES2024",
            Self::EsNext => "ESNext",
        })
    }
}

impl FromStr for EcmaScriptTarget {
    type Err = EcmaScriptParseError;

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

        let normalized = normalize_target(trimmed);
        match normalized.as_str() {
            "es5" | "ecmascript5" => Ok(Self::Es5),
            "es6" | "es2015" | "ecmascript2015" => Ok(Self::Es2015),
            "es7" | "es2016" | "ecmascript2016" => Ok(Self::Es2016),
            "es8" | "es2017" | "ecmascript2017" => Ok(Self::Es2017),
            "es9" | "es2018" | "ecmascript2018" => Ok(Self::Es2018),
            "es10" | "es2019" | "ecmascript2019" => Ok(Self::Es2019),
            "es11" | "es2020" | "ecmascript2020" => Ok(Self::Es2020),
            "es12" | "es2021" | "ecmascript2021" => Ok(Self::Es2021),
            "es13" | "es2022" | "ecmascript2022" => Ok(Self::Es2022),
            "es14" | "es2023" | "ecmascript2023" => Ok(Self::Es2023),
            "es15" | "es2024" | "ecmascript2024" => Ok(Self::Es2024),
            "esnext" | "next" => Ok(Self::EsNext),
            _ => Err(EcmaScriptParseError::UnknownTarget),
        }
    }
}

/// Error returned while parsing ECMAScript labels.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EcmaScriptParseError {
    Empty,
    UnsupportedYear,
    UnknownTarget,
}

impl fmt::Display for EcmaScriptParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("ECMAScript target cannot be empty"),
            Self::UnsupportedYear => formatter.write_str("unsupported ECMAScript edition year"),
            Self::UnknownTarget => formatter.write_str("unknown ECMAScript target"),
        }
    }
}

impl Error for EcmaScriptParseError {}

fn normalize_target(input: &str) -> String {
    input
        .chars()
        .filter(|character| !matches!(character, '-' | '_' | ' '))
        .flat_map(char::to_lowercase)
        .collect()
}

#[cfg(test)]
mod tests {
    use super::{ES2020, ESNEXT, EcmaScriptParseError, EcmaScriptTarget, EcmaScriptYear};

    #[test]
    fn parses_common_targets() -> Result<(), EcmaScriptParseError> {
        assert_eq!("es2020".parse::<EcmaScriptTarget>()?, ES2020);
        assert_eq!("ES2020".parse::<EcmaScriptTarget>()?, ES2020);
        assert_eq!("es-next".parse::<EcmaScriptTarget>()?, ESNEXT);
        assert_eq!(ES2020.to_string(), "ES2020");
        Ok(())
    }

    #[test]
    fn validates_years() {
        assert_eq!(EcmaScriptYear::new(2024).map(EcmaScriptYear::get), Ok(2024));
        assert_eq!(
            EcmaScriptYear::new(2014),
            Err(EcmaScriptParseError::UnsupportedYear)
        );
    }
}