Skip to main content

perl_parser_core/syntax/
qualified_name.rs

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