Skip to main content

use_php_type/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// PHP scalar type metadata.
8#[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/// Broad PHP type kind metadata.
48#[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/// Lightly validated PHP type name metadata.
88#[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/// PHP type metadata without type-checker behavior.
124#[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/// Error returned when PHP type metadata is invalid.
208#[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}