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

extphprs / ext-php-rs / 25379640517

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

Pull #734

github

ptondereau
ci(build): skip cargo test on macOS until libphp is available

shivammathur/setup-php's Homebrew formulas (NTS php@x.y and
-debug-zts) do not ship a libphp shared library at
<php-config --prefix>/lib. With macos-latest now running macos-15
(ld-prime + chained fixups by default), the test binary aborts at
image load with "symbol not found in flat namespace" before any
test runs, since undefined PHP runtime data symbols can no longer
be deferred to runtime via -Wl,-undefined,dynamic_lookup.

The previous carve-out only excluded macOS + Rust nightly. Widen
it to all macOS variants. Build coverage on macOS still runs in
the step above. Restore the test step for macOS once a libphp is
wired into the runner (Homebrew --enable-embed build, or a custom
install step).
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