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#[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#[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#[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#[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}