Skip to main content

perl_qualified_name/
lib.rs

1//! Focused helpers for Perl qualified-name parsing and validation.
2//!
3//! This crate has one responsibility: split canonical Perl package-qualified names
4//! (`Foo::Bar`) and validate each identifier segment with Unicode-safe rules.
5
6#![deny(unsafe_code)]
7#![warn(rust_2018_idioms)]
8#![warn(missing_docs)]
9#![warn(clippy::all)]
10
11use core::fmt;
12
13/// A validated parse failure for Perl-qualified names.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum QualifiedNameError {
16    /// The name is empty and cannot be interpreted.
17    EmptyName,
18    /// The name begins with a Perl sigil and is therefore not a package-qualified name.
19    LeadingSigil(char),
20    /// A `::` separator produced an empty segment.
21    EmptySegment {
22        /// Zero-based segment index where an empty segment was encountered.
23        index: usize,
24    },
25    /// A segment did not satisfy the Perl identifier rules.
26    InvalidSegment {
27        /// Zero-based segment index that failed identifier validation.
28        index: usize,
29    },
30}
31
32impl fmt::Display for QualifiedNameError {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Self::EmptyName => write!(f, "name is empty"),
36            Self::LeadingSigil(sigil) => {
37                write!(f, "qualified name cannot start with sigil '{sigil}'")
38            }
39            Self::EmptySegment { index } => {
40                write!(f, "segment {index} is empty (leading/trailing/double separator)")
41            }
42            Self::InvalidSegment { index } => {
43                write!(f, "segment {index} is not a valid identifier")
44            }
45        }
46    }
47}
48
49impl std::error::Error for QualifiedNameError {}
50
51/// Split a potentially qualified Perl name into `(package, bare_name)`.
52///
53/// `Foo::Bar` => `(Some("Foo"), "Bar")`
54/// `process` => `(None, "process")`
55#[must_use]
56pub fn split_qualified_name(name: &str) -> (Option<&str>, &str) {
57    if let Some(idx) = name.rfind("::") {
58        (Some(&name[..idx]), &name[idx + 2..])
59    } else {
60        (None, name)
61    }
62}
63
64/// Extract the parent container/package from a qualified Perl symbol.
65///
66/// `Foo::Bar::baz` => `Some("Foo::Bar")`
67/// `process` => `None`
68#[must_use]
69pub fn container_name(name: &str) -> Option<&str> {
70    split_qualified_name(name).0
71}
72
73/// Validate a full Perl qualified name with package separators.
74///
75/// - Rejects empty input.
76/// - Rejects leading sigils (`$`, `@`, `%`, `&`, `*`).
77/// - Requires each segment to be a valid Perl identifier.
78/// - Rejects empty segments from trailing/double/leading separators.
79pub fn validate_perl_qualified_name(name: &str) -> Result<(), QualifiedNameError> {
80    if name.is_empty() {
81        return Err(QualifiedNameError::EmptyName);
82    }
83
84    if name.starts_with(['$', '@', '%', '&', '*']) {
85        let sigil = name.chars().next().unwrap_or_default();
86        return Err(QualifiedNameError::LeadingSigil(sigil));
87    }
88
89    for (idx, part) in name.split("::").enumerate() {
90        if part.is_empty() {
91            return Err(QualifiedNameError::EmptySegment { index: idx });
92        }
93
94        if !is_valid_identifier_part(part) {
95            return Err(QualifiedNameError::InvalidSegment { index: idx });
96        }
97    }
98
99    Ok(())
100}
101
102/// Check whether a string is a valid Perl identifier component.
103#[must_use]
104pub fn is_valid_identifier_part(s: &str) -> bool {
105    let mut chars = s.chars();
106    match chars.next() {
107        Some(c) if c.is_alphabetic() || c == '_' => chars.all(|c| c.is_alphanumeric() || c == '_'),
108        _ => false,
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::{
115        container_name, is_valid_identifier_part, split_qualified_name,
116        validate_perl_qualified_name,
117    };
118
119    #[test]
120    fn splits_qualified_and_unqualified_names() {
121        assert_eq!(split_qualified_name("Foo::Bar"), (Some("Foo"), "Bar"));
122        assert_eq!(split_qualified_name("process"), (None, "process"));
123    }
124
125    #[test]
126    fn extracts_container_name() {
127        assert_eq!(container_name("Foo::Bar::baz"), Some("Foo::Bar"));
128        assert_eq!(container_name("MyClass::new"), Some("MyClass"));
129        assert_eq!(container_name("toplevel"), None);
130        assert_eq!(container_name(""), None);
131        assert_eq!(container_name("Package::"), Some("Package"));
132    }
133
134    #[test]
135    fn validates_simple_qualified_names() {
136        assert!(validate_perl_qualified_name("Package").is_ok());
137        assert!(validate_perl_qualified_name("Package::Sub").is_ok());
138        assert!(validate_perl_qualified_name("Müller::Util").is_ok());
139        assert!(validate_perl_qualified_name("日本::パッケージ").is_ok());
140    }
141
142    #[test]
143    fn rejects_invalid_qualified_names() {
144        assert!(validate_perl_qualified_name("").is_err());
145        assert!(validate_perl_qualified_name("Package::").is_err());
146        assert!(validate_perl_qualified_name("Foo::::Bar").is_err());
147        assert!(validate_perl_qualified_name("$foo").is_err());
148    }
149
150    #[test]
151    fn validates_identifier_part_syntax() {
152        assert!(is_valid_identifier_part("MyPkg"));
153        assert!(is_valid_identifier_part("π"));
154        assert!(!is_valid_identifier_part("1abc"));
155        assert!(!is_valid_identifier_part(""));
156    }
157}