#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct JsIdentifier(String);
impl JsIdentifier {
pub fn new(input: &str) -> Result<Self, JsIdentifierError> {
validate_ascii_js_identifier(input)?;
Ok(Self(input.to_string()))
}
#[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)
}
}
#[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 {}
#[must_use]
pub const fn is_ascii_js_identifier_start(character: char) -> bool {
character == '$' || character == '_' || character.is_ascii_alphabetic()
}
#[must_use]
pub const fn is_ascii_js_identifier_continue(character: char) -> bool {
is_ascii_js_identifier_start(character) || character.is_ascii_digit()
}
#[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("π"));
}
}