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