1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
9pub enum PhpScalarType {
10 Bool,
11 Int,
12 Float,
13 String,
14}
15
16impl PhpScalarType {
17 pub const fn as_str(self) -> &'static str {
18 match self {
19 Self::Bool => "bool",
20 Self::Int => "int",
21 Self::Float => "float",
22 Self::String => "string",
23 }
24 }
25}
26
27impl fmt::Display for PhpScalarType {
28 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
29 formatter.write_str(self.as_str())
30 }
31}
32
33impl FromStr for PhpScalarType {
34 type Err = PhpTypeError;
35
36 fn from_str(input: &str) -> Result<Self, Self::Err> {
37 match normalized_label(input)?.as_str() {
38 "bool" | "boolean" => Ok(Self::Bool),
39 "int" | "integer" => Ok(Self::Int),
40 "float" | "double" => Ok(Self::Float),
41 "string" => Ok(Self::String),
42 _ => Err(PhpTypeError::UnknownLabel),
43 }
44 }
45}
46
47#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
49pub enum PhpTypeKind {
50 Scalar,
51 Nullable,
52 Union,
53 Intersection,
54 Mixed,
55 Never,
56 Void,
57 Callable,
58 Iterable,
59 Object,
60 ClassLike,
61}
62
63impl PhpTypeKind {
64 pub const fn as_str(self) -> &'static str {
65 match self {
66 Self::Scalar => "scalar",
67 Self::Nullable => "nullable",
68 Self::Union => "union",
69 Self::Intersection => "intersection",
70 Self::Mixed => "mixed",
71 Self::Never => "never",
72 Self::Void => "void",
73 Self::Callable => "callable",
74 Self::Iterable => "iterable",
75 Self::Object => "object",
76 Self::ClassLike => "class-like",
77 }
78 }
79}
80
81impl fmt::Display for PhpTypeKind {
82 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
83 formatter.write_str(self.as_str())
84 }
85}
86
87#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
89pub struct PhpTypeName(String);
90
91impl PhpTypeName {
92 pub fn new(input: &str) -> Result<Self, PhpTypeError> {
93 let trimmed = input.trim().trim_start_matches('\\');
94 validate_type_name(trimmed)?;
95 Ok(Self(trimmed.to_string()))
96 }
97
98 pub fn as_str(&self) -> &str {
99 &self.0
100 }
101
102 pub fn segments(&self) -> Vec<&str> {
103 self.0.split('\\').collect()
104 }
105}
106
107impl fmt::Display for PhpTypeName {
108 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109 formatter.write_str(self.as_str())
110 }
111}
112
113impl FromStr for PhpTypeName {
114 type Err = PhpTypeError;
115
116 fn from_str(input: &str) -> Result<Self, Self::Err> {
117 Self::new(input)
118 }
119}
120
121pub type PhpClassLikeTypeName = PhpTypeName;
122
123#[derive(Clone, Debug, Eq, PartialEq)]
125pub enum PhpType {
126 Scalar(PhpScalarType),
127 Named(PhpTypeName),
128 Nullable(Box<PhpType>),
129 Union(Vec<PhpType>),
130 Intersection(Vec<PhpType>),
131 Mixed,
132 Never,
133 Void,
134 Callable,
135 Iterable,
136 Object,
137}
138
139impl PhpType {
140 pub const fn scalar(kind: PhpScalarType) -> Self {
141 Self::Scalar(kind)
142 }
143
144 pub const fn named(name: PhpTypeName) -> Self {
145 Self::Named(name)
146 }
147
148 pub fn nullable(inner: Self) -> Result<Self, PhpTypeError> {
149 if matches!(inner, Self::Void | Self::Never) {
150 Err(PhpTypeError::InvalidComposite)
151 } else {
152 Ok(Self::Nullable(Box::new(inner)))
153 }
154 }
155
156 pub fn union(types: Vec<Self>) -> Result<Self, PhpTypeError> {
157 if types.len() < 2 {
158 Err(PhpTypeError::TooFewTypes)
159 } else {
160 Ok(Self::Union(types))
161 }
162 }
163
164 pub fn intersection(types: Vec<Self>) -> Result<Self, PhpTypeError> {
165 if types.len() < 2 {
166 Err(PhpTypeError::TooFewTypes)
167 } else {
168 Ok(Self::Intersection(types))
169 }
170 }
171
172 pub const fn kind(&self) -> PhpTypeKind {
173 match self {
174 Self::Scalar(_) => PhpTypeKind::Scalar,
175 Self::Named(_) => PhpTypeKind::ClassLike,
176 Self::Nullable(_) => PhpTypeKind::Nullable,
177 Self::Union(_) => PhpTypeKind::Union,
178 Self::Intersection(_) => PhpTypeKind::Intersection,
179 Self::Mixed => PhpTypeKind::Mixed,
180 Self::Never => PhpTypeKind::Never,
181 Self::Void => PhpTypeKind::Void,
182 Self::Callable => PhpTypeKind::Callable,
183 Self::Iterable => PhpTypeKind::Iterable,
184 Self::Object => PhpTypeKind::Object,
185 }
186 }
187}
188
189impl fmt::Display for PhpType {
190 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
191 match self {
192 Self::Scalar(kind) => formatter.write_str(kind.as_str()),
193 Self::Named(name) => formatter.write_str(name.as_str()),
194 Self::Nullable(inner) => write!(formatter, "?{inner}"),
195 Self::Union(types) => write_joined_types(formatter, types, "|"),
196 Self::Intersection(types) => write_joined_types(formatter, types, "&"),
197 Self::Mixed => formatter.write_str("mixed"),
198 Self::Never => formatter.write_str("never"),
199 Self::Void => formatter.write_str("void"),
200 Self::Callable => formatter.write_str("callable"),
201 Self::Iterable => formatter.write_str("iterable"),
202 Self::Object => formatter.write_str("object"),
203 }
204 }
205}
206
207#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209pub enum PhpTypeError {
210 Empty,
211 EmptySegment,
212 InvalidName,
213 InvalidComposite,
214 TooFewTypes,
215 UnknownLabel,
216}
217
218impl fmt::Display for PhpTypeError {
219 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
220 match self {
221 Self::Empty => formatter.write_str("PHP type metadata cannot be empty"),
222 Self::EmptySegment => {
223 formatter.write_str("PHP type name cannot contain empty segments")
224 },
225 Self::InvalidName => formatter.write_str("PHP type name has an invalid shape"),
226 Self::InvalidComposite => formatter.write_str("PHP type composite is invalid"),
227 Self::TooFewTypes => formatter.write_str("PHP composite type needs at least two types"),
228 Self::UnknownLabel => formatter.write_str("unknown PHP type metadata label"),
229 }
230 }
231}
232
233impl Error for PhpTypeError {}
234
235fn write_joined_types(
236 formatter: &mut fmt::Formatter<'_>,
237 types: &[PhpType],
238 separator: &str,
239) -> fmt::Result {
240 for (index, value) in types.iter().enumerate() {
241 if index > 0 {
242 formatter.write_str(separator)?;
243 }
244 write!(formatter, "{value}")?;
245 }
246 Ok(())
247}
248
249fn validate_type_name(input: &str) -> Result<(), PhpTypeError> {
250 if input.is_empty() {
251 return Err(PhpTypeError::Empty);
252 }
253 for segment in input.split('\\') {
254 if segment.is_empty() {
255 return Err(PhpTypeError::EmptySegment);
256 }
257 let mut characters = segment.chars();
258 let Some(first) = characters.next() else {
259 return Err(PhpTypeError::EmptySegment);
260 };
261 if !(first == '_' || first.is_ascii_alphabetic())
262 || !characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
263 {
264 return Err(PhpTypeError::InvalidName);
265 }
266 }
267 Ok(())
268}
269
270fn normalized_label(input: &str) -> Result<String, PhpTypeError> {
271 let trimmed = input.trim();
272 if trimmed.is_empty() {
273 Err(PhpTypeError::Empty)
274 } else {
275 Ok(trimmed.to_ascii_lowercase())
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::{PhpScalarType, PhpType, PhpTypeError, PhpTypeName};
282
283 #[test]
284 fn builds_union_and_nullable_types() -> Result<(), PhpTypeError> {
285 let dto = PhpType::named(PhpTypeName::new("App\\Dto\\UserData")?);
286 let union = PhpType::union(vec![PhpType::scalar(PhpScalarType::String), dto])?;
287 let nullable = PhpType::nullable(PhpType::scalar(PhpScalarType::Int))?;
288
289 assert_eq!(union.to_string(), "string|App\\Dto\\UserData");
290 assert_eq!(nullable.to_string(), "?int");
291 assert_eq!(union.kind().as_str(), "union");
292 Ok(())
293 }
294}