#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
use use_python_keyword::is_python_keyword;
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PythonIdentifier(String);
impl PythonIdentifier {
pub fn new(input: &str) -> Result<Self, PythonIdentifierError> {
validate_ascii_python_identifier(input)?;
if is_python_keyword(input) {
return Err(PythonIdentifierError::Keyword);
}
Ok(Self(input.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for PythonIdentifier {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for PythonIdentifier {
type Err = PythonIdentifierError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for PythonIdentifier {
type Error = PythonIdentifierError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PythonDunderName(PythonIdentifier);
impl PythonDunderName {
pub fn new(input: &str) -> Result<Self, PythonIdentifierError> {
let identifier = PythonIdentifier::new(input)?;
if is_dunder_name(identifier.as_str()) {
Ok(Self(identifier))
} else {
Err(PythonIdentifierError::NotDunderName)
}
}
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl fmt::Display for PythonDunderName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PythonPrivateName(PythonIdentifier);
impl PythonPrivateName {
pub fn new(input: &str) -> Result<Self, PythonIdentifierError> {
let identifier = PythonIdentifier::new(input)?;
if is_private_name(identifier.as_str()) {
Ok(Self(identifier))
} else {
Err(PythonIdentifierError::NotPrivateName)
}
}
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl fmt::Display for PythonPrivateName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PythonIdentifierError {
Empty,
Keyword,
InvalidStart { character: char },
InvalidContinue { index: usize, character: char },
NotDunderName,
NotPrivateName,
}
impl fmt::Display for PythonIdentifierError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Python identifier cannot be empty"),
Self::Keyword => formatter.write_str("Python identifier cannot be a hard keyword"),
Self::InvalidStart { character } => {
write!(formatter, "invalid Python identifier start `{character}`")
}
Self::InvalidContinue { index, character } => write!(
formatter,
"invalid Python identifier continuation `{character}` at byte index {index}"
),
Self::NotDunderName => formatter.write_str("Python identifier is not a dunder name"),
Self::NotPrivateName => formatter.write_str("Python identifier is not a private name"),
}
}
}
impl Error for PythonIdentifierError {}
#[must_use]
pub const fn is_ascii_python_identifier_start(character: char) -> bool {
character == '_' || character.is_ascii_alphabetic()
}
#[must_use]
pub const fn is_ascii_python_identifier_continue(character: char) -> bool {
is_ascii_python_identifier_start(character) || character.is_ascii_digit()
}
#[must_use]
pub fn is_valid_ascii_python_identifier(input: &str) -> bool {
PythonIdentifier::new(input).is_ok()
}
#[must_use]
pub fn is_dunder_name(input: &str) -> bool {
input.len() > 4 && input.starts_with("__") && input.ends_with("__")
}
#[must_use]
pub fn is_private_name(input: &str) -> bool {
input.starts_with('_') && !is_dunder_name(input)
}
fn validate_ascii_python_identifier(input: &str) -> Result<(), PythonIdentifierError> {
if input.trim().is_empty() {
return Err(PythonIdentifierError::Empty);
}
let mut characters = input.char_indices();
let Some((_, first)) = characters.next() else {
return Err(PythonIdentifierError::Empty);
};
if !is_ascii_python_identifier_start(first) {
return Err(PythonIdentifierError::InvalidStart { character: first });
}
for (index, character) in characters {
if !is_ascii_python_identifier_continue(character) {
return Err(PythonIdentifierError::InvalidContinue { index, character });
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
PythonDunderName, PythonIdentifier, PythonIdentifierError, PythonPrivateName,
is_dunder_name, is_private_name, is_valid_ascii_python_identifier,
};
#[test]
fn accepts_ascii_identifiers() -> Result<(), PythonIdentifierError> {
let identifier = PythonIdentifier::new("async_task_1")?;
assert_eq!(identifier.as_str(), "async_task_1");
assert!(is_valid_ascii_python_identifier("_internal"));
assert!(is_valid_ascii_python_identifier("match"));
Ok(())
}
#[test]
fn rejects_invalid_identifiers_and_keywords() {
assert_eq!(PythonIdentifier::new(""), Err(PythonIdentifierError::Empty));
assert_eq!(
PythonIdentifier::new("class"),
Err(PythonIdentifierError::Keyword)
);
assert_eq!(
PythonIdentifier::new("1value"),
Err(PythonIdentifierError::InvalidStart { character: '1' })
);
assert!(!is_valid_ascii_python_identifier("has-dash"));
assert!(!is_valid_ascii_python_identifier("π"));
}
#[test]
fn validates_dunder_and_private_names() -> Result<(), PythonIdentifierError> {
let dunder = PythonDunderName::new("__init__")?;
let private = PythonPrivateName::new("_cache")?;
assert_eq!(dunder.as_str(), "__init__");
assert_eq!(private.as_str(), "_cache");
assert!(is_dunder_name("__len__"));
assert!(is_private_name("_name"));
assert!(!is_private_name("__name__"));
Ok(())
}
}