Skip to main content

use_js_identifier/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Validated ASCII-safe JavaScript identifier.
8#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub struct JsIdentifier(String);
10
11impl JsIdentifier {
12    /// Creates an ASCII-safe JavaScript identifier.
13    ///
14    /// # Errors
15    ///
16    /// Returns [`JsIdentifierError`] when `input` is empty or not ASCII identifier-shaped.
17    pub fn new(input: &str) -> Result<Self, JsIdentifierError> {
18        validate_ascii_js_identifier(input)?;
19        Ok(Self(input.to_string()))
20    }
21
22    /// Returns the identifier as a string slice.
23    #[must_use]
24    pub fn as_str(&self) -> &str {
25        &self.0
26    }
27}
28
29impl fmt::Display for JsIdentifier {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        formatter.write_str(self.as_str())
32    }
33}
34
35impl FromStr for JsIdentifier {
36    type Err = JsIdentifierError;
37
38    fn from_str(input: &str) -> Result<Self, Self::Err> {
39        Self::new(input)
40    }
41}
42
43impl TryFrom<&str> for JsIdentifier {
44    type Error = JsIdentifierError;
45
46    fn try_from(value: &str) -> Result<Self, Self::Error> {
47        Self::new(value)
48    }
49}
50
51/// Error returned when an ASCII JavaScript identifier is invalid.
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub enum JsIdentifierError {
54    Empty,
55    InvalidStart { character: char },
56    InvalidContinue { index: usize, character: char },
57}
58
59impl fmt::Display for JsIdentifierError {
60    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::Empty => formatter.write_str("JavaScript identifier cannot be empty"),
63            Self::InvalidStart { character } => {
64                write!(
65                    formatter,
66                    "invalid JavaScript identifier start `{character}`"
67                )
68            }
69            Self::InvalidContinue { index, character } => write!(
70                formatter,
71                "invalid JavaScript identifier continuation `{character}` at byte index {index}"
72            ),
73        }
74    }
75}
76
77impl Error for JsIdentifierError {}
78
79/// Returns whether `character` is accepted as an ASCII JavaScript identifier start.
80#[must_use]
81pub const fn is_ascii_js_identifier_start(character: char) -> bool {
82    character == '$' || character == '_' || character.is_ascii_alphabetic()
83}
84
85/// Returns whether `character` is accepted after the first identifier character.
86#[must_use]
87pub const fn is_ascii_js_identifier_continue(character: char) -> bool {
88    is_ascii_js_identifier_start(character) || character.is_ascii_digit()
89}
90
91/// Returns whether `input` is an ASCII-safe JavaScript identifier.
92#[must_use]
93pub fn is_valid_ascii_js_identifier(input: &str) -> bool {
94    validate_ascii_js_identifier(input).is_ok()
95}
96
97fn validate_ascii_js_identifier(input: &str) -> Result<(), JsIdentifierError> {
98    let mut characters = input.char_indices();
99    let Some((_, first)) = characters.next() else {
100        return Err(JsIdentifierError::Empty);
101    };
102
103    if !is_ascii_js_identifier_start(first) {
104        return Err(JsIdentifierError::InvalidStart { character: first });
105    }
106
107    for (index, character) in characters {
108        if !is_ascii_js_identifier_continue(character) {
109            return Err(JsIdentifierError::InvalidContinue { index, character });
110        }
111    }
112
113    Ok(())
114}
115
116#[cfg(test)]
117mod tests {
118    use super::{JsIdentifier, JsIdentifierError, is_valid_ascii_js_identifier};
119
120    #[test]
121    fn accepts_ascii_identifiers() -> Result<(), JsIdentifierError> {
122        let identifier = JsIdentifier::new("createApp_1")?;
123        assert_eq!(identifier.as_str(), "createApp_1");
124        assert!(is_valid_ascii_js_identifier("$value"));
125        assert!(is_valid_ascii_js_identifier("_internal"));
126        Ok(())
127    }
128
129    #[test]
130    fn rejects_invalid_identifiers() {
131        assert_eq!(JsIdentifier::new(""), Err(JsIdentifierError::Empty));
132        assert_eq!(
133            JsIdentifier::new("1value"),
134            Err(JsIdentifierError::InvalidStart { character: '1' })
135        );
136        assert!(!is_valid_ascii_js_identifier("has-dash"));
137        assert!(!is_valid_ascii_js_identifier("π"));
138    }
139}