Skip to main content

use_python_identifier/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7use use_python_keyword::is_python_keyword;
8
9/// Validated ASCII-safe Python identifier.
10#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
11pub struct PythonIdentifier(String);
12
13impl PythonIdentifier {
14    /// Creates an ASCII-safe Python identifier that is not a hard Python keyword.
15    ///
16    /// # Errors
17    ///
18    /// Returns [`PythonIdentifierError`] when `input` is empty, keyword-shaped, or not ASCII identifier-shaped.
19    pub fn new(input: &str) -> Result<Self, PythonIdentifierError> {
20        validate_ascii_python_identifier(input)?;
21        if is_python_keyword(input) {
22            return Err(PythonIdentifierError::Keyword);
23        }
24        Ok(Self(input.to_string()))
25    }
26
27    /// Returns the identifier as a string slice.
28    #[must_use]
29    pub fn as_str(&self) -> &str {
30        &self.0
31    }
32}
33
34impl fmt::Display for PythonIdentifier {
35    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36        formatter.write_str(self.as_str())
37    }
38}
39
40impl FromStr for PythonIdentifier {
41    type Err = PythonIdentifierError;
42
43    fn from_str(input: &str) -> Result<Self, Self::Err> {
44        Self::new(input)
45    }
46}
47
48impl TryFrom<&str> for PythonIdentifier {
49    type Error = PythonIdentifierError;
50
51    fn try_from(value: &str) -> Result<Self, Self::Error> {
52        Self::new(value)
53    }
54}
55
56/// Validated Python dunder name metadata.
57#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
58pub struct PythonDunderName(PythonIdentifier);
59
60impl PythonDunderName {
61    /// Creates Python dunder name metadata.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`PythonIdentifierError`] when `input` is not a valid dunder identifier.
66    pub fn new(input: &str) -> Result<Self, PythonIdentifierError> {
67        let identifier = PythonIdentifier::new(input)?;
68        if is_dunder_name(identifier.as_str()) {
69            Ok(Self(identifier))
70        } else {
71            Err(PythonIdentifierError::NotDunderName)
72        }
73    }
74
75    /// Returns the dunder name.
76    #[must_use]
77    pub fn as_str(&self) -> &str {
78        self.0.as_str()
79    }
80}
81
82impl fmt::Display for PythonDunderName {
83    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84        formatter.write_str(self.as_str())
85    }
86}
87
88/// Validated Python private-name metadata.
89#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
90pub struct PythonPrivateName(PythonIdentifier);
91
92impl PythonPrivateName {
93    /// Creates Python private-name metadata.
94    ///
95    /// # Errors
96    ///
97    /// Returns [`PythonIdentifierError`] when `input` is not a valid private identifier.
98    pub fn new(input: &str) -> Result<Self, PythonIdentifierError> {
99        let identifier = PythonIdentifier::new(input)?;
100        if is_private_name(identifier.as_str()) {
101            Ok(Self(identifier))
102        } else {
103            Err(PythonIdentifierError::NotPrivateName)
104        }
105    }
106
107    /// Returns the private name.
108    #[must_use]
109    pub fn as_str(&self) -> &str {
110        self.0.as_str()
111    }
112}
113
114impl fmt::Display for PythonPrivateName {
115    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
116        formatter.write_str(self.as_str())
117    }
118}
119
120/// Error returned when an ASCII Python identifier is invalid.
121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122pub enum PythonIdentifierError {
123    Empty,
124    Keyword,
125    InvalidStart { character: char },
126    InvalidContinue { index: usize, character: char },
127    NotDunderName,
128    NotPrivateName,
129}
130
131impl fmt::Display for PythonIdentifierError {
132    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            Self::Empty => formatter.write_str("Python identifier cannot be empty"),
135            Self::Keyword => formatter.write_str("Python identifier cannot be a hard keyword"),
136            Self::InvalidStart { character } => {
137                write!(formatter, "invalid Python identifier start `{character}`")
138            }
139            Self::InvalidContinue { index, character } => write!(
140                formatter,
141                "invalid Python identifier continuation `{character}` at byte index {index}"
142            ),
143            Self::NotDunderName => formatter.write_str("Python identifier is not a dunder name"),
144            Self::NotPrivateName => formatter.write_str("Python identifier is not a private name"),
145        }
146    }
147}
148
149impl Error for PythonIdentifierError {}
150
151/// Returns whether `character` is accepted as an ASCII Python identifier start.
152#[must_use]
153pub const fn is_ascii_python_identifier_start(character: char) -> bool {
154    character == '_' || character.is_ascii_alphabetic()
155}
156
157/// Returns whether `character` is accepted after the first identifier character.
158#[must_use]
159pub const fn is_ascii_python_identifier_continue(character: char) -> bool {
160    is_ascii_python_identifier_start(character) || character.is_ascii_digit()
161}
162
163/// Returns whether `input` is an ASCII-safe Python identifier and not a hard keyword.
164#[must_use]
165pub fn is_valid_ascii_python_identifier(input: &str) -> bool {
166    PythonIdentifier::new(input).is_ok()
167}
168
169/// Returns whether `input` looks like a Python dunder name such as `__init__`.
170#[must_use]
171pub fn is_dunder_name(input: &str) -> bool {
172    input.len() > 4 && input.starts_with("__") && input.ends_with("__")
173}
174
175/// Returns whether `input` looks like a single-underscore private name.
176#[must_use]
177pub fn is_private_name(input: &str) -> bool {
178    input.starts_with('_') && !is_dunder_name(input)
179}
180
181fn validate_ascii_python_identifier(input: &str) -> Result<(), PythonIdentifierError> {
182    if input.trim().is_empty() {
183        return Err(PythonIdentifierError::Empty);
184    }
185
186    let mut characters = input.char_indices();
187    let Some((_, first)) = characters.next() else {
188        return Err(PythonIdentifierError::Empty);
189    };
190
191    if !is_ascii_python_identifier_start(first) {
192        return Err(PythonIdentifierError::InvalidStart { character: first });
193    }
194
195    for (index, character) in characters {
196        if !is_ascii_python_identifier_continue(character) {
197            return Err(PythonIdentifierError::InvalidContinue { index, character });
198        }
199    }
200
201    Ok(())
202}
203
204#[cfg(test)]
205mod tests {
206    use super::{
207        PythonDunderName, PythonIdentifier, PythonIdentifierError, PythonPrivateName,
208        is_dunder_name, is_private_name, is_valid_ascii_python_identifier,
209    };
210
211    #[test]
212    fn accepts_ascii_identifiers() -> Result<(), PythonIdentifierError> {
213        let identifier = PythonIdentifier::new("async_task_1")?;
214
215        assert_eq!(identifier.as_str(), "async_task_1");
216        assert!(is_valid_ascii_python_identifier("_internal"));
217        assert!(is_valid_ascii_python_identifier("match"));
218        Ok(())
219    }
220
221    #[test]
222    fn rejects_invalid_identifiers_and_keywords() {
223        assert_eq!(PythonIdentifier::new(""), Err(PythonIdentifierError::Empty));
224        assert_eq!(
225            PythonIdentifier::new("class"),
226            Err(PythonIdentifierError::Keyword)
227        );
228        assert_eq!(
229            PythonIdentifier::new("1value"),
230            Err(PythonIdentifierError::InvalidStart { character: '1' })
231        );
232        assert!(!is_valid_ascii_python_identifier("has-dash"));
233        assert!(!is_valid_ascii_python_identifier("π"));
234    }
235
236    #[test]
237    fn validates_dunder_and_private_names() -> Result<(), PythonIdentifierError> {
238        let dunder = PythonDunderName::new("__init__")?;
239        let private = PythonPrivateName::new("_cache")?;
240
241        assert_eq!(dunder.as_str(), "__init__");
242        assert_eq!(private.as_str(), "_cache");
243        assert!(is_dunder_name("__len__"));
244        assert!(is_private_name("_name"));
245        assert!(!is_private_name("__name__"));
246        Ok(())
247    }
248}