Skip to main content

mir_types/
union.rs

1use rustc_hash::FxHashMap;
2use serde::{Deserialize, Serialize};
3use smallvec::SmallVec;
4use std::sync::{Arc, OnceLock};
5
6use crate::atomic::Atomic;
7use crate::symbol::Name;
8
9/// Returns a cached empty `Arc<[Type]>` for `type_params` / `parts` fields.
10/// Re-uses a single Arc allocation so all empty parameter lists share one
11/// control block instead of allocating one per TNamedObject construction.
12pub fn empty_type_params() -> Arc<[Type]> {
13    static EMPTY: OnceLock<Arc<[Type]>> = OnceLock::new();
14    EMPTY.get_or_init(|| Arc::from([] as [Type; 0])).clone()
15}
16
17/// Convert a `Vec<Type>` to `Arc<[Type]>`, using the cached empty Arc when
18/// the vec is empty to avoid an allocation for the common no-generic case.
19pub fn vec_to_type_params(v: Vec<Type>) -> Arc<[Type]> {
20    if v.is_empty() {
21        empty_type_params()
22    } else {
23        Arc::from(v)
24    }
25}
26
27// Most unions contain 1-2 atomics (e.g. `string|null`), so we inline two.
28pub type AtomicVec = SmallVec<[Atomic; 2]>;
29
30/// Result of classifying a type for `clone` validity (see [`Type::clone_validity`]).
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum CloneValidity {
33    /// Every member is (or may be) an object — cloning is fine.
34    Cloneable,
35    /// Every member is definitely a non-object — cloning is an error.
36    Invalid,
37    /// Some members are non-objects, some are objects — cloning may be an error.
38    PossiblyInvalid,
39    /// Empty/unknown type — no diagnostic.
40    Unknown,
41}
42
43// ---------------------------------------------------------------------------
44// Type — the primary type carrier
45// ---------------------------------------------------------------------------
46
47#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
48pub struct Type {
49    pub types: AtomicVec,
50    /// The variable holding this type may not be initialized at this point.
51    pub possibly_undefined: bool,
52    /// This type originated from a docblock annotation rather than inference.
53    pub from_docblock: bool,
54}
55
56impl Type {
57    // --- Constructors -------------------------------------------------------
58
59    pub fn empty() -> Self {
60        Self {
61            types: SmallVec::new(),
62            possibly_undefined: false,
63            from_docblock: false,
64        }
65    }
66
67    pub fn single(atomic: Atomic) -> Self {
68        let mut types = SmallVec::new();
69        types.push(atomic);
70        Self {
71            types,
72            possibly_undefined: false,
73            from_docblock: false,
74        }
75    }
76
77    pub fn mixed() -> Self {
78        Self::single(Atomic::TMixed)
79    }
80
81    pub fn void() -> Self {
82        Self::single(Atomic::TVoid)
83    }
84
85    pub fn never() -> Self {
86        Self::single(Atomic::TNever)
87    }
88
89    pub fn null() -> Self {
90        Self::single(Atomic::TNull)
91    }
92
93    pub fn bool() -> Self {
94        Self::single(Atomic::TBool)
95    }
96
97    pub fn int() -> Self {
98        Self::single(Atomic::TInt)
99    }
100
101    pub fn float() -> Self {
102        Self::single(Atomic::TFloat)
103    }
104
105    pub fn string() -> Self {
106        Self::single(Atomic::TString)
107    }
108
109    /// `T|null`
110    pub fn nullable(atomic: Atomic) -> Self {
111        // `mixed|null` = `mixed` — null is already included in mixed.
112        if matches!(atomic, Atomic::TMixed) {
113            return Self::mixed();
114        }
115        let mut types = SmallVec::new();
116        types.push(atomic);
117        types.push(Atomic::TNull);
118        Self {
119            types,
120            possibly_undefined: false,
121            from_docblock: false,
122        }
123    }
124
125    /// Build a union from multiple atomics, de-duplicating on the fly.
126    pub fn from_vec(atomics: Vec<Atomic>) -> Self {
127        let mut u = Self::empty();
128        for a in atomics {
129            u.add_type(a);
130        }
131        u
132    }
133
134    // --- Introspection -------------------------------------------------------
135
136    pub fn is_empty(&self) -> bool {
137        self.types.is_empty()
138    }
139
140    pub fn is_single(&self) -> bool {
141        self.types.len() == 1
142    }
143
144    pub fn is_nullable(&self) -> bool {
145        self.types.iter().any(|t| matches!(t, Atomic::TNull))
146    }
147
148    pub fn is_mixed(&self) -> bool {
149        self.types.iter().any(|t| match t {
150            Atomic::TMixed => true,
151            Atomic::TTemplateParam { as_type, .. } => as_type.is_mixed(),
152            _ => false,
153        })
154    }
155
156    /// True only when the type contains `TMixed` atoms and no `TTemplateParam` atoms.
157    /// Unlike [`is_mixed`], this does not treat an unconstrained template parameter as
158    /// "mixed" — a `T` placeholder is an intentionally parameterised type that will be
159    /// instantiated at the call site, so it must not trigger `MixedAssignment` warnings.
160    pub fn is_mixed_not_template(&self) -> bool {
161        self.is_mixed()
162            && !self
163                .types
164                .iter()
165                .any(|t| matches!(t, Atomic::TTemplateParam { .. }))
166    }
167
168    pub fn is_never(&self) -> bool {
169        self.types.iter().all(|t| matches!(t, Atomic::TNever)) && !self.types.is_empty()
170    }
171
172    /// Classify this type for `clone` validity. Recurses into template-param
173    /// bounds (like [`Type::is_mixed`]). Callers handle `mixed` separately.
174    pub fn clone_validity(&self) -> CloneValidity {
175        if self.types.is_empty() {
176            return CloneValidity::Unknown;
177        }
178        let mut has_non_object = false;
179        let mut has_other = false; // object or ambiguous (callable, mixed, conditional, …)
180        for t in &self.types {
181            match t {
182                Atomic::TTemplateParam { as_type, .. } => match as_type.clone_validity() {
183                    CloneValidity::Invalid => has_non_object = true,
184                    CloneValidity::PossiblyInvalid => {
185                        has_non_object = true;
186                        has_other = true;
187                    }
188                    CloneValidity::Cloneable | CloneValidity::Unknown => has_other = true,
189                },
190                other if other.is_definitely_non_object() => has_non_object = true,
191                _ => has_other = true,
192            }
193        }
194        match (has_non_object, has_other) {
195            (true, false) => CloneValidity::Invalid,
196            (true, true) => CloneValidity::PossiblyInvalid,
197            _ => CloneValidity::Cloneable,
198        }
199    }
200
201    pub fn is_void(&self) -> bool {
202        self.is_single() && matches!(self.types[0], Atomic::TVoid)
203    }
204
205    pub fn can_be_falsy(&self) -> bool {
206        self.types.iter().any(|t| t.can_be_falsy())
207    }
208
209    pub fn can_be_truthy(&self) -> bool {
210        self.types.iter().any(|t| t.can_be_truthy())
211    }
212
213    pub fn contains<F: Fn(&Atomic) -> bool>(&self, f: F) -> bool {
214        self.types.iter().any(f)
215    }
216
217    pub fn has_named_object(&self, fqcn: &str) -> bool {
218        self.types.iter().any(|t| match t {
219            Atomic::TNamedObject { fqcn: f, .. } => f.as_ref() == fqcn,
220            _ => false,
221        })
222    }
223
224    // --- Mutation ------------------------------------------------------------
225
226    /// Add an atomic to this union, skipping duplicates.
227    /// Subsumption rules: anything ⊆ TMixed; TLiteralInt ⊆ TInt; etc.
228    pub fn add_type(&mut self, atomic: Atomic) {
229        // If we already have TMixed, nothing to add.
230        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
231            return;
232        }
233
234        // Adding TMixed subsumes everything.
235        if matches!(atomic, Atomic::TMixed) {
236            self.types.clear();
237            self.types.push(Atomic::TMixed);
238            return;
239        }
240
241        // Simplify trivial conditional types: (X is ? T : T) → T
242        // Recursively simplify branches first so nested trivial conditionals collapse.
243        let atomic = if let Atomic::TConditional {
244            param_name: _,
245            subject: _,
246            if_true,
247            if_false,
248        } = &atomic
249        {
250            let mut simplified_true = Type::empty();
251            for t in &if_true.types {
252                simplified_true.add_type(t.clone());
253            }
254            let mut simplified_false = Type::empty();
255            for t in &if_false.types {
256                simplified_false.add_type(t.clone());
257            }
258            if simplified_true == simplified_false {
259                for t in simplified_true.types {
260                    self.add_type(t);
261                }
262                return;
263            }
264            atomic
265        } else {
266            atomic
267        };
268
269        // Avoid exact duplicates.
270        if self.types.contains(&atomic) {
271            return;
272        }
273
274        // TLiteralInt(n) is subsumed by TInt.
275        if let Atomic::TLiteralInt(_) = &atomic {
276            if self.types.iter().any(|t| matches!(t, Atomic::TInt)) {
277                return;
278            }
279        }
280        // TLiteralString(s) is subsumed by TString.
281        if let Atomic::TLiteralString(_) = &atomic {
282            if self.types.iter().any(|t| matches!(t, Atomic::TString)) {
283                return;
284            }
285        }
286        // TTrue / TFalse are subsumed by TBool.
287        if matches!(atomic, Atomic::TTrue | Atomic::TFalse)
288            && self.types.iter().any(|t| matches!(t, Atomic::TBool))
289        {
290            return;
291        }
292        // Adding TInt widens away all TLiteralInt variants.
293        if matches!(atomic, Atomic::TInt) {
294            self.types.retain(|t| !matches!(t, Atomic::TLiteralInt(_)));
295        }
296        // Adding TString widens away all TLiteralString variants.
297        if matches!(atomic, Atomic::TString) {
298            self.types
299                .retain(|t| !matches!(t, Atomic::TLiteralString(_)));
300        }
301        // Adding TBool widens away TTrue/TFalse.
302        if matches!(atomic, Atomic::TBool) {
303            self.types
304                .retain(|t| !matches!(t, Atomic::TTrue | Atomic::TFalse));
305        }
306
307        // TNever is the bottom type: T | never = T.
308        if matches!(atomic, Atomic::TNever) {
309            if !self.types.is_empty() {
310                return;
311            }
312        } else {
313            self.types.retain(|t| !matches!(t, Atomic::TNever));
314        }
315
316        // Empty keyed array (array{}) is a subtype of any generic array or list.
317        // Remove array{} if we already have a generic array<K,V> or list<V>.
318        if let Atomic::TKeyedArray { properties, .. } = &atomic {
319            if properties.is_empty() {
320                for existing in &self.types {
321                    match existing {
322                        Atomic::TArray { .. }
323                        | Atomic::TNonEmptyArray { .. }
324                        | Atomic::TList { .. }
325                        | Atomic::TNonEmptyList { .. } => {
326                            return; // Don't add empty array, it's subsumed
327                        }
328                        _ => {}
329                    }
330                }
331            }
332        }
333
334        // When adding a generic array or list, remove any empty keyed arrays since they're subtypes.
335        let is_generic_array_or_list = matches!(
336            &atomic,
337            Atomic::TArray { .. }
338                | Atomic::TNonEmptyArray { .. }
339                | Atomic::TList { .. }
340                | Atomic::TNonEmptyList { .. }
341        );
342        if is_generic_array_or_list {
343            self.types.retain(|t| {
344                if let Atomic::TKeyedArray { properties, .. } = t {
345                    !properties.is_empty()
346                } else {
347                    true
348                }
349            });
350        }
351
352        self.types.push(atomic);
353    }
354
355    // --- Narrowing -----------------------------------------------------------
356
357    /// Remove `null` from the union (e.g. after a null check).
358    pub fn remove_null(&self) -> Type {
359        self.filter(|t| !matches!(t, Atomic::TNull))
360    }
361
362    /// Remove `false` from the union.
363    /// `TFalse` is dropped; `TBool` becomes `TTrue` since `bool - false = true`.
364    pub fn remove_false(&self) -> Type {
365        let mut result = self.filter(|t| !matches!(t, Atomic::TFalse | Atomic::TBool));
366        if self.types.iter().any(|t| matches!(t, Atomic::TBool)) {
367            result.add_type(Atomic::TTrue);
368        }
369        result
370    }
371
372    /// Remove both `null` and `false` from the union (core type without nullable/falsy variants).
373    pub fn core_type(&self) -> Type {
374        self.remove_null().remove_false()
375    }
376
377    /// Keep only truthy atomics (e.g. after `if ($x)`).
378    pub fn narrow_to_truthy(&self) -> Type {
379        if self.is_mixed() {
380            return Type::mixed();
381        }
382        let mut result = Type::empty();
383        result.from_docblock = self.from_docblock;
384        for t in &self.types {
385            match t {
386                // Always-falsy — exclude entirely.
387                Atomic::TLiteralInt(0)
388                | Atomic::TLiteralFloat(0, 0)
389                | Atomic::TNull
390                | Atomic::TFalse => {}
391                Atomic::TLiteralString(s) if s.as_ref() == "" || s.as_ref() == "0" => {}
392                // bool contains both true (truthy) and false (falsy); truthy branch is true.
393                Atomic::TBool => result.add_type(Atomic::TTrue),
394                // array/list: empty ↔ falsy; truthy branch is non-empty-array/list.
395                Atomic::TArray { key, value } => result.add_type(Atomic::TNonEmptyArray {
396                    key: key.clone(),
397                    value: value.clone(),
398                }),
399                Atomic::TList { value } => result.add_type(Atomic::TNonEmptyList {
400                    value: value.clone(),
401                }),
402                // string: only "" and "0" are falsy; truthy branch is non-empty-string.
403                // non-empty-string still includes "0" (which is falsy) but that is the
404                // standard approximation used by Psalm and other analyzers.
405                Atomic::TString => result.add_type(Atomic::TNonEmptyString),
406                // numeric-string: "0" is the only falsy value; non-zero numerics are truthy.
407                // No named "non-zero numeric-string" type exists; keep as-is conservatively.
408                // int<0, max> only has 0 as its falsy value; truthy branch is int<1, max>.
409                // (int<0, 0> is handled by the can_be_truthy() false guard below.)
410                Atomic::TNonNegativeInt => result.add_type(Atomic::TPositiveInt),
411                Atomic::TIntRange { min: Some(0), max } if max.is_none_or(|m| m >= 1) => {
412                    let atom = if max.is_none() {
413                        Atomic::TPositiveInt
414                    } else {
415                        Atomic::TIntRange {
416                            min: Some(1),
417                            max: *max,
418                        }
419                    };
420                    result.add_type(atom);
421                }
422                // int<min, 0>: 0 is the only falsy value; truthy branch excludes it → int<min, -1>.
423                Atomic::TIntRange { min, max: Some(0) } => {
424                    let atom = match min {
425                        None => Atomic::TNegativeInt,
426                        Some(n) if *n <= -1 => Atomic::TIntRange {
427                            min: *min,
428                            max: Some(-1),
429                        },
430                        _ => continue, // min >= 0 with max == 0 → range is {0} — can_be_truthy() handles this
431                    };
432                    result.add_type(atom);
433                }
434                // Anything else that can never be truthy — drop.
435                t if !t.can_be_truthy() => {}
436                _ => result.add_type(t.clone()),
437            }
438        }
439        result
440    }
441
442    /// Keep only falsy atomics (e.g. after `if (!$x)`).
443    pub fn narrow_to_falsy(&self) -> Type {
444        if self.is_mixed() {
445            return Type::from_vec(vec![
446                Atomic::TNull,
447                Atomic::TFalse,
448                Atomic::TLiteralInt(0),
449                Atomic::TLiteralString("".into()),
450            ]);
451        }
452        let mut result = Type::empty();
453        result.from_docblock = self.from_docblock;
454        for t in &self.types {
455            match t {
456                // bool: only false is falsy; falsy branch is false.
457                Atomic::TBool => result.add_type(Atomic::TFalse),
458                // int: only 0 is falsy.
459                Atomic::TInt => result.add_type(Atomic::TLiteralInt(0)),
460                // float: only 0.0 is falsy.
461                Atomic::TFloat => result.add_type(Atomic::TLiteralFloat(0, 0)),
462                // string: only "" and "0" are falsy.
463                Atomic::TString => {
464                    result.add_type(Atomic::TLiteralString("".into()));
465                    result.add_type(Atomic::TLiteralString("0".into()));
466                }
467                // numeric-string: only "0" is a falsy numeric string.
468                Atomic::TNumericString => result.add_type(Atomic::TLiteralString("0".into())),
469                // non-negative-int: only 0 is falsy.
470                Atomic::TNonNegativeInt => result.add_type(Atomic::TLiteralInt(0)),
471                // int<0, hi>: only 0 is falsy.
472                Atomic::TIntRange {
473                    min: Some(0),
474                    max: Some(_) | None,
475                } => result.add_type(Atomic::TLiteralInt(0)),
476                // int<min, 0>: only 0 is falsy.
477                Atomic::TIntRange { max: Some(0), .. } => result.add_type(Atomic::TLiteralInt(0)),
478                t if !t.can_be_falsy() => {} // always truthy — exclude
479                _ => result.add_type(t.clone()),
480            }
481        }
482        result
483    }
484
485    /// Narrow this type as if `$x instanceof ClassName` is true.
486    ///
487    /// The instanceof check guarantees the value IS an instance of `class`, so we
488    /// replace any object / mixed constituents with the specific named object.  Scalar
489    /// constituents are dropped (they can never satisfy instanceof).
490    pub fn narrow_instanceof(&self, class: &str) -> Type {
491        let narrowed_ty = Atomic::TNamedObject {
492            fqcn: class.into(),
493            type_params: empty_type_params(),
494        };
495        // If any constituent is an object-like type, the result is the specific class.
496        let has_object = self.types.iter().any(|t| {
497            matches!(
498                t,
499                Atomic::TObject | Atomic::TNamedObject { .. } | Atomic::TMixed | Atomic::TNull // null fails instanceof, but mixed/object may include null
500            )
501        });
502        if has_object || self.is_empty() {
503            Type::single(narrowed_ty)
504        } else {
505            // Pure scalars — instanceof is always false here, but return the class
506            // defensively so callers don't see an empty union.
507            Type::single(narrowed_ty)
508        }
509    }
510
511    /// Narrow as if `is_string($x)` is true.
512    pub fn narrow_to_string(&self) -> Type {
513        self.filter(|t| t.is_string() || matches!(t, Atomic::TMixed | Atomic::TScalar))
514    }
515
516    /// Narrow as if `is_int($x)` is true.
517    pub fn narrow_to_int(&self) -> Type {
518        self.filter(|t| {
519            t.is_int() || matches!(t, Atomic::TMixed | Atomic::TScalar | Atomic::TNumeric)
520        })
521    }
522
523    /// Narrow as if `is_float($x)` is true.
524    pub fn narrow_to_float(&self) -> Type {
525        self.filter(|t| {
526            matches!(
527                t,
528                Atomic::TFloat
529                    | Atomic::TIntegralFloat
530                    | Atomic::TLiteralFloat(..)
531                    | Atomic::TMixed
532                    | Atomic::TScalar
533                    | Atomic::TNumeric
534            )
535        })
536    }
537
538    /// Narrow as if `is_bool($x)` is true.
539    pub fn narrow_to_bool(&self) -> Type {
540        self.filter(|t| {
541            matches!(
542                t,
543                Atomic::TBool | Atomic::TTrue | Atomic::TFalse | Atomic::TMixed | Atomic::TScalar
544            )
545        })
546    }
547
548    /// Narrow as if `is_null($x)` is true.
549    pub fn narrow_to_null(&self) -> Type {
550        self.filter(|t| matches!(t, Atomic::TNull | Atomic::TMixed))
551    }
552
553    /// Narrow as if `is_array($x)` is true.
554    pub fn narrow_to_array(&self) -> Type {
555        self.filter(|t| t.is_array() || matches!(t, Atomic::TMixed))
556    }
557
558    /// Narrow array/list types to their non-empty variants (for `count() > 0` etc.).
559    pub fn narrow_to_non_empty_collection(&self) -> Type {
560        let mut out = Type::empty();
561        out.from_docblock = self.from_docblock;
562        for t in &self.types {
563            match t {
564                Atomic::TArray { key, value } => out.add_type(Atomic::TNonEmptyArray {
565                    key: key.clone(),
566                    value: value.clone(),
567                }),
568                Atomic::TList { value } => out.add_type(Atomic::TNonEmptyList {
569                    value: value.clone(),
570                }),
571                _ => out.add_type(t.clone()),
572            }
573        }
574        out
575    }
576
577    /// Narrow as if `array_is_list($x)` is true.
578    /// Lists have sequential integer keys starting from 0, so:
579    /// - `list<T>` / `non-empty-list<T>` are kept unchanged.
580    /// - `array<int, T>` is narrowed to `list<T>` (could be sequential).
581    /// - `non-empty-array<int, T>` is narrowed to `non-empty-list<T>`.
582    /// - `mixed` becomes `list<mixed>` (array_is_list implies array).
583    /// - All other types (string-keyed arrays, non-arrays) are dropped.
584    pub fn narrow_to_list(&self) -> Type {
585        let mut out = Type::empty();
586        out.from_docblock = self.from_docblock;
587        for t in &self.types {
588            match t {
589                Atomic::TList { .. } | Atomic::TNonEmptyList { .. } => out.add_type(t.clone()),
590                Atomic::TArray { key, value } if matches!(key.types.as_slice(), [Atomic::TInt]) => {
591                    out.add_type(Atomic::TList {
592                        value: value.clone(),
593                    });
594                }
595                Atomic::TNonEmptyArray { key, value }
596                    if matches!(key.types.as_slice(), [Atomic::TInt]) =>
597                {
598                    out.add_type(Atomic::TNonEmptyList {
599                        value: value.clone(),
600                    });
601                }
602                Atomic::TMixed => out.add_type(Atomic::TList {
603                    value: Box::new(Type::mixed()),
604                }),
605                _ => {}
606            }
607        }
608        if out.is_empty() {
609            self.filter(|t| matches!(t, Atomic::TList { .. } | Atomic::TNonEmptyList { .. }))
610        } else {
611            out
612        }
613    }
614
615    /// Narrow as if `is_object($x)` is true. A `mixed` becomes a concrete bare
616    /// `object` (rather than staying `mixed`) so downstream object-only
617    /// operations — `clone`, `instanceof`, method calls — see an object type
618    /// instead of reporting `Mixed*`.
619    pub fn narrow_to_object(&self) -> Type {
620        let mut out = Type::empty();
621        for t in &self.types {
622            if matches!(t, Atomic::TMixed) {
623                out.add_type(Atomic::TObject);
624            } else if t.is_object() {
625                out.add_type(t.clone());
626            }
627        }
628        if out.types.is_empty() {
629            self.filter(|t| t.is_object())
630        } else {
631            out
632        }
633    }
634
635    /// Narrow as if `is_callable($x)` is true.
636    ///
637    /// PHP accepts closures, TCallable, strings (function names), arrays
638    /// (['Class', 'method'] or [$obj, 'method']), and objects with __invoke.
639    /// Keep all of these; only drop atoms that are definitely not callable
640    /// (scalars, null, bool, etc.).
641    pub fn narrow_to_callable(&self) -> Type {
642        self.filter(|t| {
643            t.is_callable()
644                || t.is_string()
645                || t.is_array()
646                || t.is_object()
647                || matches!(t, Atomic::TMixed)
648        })
649    }
650
651    /// Narrow as if `is_scalar($x)` is true (int | string | float | bool).
652    pub fn narrow_to_scalar(&self) -> Type {
653        self.filter(|t| {
654            t.is_string()
655                || t.is_int()
656                || matches!(
657                    t,
658                    Atomic::TFloat
659                        | Atomic::TIntegralFloat
660                        | Atomic::TLiteralFloat(..)
661                        | Atomic::TBool
662                        | Atomic::TTrue
663                        | Atomic::TFalse
664                        | Atomic::TScalar
665                        | Atomic::TNumeric
666                        | Atomic::TNumericString
667                        | Atomic::TMixed
668                )
669        })
670    }
671
672    /// Narrow as if `is_iterable($x)` is true (array | Traversable).
673    /// For simplicity, this narrows to arrays or objects (can't easily verify interfaces).
674    pub fn narrow_to_iterable(&self) -> Type {
675        self.filter(|t| t.is_array() || t.is_object() || matches!(t, Atomic::TMixed))
676    }
677
678    /// Narrow as if `is_countable($x)` is true (array | Countable).
679    /// For simplicity, this narrows to arrays or objects (can't easily verify Countable interface).
680    pub fn narrow_to_countable(&self) -> Type {
681        self.filter(|t| t.is_array() || t.is_object() || matches!(t, Atomic::TMixed))
682    }
683
684    /// Narrow as if `is_resource($x)` is true.
685    /// Note: No TResource atomic type exists in the type system; this is a no-op.
686    /// Resources are declining in modern PHP and not actively tracked.
687    pub fn narrow_to_resource(&self) -> Type {
688        // No resource type in the system; just return mixed (allows any type)
689        self.filter(|t| matches!(t, Atomic::TMixed))
690    }
691
692    /// Narrow as if `class_exists($x)` returned true for a string variable.
693    /// String atoms become `class-string`; existing class-string atoms pass through;
694    /// mixed/scalar becomes `class-string`. Non-string atoms are dropped (returning
695    /// empty so the caller can mark the branch as diverging).
696    pub fn narrow_to_class_string(&self) -> Type {
697        let mut out = Type::empty();
698        out.from_docblock = self.from_docblock;
699        for t in &self.types {
700            match t {
701                Atomic::TClassString(_) => out.add_type(t.clone()),
702                _ if t.is_string() || matches!(t, Atomic::TMixed | Atomic::TScalar) => {
703                    out.add_type(Atomic::TClassString(None));
704                }
705                _ => {}
706            }
707        }
708        out
709    }
710
711    // --- Merge (branch join) ------------------------------------------------
712
713    /// Merge two unions at a branch join point (e.g. after if/else).
714    /// The result is the union of all types in both.
715    pub fn merge(a: &Type, b: &Type) -> Type {
716        // Fast path: b is empty — nothing to add.
717        if b.types.is_empty() {
718            let mut result = a.clone();
719            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
720            return result;
721        }
722        // Fast path: a is empty — clone b.
723        if a.types.is_empty() {
724            let mut result = b.clone();
725            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
726            return result;
727        }
728        // Fast path: a is already mixed — b cannot widen it further.
729        if a.types.len() == 1 && matches!(a.types[0], Atomic::TMixed) {
730            let mut result = a.clone();
731            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
732            return result;
733        }
734        // Fast path: b contains mixed — result collapses to mixed.
735        if b.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
736            return Type {
737                types: smallvec::smallvec![Atomic::TMixed],
738                possibly_undefined: a.possibly_undefined || b.possibly_undefined,
739                from_docblock: a.from_docblock || b.from_docblock,
740            };
741        }
742        let mut result = a.clone();
743        result.merge_with(b);
744        result
745    }
746
747    /// Merge `other` into `self` in-place (avoids cloning `self`).
748    pub fn merge_with(&mut self, other: &Type) {
749        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
750            self.possibly_undefined |= other.possibly_undefined;
751            return;
752        }
753        if other.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
754            self.types.clear();
755            self.types.push(Atomic::TMixed);
756            self.possibly_undefined |= other.possibly_undefined;
757            return;
758        }
759        for atomic in &other.types {
760            self.add_type(atomic.clone());
761        }
762        self.possibly_undefined |= other.possibly_undefined;
763    }
764
765    /// Intersect with another union: keep only types present in `other`, widening
766    /// where `self` contains `mixed` (which is compatible with everything).
767    /// Used for match-arm subject narrowing.
768    pub fn intersect_with(&self, other: &Type) -> Type {
769        if self.is_mixed() {
770            return other.clone();
771        }
772        if other.is_mixed() {
773            return self.clone();
774        }
775        // Keep atomics from self that are also in other (by equality or subtype)
776        let mut result = Type::empty();
777        for a in &self.types {
778            for b in &other.types {
779                if a == b || atomic_subtype(a, b) || atomic_subtype(b, a) {
780                    result.add_type(a.clone());
781                    break;
782                }
783            }
784        }
785        if result.is_empty() {
786            Type::never()
787        } else {
788            result
789        }
790    }
791
792    // --- Template substitution ----------------------------------------------
793
794    /// Replace template param references with their resolved types.
795    pub fn substitute_templates(&self, bindings: &FxHashMap<Name, Type>) -> Type {
796        if bindings.is_empty() {
797            return self.clone();
798        }
799        let mut result = Type::empty();
800        result.possibly_undefined = self.possibly_undefined;
801        result.from_docblock = self.from_docblock;
802        for atomic in &self.types {
803            match atomic {
804                Atomic::TTemplateParam { name, .. } => {
805                    if let Some(resolved) = bindings.get(name) {
806                        for t in &resolved.types {
807                            result.add_type(t.clone());
808                        }
809                    } else {
810                        result.add_type(atomic.clone());
811                    }
812                }
813                Atomic::TArray { key, value } => {
814                    result.add_type(Atomic::TArray {
815                        key: Box::new(key.substitute_templates(bindings)),
816                        value: Box::new(value.substitute_templates(bindings)),
817                    });
818                }
819                Atomic::TList { value } => {
820                    result.add_type(Atomic::TList {
821                        value: Box::new(value.substitute_templates(bindings)),
822                    });
823                }
824                Atomic::TNonEmptyArray { key, value } => {
825                    result.add_type(Atomic::TNonEmptyArray {
826                        key: Box::new(key.substitute_templates(bindings)),
827                        value: Box::new(value.substitute_templates(bindings)),
828                    });
829                }
830                Atomic::TNonEmptyList { value } => {
831                    result.add_type(Atomic::TNonEmptyList {
832                        value: Box::new(value.substitute_templates(bindings)),
833                    });
834                }
835                Atomic::TKeyedArray {
836                    properties,
837                    is_open,
838                    is_list,
839                } => {
840                    use crate::atomic::KeyedProperty;
841                    let new_props = properties
842                        .iter()
843                        .map(|(k, prop)| {
844                            (
845                                k.clone(),
846                                KeyedProperty {
847                                    ty: prop.ty.substitute_templates(bindings),
848                                    optional: prop.optional,
849                                },
850                            )
851                        })
852                        .collect();
853                    result.add_type(Atomic::TKeyedArray {
854                        properties: new_props,
855                        is_open: *is_open,
856                        is_list: *is_list,
857                    });
858                }
859                Atomic::TCallable {
860                    params,
861                    return_type,
862                } => {
863                    result.add_type(Atomic::TCallable {
864                        params: params.as_ref().map(|ps| {
865                            ps.iter()
866                                .map(|p| substitute_in_fn_param(p, bindings))
867                                .collect()
868                        }),
869                        return_type: return_type
870                            .as_ref()
871                            .map(|r| Box::new(r.substitute_templates(bindings))),
872                    });
873                }
874                Atomic::TClosure {
875                    params,
876                    return_type,
877                    this_type,
878                } => {
879                    result.add_type(Atomic::TClosure {
880                        params: params
881                            .iter()
882                            .map(|p| substitute_in_fn_param(p, bindings))
883                            .collect(),
884                        return_type: Box::new(return_type.substitute_templates(bindings)),
885                        this_type: this_type
886                            .as_ref()
887                            .map(|t| Box::new(t.substitute_templates(bindings))),
888                    });
889                }
890                Atomic::TConditional {
891                    param_name,
892                    subject,
893                    if_true,
894                    if_false,
895                } => {
896                    let new_subject = subject.substitute_templates(bindings);
897                    let new_if_true = if_true.substitute_templates(bindings);
898                    let new_if_false = if_false.substitute_templates(bindings);
899
900                    // If param_name names a template that is bound in this substitution,
901                    // resolve the conditional immediately using the same predicate logic as
902                    // `resolve_conditional_returns` for the $param form.
903                    let resolved = if let Some(name) = param_name {
904                        if let Some(bound) = bindings.get(name) {
905                            if new_subject.types.len() == 1 {
906                                resolve_conditional_branch(
907                                    &new_subject.types[0],
908                                    bound,
909                                    &new_if_true,
910                                    &new_if_false,
911                                )
912                            } else {
913                                None
914                            }
915                        } else {
916                            None
917                        }
918                    } else {
919                        None
920                    };
921
922                    if let Some(branch) = resolved {
923                        for t in branch.types {
924                            result.add_type(t);
925                        }
926                    } else {
927                        result.add_type(Atomic::TConditional {
928                            param_name: *param_name,
929                            subject: Box::new(new_subject),
930                            if_true: Box::new(new_if_true),
931                            if_false: Box::new(new_if_false),
932                        });
933                    }
934                }
935                Atomic::TIntersection { parts } => {
936                    result.add_type(Atomic::TIntersection {
937                        parts: vec_to_type_params(
938                            parts
939                                .iter()
940                                .map(|p| p.substitute_templates(bindings))
941                                .collect(),
942                        ),
943                    });
944                }
945                Atomic::TNamedObject { fqcn, type_params } => {
946                    // TODO: the docblock parser emits TNamedObject { fqcn: "T" } for bare @return T
947                    // annotations instead of TTemplateParam, because it lacks template context at
948                    // parse time. This block works around that by treating bare unqualified names
949                    // as template param references when they appear in the binding map. Proper fix:
950                    // make the docblock parser template-aware so it emits TTemplateParam directly.
951                    // See issue #26 for context.
952                    if type_params.is_empty() && !fqcn.contains('\\') {
953                        if let Some(resolved) = bindings.get(fqcn) {
954                            for t in &resolved.types {
955                                result.add_type(t.clone());
956                            }
957                            continue;
958                        }
959                    }
960                    let new_params: Vec<Type> = type_params
961                        .iter()
962                        .map(|p| p.substitute_templates(bindings))
963                        .collect();
964                    result.add_type(Atomic::TNamedObject {
965                        fqcn: *fqcn,
966                        type_params: vec_to_type_params(new_params),
967                    });
968                }
969                // class-string<T> → substitute T from bindings
970                Atomic::TClassString(Some(param_name)) => {
971                    if let Some(resolved) = bindings.get(param_name) {
972                        for r_atomic in &resolved.types {
973                            let cls_name = if let Atomic::TNamedObject { fqcn, .. } = r_atomic {
974                                Some(*fqcn)
975                            } else {
976                                None
977                            };
978                            result.add_type(Atomic::TClassString(cls_name));
979                        }
980                    } else {
981                        result.add_type(atomic.clone());
982                    }
983                }
984                _ => {
985                    result.add_type(atomic.clone());
986                }
987            }
988        }
989        result
990    }
991
992    /// Resolves `TConditional` atoms whose discriminator is known at the call site.
993    ///
994    /// `lookup(param_name)` returns the call-site argument type for the named parameter,
995    /// or `None` if the argument is not available. Handles `is null`, `is string`, and
996    /// `is array` conditions; other condition types pass through unchanged.
997    pub fn resolve_conditional_returns<F>(self, lookup: F) -> Type
998    where
999        F: Fn(&str) -> Option<Type>,
1000    {
1001        self.resolve_conditional_inner(&lookup)
1002    }
1003
1004    fn resolve_conditional_inner<F>(self, lookup: &F) -> Type
1005    where
1006        F: Fn(&str) -> Option<Type>,
1007    {
1008        let mut result = Type::empty();
1009        for atomic in self.types {
1010            match atomic {
1011                Atomic::TConditional {
1012                    ref param_name,
1013                    ref subject,
1014                    ref if_true,
1015                    ref if_false,
1016                } => {
1017                    let resolved = if subject.types.len() == 1 {
1018                        if let Some(name) = param_name {
1019                            if let Some(arg_ty) = lookup(name.as_ref()) {
1020                                resolve_conditional_branch(
1021                                    &subject.types[0],
1022                                    &arg_ty,
1023                                    if_true,
1024                                    if_false,
1025                                )
1026                            } else {
1027                                None
1028                            }
1029                        } else {
1030                            None
1031                        }
1032                    } else {
1033                        None
1034                    };
1035
1036                    if let Some(branch) = resolved {
1037                        // Recursively resolve nested conditionals in the selected branch.
1038                        for t in branch.resolve_conditional_inner(lookup).types {
1039                            result.add_type(t);
1040                        }
1041                    } else {
1042                        // Cannot resolve at this call site: widen to the union of both branches.
1043                        // Recursively resolve nested conditionals in each branch.
1044                        for t in if_true.clone().resolve_conditional_inner(lookup).types {
1045                            result.add_type(t);
1046                        }
1047                        for t in if_false.clone().resolve_conditional_inner(lookup).types {
1048                            result.add_type(t);
1049                        }
1050                    }
1051                }
1052                other => result.add_type(other),
1053            }
1054        }
1055        result
1056    }
1057
1058    // --- Subtype check -------------------------------------------------------
1059
1060    /// Returns true if every atomic in `self` is a subtype of some atomic in `other`,
1061    /// using **only structural rules** — no `extends` / `implements` walk.
1062    ///
1063    /// Two distinct user-defined classes are never related here, even when one
1064    /// extends the other. Within `mir-analyzer`, when a `db` is in scope,
1065    /// prefer `crate::subtype::is_subtype(db, sub, sup)` which layers
1066    /// inheritance resolution on top of this check.
1067    pub fn is_subtype_structural(&self, other: &Type) -> bool {
1068        if other.is_mixed() {
1069            return true;
1070        }
1071        if self.is_never() {
1072            return true; // never <: everything
1073        }
1074        self.types
1075            .iter()
1076            .all(|a| other.types.iter().any(|b| atomic_subtype(a, b)))
1077    }
1078
1079    // --- Utilities ----------------------------------------------------------
1080
1081    fn filter<F: Fn(&Atomic) -> bool>(&self, f: F) -> Type {
1082        let mut result = Type::empty();
1083        result.possibly_undefined = self.possibly_undefined;
1084        result.from_docblock = self.from_docblock;
1085        for atomic in &self.types {
1086            if f(atomic) {
1087                result.types.push(atomic.clone());
1088            }
1089        }
1090        result
1091    }
1092
1093    /// Mark this union as possibly-undefined and return it.
1094    pub fn possibly_undefined(mut self) -> Self {
1095        self.possibly_undefined = true;
1096        self
1097    }
1098
1099    /// Mark this union as coming from a docblock annotation.
1100    pub fn from_docblock(mut self) -> Self {
1101        self.from_docblock = true;
1102        self
1103    }
1104}
1105
1106// ---------------------------------------------------------------------------
1107// Conditional return resolution helpers
1108// ---------------------------------------------------------------------------
1109
1110fn is_string_atomic(a: &Atomic) -> bool {
1111    matches!(
1112        a,
1113        Atomic::TString
1114            | Atomic::TNonEmptyString
1115            | Atomic::TLiteralString(_)
1116            | Atomic::TNumericString
1117            | Atomic::TClassString(_)
1118            | Atomic::TCallableString
1119    )
1120}
1121
1122fn is_array_atomic(a: &Atomic) -> bool {
1123    matches!(
1124        a,
1125        Atomic::TArray { .. }
1126            | Atomic::TNonEmptyArray { .. }
1127            | Atomic::TKeyedArray { .. }
1128            | Atomic::TList { .. }
1129            | Atomic::TNonEmptyList { .. }
1130    )
1131}
1132
1133fn is_list_atomic(a: &Atomic) -> bool {
1134    match a {
1135        Atomic::TList { .. } | Atomic::TNonEmptyList { .. } => true,
1136        Atomic::TKeyedArray { is_list, .. } => *is_list,
1137        _ => false,
1138    }
1139}
1140
1141/// Resolve one branch of a conditional return type given the subject discriminant
1142/// atomic and the actual argument type at the call site.
1143///
1144/// Returns `Some(branch)` when the branch can be determined statically, or `None`
1145/// to signal that the caller should widen to the union of both branches.
1146fn resolve_conditional_branch(
1147    subject: &Atomic,
1148    arg_ty: &Type,
1149    if_true: &Type,
1150    if_false: &Type,
1151) -> Option<Type> {
1152    let predicate: fn(&Atomic) -> bool = match subject {
1153        Atomic::TNull => |a| matches!(a, Atomic::TNull),
1154        Atomic::TTrue => |a| matches!(a, Atomic::TTrue),
1155        Atomic::TFalse => |a| matches!(a, Atomic::TFalse),
1156        Atomic::TString => is_string_atomic,
1157        Atomic::TList { .. } => is_list_atomic,
1158        Atomic::TArray { .. } => is_array_atomic,
1159        _ => return None,
1160    };
1161
1162    if arg_ty.types.is_empty() {
1163        return None;
1164    }
1165    let all_match = arg_ty.types.iter().all(&predicate);
1166    let none_match = !arg_ty.types.iter().any(predicate);
1167    if all_match {
1168        Some(if_true.clone())
1169    } else if none_match {
1170        Some(if_false.clone())
1171    } else {
1172        None
1173    }
1174}
1175
1176// ---------------------------------------------------------------------------
1177// Template substitution helpers
1178// ---------------------------------------------------------------------------
1179
1180fn substitute_in_fn_param(
1181    p: &crate::atomic::FnParam,
1182    bindings: &FxHashMap<Name, Type>,
1183) -> crate::atomic::FnParam {
1184    crate::atomic::FnParam {
1185        name: p.name,
1186        ty: p.ty.as_ref().map(|t| {
1187            let u = t.to_union();
1188            let substituted = u.substitute_templates(bindings);
1189            crate::compact::SimpleType::from_union(substituted)
1190        }),
1191        out_ty: p.out_ty.as_ref().map(|t| {
1192            let u = t.to_union();
1193            let substituted = u.substitute_templates(bindings);
1194            crate::compact::SimpleType::from_union(substituted)
1195        }),
1196        default: p.default.as_ref().map(|d| {
1197            let u = d.to_union();
1198            let substituted = u.substitute_templates(bindings);
1199            crate::compact::SimpleType::from_union(substituted)
1200        }),
1201        is_variadic: p.is_variadic,
1202        is_byref: p.is_byref,
1203        is_optional: p.is_optional,
1204    }
1205}
1206
1207// ---------------------------------------------------------------------------
1208// Atomic subtype (no codebase — structural check only)
1209// ---------------------------------------------------------------------------
1210
1211fn atomic_subtype(sub: &Atomic, sup: &Atomic) -> bool {
1212    if sub == sup {
1213        return true;
1214    }
1215    match (sub, sup) {
1216        // Bottom type
1217        (Atomic::TNever, _) => true,
1218        // Top types — anything goes in both directions for mixed
1219        (_, Atomic::TMixed) => true,
1220        (Atomic::TMixed, _) => true,
1221        // Template param in supertype position: any value satisfies an unconstrained
1222        // template (as_type = mixed), or a constrained one if it satisfies the bound.
1223        // This handles union bounds like `T of string|list<I>|array<K, V>` where
1224        // I/K/V are free template params — any type satisfies them structurally.
1225        (_, Atomic::TTemplateParam { as_type, .. }) => {
1226            as_type.is_mixed() || as_type.types.iter().any(|b| atomic_subtype(sub, b))
1227        }
1228
1229        // Scalars
1230        (Atomic::TLiteralInt(_), Atomic::TInt) => true,
1231        (Atomic::TLiteralInt(_), Atomic::TNumeric) => true,
1232        (Atomic::TLiteralInt(_), Atomic::TScalar) => true,
1233        (Atomic::TLiteralInt(n), Atomic::TPositiveInt) => *n > 0,
1234        (Atomic::TLiteralInt(n), Atomic::TNonNegativeInt) => *n >= 0,
1235        (Atomic::TLiteralInt(n), Atomic::TNegativeInt) => *n < 0,
1236        (Atomic::TPositiveInt, Atomic::TInt) => true,
1237        (Atomic::TPositiveInt, Atomic::TNonNegativeInt) => true,
1238        (Atomic::TPositiveInt, Atomic::TNumeric) => true,
1239        (Atomic::TPositiveInt, Atomic::TScalar) => true,
1240        (Atomic::TNegativeInt, Atomic::TInt) => true,
1241        (Atomic::TNegativeInt, Atomic::TNumeric) => true,
1242        (Atomic::TNegativeInt, Atomic::TScalar) => true,
1243        (Atomic::TNonNegativeInt, Atomic::TInt) => true,
1244        (Atomic::TNonNegativeInt, Atomic::TNumeric) => true,
1245        (Atomic::TNonNegativeInt, Atomic::TScalar) => true,
1246        (Atomic::TIntRange { .. }, Atomic::TInt) => true,
1247        (Atomic::TIntRange { .. }, Atomic::TNumeric) => true,
1248        (Atomic::TIntRange { .. }, Atomic::TScalar) => true,
1249        // positive-int is int<1, ∞>: subtype of int<sup_min, ∞> when sup_min <= 1
1250        (Atomic::TPositiveInt, Atomic::TIntRange { min, max }) => {
1251            max.is_none() && min.is_none_or(|m| m <= 1)
1252        }
1253        // negative-int is int<-∞, -1>: subtype of int<-∞, sup_max> when sup_max >= -1
1254        (Atomic::TNegativeInt, Atomic::TIntRange { min, max }) => {
1255            min.is_none() && max.is_none_or(|m| m >= -1)
1256        }
1257        // non-negative-int is int<0, ∞>: subtype of int<sup_min, ∞> when sup_min <= 0
1258        (Atomic::TNonNegativeInt, Atomic::TIntRange { min, max }) => {
1259            max.is_none() && min.is_none_or(|m| m <= 0)
1260        }
1261        // A bounded int range is a subtype of a named int subtype when every value fits
1262        (Atomic::TIntRange { min: sub_min, .. }, Atomic::TPositiveInt) => {
1263            sub_min.is_some_and(|lo| lo >= 1)
1264        }
1265        (Atomic::TIntRange { min: sub_min, .. }, Atomic::TNonNegativeInt) => {
1266            sub_min.is_some_and(|lo| lo >= 0)
1267        }
1268        (Atomic::TIntRange { max: sub_max, .. }, Atomic::TNegativeInt) => {
1269            sub_max.is_some_and(|hi| hi <= -1)
1270        }
1271        // int<sub_min, sub_max> <: int<sup_min, sup_max> when ranges nest
1272        (
1273            Atomic::TIntRange {
1274                min: sub_min,
1275                max: sub_max,
1276            },
1277            Atomic::TIntRange {
1278                min: sup_min,
1279                max: sup_max,
1280            },
1281        ) => {
1282            let lower_ok = match (sub_min, sup_min) {
1283                (_, None) => true,
1284                (None, Some(_)) => false,
1285                (Some(sl), Some(su)) => sl >= su,
1286            };
1287            let upper_ok = match (sub_max, sup_max) {
1288                (None, None) | (Some(_), None) => true,
1289                (None, Some(_)) => false,
1290                (Some(sl), Some(su)) => sl <= su,
1291            };
1292            lower_ok && upper_ok
1293        }
1294
1295        (Atomic::TLiteralFloat(..), Atomic::TFloat) => true,
1296        (Atomic::TLiteralFloat(..), Atomic::TNumeric) => true,
1297        (Atomic::TLiteralFloat(..), Atomic::TScalar) => true,
1298
1299        (Atomic::TLiteralString(s), Atomic::TString) => {
1300            let _ = s;
1301            true
1302        }
1303        (Atomic::TLiteralString(s), Atomic::TCallableString) => {
1304            let _ = s;
1305            true
1306        }
1307        (Atomic::TLiteralString(s), Atomic::TNonEmptyString) => !s.is_empty(),
1308        (Atomic::TLiteralString(s), Atomic::TNumericString) => s.parse::<f64>().is_ok(),
1309        // A literal string is type-compatible with class-string; validate_class_string_argument
1310        // separately checks whether the string names a real class (UndefinedClass).
1311        (Atomic::TLiteralString(_), Atomic::TClassString(_)) => true,
1312        (Atomic::TLiteralString(_), Atomic::TScalar) => true,
1313        (Atomic::TNonEmptyString, Atomic::TString) => true,
1314        (Atomic::TCallableString, Atomic::TString) => true,
1315        // numeric-string is always non-empty (e.g. "42", "-1", "0.5") — "" is not numeric.
1316        (Atomic::TNumericString, Atomic::TNonEmptyString) => true,
1317        (Atomic::TNumericString, Atomic::TString) => true,
1318        (Atomic::TClassString(_), Atomic::TString) => true,
1319        (Atomic::TInterfaceString, Atomic::TString) => true,
1320        (Atomic::TEnumString, Atomic::TString) => true,
1321        (Atomic::TTraitString, Atomic::TString) => true,
1322
1323        (Atomic::TTrue, Atomic::TBool) => true,
1324        (Atomic::TFalse, Atomic::TBool) => true,
1325
1326        (Atomic::TInt, Atomic::TNumeric) => true,
1327        (Atomic::TFloat, Atomic::TNumeric) => true,
1328        (Atomic::TIntegralFloat, Atomic::TNumeric) => true,
1329        (Atomic::TNumericString, Atomic::TNumeric) => true,
1330
1331        (Atomic::TInt, Atomic::TScalar) => true,
1332        (Atomic::TFloat, Atomic::TScalar) => true,
1333        (Atomic::TIntegralFloat, Atomic::TScalar) => true,
1334        (Atomic::TString, Atomic::TScalar) => true,
1335        (Atomic::TBool, Atomic::TScalar) => true,
1336        (Atomic::TNumeric, Atomic::TScalar) => true,
1337        (Atomic::TTrue, Atomic::TScalar) => true,
1338        (Atomic::TFalse, Atomic::TScalar) => true,
1339
1340        // Object hierarchy (structural, no codebase)
1341        (Atomic::TNamedObject { .. }, Atomic::TObject) => true,
1342        (Atomic::TStaticObject { .. }, Atomic::TObject) => true,
1343        (Atomic::TSelf { .. }, Atomic::TObject) => true,
1344        // self(X) and static(X) satisfy TNamedObject(X) with same FQCN
1345        (Atomic::TSelf { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
1346        (Atomic::TStaticObject { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
1347        // TNamedObject(X) satisfies self(X) / static(X) with same FQCN
1348        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TSelf { fqcn: b }) => a == b,
1349        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TStaticObject { fqcn: b }) => a == b,
1350        // Bare generic property accepts parameterized value: Box accepts Box<string>.
1351        // The reverse is NOT true — bare Box value does not satisfy Box<string> property
1352        // (invariant check). Only sup being bare (empty type_params) is the wildcard.
1353        (
1354            Atomic::TNamedObject {
1355                fqcn: sub_fqcn,
1356                type_params: sub_params,
1357            },
1358            Atomic::TNamedObject {
1359                fqcn: sup_fqcn,
1360                type_params: sup_params,
1361            },
1362        ) => {
1363            sub_fqcn == sup_fqcn
1364                && (sup_params.is_empty() || type_params_compatible(sub_params, sup_params))
1365        }
1366
1367        // TIntegralFloat is a subtype of float (all integral floats are floats)
1368        (Atomic::TIntegralFloat, Atomic::TFloat) => true,
1369
1370        // Literal int widens to float in PHP
1371        (Atomic::TLiteralInt(_), Atomic::TFloat) => true,
1372        (Atomic::TPositiveInt, Atomic::TFloat) => true,
1373        (Atomic::TNegativeInt, Atomic::TFloat) => true,
1374        (Atomic::TNonNegativeInt, Atomic::TFloat) => true,
1375        (Atomic::TInt, Atomic::TFloat) => true,
1376        (Atomic::TIntRange { .. }, Atomic::TFloat) => true,
1377
1378        // Literal int satisfies an int range only when the value is within bounds
1379        (Atomic::TLiteralInt(n), Atomic::TIntRange { min, max }) => {
1380            min.is_none_or(|lo| *n >= lo) && max.is_none_or(|hi| *n <= hi)
1381        }
1382
1383        // PHP callables: string and array are valid callable values
1384        (Atomic::TString, Atomic::TCallable { .. }) => true,
1385        (Atomic::TNonEmptyString, Atomic::TCallable { .. }) => true,
1386        (Atomic::TLiteralString(_), Atomic::TCallable { .. }) => true,
1387        (Atomic::TArray { .. }, Atomic::TCallable { .. }) => true,
1388        (Atomic::TNonEmptyArray { .. }, Atomic::TCallable { .. }) => true,
1389        (Atomic::TKeyedArray { .. }, Atomic::TCallable { .. }) => true,
1390
1391        // Closure <: callable, typed Closure <: Closure
1392        (Atomic::TClosure { .. }, Atomic::TCallable { .. }) => true,
1393        // callable <: Closure: callable is wider but not flagged at default error level
1394        (Atomic::TCallable { .. }, Atomic::TClosure { .. }) => true,
1395        // Any TClosure satisfies another TClosure (structural compatibility simplified)
1396        (Atomic::TClosure { .. }, Atomic::TClosure { .. }) => true,
1397        // callable <: callable (trivial)
1398        (Atomic::TCallable { .. }, Atomic::TCallable { .. }) => true,
1399        // TClosure satisfies `Closure` named object or `object`
1400        (Atomic::TClosure { .. }, Atomic::TNamedObject { fqcn, .. }) => {
1401            fqcn.as_ref().eq_ignore_ascii_case("closure")
1402        }
1403        (Atomic::TClosure { .. }, Atomic::TObject) => true,
1404        // bare `Closure` (named object without signature) satisfies any typed Closure(): T
1405        (Atomic::TNamedObject { fqcn, .. }, Atomic::TClosure { .. }) => {
1406            fqcn.as_ref().eq_ignore_ascii_case("closure")
1407        }
1408        // `Closure` named-object satisfies `callable`
1409        (Atomic::TNamedObject { fqcn, .. }, Atomic::TCallable { .. }) => {
1410            fqcn.as_ref().eq_ignore_ascii_case("closure")
1411        }
1412
1413        // List <: array  (list key is always int; int must satisfy the array's key type)
1414        (Atomic::TList { value }, Atomic::TArray { key, value: av }) => {
1415            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
1416        }
1417        (Atomic::TNonEmptyList { value }, Atomic::TArray { key, value: av }) => {
1418            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
1419        }
1420        (Atomic::TNonEmptyList { value }, Atomic::TNonEmptyArray { key, value: av }) => {
1421            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
1422        }
1423        (Atomic::TNonEmptyList { value }, Atomic::TList { value: lv }) => {
1424            value.is_subtype_structural(lv)
1425        }
1426        // array<int, X> is accepted where list<X> or non-empty-list<X> expected
1427        (Atomic::TArray { key, value: av }, Atomic::TList { value: lv }) => {
1428            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1429                && av.is_subtype_structural(lv)
1430        }
1431        (Atomic::TArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1432            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1433                && av.is_subtype_structural(lv)
1434        }
1435        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TList { value: lv }) => {
1436            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1437                && av.is_subtype_structural(lv)
1438        }
1439        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1440            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1441                && av.is_subtype_structural(lv)
1442        }
1443        // TList <: TList value covariance
1444        (Atomic::TList { value: v1 }, Atomic::TList { value: v2 }) => v1.is_subtype_structural(v2),
1445        (Atomic::TNonEmptyArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1446            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1447        }
1448
1449        // array<A, B> <: array<C, D>  iff  A <: C && B <: D
1450        (Atomic::TArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1451            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1452        }
1453
1454        // A keyed/shape array is a subtype of array<K, V> / non-empty-array<K, V>
1455        // when all property KEYS are subtypes of K. Value compatibility is checked
1456        // structurally only for scalar types; named-object values are deferred to
1457        // class-hierarchy checks in return_arrays_compatible (mir-analyzer).
1458        // Open shapes (is_open=true) may have extra unknown keys: keep permissive.
1459        (
1460            Atomic::TKeyedArray {
1461                properties,
1462                is_open,
1463                ..
1464            },
1465            Atomic::TArray { key, value },
1466        ) => {
1467            if *is_open {
1468                return true;
1469            }
1470            properties.iter().all(|(prop_key, prop)| {
1471                let key_atomic = match prop_key {
1472                    crate::atomic::ArrayKey::String(s) => Atomic::TLiteralString(s.clone()),
1473                    crate::atomic::ArrayKey::Int(n) => Atomic::TLiteralInt(*n),
1474                };
1475                if !Type::single(key_atomic).is_subtype_structural(key) {
1476                    return false; // key mismatch — definitively incompatible
1477                }
1478                // Named-object values require class-hierarchy checks not available here.
1479                let has_named_obj = prop.ty.types.iter().any(|a| {
1480                    matches!(
1481                        a,
1482                        Atomic::TNamedObject { .. }
1483                            | Atomic::TSelf { .. }
1484                            | Atomic::TStaticObject { .. }
1485                            | Atomic::TClosure { .. }
1486                            | Atomic::TTemplateParam { .. }
1487                    )
1488                });
1489                has_named_obj || prop.ty.is_subtype_structural(value)
1490            })
1491        }
1492        (
1493            Atomic::TKeyedArray {
1494                properties,
1495                is_open,
1496                ..
1497            },
1498            Atomic::TNonEmptyArray { key, value },
1499        ) => {
1500            if *is_open {
1501                return !properties.is_empty();
1502            }
1503            properties.iter().any(|(_, p)| !p.optional)
1504                && properties.iter().all(|(prop_key, prop)| {
1505                    let key_atomic = match prop_key {
1506                        crate::atomic::ArrayKey::String(s) => Atomic::TLiteralString(s.clone()),
1507                        crate::atomic::ArrayKey::Int(n) => Atomic::TLiteralInt(*n),
1508                    };
1509                    if !Type::single(key_atomic).is_subtype_structural(key) {
1510                        return false;
1511                    }
1512                    let has_named_obj = prop.ty.types.iter().any(|a| {
1513                        matches!(
1514                            a,
1515                            Atomic::TNamedObject { .. }
1516                                | Atomic::TSelf { .. }
1517                                | Atomic::TStaticObject { .. }
1518                                | Atomic::TClosure { .. }
1519                                | Atomic::TTemplateParam { .. }
1520                        )
1521                    });
1522                    has_named_obj || prop.ty.is_subtype_structural(value)
1523                })
1524        }
1525
1526        // A list-shaped keyed array (is_list=true, all int keys) is a subtype of list<X>.
1527        (
1528            Atomic::TKeyedArray {
1529                properties,
1530                is_list,
1531                ..
1532            },
1533            Atomic::TList { value: lv },
1534        ) => *is_list && properties.values().all(|p| p.ty.is_subtype_structural(lv)),
1535        (
1536            Atomic::TKeyedArray {
1537                properties,
1538                is_list,
1539                ..
1540            },
1541            Atomic::TNonEmptyList { value: lv },
1542        ) => {
1543            *is_list
1544                && !properties.is_empty()
1545                && properties.values().all(|p| p.ty.is_subtype_structural(lv))
1546        }
1547
1548        _ => false,
1549    }
1550}
1551
1552/// Whether each generic type-argument in `sub` is compatible with the
1553/// corresponding argument in `sup`. Arguments are invariant (require structural
1554/// equality) with one exception: an empty array literal (`array{}`) is accepted
1555/// against any array/list argument, so `new Box([])` — inferred as
1556/// `Box<array{}>` — satisfies a declared `Box<list<T>>` for any `T`.
1557fn type_params_compatible(sub: &[Type], sup: &[Type]) -> bool {
1558    if sub.len() != sup.len() {
1559        return false;
1560    }
1561    sub.iter()
1562        .zip(sup.iter())
1563        .all(|(a, b)| a == b || (is_empty_array_literal(a) && is_array_like(b)))
1564}
1565
1566/// True for a non-empty union whose atoms are all empty keyed arrays (`array{}`),
1567/// i.e. the type of an empty array literal `[]`.
1568fn is_empty_array_literal(t: &Type) -> bool {
1569    !t.types.is_empty()
1570        && t.types.iter().all(
1571            |atom| matches!(atom, Atomic::TKeyedArray { properties, .. } if properties.is_empty()),
1572        )
1573}
1574
1575/// True for a non-empty union whose atoms are all array/list types.
1576fn is_array_like(t: &Type) -> bool {
1577    !t.types.is_empty() && t.types.iter().all(|atom| atom.is_array())
1578}
1579
1580// ---------------------------------------------------------------------------
1581// Tests
1582// ---------------------------------------------------------------------------
1583
1584#[cfg(test)]
1585mod tests {
1586    use std::sync::Arc;
1587
1588    use super::*;
1589
1590    #[test]
1591    fn single_is_single() {
1592        let u = Type::single(Atomic::TString);
1593        assert!(u.is_single());
1594        assert!(!u.is_nullable());
1595    }
1596
1597    #[test]
1598    fn nullable_has_null() {
1599        let u = Type::nullable(Atomic::TString);
1600        assert!(u.is_nullable());
1601        assert_eq!(u.types.len(), 2);
1602    }
1603
1604    #[test]
1605    fn add_type_deduplicates() {
1606        let mut u = Type::single(Atomic::TString);
1607        u.add_type(Atomic::TString);
1608        assert_eq!(u.types.len(), 1);
1609    }
1610
1611    #[test]
1612    fn add_type_literal_subsumed_by_base() {
1613        let mut u = Type::single(Atomic::TInt);
1614        u.add_type(Atomic::TLiteralInt(42));
1615        assert_eq!(u.types.len(), 1);
1616        assert!(matches!(u.types[0], Atomic::TInt));
1617    }
1618
1619    #[test]
1620    fn add_type_base_widens_literals() {
1621        let mut u = Type::single(Atomic::TLiteralInt(1));
1622        u.add_type(Atomic::TLiteralInt(2));
1623        u.add_type(Atomic::TInt);
1624        assert_eq!(u.types.len(), 1);
1625        assert!(matches!(u.types[0], Atomic::TInt));
1626    }
1627
1628    #[test]
1629    fn mixed_subsumes_everything() {
1630        let mut u = Type::single(Atomic::TString);
1631        u.add_type(Atomic::TMixed);
1632        assert_eq!(u.types.len(), 1);
1633        assert!(u.is_mixed());
1634    }
1635
1636    #[test]
1637    fn remove_null() {
1638        let u = Type::nullable(Atomic::TString);
1639        let narrowed = u.remove_null();
1640        assert!(!narrowed.is_nullable());
1641        assert_eq!(narrowed.types.len(), 1);
1642    }
1643
1644    #[test]
1645    fn narrow_to_truthy_removes_null_false() {
1646        let mut u = Type::empty();
1647        u.add_type(Atomic::TString);
1648        u.add_type(Atomic::TNull);
1649        u.add_type(Atomic::TFalse);
1650        let truthy = u.narrow_to_truthy();
1651        assert!(!truthy.is_nullable());
1652        assert!(!truthy.contains(|t| matches!(t, Atomic::TFalse)));
1653    }
1654
1655    #[test]
1656    fn merge_combines_types() {
1657        let a = Type::single(Atomic::TString);
1658        let b = Type::single(Atomic::TInt);
1659        let merged = Type::merge(&a, &b);
1660        assert_eq!(merged.types.len(), 2);
1661    }
1662
1663    #[test]
1664    fn subtype_literal_int_under_int() {
1665        let sub = Type::single(Atomic::TLiteralInt(5));
1666        let sup = Type::single(Atomic::TInt);
1667        assert!(sub.is_subtype_structural(&sup));
1668    }
1669
1670    #[test]
1671    fn subtype_never_is_bottom() {
1672        let never = Type::never();
1673        let string = Type::single(Atomic::TString);
1674        assert!(never.is_subtype_structural(&string));
1675    }
1676
1677    #[test]
1678    fn subtype_everything_under_mixed() {
1679        let string = Type::single(Atomic::TString);
1680        let mixed = Type::mixed();
1681        assert!(string.is_subtype_structural(&mixed));
1682    }
1683
1684    #[test]
1685    fn template_substitution() {
1686        let mut bindings = FxHashMap::default();
1687        bindings.insert(Name::new("T"), Type::single(Atomic::TString));
1688
1689        let tmpl = Type::single(Atomic::TTemplateParam {
1690            name: Name::new("T"),
1691            as_type: Box::new(Type::mixed()),
1692            defining_entity: Name::new("MyClass"),
1693        });
1694
1695        let resolved = tmpl.substitute_templates(&bindings);
1696        assert_eq!(resolved.types.len(), 1);
1697        assert!(matches!(resolved.types[0], Atomic::TString));
1698    }
1699
1700    #[test]
1701    fn intersection_is_object() {
1702        let parts = vec![
1703            Type::single(Atomic::TNamedObject {
1704                fqcn: Name::new("Iterator"),
1705                type_params: empty_type_params(),
1706            }),
1707            Type::single(Atomic::TNamedObject {
1708                fqcn: Name::new("Countable"),
1709                type_params: empty_type_params(),
1710            }),
1711        ];
1712        let atomic = Atomic::TIntersection {
1713            parts: vec_to_type_params(parts),
1714        };
1715        assert!(atomic.is_object());
1716        assert!(!atomic.can_be_falsy());
1717        assert!(atomic.can_be_truthy());
1718    }
1719
1720    #[test]
1721    fn intersection_display_two_parts() {
1722        let parts = vec![
1723            Type::single(Atomic::TNamedObject {
1724                fqcn: Name::new("Iterator"),
1725                type_params: empty_type_params(),
1726            }),
1727            Type::single(Atomic::TNamedObject {
1728                fqcn: Name::new("Countable"),
1729                type_params: empty_type_params(),
1730            }),
1731        ];
1732        let u = Type::single(Atomic::TIntersection {
1733            parts: vec_to_type_params(parts),
1734        });
1735        assert_eq!(format!("{u}"), "Iterator&Countable");
1736    }
1737
1738    #[test]
1739    fn intersection_display_three_parts() {
1740        let parts = vec![
1741            Type::single(Atomic::TNamedObject {
1742                fqcn: Name::new("A"),
1743                type_params: empty_type_params(),
1744            }),
1745            Type::single(Atomic::TNamedObject {
1746                fqcn: Name::new("B"),
1747                type_params: empty_type_params(),
1748            }),
1749            Type::single(Atomic::TNamedObject {
1750                fqcn: Name::new("C"),
1751                type_params: empty_type_params(),
1752            }),
1753        ];
1754        let u = Type::single(Atomic::TIntersection {
1755            parts: vec_to_type_params(parts),
1756        });
1757        assert_eq!(format!("{u}"), "A&B&C");
1758    }
1759
1760    #[test]
1761    fn intersection_in_nullable_union_display() {
1762        let intersection = Atomic::TIntersection {
1763            parts: vec_to_type_params(vec![
1764                Type::single(Atomic::TNamedObject {
1765                    fqcn: Name::new("Iterator"),
1766                    type_params: empty_type_params(),
1767                }),
1768                Type::single(Atomic::TNamedObject {
1769                    fqcn: Name::new("Countable"),
1770                    type_params: empty_type_params(),
1771                }),
1772            ]),
1773        };
1774        let mut u = Type::single(intersection);
1775        u.add_type(Atomic::TNull);
1776        assert!(u.is_nullable());
1777        assert!(u.contains(|t| matches!(t, Atomic::TIntersection { .. })));
1778    }
1779
1780    // --- substitute_templates coverage for previously-missing arms ----------
1781
1782    fn t_param(name: &str) -> Type {
1783        Type::single(Atomic::TTemplateParam {
1784            name: Name::new(name),
1785            as_type: Box::new(Type::mixed()),
1786            defining_entity: Name::new("Fn"),
1787        })
1788    }
1789
1790    fn bindings_t_string() -> FxHashMap<Name, Type> {
1791        let mut b = FxHashMap::default();
1792        b.insert(Name::new("T"), Type::single(Atomic::TString));
1793        b
1794    }
1795
1796    #[test]
1797    fn substitute_non_empty_array_key_and_value() {
1798        let ty = Type::single(Atomic::TNonEmptyArray {
1799            key: Box::new(t_param("T")),
1800            value: Box::new(t_param("T")),
1801        });
1802        let result = ty.substitute_templates(&bindings_t_string());
1803        assert_eq!(result.types.len(), 1);
1804        let Atomic::TNonEmptyArray { key, value } = &result.types[0] else {
1805            panic!("expected TNonEmptyArray");
1806        };
1807        assert!(matches!(key.types[0], Atomic::TString));
1808        assert!(matches!(value.types[0], Atomic::TString));
1809    }
1810
1811    #[test]
1812    fn substitute_non_empty_list_value() {
1813        let ty = Type::single(Atomic::TNonEmptyList {
1814            value: Box::new(t_param("T")),
1815        });
1816        let result = ty.substitute_templates(&bindings_t_string());
1817        let Atomic::TNonEmptyList { value } = &result.types[0] else {
1818            panic!("expected TNonEmptyList");
1819        };
1820        assert!(matches!(value.types[0], Atomic::TString));
1821    }
1822
1823    #[test]
1824    fn substitute_keyed_array_property_types() {
1825        use crate::atomic::{ArrayKey, KeyedProperty};
1826        use indexmap::IndexMap;
1827        let mut props = IndexMap::new();
1828        props.insert(
1829            ArrayKey::String(Arc::from("name")),
1830            KeyedProperty {
1831                ty: t_param("T"),
1832                optional: false,
1833            },
1834        );
1835        props.insert(
1836            ArrayKey::String(Arc::from("tag")),
1837            KeyedProperty {
1838                ty: t_param("T"),
1839                optional: true,
1840            },
1841        );
1842        let ty = Type::single(Atomic::TKeyedArray {
1843            properties: props,
1844            is_open: true,
1845            is_list: false,
1846        });
1847        let result = ty.substitute_templates(&bindings_t_string());
1848        let Atomic::TKeyedArray {
1849            properties,
1850            is_open,
1851            is_list,
1852        } = &result.types[0]
1853        else {
1854            panic!("expected TKeyedArray");
1855        };
1856        assert!(is_open);
1857        assert!(!is_list);
1858        assert!(matches!(
1859            properties[&ArrayKey::String(Arc::from("name"))].ty.types[0],
1860            Atomic::TString
1861        ));
1862        assert!(properties[&ArrayKey::String(Arc::from("tag"))].optional);
1863        assert!(matches!(
1864            properties[&ArrayKey::String(Arc::from("tag"))].ty.types[0],
1865            Atomic::TString
1866        ));
1867    }
1868
1869    #[test]
1870    fn substitute_callable_params_and_return() {
1871        use crate::atomic::FnParam;
1872        let ty = Type::single(Atomic::TCallable {
1873            params: Some(vec![FnParam {
1874                name: Name::new("x"),
1875                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1876                out_ty: None,
1877                default: None,
1878                is_variadic: false,
1879                is_byref: false,
1880                is_optional: false,
1881            }]),
1882            return_type: Some(Box::new(t_param("T"))),
1883        });
1884        let result = ty.substitute_templates(&bindings_t_string());
1885        let Atomic::TCallable {
1886            params,
1887            return_type,
1888        } = &result.types[0]
1889        else {
1890            panic!("expected TCallable");
1891        };
1892        let param_ty = params.as_ref().unwrap()[0].ty.as_ref().unwrap();
1893        let param_union = param_ty.to_union();
1894        assert!(matches!(param_union.types[0], Atomic::TString));
1895        let ret = return_type.as_ref().unwrap();
1896        assert!(matches!(ret.types[0], Atomic::TString));
1897    }
1898
1899    #[test]
1900    fn substitute_callable_bare_no_panic() {
1901        // callable with no params/return — must not panic and must pass through unchanged
1902        let ty = Type::single(Atomic::TCallable {
1903            params: None,
1904            return_type: None,
1905        });
1906        let result = ty.substitute_templates(&bindings_t_string());
1907        assert!(matches!(
1908            result.types[0],
1909            Atomic::TCallable {
1910                params: None,
1911                return_type: None
1912            }
1913        ));
1914    }
1915
1916    #[test]
1917    fn substitute_closure_params_return_and_this() {
1918        use crate::atomic::FnParam;
1919        let ty = Type::single(Atomic::TClosure {
1920            params: vec![FnParam {
1921                name: Name::new("a"),
1922                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1923                out_ty: None,
1924                default: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1925                is_variadic: true,
1926                is_byref: true,
1927                is_optional: true,
1928            }],
1929            return_type: Box::new(t_param("T")),
1930            this_type: Some(Box::new(t_param("T"))),
1931        });
1932        let result = ty.substitute_templates(&bindings_t_string());
1933        let Atomic::TClosure {
1934            params,
1935            return_type,
1936            this_type,
1937        } = &result.types[0]
1938        else {
1939            panic!("expected TClosure");
1940        };
1941        let p = &params[0];
1942        let ty_union = p.ty.as_ref().unwrap().to_union();
1943        let default_union = p.default.as_ref().unwrap().to_union();
1944        assert!(matches!(ty_union.types[0], Atomic::TString));
1945        assert!(matches!(default_union.types[0], Atomic::TString));
1946        // flags preserved
1947        assert!(p.is_variadic);
1948        assert!(p.is_byref);
1949        assert!(p.is_optional);
1950        assert!(matches!(return_type.types[0], Atomic::TString));
1951        assert!(matches!(
1952            this_type.as_ref().unwrap().types[0],
1953            Atomic::TString
1954        ));
1955    }
1956
1957    #[test]
1958    fn substitute_conditional_all_branches() {
1959        let ty = Type::single(Atomic::TConditional {
1960            param_name: None,
1961            subject: Box::new(t_param("T")),
1962            if_true: Box::new(t_param("T")),
1963            if_false: Box::new(Type::single(Atomic::TInt)),
1964        });
1965        let result = ty.substitute_templates(&bindings_t_string());
1966        let Atomic::TConditional {
1967            param_name: _,
1968            subject,
1969            if_true,
1970            if_false,
1971        } = &result.types[0]
1972        else {
1973            panic!("expected TConditional");
1974        };
1975        assert!(matches!(subject.types[0], Atomic::TString));
1976        assert!(matches!(if_true.types[0], Atomic::TString));
1977        assert!(matches!(if_false.types[0], Atomic::TInt));
1978    }
1979
1980    #[test]
1981    fn resolve_conditional_is_null_non_null_arg() {
1982        let ty = Type::single(Atomic::TConditional {
1983            param_name: Some(Name::new("x")),
1984            subject: Box::new(Type::single(Atomic::TNull)),
1985            if_true: Box::new(Type::single(Atomic::TInt)),
1986            if_false: Box::new(Type::single(Atomic::TString)),
1987        });
1988        let result = ty.resolve_conditional_returns(|name| {
1989            if name == "x" {
1990                Some(Type::single(Atomic::TString)) // definitely not null
1991            } else {
1992                None
1993            }
1994        });
1995        assert!(result.types.len() == 1);
1996        assert!(matches!(result.types[0], Atomic::TString));
1997    }
1998
1999    #[test]
2000    fn resolve_conditional_is_null_null_arg() {
2001        let ty = Type::single(Atomic::TConditional {
2002            param_name: Some(Name::new("x")),
2003            subject: Box::new(Type::single(Atomic::TNull)),
2004            if_true: Box::new(Type::single(Atomic::TInt)),
2005            if_false: Box::new(Type::single(Atomic::TString)),
2006        });
2007        let result = ty.resolve_conditional_returns(|name| {
2008            if name == "x" {
2009                Some(Type::single(Atomic::TNull)) // definitely null
2010            } else {
2011                None
2012            }
2013        });
2014        assert!(result.types.len() == 1);
2015        assert!(matches!(result.types[0], Atomic::TInt));
2016    }
2017
2018    #[test]
2019    fn resolve_conditional_is_null_nullable_arg_widens_to_branch_union() {
2020        let mut nullable_str = Type::single(Atomic::TString);
2021        nullable_str.add_type(Atomic::TNull);
2022        let ty = Type::single(Atomic::TConditional {
2023            param_name: Some(Name::new("x")),
2024            subject: Box::new(Type::single(Atomic::TNull)),
2025            if_true: Box::new(Type::single(Atomic::TInt)),
2026            if_false: Box::new(Type::single(Atomic::TString)),
2027        });
2028        let result = ty.resolve_conditional_returns(|name| {
2029            if name == "x" {
2030                Some(nullable_str.clone())
2031            } else {
2032                None
2033            }
2034        });
2035        // uncertain discriminator → widen to if_true | if_false
2036        assert_eq!(result.types.len(), 2);
2037        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
2038        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
2039    }
2040
2041    #[test]
2042    fn resolve_conditional_nested_widens_inner_branch() {
2043        // ($x is null ? int : ($x is string ? string : float))
2044        // When $x is unknown, should widen to int|string|float (no TConditional remaining).
2045        let inner = Type::single(Atomic::TConditional {
2046            param_name: Some(Name::new("x")),
2047            subject: Box::new(Type::single(Atomic::TString)),
2048            if_true: Box::new(Type::single(Atomic::TString)),
2049            if_false: Box::new(Type::single(Atomic::TFloat)),
2050        });
2051        let ty = Type::single(Atomic::TConditional {
2052            param_name: Some(Name::new("x")),
2053            subject: Box::new(Type::single(Atomic::TNull)),
2054            if_true: Box::new(Type::single(Atomic::TInt)),
2055            if_false: Box::new(inner),
2056        });
2057        // unknown arg → widen both outer branches, inner conditional must also be widened
2058        let result = ty.resolve_conditional_returns(|_| None);
2059        assert!(
2060            result
2061                .types
2062                .iter()
2063                .all(|t| !matches!(t, Atomic::TConditional { .. })),
2064            "no TConditional should survive: {:?}",
2065            result.types
2066        );
2067        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
2068        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
2069        assert!(result.types.iter().any(|t| matches!(t, Atomic::TFloat)));
2070    }
2071
2072    #[test]
2073    fn resolve_conditional_nested_resolves_inner_branch() {
2074        // ($x is null ? int : ($x is string ? string : float))
2075        // When $x is definitely not null but unknown string-or-not → resolves outer to inner,
2076        // then inner must also be resolved.
2077        let inner = Type::single(Atomic::TConditional {
2078            param_name: Some(Name::new("x")),
2079            subject: Box::new(Type::single(Atomic::TString)),
2080            if_true: Box::new(Type::single(Atomic::TString)),
2081            if_false: Box::new(Type::single(Atomic::TFloat)),
2082        });
2083        let ty = Type::single(Atomic::TConditional {
2084            param_name: Some(Name::new("x")),
2085            subject: Box::new(Type::single(Atomic::TNull)),
2086            if_true: Box::new(Type::single(Atomic::TInt)),
2087            if_false: Box::new(inner),
2088        });
2089        // $x = string → outer: not null → if_false (inner); inner: is string → if_true = string
2090        let result = ty.resolve_conditional_returns(|name| {
2091            if name == "x" {
2092                Some(Type::single(Atomic::TString))
2093            } else {
2094                None
2095            }
2096        });
2097        assert!(
2098            result
2099                .types
2100                .iter()
2101                .all(|t| !matches!(t, Atomic::TConditional { .. })),
2102            "no TConditional should survive: {:?}",
2103            result.types
2104        );
2105        assert_eq!(result.types.len(), 1);
2106        assert!(matches!(result.types[0], Atomic::TString));
2107    }
2108
2109    #[test]
2110    fn substitute_intersection_parts() {
2111        let ty = Type::single(Atomic::TIntersection {
2112            parts: vec_to_type_params(vec![
2113                Type::single(Atomic::TNamedObject {
2114                    fqcn: Name::new("Countable"),
2115                    type_params: empty_type_params(),
2116                }),
2117                t_param("T"),
2118            ]),
2119        });
2120        let result = ty.substitute_templates(&bindings_t_string());
2121        let Atomic::TIntersection { parts } = &result.types[0] else {
2122            panic!("expected TIntersection");
2123        };
2124        assert_eq!(parts.len(), 2);
2125        assert!(matches!(parts[0].types[0], Atomic::TNamedObject { .. }));
2126        assert!(matches!(parts[1].types[0], Atomic::TString));
2127    }
2128
2129    #[test]
2130    fn substitute_no_template_params_identity() {
2131        let ty = Type::single(Atomic::TInt);
2132        let result = ty.substitute_templates(&bindings_t_string());
2133        assert!(matches!(result.types[0], Atomic::TInt));
2134    }
2135}