• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

extphprs / ext-php-rs / 25379163335

05 May 2026 01:25PM UTC coverage: 72.479% (+6.2%) from 66.241%
25379163335

Pull #734

github

web-flow
Merge 889e3cde4 into 0912e7c24
Pull Request #734: feat!: PHP 8 union, intersection, DNF, and class-union type hints

2871 of 3074 new or added lines in 21 files covered. (93.4%)

3 existing lines in 2 files now uncovered.

11514 of 15886 relevant lines covered (72.48%)

33.34 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.22
/crates/types/src/php_type.rs
1
//! PHP argument and return type expressions.
2
//!
3
//! [`PhpType`] is the single vocabulary used by `ext-php-rs` to describe
4
//! every shape of PHP type declaration that the crate supports:
5
//! [`PhpType::Simple`], primitive [`PhpType::Union`], class
6
//! [`PhpType::ClassUnion`], class [`PhpType::Intersection`] (PHP 8.1+), and
7
//! the disjunctive normal form [`PhpType::Dnf`] (PHP 8.2+).
8

9
use std::fmt;
10
use std::str::FromStr;
11

12
use crate::DataType;
13

14
/// One disjunct of a [`PhpType::Dnf`] type. PHP 8.2+.
15
///
16
/// PHP's DNF grammar is a top-level union whose alternatives may themselves
17
/// be intersection groups, e.g. `(A&B)|C`. Each [`DnfTerm`] is one alternative
18
/// on the union side: either a single class name (the `C`) or an intersection
19
/// group (the `A&B`).
20
///
21
/// `Intersection` always carries 2 or more members. A single-element group is
22
/// rejected by the FFI emission layer; callers should use [`DnfTerm::Single`]
23
/// for one-class disjuncts. The future type-string parser canonicalises this
24
/// shape automatically.
25
#[derive(Debug, Clone, PartialEq, Eq)]
26
pub enum DnfTerm {
27
    /// A single class name, e.g. the `C` in `(A&B)|C`. Class names must be
28
    /// non-empty and contain no interior NUL bytes.
29
    Single(String),
30
    /// An intersection group of class/interface names, e.g. the `A&B` in
31
    /// `(A&B)|C`. Always carries 2 or more members; one-element groups are
32
    /// rejected at the FFI emission layer (use [`DnfTerm::Single`] instead).
33
    Intersection(Vec<String>),
34
}
35

36
/// A PHP type expression as used in argument or return position.
37
///
38
/// `Simple` covers the long-standing single-type form (`int`, `string`,
39
/// `Foo`, ...). `Union` covers a primitive union such as `int|string`.
40
/// `ClassUnion` covers a union of class names such as `Foo|Bar`.
41
/// `Intersection` covers `Countable&Traversable`. `Dnf` covers
42
/// `(A&B)|C` and its nullable form `(A&B)|null`.
43
///
44
/// A `Union` carrying fewer than two members is technically constructable but
45
/// semantically equivalent to (or weaker than) a [`PhpType::Simple`]; callers
46
/// should prefer `Simple` for the single-type case. The runtime does not
47
/// auto-collapse unions: collapsing is the parser's job in a later step.
48
#[derive(Debug, Clone, PartialEq, Eq)]
49
pub enum PhpType {
50
    /// A single type, e.g. `int`, `string`, `Foo`.
51
    Simple(DataType),
52
    /// A union of primitive types, e.g. `int|string`.
53
    ///
54
    /// Including [`DataType::Null`] as a member produces a nullable union
55
    /// (`int|string|null`). The same shape can be expressed by combining a
56
    /// non-null `Union` with `Arg::allow_null`; both forms emit identical
57
    /// bits because `MAY_BE_NULL` and `_ZEND_TYPE_NULLABLE_BIT` share the
58
    /// same value (see `Zend/zend_types.h:148` in php-src). Pick whichever
59
    /// reads best at the call site.
60
    Union(Vec<DataType>),
61
    /// A union of class names, e.g. `Foo|Bar`. Each entry must be a valid
62
    /// PHP class name (no NUL bytes).
63
    ///
64
    /// A single-element vec is accepted but degenerate: prefer
65
    /// `Simple(DataType::Object(Some(name)))` for the single-class case.
66
    ///
67
    /// Mixing primitives and classes (e.g. `int|Foo`) is not expressible
68
    /// here; class-side DNF such as `(A&B)|C` lives in [`PhpType::Dnf`].
69
    ///
70
    /// Nullability flows through `Arg::allow_null`; PHP's `?Foo|Bar`
71
    /// shorthand is not legal syntax (the engine rejects `?` on a union),
72
    /// so the rendered stub spells nullables as `Foo|Bar|null`.
73
    ClassUnion(Vec<String>),
74
    /// An intersection of class/interface names, e.g. `Countable&Traversable`.
75
    /// A value satisfies the type only when it is an instance of every named
76
    /// class or interface. Each entry must be a valid PHP class name (no NUL
77
    /// bytes).
78
    ///
79
    /// A single-element vec is accepted but degenerate: prefer
80
    /// `Simple(DataType::Object(Some(name)))` for the single-class case.
81
    ///
82
    /// Pairing this variant with `Arg::allow_null` is rejected by the
83
    /// FFI emission layer. The legal nullable form is the DNF
84
    /// `(Foo&Bar)|null`; build a [`PhpType::Dnf`] for that case.
85
    Intersection(Vec<String>),
86
    /// Disjunctive Normal Form: a top-level union whose alternatives may
87
    /// themselves be intersection groups, e.g. `(A&B)|C`. PHP 8.2+.
88
    ///
89
    /// Examples:
90
    /// - `(A&B)|C` produces
91
    ///   `Dnf(vec![DnfTerm::Intersection(["A","B"]), DnfTerm::Single("C")])`.
92
    /// - `(A&B)|null` produces
93
    ///   `Dnf(vec![DnfTerm::Intersection(["A","B"])])` with
94
    ///   `Arg::allow_null` on the arg.
95
    ///
96
    /// Nullability is carried via `allow_null`, never as a stringly-typed
97
    /// `DnfTerm::Single("null")` term — the same canonicalisation rule the
98
    /// other compound variants follow. Mixing primitives with class terms
99
    /// (e.g. `(A&B)|int`) is intentionally not modelled here; if demand
100
    /// surfaces, [`DnfTerm`] can grow a third variant in a follow-up.
101
    ///
102
    /// Validation (see the FFI emission layer): empty `terms` is rejected;
103
    /// `terms.len() == 1` is degenerate (use [`PhpType::Simple`] or
104
    /// [`PhpType::Intersection`]); each
105
    /// [`DnfTerm::Intersection`] must carry 2 or more members.
106
    Dnf(Vec<DnfTerm>),
107
}
108

109
impl From<DataType> for PhpType {
110
    fn from(dt: DataType) -> Self {
73✔
111
        Self::Simple(dt)
73✔
112
    }
73✔
113
}
114

115
impl fmt::Display for PhpType {
116
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47✔
117
        match self {
47✔
118
            Self::Simple(dt) => write_php_primitive_or_class(*dt, f),
22✔
119
            Self::Union(members) => {
6✔
120
                let mut first = true;
6✔
121
                for dt in members {
15✔
122
                    if !first {
15✔
123
                        f.write_str("|")?;
9✔
124
                    }
6✔
125
                    write_php_primitive_or_class(*dt, f)?;
15✔
126
                    first = false;
15✔
127
                }
128
                Ok(())
6✔
129
            }
130
            Self::ClassUnion(names) => write_pipe_joined_classes(names, f),
9✔
131
            Self::Intersection(names) => write_amp_joined_classes(names, f),
4✔
132
            Self::Dnf(terms) => {
6✔
133
                let mut first = true;
6✔
134
                for term in terms {
13✔
135
                    if !first {
13✔
136
                        f.write_str("|")?;
7✔
137
                    }
6✔
138
                    fmt::Display::fmt(term, f)?;
13✔
139
                    first = false;
13✔
140
                }
141
                Ok(())
6✔
142
            }
143
        }
144
    }
47✔
145
}
146

147
impl fmt::Display for DnfTerm {
148
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13✔
149
        match self {
13✔
150
            Self::Single(name) => write_class_name(name, f),
7✔
151
            Self::Intersection(names) => {
6✔
152
                f.write_str("(")?;
6✔
153
                write_amp_joined_classes(names, f)?;
6✔
154
                f.write_str(")")
6✔
155
            }
156
        }
157
    }
13✔
158
}
159

160
fn write_php_primitive_or_class(dt: DataType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37✔
161
    match dt {
2✔
162
        DataType::Bool => f.write_str("bool"),
2✔
163
        DataType::True => f.write_str("true"),
1✔
164
        DataType::False => f.write_str("false"),
1✔
165
        DataType::Long => f.write_str("int"),
8✔
166
        DataType::Double => f.write_str("float"),
1✔
167
        DataType::String => f.write_str("string"),
7✔
168
        DataType::Array => f.write_str("array"),
1✔
NEW
169
        DataType::Object(Some(name)) => write_class_name(name, f),
×
170
        DataType::Object(None) => f.write_str("object"),
2✔
171
        DataType::Resource => f.write_str("resource"),
1✔
172
        DataType::Callable => f.write_str("callable"),
2✔
173
        DataType::Iterable => f.write_str("iterable"),
2✔
174
        DataType::Void => f.write_str("void"),
2✔
175
        DataType::Null => f.write_str("null"),
6✔
176
        // `Mixed` plus the variants without a syntactic PHP type form
177
        // (`Undef`, `Reference`, `ConstantExpression`, `Ptr`, `Indirect`)
178
        // all render as `mixed`, matching `datatype_to_phpdoc` in
179
        // `src/describe/stub.rs` of the runtime crate.
180
        DataType::Mixed
181
        | DataType::Undef
182
        | DataType::Reference
183
        | DataType::ConstantExpression
184
        | DataType::Ptr
185
        | DataType::Indirect => f.write_str("mixed"),
1✔
186
    }
187
}
37✔
188

189
fn write_class_name(name: &str, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42✔
190
    if name.starts_with('\\') {
42✔
191
        f.write_str(name)
1✔
192
    } else {
193
        f.write_str("\\")?;
41✔
194
        f.write_str(name)
41✔
195
    }
196
}
42✔
197

198
fn write_pipe_joined_classes(names: &[String], f: &mut fmt::Formatter<'_>) -> fmt::Result {
9✔
199
    let mut first = true;
9✔
200
    for name in names {
14✔
201
        if !first {
14✔
202
            f.write_str("|")?;
5✔
203
        }
9✔
204
        write_class_name(name, f)?;
14✔
205
        first = false;
14✔
206
    }
207
    Ok(())
9✔
208
}
9✔
209

210
fn write_amp_joined_classes(names: &[String], f: &mut fmt::Formatter<'_>) -> fmt::Result {
10✔
211
    let mut first = true;
10✔
212
    for name in names {
21✔
213
        if !first {
21✔
214
            f.write_str("&")?;
11✔
215
        }
10✔
216
        write_class_name(name, f)?;
21✔
217
        first = false;
21✔
218
    }
219
    Ok(())
10✔
220
}
10✔
221

222
const _: () = {
223
    assert!(core::mem::size_of::<PhpType>() <= 32);
224
};
225

226
/// Error produced by [`PhpType::from_str`].
227
///
228
/// The parser surfaces every failure mode that the runtime crate can check
229
/// without round-tripping through `zend_compile.c`. Variants carry byte
230
/// positions in the input where useful so callers (especially the
231
/// `#[php(types = "...")]` proc-macro) can underline the offending span.
232
#[derive(Debug, Clone, PartialEq, Eq)]
233
#[non_exhaustive]
234
pub enum PhpTypeParseError {
235
    /// Input was empty or whitespace-only.
236
    Empty,
237
    /// A `|`-separated alternative was empty (e.g. leading or trailing pipe,
238
    /// or two pipes in a row).
239
    EmptyTerm {
240
        /// Byte position of the empty alternative in the original input.
241
        pos: usize,
242
    },
243
    /// A `(` was opened without a matching `)`, or vice versa.
244
    UnbalancedParens {
245
        /// Byte position of the offending parenthesis.
246
        pos: usize,
247
    },
248
    /// An unexpected character was encountered (control byte, stray comma,
249
    /// nested `(`, etc.).
250
    UnexpectedChar {
251
        /// The offending character.
252
        ch: char,
253
        /// Byte position of the offending character.
254
        pos: usize,
255
    },
256
    /// A `(` appeared inside another `(` group: DNF only allows one level.
257
    NestedGroups {
258
        /// Byte position of the inner `(`.
259
        pos: usize,
260
    },
261
    /// A `|` appeared inside an intersection group: PHP rejects unions
262
    /// nested inside intersections (`A&(B|C)` is illegal).
263
    UnionInIntersection {
264
        /// Byte position of the offending `|`.
265
        pos: usize,
266
    },
267
    /// A bare `&` appeared outside a `( ... )` group at union level: PHP's
268
    /// grammar refuses `A&B|C` because `intersection_type` is not a
269
    /// `union_type_element` without parens.
270
    NakedAmpInUnion {
271
        /// Byte position of the offending `&`.
272
        pos: usize,
273
    },
274
    /// A `?` shorthand was applied to a compound type (`?int|string`,
275
    /// `?A&B`, `?(A&B)`). `?` is only legal on a single primitive or class.
276
    NullableCompound {
277
        /// Byte position of the offending `?`.
278
        pos: usize,
279
    },
280
    /// A `( ... )` group held fewer than two members. PHP requires at least
281
    /// `(A&B)` inside parens; `(A)` is a grammar error.
282
    IntersectionTooSmall {
283
        /// Byte position of the offending `(`.
284
        pos: usize,
285
    },
286
    /// A class name was empty or contained an interior NUL byte (the runtime
287
    /// would later turn that into `Error::InvalidCString`; the parser catches
288
    /// it earlier).
289
    InvalidClassName {
290
        /// The offending class name.
291
        name: String,
292
    },
293
    /// A keyword `static`, `never`, `self`, or `parent` appeared. ext-php-rs
294
    /// cannot register internal arg-info for these — they're context types
295
    /// the engine resolves at the call site.
296
    UnsupportedKeyword {
297
        /// The offending keyword.
298
        name: String,
299
    },
300
    /// The same primitive or class name appeared twice in a union or
301
    /// intersection. PHP rejects duplicates with
302
    /// "Duplicate type %s is redundant".
303
    DuplicateMember {
304
        /// The duplicated member, rendered in PHP syntax.
305
        name: String,
306
    },
307
    /// A union mixed primitive types with class names (`int|Foo`). The
308
    /// runtime [`PhpType`] variants do not model this mixing — see the
309
    /// note on [`PhpType::Dnf`].
310
    MixedPrimitiveAndClass,
311
    /// The input describes a class-side type combined with `null`
312
    /// (`?Foo`, `Foo|null`, `Foo|Bar|null`, `(A&B)|null`). The runtime
313
    /// [`PhpType`] does not carry nullability for class-side variants;
314
    /// callers should parse the non-null form and chain `Arg::allow_null`
315
    /// on the resulting `Arg`.
316
    ClassNullableNotRepresentable,
317
    /// A primitive name appeared inside an intersection. PHP rejects
318
    /// `int&string` and similar shapes at compile time.
319
    PrimitiveInIntersection {
320
        /// The offending primitive name.
321
        name: String,
322
    },
323
    /// A primitive name appeared inside a class-only context (multi-class
324
    /// union or DNF group). The variants `ClassUnion`/`Dnf` only carry
325
    /// class names; mixing primitives is rejected at construction.
326
    PrimitiveInClassUnion {
327
        /// The offending primitive name.
328
        name: String,
329
    },
330
}
331

332
impl fmt::Display for PhpTypeParseError {
333
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5✔
334
        match self {
5✔
335
            Self::Empty => write!(f, "empty type string"),
1✔
336
            Self::EmptyTerm { pos } => write!(f, "empty term at position {pos}"),
2✔
NEW
337
            Self::UnbalancedParens { pos } => {
×
NEW
338
                write!(f, "unbalanced parenthesis at position {pos}")
×
339
            }
NEW
340
            Self::UnexpectedChar { ch, pos } => {
×
NEW
341
                write!(f, "unexpected character {ch:?} at position {pos}")
×
342
            }
NEW
343
            Self::NestedGroups { pos } => {
×
NEW
344
                write!(f, "nested `(` groups not allowed at position {pos}")
×
345
            }
NEW
346
            Self::UnionInIntersection { pos } => write!(
×
NEW
347
                f,
×
348
                "union inside intersection at position {pos}: intersections cannot contain unions"
349
            ),
NEW
350
            Self::NakedAmpInUnion { pos } => write!(
×
NEW
351
                f,
×
352
                "bare `&` at union level (position {pos}): use parentheses, e.g. `(A&B)|C`"
353
            ),
NEW
354
            Self::NullableCompound { pos } => write!(
×
NEW
355
                f,
×
356
                "`?` shorthand at position {pos} can only apply to a single type"
357
            ),
NEW
358
            Self::IntersectionTooSmall { pos } => write!(
×
NEW
359
                f,
×
360
                "intersection group at position {pos} must contain at least two class names"
361
            ),
NEW
362
            Self::InvalidClassName { name } => {
×
NEW
363
                write!(f, "invalid class name {name:?} (empty or contains NUL)")
×
364
            }
NEW
365
            Self::UnsupportedKeyword { name } => write!(
×
NEW
366
                f,
×
367
                "keyword {name:?} is not supported in ext-php-rs argument and return types"
368
            ),
NEW
369
            Self::DuplicateMember { name } => write!(f, "duplicate type {name:?}"),
×
NEW
370
            Self::MixedPrimitiveAndClass => write!(
×
NEW
371
                f,
×
372
                "primitive types and class names cannot be mixed in a union"
373
            ),
374
            Self::ClassNullableNotRepresentable => write!(
2✔
375
                f,
2✔
376
                "class-side nullable type cannot be represented as a single PhpType; \
377
                 parse the non-null form and chain `Arg::allow_null()` on the resulting Arg"
378
            ),
NEW
379
            Self::PrimitiveInIntersection { name } => {
×
NEW
380
                write!(f, "primitive {name:?} cannot appear in an intersection")
×
381
            }
NEW
382
            Self::PrimitiveInClassUnion { name } => write!(
×
NEW
383
                f,
×
384
                "primitive {name:?} cannot appear in a class-only union or DNF term"
385
            ),
386
        }
387
    }
5✔
388
}
389

390
impl std::error::Error for PhpTypeParseError {}
391

392
impl FromStr for PhpType {
393
    type Err = PhpTypeParseError;
394

395
    fn from_str(s: &str) -> Result<Self, Self::Err> {
136✔
396
        parse(s)
136✔
397
    }
136✔
398
}
399

400
fn parse(s: &str) -> Result<PhpType, PhpTypeParseError> {
136✔
401
    let trimmed = s.trim();
136✔
402
    if trimmed.is_empty() {
136✔
403
        return Err(PhpTypeParseError::Empty);
3✔
404
    }
133✔
405

406
    validate_balanced_parens(s)?;
133✔
407

408
    let (nullable, body, body_offset) = strip_nullable_prefix(s, trimmed);
132✔
409

410
    if has_top_level_char(body, '|') {
132✔
411
        if nullable {
63✔
412
            return Err(PhpTypeParseError::NullableCompound { pos: 0 });
1✔
413
        }
62✔
414
        return parse_union(body, body_offset);
62✔
415
    }
69✔
416

417
    if has_top_level_char(body, '&') {
69✔
418
        if nullable {
15✔
419
            return Err(PhpTypeParseError::NullableCompound { pos: 0 });
1✔
420
        }
14✔
421
        return parse_bare_intersection(body, body_offset);
14✔
422
    }
54✔
423

424
    if body.starts_with('(') {
54✔
NEW
425
        return Err(PhpTypeParseError::IntersectionTooSmall { pos: body_offset });
×
426
    }
54✔
427

428
    let single = parse_atom(body)?;
54✔
429
    match single {
37✔
430
        Atom::Primitive(dt) if nullable => Ok(PhpType::Union(vec![dt, DataType::Null])),
2✔
431
        Atom::Primitive(dt) => Ok(PhpType::Simple(dt)),
35✔
432
        Atom::Class(_) if nullable => Err(PhpTypeParseError::ClassNullableNotRepresentable),
3✔
433
        Atom::Class(name) => Ok(PhpType::ClassUnion(vec![name])),
10✔
434
    }
435
}
136✔
436

437
fn validate_balanced_parens(s: &str) -> Result<(), PhpTypeParseError> {
133✔
438
    let mut depth: usize = 0;
133✔
439
    let mut last_open: Option<usize> = None;
133✔
440
    for (i, ch) in s.char_indices() {
1,230✔
441
        match ch {
1,230✔
442
            '(' => {
23✔
443
                depth += 1;
23✔
444
                last_open = Some(i);
23✔
445
            }
23✔
446
            ')' => {
447
                if depth == 0 {
22✔
NEW
448
                    return Err(PhpTypeParseError::UnbalancedParens { pos: i });
×
449
                }
22✔
450
                depth -= 1;
22✔
451
            }
452
            _ => {}
1,185✔
453
        }
454
    }
455
    if depth != 0 {
133✔
456
        return Err(PhpTypeParseError::UnbalancedParens {
1✔
457
            pos: last_open.unwrap_or(0),
1✔
458
        });
1✔
459
    }
132✔
460
    Ok(())
132✔
461
}
133✔
462

463
fn has_top_level_char(body: &str, target: char) -> bool {
222✔
464
    let mut depth = 0usize;
222✔
465
    for ch in body.chars() {
1,313✔
466
        match ch {
1,275✔
467
            '(' => depth += 1,
19✔
468
            ')' if depth > 0 => depth -= 1,
19✔
469
            c if c == target && depth == 0 => return true,
1,275✔
470
            _ => {}
1,197✔
471
        }
472
    }
473
    false
144✔
474
}
222✔
475

476
#[derive(Debug, Clone, PartialEq, Eq)]
477
enum Atom {
478
    Primitive(DataType),
479
    Class(String),
480
}
481

482
fn strip_nullable_prefix<'a>(original: &'a str, trimmed: &'a str) -> (bool, &'a str, usize) {
132✔
483
    let leading_ws = original.len() - original.trim_start().len();
132✔
484
    if let Some(rest) = trimmed.strip_prefix('?') {
132✔
485
        (true, rest.trim_start(), leading_ws + 1)
7✔
486
    } else {
487
        (false, trimmed, leading_ws)
125✔
488
    }
489
}
132✔
490

491
fn parse_atom(raw: &str) -> Result<Atom, PhpTypeParseError> {
231✔
492
    let trimmed = raw.trim();
231✔
493
    if trimmed.is_empty() {
231✔
NEW
494
        return Err(PhpTypeParseError::EmptyTerm { pos: 0 });
×
495
    }
231✔
496
    reject_structural_chars(trimmed)?;
231✔
497
    reject_unsupported_keyword(trimmed)?;
231✔
498
    if let Some(dt) = primitive_from_name(trimmed) {
227✔
499
        return Ok(Atom::Primitive(dt));
95✔
500
    }
132✔
501
    let class = normalise_class_name(trimmed)?;
132✔
502
    Ok(Atom::Class(class))
132✔
503
}
231✔
504

505
fn reject_structural_chars(name: &str) -> Result<(), PhpTypeParseError> {
231✔
506
    for (i, ch) in name.char_indices() {
1,009✔
507
        match ch {
1,009✔
508
            '(' | ')' | '|' | '&' | '?' | ' ' | '\t' | '\n' | '\r' => {
NEW
509
                return Err(PhpTypeParseError::UnexpectedChar { ch, pos: i });
×
510
            }
511
            _ => {}
1,009✔
512
        }
513
    }
514
    Ok(())
231✔
515
}
231✔
516

517
fn parse_union(body: &str, body_offset: usize) -> Result<PhpType, PhpTypeParseError> {
62✔
518
    let mut alts: Vec<(Alt, usize)> = Vec::new();
62✔
519
    for piece in split_top_level_pipes(body) {
133✔
520
        let span_start = body_offset + piece.start;
133✔
521
        let raw = &body[piece.start..piece.end];
133✔
522
        if raw.trim().is_empty() {
133✔
523
            return Err(PhpTypeParseError::EmptyTerm { pos: span_start });
5✔
524
        }
128✔
525
        alts.push((parse_alt(raw, span_start)?, span_start));
128✔
526
    }
527

528
    let has_group = alts.iter().any(|(a, _)| matches!(a, Alt::Group(_)));
101✔
529
    let has_class = alts
54✔
530
        .iter()
54✔
531
        .any(|(a, _)| matches!(a, Alt::Atom(Atom::Class(_)) | Alt::Group(_)));
67✔
532
    let has_null = alts
54✔
533
        .iter()
54✔
534
        .any(|(a, _)| matches!(a, Alt::Atom(Atom::Primitive(DataType::Null))));
54✔
535
    let has_non_null_primitive = alts.iter().any(|(a, _)| {
92✔
536
        matches!(
71✔
537
            a,
24✔
538
            Alt::Atom(Atom::Primitive(dt)) if !matches!(dt, DataType::Null)
24✔
539
        )
540
    });
92✔
541

542
    if has_class && has_null {
54✔
543
        return Err(PhpTypeParseError::ClassNullableNotRepresentable);
3✔
544
    }
51✔
545
    if has_class && has_non_null_primitive {
51✔
546
        return Err(PhpTypeParseError::MixedPrimitiveAndClass);
1✔
547
    }
50✔
548

549
    if has_group {
50✔
550
        let mut terms: Vec<DnfTerm> = Vec::with_capacity(alts.len());
18✔
551
        for (alt, _) in alts {
40✔
552
            terms.push(match alt {
40✔
553
                Alt::Group(names) => DnfTerm::Intersection(names),
18✔
554
                Alt::Atom(Atom::Class(name)) => DnfTerm::Single(name),
22✔
555
                Alt::Atom(Atom::Primitive(_)) => {
NEW
556
                    unreachable!("guarded above by has_class && has_*_primitive checks")
×
557
                }
558
            });
559
        }
560
        check_no_duplicate_in_dnf(&terms)?;
18✔
561
        return Ok(PhpType::Dnf(terms));
17✔
562
    }
32✔
563

564
    if !has_class {
32✔
565
        let members: Vec<DataType> = alts
20✔
566
            .into_iter()
20✔
567
            .map(|(alt, _)| match alt {
48✔
568
                Alt::Atom(Atom::Primitive(dt)) => dt,
48✔
NEW
569
                _ => unreachable!("class-free path"),
×
570
            })
48✔
571
            .collect();
20✔
572
        check_no_duplicate_data_types(&members)?;
20✔
573
        return Ok(PhpType::Union(members));
19✔
574
    }
12✔
575

576
    let names: Vec<String> = alts
12✔
577
        .into_iter()
12✔
578
        .map(|(alt, _)| match alt {
24✔
579
            Alt::Atom(Atom::Class(name)) => name,
24✔
NEW
580
            _ => unreachable!("primitive-free path"),
×
581
        })
24✔
582
        .collect();
12✔
583
    check_no_duplicate_strings(&names)?;
12✔
584
    Ok(PhpType::ClassUnion(names))
11✔
585
}
62✔
586

587
fn check_no_duplicate_data_types(members: &[DataType]) -> Result<(), PhpTypeParseError> {
20✔
588
    for (i, a) in members.iter().enumerate() {
48✔
589
        for b in &members[..i] {
48✔
590
            if a == b {
36✔
591
                return Err(PhpTypeParseError::DuplicateMember {
1✔
592
                    name: format!("{a}"),
1✔
593
                });
1✔
594
            }
35✔
595
        }
596
    }
597
    Ok(())
19✔
598
}
20✔
599

600
fn check_no_duplicate_strings(names: &[String]) -> Result<(), PhpTypeParseError> {
24✔
601
    for (i, a) in names.iter().enumerate() {
52✔
602
        for b in &names[..i] {
52✔
603
            if a == b {
31✔
604
                return Err(PhpTypeParseError::DuplicateMember { name: a.clone() });
2✔
605
            }
29✔
606
        }
607
    }
608
    Ok(())
22✔
609
}
24✔
610

611
fn check_no_duplicate_in_dnf(terms: &[DnfTerm]) -> Result<(), PhpTypeParseError> {
18✔
612
    for (i, a) in terms.iter().enumerate() {
40✔
613
        for b in &terms[..i] {
40✔
614
            if a == b {
26✔
615
                let name = match a {
1✔
616
                    DnfTerm::Single(s) => s.clone(),
1✔
NEW
617
                    DnfTerm::Intersection(parts) => format!("({})", parts.join("&")),
×
618
                };
619
                return Err(PhpTypeParseError::DuplicateMember { name });
1✔
620
            }
25✔
621
        }
622
    }
623
    Ok(())
17✔
624
}
18✔
625

626
#[derive(Debug, Clone, PartialEq, Eq)]
627
enum Alt {
628
    Atom(Atom),
629
    Group(Vec<String>),
630
}
631

632
fn parse_alt(raw: &str, span_start: usize) -> Result<Alt, PhpTypeParseError> {
128✔
633
    let trimmed = raw.trim();
128✔
634
    if trimmed.starts_with('(') {
128✔
635
        let leading = raw.len() - raw.trim_start().len();
21✔
636
        let group_start = span_start + leading;
21✔
637
        return parse_group(trimmed, group_start).map(Alt::Group);
21✔
638
    }
107✔
639
    if trimmed.contains('&') {
107✔
640
        let amp_pos = raw.find('&').map_or(span_start, |i| span_start + i);
1✔
641
        return Err(PhpTypeParseError::NakedAmpInUnion { pos: amp_pos });
1✔
642
    }
106✔
643
    parse_atom(trimmed).map(Alt::Atom)
106✔
644
}
128✔
645

646
fn parse_group(raw: &str, group_start: usize) -> Result<Vec<String>, PhpTypeParseError> {
21✔
647
    debug_assert!(raw.starts_with('('));
21✔
648
    let inner_end = match raw.rfind(')') {
21✔
649
        Some(i) if i > 0 => i,
21✔
650
        _ => {
NEW
651
            return Err(PhpTypeParseError::UnbalancedParens { pos: group_start });
×
652
        }
653
    };
654
    let after_close = raw[inner_end + 1..].trim();
21✔
655
    if !after_close.is_empty() {
21✔
NEW
656
        return Err(PhpTypeParseError::UnexpectedChar {
×
NEW
657
            ch: after_close.chars().next().unwrap_or(')'),
×
NEW
658
            pos: group_start + inner_end + 1,
×
NEW
659
        });
×
660
    }
21✔
661
    let inner = &raw[1..inner_end];
21✔
662
    let inner_offset = group_start + 1;
21✔
663
    if inner.contains('(') {
21✔
NEW
664
        return Err(PhpTypeParseError::NestedGroups {
×
NEW
665
            pos: inner_offset + inner.find('(').unwrap_or(0),
×
NEW
666
        });
×
667
    }
21✔
668
    if has_top_level_char(inner, '|') {
21✔
NEW
669
        let pipe_pos = inner.find('|').map_or(inner_offset, |i| inner_offset + i);
×
NEW
670
        return Err(PhpTypeParseError::UnionInIntersection { pos: pipe_pos });
×
671
    }
21✔
672

673
    let pieces = split_top_level_amps(inner);
21✔
674
    if pieces.len() < 2 {
21✔
675
        return Err(PhpTypeParseError::IntersectionTooSmall { pos: group_start });
1✔
676
    }
20✔
677
    let mut names: Vec<String> = Vec::with_capacity(pieces.len());
20✔
678
    for piece in pieces {
40✔
679
        let span_start = inner_offset + piece.start;
40✔
680
        let part = &inner[piece.start..piece.end];
40✔
681
        if part.trim().is_empty() {
40✔
NEW
682
            return Err(PhpTypeParseError::EmptyTerm { pos: span_start });
×
683
        }
40✔
684
        match parse_atom(part)? {
40✔
685
            Atom::Class(name) => names.push(name),
39✔
686
            Atom::Primitive(dt) => {
1✔
687
                return Err(PhpTypeParseError::PrimitiveInIntersection {
1✔
688
                    name: format!("{dt}"),
1✔
689
                });
1✔
690
            }
691
        }
692
    }
693
    Ok(names)
19✔
694
}
21✔
695

696
fn parse_bare_intersection(body: &str, body_offset: usize) -> Result<PhpType, PhpTypeParseError> {
14✔
697
    let mut names: Vec<String> = Vec::new();
14✔
698
    for piece in split_top_level_amps(body) {
32✔
699
        let span_start = body_offset + piece.start;
32✔
700
        let raw = &body[piece.start..piece.end];
32✔
701
        let trimmed = raw.trim();
32✔
702
        if trimmed.is_empty() {
32✔
NEW
703
            return Err(PhpTypeParseError::EmptyTerm { pos: span_start });
×
704
        }
32✔
705
        if trimmed.starts_with('(') {
32✔
706
            // `A&(...)` — intersections cannot contain a paren group at all.
707
            // The inner shape is a union (`A&(B|C)`) or another intersection
708
            // (`A&(B&C)`); both are illegal in PHP type hints.
709
            let leading_ws = raw.len() - raw.trim_start().len();
1✔
710
            return Err(PhpTypeParseError::UnionInIntersection {
1✔
711
                pos: span_start + leading_ws,
1✔
712
            });
1✔
713
        }
31✔
714
        match parse_atom(raw)? {
31✔
715
            Atom::Class(name) => names.push(name),
30✔
716
            Atom::Primitive(dt) => {
1✔
717
                return Err(PhpTypeParseError::PrimitiveInIntersection {
1✔
718
                    name: format!("{dt}"),
1✔
719
                });
1✔
720
            }
721
        }
722
    }
723
    check_no_duplicate_strings(&names)?;
12✔
724
    Ok(PhpType::Intersection(names))
11✔
725
}
14✔
726

727
fn split_top_level_amps(body: &str) -> Vec<Piece> {
35✔
728
    let mut pieces = Vec::new();
35✔
729
    let mut depth = 0usize;
35✔
730
    let mut start = 0usize;
35✔
731
    for (i, ch) in body.char_indices() {
308✔
732
        match ch {
1✔
733
            '(' => depth += 1,
1✔
734
            ')' if depth > 0 => depth -= 1,
1✔
735
            '&' if depth == 0 => {
38✔
736
                pieces.push(Piece { start, end: i });
38✔
737
                start = i + 1;
38✔
738
            }
38✔
739
            _ => {}
268✔
740
        }
741
    }
742
    pieces.push(Piece {
35✔
743
        start,
35✔
744
        end: body.len(),
35✔
745
    });
35✔
746
    pieces
35✔
747
}
35✔
748

749
#[derive(Debug, Clone, Copy)]
750
struct Piece {
751
    start: usize,
752
    end: usize,
753
}
754

755
fn split_top_level_pipes(body: &str) -> Vec<Piece> {
62✔
756
    let mut pieces = Vec::new();
62✔
757
    let mut depth = 0usize;
62✔
758
    let mut start = 0usize;
62✔
759
    for (i, ch) in body.char_indices() {
772✔
760
        match ch {
21✔
761
            '(' => depth += 1,
21✔
762
            ')' if depth > 0 => depth -= 1,
21✔
763
            '|' if depth == 0 => {
78✔
764
                pieces.push(Piece { start, end: i });
78✔
765
                start = i + 1;
78✔
766
            }
78✔
767
            _ => {}
652✔
768
        }
769
    }
770
    pieces.push(Piece {
62✔
771
        start,
62✔
772
        end: body.len(),
62✔
773
    });
62✔
774
    pieces
62✔
775
}
62✔
776

777
fn reject_unsupported_keyword(name: &str) -> Result<(), PhpTypeParseError> {
231✔
778
    let lowered = name.to_ascii_lowercase();
231✔
779
    match lowered.as_str() {
231✔
780
        "static" | "never" | "self" | "parent" => Err(PhpTypeParseError::UnsupportedKeyword {
231✔
781
            name: name.to_owned(),
4✔
782
        }),
4✔
783
        _ => Ok(()),
227✔
784
    }
785
}
231✔
786

787
fn normalise_class_name(raw: &str) -> Result<String, PhpTypeParseError> {
132✔
788
    let stripped = raw.strip_prefix('\\').unwrap_or(raw);
132✔
789
    if stripped.is_empty() || stripped.contains('\0') {
132✔
NEW
790
        return Err(PhpTypeParseError::InvalidClassName {
×
NEW
791
            name: raw.to_owned(),
×
NEW
792
        });
×
793
    }
132✔
794
    Ok(stripped.to_owned())
132✔
795
}
132✔
796

797
fn primitive_from_name(name: &str) -> Option<DataType> {
227✔
798
    let lowered = name.to_ascii_lowercase();
227✔
799
    Some(match lowered.as_str() {
227✔
800
        "int" => DataType::Long,
227✔
801
        "float" => DataType::Double,
189✔
802
        "bool" => DataType::Bool,
188✔
803
        "true" => DataType::True,
185✔
804
        "false" => DataType::False,
184✔
805
        "string" => DataType::String,
183✔
806
        "array" => DataType::Array,
162✔
807
        "object" => DataType::Object(None),
161✔
808
        "callable" => DataType::Callable,
158✔
809
        "iterable" => DataType::Iterable,
155✔
810
        "resource" => DataType::Resource,
152✔
811
        "mixed" => DataType::Mixed,
151✔
812
        "void" => DataType::Void,
150✔
813
        "null" => DataType::Null,
147✔
814
        _ => return None,
132✔
815
    })
816
}
227✔
817

818
#[cfg(feature = "proc-macro")]
819
impl quote::ToTokens for DnfTerm {
820
    fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
11✔
821
        use quote::quote;
822
        let stream = match self {
11✔
823
            Self::Single(name) => {
6✔
824
                let name_lit = name.as_str();
6✔
825
                quote!(::ext_php_rs::types::DnfTerm::Single(
6✔
826
                    ::std::string::String::from(#name_lit)
827
                ))
828
            }
829
            Self::Intersection(names) => {
5✔
830
                let literals = names.iter().map(String::as_str);
5✔
831
                quote!(::ext_php_rs::types::DnfTerm::Intersection(
5✔
832
                    ::std::vec![ #( ::std::string::String::from(#literals) ),* ]
833
                ))
834
            }
835
        };
836
        stream.to_tokens(tokens);
11✔
837
    }
11✔
838
}
839

840
#[cfg(feature = "proc-macro")]
841
impl quote::ToTokens for PhpType {
842
    fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
29✔
843
        use quote::quote;
844
        let stream = match self {
29✔
845
            Self::Simple(dt) => quote!(::ext_php_rs::types::PhpType::Simple(#dt)),
2✔
846
            Self::Union(members) => {
12✔
847
                quote!(::ext_php_rs::types::PhpType::Union(
12✔
848
                    ::std::vec![ #( #members ),* ]
849
                ))
850
            }
851
            Self::ClassUnion(names) => {
5✔
852
                let literals = names.iter().map(String::as_str);
5✔
853
                quote!(::ext_php_rs::types::PhpType::ClassUnion(
5✔
854
                    ::std::vec![ #( ::std::string::String::from(#literals) ),* ]
855
                ))
856
            }
857
            Self::Intersection(names) => {
5✔
858
                let literals = names.iter().map(String::as_str);
5✔
859
                quote!(::ext_php_rs::types::PhpType::Intersection(
5✔
860
                    ::std::vec![ #( ::std::string::String::from(#literals) ),* ]
861
                ))
862
            }
863
            Self::Dnf(terms) => {
5✔
864
                quote!(::ext_php_rs::types::PhpType::Dnf(
5✔
865
                    ::std::vec![ #( #terms ),* ]
866
                ))
867
            }
868
        };
869
        stream.to_tokens(tokens);
29✔
870
    }
29✔
871
}
872

873
#[cfg(all(test, feature = "proc-macro"))]
874
mod tokens_tests {
875
    use super::{DataType, DnfTerm, PhpType};
876
    use quote::quote;
877

878
    fn render<T: quote::ToTokens>(value: &T) -> String {
7✔
879
        quote!(#value).to_string()
7✔
880
    }
7✔
881

882
    #[test]
883
    fn simple_int_emits_runtime_path() {
1✔
884
        let ty: PhpType = "int".parse().unwrap();
1✔
885
        assert_eq!(
1✔
886
            render(&ty),
1✔
887
            quote!(::ext_php_rs::types::PhpType::Simple(
1✔
888
                ::ext_php_rs::flags::DataType::Long
889
            ))
890
            .to_string()
1✔
891
        );
892
    }
1✔
893

894
    #[test]
895
    fn primitive_union_emits_vec_of_data_types() {
1✔
896
        let ty: PhpType = "int|string|null".parse().unwrap();
1✔
897
        assert_eq!(
1✔
898
            render(&ty),
1✔
899
            quote!(::ext_php_rs::types::PhpType::Union(::std::vec![
1✔
900
                ::ext_php_rs::flags::DataType::Long,
901
                ::ext_php_rs::flags::DataType::String,
902
                ::ext_php_rs::flags::DataType::Null
903
            ]))
904
            .to_string()
1✔
905
        );
906
    }
1✔
907

908
    #[test]
909
    fn class_union_emits_owned_strings() {
1✔
910
        let ty: PhpType = "Foo|Bar".parse().unwrap();
1✔
911
        assert_eq!(
1✔
912
            render(&ty),
1✔
913
            quote!(::ext_php_rs::types::PhpType::ClassUnion(::std::vec![
1✔
914
                ::std::string::String::from("Foo"),
915
                ::std::string::String::from("Bar")
916
            ]))
917
            .to_string()
1✔
918
        );
919
    }
1✔
920

921
    #[test]
922
    fn intersection_emits_amp_joined_classes() {
1✔
923
        let ty: PhpType = "Countable&Traversable".parse().unwrap();
1✔
924
        assert_eq!(
1✔
925
            render(&ty),
1✔
926
            quote!(::ext_php_rs::types::PhpType::Intersection(::std::vec![
1✔
927
                ::std::string::String::from("Countable"),
928
                ::std::string::String::from("Traversable")
929
            ]))
930
            .to_string()
1✔
931
        );
932
    }
1✔
933

934
    #[test]
935
    fn dnf_emits_intersection_then_single() {
1✔
936
        let ty: PhpType = "(A&B)|C".parse().unwrap();
1✔
937
        assert_eq!(
1✔
938
            render(&ty),
1✔
939
            quote!(::ext_php_rs::types::PhpType::Dnf(::std::vec![
1✔
940
                ::ext_php_rs::types::DnfTerm::Intersection(::std::vec![
941
                    ::std::string::String::from("A"),
942
                    ::std::string::String::from("B")
943
                ]),
944
                ::ext_php_rs::types::DnfTerm::Single(::std::string::String::from("C"))
945
            ]))
946
            .to_string()
1✔
947
        );
948
    }
1✔
949

950
    #[test]
951
    fn dnf_term_single_round_trips() {
1✔
952
        let term = DnfTerm::Single("X".to_owned());
1✔
953
        assert_eq!(
1✔
954
            render(&term),
1✔
955
            quote!(::ext_php_rs::types::DnfTerm::Single(
1✔
956
                ::std::string::String::from("X")
957
            ))
958
            .to_string()
1✔
959
        );
960
    }
1✔
961

962
    #[test]
963
    fn data_type_token_path_lives_in_flags_module() {
1✔
964
        // Sanity check that DataType emission stays under flags::, even
965
        // when wrapped in PhpType::Simple.
966
        let ty = PhpType::Simple(DataType::Object(None));
1✔
967
        assert!(render(&ty).contains("flags :: DataType :: Object"));
1✔
968
    }
1✔
969
}
970

971
#[cfg(test)]
972
mod tests {
973
    use super::*;
974

975
    #[test]
976
    fn class_union_round_trips_through_clone_and_eq() {
1✔
977
        let foo_or_bar = PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]);
1✔
978
        assert_eq!(foo_or_bar.clone(), foo_or_bar);
1✔
979
    }
1✔
980

981
    #[test]
982
    fn class_union_is_distinct_from_primitive_union_and_simple() {
1✔
983
        let class = PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]);
1✔
984
        let primitive = PhpType::Union(vec![DataType::Long, DataType::String]);
1✔
985
        let simple = PhpType::Simple(DataType::String);
1✔
986

987
        assert_ne!(class, primitive);
1✔
988
        assert_ne!(class, simple);
1✔
989
    }
1✔
990

991
    #[test]
992
    fn intersection_round_trips_through_clone_and_eq() {
1✔
993
        let countable_and_traversable =
1✔
994
            PhpType::Intersection(vec!["Countable".to_owned(), "Traversable".to_owned()]);
1✔
995
        assert_eq!(countable_and_traversable.clone(), countable_and_traversable);
1✔
996
    }
1✔
997

998
    #[test]
999
    fn intersection_is_distinct_from_class_union_simple_and_primitive_union() {
1✔
1000
        let intersection = PhpType::Intersection(vec!["Foo".to_owned(), "Bar".to_owned()]);
1✔
1001
        let class_union = PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()]);
1✔
1002
        let primitive = PhpType::Union(vec![DataType::Long, DataType::String]);
1✔
1003
        let simple = PhpType::Simple(DataType::String);
1✔
1004

1005
        assert_ne!(intersection, class_union);
1✔
1006
        assert_ne!(intersection, primitive);
1✔
1007
        assert_ne!(intersection, simple);
1✔
1008
    }
1✔
1009

1010
    #[test]
1011
    fn dnf_round_trips_through_clone_and_eq() {
1✔
1012
        let dnf = PhpType::Dnf(vec![
1✔
1013
            DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]),
1✔
1014
            DnfTerm::Single("C".to_owned()),
1✔
1015
        ]);
1✔
1016
        assert_eq!(dnf.clone(), dnf);
1✔
1017
    }
1✔
1018

1019
    #[test]
1020
    fn dnf_is_distinct_from_intersection_class_union_and_simple() {
1✔
1021
        let dnf = PhpType::Dnf(vec![
1✔
1022
            DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]),
1✔
1023
            DnfTerm::Single("C".to_owned()),
1✔
1024
        ]);
1✔
1025
        let intersection = PhpType::Intersection(vec!["A".to_owned(), "B".to_owned()]);
1✔
1026
        let class_union = PhpType::ClassUnion(vec!["A".to_owned(), "C".to_owned()]);
1✔
1027
        let simple = PhpType::Simple(DataType::String);
1✔
1028

1029
        assert_ne!(dnf, intersection);
1✔
1030
        assert_ne!(dnf, class_union);
1✔
1031
        assert_ne!(dnf, simple);
1✔
1032
    }
1✔
1033

1034
    #[test]
1035
    fn dnf_term_round_trips_through_clone_and_eq() {
1✔
1036
        let single = DnfTerm::Single("Foo".to_owned());
1✔
1037
        let group = DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]);
1✔
1038
        assert_eq!(single.clone(), single);
1✔
1039
        assert_eq!(group.clone(), group);
1✔
1040
        assert_ne!(single, group);
1✔
1041
    }
1✔
1042

1043
    #[test]
1044
    fn parses_int_primitive() {
1✔
1045
        let ty: PhpType = "int".parse().expect("int parses");
1✔
1046
        assert_eq!(ty, PhpType::Simple(DataType::Long));
1✔
1047
    }
1✔
1048

1049
    #[test]
1050
    fn parses_every_primitive_name() {
1✔
1051
        let cases: &[(&str, DataType)] = &[
1✔
1052
            ("int", DataType::Long),
1✔
1053
            ("float", DataType::Double),
1✔
1054
            ("bool", DataType::Bool),
1✔
1055
            ("true", DataType::True),
1✔
1056
            ("false", DataType::False),
1✔
1057
            ("string", DataType::String),
1✔
1058
            ("array", DataType::Array),
1✔
1059
            ("object", DataType::Object(None)),
1✔
1060
            ("callable", DataType::Callable),
1✔
1061
            ("iterable", DataType::Iterable),
1✔
1062
            ("resource", DataType::Resource),
1✔
1063
            ("mixed", DataType::Mixed),
1✔
1064
            ("void", DataType::Void),
1✔
1065
            ("null", DataType::Null),
1✔
1066
        ];
1✔
1067
        for &(name, expected) in cases {
14✔
1068
            let parsed: PhpType = name.parse().unwrap_or_else(|e| panic!("{name} → {e}"));
14✔
1069
            assert_eq!(parsed, PhpType::Simple(expected), "name = {name}");
14✔
1070
        }
1071
    }
1✔
1072

1073
    #[test]
1074
    fn primitives_are_case_insensitive() {
1✔
1075
        for input in ["INT", "Int", "iNt"] {
3✔
1076
            let parsed: PhpType = input.parse().expect("case insensitive");
3✔
1077
            assert_eq!(parsed, PhpType::Simple(DataType::Long), "input = {input}");
3✔
1078
        }
1079
    }
1✔
1080

1081
    #[test]
1082
    fn parses_single_class_into_class_union() {
1✔
1083
        let parsed: PhpType = "Foo".parse().expect("class parses");
1✔
1084
        assert_eq!(parsed, PhpType::ClassUnion(vec!["Foo".to_owned()]));
1✔
1085
    }
1✔
1086

1087
    #[test]
1088
    fn strips_leading_backslash_from_class_name() {
1✔
1089
        let parsed: PhpType = "\\Foo".parse().expect("\\Foo parses");
1✔
1090
        assert_eq!(parsed, PhpType::ClassUnion(vec!["Foo".to_owned()]));
1✔
1091
    }
1✔
1092

1093
    #[test]
1094
    fn preserves_namespace_separators() {
1✔
1095
        let parsed: PhpType = "\\Ns\\Foo".parse().expect("namespaced class parses");
1✔
1096
        assert_eq!(parsed, PhpType::ClassUnion(vec!["Ns\\Foo".to_owned()]));
1✔
1097
    }
1✔
1098

1099
    #[test]
1100
    fn class_names_keep_their_case() {
1✔
1101
        let parsed: PhpType = "FooBar".parse().expect("CamelCase preserved");
1✔
1102
        assert_eq!(parsed, PhpType::ClassUnion(vec!["FooBar".to_owned()]));
1✔
1103
    }
1✔
1104

1105
    #[test]
1106
    fn parses_primitive_union() {
1✔
1107
        let parsed: PhpType = "int|string".parse().expect("union parses");
1✔
1108
        assert_eq!(
1✔
1109
            parsed,
1110
            PhpType::Union(vec![DataType::Long, DataType::String])
1✔
1111
        );
1112
    }
1✔
1113

1114
    #[test]
1115
    fn parses_primitive_union_with_inline_null() {
1✔
1116
        let parsed: PhpType = "int|string|null".parse().expect("nullable union parses");
1✔
1117
        assert_eq!(
1✔
1118
            parsed,
1119
            PhpType::Union(vec![DataType::Long, DataType::String, DataType::Null])
1✔
1120
        );
1121
    }
1✔
1122

1123
    #[test]
1124
    fn nullable_shorthand_canonicalises_to_union_for_primitives() {
1✔
1125
        let parsed: PhpType = "?int".parse().expect("?int parses");
1✔
1126
        assert_eq!(parsed, PhpType::Union(vec![DataType::Long, DataType::Null]));
1✔
1127
    }
1✔
1128

1129
    #[test]
1130
    fn whitespace_around_pipes_is_tolerated() {
1✔
1131
        let parsed: PhpType = "int | string".parse().expect("whitespace tolerated");
1✔
1132
        assert_eq!(
1✔
1133
            parsed,
1134
            PhpType::Union(vec![DataType::Long, DataType::String])
1✔
1135
        );
1136
    }
1✔
1137

1138
    #[test]
1139
    fn parses_class_union() {
1✔
1140
        let parsed: PhpType = "Foo|Bar".parse().expect("class union parses");
1✔
1141
        assert_eq!(
1✔
1142
            parsed,
1143
            PhpType::ClassUnion(vec!["Foo".to_owned(), "Bar".to_owned()])
1✔
1144
        );
1145
    }
1✔
1146

1147
    #[test]
1148
    fn class_union_strips_backslashes_per_member() {
1✔
1149
        let parsed: PhpType = "\\Foo|\\Ns\\Bar".parse().expect("class union normalises");
1✔
1150
        assert_eq!(
1✔
1151
            parsed,
1152
            PhpType::ClassUnion(vec!["Foo".to_owned(), "Ns\\Bar".to_owned()])
1✔
1153
        );
1154
    }
1✔
1155

1156
    #[test]
1157
    fn parses_bare_intersection() {
1✔
1158
        let parsed: PhpType = "Foo&Bar".parse().expect("intersection parses");
1✔
1159
        assert_eq!(
1✔
1160
            parsed,
1161
            PhpType::Intersection(vec!["Foo".to_owned(), "Bar".to_owned()])
1✔
1162
        );
1163
    }
1✔
1164

1165
    #[test]
1166
    fn parses_three_way_bare_intersection() {
1✔
1167
        let parsed: PhpType = "A&B&C".parse().expect("3-way intersection parses");
1✔
1168
        assert_eq!(
1✔
1169
            parsed,
1170
            PhpType::Intersection(vec!["A".to_owned(), "B".to_owned(), "C".to_owned()])
1✔
1171
        );
1172
    }
1✔
1173

1174
    #[test]
1175
    fn parses_dnf_group_then_single() {
1✔
1176
        let parsed: PhpType = "(A&B)|C".parse().expect("(A&B)|C parses");
1✔
1177
        assert_eq!(
1✔
1178
            parsed,
1179
            PhpType::Dnf(vec![
1✔
1180
                DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]),
1✔
1181
                DnfTerm::Single("C".to_owned()),
1✔
1182
            ])
1✔
1183
        );
1184
    }
1✔
1185

1186
    #[test]
1187
    fn parses_dnf_single_then_group() {
1✔
1188
        let parsed: PhpType = "C|(A&B)".parse().expect("C|(A&B) parses");
1✔
1189
        assert_eq!(
1✔
1190
            parsed,
1191
            PhpType::Dnf(vec![
1✔
1192
                DnfTerm::Single("C".to_owned()),
1✔
1193
                DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]),
1✔
1194
            ])
1✔
1195
        );
1196
    }
1✔
1197

1198
    #[test]
1199
    fn parses_dnf_group_then_two_singles() {
1✔
1200
        let parsed: PhpType = "(A&B)|C|D".parse().expect("(A&B)|C|D parses");
1✔
1201
        assert_eq!(
1✔
1202
            parsed,
1203
            PhpType::Dnf(vec![
1✔
1204
                DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]),
1✔
1205
                DnfTerm::Single("C".to_owned()),
1✔
1206
                DnfTerm::Single("D".to_owned()),
1✔
1207
            ])
1✔
1208
        );
1209
    }
1✔
1210

1211
    #[test]
1212
    fn parses_dnf_group_strips_backslashes() {
1✔
1213
        let parsed: PhpType = "(\\A&\\B)|\\C".parse().expect("(\\A&\\B)|\\C parses");
1✔
1214
        assert_eq!(
1✔
1215
            parsed,
1216
            PhpType::Dnf(vec![
1✔
1217
                DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]),
1✔
1218
                DnfTerm::Single("C".to_owned()),
1✔
1219
            ])
1✔
1220
        );
1221
    }
1✔
1222

1223
    fn err(input: &str) -> PhpTypeParseError {
26✔
1224
        input.parse::<PhpType>().expect_err(input)
26✔
1225
    }
26✔
1226

1227
    #[test]
1228
    fn rejects_empty_input() {
1✔
1229
        assert_eq!(err(""), PhpTypeParseError::Empty);
1✔
1230
        assert_eq!(err("   "), PhpTypeParseError::Empty);
1✔
1231
    }
1✔
1232

1233
    #[test]
1234
    fn rejects_leading_pipe() {
1✔
1235
        assert!(matches!(err("|int"), PhpTypeParseError::EmptyTerm { .. }));
1✔
1236
    }
1✔
1237

1238
    #[test]
1239
    fn rejects_trailing_pipe() {
1✔
1240
        assert!(matches!(err("int|"), PhpTypeParseError::EmptyTerm { .. }));
1✔
1241
    }
1✔
1242

1243
    #[test]
1244
    fn rejects_double_pipe() {
1✔
1245
        assert!(matches!(
1✔
1246
            err("int||string"),
1✔
1247
            PhpTypeParseError::EmptyTerm { .. }
1248
        ));
1249
    }
1✔
1250

1251
    #[test]
1252
    fn rejects_unbalanced_paren() {
1✔
1253
        assert!(matches!(
1✔
1254
            err("(A&B|C"),
1✔
1255
            PhpTypeParseError::UnbalancedParens { .. }
1256
        ));
1257
    }
1✔
1258

1259
    #[test]
1260
    fn rejects_union_inside_intersection() {
1✔
1261
        assert!(matches!(
1✔
1262
            err("A&(B|C)"),
1✔
1263
            PhpTypeParseError::NakedAmpInUnion { .. }
1264
                | PhpTypeParseError::UnionInIntersection { .. }
1265
        ));
1266
    }
1✔
1267

1268
    #[test]
1269
    fn rejects_naked_amp_in_union() {
1✔
1270
        assert!(matches!(
1✔
1271
            err("A&B|C"),
1✔
1272
            PhpTypeParseError::NakedAmpInUnion { .. }
1273
        ));
1274
    }
1✔
1275

1276
    #[test]
1277
    fn rejects_nullable_compound_union() {
1✔
1278
        assert!(matches!(
1✔
1279
            err("?int|string"),
1✔
1280
            PhpTypeParseError::NullableCompound { .. }
1281
        ));
1282
    }
1✔
1283

1284
    #[test]
1285
    fn rejects_nullable_compound_intersection() {
1✔
1286
        assert!(matches!(
1✔
1287
            err("?A&B"),
1✔
1288
            PhpTypeParseError::NullableCompound { .. }
1289
        ));
1290
    }
1✔
1291

1292
    #[test]
1293
    fn rejects_unsupported_keywords() {
1✔
1294
        for kw in ["static", "never", "self", "parent"] {
4✔
1295
            assert!(
4✔
1296
                matches!(err(kw), PhpTypeParseError::UnsupportedKeyword { .. }),
4✔
1297
                "{kw} should be rejected"
1298
            );
1299
        }
1300
    }
1✔
1301

1302
    #[test]
1303
    fn rejects_class_nullable_simple() {
1✔
1304
        assert_eq!(
1✔
1305
            err("?Foo"),
1✔
1306
            PhpTypeParseError::ClassNullableNotRepresentable
1307
        );
1308
    }
1✔
1309

1310
    #[test]
1311
    fn rejects_class_nullable_pipe_null() {
1✔
1312
        assert_eq!(
1✔
1313
            err("Foo|null"),
1✔
1314
            PhpTypeParseError::ClassNullableNotRepresentable
1315
        );
1316
    }
1✔
1317

1318
    #[test]
1319
    fn rejects_class_union_with_null_member() {
1✔
1320
        assert_eq!(
1✔
1321
            err("Foo|Bar|null"),
1✔
1322
            PhpTypeParseError::ClassNullableNotRepresentable
1323
        );
1324
    }
1✔
1325

1326
    #[test]
1327
    fn rejects_dnf_with_null_member() {
1✔
1328
        assert_eq!(
1✔
1329
            err("(A&B)|null"),
1✔
1330
            PhpTypeParseError::ClassNullableNotRepresentable
1331
        );
1332
    }
1✔
1333

1334
    #[test]
1335
    fn rejects_mixed_primitive_and_class() {
1✔
1336
        assert_eq!(err("int|Foo"), PhpTypeParseError::MixedPrimitiveAndClass);
1✔
1337
    }
1✔
1338

1339
    #[test]
1340
    fn rejects_single_element_paren_group() {
1✔
1341
        assert!(matches!(
1✔
1342
            err("(A)|B"),
1✔
1343
            PhpTypeParseError::IntersectionTooSmall { .. }
1344
        ));
1345
    }
1✔
1346

1347
    #[test]
1348
    fn rejects_primitive_in_intersection() {
1✔
1349
        assert!(matches!(
1✔
1350
            err("A&int"),
1✔
1351
            PhpTypeParseError::PrimitiveInIntersection { .. }
1352
        ));
1353
        assert!(matches!(
1✔
1354
            err("(A&int)|C"),
1✔
1355
            PhpTypeParseError::PrimitiveInIntersection { .. }
1356
        ));
1357
    }
1✔
1358

1359
    #[test]
1360
    fn rejects_duplicate_in_union() {
1✔
1361
        assert!(matches!(
1✔
1362
            err("int|int"),
1✔
1363
            PhpTypeParseError::DuplicateMember { .. }
1364
        ));
1365
    }
1✔
1366

1367
    #[test]
1368
    fn rejects_duplicate_in_class_union() {
1✔
1369
        assert!(matches!(
1✔
1370
            err("Foo|Foo"),
1✔
1371
            PhpTypeParseError::DuplicateMember { .. }
1372
        ));
1373
    }
1✔
1374

1375
    #[test]
1376
    fn rejects_duplicate_in_intersection() {
1✔
1377
        assert!(matches!(
1✔
1378
            err("A&B&A"),
1✔
1379
            PhpTypeParseError::DuplicateMember { .. }
1380
        ));
1381
    }
1✔
1382

1383
    #[test]
1384
    fn rejects_duplicate_in_dnf() {
1✔
1385
        assert!(matches!(
1✔
1386
            err("(A&B)|C|C"),
1✔
1387
            PhpTypeParseError::DuplicateMember { .. }
1388
        ));
1389
    }
1✔
1390

1391
    #[test]
1392
    fn display_simple_primitives_match_php_names() {
1✔
1393
        let cases: &[(DataType, &str)] = &[
1✔
1394
            (DataType::Long, "int"),
1✔
1395
            (DataType::Double, "float"),
1✔
1396
            (DataType::Bool, "bool"),
1✔
1397
            (DataType::True, "true"),
1✔
1398
            (DataType::False, "false"),
1✔
1399
            (DataType::String, "string"),
1✔
1400
            (DataType::Array, "array"),
1✔
1401
            (DataType::Object(None), "object"),
1✔
1402
            (DataType::Callable, "callable"),
1✔
1403
            (DataType::Iterable, "iterable"),
1✔
1404
            (DataType::Resource, "resource"),
1✔
1405
            (DataType::Mixed, "mixed"),
1✔
1406
            (DataType::Void, "void"),
1✔
1407
            (DataType::Null, "null"),
1✔
1408
        ];
1✔
1409
        for &(dt, expected) in cases {
14✔
1410
            let s = format!("{}", PhpType::Simple(dt));
14✔
1411
            assert_eq!(s, expected, "DataType::{dt:?}");
14✔
1412
        }
1413
    }
1✔
1414

1415
    #[test]
1416
    fn display_class_union_adds_leading_backslash() {
1✔
1417
        let ty = PhpType::ClassUnion(vec!["Foo".to_owned(), "Ns\\Bar".to_owned()]);
1✔
1418
        assert_eq!(format!("{ty}"), "\\Foo|\\Ns\\Bar");
1✔
1419
    }
1✔
1420

1421
    #[test]
1422
    fn display_intersection_renders_amp_separated() {
1✔
1423
        let ty = PhpType::Intersection(vec!["A".to_owned(), "B".to_owned()]);
1✔
1424
        assert_eq!(format!("{ty}"), "\\A&\\B");
1✔
1425
    }
1✔
1426

1427
    #[test]
1428
    fn display_dnf_wraps_intersection_groups_in_parens() {
1✔
1429
        let ty = PhpType::Dnf(vec![
1✔
1430
            DnfTerm::Intersection(vec!["A".to_owned(), "B".to_owned()]),
1✔
1431
            DnfTerm::Single("C".to_owned()),
1✔
1432
        ]);
1✔
1433
        assert_eq!(format!("{ty}"), "(\\A&\\B)|\\C");
1✔
1434
    }
1✔
1435

1436
    #[test]
1437
    fn display_union_pipe_separated_with_inline_null() {
1✔
1438
        let ty = PhpType::Union(vec![DataType::Long, DataType::String, DataType::Null]);
1✔
1439
        assert_eq!(format!("{ty}"), "int|string|null");
1✔
1440
    }
1✔
1441

1442
    #[test]
1443
    fn display_already_qualified_class_does_not_double_backslash() {
1✔
1444
        let ty = PhpType::ClassUnion(vec!["\\AlreadyQualified".to_owned()]);
1✔
1445
        assert_eq!(format!("{ty}"), "\\AlreadyQualified");
1✔
1446
    }
1✔
1447

1448
    #[test]
1449
    fn roundtrip_happy_path_corpus() {
1✔
1450
        let inputs = [
1✔
1451
            "int",
1✔
1452
            "string",
1✔
1453
            "bool",
1✔
1454
            "void",
1✔
1455
            "null",
1✔
1456
            "object",
1✔
1457
            "iterable",
1✔
1458
            "callable",
1✔
1459
            "Foo",
1✔
1460
            "\\Foo",
1✔
1461
            "\\Ns\\Foo",
1✔
1462
            "int|string",
1✔
1463
            "int|string|null",
1✔
1464
            "?int",
1✔
1465
            "Foo|Bar",
1✔
1466
            "\\Foo|\\Bar",
1✔
1467
            "Foo&Bar",
1✔
1468
            "A&B&C",
1✔
1469
            "(A&B)|C",
1✔
1470
            "C|(A&B)",
1✔
1471
            "(A&B)|C|D",
1✔
1472
            "(\\A&\\B)|\\C",
1✔
1473
            "int | string",
1✔
1474
        ];
1✔
1475
        for input in inputs {
23✔
1476
            let parsed: PhpType = input.parse().unwrap_or_else(|e| panic!("{input} → {e}"));
23✔
1477
            let rendered = format!("{parsed}");
23✔
1478
            let reparsed: PhpType = rendered
23✔
1479
                .parse()
23✔
1480
                .unwrap_or_else(|e| panic!("reparse {rendered} → {e}"));
23✔
1481
            assert_eq!(
23✔
1482
                parsed, reparsed,
1483
                "input {input:?} rendered as {rendered:?} did not roundtrip"
1484
            );
1485
        }
1486
    }
1✔
1487
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc