use-js-identifier 0.0.1

ASCII-safe JavaScript identifier primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

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

/// Validated ASCII-safe JavaScript identifier.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct JsIdentifier(String);

impl JsIdentifier {
    /// Creates an ASCII-safe JavaScript identifier.
    ///
    /// # Errors
    ///
    /// Returns [`JsIdentifierError`] when `input` is empty or not ASCII identifier-shaped.
    pub fn new(input: &str) -> Result<Self, JsIdentifierError> {
        validate_ascii_js_identifier(input)?;
        Ok(Self(input.to_string()))
    }

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

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

impl FromStr for JsIdentifier {
    type Err = JsIdentifierError;

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

impl TryFrom<&str> for JsIdentifier {
    type Error = JsIdentifierError;

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

/// Error returned when an ASCII JavaScript identifier is invalid.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum JsIdentifierError {
    Empty,
    InvalidStart { character: char },
    InvalidContinue { index: usize, character: char },
}

impl fmt::Display for JsIdentifierError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("JavaScript identifier cannot be empty"),
            Self::InvalidStart { character } => {
                write!(
                    formatter,
                    "invalid JavaScript identifier start `{character}`"
                )
            }
            Self::InvalidContinue { index, character } => write!(
                formatter,
                "invalid JavaScript identifier continuation `{character}` at byte index {index}"
            ),
        }
    }
}

impl Error for JsIdentifierError {}

/// Returns whether `character` is accepted as an ASCII JavaScript identifier start.
#[must_use]
pub const fn is_ascii_js_identifier_start(character: char) -> bool {
    character == '$' || character == '_' || character.is_ascii_alphabetic()
}

/// Returns whether `character` is accepted after the first identifier character.
#[must_use]
pub const fn is_ascii_js_identifier_continue(character: char) -> bool {
    is_ascii_js_identifier_start(character) || character.is_ascii_digit()
}

/// Returns whether `input` is an ASCII-safe JavaScript identifier.
#[must_use]
pub fn is_valid_ascii_js_identifier(input: &str) -> bool {
    validate_ascii_js_identifier(input).is_ok()
}

fn validate_ascii_js_identifier(input: &str) -> Result<(), JsIdentifierError> {
    let mut characters = input.char_indices();
    let Some((_, first)) = characters.next() else {
        return Err(JsIdentifierError::Empty);
    };

    if !is_ascii_js_identifier_start(first) {
        return Err(JsIdentifierError::InvalidStart { character: first });
    }

    for (index, character) in characters {
        if !is_ascii_js_identifier_continue(character) {
            return Err(JsIdentifierError::InvalidContinue { index, character });
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{JsIdentifier, JsIdentifierError, is_valid_ascii_js_identifier};

    #[test]
    fn accepts_ascii_identifiers() -> Result<(), JsIdentifierError> {
        let identifier = JsIdentifier::new("createApp_1")?;
        assert_eq!(identifier.as_str(), "createApp_1");
        assert!(is_valid_ascii_js_identifier("$value"));
        assert!(is_valid_ascii_js_identifier("_internal"));
        Ok(())
    }

    #[test]
    fn rejects_invalid_identifiers() {
        assert_eq!(JsIdentifier::new(""), Err(JsIdentifierError::Empty));
        assert_eq!(
            JsIdentifier::new("1value"),
            Err(JsIdentifierError::InvalidStart { character: '1' })
        );
        assert!(!is_valid_ascii_js_identifier("has-dash"));
        assert!(!is_valid_ascii_js_identifier("π"));
    }
}