Skip to main content

mago_guard/
path.rs

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/// Selects a specific symbol or a group of symbols.
27#[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
28#[schemars(with = "String")]
29pub enum SymbolSelector {
30    /// A specific namespace, e.g., `App\Domain\`
31    Namespace(NamespacePath),
32    /// A specific, fully qualified symbol name.
33    Symbol(String),
34    /// A glob-like pattern, e.g., `App\Domain\**`.
35    Pattern(String),
36}
37
38/// Represents a path, which can be a standard namespace, a layer, or a special keyword.
39#[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
40#[schemars(with = "String")]
41pub enum Path {
42    /// Represents all namespaces, often denoted as `*` or `@all`.
43    All,
44    /// The current namespace, represented as `@self` or `@this`.
45    Self_,
46    /// Native PHP symbols, represented as `@native`, `@php`, or `@builtin`.
47    Native,
48    /// A reusable layer reference, e.g., `@layer:common`.
49    Layer(String),
50    /// A selector for symbols, namespaces, or patterns.
51    Selector(SymbolSelector),
52}
53
54/// Checks if a string segment is a valid PHP identifier.
55pub(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}