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