1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub struct JsIdentifier(String);
10
11impl JsIdentifier {
12 pub fn new(input: &str) -> Result<Self, JsIdentifierError> {
18 validate_ascii_js_identifier(input)?;
19 Ok(Self(input.to_string()))
20 }
21
22 #[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#[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#[must_use]
81pub const fn is_ascii_js_identifier_start(character: char) -> bool {
82 character == '$' || character == '_' || character.is_ascii_alphabetic()
83}
84
85#[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#[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}