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    /// `int|string` — the canonical PHP array-key type, used as the default
110    /// key type when a docblock/inferred array has no more specific key.
111    pub fn array_key() -> Self {
112        let mut u = Self::single(Atomic::TInt);
113        u.add_type(Atomic::TString);
114        u
115    }
116
117    /// `T|null`
118    pub fn nullable(atomic: Atomic) -> Self {
119        // `mixed|null` = `mixed` — null is already included in mixed.
120        if matches!(atomic, Atomic::TMixed) {
121            return Self::mixed();
122        }
123        let mut types = SmallVec::new();
124        types.push(atomic);
125        types.push(Atomic::TNull);
126        Self {
127            types,
128            possibly_undefined: false,
129            from_docblock: false,
130        }
131    }
132
133    /// Build a union from multiple atomics, de-duplicating on the fly.
134    pub fn from_vec(atomics: Vec<Atomic>) -> Self {
135        let mut u = Self::empty();
136        for a in atomics {
137            u.add_type(a);
138        }
139        u
140    }
141
142    // --- Introspection -------------------------------------------------------
143
144    pub fn is_empty(&self) -> bool {
145        self.types.is_empty()
146    }
147
148    pub fn is_single(&self) -> bool {
149        self.types.len() == 1
150    }
151
152    pub fn is_nullable(&self) -> bool {
153        self.types.iter().any(|t| matches!(t, Atomic::TNull))
154    }
155
156    /// True when this is exactly `int|string` — the array-key domain, which
157    /// is already the maximal set of legal PHP array keys and so should be
158    /// treated like a "default"/unconstrained key, same as `mixed` would be
159    /// for a non-key type parameter.
160    pub fn is_array_key(&self) -> bool {
161        self.types.len() == 2
162            && self.types.iter().any(|t| matches!(t, Atomic::TInt))
163            && self.types.iter().any(|t| matches!(t, Atomic::TString))
164    }
165
166    pub fn is_mixed(&self) -> bool {
167        self.types.iter().any(|t| match t {
168            Atomic::TMixed => true,
169            Atomic::TTemplateParam { as_type, .. } => as_type.is_mixed(),
170            _ => false,
171        })
172    }
173
174    /// True only when the type contains `TMixed` atoms and no `TTemplateParam` atoms.
175    /// Unlike [`is_mixed`], this does not treat an unconstrained template parameter as
176    /// "mixed" — a `T` placeholder is an intentionally parameterised type that will be
177    /// instantiated at the call site, so it must not trigger `MixedAssignment` warnings.
178    pub fn is_mixed_not_template(&self) -> bool {
179        self.is_mixed()
180            && !self
181                .types
182                .iter()
183                .any(|t| matches!(t, Atomic::TTemplateParam { .. }))
184    }
185
186    pub fn is_never(&self) -> bool {
187        self.types.iter().all(|t| matches!(t, Atomic::TNever)) && !self.types.is_empty()
188    }
189
190    /// Classify this type for `clone` validity. Recurses into template-param
191    /// bounds (like [`Type::is_mixed`]). Callers handle `mixed` separately.
192    pub fn clone_validity(&self) -> CloneValidity {
193        if self.types.is_empty() {
194            return CloneValidity::Unknown;
195        }
196        let mut has_non_object = false;
197        let mut has_other = false; // object or ambiguous (callable, mixed, conditional, …)
198        for t in &self.types {
199            match t {
200                Atomic::TTemplateParam { as_type, .. } => match as_type.clone_validity() {
201                    CloneValidity::Invalid => has_non_object = true,
202                    CloneValidity::PossiblyInvalid => {
203                        has_non_object = true;
204                        has_other = true;
205                    }
206                    CloneValidity::Cloneable | CloneValidity::Unknown => has_other = true,
207                },
208                other if other.is_definitely_non_object() => has_non_object = true,
209                _ => has_other = true,
210            }
211        }
212        match (has_non_object, has_other) {
213            (true, false) => CloneValidity::Invalid,
214            (true, true) => CloneValidity::PossiblyInvalid,
215            _ => CloneValidity::Cloneable,
216        }
217    }
218
219    pub fn is_void(&self) -> bool {
220        self.is_single() && matches!(self.types[0], Atomic::TVoid)
221    }
222
223    pub fn can_be_falsy(&self) -> bool {
224        self.types.iter().any(|t| t.can_be_falsy())
225    }
226
227    pub fn can_be_truthy(&self) -> bool {
228        self.types.iter().any(|t| t.can_be_truthy())
229    }
230
231    pub fn contains<F: Fn(&Atomic) -> bool>(&self, f: F) -> bool {
232        self.types.iter().any(f)
233    }
234
235    pub fn has_named_object(&self, fqcn: &str) -> bool {
236        self.types.iter().any(|t| match t {
237            Atomic::TNamedObject { fqcn: f, .. } => f.as_ref() == fqcn,
238            _ => false,
239        })
240    }
241
242    // --- Mutation ------------------------------------------------------------
243
244    /// Add an atomic to this union, skipping duplicates.
245    /// Subsumption rules: anything ⊆ TMixed; TLiteralInt ⊆ TInt; etc.
246    pub fn add_type(&mut self, atomic: Atomic) {
247        // If we already have TMixed, nothing to add.
248        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
249            return;
250        }
251
252        // Adding TMixed subsumes everything.
253        if matches!(atomic, Atomic::TMixed) {
254            self.types.clear();
255            self.types.push(Atomic::TMixed);
256            return;
257        }
258
259        // Simplify trivial conditional types: (X is ? T : T) → T
260        // Recursively simplify branches first so nested trivial conditionals collapse.
261        let atomic = if let Atomic::TConditional { data } = &atomic {
262            let (if_true, if_false) = (&data.if_true, &data.if_false);
263            let mut simplified_true = Type::empty();
264            for t in &if_true.types {
265                simplified_true.add_type(t.clone());
266            }
267            let mut simplified_false = Type::empty();
268            for t in &if_false.types {
269                simplified_false.add_type(t.clone());
270            }
271            if simplified_true == simplified_false {
272                for t in simplified_true.types {
273                    self.add_type(t);
274                }
275                return;
276            }
277            atomic
278        } else {
279            atomic
280        };
281
282        // Avoid exact duplicates.
283        if self.types.contains(&atomic) {
284            return;
285        }
286
287        // TLiteralInt(n) is subsumed by TInt.
288        if let Atomic::TLiteralInt(_) = &atomic {
289            if self.types.iter().any(|t| matches!(t, Atomic::TInt)) {
290                return;
291            }
292        }
293        // TLiteralString(s) is subsumed by TString.
294        if let Atomic::TLiteralString(_) = &atomic {
295            if self.types.iter().any(|t| matches!(t, Atomic::TString)) {
296                return;
297            }
298        }
299        // TTrue / TFalse are subsumed by TBool.
300        if matches!(atomic, Atomic::TTrue | Atomic::TFalse)
301            && self.types.iter().any(|t| matches!(t, Atomic::TBool))
302        {
303            return;
304        }
305        // TTrue and TFalse together are exactly TBool — merge rather than
306        // keeping both literals once both are present.
307        if matches!(atomic, Atomic::TTrue) && self.types.iter().any(|t| matches!(t, Atomic::TFalse))
308        {
309            self.types.retain(|t| !matches!(t, Atomic::TFalse));
310            self.types.push(Atomic::TBool);
311            return;
312        }
313        if matches!(atomic, Atomic::TFalse) && self.types.iter().any(|t| matches!(t, Atomic::TTrue))
314        {
315            self.types.retain(|t| !matches!(t, Atomic::TTrue));
316            self.types.push(Atomic::TBool);
317            return;
318        }
319        // Adding TInt widens away all TLiteralInt variants.
320        if matches!(atomic, Atomic::TInt) {
321            self.types.retain(|t| !matches!(t, Atomic::TLiteralInt(_)));
322        }
323        // Adding TString widens away all TLiteralString variants.
324        if matches!(atomic, Atomic::TString) {
325            self.types
326                .retain(|t| !matches!(t, Atomic::TLiteralString(_)));
327        }
328        // Adding TBool widens away TTrue/TFalse.
329        if matches!(atomic, Atomic::TBool) {
330            self.types
331                .retain(|t| !matches!(t, Atomic::TTrue | Atomic::TFalse));
332        }
333
334        // TNever is the bottom type: T | never = T.
335        if matches!(atomic, Atomic::TNever) {
336            if !self.types.is_empty() {
337                return;
338            }
339        } else {
340            self.types.retain(|t| !matches!(t, Atomic::TNever));
341        }
342
343        // Empty keyed array (array{}) is a subtype of any generic array or list.
344        // Remove array{} if we already have a generic array<K,V> or list<V>.
345        if let Atomic::TKeyedArray { properties, .. } = &atomic {
346            if properties.is_empty() {
347                for existing in &self.types {
348                    match existing {
349                        Atomic::TArray { .. }
350                        | Atomic::TNonEmptyArray { .. }
351                        | Atomic::TList { .. }
352                        | Atomic::TNonEmptyList { .. } => {
353                            return; // Don't add empty array, it's subsumed
354                        }
355                        _ => {}
356                    }
357                }
358            }
359        }
360
361        // When adding a generic array or list, remove any empty keyed arrays since they're subtypes.
362        let is_generic_array_or_list = matches!(
363            &atomic,
364            Atomic::TArray { .. }
365                | Atomic::TNonEmptyArray { .. }
366                | Atomic::TList { .. }
367                | Atomic::TNonEmptyList { .. }
368        );
369        if is_generic_array_or_list {
370            self.types.retain(|t| {
371                if let Atomic::TKeyedArray { properties, .. } = t {
372                    !properties.is_empty()
373                } else {
374                    true
375                }
376            });
377        }
378
379        self.types.push(atomic);
380    }
381
382    // --- Narrowing -----------------------------------------------------------
383
384    /// Remove `null` from the union (e.g. after a null check).
385    pub fn remove_null(&self) -> Type {
386        self.filter(|t| !matches!(t, Atomic::TNull))
387    }
388
389    /// Remove `false` from the union.
390    /// `TFalse` is dropped; `TBool` becomes `TTrue` since `bool - false = true`.
391    pub fn remove_false(&self) -> Type {
392        let mut result = self.filter(|t| !matches!(t, Atomic::TFalse | Atomic::TBool));
393        if self.types.iter().any(|t| matches!(t, Atomic::TBool)) {
394            result.add_type(Atomic::TTrue);
395        }
396        result
397    }
398
399    /// Remove both `null` and `false` from the union (core type without nullable/falsy variants).
400    pub fn core_type(&self) -> Type {
401        self.remove_null().remove_false()
402    }
403
404    /// Keep only truthy atomics (e.g. after `if ($x)`).
405    pub fn narrow_to_truthy(&self) -> Type {
406        if self.is_mixed_not_template() {
407            return Type::mixed();
408        }
409        let mut result = Type::empty();
410        result.from_docblock = self.from_docblock;
411        for t in &self.types {
412            match t {
413                // An unconstrained/bounded template could resolve to anything at
414                // runtime, truthy or falsy — preserve it rather than dropping or
415                // widening it, the same way narrow_to_string/_int/etc. already do.
416                Atomic::TTemplateParam { .. } => result.add_type(t.clone()),
417                // Always-falsy — exclude entirely.
418                Atomic::TLiteralInt(0)
419                | Atomic::TLiteralFloat(0, 0)
420                | Atomic::TNull
421                | Atomic::TFalse => {}
422                Atomic::TLiteralString(s) if s.as_ref() == "" || s.as_ref() == "0" => {}
423                // bool contains both true (truthy) and false (falsy); truthy branch is true.
424                Atomic::TBool => result.add_type(Atomic::TTrue),
425                // array/list: empty ↔ falsy; truthy branch is non-empty-array/list.
426                Atomic::TArray { key, value } => result.add_type(Atomic::TNonEmptyArray {
427                    key: key.clone(),
428                    value: value.clone(),
429                }),
430                Atomic::TList { value } => result.add_type(Atomic::TNonEmptyList {
431                    value: value.clone(),
432                }),
433                // string: only "" and "0" are falsy; truthy branch is non-empty-string.
434                // non-empty-string still includes "0" (which is falsy) but that is the
435                // standard approximation used by Psalm and other analyzers.
436                Atomic::TString => result.add_type(Atomic::TNonEmptyString),
437                // numeric-string: "0" is the only falsy value; non-zero numerics are truthy.
438                // No named "non-zero numeric-string" type exists; keep as-is conservatively.
439                // int<0, max> only has 0 as its falsy value; truthy branch is int<1, max>.
440                // (int<0, 0> is handled by the can_be_truthy() false guard below.)
441                Atomic::TNonNegativeInt => result.add_type(Atomic::TPositiveInt),
442                Atomic::TIntRange { min: Some(0), max } if max.is_none_or(|m| m >= 1) => {
443                    let atom = if max.is_none() {
444                        Atomic::TPositiveInt
445                    } else {
446                        Atomic::TIntRange {
447                            min: Some(1),
448                            max: *max,
449                        }
450                    };
451                    result.add_type(atom);
452                }
453                // int<min, 0>: 0 is the only falsy value; truthy branch excludes it → int<min, -1>.
454                Atomic::TIntRange { min, max: Some(0) } => {
455                    let atom = match min {
456                        None => Atomic::TNegativeInt,
457                        Some(n) if *n <= -1 => Atomic::TIntRange {
458                            min: *min,
459                            max: Some(-1),
460                        },
461                        _ => continue, // min >= 0 with max == 0 → range is {0} — can_be_truthy() handles this
462                    };
463                    result.add_type(atom);
464                }
465                // Anything else that can never be truthy — drop.
466                t if !t.can_be_truthy() => {}
467                _ => result.add_type(t.clone()),
468            }
469        }
470        result
471    }
472
473    /// Keep only falsy atomics (e.g. after `if (!$x)`).
474    pub fn narrow_to_falsy(&self) -> Type {
475        if self.is_mixed_not_template() {
476            return Type::from_vec(vec![
477                Atomic::TNull,
478                Atomic::TFalse,
479                Atomic::TLiteralInt(0),
480                Atomic::TLiteralString("".into()),
481            ]);
482        }
483        let mut result = Type::empty();
484        result.from_docblock = self.from_docblock;
485        for t in &self.types {
486            match t {
487                // An unconstrained/bounded template could resolve to anything at
488                // runtime, truthy or falsy — preserve it rather than dropping it
489                // (its own `can_be_falsy()` conservatively defaults to `false`,
490                // which would otherwise wrongly exclude it here).
491                Atomic::TTemplateParam { .. } => result.add_type(t.clone()),
492                // bool: only false is falsy; falsy branch is false.
493                Atomic::TBool => result.add_type(Atomic::TFalse),
494                // int: only 0 is falsy.
495                Atomic::TInt => result.add_type(Atomic::TLiteralInt(0)),
496                // float: only 0.0 is falsy.
497                Atomic::TFloat => result.add_type(Atomic::TLiteralFloat(0, 0)),
498                // string: only "" and "0" are falsy.
499                Atomic::TString => {
500                    result.add_type(Atomic::TLiteralString("".into()));
501                    result.add_type(Atomic::TLiteralString("0".into()));
502                }
503                // numeric-string: only "0" is a falsy numeric string.
504                Atomic::TNumericString => result.add_type(Atomic::TLiteralString("0".into())),
505                // non-negative-int: only 0 is falsy.
506                Atomic::TNonNegativeInt => result.add_type(Atomic::TLiteralInt(0)),
507                // int<0, hi>: only 0 is falsy.
508                Atomic::TIntRange {
509                    min: Some(0),
510                    max: Some(_) | None,
511                } => result.add_type(Atomic::TLiteralInt(0)),
512                // int<min, 0>: only 0 is falsy.
513                Atomic::TIntRange { max: Some(0), .. } => result.add_type(Atomic::TLiteralInt(0)),
514                t if !t.can_be_falsy() => {} // always truthy — exclude
515                _ => result.add_type(t.clone()),
516            }
517        }
518        result
519    }
520
521    /// Narrow this type as if `$x instanceof ClassName` is true.
522    ///
523    /// The instanceof check guarantees the value IS an instance of `class`, so we
524    /// replace any object / mixed constituents with the specific named object.  Scalar
525    /// constituents are dropped (they can never satisfy instanceof).
526    pub fn narrow_instanceof(&self, class: &str) -> Type {
527        let narrowed_ty = Atomic::TNamedObject {
528            fqcn: class.into(),
529            type_params: empty_type_params(),
530        };
531        // If any constituent is an object-like type, the result is the specific class.
532        let has_object = self.types.iter().any(|t| {
533            matches!(
534                t,
535                Atomic::TObject | Atomic::TNamedObject { .. } | Atomic::TMixed | Atomic::TNull // null fails instanceof, but mixed/object may include null
536            )
537        });
538        if has_object || self.is_empty() {
539            Type::single(narrowed_ty)
540        } else {
541            // Pure scalars — instanceof is always false here, but return the class
542            // defensively so callers don't see an empty union.
543            Type::single(narrowed_ty)
544        }
545    }
546
547    /// Narrow as if `is_string($x)` is true. `mixed`/`scalar` become a concrete
548    /// `string` (rather than staying `mixed`) so downstream string-only
549    /// operations see a usable type instead of reporting `Mixed*`.
550    pub fn narrow_to_string(&self) -> Type {
551        self.filter_replacing(
552            |t| t.is_string() || matches!(t, Atomic::TTemplateParam { .. }),
553            |t| matches!(t, Atomic::TMixed | Atomic::TScalar),
554            Atomic::TString,
555        )
556    }
557
558    /// Narrow as if `is_int($x)` is true.
559    pub fn narrow_to_int(&self) -> Type {
560        self.filter_replacing(
561            |t| t.is_int() || matches!(t, Atomic::TTemplateParam { .. }),
562            |t| matches!(t, Atomic::TMixed | Atomic::TScalar | Atomic::TNumeric),
563            Atomic::TInt,
564        )
565    }
566
567    /// Narrow as if `is_float($x)` is true.
568    pub fn narrow_to_float(&self) -> Type {
569        self.filter_replacing(
570            |t| {
571                matches!(
572                    t,
573                    Atomic::TFloat
574                        | Atomic::TIntegralFloat
575                        | Atomic::TLiteralFloat(..)
576                        | Atomic::TTemplateParam { .. }
577                )
578            },
579            |t| matches!(t, Atomic::TMixed | Atomic::TScalar | Atomic::TNumeric),
580            Atomic::TFloat,
581        )
582    }
583
584    /// Narrow as if `is_bool($x)` is true.
585    pub fn narrow_to_bool(&self) -> Type {
586        self.filter_replacing(
587            |t| {
588                matches!(
589                    t,
590                    Atomic::TBool | Atomic::TTrue | Atomic::TFalse | Atomic::TTemplateParam { .. }
591                )
592            },
593            |t| matches!(t, Atomic::TMixed | Atomic::TScalar),
594            Atomic::TBool,
595        )
596    }
597
598    /// Narrow as if `is_null($x)` is true.
599    pub fn narrow_to_null(&self) -> Type {
600        self.filter_replacing(
601            |t| matches!(t, Atomic::TNull | Atomic::TTemplateParam { .. }),
602            |t| matches!(t, Atomic::TMixed),
603            Atomic::TNull,
604        )
605    }
606
607    /// Narrow as if `is_array($x)` is true.
608    pub fn narrow_to_array(&self) -> Type {
609        self.filter_replacing(
610            |t| t.is_array() || matches!(t, Atomic::TTemplateParam { .. }),
611            |t| matches!(t, Atomic::TMixed),
612            Atomic::TArray {
613                key: Box::new(Type::mixed()),
614                value: Box::new(Type::mixed()),
615            },
616        )
617    }
618
619    /// Narrow array/list types to their non-empty variants (for `count() > 0` etc.).
620    pub fn narrow_to_non_empty_collection(&self) -> Type {
621        let mut out = Type::empty();
622        out.from_docblock = self.from_docblock;
623        for t in &self.types {
624            match t {
625                Atomic::TArray { key, value } => out.add_type(Atomic::TNonEmptyArray {
626                    key: key.clone(),
627                    value: value.clone(),
628                }),
629                Atomic::TList { value } => out.add_type(Atomic::TNonEmptyList {
630                    value: value.clone(),
631                }),
632                _ => out.add_type(t.clone()),
633            }
634        }
635        out
636    }
637
638    /// Narrow array/list types when proven empty (e.g. `array_key_first($x) === null`).
639    /// Drops the non-empty variants outright — they can never be empty — but
640    /// otherwise leaves atoms unchanged, since there's no atomic "provably
641    /// empty array" type to narrow a plain `array`/`list` down to.
642    pub fn narrow_to_empty_collection(&self) -> Type {
643        self.filter(|t| {
644            !matches!(
645                t,
646                Atomic::TNonEmptyArray { .. } | Atomic::TNonEmptyList { .. }
647            )
648        })
649    }
650
651    /// Narrow as if `array_is_list($x)` is true.
652    /// Lists have sequential integer keys starting from 0, so:
653    /// - `list<T>` / `non-empty-list<T>` are kept unchanged.
654    /// - `array<int, T>` is narrowed to `list<T>` (could be sequential).
655    /// - `non-empty-array<int, T>` is narrowed to `non-empty-list<T>`.
656    /// - `TKeyedArray` (shape) is kept only when its own `is_list` flag is
657    ///   already true — that flag is precise (set from the actual literal's
658    ///   keys, or an explicit `list{...}` docblock), not a hint, so a
659    ///   string-keyed or non-contiguous shape is correctly excluded.
660    /// - `mixed` becomes `list<mixed>` (array_is_list implies array).
661    /// - All other types (string-keyed arrays, non-arrays) are dropped.
662    pub fn narrow_to_list(&self) -> Type {
663        let mut out = Type::empty();
664        out.from_docblock = self.from_docblock;
665        for t in &self.types {
666            match t {
667                Atomic::TList { .. } | Atomic::TNonEmptyList { .. } => out.add_type(t.clone()),
668                Atomic::TArray { key, value } if matches!(key.types.as_slice(), [Atomic::TInt]) => {
669                    out.add_type(Atomic::TList {
670                        value: value.clone(),
671                    });
672                }
673                Atomic::TNonEmptyArray { key, value }
674                    if matches!(key.types.as_slice(), [Atomic::TInt]) =>
675                {
676                    out.add_type(Atomic::TNonEmptyList {
677                        value: value.clone(),
678                    });
679                }
680                Atomic::TKeyedArray { is_list: true, .. } => out.add_type(t.clone()),
681                Atomic::TMixed => out.add_type(Atomic::TList {
682                    value: Box::new(Type::mixed()),
683                }),
684                _ => {}
685            }
686        }
687        if out.is_empty() {
688            self.filter(|t| matches!(t, Atomic::TList { .. } | Atomic::TNonEmptyList { .. }))
689        } else {
690            out
691        }
692    }
693
694    /// Narrow as if `is_object($x)` is true. A `mixed` becomes a concrete bare
695    /// `object` (rather than staying `mixed`) so downstream object-only
696    /// operations — `clone`, `instanceof`, method calls — see an object type
697    /// instead of reporting `Mixed*`.
698    pub fn narrow_to_object(&self) -> Type {
699        let mut out = Type::empty();
700        for t in &self.types {
701            if matches!(t, Atomic::TMixed) {
702                out.add_type(Atomic::TObject);
703            } else if t.is_object() || matches!(t, Atomic::TTemplateParam { .. }) {
704                out.add_type(t.clone());
705            }
706        }
707        if out.types.is_empty() {
708            self.filter(|t| t.is_object())
709        } else {
710            out
711        }
712    }
713
714    /// Narrow as if `is_callable($x)` is true.
715    ///
716    /// PHP accepts closures, TCallable, strings (function names), arrays
717    /// (['Class', 'method'] or [$obj, 'method']), and objects with __invoke.
718    /// Keep all of these; only drop atoms that are definitely not callable
719    /// (scalars, null, bool, etc.).
720    pub fn narrow_to_callable(&self) -> Type {
721        self.filter(|t| {
722            t.is_callable()
723                || t.is_string()
724                || t.is_array()
725                || t.is_object()
726                || matches!(t, Atomic::TMixed | Atomic::TTemplateParam { .. })
727        })
728    }
729
730    /// Narrow as if `is_scalar($x)` is true (int | string | float | bool).
731    pub fn narrow_to_scalar(&self) -> Type {
732        self.filter_replacing(
733            |t| {
734                t.is_string()
735                    || t.is_int()
736                    || matches!(
737                        t,
738                        Atomic::TFloat
739                            | Atomic::TIntegralFloat
740                            | Atomic::TLiteralFloat(..)
741                            | Atomic::TBool
742                            | Atomic::TTrue
743                            | Atomic::TFalse
744                            | Atomic::TScalar
745                            | Atomic::TNumeric
746                            | Atomic::TNumericString
747                            | Atomic::TTemplateParam { .. }
748                    )
749            },
750            |t| matches!(t, Atomic::TMixed),
751            Atomic::TScalar,
752        )
753    }
754
755    /// Narrow as if `is_iterable($x)` is true (array | Traversable).
756    /// For simplicity, this narrows to arrays or objects (can't easily verify interfaces).
757    pub fn narrow_to_iterable(&self) -> Type {
758        self.filter(|t| {
759            t.is_array()
760                || t.is_object()
761                || matches!(t, Atomic::TMixed | Atomic::TTemplateParam { .. })
762        })
763    }
764
765    /// Narrow as if `is_countable($x)` is true (array | Countable).
766    /// For simplicity, this narrows to arrays or objects (can't easily verify Countable interface).
767    pub fn narrow_to_countable(&self) -> Type {
768        self.filter(|t| {
769            t.is_array()
770                || t.is_object()
771                || matches!(t, Atomic::TMixed | Atomic::TTemplateParam { .. })
772        })
773    }
774
775    /// Narrow as if `is_resource($x)` is true.
776    /// Note: No TResource atomic type exists in the type system; this is a no-op.
777    /// Resources are declining in modern PHP and not actively tracked.
778    pub fn narrow_to_resource(&self) -> Type {
779        // No resource type in the system; just return mixed (allows any type)
780        self.filter(|t| matches!(t, Atomic::TMixed))
781    }
782
783    /// Narrow as if `class_exists($x)` returned true for a string variable.
784    /// String atoms become `class-string`; existing class-string atoms pass through;
785    /// mixed/scalar becomes `class-string`. Non-string atoms are dropped (returning
786    /// empty so the caller can mark the branch as diverging).
787    pub fn narrow_to_class_string(&self) -> Type {
788        let mut out = Type::empty();
789        out.from_docblock = self.from_docblock;
790        for t in &self.types {
791            match t {
792                Atomic::TClassString(_) => out.add_type(t.clone()),
793                _ if t.is_string() || matches!(t, Atomic::TMixed | Atomic::TScalar) => {
794                    out.add_type(Atomic::TClassString(None));
795                }
796                _ => {}
797            }
798        }
799        out
800    }
801
802    /// Narrow as if `interface_exists($x)` returned true for a string variable.
803    /// String atoms become `interface-string`; existing interface-string atoms pass
804    /// through; mixed/scalar becomes `interface-string`. Non-string atoms are dropped
805    /// (returning empty so the caller can mark the branch as diverging).
806    pub fn narrow_to_interface_string(&self) -> Type {
807        let mut out = Type::empty();
808        out.from_docblock = self.from_docblock;
809        for t in &self.types {
810            match t {
811                Atomic::TInterfaceString(_) => out.add_type(t.clone()),
812                // A known class-string keeps its name — every interface-string is
813                // also a valid class-string, so `interface_exists()` returning true
814                // narrows the atom without losing which class it names.
815                Atomic::TClassString(name) => {
816                    out.add_type(Atomic::TInterfaceString(*name));
817                }
818                _ if t.is_string() || matches!(t, Atomic::TMixed | Atomic::TScalar) => {
819                    out.add_type(Atomic::TInterfaceString(None));
820                }
821                _ => {}
822            }
823        }
824        out
825    }
826
827    // --- Merge (branch join) ------------------------------------------------
828
829    /// Merge two unions at a branch join point (e.g. after if/else).
830    /// The result is the union of all types in both.
831    pub fn merge(a: &Type, b: &Type) -> Type {
832        // Fast path: b is empty — nothing to add.
833        if b.types.is_empty() {
834            let mut result = a.clone();
835            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
836            return result;
837        }
838        // Fast path: a is empty — clone b.
839        if a.types.is_empty() {
840            let mut result = b.clone();
841            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
842            return result;
843        }
844        // Fast path: a is already mixed — b cannot widen it further.
845        if a.types.len() == 1 && matches!(a.types[0], Atomic::TMixed) {
846            let mut result = a.clone();
847            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
848            return result;
849        }
850        // Fast path: b contains mixed — result collapses to mixed.
851        if b.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
852            return Type {
853                types: smallvec::smallvec![Atomic::TMixed],
854                possibly_undefined: a.possibly_undefined || b.possibly_undefined,
855                from_docblock: a.from_docblock || b.from_docblock,
856            };
857        }
858        let mut result = a.clone();
859        result.merge_with(b);
860        result
861    }
862
863    /// Merge `other` into `self` in-place (avoids cloning `self`).
864    pub fn merge_with(&mut self, other: &Type) {
865        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
866            self.possibly_undefined |= other.possibly_undefined;
867            return;
868        }
869        if other.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
870            self.types.clear();
871            self.types.push(Atomic::TMixed);
872            self.possibly_undefined |= other.possibly_undefined;
873            return;
874        }
875        for atomic in &other.types {
876            self.add_type(atomic.clone());
877        }
878        self.possibly_undefined |= other.possibly_undefined;
879    }
880
881    /// Intersect with another union: keep only types present in `other`, widening
882    /// where `self` contains `mixed` (which is compatible with everything).
883    /// Used for match-arm subject narrowing.
884    pub fn intersect_with(&self, other: &Type) -> Type {
885        if self.is_mixed() {
886            return other.clone();
887        }
888        if other.is_mixed() {
889            return self.clone();
890        }
891        // Keep the more specific of each overlapping (self, other) atomic
892        // pair — e.g. intersecting `int` with `1|2` must keep `1|2` (the
893        // narrower side), not `int` (self's own, wider atomic): the whole
894        // point of narrowing a variable against a match/switch arm's
895        // literal conditions is to end up with the literal, not the bare
896        // declared type it already had. Every matching pair is kept (not
897        // just the first), so `int ∩ (1|2)` keeps both `1` and `2`.
898        let mut result = Type::empty();
899        for a in &self.types {
900            for b in &other.types {
901                if a == b {
902                    result.add_type(a.clone());
903                } else if atomic_subtype(b, a) {
904                    result.add_type(b.clone());
905                } else if atomic_subtype(a, b) {
906                    result.add_type(a.clone());
907                }
908            }
909        }
910        if result.is_empty() {
911            Type::never()
912        } else {
913            result
914        }
915    }
916
917    // --- Template substitution ----------------------------------------------
918
919    /// Replace template param references with their resolved types.
920    pub fn substitute_templates(&self, bindings: &FxHashMap<Name, Type>) -> Type {
921        if bindings.is_empty() {
922            return self.clone();
923        }
924        // Most argument/return types are plain scalars or resolved objects
925        // with nothing to substitute — skip the rebuild entirely.
926        if !self.types.iter().any(atomic_may_contain_templates) {
927            return self.clone();
928        }
929        let mut result = Type::empty();
930        result.possibly_undefined = self.possibly_undefined;
931        result.from_docblock = self.from_docblock;
932        for atomic in &self.types {
933            match atomic {
934                Atomic::TTemplateParam { name, .. } => {
935                    if let Some(resolved) = bindings.get(name) {
936                        for t in &resolved.types {
937                            result.add_type(t.clone());
938                        }
939                    } else {
940                        result.add_type(atomic.clone());
941                    }
942                }
943                Atomic::TArray { key, value } => {
944                    result.add_type(Atomic::TArray {
945                        key: Box::new(key.substitute_templates(bindings)),
946                        value: Box::new(value.substitute_templates(bindings)),
947                    });
948                }
949                Atomic::TList { value } => {
950                    result.add_type(Atomic::TList {
951                        value: Box::new(value.substitute_templates(bindings)),
952                    });
953                }
954                Atomic::TNonEmptyArray { key, value } => {
955                    result.add_type(Atomic::TNonEmptyArray {
956                        key: Box::new(key.substitute_templates(bindings)),
957                        value: Box::new(value.substitute_templates(bindings)),
958                    });
959                }
960                Atomic::TNonEmptyList { value } => {
961                    result.add_type(Atomic::TNonEmptyList {
962                        value: Box::new(value.substitute_templates(bindings)),
963                    });
964                }
965                Atomic::TKeyedArray {
966                    properties,
967                    is_open,
968                    is_list,
969                } => {
970                    use crate::atomic::KeyedProperty;
971                    let new_props = properties
972                        .iter()
973                        .map(|(k, prop)| {
974                            (
975                                k.clone(),
976                                KeyedProperty {
977                                    ty: prop.ty.substitute_templates(bindings),
978                                    optional: prop.optional,
979                                },
980                            )
981                        })
982                        .collect();
983                    result.add_type(Atomic::TKeyedArray {
984                        properties: Box::new(new_props),
985                        is_open: *is_open,
986                        is_list: *is_list,
987                    });
988                }
989                Atomic::TCallable {
990                    params,
991                    return_type,
992                } => {
993                    result.add_type(Atomic::TCallable {
994                        params: params.as_ref().map(|ps| {
995                            ps.iter()
996                                .map(|p| substitute_in_fn_param(p, bindings))
997                                .collect()
998                        }),
999                        return_type: return_type
1000                            .as_ref()
1001                            .map(|r| Box::new(r.substitute_templates(bindings))),
1002                    });
1003                }
1004                Atomic::TClosure { data } => {
1005                    result.add_type(Atomic::TClosure {
1006                        data: Box::new(crate::atomic::ClosureData {
1007                            params: data
1008                                .params
1009                                .iter()
1010                                .map(|p| substitute_in_fn_param(p, bindings))
1011                                .collect(),
1012                            return_type: data.return_type.substitute_templates(bindings),
1013                            this_type: data
1014                                .this_type
1015                                .as_ref()
1016                                .map(|t| t.substitute_templates(bindings)),
1017                        }),
1018                    });
1019                }
1020                Atomic::TConditional { data } => {
1021                    let param_name = &data.param_name;
1022                    let new_subject = data.subject.substitute_templates(bindings);
1023                    let new_if_true = data.if_true.substitute_templates(bindings);
1024                    let new_if_false = data.if_false.substitute_templates(bindings);
1025
1026                    // If param_name names a template that is bound in this substitution,
1027                    // resolve the conditional immediately using the same predicate logic as
1028                    // `resolve_conditional_returns` for the $param form.
1029                    let resolved = if let Some(name) = param_name {
1030                        if let Some(bound) = bindings.get(name) {
1031                            if new_subject.types.len() == 1 {
1032                                resolve_conditional_branch(
1033                                    &new_subject.types[0],
1034                                    bound,
1035                                    &new_if_true,
1036                                    &new_if_false,
1037                                )
1038                            } else {
1039                                None
1040                            }
1041                        } else {
1042                            None
1043                        }
1044                    } else {
1045                        None
1046                    };
1047
1048                    if let Some(branch) = resolved {
1049                        for t in branch.types {
1050                            result.add_type(t);
1051                        }
1052                    } else {
1053                        result.add_type(Atomic::TConditional {
1054                            data: Box::new(crate::atomic::ConditionalData {
1055                                param_name: *param_name,
1056                                subject: new_subject,
1057                                if_true: new_if_true,
1058                                if_false: new_if_false,
1059                            }),
1060                        });
1061                    }
1062                }
1063                Atomic::TIntersection { parts } => {
1064                    result.add_type(Atomic::TIntersection {
1065                        parts: vec_to_type_params(
1066                            parts
1067                                .iter()
1068                                .map(|p| p.substitute_templates(bindings))
1069                                .collect(),
1070                        ),
1071                    });
1072                }
1073                Atomic::TNamedObject { fqcn, type_params } => {
1074                    // TODO: the docblock parser emits TNamedObject { fqcn: "T" } for bare @return T
1075                    // annotations instead of TTemplateParam, because it lacks template context at
1076                    // parse time. This block works around that by treating bare unqualified names
1077                    // as template param references when they appear in the binding map. Proper fix:
1078                    // make the docblock parser template-aware so it emits TTemplateParam directly.
1079                    // See issue #26 for context.
1080                    if type_params.is_empty() && !fqcn.contains('\\') {
1081                        if let Some(resolved) = bindings.get(fqcn) {
1082                            for t in &resolved.types {
1083                                result.add_type(t.clone());
1084                            }
1085                            continue;
1086                        }
1087                    }
1088                    let new_params: Vec<Type> = type_params
1089                        .iter()
1090                        .map(|p| p.substitute_templates(bindings))
1091                        .collect();
1092                    result.add_type(Atomic::TNamedObject {
1093                        fqcn: *fqcn,
1094                        type_params: vec_to_type_params(new_params),
1095                    });
1096                }
1097                // class-string<T> → substitute T from bindings
1098                Atomic::TClassString(Some(param_name)) => {
1099                    if let Some(resolved) = bindings.get(param_name) {
1100                        for r_atomic in &resolved.types {
1101                            let cls_name = if let Atomic::TNamedObject { fqcn, .. } = r_atomic {
1102                                Some(*fqcn)
1103                            } else {
1104                                None
1105                            };
1106                            result.add_type(Atomic::TClassString(cls_name));
1107                        }
1108                    } else {
1109                        result.add_type(atomic.clone());
1110                    }
1111                }
1112                // interface-string<T> → substitute T from bindings
1113                Atomic::TInterfaceString(Some(param_name)) => {
1114                    if let Some(resolved) = bindings.get(param_name) {
1115                        for r_atomic in &resolved.types {
1116                            let iface_name = if let Atomic::TNamedObject { fqcn, .. } = r_atomic {
1117                                Some(*fqcn)
1118                            } else {
1119                                None
1120                            };
1121                            result.add_type(Atomic::TInterfaceString(iface_name));
1122                        }
1123                    } else {
1124                        result.add_type(atomic.clone());
1125                    }
1126                }
1127                _ => {
1128                    result.add_type(atomic.clone());
1129                }
1130            }
1131        }
1132        result
1133    }
1134
1135    /// Resolves `TConditional` atoms whose discriminator is known at the call site.
1136    ///
1137    /// `lookup(param_name)` returns the call-site argument type for the named parameter,
1138    /// or `None` if the argument is not available. Handles `is null`, `is string`, and
1139    /// `is array` conditions; other condition types pass through unchanged.
1140    pub fn resolve_conditional_returns<F>(self, lookup: F) -> Type
1141    where
1142        F: Fn(&str) -> Option<Type>,
1143    {
1144        self.resolve_conditional_inner(&lookup)
1145    }
1146
1147    fn resolve_conditional_inner<F>(self, lookup: &F) -> Type
1148    where
1149        F: Fn(&str) -> Option<Type>,
1150    {
1151        let mut result = Type::empty();
1152        for atomic in self.types {
1153            match atomic {
1154                Atomic::TConditional { ref data } => {
1155                    let (param_name, subject, if_true, if_false) = (
1156                        &data.param_name,
1157                        &data.subject,
1158                        &data.if_true,
1159                        &data.if_false,
1160                    );
1161                    let resolved = if subject.types.len() == 1 {
1162                        if let Some(name) = param_name {
1163                            if let Some(arg_ty) = lookup(name.as_ref()) {
1164                                resolve_conditional_branch(
1165                                    &subject.types[0],
1166                                    &arg_ty,
1167                                    if_true,
1168                                    if_false,
1169                                )
1170                            } else {
1171                                None
1172                            }
1173                        } else {
1174                            None
1175                        }
1176                    } else {
1177                        None
1178                    };
1179
1180                    if let Some(branch) = resolved {
1181                        // Recursively resolve nested conditionals in the selected branch.
1182                        for t in branch.resolve_conditional_inner(lookup).types {
1183                            result.add_type(t);
1184                        }
1185                    } else {
1186                        // Cannot resolve at this call site: widen to the union of both branches.
1187                        // Recursively resolve nested conditionals in each branch.
1188                        for t in if_true.clone().resolve_conditional_inner(lookup).types {
1189                            result.add_type(t);
1190                        }
1191                        for t in if_false.clone().resolve_conditional_inner(lookup).types {
1192                            result.add_type(t);
1193                        }
1194                    }
1195                }
1196                other => result.add_type(other),
1197            }
1198        }
1199        result
1200    }
1201
1202    // --- Subtype check -------------------------------------------------------
1203
1204    /// Returns true if every atomic in `self` is a subtype of some atomic in `other`,
1205    /// using **only structural rules** — no `extends` / `implements` walk.
1206    ///
1207    /// Two distinct user-defined classes are never related here, even when one
1208    /// extends the other. Within `mir-analyzer`, when a `db` is in scope,
1209    /// prefer `crate::subtype::is_subtype(db, sub, sup)` which layers
1210    /// inheritance resolution on top of this check.
1211    pub fn is_subtype_structural(&self, other: &Type) -> bool {
1212        if other.is_mixed() {
1213            return true;
1214        }
1215        if self.is_never() {
1216            return true; // never <: everything
1217        }
1218        self.types
1219            .iter()
1220            .all(|a| other.types.iter().any(|b| atomic_subtype(a, b)))
1221    }
1222
1223    /// `sub <: self`, structurally, for a single atomic — equivalent to
1224    /// `Type::single(sub.clone()).is_subtype_structural(self)` without the
1225    /// clone and the temporary single-atomic union.
1226    pub fn accepts_atomic_structural(&self, sub: &Atomic) -> bool {
1227        if self.is_mixed() {
1228            return true;
1229        }
1230        matches!(sub, Atomic::TNever) || self.types.iter().any(|b| atomic_subtype(sub, b))
1231    }
1232
1233    // --- Utilities ----------------------------------------------------------
1234
1235    fn filter<F: Fn(&Atomic) -> bool>(&self, f: F) -> Type {
1236        let mut result = Type::empty();
1237        result.possibly_undefined = self.possibly_undefined;
1238        result.from_docblock = self.from_docblock;
1239        for atomic in &self.types {
1240            if f(atomic) {
1241                result.types.push(atomic.clone());
1242            }
1243        }
1244        result
1245    }
1246
1247    /// Like `filter`, but atoms matching `placeholder` are substituted with
1248    /// `replacement` instead of passing through unchanged. Used so narrowing
1249    /// an unrefined `mixed`/`scalar`/`numeric` value (e.g. via `is_int($x)`)
1250    /// yields the concrete narrowed type instead of staying `mixed`.
1251    fn filter_replacing<K: Fn(&Atomic) -> bool, P: Fn(&Atomic) -> bool>(
1252        &self,
1253        keep: K,
1254        placeholder: P,
1255        replacement: Atomic,
1256    ) -> Type {
1257        let mut result = Type::empty();
1258        result.possibly_undefined = self.possibly_undefined;
1259        result.from_docblock = self.from_docblock;
1260        for atomic in &self.types {
1261            if keep(atomic) {
1262                result.add_type(atomic.clone());
1263            } else if placeholder(atomic) {
1264                result.add_type(replacement.clone());
1265            }
1266        }
1267        result
1268    }
1269
1270    /// Mark this union as possibly-undefined and return it.
1271    pub fn possibly_undefined(mut self) -> Self {
1272        self.possibly_undefined = true;
1273        self
1274    }
1275
1276    /// Mark this union as coming from a docblock annotation.
1277    pub fn from_docblock(mut self) -> Self {
1278        self.from_docblock = true;
1279        self
1280    }
1281}
1282
1283// ---------------------------------------------------------------------------
1284// Conditional return resolution helpers
1285// ---------------------------------------------------------------------------
1286
1287fn is_string_atomic(a: &Atomic) -> bool {
1288    matches!(
1289        a,
1290        Atomic::TString
1291            | Atomic::TNonEmptyString
1292            | Atomic::TLiteralString(_)
1293            | Atomic::TNumericString
1294            | Atomic::TClassString(_)
1295            | Atomic::TInterfaceString(_)
1296            | Atomic::TCallableString
1297    )
1298}
1299
1300fn is_array_atomic(a: &Atomic) -> bool {
1301    matches!(
1302        a,
1303        Atomic::TArray { .. }
1304            | Atomic::TNonEmptyArray { .. }
1305            | Atomic::TKeyedArray { .. }
1306            | Atomic::TList { .. }
1307            | Atomic::TNonEmptyList { .. }
1308    )
1309}
1310
1311fn is_list_atomic(a: &Atomic) -> bool {
1312    match a {
1313        Atomic::TList { .. } | Atomic::TNonEmptyList { .. } => true,
1314        Atomic::TKeyedArray { is_list, .. } => *is_list,
1315        _ => false,
1316    }
1317}
1318
1319fn is_float_atomic(a: &Atomic) -> bool {
1320    matches!(
1321        a,
1322        Atomic::TFloat | Atomic::TIntegralFloat | Atomic::TLiteralFloat(..)
1323    )
1324}
1325
1326fn is_bool_atomic(a: &Atomic) -> bool {
1327    matches!(a, Atomic::TBool | Atomic::TTrue | Atomic::TFalse)
1328}
1329
1330/// Resolve one branch of a conditional return type given the subject discriminant
1331/// atomic and the actual argument type at the call site.
1332///
1333/// Returns `Some(branch)` when the branch can be determined statically, or `None`
1334/// to signal that the caller should widen to the union of both branches.
1335fn resolve_conditional_branch(
1336    subject: &Atomic,
1337    arg_ty: &Type,
1338    if_true: &Type,
1339    if_false: &Type,
1340) -> Option<Type> {
1341    let predicate: fn(&Atomic) -> bool = match subject {
1342        Atomic::TNull => |a| matches!(a, Atomic::TNull),
1343        Atomic::TTrue => |a| matches!(a, Atomic::TTrue),
1344        Atomic::TFalse => |a| matches!(a, Atomic::TFalse),
1345        Atomic::TString => is_string_atomic,
1346        Atomic::TList { .. } => is_list_atomic,
1347        Atomic::TArray { .. } => is_array_atomic,
1348        Atomic::TInt => Atomic::is_int,
1349        Atomic::TFloat => is_float_atomic,
1350        Atomic::TBool => is_bool_atomic,
1351        _ => return None,
1352    };
1353
1354    if arg_ty.types.is_empty() {
1355        return None;
1356    }
1357    let all_match = arg_ty.types.iter().all(&predicate);
1358    let none_match = !arg_ty.types.iter().any(predicate);
1359    if all_match {
1360        Some(if_true.clone())
1361    } else if none_match {
1362        Some(if_false.clone())
1363    } else {
1364        None
1365    }
1366}
1367
1368// ---------------------------------------------------------------------------
1369// Template substitution helpers
1370// ---------------------------------------------------------------------------
1371
1372/// Whether `substitute_templates` could change this atomic: it is either a
1373/// template reference itself or a container/callable that may hold one.
1374/// Mirrors the substituting arms of that function's match — keep in sync.
1375fn atomic_may_contain_templates(atomic: &Atomic) -> bool {
1376    match atomic {
1377        // Bare unqualified names double as template refs (docblock parser
1378        // workaround, see substitute_templates); qualified names only matter
1379        // when they carry generic params.
1380        Atomic::TNamedObject { fqcn, type_params } => {
1381            !type_params.is_empty() || !fqcn.contains('\\')
1382        }
1383        Atomic::TTemplateParam { .. }
1384        | Atomic::TArray { .. }
1385        | Atomic::TList { .. }
1386        | Atomic::TNonEmptyArray { .. }
1387        | Atomic::TNonEmptyList { .. }
1388        | Atomic::TKeyedArray { .. }
1389        | Atomic::TCallable { .. }
1390        | Atomic::TClosure { .. }
1391        | Atomic::TConditional { .. }
1392        | Atomic::TIntersection { .. }
1393        | Atomic::TClassString(Some(_))
1394        | Atomic::TInterfaceString(Some(_)) => true,
1395        _ => false,
1396    }
1397}
1398
1399fn substitute_in_fn_param(
1400    p: &crate::atomic::FnParam,
1401    bindings: &FxHashMap<Name, Type>,
1402) -> crate::atomic::FnParam {
1403    crate::atomic::FnParam {
1404        name: p.name,
1405        ty: p.ty.as_ref().map(|t| {
1406            let u = t.to_union();
1407            let substituted = u.substitute_templates(bindings);
1408            crate::compact::SimpleType::from_union(substituted)
1409        }),
1410        out_ty: p.out_ty.as_ref().map(|t| {
1411            let u = t.to_union();
1412            let substituted = u.substitute_templates(bindings);
1413            crate::compact::SimpleType::from_union(substituted)
1414        }),
1415        default: p.default.as_ref().map(|d| {
1416            let u = d.to_union();
1417            let substituted = u.substitute_templates(bindings);
1418            crate::compact::SimpleType::from_union(substituted)
1419        }),
1420        is_variadic: p.is_variadic,
1421        is_byref: p.is_byref,
1422        is_optional: p.is_optional,
1423    }
1424}
1425
1426// ---------------------------------------------------------------------------
1427// Atomic subtype (no codebase — structural check only)
1428// ---------------------------------------------------------------------------
1429
1430/// Structural `sub <: sup` for a single atomic pair, without hierarchy resolution.
1431pub fn atomic_subtype(sub: &Atomic, sup: &Atomic) -> bool {
1432    if sub == sup {
1433        return true;
1434    }
1435    match (sub, sup) {
1436        // Bottom type
1437        (Atomic::TNever, _) => true,
1438        // Top types — anything goes in both directions for mixed
1439        (_, Atomic::TMixed) => true,
1440        (Atomic::TMixed, _) => true,
1441        // Template param in supertype position: any value satisfies an unconstrained
1442        // template (as_type = mixed), or a constrained one if it satisfies the bound.
1443        // This handles union bounds like `T of string|list<I>|array<K, V>` where
1444        // I/K/V are free template params — any type satisfies them structurally.
1445        (_, Atomic::TTemplateParam { as_type, .. }) => {
1446            as_type.is_mixed() || as_type.types.iter().any(|b| atomic_subtype(sub, b))
1447        }
1448
1449        // Scalars
1450        (Atomic::TLiteralInt(_), Atomic::TInt) => true,
1451        (Atomic::TLiteralInt(_), Atomic::TNumeric) => true,
1452        (Atomic::TLiteralInt(_), Atomic::TScalar) => true,
1453        (Atomic::TLiteralInt(n), Atomic::TPositiveInt) => *n > 0,
1454        (Atomic::TLiteralInt(n), Atomic::TNonNegativeInt) => *n >= 0,
1455        (Atomic::TLiteralInt(n), Atomic::TNegativeInt) => *n < 0,
1456        (Atomic::TPositiveInt, Atomic::TInt) => true,
1457        (Atomic::TPositiveInt, Atomic::TNonNegativeInt) => true,
1458        (Atomic::TPositiveInt, Atomic::TNumeric) => true,
1459        (Atomic::TPositiveInt, Atomic::TScalar) => true,
1460        (Atomic::TNegativeInt, Atomic::TInt) => true,
1461        (Atomic::TNegativeInt, Atomic::TNumeric) => true,
1462        (Atomic::TNegativeInt, Atomic::TScalar) => true,
1463        (Atomic::TNonNegativeInt, Atomic::TInt) => true,
1464        (Atomic::TNonNegativeInt, Atomic::TNumeric) => true,
1465        (Atomic::TNonNegativeInt, Atomic::TScalar) => true,
1466        (Atomic::TIntRange { .. }, Atomic::TInt) => true,
1467        (Atomic::TIntRange { .. }, Atomic::TNumeric) => true,
1468        (Atomic::TIntRange { .. }, Atomic::TScalar) => true,
1469        // positive-int is int<1, ∞>: subtype of int<sup_min, ∞> when sup_min <= 1
1470        (Atomic::TPositiveInt, Atomic::TIntRange { min, max }) => {
1471            max.is_none() && min.is_none_or(|m| m <= 1)
1472        }
1473        // negative-int is int<-∞, -1>: subtype of int<-∞, sup_max> when sup_max >= -1
1474        (Atomic::TNegativeInt, Atomic::TIntRange { min, max }) => {
1475            min.is_none() && max.is_none_or(|m| m >= -1)
1476        }
1477        // non-negative-int is int<0, ∞>: subtype of int<sup_min, ∞> when sup_min <= 0
1478        (Atomic::TNonNegativeInt, Atomic::TIntRange { min, max }) => {
1479            max.is_none() && min.is_none_or(|m| m <= 0)
1480        }
1481        // A bounded int range is a subtype of a named int subtype when every value fits
1482        (Atomic::TIntRange { min: sub_min, .. }, Atomic::TPositiveInt) => {
1483            sub_min.is_some_and(|lo| lo >= 1)
1484        }
1485        (Atomic::TIntRange { min: sub_min, .. }, Atomic::TNonNegativeInt) => {
1486            sub_min.is_some_and(|lo| lo >= 0)
1487        }
1488        (Atomic::TIntRange { max: sub_max, .. }, Atomic::TNegativeInt) => {
1489            sub_max.is_some_and(|hi| hi <= -1)
1490        }
1491        // int<sub_min, sub_max> <: int<sup_min, sup_max> when ranges nest
1492        (
1493            Atomic::TIntRange {
1494                min: sub_min,
1495                max: sub_max,
1496            },
1497            Atomic::TIntRange {
1498                min: sup_min,
1499                max: sup_max,
1500            },
1501        ) => {
1502            let lower_ok = match (sub_min, sup_min) {
1503                (_, None) => true,
1504                (None, Some(_)) => false,
1505                (Some(sl), Some(su)) => sl >= su,
1506            };
1507            let upper_ok = match (sub_max, sup_max) {
1508                (None, None) | (Some(_), None) => true,
1509                (None, Some(_)) => false,
1510                (Some(sl), Some(su)) => sl <= su,
1511            };
1512            lower_ok && upper_ok
1513        }
1514
1515        (Atomic::TLiteralFloat(..), Atomic::TFloat) => true,
1516        (Atomic::TLiteralFloat(..), Atomic::TNumeric) => true,
1517        (Atomic::TLiteralFloat(..), Atomic::TScalar) => true,
1518
1519        (Atomic::TLiteralString(s), Atomic::TString) => {
1520            let _ = s;
1521            true
1522        }
1523        (Atomic::TLiteralString(s), Atomic::TCallableString) => {
1524            let _ = s;
1525            true
1526        }
1527        (Atomic::TLiteralString(s), Atomic::TNonEmptyString) => !s.is_empty(),
1528        (Atomic::TLiteralString(s), Atomic::TNumericString) => s.parse::<f64>().is_ok(),
1529        // A literal string is type-compatible with class-string; validate_class_string_argument
1530        // separately checks whether the string names a real class (UndefinedClass).
1531        (Atomic::TLiteralString(_), Atomic::TClassString(_)) => true,
1532        // Same, for interface-string; validate_interface_string_argument checks existence
1533        // and that the name actually resolves to an interface.
1534        (Atomic::TLiteralString(_), Atomic::TInterfaceString(_)) => true,
1535        (Atomic::TLiteralString(_), Atomic::TScalar) => true,
1536        (Atomic::TNonEmptyString, Atomic::TString) => true,
1537        (Atomic::TCallableString, Atomic::TString) => true,
1538        // numeric-string is always non-empty (e.g. "42", "-1", "0.5") — "" is not numeric.
1539        (Atomic::TNumericString, Atomic::TNonEmptyString) => true,
1540        (Atomic::TNumericString, Atomic::TString) => true,
1541        (Atomic::TClassString(_), Atomic::TString) => true,
1542        (Atomic::TInterfaceString(_), Atomic::TString) => true,
1543        // Every interface-string is a valid class-string: PHP doesn't distinguish
1544        // the two at runtime — both are just strings naming a class-like symbol.
1545        // Instantiability (`new $x()`) is guarded separately, since an interface
1546        // name can never be `new`-ed even though it satisfies class-string.
1547        (Atomic::TInterfaceString(_), Atomic::TClassString(None)) => true,
1548        (Atomic::TInterfaceString(Some(a)), Atomic::TClassString(Some(b))) => a == b,
1549        (Atomic::TEnumString, Atomic::TString) => true,
1550        (Atomic::TTraitString, Atomic::TString) => true,
1551
1552        (Atomic::TTrue, Atomic::TBool) => true,
1553        (Atomic::TFalse, Atomic::TBool) => true,
1554
1555        (Atomic::TInt, Atomic::TNumeric) => true,
1556        (Atomic::TFloat, Atomic::TNumeric) => true,
1557        (Atomic::TIntegralFloat, Atomic::TNumeric) => true,
1558        (Atomic::TNumericString, Atomic::TNumeric) => true,
1559
1560        (Atomic::TInt, Atomic::TScalar) => true,
1561        (Atomic::TFloat, Atomic::TScalar) => true,
1562        (Atomic::TIntegralFloat, Atomic::TScalar) => true,
1563        (Atomic::TString, Atomic::TScalar) => true,
1564        (Atomic::TBool, Atomic::TScalar) => true,
1565        (Atomic::TNumeric, Atomic::TScalar) => true,
1566        (Atomic::TTrue, Atomic::TScalar) => true,
1567        (Atomic::TFalse, Atomic::TScalar) => true,
1568
1569        // Object hierarchy (structural, no codebase)
1570        (Atomic::TNamedObject { .. }, Atomic::TObject) => true,
1571        (Atomic::TStaticObject { .. }, Atomic::TObject) => true,
1572        (Atomic::TSelf { .. }, Atomic::TObject) => true,
1573        // self(X) and static(X) satisfy TNamedObject(X) with same FQCN
1574        (Atomic::TSelf { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
1575        (Atomic::TStaticObject { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
1576        // TNamedObject(X) satisfies self(X) / static(X) with same FQCN
1577        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TSelf { fqcn: b }) => a == b,
1578        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TStaticObject { fqcn: b }) => a == b,
1579        // Bare generic property accepts parameterized value: Box accepts Box<string>.
1580        // The reverse is NOT true — bare Box value does not satisfy Box<string> property
1581        // (invariant check). Only sup being bare (empty type_params) is the wildcard.
1582        (
1583            Atomic::TNamedObject {
1584                fqcn: sub_fqcn,
1585                type_params: sub_params,
1586            },
1587            Atomic::TNamedObject {
1588                fqcn: sup_fqcn,
1589                type_params: sup_params,
1590            },
1591        ) => {
1592            sub_fqcn == sup_fqcn
1593                && (sup_params.is_empty() || type_params_compatible(sub_params, sup_params))
1594        }
1595
1596        // TIntegralFloat is a subtype of float (all integral floats are floats)
1597        (Atomic::TIntegralFloat, Atomic::TFloat) => true,
1598
1599        // Literal int widens to float in PHP
1600        (Atomic::TLiteralInt(_), Atomic::TFloat) => true,
1601        (Atomic::TPositiveInt, Atomic::TFloat) => true,
1602        (Atomic::TNegativeInt, Atomic::TFloat) => true,
1603        (Atomic::TNonNegativeInt, Atomic::TFloat) => true,
1604        (Atomic::TInt, Atomic::TFloat) => true,
1605        (Atomic::TIntRange { .. }, Atomic::TFloat) => true,
1606
1607        // Literal int satisfies an int range only when the value is within bounds
1608        (Atomic::TLiteralInt(n), Atomic::TIntRange { min, max }) => {
1609            min.is_none_or(|lo| *n >= lo) && max.is_none_or(|hi| *n <= hi)
1610        }
1611
1612        // PHP callables: string and array are valid callable values
1613        (Atomic::TString, Atomic::TCallable { .. }) => true,
1614        (Atomic::TNonEmptyString, Atomic::TCallable { .. }) => true,
1615        (Atomic::TLiteralString(_), Atomic::TCallable { .. }) => true,
1616        (Atomic::TArray { .. }, Atomic::TCallable { .. }) => true,
1617        (Atomic::TNonEmptyArray { .. }, Atomic::TCallable { .. }) => true,
1618        (Atomic::TKeyedArray { .. }, Atomic::TCallable { .. }) => true,
1619
1620        // Closure <: callable, typed Closure <: Closure
1621        (Atomic::TClosure { .. }, Atomic::TCallable { .. }) => true,
1622        // callable <: Closure: callable is wider but not flagged at default error level
1623        (Atomic::TCallable { .. }, Atomic::TClosure { .. }) => true,
1624        // TClosure <: TClosure: check arity, per-parameter contravariance, and
1625        // return covariance for scalar/array-shaped types, where a purely
1626        // structural check is reliable. A named-class (or nested-callable)
1627        // param/return is skipped rather than checked — this checker has no
1628        // database access to walk `extends`/`implements`, so it can't safely
1629        // tell a real Liskov violation apart from a legitimate subclass/
1630        // superclass substitution; treating it as compatible avoids false
1631        // positives on that far more common case at the cost of missing the
1632        // narrower nominal-variance violation.
1633        (Atomic::TClosure { data: sub }, Atomic::TClosure { data: sup }) => {
1634            fn has_nominal_type(t: &Type) -> bool {
1635                t.types.iter().any(|a| {
1636                    matches!(
1637                        a,
1638                        Atomic::TNamedObject { .. }
1639                            | Atomic::TSelf { .. }
1640                            | Atomic::TStaticObject { .. }
1641                            | Atomic::TTemplateParam { .. }
1642                            | Atomic::TClosure { .. }
1643                            | Atomic::TCallable { .. }
1644                    )
1645                })
1646            }
1647            let sub_required = sub
1648                .params
1649                .iter()
1650                .filter(|p| !p.is_optional && !p.is_variadic)
1651                .count();
1652            if sub_required > sup.params.len() {
1653                false
1654            } else {
1655                let params_ok = sup.params.iter().enumerate().all(|(i, sup_param)| {
1656                    let Some(sub_param) = sub.params.get(i) else {
1657                        return true;
1658                    };
1659                    if sub_param.is_optional || sub_param.is_variadic {
1660                        return true;
1661                    }
1662                    let (Some(sub_ty), Some(sup_ty)) =
1663                        (sub_param.ty.as_ref(), sup_param.ty.as_ref())
1664                    else {
1665                        return true;
1666                    };
1667                    let (sub_u, sup_u) = (sub_ty.to_union(), sup_ty.to_union());
1668                    if has_nominal_type(&sub_u) || has_nominal_type(&sup_u) {
1669                        return true;
1670                    }
1671                    // Contravariance: whatever `sup` promises to pass must be
1672                    // acceptable to `sub`'s declared parameter type.
1673                    sup_u.is_subtype_structural(&sub_u)
1674                });
1675                params_ok
1676                    && (sub.return_type.is_mixed()
1677                        || sup.return_type.is_mixed()
1678                        || has_nominal_type(&sub.return_type)
1679                        || has_nominal_type(&sup.return_type)
1680                        || sub.return_type.is_subtype_structural(&sup.return_type))
1681            }
1682        }
1683        // callable <: callable (trivial)
1684        (Atomic::TCallable { .. }, Atomic::TCallable { .. }) => true,
1685        // TClosure satisfies `Closure` named object or `object`
1686        (Atomic::TClosure { .. }, Atomic::TNamedObject { fqcn, .. }) => {
1687            fqcn.as_ref().eq_ignore_ascii_case("closure")
1688        }
1689        (Atomic::TClosure { .. }, Atomic::TObject) => true,
1690        // bare `Closure` (named object without signature) satisfies any typed Closure(): T
1691        (Atomic::TNamedObject { fqcn, .. }, Atomic::TClosure { .. }) => {
1692            fqcn.as_ref().eq_ignore_ascii_case("closure")
1693        }
1694        // `Closure` named-object satisfies `callable`
1695        (Atomic::TNamedObject { fqcn, .. }, Atomic::TCallable { .. }) => {
1696            fqcn.as_ref().eq_ignore_ascii_case("closure")
1697        }
1698
1699        // A&B&C <: D&E iff every part of the supertype is satisfied by some
1700        // part of the subtype — an intersection with MORE conjuncts is the
1701        // more specific (sub)type, so `Countable&ArrayAccess&Iterator` is a
1702        // subtype of `Countable&ArrayAccess`. Purely structural (each part's
1703        // own `is_subtype_structural` recurses, so e.g. two differently-named
1704        // interfaces only match when equal — same conservative stance as the
1705        // TClosure<:TClosure arm above for named types).
1706        (
1707            Atomic::TIntersection { parts: sub_parts },
1708            Atomic::TIntersection { parts: sup_parts },
1709        ) => sup_parts.iter().all(|sup_part| {
1710            sub_parts
1711                .iter()
1712                .any(|sub_part| sub_part.is_subtype_structural(sup_part))
1713        }),
1714
1715        // List <: array  (list key is always int; int must satisfy the array's key type)
1716        (Atomic::TList { value }, Atomic::TArray { key, value: av }) => {
1717            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
1718        }
1719        (Atomic::TNonEmptyList { value }, Atomic::TArray { key, value: av }) => {
1720            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
1721        }
1722        (Atomic::TNonEmptyList { value }, Atomic::TNonEmptyArray { key, value: av }) => {
1723            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
1724        }
1725        (Atomic::TNonEmptyList { value }, Atomic::TList { value: lv }) => {
1726            value.is_subtype_structural(lv)
1727        }
1728        // array<int, X> is accepted where list<X> or non-empty-list<X> expected
1729        (Atomic::TArray { key, value: av }, Atomic::TList { value: lv }) => {
1730            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1731                && av.is_subtype_structural(lv)
1732        }
1733        (Atomic::TArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1734            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1735                && av.is_subtype_structural(lv)
1736        }
1737        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TList { value: lv }) => {
1738            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1739                && av.is_subtype_structural(lv)
1740        }
1741        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1742            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1743                && av.is_subtype_structural(lv)
1744        }
1745        // TList <: TList value covariance
1746        (Atomic::TList { value: v1 }, Atomic::TList { value: v2 }) => v1.is_subtype_structural(v2),
1747        (Atomic::TNonEmptyArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1748            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1749        }
1750
1751        // array<A, B> <: array<C, D>  iff  A <: C && B <: D
1752        (Atomic::TArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1753            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1754        }
1755
1756        // A keyed/shape array is a subtype of array<K, V> / non-empty-array<K, V>
1757        // when all property KEYS are subtypes of K. Value compatibility is checked
1758        // structurally only for scalar types; named-object values are deferred to
1759        // class-hierarchy checks in return_arrays_compatible (mir-analyzer).
1760        // Open shapes (is_open=true) may have extra unknown keys: keep permissive.
1761        (
1762            Atomic::TKeyedArray {
1763                properties,
1764                is_open,
1765                ..
1766            },
1767            Atomic::TArray { key, value },
1768        ) => {
1769            if *is_open {
1770                return true;
1771            }
1772            properties.iter().all(|(prop_key, prop)| {
1773                let key_atomic = match prop_key {
1774                    crate::atomic::ArrayKey::String(s) => Atomic::TLiteralString(s.clone()),
1775                    crate::atomic::ArrayKey::Int(n) => Atomic::TLiteralInt(*n),
1776                };
1777                if !Type::single(key_atomic).is_subtype_structural(key) {
1778                    return false; // key mismatch — definitively incompatible
1779                }
1780                // Named-object values require class-hierarchy checks not available here.
1781                let has_named_obj = prop.ty.types.iter().any(|a| {
1782                    matches!(
1783                        a,
1784                        Atomic::TNamedObject { .. }
1785                            | Atomic::TSelf { .. }
1786                            | Atomic::TStaticObject { .. }
1787                            | Atomic::TClosure { .. }
1788                            | Atomic::TTemplateParam { .. }
1789                    )
1790                });
1791                has_named_obj || prop.ty.is_subtype_structural(value)
1792            })
1793        }
1794        (
1795            Atomic::TKeyedArray {
1796                properties,
1797                is_open,
1798                ..
1799            },
1800            Atomic::TNonEmptyArray { key, value },
1801        ) => {
1802            if *is_open {
1803                return !properties.is_empty();
1804            }
1805            properties.iter().any(|(_, p)| !p.optional)
1806                && properties.iter().all(|(prop_key, prop)| {
1807                    let key_atomic = match prop_key {
1808                        crate::atomic::ArrayKey::String(s) => Atomic::TLiteralString(s.clone()),
1809                        crate::atomic::ArrayKey::Int(n) => Atomic::TLiteralInt(*n),
1810                    };
1811                    if !Type::single(key_atomic).is_subtype_structural(key) {
1812                        return false;
1813                    }
1814                    let has_named_obj = prop.ty.types.iter().any(|a| {
1815                        matches!(
1816                            a,
1817                            Atomic::TNamedObject { .. }
1818                                | Atomic::TSelf { .. }
1819                                | Atomic::TStaticObject { .. }
1820                                | Atomic::TClosure { .. }
1821                                | Atomic::TTemplateParam { .. }
1822                        )
1823                    });
1824                    has_named_obj || prop.ty.is_subtype_structural(value)
1825                })
1826        }
1827
1828        // A list-shaped keyed array (is_list=true, all int keys) is a subtype of list<X>.
1829        (
1830            Atomic::TKeyedArray {
1831                properties,
1832                is_list,
1833                ..
1834            },
1835            Atomic::TList { value: lv },
1836        ) => *is_list && properties.values().all(|p| p.ty.is_subtype_structural(lv)),
1837        (
1838            Atomic::TKeyedArray {
1839                properties,
1840                is_list,
1841                ..
1842            },
1843            Atomic::TNonEmptyList { value: lv },
1844        ) => {
1845            *is_list
1846                && !properties.is_empty()
1847                && properties.values().all(|p| p.ty.is_subtype_structural(lv))
1848        }
1849
1850        // Two shapes: every sup key must be satisfied (present+compatible, or
1851        // absent-but-optional/sub-open), and sub may not have keys sup doesn't
1852        // declare unless sup itself is open. Named-object values are deferred
1853        // to class-hierarchy checks, same as the TArray/TList sup arms above.
1854        (
1855            Atomic::TKeyedArray {
1856                properties: sub_props,
1857                is_open: sub_open,
1858                ..
1859            },
1860            Atomic::TKeyedArray {
1861                properties: sup_props,
1862                is_open: sup_open,
1863                ..
1864            },
1865        ) => {
1866            let keys_satisfied = sup_props
1867                .iter()
1868                .all(|(key, sup_prop)| match sub_props.get(key) {
1869                    Some(sub_prop) => {
1870                        let has_named_obj = sup_prop.ty.types.iter().any(|a| {
1871                            matches!(
1872                                a,
1873                                Atomic::TNamedObject { .. }
1874                                    | Atomic::TSelf { .. }
1875                                    | Atomic::TStaticObject { .. }
1876                                    | Atomic::TClosure { .. }
1877                                    | Atomic::TTemplateParam { .. }
1878                            )
1879                        });
1880                        has_named_obj || sub_prop.ty.is_subtype_structural(&sup_prop.ty)
1881                    }
1882                    None => sup_prop.optional || *sub_open,
1883                });
1884            let no_undeclared_extras =
1885                *sup_open || sub_props.keys().all(|k| sup_props.contains_key(k));
1886            keys_satisfied && no_undeclared_extras
1887        }
1888
1889        _ => false,
1890    }
1891}
1892
1893/// Whether each generic type-argument in `sub` is compatible with the
1894/// corresponding argument in `sup`. Arguments are invariant (require structural
1895/// equality) with one exception: an empty array literal (`array{}`) is accepted
1896/// against any array/list argument, so `new Box([])` — inferred as
1897/// `Box<array{}>` — satisfies a declared `Box<list<T>>` for any `T`.
1898fn type_params_compatible(sub: &[Type], sup: &[Type]) -> bool {
1899    if sub.len() != sup.len() {
1900        return false;
1901    }
1902    sub.iter()
1903        .zip(sup.iter())
1904        .all(|(a, b)| a == b || (is_empty_array_literal(a) && is_array_like(b)))
1905}
1906
1907/// True for a non-empty union whose atoms are all empty keyed arrays (`array{}`),
1908/// i.e. the type of an empty array literal `[]`.
1909fn is_empty_array_literal(t: &Type) -> bool {
1910    !t.types.is_empty()
1911        && t.types.iter().all(
1912            |atom| matches!(atom, Atomic::TKeyedArray { properties, .. } if properties.is_empty()),
1913        )
1914}
1915
1916/// True for a non-empty union whose atoms are all array/list types.
1917fn is_array_like(t: &Type) -> bool {
1918    !t.types.is_empty() && t.types.iter().all(|atom| atom.is_array())
1919}
1920
1921// ---------------------------------------------------------------------------
1922// Tests
1923// ---------------------------------------------------------------------------
1924
1925#[cfg(test)]
1926mod tests {
1927    use std::sync::Arc;
1928
1929    use super::*;
1930
1931    fn conditional(
1932        param_name: Option<Name>,
1933        subject: Type,
1934        if_true: Type,
1935        if_false: Type,
1936    ) -> Atomic {
1937        Atomic::TConditional {
1938            data: Box::new(crate::atomic::ConditionalData {
1939                param_name,
1940                subject,
1941                if_true,
1942                if_false,
1943            }),
1944        }
1945    }
1946
1947    #[test]
1948    fn single_is_single() {
1949        let u = Type::single(Atomic::TString);
1950        assert!(u.is_single());
1951        assert!(!u.is_nullable());
1952    }
1953
1954    #[test]
1955    fn nullable_has_null() {
1956        let u = Type::nullable(Atomic::TString);
1957        assert!(u.is_nullable());
1958        assert_eq!(u.types.len(), 2);
1959    }
1960
1961    #[test]
1962    fn add_type_deduplicates() {
1963        let mut u = Type::single(Atomic::TString);
1964        u.add_type(Atomic::TString);
1965        assert_eq!(u.types.len(), 1);
1966    }
1967
1968    #[test]
1969    fn array_key_is_int_string() {
1970        let k = Type::array_key();
1971        assert!(k.is_array_key());
1972        assert_eq!(k.types.len(), 2);
1973    }
1974
1975    #[test]
1976    fn is_array_key_false_for_plain_int() {
1977        assert!(!Type::int().is_array_key());
1978    }
1979
1980    #[test]
1981    fn is_array_key_false_for_mixed() {
1982        assert!(!Type::mixed().is_array_key());
1983    }
1984
1985    #[test]
1986    fn is_array_key_false_for_int_string_null() {
1987        let mut u = Type::array_key();
1988        u.add_type(Atomic::TNull);
1989        assert!(!u.is_array_key());
1990    }
1991
1992    #[test]
1993    fn add_type_literal_subsumed_by_base() {
1994        let mut u = Type::single(Atomic::TInt);
1995        u.add_type(Atomic::TLiteralInt(42));
1996        assert_eq!(u.types.len(), 1);
1997        assert!(matches!(u.types[0], Atomic::TInt));
1998    }
1999
2000    #[test]
2001    fn true_then_false_merges_to_bool() {
2002        let mut u = Type::single(Atomic::TTrue);
2003        u.add_type(Atomic::TFalse);
2004        assert_eq!(u.types.len(), 1);
2005        assert!(matches!(u.types[0], Atomic::TBool));
2006    }
2007
2008    #[test]
2009    fn false_then_true_merges_to_bool() {
2010        let mut u = Type::single(Atomic::TFalse);
2011        u.add_type(Atomic::TTrue);
2012        assert_eq!(u.types.len(), 1);
2013        assert!(matches!(u.types[0], Atomic::TBool));
2014    }
2015
2016    #[test]
2017    fn true_alone_stays_true() {
2018        let u = Type::single(Atomic::TTrue);
2019        assert_eq!(u.types.len(), 1);
2020        assert!(matches!(u.types[0], Atomic::TTrue));
2021    }
2022
2023    #[test]
2024    fn true_false_merge_preserves_other_union_members() {
2025        let mut u = Type::single(Atomic::TTrue);
2026        u.add_type(Atomic::TNull);
2027        u.add_type(Atomic::TFalse);
2028        assert_eq!(u.types.len(), 2);
2029        assert!(u.contains(|t| matches!(t, Atomic::TBool)));
2030        assert!(u.contains(|t| matches!(t, Atomic::TNull)));
2031    }
2032
2033    #[test]
2034    fn add_type_base_widens_literals() {
2035        let mut u = Type::single(Atomic::TLiteralInt(1));
2036        u.add_type(Atomic::TLiteralInt(2));
2037        u.add_type(Atomic::TInt);
2038        assert_eq!(u.types.len(), 1);
2039        assert!(matches!(u.types[0], Atomic::TInt));
2040    }
2041
2042    #[test]
2043    fn mixed_subsumes_everything() {
2044        let mut u = Type::single(Atomic::TString);
2045        u.add_type(Atomic::TMixed);
2046        assert_eq!(u.types.len(), 1);
2047        assert!(u.is_mixed());
2048    }
2049
2050    #[test]
2051    fn remove_null() {
2052        let u = Type::nullable(Atomic::TString);
2053        let narrowed = u.remove_null();
2054        assert!(!narrowed.is_nullable());
2055        assert_eq!(narrowed.types.len(), 1);
2056    }
2057
2058    #[test]
2059    fn narrow_to_truthy_removes_null_false() {
2060        let mut u = Type::empty();
2061        u.add_type(Atomic::TString);
2062        u.add_type(Atomic::TNull);
2063        u.add_type(Atomic::TFalse);
2064        let truthy = u.narrow_to_truthy();
2065        assert!(!truthy.is_nullable());
2066        assert!(!truthy.contains(|t| matches!(t, Atomic::TFalse)));
2067    }
2068
2069    #[test]
2070    fn merge_combines_types() {
2071        let a = Type::single(Atomic::TString);
2072        let b = Type::single(Atomic::TInt);
2073        let merged = Type::merge(&a, &b);
2074        assert_eq!(merged.types.len(), 2);
2075    }
2076
2077    #[test]
2078    fn intersect_keeps_narrower_side_not_self() {
2079        // int ∩ (1|2) must keep the narrower `1|2`, not the wider `int` —
2080        // this is exactly what a `match ($x) { 1, 2 => ... }` arm relies on
2081        // to narrow $x inside its body.
2082        let int_ty = Type::single(Atomic::TInt);
2083        let mut literals = Type::empty();
2084        literals.add_type(Atomic::TLiteralInt(1));
2085        literals.add_type(Atomic::TLiteralInt(2));
2086
2087        let narrowed = int_ty.intersect_with(&literals);
2088        assert_eq!(narrowed.types.len(), 2);
2089        assert!(narrowed.contains(|t| matches!(t, Atomic::TLiteralInt(1))));
2090        assert!(narrowed.contains(|t| matches!(t, Atomic::TLiteralInt(2))));
2091        assert!(!narrowed.contains(|t| matches!(t, Atomic::TInt)));
2092    }
2093
2094    #[test]
2095    fn subtype_literal_int_under_int() {
2096        let sub = Type::single(Atomic::TLiteralInt(5));
2097        let sup = Type::single(Atomic::TInt);
2098        assert!(sub.is_subtype_structural(&sup));
2099    }
2100
2101    #[test]
2102    fn subtype_never_is_bottom() {
2103        let never = Type::never();
2104        let string = Type::single(Atomic::TString);
2105        assert!(never.is_subtype_structural(&string));
2106    }
2107
2108    #[test]
2109    fn subtype_everything_under_mixed() {
2110        let string = Type::single(Atomic::TString);
2111        let mixed = Type::mixed();
2112        assert!(string.is_subtype_structural(&mixed));
2113    }
2114
2115    #[test]
2116    fn template_substitution() {
2117        let mut bindings = FxHashMap::default();
2118        bindings.insert(Name::new("T"), Type::single(Atomic::TString));
2119
2120        let tmpl = Type::single(Atomic::TTemplateParam {
2121            name: Name::new("T"),
2122            as_type: Box::new(Type::mixed()),
2123            defining_entity: Name::new("MyClass"),
2124        });
2125
2126        let resolved = tmpl.substitute_templates(&bindings);
2127        assert_eq!(resolved.types.len(), 1);
2128        assert!(matches!(resolved.types[0], Atomic::TString));
2129    }
2130
2131    #[test]
2132    fn intersection_is_object() {
2133        let parts = vec![
2134            Type::single(Atomic::TNamedObject {
2135                fqcn: Name::new("Iterator"),
2136                type_params: empty_type_params(),
2137            }),
2138            Type::single(Atomic::TNamedObject {
2139                fqcn: Name::new("Countable"),
2140                type_params: empty_type_params(),
2141            }),
2142        ];
2143        let atomic = Atomic::TIntersection {
2144            parts: vec_to_type_params(parts),
2145        };
2146        assert!(atomic.is_object());
2147        assert!(!atomic.can_be_falsy());
2148        assert!(atomic.can_be_truthy());
2149    }
2150
2151    #[test]
2152    fn intersection_display_two_parts() {
2153        let parts = vec![
2154            Type::single(Atomic::TNamedObject {
2155                fqcn: Name::new("Iterator"),
2156                type_params: empty_type_params(),
2157            }),
2158            Type::single(Atomic::TNamedObject {
2159                fqcn: Name::new("Countable"),
2160                type_params: empty_type_params(),
2161            }),
2162        ];
2163        let u = Type::single(Atomic::TIntersection {
2164            parts: vec_to_type_params(parts),
2165        });
2166        assert_eq!(format!("{u}"), "Iterator&Countable");
2167    }
2168
2169    #[test]
2170    fn intersection_display_three_parts() {
2171        let parts = vec![
2172            Type::single(Atomic::TNamedObject {
2173                fqcn: Name::new("A"),
2174                type_params: empty_type_params(),
2175            }),
2176            Type::single(Atomic::TNamedObject {
2177                fqcn: Name::new("B"),
2178                type_params: empty_type_params(),
2179            }),
2180            Type::single(Atomic::TNamedObject {
2181                fqcn: Name::new("C"),
2182                type_params: empty_type_params(),
2183            }),
2184        ];
2185        let u = Type::single(Atomic::TIntersection {
2186            parts: vec_to_type_params(parts),
2187        });
2188        assert_eq!(format!("{u}"), "A&B&C");
2189    }
2190
2191    #[test]
2192    fn intersection_in_nullable_union_display() {
2193        let intersection = Atomic::TIntersection {
2194            parts: vec_to_type_params(vec![
2195                Type::single(Atomic::TNamedObject {
2196                    fqcn: Name::new("Iterator"),
2197                    type_params: empty_type_params(),
2198                }),
2199                Type::single(Atomic::TNamedObject {
2200                    fqcn: Name::new("Countable"),
2201                    type_params: empty_type_params(),
2202                }),
2203            ]),
2204        };
2205        let mut u = Type::single(intersection);
2206        u.add_type(Atomic::TNull);
2207        assert!(u.is_nullable());
2208        assert!(u.contains(|t| matches!(t, Atomic::TIntersection { .. })));
2209    }
2210
2211    // --- substitute_templates coverage for previously-missing arms ----------
2212
2213    fn t_param(name: &str) -> Type {
2214        Type::single(Atomic::TTemplateParam {
2215            name: Name::new(name),
2216            as_type: Box::new(Type::mixed()),
2217            defining_entity: Name::new("Fn"),
2218        })
2219    }
2220
2221    fn bindings_t_string() -> FxHashMap<Name, Type> {
2222        let mut b = FxHashMap::default();
2223        b.insert(Name::new("T"), Type::single(Atomic::TString));
2224        b
2225    }
2226
2227    #[test]
2228    fn substitute_non_empty_array_key_and_value() {
2229        let ty = Type::single(Atomic::TNonEmptyArray {
2230            key: Box::new(t_param("T")),
2231            value: Box::new(t_param("T")),
2232        });
2233        let result = ty.substitute_templates(&bindings_t_string());
2234        assert_eq!(result.types.len(), 1);
2235        let Atomic::TNonEmptyArray { key, value } = &result.types[0] else {
2236            panic!("expected TNonEmptyArray");
2237        };
2238        assert!(matches!(key.types[0], Atomic::TString));
2239        assert!(matches!(value.types[0], Atomic::TString));
2240    }
2241
2242    #[test]
2243    fn substitute_non_empty_list_value() {
2244        let ty = Type::single(Atomic::TNonEmptyList {
2245            value: Box::new(t_param("T")),
2246        });
2247        let result = ty.substitute_templates(&bindings_t_string());
2248        let Atomic::TNonEmptyList { value } = &result.types[0] else {
2249            panic!("expected TNonEmptyList");
2250        };
2251        assert!(matches!(value.types[0], Atomic::TString));
2252    }
2253
2254    #[test]
2255    fn substitute_keyed_array_property_types() {
2256        use crate::atomic::{ArrayKey, KeyedProperty};
2257        use indexmap::IndexMap;
2258        let mut props = IndexMap::new();
2259        props.insert(
2260            ArrayKey::String(Arc::from("name")),
2261            KeyedProperty {
2262                ty: t_param("T"),
2263                optional: false,
2264            },
2265        );
2266        props.insert(
2267            ArrayKey::String(Arc::from("tag")),
2268            KeyedProperty {
2269                ty: t_param("T"),
2270                optional: true,
2271            },
2272        );
2273        let ty = Type::single(Atomic::TKeyedArray {
2274            properties: Box::new(props),
2275            is_open: true,
2276            is_list: false,
2277        });
2278        let result = ty.substitute_templates(&bindings_t_string());
2279        let Atomic::TKeyedArray {
2280            properties,
2281            is_open,
2282            is_list,
2283        } = &result.types[0]
2284        else {
2285            panic!("expected TKeyedArray");
2286        };
2287        assert!(is_open);
2288        assert!(!is_list);
2289        assert!(matches!(
2290            properties[&ArrayKey::String(Arc::from("name"))].ty.types[0],
2291            Atomic::TString
2292        ));
2293        assert!(properties[&ArrayKey::String(Arc::from("tag"))].optional);
2294        assert!(matches!(
2295            properties[&ArrayKey::String(Arc::from("tag"))].ty.types[0],
2296            Atomic::TString
2297        ));
2298    }
2299
2300    #[test]
2301    fn substitute_callable_params_and_return() {
2302        use crate::atomic::FnParam;
2303        let ty = Type::single(Atomic::TCallable {
2304            params: Some(Box::new([FnParam {
2305                name: Name::new("x"),
2306                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
2307                out_ty: None,
2308                default: None,
2309                is_variadic: false,
2310                is_byref: false,
2311                is_optional: false,
2312            }])),
2313            return_type: Some(Box::new(t_param("T"))),
2314        });
2315        let result = ty.substitute_templates(&bindings_t_string());
2316        let Atomic::TCallable {
2317            params,
2318            return_type,
2319        } = &result.types[0]
2320        else {
2321            panic!("expected TCallable");
2322        };
2323        let param_ty = params.as_ref().unwrap()[0].ty.as_ref().unwrap();
2324        let param_union = param_ty.to_union();
2325        assert!(matches!(param_union.types[0], Atomic::TString));
2326        let ret = return_type.as_ref().unwrap();
2327        assert!(matches!(ret.types[0], Atomic::TString));
2328    }
2329
2330    #[test]
2331    fn substitute_callable_bare_no_panic() {
2332        // callable with no params/return — must not panic and must pass through unchanged
2333        let ty = Type::single(Atomic::TCallable {
2334            params: None,
2335            return_type: None,
2336        });
2337        let result = ty.substitute_templates(&bindings_t_string());
2338        assert!(matches!(
2339            result.types[0],
2340            Atomic::TCallable {
2341                params: None,
2342                return_type: None
2343            }
2344        ));
2345    }
2346
2347    #[test]
2348    fn substitute_closure_params_return_and_this() {
2349        use crate::atomic::FnParam;
2350        let ty = Type::single(Atomic::TClosure {
2351            data: Box::new(crate::atomic::ClosureData {
2352                params: Box::new([FnParam {
2353                    name: Name::new("a"),
2354                    ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
2355                    out_ty: None,
2356                    default: Some(crate::compact::SimpleType::from_union(t_param("T"))),
2357                    is_variadic: true,
2358                    is_byref: true,
2359                    is_optional: true,
2360                }]),
2361                return_type: t_param("T"),
2362                this_type: Some(t_param("T")),
2363            }),
2364        });
2365        let result = ty.substitute_templates(&bindings_t_string());
2366        let Atomic::TClosure { data } = &result.types[0] else {
2367            panic!("expected TClosure");
2368        };
2369        let (params, return_type, this_type) = (&data.params, &data.return_type, &data.this_type);
2370        let p = &params[0];
2371        let ty_union = p.ty.as_ref().unwrap().to_union();
2372        let default_union = p.default.as_ref().unwrap().to_union();
2373        assert!(matches!(ty_union.types[0], Atomic::TString));
2374        assert!(matches!(default_union.types[0], Atomic::TString));
2375        // flags preserved
2376        assert!(p.is_variadic);
2377        assert!(p.is_byref);
2378        assert!(p.is_optional);
2379        assert!(matches!(return_type.types[0], Atomic::TString));
2380        assert!(matches!(
2381            this_type.as_ref().unwrap().types[0],
2382            Atomic::TString
2383        ));
2384    }
2385
2386    #[test]
2387    fn substitute_conditional_all_branches() {
2388        let ty = Type::single(conditional(
2389            None,
2390            t_param("T"),
2391            t_param("T"),
2392            Type::single(Atomic::TInt),
2393        ));
2394        let result = ty.substitute_templates(&bindings_t_string());
2395        let Atomic::TConditional { data } = &result.types[0] else {
2396            panic!("expected TConditional");
2397        };
2398        let (subject, if_true, if_false) = (&data.subject, &data.if_true, &data.if_false);
2399        assert!(matches!(subject.types[0], Atomic::TString));
2400        assert!(matches!(if_true.types[0], Atomic::TString));
2401        assert!(matches!(if_false.types[0], Atomic::TInt));
2402    }
2403
2404    #[test]
2405    fn resolve_conditional_is_null_non_null_arg() {
2406        let ty = Type::single(conditional(
2407            Some(Name::new("x")),
2408            Type::single(Atomic::TNull),
2409            Type::single(Atomic::TInt),
2410            Type::single(Atomic::TString),
2411        ));
2412        let result = ty.resolve_conditional_returns(|name| {
2413            if name == "x" {
2414                Some(Type::single(Atomic::TString)) // definitely not null
2415            } else {
2416                None
2417            }
2418        });
2419        assert!(result.types.len() == 1);
2420        assert!(matches!(result.types[0], Atomic::TString));
2421    }
2422
2423    #[test]
2424    fn resolve_conditional_is_null_null_arg() {
2425        let ty = Type::single(conditional(
2426            Some(Name::new("x")),
2427            Type::single(Atomic::TNull),
2428            Type::single(Atomic::TInt),
2429            Type::single(Atomic::TString),
2430        ));
2431        let result = ty.resolve_conditional_returns(|name| {
2432            if name == "x" {
2433                Some(Type::single(Atomic::TNull)) // definitely null
2434            } else {
2435                None
2436            }
2437        });
2438        assert!(result.types.len() == 1);
2439        assert!(matches!(result.types[0], Atomic::TInt));
2440    }
2441
2442    #[test]
2443    fn resolve_conditional_is_null_nullable_arg_widens_to_branch_union() {
2444        let mut nullable_str = Type::single(Atomic::TString);
2445        nullable_str.add_type(Atomic::TNull);
2446        let ty = Type::single(conditional(
2447            Some(Name::new("x")),
2448            Type::single(Atomic::TNull),
2449            Type::single(Atomic::TInt),
2450            Type::single(Atomic::TString),
2451        ));
2452        let result = ty.resolve_conditional_returns(|name| {
2453            if name == "x" {
2454                Some(nullable_str.clone())
2455            } else {
2456                None
2457            }
2458        });
2459        // uncertain discriminator → widen to if_true | if_false
2460        assert_eq!(result.types.len(), 2);
2461        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
2462        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
2463    }
2464
2465    #[test]
2466    fn resolve_conditional_nested_widens_inner_branch() {
2467        // ($x is null ? int : ($x is string ? string : float))
2468        // When $x is unknown, should widen to int|string|float (no TConditional remaining).
2469        let inner = Type::single(conditional(
2470            Some(Name::new("x")),
2471            Type::single(Atomic::TString),
2472            Type::single(Atomic::TString),
2473            Type::single(Atomic::TFloat),
2474        ));
2475        let ty = Type::single(conditional(
2476            Some(Name::new("x")),
2477            Type::single(Atomic::TNull),
2478            Type::single(Atomic::TInt),
2479            inner,
2480        ));
2481        // unknown arg → widen both outer branches, inner conditional must also be widened
2482        let result = ty.resolve_conditional_returns(|_| None);
2483        assert!(
2484            result
2485                .types
2486                .iter()
2487                .all(|t| !matches!(t, Atomic::TConditional { .. })),
2488            "no TConditional should survive: {:?}",
2489            result.types
2490        );
2491        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
2492        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
2493        assert!(result.types.iter().any(|t| matches!(t, Atomic::TFloat)));
2494    }
2495
2496    #[test]
2497    fn resolve_conditional_nested_resolves_inner_branch() {
2498        // ($x is null ? int : ($x is string ? string : float))
2499        // When $x is definitely not null but unknown string-or-not → resolves outer to inner,
2500        // then inner must also be resolved.
2501        let inner = Type::single(conditional(
2502            Some(Name::new("x")),
2503            Type::single(Atomic::TString),
2504            Type::single(Atomic::TString),
2505            Type::single(Atomic::TFloat),
2506        ));
2507        let ty = Type::single(conditional(
2508            Some(Name::new("x")),
2509            Type::single(Atomic::TNull),
2510            Type::single(Atomic::TInt),
2511            inner,
2512        ));
2513        // $x = string → outer: not null → if_false (inner); inner: is string → if_true = string
2514        let result = ty.resolve_conditional_returns(|name| {
2515            if name == "x" {
2516                Some(Type::single(Atomic::TString))
2517            } else {
2518                None
2519            }
2520        });
2521        assert!(
2522            result
2523                .types
2524                .iter()
2525                .all(|t| !matches!(t, Atomic::TConditional { .. })),
2526            "no TConditional should survive: {:?}",
2527            result.types
2528        );
2529        assert_eq!(result.types.len(), 1);
2530        assert!(matches!(result.types[0], Atomic::TString));
2531    }
2532
2533    #[test]
2534    fn substitute_intersection_parts() {
2535        let ty = Type::single(Atomic::TIntersection {
2536            parts: vec_to_type_params(vec![
2537                Type::single(Atomic::TNamedObject {
2538                    fqcn: Name::new("Countable"),
2539                    type_params: empty_type_params(),
2540                }),
2541                t_param("T"),
2542            ]),
2543        });
2544        let result = ty.substitute_templates(&bindings_t_string());
2545        let Atomic::TIntersection { parts } = &result.types[0] else {
2546            panic!("expected TIntersection");
2547        };
2548        assert_eq!(parts.len(), 2);
2549        assert!(matches!(parts[0].types[0], Atomic::TNamedObject { .. }));
2550        assert!(matches!(parts[1].types[0], Atomic::TString));
2551    }
2552
2553    #[test]
2554    fn substitute_no_template_params_identity() {
2555        let ty = Type::single(Atomic::TInt);
2556        let result = ty.substitute_templates(&bindings_t_string());
2557        assert!(matches!(result.types[0], Atomic::TInt));
2558    }
2559}