1use std::fmt;
2use std::str::FromStr;
3
4use schemars::JsonSchema;
5#[cfg(feature = "serde")]
6use serde::Serializer;
7#[cfg(feature = "serde")]
8use serde::de;
9#[cfg(feature = "serde")]
10use serde::de::Deserializer;
11
12use mago_syntax_core::part_of_identifier;
13use mago_syntax_core::start_of_identifier;
14
15const INVALID_PATH_ERROR: &str = "Invalid path: must be '*', '@all', '@self', '@this', '@native', '@php', '@builtin', a layer (e.g., '@layer:name'), a valid namespace (ending with '\\'), a valid symbol name, or a pattern containing wildcards ('*').";
16const INVALID_SELECTOR_ERROR: &str = "Invalid symbol selector: must be a valid namespace (ending with '\\'), a valid symbol name, or a pattern containing wildcards ('*').";
17const INVALID_NAMESPACE_ERROR: &str = "Invalid namespace: must be '@global' or a valid namespace ending with '\\'.";
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
20#[schemars(with = "String")]
21pub enum NamespacePath {
22 Global,
23 Specific(String),
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
28#[schemars(with = "String")]
29pub enum SymbolSelector {
30 Namespace(NamespacePath),
32 Symbol(String),
34 Pattern(String),
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
40#[schemars(with = "String")]
41pub enum Path {
42 All,
44 Self_,
46 Native,
48 Layer(String),
50 Selector(SymbolSelector),
52}
53
54pub(crate) fn is_valid_identifier_part(part: &str) -> bool {
56 if part.is_empty() {
57 return false;
58 }
59
60 let bytes = part.as_bytes();
61
62 matches!(bytes[0], start_of_identifier!()) && bytes[1..].iter().all(|byte| matches!(byte, part_of_identifier!()))
63}
64
65fn is_valid_pattern_part(part: &str) -> bool {
66 if part == "*" || part == "**" {
67 return true;
68 }
69
70 part.as_bytes()
71 .iter()
72 .all(|&byte| matches!(byte, b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'_' | b'\x80'..=b'\xff' | b'*'))
73}
74
75impl FromStr for NamespacePath {
76 type Err = &'static str;
77
78 fn from_str(s: &str) -> Result<Self, Self::Err> {
79 if s.eq_ignore_ascii_case("@global") {
80 return Ok(NamespacePath::Global);
81 }
82
83 let path_to_validate = s.strip_suffix('\\').unwrap_or(s);
84 if path_to_validate.split('\\').all(is_valid_identifier_part) {
85 Ok(NamespacePath::Specific(s.to_string()))
86 } else {
87 Err(INVALID_NAMESPACE_ERROR)
88 }
89 }
90}
91
92impl FromStr for SymbolSelector {
93 type Err = &'static str;
94
95 fn from_str(s: &str) -> Result<Self, Self::Err> {
96 if s.contains('*') {
97 if s.split('\\').all(is_valid_pattern_part) {
98 Ok(SymbolSelector::Pattern(s.to_string()))
99 } else {
100 Err(INVALID_SELECTOR_ERROR)
101 }
102 } else if s.ends_with('\\') || s.eq_ignore_ascii_case("@global") {
103 s.parse().map(SymbolSelector::Namespace)
104 } else if s.split('\\').all(is_valid_identifier_part) {
105 Ok(SymbolSelector::Symbol(s.to_string()))
106 } else {
107 Err(INVALID_SELECTOR_ERROR)
108 }
109 }
110}
111
112impl FromStr for Path {
113 type Err = &'static str;
114
115 fn from_str(s: &str) -> Result<Self, Self::Err> {
116 if s == "*" || s.eq_ignore_ascii_case("@all") {
117 Ok(Path::All)
118 } else if s.eq_ignore_ascii_case("@self") || s.eq_ignore_ascii_case("@this") {
119 Ok(Path::Self_)
120 } else if s.eq_ignore_ascii_case("@native")
121 || s.eq_ignore_ascii_case("@php")
122 || s.eq_ignore_ascii_case("@builtin")
123 {
124 Ok(Path::Native)
125 } else if let Some(layer_name) = s.strip_prefix("@layer:").or_else(|| s.strip_prefix("@layers:")) {
126 Ok(Path::Layer(layer_name.to_string()))
127 } else {
128 s.parse().ok().map(Path::Selector).ok_or(INVALID_PATH_ERROR)
129 }
130 }
131}
132
133impl fmt::Display for NamespacePath {
134 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135 match self {
136 NamespacePath::Global => write!(f, "@global"),
137 NamespacePath::Specific(s) => write!(f, "{s}"),
138 }
139 }
140}
141
142impl fmt::Display for SymbolSelector {
143 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144 match self {
145 SymbolSelector::Namespace(ns) => write!(f, "{ns}"),
146 SymbolSelector::Symbol(s) => write!(f, "{s}"),
147 SymbolSelector::Pattern(s) => write!(f, "{s}"),
148 }
149 }
150}
151
152impl fmt::Display for Path {
153 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154 match self {
155 Path::All => write!(f, "@all"),
156 Path::Self_ => write!(f, "@self"),
157 Path::Native => write!(f, "@native"),
158 Path::Layer(name) => write!(f, "@layer:{name}"),
159 Path::Selector(selector) => write!(f, "{selector}"),
160 }
161 }
162}
163
164#[cfg(feature = "serde")]
165impl<'de> serde::Deserialize<'de> for NamespacePath {
166 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
167 where
168 D: Deserializer<'de>,
169 {
170 String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
171 }
172}
173
174#[cfg(feature = "serde")]
175impl<'de> serde::Deserialize<'de> for SymbolSelector {
176 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
177 where
178 D: Deserializer<'de>,
179 {
180 String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
181 }
182}
183
184#[cfg(feature = "serde")]
185impl<'de> serde::Deserialize<'de> for Path {
186 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
187 where
188 D: Deserializer<'de>,
189 {
190 String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
191 }
192}
193
194#[cfg(feature = "serde")]
195impl serde::Serialize for NamespacePath {
196 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
197 where
198 S: Serializer,
199 {
200 serializer.serialize_str(&self.to_string())
201 }
202}
203
204#[cfg(feature = "serde")]
205impl serde::Serialize for SymbolSelector {
206 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
207 where
208 S: Serializer,
209 {
210 serializer.serialize_str(&self.to_string())
211 }
212}
213
214#[cfg(feature = "serde")]
215impl serde::Serialize for Path {
216 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
217 where
218 S: Serializer,
219 {
220 serializer.serialize_str(&self.to_string())
221 }
222}
223
224#[cfg(test)]
225#[allow(clippy::unwrap_used, clippy::expect_used)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn test_path_parsing_and_formatting() {
231 assert_eq!("@all".parse::<Path>().unwrap(), Path::All);
232 assert_eq!("@self".parse::<Path>().unwrap(), Path::Self_);
233 assert_eq!("@native".parse::<Path>().unwrap(), Path::Native);
234 assert_eq!("@layer:core".parse::<Path>().unwrap(), Path::Layer("core".to_string()));
235 assert_eq!(
236 "App\\Domain\\".parse::<Path>().unwrap(),
237 Path::Selector(SymbolSelector::Namespace(NamespacePath::Specific("App\\Domain\\".to_string())))
238 );
239 assert_eq!(
240 "App\\Domain\\Model".parse::<Path>().unwrap(),
241 Path::Selector(SymbolSelector::Symbol("App\\Domain\\Model".to_string()))
242 );
243 assert_eq!("App\\**".parse::<Path>().unwrap(), Path::Selector(SymbolSelector::Pattern("App\\**".to_string())));
244
245 assert_eq!(Path::All.to_string(), "@all");
246 assert_eq!(Path::Self_.to_string(), "@self");
247 assert_eq!(Path::Native.to_string(), "@native");
248 assert_eq!(Path::Layer("core".to_string()).to_string(), "@layer:core");
249 assert_eq!(Path::Selector(SymbolSelector::Namespace(NamespacePath::Global)).to_string(), "@global");
250 assert_eq!(
251 Path::Selector(SymbolSelector::Namespace(NamespacePath::Specific("App\\Domain\\".to_string()))).to_string(),
252 "App\\Domain\\"
253 );
254 assert_eq!(Path::Selector(SymbolSelector::Symbol("My\\Class".to_string())).to_string(), "My\\Class");
255 assert_eq!(Path::Selector(SymbolSelector::Pattern("My\\**".to_string())).to_string(), "My\\**");
256 }
257
258 #[test]
259 fn test_valid_patterns_parse_correctly() {
260 "App\\*".parse::<Path>().unwrap();
261 "App\\**".parse::<Path>().unwrap();
262 "App\\*Something".parse::<Path>().unwrap();
263 "App\\*Something*".parse::<Path>().unwrap();
264 "App\\*Some*thing".parse::<Path>().unwrap();
265 }
266
267 #[test]
268 fn test_invalid_paths_fail_to_parse() {
269 "Invalid-Class".parse::<Path>().unwrap_err();
270 "My\\Invalid-Namespace\\".parse::<Path>().unwrap_err();
271 "1LeadingNumber".parse::<Path>().unwrap_err();
272 "My\\1LeadingNumber".parse::<Path>().unwrap_err();
273 "@My\\Namespace\\".parse::<Path>().unwrap_err();
274 "My\\Invalid-Namespace\\".parse::<Path>().unwrap_err();
275 "App\\Invalid-*.php".parse::<Path>().unwrap_err();
276 }
277}