Skip to main content

use_php_namespace/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7macro_rules! namespace_name_newtype {
8    ($name:ident) => {
9        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10        pub struct $name(String);
11
12        impl $name {
13            pub fn new(input: &str) -> Result<Self, PhpNamespaceError> {
14                let trimmed = input.trim().trim_matches('\\');
15                validate_namespace_path(trimmed)?;
16                Ok(Self(trimmed.to_string()))
17            }
18
19            pub fn as_str(&self) -> &str {
20                &self.0
21            }
22
23            pub fn segments(&self) -> Vec<&str> {
24                split_segments(self.as_str())
25            }
26        }
27
28        impl fmt::Display for $name {
29            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
30                formatter.write_str(self.as_str())
31            }
32        }
33
34        impl FromStr for $name {
35            type Err = PhpNamespaceError;
36
37            fn from_str(input: &str) -> Result<Self, Self::Err> {
38                Self::new(input)
39            }
40        }
41    };
42}
43
44namespace_name_newtype!(PhpNamespacePath);
45namespace_name_newtype!(PhpRelativeName);
46namespace_name_newtype!(PhpNamespaceAlias);
47
48impl PhpNamespacePath {
49    pub fn global() -> Self {
50        Self(String::new())
51    }
52
53    pub fn is_global(&self) -> bool {
54        self.0.is_empty()
55    }
56
57    pub fn join(&self, segment: &str) -> Result<Self, PhpNamespaceError> {
58        validate_segment(segment)?;
59        if self.is_global() {
60            Self::new(segment)
61        } else {
62            Self::new(&format!("{}\\{segment}", self.as_str()))
63        }
64    }
65}
66
67/// Fully qualified PHP name metadata.
68#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
69pub struct PhpFullyQualifiedName(String);
70
71impl PhpFullyQualifiedName {
72    pub fn new(input: &str) -> Result<Self, PhpNamespaceError> {
73        let trimmed = input.trim();
74        if !trimmed.starts_with('\\') {
75            return Err(PhpNamespaceError::NotFullyQualified);
76        }
77        let bare = trimmed.trim_start_matches('\\');
78        validate_namespace_path(bare)?;
79        Ok(Self(bare.to_string()))
80    }
81
82    pub fn as_str(&self) -> &str {
83        &self.0
84    }
85
86    pub fn segments(&self) -> Vec<&str> {
87        split_segments(self.as_str())
88    }
89}
90
91impl fmt::Display for PhpFullyQualifiedName {
92    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93        write!(formatter, "\\{}", self.as_str())
94    }
95}
96
97impl FromStr for PhpFullyQualifiedName {
98    type Err = PhpNamespaceError;
99
100    fn from_str(input: &str) -> Result<Self, Self::Err> {
101        Self::new(input)
102    }
103}
104
105/// PHP namespace import metadata.
106#[derive(Clone, Debug, Eq, PartialEq)]
107pub struct PhpUseImport {
108    target: PhpFullyQualifiedName,
109    alias: Option<PhpNamespaceAlias>,
110}
111
112impl PhpUseImport {
113    pub const fn new(target: PhpFullyQualifiedName) -> Self {
114        Self {
115            target,
116            alias: None,
117        }
118    }
119
120    pub fn with_alias(mut self, alias: PhpNamespaceAlias) -> Self {
121        self.alias = Some(alias);
122        self
123    }
124
125    pub const fn target(&self) -> &PhpFullyQualifiedName {
126        &self.target
127    }
128
129    pub const fn alias(&self) -> Option<&PhpNamespaceAlias> {
130        self.alias.as_ref()
131    }
132}
133
134/// Marker for PHP's global namespace.
135#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
136pub struct GlobalNamespace;
137
138impl GlobalNamespace {
139    pub const fn path(self) -> &'static str {
140        ""
141    }
142}
143
144/// Error returned when PHP namespace metadata is invalid.
145#[derive(Clone, Copy, Debug, Eq, PartialEq)]
146pub enum PhpNamespaceError {
147    Empty,
148    EmptySegment,
149    InvalidSegment,
150    NotFullyQualified,
151}
152
153impl fmt::Display for PhpNamespaceError {
154    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155        match self {
156            Self::Empty => formatter.write_str("PHP namespace metadata cannot be empty"),
157            Self::EmptySegment => {
158                formatter.write_str("PHP namespace cannot contain empty segments")
159            },
160            Self::InvalidSegment => {
161                formatter.write_str("PHP namespace segment has an invalid shape")
162            },
163            Self::NotFullyQualified => formatter.write_str("PHP name is not fully qualified"),
164        }
165    }
166}
167
168impl Error for PhpNamespaceError {}
169
170fn split_segments(input: &str) -> Vec<&str> {
171    input
172        .split('\\')
173        .filter(|segment| !segment.is_empty())
174        .collect()
175}
176
177fn validate_namespace_path(input: &str) -> Result<(), PhpNamespaceError> {
178    if input.is_empty() {
179        return Ok(());
180    }
181    for segment in input.split('\\') {
182        if segment.is_empty() {
183            return Err(PhpNamespaceError::EmptySegment);
184        }
185        validate_segment(segment)?;
186    }
187    Ok(())
188}
189
190fn validate_segment(input: &str) -> Result<(), PhpNamespaceError> {
191    let trimmed = input.trim();
192    if trimmed.is_empty() {
193        return Err(PhpNamespaceError::Empty);
194    }
195    let mut characters = trimmed.chars();
196    let Some(first) = characters.next() else {
197        return Err(PhpNamespaceError::Empty);
198    };
199    if (first == '_' || first.is_ascii_alphabetic())
200        && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
201    {
202        Ok(())
203    } else {
204        Err(PhpNamespaceError::InvalidSegment)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::{
211        GlobalNamespace, PhpFullyQualifiedName, PhpNamespaceAlias, PhpNamespaceError,
212        PhpNamespacePath, PhpUseImport,
213    };
214
215    #[test]
216    fn validates_namespace_paths() -> Result<(), PhpNamespaceError> {
217        let namespace = PhpNamespacePath::new("App\\Http")?.join("Controller")?;
218        let name = PhpFullyQualifiedName::new("\\App\\Http\\Controller\\HomeController")?;
219        let import = PhpUseImport::new(name).with_alias(PhpNamespaceAlias::new("HomeController")?);
220
221        assert_eq!(namespace.segments(), vec!["App", "Http", "Controller"]);
222        assert_eq!(import.alias().expect("alias").as_str(), "HomeController");
223        assert_eq!(GlobalNamespace.path(), "");
224        Ok(())
225    }
226
227    #[test]
228    fn supports_global_namespace() {
229        assert!(PhpNamespacePath::global().is_global());
230    }
231}