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    /// A `false`/`null` failure variant was deliberately stripped from this type
55    /// (e.g. `preg_split`'s regex-error `false`) because real code overwhelmingly
56    /// never checks for it. A defensive `=== false`/`=== null` check or a
57    /// `(string)`/`(array)` cast guarding against that exact stripped variant is
58    /// still legitimate, so the impossibility/redundancy checks (`ImpossibleIdenticalComparison`,
59    /// `RedundantCast`, the `=== null` narrowing divergence) exempt it instead of
60    /// flagging the caller's own defensive code.
61    pub falsy_stripped: bool,
62    /// This type was read from a generic array/list offset (`$arr[$key]`)
63    /// whose key presence isn't statically provable — PHP returns `null`
64    /// (with a warning) for a missing offset, so a defensive `=== null`/
65    /// `!== null` check against it is legitimate even though the inferred
66    /// value type itself doesn't literally include `null` (adding `null`
67    /// unconditionally to every generic-array read would drown real code in
68    /// `PossiblyNull*` noise for the overwhelmingly common case where the key
69    /// really is present). The impossibility/redundancy checks
70    /// (`ImpossibleIdenticalComparison`, the `=== null` narrowing divergence)
71    /// exempt it, mirroring `falsy_stripped`.
72    pub possibly_absent_offset: bool,
73}
74
75type IntBounds = (Option<i64>, Option<i64>);
76
77/// Inclusive `(min, max)` bounds of an int atomic; `None` bound is unbounded.
78fn int_bounds(a: &Atomic) -> Option<IntBounds> {
79    match a {
80        Atomic::TInt => Some((None, None)),
81        Atomic::TLiteralInt(n) => Some((Some(*n), Some(*n))),
82        Atomic::TPositiveInt => Some((Some(1), None)),
83        Atomic::TNonNegativeInt => Some((Some(0), None)),
84        Atomic::TNegativeInt => Some((None, Some(-1))),
85        Atomic::TIntRange { min, max } => Some((*min, *max)),
86        _ => None,
87    }
88}
89
90fn bounds_contain(outer: IntBounds, inner: IntBounds) -> bool {
91    let lo_ok = match (outer.0, inner.0) {
92        (None, _) => true,
93        (Some(o), Some(i)) => o <= i,
94        (Some(_), None) => false,
95    };
96    let hi_ok = match (outer.1, inner.1) {
97        (None, _) => true,
98        (Some(o), Some(i)) => o >= i,
99        (Some(_), None) => false,
100    };
101    lo_ok && hi_ok
102}
103
104impl Type {
105    // --- Constructors -------------------------------------------------------
106
107    pub fn empty() -> Self {
108        Self {
109            types: SmallVec::new(),
110            possibly_undefined: false,
111            from_docblock: false,
112            falsy_stripped: false,
113            possibly_absent_offset: false,
114        }
115    }
116
117    pub fn single(atomic: Atomic) -> Self {
118        let mut types = SmallVec::new();
119        types.push(atomic);
120        Self {
121            types,
122            possibly_undefined: false,
123            from_docblock: false,
124            falsy_stripped: false,
125            possibly_absent_offset: false,
126        }
127    }
128
129    pub fn mixed() -> Self {
130        Self::single(Atomic::TMixed)
131    }
132
133    pub fn void() -> Self {
134        Self::single(Atomic::TVoid)
135    }
136
137    pub fn never() -> Self {
138        Self::single(Atomic::TNever)
139    }
140
141    pub fn null() -> Self {
142        Self::single(Atomic::TNull)
143    }
144
145    pub fn bool() -> Self {
146        Self::single(Atomic::TBool)
147    }
148
149    pub fn int() -> Self {
150        Self::single(Atomic::TInt)
151    }
152
153    pub fn float() -> Self {
154        Self::single(Atomic::TFloat)
155    }
156
157    pub fn string() -> Self {
158        Self::single(Atomic::TString)
159    }
160
161    /// `int|string` — the canonical PHP array-key type, used as the default
162    /// key type when a docblock/inferred array has no more specific key.
163    pub fn array_key() -> Self {
164        let mut u = Self::single(Atomic::TInt);
165        u.add_type(Atomic::TString);
166        u
167    }
168
169    /// `T|null`
170    pub fn nullable(atomic: Atomic) -> Self {
171        // `mixed|null` = `mixed` — null is already included in mixed.
172        if matches!(atomic, Atomic::TMixed) {
173            return Self::mixed();
174        }
175        let mut types = SmallVec::new();
176        types.push(atomic);
177        types.push(Atomic::TNull);
178        Self {
179            types,
180            possibly_undefined: false,
181            from_docblock: false,
182            falsy_stripped: false,
183            possibly_absent_offset: false,
184        }
185    }
186
187    /// Build a union from multiple atomics, de-duplicating on the fly.
188    pub fn from_vec(atomics: Vec<Atomic>) -> Self {
189        let mut u = Self::empty();
190        for a in atomics {
191            u.add_type(a);
192        }
193        u
194    }
195
196    // --- Introspection -------------------------------------------------------
197
198    pub fn is_empty(&self) -> bool {
199        self.types.is_empty()
200    }
201
202    pub fn is_single(&self) -> bool {
203        self.types.len() == 1
204    }
205
206    pub fn is_nullable(&self) -> bool {
207        self.types.iter().any(|t| matches!(t, Atomic::TNull))
208    }
209
210    /// True when this is exactly `int|string` — the array-key domain, which
211    /// is already the maximal set of legal PHP array keys and so should be
212    /// treated like a "default"/unconstrained key, same as `mixed` would be
213    /// for a non-key type parameter.
214    pub fn is_array_key(&self) -> bool {
215        self.types.len() == 2
216            && self.types.iter().any(|t| matches!(t, Atomic::TInt))
217            && self.types.iter().any(|t| matches!(t, Atomic::TString))
218    }
219
220    pub fn is_mixed(&self) -> bool {
221        self.types.iter().any(|t| match t {
222            Atomic::TMixed => true,
223            Atomic::TTemplateParam { as_type, .. } => as_type.is_mixed(),
224            _ => false,
225        })
226    }
227
228    /// True only when the type contains `TMixed` atoms and no `TTemplateParam` atoms.
229    /// Unlike [`Self::is_mixed`], this does not treat an unconstrained template parameter as
230    /// "mixed" — a `T` placeholder is an intentionally parameterised type that will be
231    /// instantiated at the call site, so it must not trigger `MixedAssignment` warnings.
232    pub fn is_mixed_not_template(&self) -> bool {
233        self.is_mixed()
234            && !self
235                .types
236                .iter()
237                .any(|t| matches!(t, Atomic::TTemplateParam { .. }))
238    }
239
240    pub fn is_never(&self) -> bool {
241        self.types.iter().all(|t| matches!(t, Atomic::TNever)) && !self.types.is_empty()
242    }
243
244    /// Classify this type for `clone` validity. Recurses into template-param
245    /// bounds (like [`Type::is_mixed`]). Callers handle `mixed` separately.
246    pub fn clone_validity(&self) -> CloneValidity {
247        if self.types.is_empty() {
248            return CloneValidity::Unknown;
249        }
250        let mut has_non_object = false;
251        let mut has_other = false; // object or ambiguous (callable, mixed, conditional, …)
252        for t in &self.types {
253            match t {
254                Atomic::TTemplateParam { as_type, .. } => match as_type.clone_validity() {
255                    CloneValidity::Invalid => has_non_object = true,
256                    CloneValidity::PossiblyInvalid => {
257                        has_non_object = true;
258                        has_other = true;
259                    }
260                    CloneValidity::Cloneable | CloneValidity::Unknown => has_other = true,
261                },
262                other if other.is_definitely_non_object() => has_non_object = true,
263                _ => has_other = true,
264            }
265        }
266        match (has_non_object, has_other) {
267            (true, false) => CloneValidity::Invalid,
268            (true, true) => CloneValidity::PossiblyInvalid,
269            _ => CloneValidity::Cloneable,
270        }
271    }
272
273    pub fn is_void(&self) -> bool {
274        self.is_single() && matches!(self.types[0], Atomic::TVoid)
275    }
276
277    pub fn can_be_falsy(&self) -> bool {
278        self.types.iter().any(|t| t.can_be_falsy())
279    }
280
281    pub fn can_be_truthy(&self) -> bool {
282        self.types.iter().any(|t| t.can_be_truthy())
283    }
284
285    pub fn contains<F: Fn(&Atomic) -> bool>(&self, f: F) -> bool {
286        self.types.iter().any(f)
287    }
288
289    pub fn has_named_object(&self, fqcn: &str) -> bool {
290        self.types.iter().any(|t| match t {
291            Atomic::TNamedObject { fqcn: f, .. } => f.as_ref() == fqcn,
292            _ => false,
293        })
294    }
295
296    // --- Mutation ------------------------------------------------------------
297
298    /// Add an atomic to this union, skipping duplicates.
299    /// Subsumption rules: anything ⊆ TMixed; TLiteralInt ⊆ TInt; etc.
300    pub fn add_type(&mut self, atomic: Atomic) {
301        // If we already have TMixed, nothing to add.
302        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
303            return;
304        }
305
306        // Adding TMixed subsumes everything.
307        if matches!(atomic, Atomic::TMixed) {
308            self.types.clear();
309            self.types.push(Atomic::TMixed);
310            return;
311        }
312
313        // Simplify trivial conditional types: (X is ? T : T) → T
314        // Recursively simplify branches first so nested trivial conditionals collapse.
315        let atomic = if let Atomic::TConditional { data } = &atomic {
316            let (if_true, if_false) = (&data.if_true, &data.if_false);
317            let mut simplified_true = Type::empty();
318            for t in &if_true.types {
319                simplified_true.add_type(t.clone());
320            }
321            let mut simplified_false = Type::empty();
322            for t in &if_false.types {
323                simplified_false.add_type(t.clone());
324            }
325            if simplified_true == simplified_false {
326                for t in simplified_true.types {
327                    self.add_type(t);
328                }
329                return;
330            }
331            atomic
332        } else {
333            atomic
334        };
335
336        // Avoid exact duplicates.
337        if self.types.contains(&atomic) {
338            return;
339        }
340
341        // Int atomics absorb each other by bound containment (`5 | int<0, max>`
342        // → `int<0, max>`, `non-negative-int | int` → `int`).
343        if let Some(new_bounds) = int_bounds(&atomic) {
344            if self
345                .types
346                .iter()
347                .any(|t| int_bounds(t).is_some_and(|b| bounds_contain(b, new_bounds)))
348            {
349                return;
350            }
351            self.types
352                .retain(|t| !int_bounds(t).is_some_and(|b| bounds_contain(new_bounds, b)));
353        }
354        // TLiteralString(s) is subsumed by TString.
355        if let Atomic::TLiteralString(_) = &atomic {
356            if self.types.iter().any(|t| matches!(t, Atomic::TString)) {
357                return;
358            }
359        }
360        // TTrue / TFalse are subsumed by TBool.
361        if matches!(atomic, Atomic::TTrue | Atomic::TFalse)
362            && self.types.iter().any(|t| matches!(t, Atomic::TBool))
363        {
364            return;
365        }
366        // TTrue and TFalse together are exactly TBool — merge rather than
367        // keeping both literals once both are present.
368        if matches!(atomic, Atomic::TTrue) && self.types.iter().any(|t| matches!(t, Atomic::TFalse))
369        {
370            self.types.retain(|t| !matches!(t, Atomic::TFalse));
371            self.types.push(Atomic::TBool);
372            return;
373        }
374        if matches!(atomic, Atomic::TFalse) && self.types.iter().any(|t| matches!(t, Atomic::TTrue))
375        {
376            self.types.retain(|t| !matches!(t, Atomic::TTrue));
377            self.types.push(Atomic::TBool);
378            return;
379        }
380        // Adding TString widens away all TLiteralString variants.
381        if matches!(atomic, Atomic::TString) {
382            self.types
383                .retain(|t| !matches!(t, Atomic::TLiteralString(_)));
384        }
385        // Adding TBool widens away TTrue/TFalse.
386        if matches!(atomic, Atomic::TBool) {
387            self.types
388                .retain(|t| !matches!(t, Atomic::TTrue | Atomic::TFalse));
389        }
390
391        // TNever is the bottom type: T | never = T.
392        if matches!(atomic, Atomic::TNever) {
393            if !self.types.is_empty() {
394                return;
395            }
396        } else {
397            self.types.retain(|t| !matches!(t, Atomic::TNever));
398        }
399
400        // Closed empty keyed array (array{}) is a subtype of any generic array or
401        // list. Remove it if we already have a generic array<K,V> or list<V>. An
402        // OPEN empty shape (array{...}) carries real information — it may hold
403        // unknown, possibly non-list, extra keys at runtime — so it's not
404        // subsumed and must not be dropped.
405        if let Atomic::TKeyedArray {
406            properties,
407            is_open,
408            ..
409        } = &atomic
410        {
411            if properties.is_empty() && !is_open {
412                for existing in &self.types {
413                    match existing {
414                        Atomic::TArray { .. }
415                        | Atomic::TNonEmptyArray { .. }
416                        | Atomic::TList { .. }
417                        | Atomic::TNonEmptyList { .. } => {
418                            return; // Don't add empty array, it's subsumed
419                        }
420                        _ => {}
421                    }
422                }
423            }
424        }
425
426        // When adding a generic array or list, remove any CLOSED empty keyed
427        // arrays since they're subtypes (same reasoning as above, mirrored).
428        let is_generic_array_or_list = matches!(
429            &atomic,
430            Atomic::TArray { .. }
431                | Atomic::TNonEmptyArray { .. }
432                | Atomic::TList { .. }
433                | Atomic::TNonEmptyList { .. }
434        );
435        if is_generic_array_or_list {
436            self.types.retain(|t| {
437                if let Atomic::TKeyedArray {
438                    properties,
439                    is_open,
440                    ..
441                } = t
442                {
443                    !properties.is_empty() || *is_open
444                } else {
445                    true
446                }
447            });
448        }
449
450        self.types.push(atomic);
451    }
452
453    // --- Narrowing -----------------------------------------------------------
454
455    /// Remove `null` from the union (e.g. after a null check).
456    pub fn remove_null(&self) -> Type {
457        self.filter(|t| !matches!(t, Atomic::TNull))
458    }
459
460    /// Remove `false` from the union.
461    /// `TFalse` is dropped; `TBool` becomes `TTrue` since `bool - false = true`.
462    pub fn remove_false(&self) -> Type {
463        let mut result = self.filter(|t| !matches!(t, Atomic::TFalse | Atomic::TBool));
464        if self.types.iter().any(|t| matches!(t, Atomic::TBool)) {
465            result.add_type(Atomic::TTrue);
466        }
467        result
468    }
469
470    /// Remove `true` from the union.
471    /// `TTrue` is dropped; `TBool` becomes `TFalse` since `bool - true = false`.
472    pub fn remove_true(&self) -> Type {
473        let mut result = self.filter(|t| !matches!(t, Atomic::TTrue | Atomic::TBool));
474        if self.types.iter().any(|t| matches!(t, Atomic::TBool)) {
475            result.add_type(Atomic::TFalse);
476        }
477        result
478    }
479
480    /// Remove both `null` and `false` from the union (core type without nullable/falsy variants).
481    pub fn core_type(&self) -> Type {
482        self.remove_null().remove_false()
483    }
484
485    /// Keep only truthy atomics (e.g. after `if ($x)`).
486    pub fn narrow_to_truthy(&self) -> Type {
487        if self.is_mixed_not_template() {
488            return Type::mixed();
489        }
490        let mut result = Type::empty();
491        result.from_docblock = self.from_docblock;
492        for t in &self.types {
493            match t {
494                // An unconstrained/bounded template could resolve to anything at
495                // runtime, truthy or falsy — preserve it rather than dropping or
496                // widening it, the same way narrow_to_string/_int/etc. already do.
497                Atomic::TTemplateParam { .. } => result.add_type(t.clone()),
498                // Always-falsy — exclude entirely.
499                Atomic::TLiteralInt(0)
500                | Atomic::TLiteralFloat(0, 0)
501                | Atomic::TNull
502                | Atomic::TFalse => {}
503                Atomic::TLiteralString(s) if s.as_ref() == "" || s.as_ref() == "0" => {}
504                // bool contains both true (truthy) and false (falsy); truthy branch is true.
505                Atomic::TBool => result.add_type(Atomic::TTrue),
506                // array/list: empty ↔ falsy; truthy branch is non-empty-array/list.
507                Atomic::TArray { key, value } => result.add_type(Atomic::TNonEmptyArray {
508                    key: key.clone(),
509                    value: value.clone(),
510                }),
511                Atomic::TList { value } => result.add_type(Atomic::TNonEmptyList {
512                    value: value.clone(),
513                }),
514                // string: only "" and "0" are falsy; truthy branch is non-empty-string.
515                // non-empty-string still includes "0" (which is falsy) but that is the
516                // standard approximation used by Psalm and other analyzers.
517                Atomic::TString => result.add_type(Atomic::TNonEmptyString),
518                // numeric-string: "0" is the only falsy value; non-zero numerics are truthy.
519                // No named "non-zero numeric-string" type exists; keep as-is conservatively.
520                // int<0, max> only has 0 as its falsy value; truthy branch is int<1, max>.
521                // (int<0, 0> is handled by the can_be_truthy() false guard below.)
522                Atomic::TNonNegativeInt => result.add_type(Atomic::TPositiveInt),
523                Atomic::TIntRange { min: Some(0), max } if max.is_none_or(|m| m >= 1) => {
524                    let atom = if max.is_none() {
525                        Atomic::TPositiveInt
526                    } else {
527                        Atomic::TIntRange {
528                            min: Some(1),
529                            max: *max,
530                        }
531                    };
532                    result.add_type(atom);
533                }
534                // int<min, 0>: 0 is the only falsy value; truthy branch excludes it → int<min, -1>.
535                Atomic::TIntRange { min, max: Some(0) } => {
536                    let atom = match min {
537                        None => Atomic::TNegativeInt,
538                        Some(n) if *n <= -1 => Atomic::TIntRange {
539                            min: *min,
540                            max: Some(-1),
541                        },
542                        _ => continue, // min >= 0 with max == 0 → range is {0} — can_be_truthy() handles this
543                    };
544                    result.add_type(atom);
545                }
546                // Anything else that can never be truthy — drop.
547                t if !t.can_be_truthy() => {}
548                _ => result.add_type(t.clone()),
549            }
550        }
551        result
552    }
553
554    /// Keep only falsy atomics (e.g. after `if (!$x)`).
555    pub fn narrow_to_falsy(&self) -> Type {
556        if self.is_mixed_not_template() {
557            return Type::from_vec(vec![
558                Atomic::TNull,
559                Atomic::TFalse,
560                Atomic::TLiteralInt(0),
561                Atomic::TLiteralString("".into()),
562            ]);
563        }
564        let mut result = Type::empty();
565        result.from_docblock = self.from_docblock;
566        for t in &self.types {
567            match t {
568                // An unconstrained/bounded template could resolve to anything at
569                // runtime, truthy or falsy — preserve it rather than dropping it
570                // (its own `can_be_falsy()` conservatively defaults to `false`,
571                // which would otherwise wrongly exclude it here).
572                Atomic::TTemplateParam { .. } => result.add_type(t.clone()),
573                // bool: only false is falsy; falsy branch is false.
574                Atomic::TBool => result.add_type(Atomic::TFalse),
575                // int: only 0 is falsy.
576                Atomic::TInt => result.add_type(Atomic::TLiteralInt(0)),
577                // float: only 0.0 is falsy.
578                Atomic::TFloat => result.add_type(Atomic::TLiteralFloat(0, 0)),
579                // string: only "" and "0" are falsy.
580                Atomic::TString => {
581                    result.add_type(Atomic::TLiteralString("".into()));
582                    result.add_type(Atomic::TLiteralString("0".into()));
583                }
584                // numeric-string: only "0" is a falsy numeric string.
585                Atomic::TNumericString => result.add_type(Atomic::TLiteralString("0".into())),
586                // non-negative-int: only 0 is falsy.
587                Atomic::TNonNegativeInt => result.add_type(Atomic::TLiteralInt(0)),
588                // int<0, hi>: only 0 is falsy.
589                Atomic::TIntRange {
590                    min: Some(0),
591                    max: Some(_) | None,
592                } => result.add_type(Atomic::TLiteralInt(0)),
593                // int<min, 0>: only 0 is falsy.
594                Atomic::TIntRange { max: Some(0), .. } => result.add_type(Atomic::TLiteralInt(0)),
595                t if !t.can_be_falsy() => {} // always truthy — exclude
596                _ => result.add_type(t.clone()),
597            }
598        }
599        result
600    }
601
602    /// Narrow this type as if `$x instanceof ClassName` is true.
603    ///
604    /// The instanceof check guarantees the value IS an instance of `class`, so we
605    /// replace any object / mixed constituents with the specific named object.  Scalar
606    /// constituents are dropped (they can never satisfy instanceof).
607    pub fn narrow_instanceof(&self, class: &str) -> Type {
608        let narrowed_ty = Atomic::TNamedObject {
609            fqcn: class.into(),
610            type_params: empty_type_params(),
611        };
612        // If any constituent is an object-like type, the result is the specific class.
613        let has_object = self.types.iter().any(|t| {
614            matches!(
615                t,
616                Atomic::TObject | Atomic::TNamedObject { .. } | Atomic::TMixed | Atomic::TNull // null fails instanceof, but mixed/object may include null
617            )
618        });
619        if has_object || self.is_empty() {
620            Type::single(narrowed_ty)
621        } else {
622            // Pure scalars — instanceof is always false here, but return the class
623            // defensively so callers don't see an empty union.
624            Type::single(narrowed_ty)
625        }
626    }
627
628    /// Narrow as if `is_string($x)` is true. `mixed`/`scalar` become a concrete
629    /// `string` (rather than staying `mixed`) so downstream string-only
630    /// operations see a usable type instead of reporting `Mixed*`.
631    pub fn narrow_to_string(&self) -> Type {
632        self.filter_replacing(
633            |t| t.is_string() || matches!(t, Atomic::TTemplateParam { .. }),
634            |t| matches!(t, Atomic::TMixed | Atomic::TScalar),
635            Atomic::TString,
636        )
637    }
638
639    /// Narrow as if `is_int($x)` is true.
640    pub fn narrow_to_int(&self) -> Type {
641        self.filter_replacing(
642            |t| t.is_int() || matches!(t, Atomic::TTemplateParam { .. }),
643            |t| matches!(t, Atomic::TMixed | Atomic::TScalar | Atomic::TNumeric),
644            Atomic::TInt,
645        )
646    }
647
648    /// Narrow as if `is_float($x)` is true.
649    pub fn narrow_to_float(&self) -> Type {
650        self.filter_replacing(
651            |t| {
652                matches!(
653                    t,
654                    Atomic::TFloat
655                        | Atomic::TIntegralFloat
656                        | Atomic::TLiteralFloat(..)
657                        | Atomic::TTemplateParam { .. }
658                )
659            },
660            |t| matches!(t, Atomic::TMixed | Atomic::TScalar | Atomic::TNumeric),
661            Atomic::TFloat,
662        )
663    }
664
665    /// Narrow as if `is_bool($x)` is true.
666    pub fn narrow_to_bool(&self) -> Type {
667        self.filter_replacing(
668            |t| {
669                matches!(
670                    t,
671                    Atomic::TBool | Atomic::TTrue | Atomic::TFalse | Atomic::TTemplateParam { .. }
672                )
673            },
674            |t| matches!(t, Atomic::TMixed | Atomic::TScalar),
675            Atomic::TBool,
676        )
677    }
678
679    /// Narrow as if `is_null($x)` is true.
680    pub fn narrow_to_null(&self) -> Type {
681        self.filter_replacing(
682            |t| matches!(t, Atomic::TNull | Atomic::TTemplateParam { .. }),
683            |t| matches!(t, Atomic::TMixed),
684            Atomic::TNull,
685        )
686    }
687
688    /// Narrow as if `is_array($x)` is true.
689    pub fn narrow_to_array(&self) -> Type {
690        self.filter_replacing(
691            |t| t.is_array() || matches!(t, Atomic::TTemplateParam { .. }),
692            |t| matches!(t, Atomic::TMixed),
693            Atomic::TArray {
694                key: Box::new(Type::mixed()),
695                value: Box::new(Type::mixed()),
696            },
697        )
698    }
699
700    /// Keeps the outer collection kind but turns every nested `non-empty-list`/`non-empty-array`
701    /// into `list`/`array`, for comparisons that ignore element emptiness.
702    pub fn widen_nested_non_empty(&self) -> Type {
703        fn relax_all(ty: &Type) -> Type {
704            map_atoms(ty, |a| match a {
705                Atomic::TNonEmptyList { value } => Atomic::TList {
706                    value: Box::new(relax_all(value)),
707                },
708                Atomic::TNonEmptyArray { key, value } => Atomic::TArray {
709                    key: key.clone(),
710                    value: Box::new(relax_all(value)),
711                },
712                other => map_values(other, relax_all),
713            })
714        }
715        fn map_values(atomic: &Atomic, f: fn(&Type) -> Type) -> Atomic {
716            match atomic {
717                Atomic::TArray { key, value } => Atomic::TArray {
718                    key: key.clone(),
719                    value: Box::new(f(value)),
720                },
721                Atomic::TList { value } => Atomic::TList {
722                    value: Box::new(f(value)),
723                },
724                Atomic::TNonEmptyArray { key, value } => Atomic::TNonEmptyArray {
725                    key: key.clone(),
726                    value: Box::new(f(value)),
727                },
728                Atomic::TNonEmptyList { value } => Atomic::TNonEmptyList {
729                    value: Box::new(f(value)),
730                },
731                Atomic::TKeyedArray {
732                    properties,
733                    is_open,
734                    is_list,
735                } => Atomic::TKeyedArray {
736                    properties: Box::new(
737                        properties
738                            .iter()
739                            .map(|(k, p)| {
740                                (
741                                    k.clone(),
742                                    crate::atomic::KeyedProperty {
743                                        ty: f(&p.ty),
744                                        optional: p.optional,
745                                    },
746                                )
747                            })
748                            .collect(),
749                    ),
750                    is_open: *is_open,
751                    is_list: *is_list,
752                },
753                other => other.clone(),
754            }
755        }
756        fn map_atoms(ty: &Type, f: impl Fn(&Atomic) -> Atomic) -> Type {
757            let mut result = Type::empty();
758            result.possibly_undefined = ty.possibly_undefined;
759            result.from_docblock = ty.from_docblock;
760            for atomic in &ty.types {
761                result.add_type(f(atomic));
762            }
763            result
764        }
765        map_atoms(self, |a| map_values(a, relax_all))
766    }
767
768    /// Narrow array/list types to their non-empty variants (for `count() > 0` etc.),
769    /// dropping the closed empty shape `array{}`.
770    pub fn narrow_to_non_empty_collection(&self) -> Type {
771        let mut out = Type::empty();
772        out.from_docblock = self.from_docblock;
773        for t in &self.types {
774            match t {
775                Atomic::TArray { key, value } => out.add_type(Atomic::TNonEmptyArray {
776                    key: key.clone(),
777                    value: value.clone(),
778                }),
779                Atomic::TList { value } => out.add_type(Atomic::TNonEmptyList {
780                    value: value.clone(),
781                }),
782                Atomic::TKeyedArray {
783                    properties,
784                    is_open: false,
785                    ..
786                } if properties.is_empty() => {}
787                _ => out.add_type(t.clone()),
788            }
789        }
790        out
791    }
792
793    /// Narrow array/list types when proven empty (e.g. `array_key_first($x) === null`,
794    /// `$arr === []`). Drops the non-empty variants outright — they can never
795    /// be empty — and narrows a plain `array`/`list` down to the same closed,
796    /// zero-property `TKeyedArray` an empty `[]` literal itself types as, so
797    /// e.g. `$values[0]` on a proven-empty branch is flagged as a
798    /// `NonExistentArrayOffset` instead of silently keeping the pre-narrow
799    /// element type. `TKeyedArray` atoms are left unchanged — narrowing an
800    /// already-shaped array to "empty" when it may declare required
801    /// properties is a separate, more nuanced case.
802    pub fn narrow_to_empty_collection(&self) -> Type {
803        let mut out = Type::empty();
804        out.from_docblock = self.from_docblock;
805        for t in &self.types {
806            match t {
807                Atomic::TNonEmptyArray { .. } | Atomic::TNonEmptyList { .. } => {}
808                Atomic::TArray { .. } | Atomic::TList { .. } => {
809                    out.add_type(Atomic::TKeyedArray {
810                        properties: Box::default(),
811                        is_open: false,
812                        is_list: true,
813                    });
814                }
815                _ => out.add_type(t.clone()),
816            }
817        }
818        out
819    }
820
821    /// Narrow as if `array_is_list($x)` is true.
822    /// Lists have sequential integer keys starting from 0, so:
823    /// - `list<T>` / `non-empty-list<T>` are kept unchanged.
824    /// - `array<int, T>` is narrowed to `list<T>` (could be sequential).
825    /// - `non-empty-array<int, T>` is narrowed to `non-empty-list<T>`.
826    /// - `TKeyedArray` (shape) is kept only when its own `is_list` flag is
827    ///   already true — that flag is precise (set from the actual literal's
828    ///   keys, or an explicit `list{...}` docblock), not a hint, so a
829    ///   string-keyed or non-contiguous shape is correctly excluded.
830    /// - `mixed` becomes `list<mixed>` (array_is_list implies array).
831    /// - All other types (string-keyed arrays, non-arrays) are dropped.
832    pub fn narrow_to_list(&self) -> Type {
833        let mut out = Type::empty();
834        out.from_docblock = self.from_docblock;
835        for t in &self.types {
836            match t {
837                Atomic::TList { .. } | Atomic::TNonEmptyList { .. } => out.add_type(t.clone()),
838                // Guard on "key admits int" rather than "key is exactly TInt" —
839                // `is_array($mixed)` narrows an unknown key to `Type::mixed()`,
840                // which is a list candidate (the runtime shape is still unknown),
841                // unlike a key statically known to exclude int entirely (e.g. a
842                // docblock-declared `array<string, T>`), which must stay excluded.
843                Atomic::TArray { key, value } if !key.narrow_to_int().is_empty() => {
844                    out.add_type(Atomic::TList {
845                        value: value.clone(),
846                    });
847                }
848                Atomic::TNonEmptyArray { key, value } if !key.narrow_to_int().is_empty() => {
849                    out.add_type(Atomic::TNonEmptyList {
850                        value: value.clone(),
851                    });
852                }
853                Atomic::TKeyedArray { is_list: true, .. } => out.add_type(t.clone()),
854                Atomic::TMixed => out.add_type(Atomic::TList {
855                    value: Box::new(Type::mixed()),
856                }),
857                _ => {}
858            }
859        }
860        if out.is_empty() {
861            self.filter(|t| matches!(t, Atomic::TList { .. } | Atomic::TNonEmptyList { .. }))
862        } else {
863            out
864        }
865    }
866
867    /// Narrow as if `is_object($x)` is true. A `mixed` becomes a concrete bare
868    /// `object` (rather than staying `mixed`) so downstream object-only
869    /// operations — `clone`, `instanceof`, method calls — see an object type
870    /// instead of reporting `Mixed*`.
871    pub fn narrow_to_object(&self) -> Type {
872        let mut out = Type::empty();
873        for t in &self.types {
874            if matches!(t, Atomic::TMixed) {
875                out.add_type(Atomic::TObject);
876            } else if t.is_object() || matches!(t, Atomic::TTemplateParam { .. }) {
877                out.add_type(t.clone());
878            }
879        }
880        if out.types.is_empty() {
881            self.filter(|t| t.is_object())
882        } else {
883            out
884        }
885    }
886
887    /// Narrow as if `is_callable($x)` is true.
888    ///
889    /// PHP accepts closures, TCallable, strings (function names), arrays
890    /// (['Class', 'method'] or [$obj, 'method']), and objects with __invoke.
891    /// Keep all of these; only drop atoms that are definitely not callable
892    /// (scalars, null, bool, etc.).
893    pub fn narrow_to_callable(&self) -> Type {
894        let narrowed = self.filter(|t| {
895            t.is_callable()
896                || t.is_string()
897                || t.is_array()
898                || t.is_object()
899                || matches!(t, Atomic::TMixed | Atomic::TTemplateParam { .. })
900        });
901        // A bare `object` carries no known `__invoke` signature to compare
902        // against a `callable`-typed target, so it stayed an object rather
903        // than satisfying one — represent it as a generic callable instead,
904        // matching what `is_callable()` actually proved. A `TNamedObject`
905        // keeps its own class (its real `__invoke`, if any, is more precise
906        // than a generic callable).
907        let mut result = Type::empty();
908        result.possibly_undefined = narrowed.possibly_undefined;
909        result.from_docblock = narrowed.from_docblock;
910        for atomic in narrowed.types {
911            if matches!(atomic, Atomic::TObject) {
912                result.add_type(Atomic::TCallable {
913                    params: None,
914                    return_type: None,
915                });
916            } else {
917                result.add_type(atomic);
918            }
919        }
920        result
921    }
922
923    /// Narrow as if `is_scalar($x)` is true (int | string | float | bool).
924    pub fn narrow_to_scalar(&self) -> Type {
925        self.filter_replacing(
926            |t| {
927                t.is_string()
928                    || t.is_int()
929                    || matches!(
930                        t,
931                        Atomic::TFloat
932                            | Atomic::TIntegralFloat
933                            | Atomic::TLiteralFloat(..)
934                            | Atomic::TBool
935                            | Atomic::TTrue
936                            | Atomic::TFalse
937                            | Atomic::TScalar
938                            | Atomic::TNumeric
939                            | Atomic::TNumericString
940                            | Atomic::TTemplateParam { .. }
941                    )
942            },
943            |t| matches!(t, Atomic::TMixed),
944            Atomic::TScalar,
945        )
946    }
947
948    /// Narrow as if `is_iterable($x)` is true (array | Traversable).
949    /// For simplicity, this narrows to arrays or objects (can't easily verify interfaces).
950    pub fn narrow_to_iterable(&self) -> Type {
951        self.filter(|t| {
952            t.is_array()
953                || t.is_object()
954                || matches!(t, Atomic::TMixed | Atomic::TTemplateParam { .. })
955        })
956    }
957
958    /// Narrow as if `is_countable($x)` is true (array | Countable).
959    /// For simplicity, this narrows to arrays or objects (can't easily verify Countable interface).
960    pub fn narrow_to_countable(&self) -> Type {
961        self.filter(|t| {
962            t.is_array()
963                || t.is_object()
964                || matches!(t, Atomic::TMixed | Atomic::TTemplateParam { .. })
965        })
966    }
967
968    /// Narrow as if `is_resource($x)` is true.
969    /// Note: No TResource atomic type exists in the type system; this is a no-op.
970    /// Resources are declining in modern PHP and not actively tracked.
971    pub fn narrow_to_resource(&self) -> Type {
972        // No resource type in the system; just return mixed (allows any type)
973        self.filter(|t| matches!(t, Atomic::TMixed))
974    }
975
976    /// Narrow as if `class_exists($x)` returned true for a string variable.
977    /// String atoms become `class-string`; existing class-string atoms pass through;
978    /// mixed/scalar becomes `class-string`. Non-string atoms are dropped (returning
979    /// empty so the caller can mark the branch as diverging).
980    pub fn narrow_to_class_string(&self) -> Type {
981        let mut out = Type::empty();
982        out.from_docblock = self.from_docblock;
983        for t in &self.types {
984            match t {
985                Atomic::TClassString(_) => out.add_type(t.clone()),
986                _ if t.is_string() || matches!(t, Atomic::TMixed | Atomic::TScalar) => {
987                    out.add_type(Atomic::TClassString(None));
988                }
989                _ => {}
990            }
991        }
992        out
993    }
994
995    /// Narrow as if `interface_exists($x)` returned true for a string variable.
996    /// String atoms become `interface-string`; existing interface-string atoms pass
997    /// through; mixed/scalar becomes `interface-string`. Non-string atoms are dropped
998    /// (returning empty so the caller can mark the branch as diverging).
999    pub fn narrow_to_interface_string(&self) -> Type {
1000        let mut out = Type::empty();
1001        out.from_docblock = self.from_docblock;
1002        for t in &self.types {
1003            match t {
1004                Atomic::TInterfaceString(_) => out.add_type(t.clone()),
1005                // A known class-string keeps its name — every interface-string is
1006                // also a valid class-string, so `interface_exists()` returning true
1007                // narrows the atom without losing which class it names.
1008                Atomic::TClassString(name) => {
1009                    out.add_type(Atomic::TInterfaceString(*name));
1010                }
1011                _ if t.is_string() || matches!(t, Atomic::TMixed | Atomic::TScalar) => {
1012                    out.add_type(Atomic::TInterfaceString(None));
1013                }
1014                _ => {}
1015            }
1016        }
1017        out
1018    }
1019
1020    // --- Merge (branch join) ------------------------------------------------
1021
1022    /// Merge two unions at a branch join point (e.g. after if/else).
1023    /// The result is the union of all types in both.
1024    pub fn merge(a: &Type, b: &Type) -> Type {
1025        // Fast path: b is empty — nothing to add.
1026        if b.types.is_empty() {
1027            let mut result = a.clone();
1028            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
1029            return result;
1030        }
1031        // Fast path: a is empty — clone b.
1032        if a.types.is_empty() {
1033            let mut result = b.clone();
1034            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
1035            return result;
1036        }
1037        // Fast path: a is already mixed — b cannot widen it further.
1038        if a.types.len() == 1 && matches!(a.types[0], Atomic::TMixed) {
1039            let mut result = a.clone();
1040            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
1041            return result;
1042        }
1043        // Fast path: b contains mixed — result collapses to mixed.
1044        if b.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
1045            return Type {
1046                types: smallvec::smallvec![Atomic::TMixed],
1047                possibly_undefined: a.possibly_undefined || b.possibly_undefined,
1048                from_docblock: a.from_docblock || b.from_docblock,
1049                falsy_stripped: false,
1050                possibly_absent_offset: false,
1051            };
1052        }
1053        let mut result = a.clone();
1054        result.merge_with(b);
1055        result
1056    }
1057
1058    /// Merge `other` into `self` in-place (avoids cloning `self`).
1059    pub fn merge_with(&mut self, other: &Type) {
1060        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
1061            self.possibly_undefined |= other.possibly_undefined;
1062            return;
1063        }
1064        if other.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
1065            self.types.clear();
1066            self.types.push(Atomic::TMixed);
1067            self.possibly_undefined |= other.possibly_undefined;
1068            return;
1069        }
1070        for atomic in &other.types {
1071            self.add_type(atomic.clone());
1072        }
1073        self.possibly_undefined |= other.possibly_undefined;
1074    }
1075
1076    /// Intersect with another union: keep only types present in `other`, widening
1077    /// where `self` contains `mixed` (which is compatible with everything).
1078    /// Used for match-arm subject narrowing.
1079    pub fn intersect_with(&self, other: &Type) -> Type {
1080        if self.is_mixed() {
1081            return other.clone();
1082        }
1083        if other.is_mixed() {
1084            return self.clone();
1085        }
1086        // Keep the more specific of each overlapping (self, other) atomic
1087        // pair — e.g. intersecting `int` with `1|2` must keep `1|2` (the
1088        // narrower side), not `int` (self's own, wider atomic): the whole
1089        // point of narrowing a variable against a match/switch arm's
1090        // literal conditions is to end up with the literal, not the bare
1091        // declared type it already had. Every matching pair is kept (not
1092        // just the first), so `int ∩ (1|2)` keeps both `1` and `2`.
1093        let mut result = Type::empty();
1094        for a in &self.types {
1095            for b in &other.types {
1096                if a == b {
1097                    result.add_type(a.clone());
1098                } else if atomic_subtype(b, a) {
1099                    result.add_type(b.clone());
1100                } else if atomic_subtype(a, b) {
1101                    result.add_type(a.clone());
1102                }
1103            }
1104        }
1105        if result.is_empty() {
1106            Type::never()
1107        } else {
1108            result
1109        }
1110    }
1111
1112    // --- Template substitution ----------------------------------------------
1113
1114    /// Replace template param references with their resolved types.
1115    pub fn substitute_templates(&self, bindings: &FxHashMap<Name, Type>) -> Type {
1116        if bindings.is_empty() {
1117            return self.clone();
1118        }
1119        // Most argument/return types are plain scalars or resolved objects
1120        // with nothing to substitute — skip the rebuild entirely.
1121        if !self.types.iter().any(atomic_may_contain_templates) {
1122            return self.clone();
1123        }
1124        let mut result = Type::empty();
1125        result.possibly_undefined = self.possibly_undefined;
1126        result.from_docblock = self.from_docblock;
1127        for atomic in &self.types {
1128            match atomic {
1129                Atomic::TTemplateParam { name, .. } => {
1130                    if let Some(resolved) = bindings.get(name) {
1131                        for t in &resolved.types {
1132                            result.add_type(t.clone());
1133                        }
1134                    } else {
1135                        result.add_type(atomic.clone());
1136                    }
1137                }
1138                Atomic::TArray { key, value } => {
1139                    result.add_type(Atomic::TArray {
1140                        key: Box::new(key.substitute_templates(bindings)),
1141                        value: Box::new(value.substitute_templates(bindings)),
1142                    });
1143                }
1144                Atomic::TList { value } => {
1145                    result.add_type(Atomic::TList {
1146                        value: Box::new(value.substitute_templates(bindings)),
1147                    });
1148                }
1149                Atomic::TNonEmptyArray { key, value } => {
1150                    result.add_type(Atomic::TNonEmptyArray {
1151                        key: Box::new(key.substitute_templates(bindings)),
1152                        value: Box::new(value.substitute_templates(bindings)),
1153                    });
1154                }
1155                Atomic::TNonEmptyList { value } => {
1156                    result.add_type(Atomic::TNonEmptyList {
1157                        value: Box::new(value.substitute_templates(bindings)),
1158                    });
1159                }
1160                Atomic::TKeyedArray {
1161                    properties,
1162                    is_open,
1163                    is_list,
1164                } => {
1165                    use crate::atomic::KeyedProperty;
1166                    let new_props = properties
1167                        .iter()
1168                        .map(|(k, prop)| {
1169                            (
1170                                k.clone(),
1171                                KeyedProperty {
1172                                    ty: prop.ty.substitute_templates(bindings),
1173                                    optional: prop.optional,
1174                                },
1175                            )
1176                        })
1177                        .collect();
1178                    result.add_type(Atomic::TKeyedArray {
1179                        properties: Box::new(new_props),
1180                        is_open: *is_open,
1181                        is_list: *is_list,
1182                    });
1183                }
1184                Atomic::TCallable {
1185                    params,
1186                    return_type,
1187                } => {
1188                    result.add_type(Atomic::TCallable {
1189                        params: params.as_ref().map(|ps| {
1190                            ps.iter()
1191                                .map(|p| substitute_in_fn_param(p, bindings))
1192                                .collect()
1193                        }),
1194                        return_type: return_type
1195                            .as_ref()
1196                            .map(|r| Box::new(r.substitute_templates(bindings))),
1197                    });
1198                }
1199                Atomic::TClosure { data } => {
1200                    result.add_type(Atomic::TClosure {
1201                        data: Box::new(crate::atomic::ClosureData {
1202                            params: data
1203                                .params
1204                                .iter()
1205                                .map(|p| substitute_in_fn_param(p, bindings))
1206                                .collect(),
1207                            return_type: data.return_type.substitute_templates(bindings),
1208                            this_type: data
1209                                .this_type
1210                                .as_ref()
1211                                .map(|t| t.substitute_templates(bindings)),
1212                        }),
1213                    });
1214                }
1215                Atomic::TConditional { data } => {
1216                    let param_name = &data.param_name;
1217                    let new_subject = data.subject.substitute_templates(bindings);
1218                    let new_if_true = data.if_true.substitute_templates(bindings);
1219                    let new_if_false = data.if_false.substitute_templates(bindings);
1220
1221                    // If param_name names a template that is bound in this substitution,
1222                    // resolve the conditional immediately using the same predicate logic as
1223                    // `resolve_conditional_returns` for the $param form.
1224                    let resolved = if let Some(name) = param_name {
1225                        if let Some(bound) = bindings.get(name) {
1226                            if new_subject.types.len() == 1 {
1227                                resolve_conditional_branch(
1228                                    &new_subject.types[0],
1229                                    bound,
1230                                    &new_if_true,
1231                                    &new_if_false,
1232                                )
1233                            } else {
1234                                None
1235                            }
1236                        } else {
1237                            None
1238                        }
1239                    } else {
1240                        None
1241                    };
1242
1243                    if let Some(branch) = resolved {
1244                        for t in branch.types {
1245                            result.add_type(t);
1246                        }
1247                    } else {
1248                        result.add_type(Atomic::TConditional {
1249                            data: Box::new(crate::atomic::ConditionalData {
1250                                param_name: *param_name,
1251                                subject: new_subject,
1252                                if_true: new_if_true,
1253                                if_false: new_if_false,
1254                            }),
1255                        });
1256                    }
1257                }
1258                Atomic::TKeyOf { target } => {
1259                    let new_target = target.substitute_templates(bindings);
1260                    if let Some(resolved) = eval_key_of_type(&new_target) {
1261                        result.merge_with(&resolved);
1262                    } else {
1263                        result.add_type(Atomic::TKeyOf {
1264                            target: Box::new(new_target),
1265                        });
1266                    }
1267                }
1268                Atomic::TValueOf { target } => {
1269                    let new_target = target.substitute_templates(bindings);
1270                    if let Some(resolved) = eval_value_of_type(&new_target) {
1271                        result.merge_with(&resolved);
1272                    } else {
1273                        result.add_type(Atomic::TValueOf {
1274                            target: Box::new(new_target),
1275                        });
1276                    }
1277                }
1278                Atomic::TIntersection { parts } => {
1279                    result.add_type(Atomic::TIntersection {
1280                        parts: vec_to_type_params(
1281                            parts
1282                                .iter()
1283                                .map(|p| p.substitute_templates(bindings))
1284                                .collect(),
1285                        ),
1286                    });
1287                }
1288                Atomic::TNamedObject { fqcn, type_params } => {
1289                    // See issue #26 for context.
1290                    if type_params.is_empty() && !fqcn.contains('\\') {
1291                        if let Some(resolved) = bindings.get(fqcn) {
1292                            for t in &resolved.types {
1293                                result.add_type(t.clone());
1294                            }
1295                            continue;
1296                        }
1297                    }
1298                    let new_params: Vec<Type> = type_params
1299                        .iter()
1300                        .map(|p| p.substitute_templates(bindings))
1301                        .collect();
1302                    result.add_type(Atomic::TNamedObject {
1303                        fqcn: *fqcn,
1304                        type_params: vec_to_type_params(new_params),
1305                    });
1306                }
1307                // class-string<T> → substitute T from bindings
1308                Atomic::TClassString(Some(param_name)) => {
1309                    if let Some(resolved) = bindings.get(param_name) {
1310                        for r_atomic in &resolved.types {
1311                            let cls_name = if let Atomic::TNamedObject { fqcn, .. } = r_atomic {
1312                                Some(*fqcn)
1313                            } else {
1314                                None
1315                            };
1316                            result.add_type(Atomic::TClassString(cls_name));
1317                        }
1318                    } else {
1319                        result.add_type(atomic.clone());
1320                    }
1321                }
1322                // interface-string<T> → substitute T from bindings
1323                Atomic::TInterfaceString(Some(param_name)) => {
1324                    if let Some(resolved) = bindings.get(param_name) {
1325                        for r_atomic in &resolved.types {
1326                            let iface_name = if let Atomic::TNamedObject { fqcn, .. } = r_atomic {
1327                                Some(*fqcn)
1328                            } else {
1329                                None
1330                            };
1331                            result.add_type(Atomic::TInterfaceString(iface_name));
1332                        }
1333                    } else {
1334                        result.add_type(atomic.clone());
1335                    }
1336                }
1337                _ => {
1338                    result.add_type(atomic.clone());
1339                }
1340            }
1341        }
1342        result
1343    }
1344
1345    /// Resolves `TConditional` atoms whose discriminator is known at the call site.
1346    ///
1347    /// `lookup(param_name)` returns the call-site argument type for the named parameter,
1348    /// or `None` if the argument is not available. Handles `is null`, `is string`, and
1349    /// `is array` conditions; other condition types pass through unchanged.
1350    pub fn resolve_conditional_returns<F>(self, lookup: F) -> Type
1351    where
1352        F: Fn(&str) -> Option<Type>,
1353    {
1354        self.resolve_conditional_inner(&lookup)
1355    }
1356
1357    fn resolve_conditional_inner<F>(self, lookup: &F) -> Type
1358    where
1359        F: Fn(&str) -> Option<Type>,
1360    {
1361        let mut result = Type::empty();
1362        for atomic in self.types {
1363            match atomic {
1364                Atomic::TConditional { ref data } => {
1365                    let (param_name, subject, if_true, if_false) = (
1366                        &data.param_name,
1367                        &data.subject,
1368                        &data.if_true,
1369                        &data.if_false,
1370                    );
1371                    let resolved = if subject.types.len() == 1 {
1372                        if let Some(name) = param_name {
1373                            if let Some(arg_ty) = lookup(name.as_ref()) {
1374                                resolve_conditional_branch(
1375                                    &subject.types[0],
1376                                    &arg_ty,
1377                                    if_true,
1378                                    if_false,
1379                                )
1380                            } else {
1381                                None
1382                            }
1383                        } else {
1384                            None
1385                        }
1386                    } else {
1387                        None
1388                    };
1389
1390                    if let Some(branch) = resolved {
1391                        // Recursively resolve nested conditionals in the selected branch.
1392                        for t in branch.resolve_conditional_inner(lookup).types {
1393                            result.add_type(t);
1394                        }
1395                    } else {
1396                        // Cannot resolve at this call site: widen to the union of both branches.
1397                        // Recursively resolve nested conditionals in each branch.
1398                        for t in if_true.clone().resolve_conditional_inner(lookup).types {
1399                            result.add_type(t);
1400                        }
1401                        for t in if_false.clone().resolve_conditional_inner(lookup).types {
1402                            result.add_type(t);
1403                        }
1404                    }
1405                }
1406                other => result.add_type(other),
1407            }
1408        }
1409        result
1410    }
1411
1412    // --- Subtype check -------------------------------------------------------
1413
1414    /// Returns true if every atomic in `self` is a subtype of some atomic in `other`,
1415    /// using **only structural rules** — no `extends` / `implements` walk.
1416    ///
1417    /// Two distinct user-defined classes are never related here, even when one
1418    /// extends the other. Within `mir-analyzer`, when a `db` is in scope,
1419    /// prefer `crate::subtype::is_subtype(db, sub, sup)` which layers
1420    /// inheritance resolution on top of this check.
1421    pub fn is_subtype_structural(&self, other: &Type) -> bool {
1422        if other.is_mixed() {
1423            return true;
1424        }
1425        if self.is_never() {
1426            return true; // never <: everything
1427        }
1428        self.types
1429            .iter()
1430            .all(|a| other.types.iter().any(|b| atomic_subtype(a, b)))
1431    }
1432
1433    /// Like `is_subtype_structural`, but a template atom is judged by its bound
1434    /// (an unbound one is `mixed` and fits anything).
1435    pub fn is_subtype_by_template_bounds(&self, other: &Type) -> bool {
1436        self.is_subtype_structural(other)
1437            || self.types.iter().all(|a| match a {
1438                Atomic::TTemplateParam { as_type, .. } => {
1439                    as_type.is_subtype_by_template_bounds(other)
1440                }
1441                _ => other.accepts_atomic_structural(a),
1442            })
1443    }
1444
1445    /// `sub <: self`, structurally, for a single atomic — equivalent to
1446    /// `Type::single(sub.clone()).is_subtype_structural(self)` without the
1447    /// clone and the temporary single-atomic union.
1448    pub fn accepts_atomic_structural(&self, sub: &Atomic) -> bool {
1449        if self.is_mixed() {
1450            return true;
1451        }
1452        matches!(sub, Atomic::TNever) || self.types.iter().any(|b| atomic_subtype(sub, b))
1453    }
1454
1455    // --- Utilities ----------------------------------------------------------
1456
1457    fn filter<F: Fn(&Atomic) -> bool>(&self, f: F) -> Type {
1458        let mut result = Type::empty();
1459        result.possibly_undefined = self.possibly_undefined;
1460        result.from_docblock = self.from_docblock;
1461        for atomic in &self.types {
1462            if f(atomic) {
1463                result.types.push(atomic.clone());
1464            }
1465        }
1466        result
1467    }
1468
1469    /// Like `filter`, but atoms matching `placeholder` are substituted with
1470    /// `replacement` instead of passing through unchanged. Used so narrowing
1471    /// an unrefined `mixed`/`scalar`/`numeric` value (e.g. via `is_int($x)`)
1472    /// yields the concrete narrowed type instead of staying `mixed`.
1473    fn filter_replacing<K: Fn(&Atomic) -> bool, P: Fn(&Atomic) -> bool>(
1474        &self,
1475        keep: K,
1476        placeholder: P,
1477        replacement: Atomic,
1478    ) -> Type {
1479        let mut result = Type::empty();
1480        result.possibly_undefined = self.possibly_undefined;
1481        result.from_docblock = self.from_docblock;
1482        for atomic in &self.types {
1483            if keep(atomic) {
1484                result.add_type(atomic.clone());
1485            } else if placeholder(atomic) {
1486                result.add_type(replacement.clone());
1487            }
1488        }
1489        result
1490    }
1491
1492    /// Mark this union as possibly-undefined and return it.
1493    pub fn possibly_undefined(mut self) -> Self {
1494        self.possibly_undefined = true;
1495        self
1496    }
1497
1498    /// Mark this union as coming from a docblock annotation.
1499    pub fn from_docblock(mut self) -> Self {
1500        self.from_docblock = true;
1501        self
1502    }
1503
1504    /// Mark this union as having had a `false`/`null` failure variant stripped
1505    /// for flow purposes (see the field doc on [`Type::falsy_stripped`]).
1506    pub fn falsy_stripped(mut self) -> Self {
1507        self.falsy_stripped = true;
1508        self
1509    }
1510
1511    /// Mark this union as read from a generic array/list offset whose key
1512    /// presence isn't statically provable (see the field doc on
1513    /// [`Type::possibly_absent_offset`]).
1514    pub fn possibly_absent_offset(mut self) -> Self {
1515        self.possibly_absent_offset = true;
1516        self
1517    }
1518}
1519
1520// ---------------------------------------------------------------------------
1521// Conditional return resolution helpers
1522// ---------------------------------------------------------------------------
1523
1524/// Coarse value class of a runtime value.
1525///
1526/// One atom may hold several classes (a `bool` may hold `true` *or*
1527/// `false`; a `scalar` argument is one of the five scalar classes).
1528/// Literal refinements stay precise only where they matter: `int`/`float`
1529/// literals get their own class so that `int` and `float` literals decide
1530/// against each other's subjects; string literals collapse to
1531/// [`ValueClass::String`] (refined string kinds never feed a conditional
1532/// subject and would only cost match precision).
1533#[derive(Clone, PartialEq, Eq, Debug)]
1534enum ValueClass {
1535    Int,
1536    Float,
1537    True,
1538    False,
1539    String,
1540    Array,
1541    /// `list`-shaped arrays (sequential integer keys) — a refinement of [`Array`].
1542    List,
1543    Object,
1544    Null,
1545    /// `mixed`/`void` — a value of every class; it overlaps every other
1546    /// class, so a `mixed` argument rules out no branch.
1547    Top,
1548    /// Literal `int` — precise against `int`/`float` subjects: an `int`
1549    /// literal is not a float, a float literal is not an int.
1550    LitInt(i64),
1551    /// Literal `float` — the bit decomposition mirrors [`Atomic::TLiteralFloat`].
1552    LitFloat(i64, i64),
1553    /// A specific `string` literal.
1554    LitString(Arc<str>),
1555}
1556
1557impl ValueClass {
1558    /// Whether a value of class `b` is necessarily a value of class `a`, i.e.
1559    /// whether the class lattice (`int` literal <: `int` <: `scalar`;
1560    /// `list` <: `array`; `true`/`false` <: `bool`; `mixed` <: everything)
1561    /// places `b` beneath `a`.
1562    fn includes(&self, b: &Self) -> bool {
1563        self == b
1564            || matches!(
1565                (self, b),
1566                (Self::Top, _)
1567                    | (Self::Int, Self::LitInt(_))
1568                    | (Self::Float, Self::LitFloat(..))
1569                    | (Self::String, Self::LitString(_))
1570                    | (Self::Array, Self::List)
1571            )
1572    }
1573}
1574
1575/// The value classes an atom may hold.
1576///
1577/// Meta-types map to every class they may hold (`TBool` holds `true` *and*
1578/// `false`); opaque/deferred atoms (`TTemplateParam`, `TKeyOf`, …) map to
1579/// none — an argument of such a type rules out no branch.
1580fn value_classes(a: &Atomic) -> Vec<ValueClass> {
1581    use ValueClass::*;
1582    match a {
1583        // Integers (range bounds are not modeled: a subject `int` is a
1584        // family question).
1585        Atomic::TInt
1586        | Atomic::TIntRange { .. }
1587        | Atomic::TPositiveInt
1588        | Atomic::TNegativeInt
1589        | Atomic::TNonNegativeInt => vec![Int],
1590        Atomic::TLiteralInt(v) => vec![LitInt(*v)],
1591        // Floats.
1592        Atomic::TFloat | Atomic::TIntegralFloat => vec![Float],
1593        Atomic::TLiteralFloat(int_bits, frac_bits) => {
1594            vec![LitFloat(*int_bits, *frac_bits)]
1595        }
1596        // Bools.
1597        Atomic::TBool => vec![True, False],
1598        Atomic::TTrue => vec![True],
1599        Atomic::TFalse => vec![False],
1600        // Strings — every refined string kind maps to one class.
1601        Atomic::TString
1602        | Atomic::TNonEmptyString
1603        | Atomic::TNumericString
1604        | Atomic::TClassString(_)
1605        | Atomic::TInterfaceString(_)
1606        | Atomic::TEnumString
1607        | Atomic::TTraitString
1608        | Atomic::TCallableString => vec![String],
1609        Atomic::TLiteralString(s) => vec![LitString(s.clone())],
1610        // Arrays — `list` is a refinement of `array`.
1611        Atomic::TArray { .. }
1612        | Atomic::TNonEmptyArray { .. }
1613        | Atomic::TKeyedArray { is_list: false, .. } => vec![Array],
1614        Atomic::TList { .. }
1615        | Atomic::TNonEmptyList { .. }
1616        | Atomic::TKeyedArray { is_list: true, .. } => vec![List],
1617        // Objects — a specific class instance is an object at runtime.
1618        Atomic::TObject
1619        | Atomic::TNamedObject { .. }
1620        | Atomic::TStaticObject { .. }
1621        | Atomic::TSelf { .. }
1622        | Atomic::TParent { .. }
1623        | Atomic::TClosure { .. }
1624        | Atomic::TLiteralEnumCase { .. } => vec![Object],
1625        // Null. `void` maps to `Top` (a `void`-typed value is unknown; treating
1626        // it as "everything" keeps any branch it feeds undecidable).
1627        Atomic::TNull => vec![Null],
1628        Atomic::TVoid => vec![Top],
1629        // Everything / scalars.
1630        Atomic::TMixed => vec![Top],
1631        Atomic::TScalar => vec![Int, Float, True, False, String],
1632        // `numeric` may hold `int` or `float` values (a numeric *string*
1633        // can never be the discriminant of an `is numeric` subject).
1634        Atomic::TNumeric => vec![Int, Float],
1635        // Opaque / deferred — rules out no branch.
1636        Atomic::TCallable { .. }
1637        | Atomic::TNever
1638        | Atomic::TTemplateParam { .. }
1639        | Atomic::TKeyOf { .. }
1640        | Atomic::TValueOf { .. }
1641        | Atomic::TConditional { .. }
1642        | Atomic::TIntersection { .. } => Vec::new(),
1643    }
1644}
1645
1646/// Whether a conditional discriminant subject can be decided by value class.
1647///
1648/// Only the bare family kinds qualify. A refined subject (a named object, a
1649/// shape, a non-empty list, an enum case, …) carries value-level constraints
1650/// the class lattice cannot express — an argument that is *some* object is
1651/// not necessarily *that* class — so refined subjects stay undecidable, the
1652/// same behavior as the pre-class predicate (which had no arm for them).
1653fn subject_is_decidable(subject: &Atomic) -> bool {
1654    matches!(
1655        subject,
1656        Atomic::TNull
1657            | Atomic::TTrue
1658            | Atomic::TFalse
1659            | Atomic::TBool
1660            | Atomic::TString
1661            | Atomic::TInt
1662            | Atomic::TFloat
1663            | Atomic::TArray { .. }
1664            | Atomic::TList { .. }
1665            | Atomic::TObject
1666            | Atomic::TMixed
1667            | Atomic::TScalar
1668    )
1669}
1670
1671/// Resolve one branch of a conditional return type given the subject
1672/// discriminant and the actual argument type at the call site.
1673///
1674/// Returns `Some(branch)` when the branch can be determined statically, or
1675/// `None` to signal that the caller should widen to the union of both
1676/// branches.
1677///
1678/// Decision rule (value-class semantics): the discriminant and the argument
1679/// are compared *only on value classes* — the value sets the runtime type
1680/// system uses for narrowing. Class containment is the same lattice the
1681/// subtype relation uses for these kinds (`true` ⊆ `bool` ⊆ `scalar`,
1682/// `list` ⊆ `array`, `int`/`float` literals distinct, `mixed` ⊆ everything),
1683/// so a subject kind and an argument kind are related iff some contained
1684/// class relates them.
1685///
1686/// The true branch commits when every argument class is contained in some
1687/// subject class (every value the argument can hold is a subject value); the
1688/// false branch commits when every argument class is disjoint from every
1689/// subject class (no value the argument can hold is a subject value).
1690/// Otherwise the branch is undecidable and the caller widens to the union of
1691/// both branches.
1692fn resolve_conditional_branch(
1693    subject: &Atomic,
1694    arg_ty: &Type,
1695    if_true: &Type,
1696    if_false: &Type,
1697) -> Option<Type> {
1698    if !subject_is_decidable(subject) {
1699        return None;
1700    }
1701    if arg_ty.types.is_empty() {
1702        return None;
1703    }
1704    let subject_classes = value_classes(subject);
1705    let arg_classes: Vec<ValueClass> = arg_ty.types.iter().flat_map(value_classes).collect();
1706    if arg_classes.is_empty() {
1707        // Opaque argument (template, `key-of`, …) — no branch is ruled out.
1708        return None;
1709    }
1710    let all_match = arg_classes
1711        .iter()
1712        .all(|c| subject_classes.iter().any(|s| s.includes(c)));
1713    let any_overlap = arg_classes.iter().any(|c| {
1714        subject_classes
1715            .iter()
1716            .any(|s| s.includes(c) || c.includes(s))
1717    });
1718    if all_match {
1719        Some(if_true.clone())
1720    } else if !any_overlap {
1721        Some(if_false.clone())
1722    } else {
1723        None
1724    }
1725}
1726
1727// ---------------------------------------------------------------------------
1728// Template substitution helpers
1729// ---------------------------------------------------------------------------
1730
1731/// Whether `substitute_templates` could change this atomic: it is either a
1732/// template reference itself or a container/callable that may hold one.
1733/// Mirrors the substituting arms of that function's match — keep in sync.
1734fn atomic_may_contain_templates(atomic: &Atomic) -> bool {
1735    match atomic {
1736        // Bare unqualified names double as template refs (docblock parser
1737        // workaround, see substitute_templates); qualified names only matter
1738        // when they carry generic params.
1739        Atomic::TNamedObject { fqcn, type_params } => {
1740            !type_params.is_empty() || !fqcn.contains('\\')
1741        }
1742        Atomic::TTemplateParam { .. }
1743        | Atomic::TKeyOf { .. }
1744        | Atomic::TValueOf { .. }
1745        | Atomic::TArray { .. }
1746        | Atomic::TList { .. }
1747        | Atomic::TNonEmptyArray { .. }
1748        | Atomic::TNonEmptyList { .. }
1749        | Atomic::TKeyedArray { .. }
1750        | Atomic::TCallable { .. }
1751        | Atomic::TClosure { .. }
1752        | Atomic::TConditional { .. }
1753        | Atomic::TIntersection { .. }
1754        | Atomic::TClassString(Some(_))
1755        | Atomic::TInterfaceString(Some(_)) => true,
1756        _ => false,
1757    }
1758}
1759
1760pub fn eval_key_of_type(t: &Type) -> Option<Type> {
1761    let mut result = Type::empty();
1762    for atomic in &t.types {
1763        match atomic {
1764            Atomic::TArray { key, .. } | Atomic::TNonEmptyArray { key, .. } => {
1765                for k in &key.types {
1766                    result.add_type(k.clone());
1767                }
1768            }
1769            Atomic::TList { .. } | Atomic::TNonEmptyList { .. } => {
1770                result.add_type(Atomic::TInt);
1771            }
1772            Atomic::TKeyedArray { properties, .. } => {
1773                for key in properties.keys() {
1774                    match key {
1775                        crate::atomic::ArrayKey::Int(n) => result.add_type(Atomic::TLiteralInt(*n)),
1776                        crate::atomic::ArrayKey::String(s) => {
1777                            result.add_type(Atomic::TLiteralString(s.clone()))
1778                        }
1779                    }
1780                }
1781            }
1782            _ => return None,
1783        }
1784    }
1785    (!result.types.is_empty()).then_some(result)
1786}
1787
1788pub fn eval_value_of_type(t: &Type) -> Option<Type> {
1789    let mut result = Type::empty();
1790    for atomic in &t.types {
1791        match atomic {
1792            Atomic::TArray { value, .. }
1793            | Atomic::TNonEmptyArray { value, .. }
1794            | Atomic::TList { value }
1795            | Atomic::TNonEmptyList { value } => {
1796                result.merge_with(value);
1797            }
1798            Atomic::TKeyedArray { properties, .. } => {
1799                for prop in properties.values() {
1800                    result.merge_with(&prop.ty);
1801                }
1802            }
1803            _ => return None,
1804        }
1805    }
1806    (!result.types.is_empty()).then_some(result)
1807}
1808
1809fn substitute_in_fn_param(
1810    p: &crate::atomic::FnParam,
1811    bindings: &FxHashMap<Name, Type>,
1812) -> crate::atomic::FnParam {
1813    crate::atomic::FnParam {
1814        name: p.name,
1815        ty: p.ty.as_ref().map(|t| {
1816            let u = t.to_union();
1817            let substituted = u.substitute_templates(bindings);
1818            crate::compact::SimpleType::from_union(substituted)
1819        }),
1820        out_ty: p.out_ty.as_ref().map(|t| {
1821            let u = t.to_union();
1822            let substituted = u.substitute_templates(bindings);
1823            crate::compact::SimpleType::from_union(substituted)
1824        }),
1825        default: p.default.as_ref().map(|d| {
1826            let u = d.to_union();
1827            let substituted = u.substitute_templates(bindings);
1828            crate::compact::SimpleType::from_union(substituted)
1829        }),
1830        is_variadic: p.is_variadic,
1831        is_byref: p.is_byref,
1832        is_optional: p.is_optional,
1833    }
1834}
1835
1836// ---------------------------------------------------------------------------
1837// Atomic subtype (no codebase — structural check only)
1838// ---------------------------------------------------------------------------
1839
1840/// Structural `sub <: sup` for a single atomic pair, without hierarchy resolution.
1841pub fn atomic_subtype(sub: &Atomic, sup: &Atomic) -> bool {
1842    if sub == sup {
1843        return true;
1844    }
1845    match (sub, sup) {
1846        // Bottom type
1847        (Atomic::TNever, _) => true,
1848        // Top types — anything goes in both directions for mixed
1849        (_, Atomic::TMixed) => true,
1850        (Atomic::TMixed, _) => true,
1851        // Template param in supertype position: any value satisfies an unconstrained
1852        // template (as_type = mixed), or a constrained one if it satisfies the bound.
1853        // This handles union bounds like `T of string|list<I>|array<K, V>` where
1854        // I/K/V are free template params — any type satisfies them structurally.
1855        (_, Atomic::TTemplateParam { as_type, .. }) => {
1856            as_type.is_mixed() || as_type.types.iter().any(|b| atomic_subtype(sub, b))
1857        }
1858
1859        // Scalars
1860        (Atomic::TLiteralInt(_), Atomic::TInt) => true,
1861        (Atomic::TLiteralInt(_), Atomic::TNumeric) => true,
1862        (Atomic::TLiteralInt(_), Atomic::TScalar) => true,
1863        (Atomic::TLiteralInt(n), Atomic::TPositiveInt) => *n > 0,
1864        (Atomic::TLiteralInt(n), Atomic::TNonNegativeInt) => *n >= 0,
1865        (Atomic::TLiteralInt(n), Atomic::TNegativeInt) => *n < 0,
1866        (Atomic::TPositiveInt, Atomic::TInt) => true,
1867        (Atomic::TPositiveInt, Atomic::TNonNegativeInt) => true,
1868        (Atomic::TPositiveInt, Atomic::TNumeric) => true,
1869        (Atomic::TPositiveInt, Atomic::TScalar) => true,
1870        (Atomic::TNegativeInt, Atomic::TInt) => true,
1871        (Atomic::TNegativeInt, Atomic::TNumeric) => true,
1872        (Atomic::TNegativeInt, Atomic::TScalar) => true,
1873        (Atomic::TNonNegativeInt, Atomic::TInt) => true,
1874        (Atomic::TNonNegativeInt, Atomic::TNumeric) => true,
1875        (Atomic::TNonNegativeInt, Atomic::TScalar) => true,
1876        (Atomic::TIntRange { .. }, Atomic::TInt) => true,
1877        (Atomic::TIntRange { .. }, Atomic::TNumeric) => true,
1878        (Atomic::TIntRange { .. }, Atomic::TScalar) => true,
1879        // positive-int is int<1, ∞>: subtype of int<sup_min, ∞> when sup_min <= 1
1880        (Atomic::TPositiveInt, Atomic::TIntRange { min, max }) => {
1881            max.is_none() && min.is_none_or(|m| m <= 1)
1882        }
1883        // negative-int is int<-∞, -1>: subtype of int<-∞, sup_max> when sup_max >= -1
1884        (Atomic::TNegativeInt, Atomic::TIntRange { min, max }) => {
1885            min.is_none() && max.is_none_or(|m| m >= -1)
1886        }
1887        // non-negative-int is int<0, ∞>: subtype of int<sup_min, ∞> when sup_min <= 0
1888        (Atomic::TNonNegativeInt, Atomic::TIntRange { min, max }) => {
1889            max.is_none() && min.is_none_or(|m| m <= 0)
1890        }
1891        // A bounded int range is a subtype of a named int subtype when every value fits
1892        (Atomic::TIntRange { min: sub_min, .. }, Atomic::TPositiveInt) => {
1893            sub_min.is_some_and(|lo| lo >= 1)
1894        }
1895        (Atomic::TIntRange { min: sub_min, .. }, Atomic::TNonNegativeInt) => {
1896            sub_min.is_some_and(|lo| lo >= 0)
1897        }
1898        (Atomic::TIntRange { max: sub_max, .. }, Atomic::TNegativeInt) => {
1899            sub_max.is_some_and(|hi| hi <= -1)
1900        }
1901        // int<sub_min, sub_max> <: int<sup_min, sup_max> when ranges nest
1902        (
1903            Atomic::TIntRange {
1904                min: sub_min,
1905                max: sub_max,
1906            },
1907            Atomic::TIntRange {
1908                min: sup_min,
1909                max: sup_max,
1910            },
1911        ) => {
1912            let lower_ok = match (sub_min, sup_min) {
1913                (_, None) => true,
1914                (None, Some(_)) => false,
1915                (Some(sl), Some(su)) => sl >= su,
1916            };
1917            let upper_ok = match (sub_max, sup_max) {
1918                (None, None) | (Some(_), None) => true,
1919                (None, Some(_)) => false,
1920                (Some(sl), Some(su)) => sl <= su,
1921            };
1922            lower_ok && upper_ok
1923        }
1924
1925        (Atomic::TLiteralFloat(..), Atomic::TFloat) => true,
1926        (Atomic::TLiteralFloat(..), Atomic::TNumeric) => true,
1927        (Atomic::TLiteralFloat(..), Atomic::TScalar) => true,
1928
1929        (Atomic::TLiteralString(s), Atomic::TString) => {
1930            let _ = s;
1931            true
1932        }
1933        (Atomic::TLiteralString(s), Atomic::TCallableString) => {
1934            let _ = s;
1935            true
1936        }
1937        (Atomic::TLiteralString(s), Atomic::TNonEmptyString) => !s.is_empty(),
1938        (Atomic::TLiteralString(s), Atomic::TNumericString) => s.parse::<f64>().is_ok(),
1939        // A literal string is type-compatible with class-string; validate_class_string_argument
1940        // separately checks whether the string names a real class (UndefinedClass).
1941        (Atomic::TLiteralString(_), Atomic::TClassString(_)) => true,
1942        // Same, for interface-string; validate_interface_string_argument checks existence
1943        // and that the name actually resolves to an interface.
1944        (Atomic::TLiteralString(_), Atomic::TInterfaceString(_)) => true,
1945        (Atomic::TLiteralString(_), Atomic::TScalar) => true,
1946        (Atomic::TNonEmptyString, Atomic::TString) => true,
1947        (Atomic::TCallableString, Atomic::TString) => true,
1948        // numeric-string is always non-empty (e.g. "42", "-1", "0.5") — "" is not numeric.
1949        (Atomic::TNumericString, Atomic::TNonEmptyString) => true,
1950        (Atomic::TNumericString, Atomic::TString) => true,
1951        // A class/interface/callable/enum/trait name can never be the empty
1952        // string in real PHP — every one of these string-subtype atoms is
1953        // always non-empty, same reasoning as numeric-string above.
1954        (Atomic::TClassString(_), Atomic::TNonEmptyString) => true,
1955        (Atomic::TInterfaceString(_), Atomic::TNonEmptyString) => true,
1956        (Atomic::TCallableString, Atomic::TNonEmptyString) => true,
1957        (Atomic::TEnumString, Atomic::TNonEmptyString) => true,
1958        (Atomic::TTraitString, Atomic::TNonEmptyString) => true,
1959        (Atomic::TClassString(_), Atomic::TString) => true,
1960        (Atomic::TInterfaceString(_), Atomic::TString) => true,
1961        // Every interface-string is a valid class-string: PHP doesn't distinguish
1962        // the two at runtime — both are just strings naming a class-like symbol.
1963        // Instantiability (`new $x()`) is guarded separately, since an interface
1964        // name can never be `new`-ed even though it satisfies class-string.
1965        (Atomic::TInterfaceString(_), Atomic::TClassString(None)) => true,
1966        (Atomic::TClassString(Some(_)), Atomic::TClassString(None)) => true,
1967        (Atomic::TInterfaceString(Some(a)), Atomic::TClassString(Some(b))) => a == b,
1968        (Atomic::TEnumString, Atomic::TString) => true,
1969        (Atomic::TTraitString, Atomic::TString) => true,
1970
1971        (Atomic::TTrue, Atomic::TBool) => true,
1972        (Atomic::TFalse, Atomic::TBool) => true,
1973
1974        (Atomic::TInt, Atomic::TNumeric) => true,
1975        (Atomic::TFloat, Atomic::TNumeric) => true,
1976        (Atomic::TIntegralFloat, Atomic::TNumeric) => true,
1977        (Atomic::TNumericString, Atomic::TNumeric) => true,
1978
1979        (Atomic::TInt, Atomic::TScalar) => true,
1980        (Atomic::TFloat, Atomic::TScalar) => true,
1981        (Atomic::TIntegralFloat, Atomic::TScalar) => true,
1982        (Atomic::TString, Atomic::TScalar) => true,
1983        (Atomic::TBool, Atomic::TScalar) => true,
1984        (Atomic::TNumeric, Atomic::TScalar) => true,
1985        (Atomic::TTrue, Atomic::TScalar) => true,
1986        (Atomic::TFalse, Atomic::TScalar) => true,
1987        // Every refined string atom is, at runtime, still just a `string` —
1988        // and therefore a `scalar` — same as the already-covered TLiteralString
1989        // and int-family refinements just above/below.
1990        (Atomic::TNonEmptyString, Atomic::TScalar) => true,
1991        (Atomic::TNumericString, Atomic::TScalar) => true,
1992        (Atomic::TCallableString, Atomic::TScalar) => true,
1993        (Atomic::TClassString(_), Atomic::TScalar) => true,
1994        (Atomic::TInterfaceString(_), Atomic::TScalar) => true,
1995        (Atomic::TEnumString, Atomic::TScalar) => true,
1996        (Atomic::TTraitString, Atomic::TScalar) => true,
1997
1998        // Object hierarchy (structural, no codebase)
1999        (Atomic::TNamedObject { .. }, Atomic::TObject) => true,
2000        (Atomic::TStaticObject { .. }, Atomic::TObject) => true,
2001        (Atomic::TSelf { .. }, Atomic::TObject) => true,
2002        // An enum-case literal is, at runtime, an object.
2003        (Atomic::TLiteralEnumCase { .. }, Atomic::TObject) => true,
2004        // self(X) and static(X) satisfy TNamedObject(X) with same FQCN
2005        (Atomic::TSelf { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
2006        (Atomic::TStaticObject { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
2007        // TNamedObject(X) satisfies self(X) / static(X) with same FQCN
2008        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TSelf { fqcn: b }) => a == b,
2009        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TStaticObject { fqcn: b }) => a == b,
2010        // An enum-case literal satisfies its own bare enum type (enums are
2011        // represented as TNamedObject).
2012        (Atomic::TLiteralEnumCase { enum_fqcn, .. }, Atomic::TNamedObject { fqcn, .. }) => {
2013            enum_fqcn == fqcn
2014        }
2015        (Atomic::TLiteralEnumCase { enum_fqcn, .. }, Atomic::TSelf { fqcn })
2016        | (Atomic::TLiteralEnumCase { enum_fqcn, .. }, Atomic::TStaticObject { fqcn }) => {
2017            enum_fqcn == fqcn
2018        }
2019        // Bare generic property accepts parameterized value: Box accepts Box<string>.
2020        // The reverse is NOT true — bare Box value does not satisfy Box<string> property
2021        // (invariant check). Only sup being bare (empty type_params) is the wildcard.
2022        (
2023            Atomic::TNamedObject {
2024                fqcn: sub_fqcn,
2025                type_params: sub_params,
2026            },
2027            Atomic::TNamedObject {
2028                fqcn: sup_fqcn,
2029                type_params: sup_params,
2030            },
2031        ) => {
2032            sub_fqcn == sup_fqcn
2033                && (sup_params.is_empty() || type_params_compatible(sub_params, sup_params))
2034        }
2035
2036        // TIntegralFloat is a subtype of float (all integral floats are floats)
2037        (Atomic::TIntegralFloat, Atomic::TFloat) => true,
2038
2039        // Literal int widens to float in PHP
2040        (Atomic::TLiteralInt(_), Atomic::TFloat) => true,
2041        (Atomic::TPositiveInt, Atomic::TFloat) => true,
2042        (Atomic::TNegativeInt, Atomic::TFloat) => true,
2043        (Atomic::TNonNegativeInt, Atomic::TFloat) => true,
2044        (Atomic::TInt, Atomic::TFloat) => true,
2045        (Atomic::TIntRange { .. }, Atomic::TFloat) => true,
2046
2047        // Literal int satisfies an int range only when the value is within bounds
2048        (Atomic::TLiteralInt(n), Atomic::TIntRange { min, max }) => {
2049            min.is_none_or(|lo| *n >= lo) && max.is_none_or(|hi| *n <= hi)
2050        }
2051
2052        // PHP callables: string and array are valid callable values
2053        (Atomic::TString, Atomic::TCallable { .. }) => true,
2054        (Atomic::TNonEmptyString, Atomic::TCallable { .. }) => true,
2055        (Atomic::TLiteralString(_), Atomic::TCallable { .. }) => true,
2056        (Atomic::TArray { .. }, Atomic::TCallable { .. }) => true,
2057        (Atomic::TNonEmptyArray { .. }, Atomic::TCallable { .. }) => true,
2058        (Atomic::TKeyedArray { .. }, Atomic::TCallable { .. }) => true,
2059
2060        // Closure <: callable, typed Closure <: Closure
2061        (Atomic::TClosure { .. }, Atomic::TCallable { .. }) => true,
2062        // callable <: Closure: callable is wider but not flagged at default error level
2063        (Atomic::TCallable { .. }, Atomic::TClosure { .. }) => true,
2064        // TClosure <: TClosure: check arity, per-parameter contravariance, and
2065        // return covariance for scalar/array-shaped types, where a purely
2066        // structural check is reliable. A named-class (or nested-callable)
2067        // param/return is skipped rather than checked — this checker has no
2068        // database access to walk `extends`/`implements`, so it can't safely
2069        // tell a real Liskov violation apart from a legitimate subclass/
2070        // superclass substitution; treating it as compatible avoids false
2071        // positives on that far more common case at the cost of missing the
2072        // narrower nominal-variance violation.
2073        (Atomic::TClosure { data: sub }, Atomic::TClosure { data: sup }) => {
2074            fn has_nominal_type(t: &Type) -> bool {
2075                t.types.iter().any(|a| {
2076                    matches!(
2077                        a,
2078                        Atomic::TNamedObject { .. }
2079                            | Atomic::TSelf { .. }
2080                            | Atomic::TStaticObject { .. }
2081                            | Atomic::TTemplateParam { .. }
2082                            | Atomic::TClosure { .. }
2083                            | Atomic::TCallable { .. }
2084                    )
2085                })
2086            }
2087            let sub_required = sub
2088                .params
2089                .iter()
2090                .filter(|p| !p.is_optional && !p.is_variadic)
2091                .count();
2092            if sub_required > sup.params.len() {
2093                false
2094            } else {
2095                let params_ok = sup.params.iter().enumerate().all(|(i, sup_param)| {
2096                    let Some(sub_param) = sub.params.get(i) else {
2097                        return true;
2098                    };
2099                    if sub_param.is_optional || sub_param.is_variadic {
2100                        return true;
2101                    }
2102                    let (Some(sub_ty), Some(sup_ty)) =
2103                        (sub_param.ty.as_ref(), sup_param.ty.as_ref())
2104                    else {
2105                        return true;
2106                    };
2107                    let (sub_u, sup_u) = (sub_ty.to_union(), sup_ty.to_union());
2108                    if has_nominal_type(&sub_u) || has_nominal_type(&sup_u) {
2109                        return true;
2110                    }
2111                    // Contravariance: whatever `sup` promises to pass must be
2112                    // acceptable to `sub`'s declared parameter type.
2113                    sup_u.is_subtype_structural(&sub_u)
2114                });
2115                params_ok
2116                    && (sub.return_type.is_mixed()
2117                        || sup.return_type.is_mixed()
2118                        // A void callback discards its return value, so any
2119                        // return type satisfies it — PHP places no
2120                        // constraint on what an implementation returns here.
2121                        || sup.return_type.is_void()
2122                        || has_nominal_type(&sub.return_type)
2123                        || has_nominal_type(&sup.return_type)
2124                        || sub.return_type.is_subtype_structural(&sup.return_type))
2125            }
2126        }
2127        // callable <: callable (trivial)
2128        (Atomic::TCallable { .. }, Atomic::TCallable { .. }) => true,
2129        // TClosure satisfies `Closure` named object or `object`
2130        (Atomic::TClosure { .. }, Atomic::TNamedObject { fqcn, .. }) => {
2131            fqcn.as_ref().eq_ignore_ascii_case("closure")
2132        }
2133        (Atomic::TClosure { .. }, Atomic::TObject) => true,
2134        // bare `Closure` (named object without signature) satisfies any typed Closure(): T
2135        (Atomic::TNamedObject { fqcn, .. }, Atomic::TClosure { .. }) => {
2136            fqcn.as_ref().eq_ignore_ascii_case("closure")
2137        }
2138        // `Closure` named-object satisfies `callable`
2139        (Atomic::TNamedObject { fqcn, .. }, Atomic::TCallable { .. }) => {
2140            fqcn.as_ref().eq_ignore_ascii_case("closure")
2141        }
2142
2143        // A&B&C <: D&E iff every part of the supertype is satisfied by some
2144        // part of the subtype — an intersection with MORE conjuncts is the
2145        // more specific (sub)type, so `Countable&ArrayAccess&Iterator` is a
2146        // subtype of `Countable&ArrayAccess`. Purely structural (each part's
2147        // own `is_subtype_structural` recurses, so e.g. two differently-named
2148        // interfaces only match when equal — same conservative stance as the
2149        // TClosure<:TClosure arm above for named types).
2150        (
2151            Atomic::TIntersection { parts: sub_parts },
2152            Atomic::TIntersection { parts: sup_parts },
2153        ) => sup_parts.iter().all(|sup_part| {
2154            sub_parts
2155                .iter()
2156                .any(|sub_part| sub_part.is_subtype_structural(sup_part))
2157        }),
2158
2159        // List <: array  (list key is always int; int must satisfy the array's key type)
2160        (Atomic::TList { value }, Atomic::TArray { key, value: av }) => {
2161            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
2162        }
2163        (Atomic::TNonEmptyList { value }, Atomic::TArray { key, value: av }) => {
2164            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
2165        }
2166        (Atomic::TNonEmptyList { value }, Atomic::TNonEmptyArray { key, value: av }) => {
2167            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
2168        }
2169        (Atomic::TNonEmptyList { value }, Atomic::TList { value: lv }) => {
2170            value.is_subtype_structural(lv)
2171        }
2172        (Atomic::TNonEmptyList { value: v1 }, Atomic::TNonEmptyList { value: v2 }) => {
2173            v1.is_subtype_structural(v2)
2174        }
2175        // array<int, X> is accepted where list<X> or non-empty-list<X> expected
2176        (Atomic::TArray { key, value: av }, Atomic::TList { value: lv }) => {
2177            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
2178                && av.is_subtype_structural(lv)
2179        }
2180        (Atomic::TArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
2181            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
2182                && av.is_subtype_structural(lv)
2183        }
2184        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TList { value: lv }) => {
2185            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
2186                && av.is_subtype_structural(lv)
2187        }
2188        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
2189            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
2190                && av.is_subtype_structural(lv)
2191        }
2192        // TList <: TList value covariance
2193        (Atomic::TList { value: v1 }, Atomic::TList { value: v2 }) => v1.is_subtype_structural(v2),
2194        (Atomic::TNonEmptyArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
2195            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
2196        }
2197
2198        // array<A, B> <: array<C, D>  iff  A <: C && B <: D
2199        (Atomic::TArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
2200            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
2201        }
2202
2203        // A keyed/shape array is a subtype of array<K, V> / non-empty-array<K, V>
2204        // when all property KEYS are subtypes of K. Value compatibility is checked
2205        // structurally only for scalar types; named-object values are deferred to
2206        // class-hierarchy checks in return_arrays_compatible (mir-analyzer).
2207        // Open shapes (is_open=true) may have extra unknown keys beyond `properties`:
2208        // those stay unchecked (permissive), but every KNOWN property must still
2209        // satisfy K/V regardless of openness — an open shape isn't a license to skip
2210        // checking the keys it does declare.
2211        (Atomic::TKeyedArray { properties, .. }, Atomic::TArray { key, value }) => {
2212            properties.iter().all(|(prop_key, prop)| {
2213                let key_atomic = match prop_key {
2214                    crate::atomic::ArrayKey::String(s) => Atomic::TLiteralString(s.clone()),
2215                    crate::atomic::ArrayKey::Int(n) => Atomic::TLiteralInt(*n),
2216                };
2217                if !Type::single(key_atomic).is_subtype_structural(key) {
2218                    return false; // key mismatch — definitively incompatible
2219                }
2220                // Named-object values require class-hierarchy checks not available here.
2221                let has_named_obj = prop.ty.types.iter().any(|a| {
2222                    matches!(
2223                        a,
2224                        Atomic::TNamedObject { .. }
2225                            | Atomic::TSelf { .. }
2226                            | Atomic::TStaticObject { .. }
2227                            | Atomic::TClosure { .. }
2228                            | Atomic::TTemplateParam { .. }
2229                    )
2230                });
2231                has_named_obj || prop.ty.is_subtype_structural(value)
2232            })
2233        }
2234        (
2235            Atomic::TKeyedArray {
2236                properties,
2237                is_open,
2238                ..
2239            },
2240            Atomic::TNonEmptyArray { key, value },
2241        ) => {
2242            (*is_open || properties.iter().any(|(_, p)| !p.optional))
2243                && properties.iter().all(|(prop_key, prop)| {
2244                    let key_atomic = match prop_key {
2245                        crate::atomic::ArrayKey::String(s) => Atomic::TLiteralString(s.clone()),
2246                        crate::atomic::ArrayKey::Int(n) => Atomic::TLiteralInt(*n),
2247                    };
2248                    if !Type::single(key_atomic).is_subtype_structural(key) {
2249                        return false;
2250                    }
2251                    let has_named_obj = prop.ty.types.iter().any(|a| {
2252                        matches!(
2253                            a,
2254                            Atomic::TNamedObject { .. }
2255                                | Atomic::TSelf { .. }
2256                                | Atomic::TStaticObject { .. }
2257                                | Atomic::TClosure { .. }
2258                                | Atomic::TTemplateParam { .. }
2259                        )
2260                    });
2261                    has_named_obj || prop.ty.is_subtype_structural(value)
2262                })
2263        }
2264
2265        // A list-shaped keyed array (is_list=true, all int keys) is a subtype of list<X>.
2266        (
2267            Atomic::TKeyedArray {
2268                properties,
2269                is_list,
2270                ..
2271            },
2272            Atomic::TList { value: lv },
2273        ) => *is_list && properties.values().all(|p| p.ty.is_subtype_structural(lv)),
2274        (
2275            Atomic::TKeyedArray {
2276                properties,
2277                is_list,
2278                ..
2279            },
2280            Atomic::TNonEmptyList { value: lv },
2281        ) => {
2282            *is_list
2283                && !properties.is_empty()
2284                && properties.values().all(|p| p.ty.is_subtype_structural(lv))
2285        }
2286
2287        // Two shapes: every sup key must be satisfied (present+compatible, or
2288        // absent-but-optional/sub-open), and sub may not have keys sup doesn't
2289        // declare unless sup itself is open. Named-object values are deferred
2290        // to class-hierarchy checks, same as the TArray/TList sup arms above.
2291        (
2292            Atomic::TKeyedArray {
2293                properties: sub_props,
2294                is_open: sub_open,
2295                ..
2296            },
2297            Atomic::TKeyedArray {
2298                properties: sup_props,
2299                is_open: sup_open,
2300                ..
2301            },
2302        ) => {
2303            let keys_satisfied = sup_props
2304                .iter()
2305                .all(|(key, sup_prop)| match sub_props.get(key) {
2306                    Some(sub_prop) => {
2307                        // A key merely optional on the sub side may legally be
2308                        // absent at runtime, so it can't satisfy a sup key that
2309                        // requires it present.
2310                        if !sup_prop.optional && sub_prop.optional {
2311                            return false;
2312                        }
2313                        let has_named_obj = sup_prop.ty.types.iter().any(|a| {
2314                            matches!(
2315                                a,
2316                                Atomic::TNamedObject { .. }
2317                                    | Atomic::TSelf { .. }
2318                                    | Atomic::TStaticObject { .. }
2319                                    | Atomic::TClosure { .. }
2320                                    | Atomic::TTemplateParam { .. }
2321                            )
2322                        });
2323                        has_named_obj || sub_prop.ty.is_subtype_by_template_bounds(&sup_prop.ty)
2324                    }
2325                    None => sup_prop.optional || *sub_open,
2326                });
2327            let no_undeclared_extras =
2328                *sup_open || sub_props.keys().all(|k| sup_props.contains_key(k));
2329            keys_satisfied && no_undeclared_extras
2330        }
2331        // TKeyedArray (array shape) satisfies TIntersection iff it satisfies every
2332        // intersection part. This handles the Psalm idiom of using intersection types
2333        // for "shape plus extra keys allowed" patterns like:
2334        // @psalm-type Context = array<string,mixed> & array{actor:...,target?:...,outcome:...}
2335        (
2336            Atomic::TKeyedArray {
2337                properties,
2338                is_open,
2339                ..
2340            },
2341            Atomic::TIntersection { parts },
2342        ) => parts.iter().all(|part| {
2343            part.types.iter().any(|part_atomic| match part_atomic {
2344                Atomic::TKeyedArray {
2345                    properties: sup_props,
2346                    is_open: sup_open,
2347                    ..
2348                } => {
2349                    let keys_satisfied =
2350                        sup_props
2351                            .iter()
2352                            .all(|(key, sup_prop)| match properties.get(key) {
2353                                Some(sub_prop) => {
2354                                    if !sup_prop.optional && sub_prop.optional {
2355                                        return false;
2356                                    }
2357                                    let has_named_obj = sup_prop.ty.types.iter().any(|a| {
2358                                        matches!(
2359                                            a,
2360                                            Atomic::TNamedObject { .. }
2361                                                | Atomic::TSelf { .. }
2362                                                | Atomic::TStaticObject { .. }
2363                                                | Atomic::TClosure { .. }
2364                                                | Atomic::TTemplateParam { .. }
2365                                        )
2366                                    });
2367                                    has_named_obj
2368                                        || sub_prop.ty.is_subtype_by_template_bounds(&sup_prop.ty)
2369                                        || bare_array_may_fit(&sub_prop.ty, &sup_prop.ty)
2370                                }
2371                                None => *is_open || sup_prop.optional,
2372                            });
2373                    let has_array_part = parts.iter().any(|part| {
2374                        part.types
2375                            .iter()
2376                            .any(|t| matches!(t, Atomic::TArray { .. }))
2377                    });
2378                    let has_keyed_array_part = parts.iter().any(|part| {
2379                        part.types
2380                            .iter()
2381                            .any(|t| matches!(t, Atomic::TKeyedArray { .. }))
2382                    });
2383                    let keys_allowed_by_some_part = if has_array_part {
2384                        true
2385                    } else if has_keyed_array_part {
2386                        properties.keys().all(|k| {
2387                            parts.iter().any(|part| {
2388                                part.types.iter().any(|t| {
2389                                    if let Atomic::TKeyedArray {
2390                                        properties: part_props,
2391                                        ..
2392                                    } = t
2393                                    {
2394                                        part_props.contains_key(k)
2395                                    } else {
2396                                        false
2397                                    }
2398                                })
2399                            })
2400                        })
2401                    } else {
2402                        true
2403                    };
2404                    let no_undeclared_extras = *sup_open || keys_allowed_by_some_part;
2405                    keys_satisfied && no_undeclared_extras
2406                }
2407                Atomic::TArray { key, value } => properties.iter().all(|(prop_key, prop)| {
2408                    let key_atomic = match prop_key {
2409                        crate::atomic::ArrayKey::String(s) => Atomic::TLiteralString(s.clone()),
2410                        crate::atomic::ArrayKey::Int(n) => Atomic::TLiteralInt(*n),
2411                    };
2412                    if !Type::single(key_atomic).is_subtype_structural(key) {
2413                        return false;
2414                    }
2415                    let has_named_obj = prop.ty.types.iter().any(|a| {
2416                        matches!(
2417                            a,
2418                            Atomic::TNamedObject { .. }
2419                                | Atomic::TSelf { .. }
2420                                | Atomic::TStaticObject { .. }
2421                                | Atomic::TClosure { .. }
2422                                | Atomic::TTemplateParam { .. }
2423                        )
2424                    });
2425                    has_named_obj || prop.ty.is_subtype_structural(value)
2426                }),
2427                Atomic::TNamedObject { .. }
2428                | Atomic::TSelf { .. }
2429                | Atomic::TStaticObject { .. }
2430                | Atomic::TClosure { .. }
2431                | Atomic::TTemplateParam { .. } => true,
2432                _ => false,
2433            })
2434        }),
2435
2436        _ => false,
2437    }
2438}
2439
2440/// A bare `array` carries no key/value info, so it may hold any array-shaped `sup`.
2441fn bare_array_may_fit(sub: &Type, sup: &Type) -> bool {
2442    fn array_like(t: &Atomic) -> bool {
2443        match t {
2444            Atomic::TArray { .. }
2445            | Atomic::TNonEmptyArray { .. }
2446            | Atomic::TList { .. }
2447            | Atomic::TNonEmptyList { .. }
2448            | Atomic::TKeyedArray { .. } => true,
2449            Atomic::TIntersection { parts } => parts.iter().all(|p| p.types.iter().all(array_like)),
2450            _ => false,
2451        }
2452    }
2453    matches!(
2454        sub.types.as_slice(),
2455        [Atomic::TArray { key, value }]
2456            if value.is_mixed()
2457                && (key.is_mixed()
2458                    || matches!(key.types.as_slice(), [Atomic::TInt, Atomic::TString] | [Atomic::TString, Atomic::TInt]))
2459    ) && sup.types.iter().all(array_like)
2460}
2461
2462/// Whether each generic type-argument in `sub` is compatible with the
2463/// corresponding argument in `sup`. Arguments are invariant (require structural
2464/// equality) with one exception: an empty array literal (`array{}`) is accepted
2465/// against any array/list argument, so `new Box([])` — inferred as
2466/// `Box<array{}>` — satisfies a declared `Box<list<T>>` for any `T`.
2467fn type_params_compatible(sub: &[Type], sup: &[Type]) -> bool {
2468    if sub.len() != sup.len() {
2469        return false;
2470    }
2471    sub.iter()
2472        .zip(sup.iter())
2473        .all(|(a, b)| a == b || (is_empty_array_literal(a) && is_array_like(b)))
2474}
2475
2476/// True for a non-empty union whose atoms are all empty keyed arrays (`array{}`),
2477/// i.e. the type of an empty array literal `[]`.
2478fn is_empty_array_literal(t: &Type) -> bool {
2479    !t.types.is_empty()
2480        && t.types.iter().all(
2481            |atom| matches!(atom, Atomic::TKeyedArray { properties, .. } if properties.is_empty()),
2482        )
2483}
2484
2485/// True for a non-empty union whose atoms are all array/list types.
2486fn is_array_like(t: &Type) -> bool {
2487    !t.types.is_empty() && t.types.iter().all(|atom| atom.is_array())
2488}
2489
2490// ---------------------------------------------------------------------------
2491// Tests
2492// ---------------------------------------------------------------------------
2493
2494/// Accumulates a union with hash-based dedup of literal strings/ints, so large
2495/// literal sets build in linear time. Equivalent to repeated [`Type::add_type`].
2496pub struct UnionBuilder {
2497    ty: Type,
2498    strings: std::collections::HashSet<std::sync::Arc<str>>,
2499    ints: std::collections::HashSet<i64>,
2500    // No atom present can subsume a new literal (`mixed`, `string`, int ranges).
2501    literals_independent: bool,
2502}
2503
2504impl Default for UnionBuilder {
2505    fn default() -> Self {
2506        Self::new()
2507    }
2508}
2509
2510impl UnionBuilder {
2511    pub fn new() -> Self {
2512        Self {
2513            ty: Type::empty(),
2514            strings: Default::default(),
2515            ints: Default::default(),
2516            literals_independent: true,
2517        }
2518    }
2519
2520    pub fn is_empty(&self) -> bool {
2521        self.ty.is_empty()
2522    }
2523
2524    pub fn merge(&mut self, other: &Type) {
2525        for a in &other.types {
2526            self.add(a.clone());
2527        }
2528        self.ty.possibly_undefined |= other.possibly_undefined;
2529    }
2530
2531    pub fn add(&mut self, atomic: Atomic) {
2532        // A lone `never` is dropped by add_type once anything else arrives.
2533        let lone_never = matches!(self.ty.types.first(), Some(Atomic::TNever));
2534        if self.literals_independent && !lone_never {
2535            match &atomic {
2536                Atomic::TLiteralString(s) => {
2537                    if self.strings.insert(s.clone()) {
2538                        self.ty.types.push(atomic);
2539                    }
2540                    return;
2541                }
2542                Atomic::TLiteralInt(n) => {
2543                    if self.ints.insert(*n) {
2544                        self.ty.types.push(atomic);
2545                    }
2546                    return;
2547                }
2548                _ => {}
2549            }
2550        }
2551        let disturbs_literals = matches!(
2552            atomic,
2553            Atomic::TString | Atomic::TMixed | Atomic::TConditional { .. }
2554        ) || int_bounds(&atomic).is_some();
2555        self.ty.add_type(atomic);
2556        if disturbs_literals || lone_never {
2557            self.resync();
2558        }
2559    }
2560
2561    fn resync(&mut self) {
2562        self.strings.clear();
2563        self.ints.clear();
2564        self.literals_independent = true;
2565        for t in &self.ty.types {
2566            match t {
2567                Atomic::TLiteralString(s) => {
2568                    self.strings.insert(s.clone());
2569                }
2570                Atomic::TLiteralInt(n) => {
2571                    self.ints.insert(*n);
2572                }
2573                Atomic::TMixed | Atomic::TString => self.literals_independent = false,
2574                other if int_bounds(other).is_some() => self.literals_independent = false,
2575                _ => {}
2576            }
2577        }
2578    }
2579
2580    pub fn build(self) -> Type {
2581        self.ty
2582    }
2583}
2584
2585#[cfg(test)]
2586mod tests {
2587    use std::sync::Arc;
2588
2589    use super::*;
2590
2591    fn conditional(
2592        param_name: Option<Name>,
2593        subject: Type,
2594        if_true: Type,
2595        if_false: Type,
2596    ) -> Atomic {
2597        Atomic::TConditional {
2598            data: Box::new(crate::atomic::ConditionalData {
2599                param_name,
2600                subject,
2601                if_true,
2602                if_false,
2603            }),
2604        }
2605    }
2606
2607    #[test]
2608    fn single_is_single() {
2609        let u = Type::single(Atomic::TString);
2610        assert!(u.is_single());
2611        assert!(!u.is_nullable());
2612    }
2613
2614    #[test]
2615    fn nullable_has_null() {
2616        let u = Type::nullable(Atomic::TString);
2617        assert!(u.is_nullable());
2618        assert_eq!(u.types.len(), 2);
2619    }
2620
2621    #[test]
2622    fn add_type_deduplicates() {
2623        let mut u = Type::single(Atomic::TString);
2624        u.add_type(Atomic::TString);
2625        assert_eq!(u.types.len(), 1);
2626    }
2627
2628    #[test]
2629    fn array_key_is_int_string() {
2630        let k = Type::array_key();
2631        assert!(k.is_array_key());
2632        assert_eq!(k.types.len(), 2);
2633    }
2634
2635    #[test]
2636    fn is_array_key_false_for_plain_int() {
2637        assert!(!Type::int().is_array_key());
2638    }
2639
2640    #[test]
2641    fn is_array_key_false_for_mixed() {
2642        assert!(!Type::mixed().is_array_key());
2643    }
2644
2645    #[test]
2646    fn is_array_key_false_for_int_string_null() {
2647        let mut u = Type::array_key();
2648        u.add_type(Atomic::TNull);
2649        assert!(!u.is_array_key());
2650    }
2651
2652    #[test]
2653    fn add_type_literal_subsumed_by_base() {
2654        let mut u = Type::single(Atomic::TInt);
2655        u.add_type(Atomic::TLiteralInt(42));
2656        assert_eq!(u.types.len(), 1);
2657        assert!(matches!(u.types[0], Atomic::TInt));
2658    }
2659
2660    #[test]
2661    fn true_then_false_merges_to_bool() {
2662        let mut u = Type::single(Atomic::TTrue);
2663        u.add_type(Atomic::TFalse);
2664        assert_eq!(u.types.len(), 1);
2665        assert!(matches!(u.types[0], Atomic::TBool));
2666    }
2667
2668    #[test]
2669    fn false_then_true_merges_to_bool() {
2670        let mut u = Type::single(Atomic::TFalse);
2671        u.add_type(Atomic::TTrue);
2672        assert_eq!(u.types.len(), 1);
2673        assert!(matches!(u.types[0], Atomic::TBool));
2674    }
2675
2676    #[test]
2677    fn true_alone_stays_true() {
2678        let u = Type::single(Atomic::TTrue);
2679        assert_eq!(u.types.len(), 1);
2680        assert!(matches!(u.types[0], Atomic::TTrue));
2681    }
2682
2683    #[test]
2684    fn true_false_merge_preserves_other_union_members() {
2685        let mut u = Type::single(Atomic::TTrue);
2686        u.add_type(Atomic::TNull);
2687        u.add_type(Atomic::TFalse);
2688        assert_eq!(u.types.len(), 2);
2689        assert!(u.contains(|t| matches!(t, Atomic::TBool)));
2690        assert!(u.contains(|t| matches!(t, Atomic::TNull)));
2691    }
2692
2693    #[test]
2694    fn add_type_base_widens_literals() {
2695        let mut u = Type::single(Atomic::TLiteralInt(1));
2696        u.add_type(Atomic::TLiteralInt(2));
2697        u.add_type(Atomic::TInt);
2698        assert_eq!(u.types.len(), 1);
2699        assert!(matches!(u.types[0], Atomic::TInt));
2700    }
2701
2702    #[test]
2703    fn mixed_subsumes_everything() {
2704        let mut u = Type::single(Atomic::TString);
2705        u.add_type(Atomic::TMixed);
2706        assert_eq!(u.types.len(), 1);
2707        assert!(u.is_mixed());
2708    }
2709
2710    #[test]
2711    fn remove_null() {
2712        let u = Type::nullable(Atomic::TString);
2713        let narrowed = u.remove_null();
2714        assert!(!narrowed.is_nullable());
2715        assert_eq!(narrowed.types.len(), 1);
2716    }
2717
2718    #[test]
2719    fn narrow_to_truthy_removes_null_false() {
2720        let mut u = Type::empty();
2721        u.add_type(Atomic::TString);
2722        u.add_type(Atomic::TNull);
2723        u.add_type(Atomic::TFalse);
2724        let truthy = u.narrow_to_truthy();
2725        assert!(!truthy.is_nullable());
2726        assert!(!truthy.contains(|t| matches!(t, Atomic::TFalse)));
2727    }
2728
2729    #[test]
2730    fn merge_combines_types() {
2731        let a = Type::single(Atomic::TString);
2732        let b = Type::single(Atomic::TInt);
2733        let merged = Type::merge(&a, &b);
2734        assert_eq!(merged.types.len(), 2);
2735    }
2736
2737    #[test]
2738    fn intersect_keeps_narrower_side_not_self() {
2739        // int ∩ (1|2) must keep the narrower `1|2`, not the wider `int` —
2740        // this is exactly what a `match ($x) { 1, 2 => ... }` arm relies on
2741        // to narrow $x inside its body.
2742        let int_ty = Type::single(Atomic::TInt);
2743        let mut literals = Type::empty();
2744        literals.add_type(Atomic::TLiteralInt(1));
2745        literals.add_type(Atomic::TLiteralInt(2));
2746
2747        let narrowed = int_ty.intersect_with(&literals);
2748        assert_eq!(narrowed.types.len(), 2);
2749        assert!(narrowed.contains(|t| matches!(t, Atomic::TLiteralInt(1))));
2750        assert!(narrowed.contains(|t| matches!(t, Atomic::TLiteralInt(2))));
2751        assert!(!narrowed.contains(|t| matches!(t, Atomic::TInt)));
2752    }
2753
2754    #[test]
2755    fn subtype_literal_int_under_int() {
2756        let sub = Type::single(Atomic::TLiteralInt(5));
2757        let sup = Type::single(Atomic::TInt);
2758        assert!(sub.is_subtype_structural(&sup));
2759    }
2760
2761    #[test]
2762    fn subtype_never_is_bottom() {
2763        let never = Type::never();
2764        let string = Type::single(Atomic::TString);
2765        assert!(never.is_subtype_structural(&string));
2766    }
2767
2768    #[test]
2769    fn subtype_everything_under_mixed() {
2770        let string = Type::single(Atomic::TString);
2771        let mixed = Type::mixed();
2772        assert!(string.is_subtype_structural(&mixed));
2773    }
2774
2775    #[test]
2776    fn subtype_enum_case_under_own_enum() {
2777        let sub = Type::single(Atomic::TLiteralEnumCase {
2778            enum_fqcn: Name::new("RoundingMode"),
2779            case_name: Name::new("Unnecessary"),
2780        });
2781        let sup = Type::single(Atomic::TNamedObject {
2782            fqcn: Name::new("RoundingMode"),
2783            type_params: empty_type_params(),
2784        });
2785        assert!(sub.is_subtype_structural(&sup));
2786    }
2787
2788    #[test]
2789    fn enum_case_not_subtype_of_unrelated_enum() {
2790        let sub = Type::single(Atomic::TLiteralEnumCase {
2791            enum_fqcn: Name::new("RoundingMode"),
2792            case_name: Name::new("Unnecessary"),
2793        });
2794        let sup = Type::single(Atomic::TNamedObject {
2795            fqcn: Name::new("Suit"),
2796            type_params: empty_type_params(),
2797        });
2798        assert!(!sub.is_subtype_structural(&sup));
2799    }
2800
2801    #[test]
2802    fn subtype_enum_case_under_own_self_and_static() {
2803        let case = |enum_fqcn: &str| {
2804            Type::single(Atomic::TLiteralEnumCase {
2805                enum_fqcn: Name::new(enum_fqcn),
2806                case_name: Name::new("A"),
2807            })
2808        };
2809        let own = Type::single(Atomic::TSelf {
2810            fqcn: Name::new("Suit"),
2811        });
2812        let own_static = Type::single(Atomic::TStaticObject {
2813            fqcn: Name::new("Suit"),
2814        });
2815        assert!(case("Suit").is_subtype_structural(&own));
2816        assert!(case("Suit").is_subtype_structural(&own_static));
2817        assert!(!case("Other").is_subtype_structural(&own));
2818    }
2819
2820    #[test]
2821    fn subtype_enum_case_under_bare_object() {
2822        let sub = Type::single(Atomic::TLiteralEnumCase {
2823            enum_fqcn: Name::new("RoundingMode"),
2824            case_name: Name::new("Unnecessary"),
2825        });
2826        let sup = Type::single(Atomic::TObject);
2827        assert!(sub.is_subtype_structural(&sup));
2828    }
2829
2830    #[test]
2831    fn template_substitution() {
2832        let mut bindings = FxHashMap::default();
2833        bindings.insert(Name::new("T"), Type::single(Atomic::TString));
2834
2835        let tmpl = Type::single(Atomic::TTemplateParam {
2836            name: Name::new("T"),
2837            as_type: Box::new(Type::mixed()),
2838            defining_entity: Name::new("MyClass"),
2839        });
2840
2841        let resolved = tmpl.substitute_templates(&bindings);
2842        assert_eq!(resolved.types.len(), 1);
2843        assert!(matches!(resolved.types[0], Atomic::TString));
2844    }
2845
2846    #[test]
2847    fn intersection_is_object() {
2848        let parts = vec![
2849            Type::single(Atomic::TNamedObject {
2850                fqcn: Name::new("Iterator"),
2851                type_params: empty_type_params(),
2852            }),
2853            Type::single(Atomic::TNamedObject {
2854                fqcn: Name::new("Countable"),
2855                type_params: empty_type_params(),
2856            }),
2857        ];
2858        let atomic = Atomic::TIntersection {
2859            parts: vec_to_type_params(parts),
2860        };
2861        assert!(atomic.is_object());
2862        assert!(!atomic.can_be_falsy());
2863        assert!(atomic.can_be_truthy());
2864    }
2865
2866    #[test]
2867    fn intersection_display_two_parts() {
2868        let parts = vec![
2869            Type::single(Atomic::TNamedObject {
2870                fqcn: Name::new("Iterator"),
2871                type_params: empty_type_params(),
2872            }),
2873            Type::single(Atomic::TNamedObject {
2874                fqcn: Name::new("Countable"),
2875                type_params: empty_type_params(),
2876            }),
2877        ];
2878        let u = Type::single(Atomic::TIntersection {
2879            parts: vec_to_type_params(parts),
2880        });
2881        assert_eq!(format!("{u}"), "Iterator&Countable");
2882    }
2883
2884    #[test]
2885    fn intersection_display_three_parts() {
2886        let parts = vec![
2887            Type::single(Atomic::TNamedObject {
2888                fqcn: Name::new("A"),
2889                type_params: empty_type_params(),
2890            }),
2891            Type::single(Atomic::TNamedObject {
2892                fqcn: Name::new("B"),
2893                type_params: empty_type_params(),
2894            }),
2895            Type::single(Atomic::TNamedObject {
2896                fqcn: Name::new("C"),
2897                type_params: empty_type_params(),
2898            }),
2899        ];
2900        let u = Type::single(Atomic::TIntersection {
2901            parts: vec_to_type_params(parts),
2902        });
2903        assert_eq!(format!("{u}"), "A&B&C");
2904    }
2905
2906    #[test]
2907    fn intersection_in_nullable_union_display() {
2908        let intersection = Atomic::TIntersection {
2909            parts: vec_to_type_params(vec![
2910                Type::single(Atomic::TNamedObject {
2911                    fqcn: Name::new("Iterator"),
2912                    type_params: empty_type_params(),
2913                }),
2914                Type::single(Atomic::TNamedObject {
2915                    fqcn: Name::new("Countable"),
2916                    type_params: empty_type_params(),
2917                }),
2918            ]),
2919        };
2920        let mut u = Type::single(intersection);
2921        u.add_type(Atomic::TNull);
2922        assert!(u.is_nullable());
2923        assert!(u.contains(|t| matches!(t, Atomic::TIntersection { .. })));
2924    }
2925
2926    // --- substitute_templates coverage for previously-missing arms ----------
2927
2928    fn t_param(name: &str) -> Type {
2929        Type::single(Atomic::TTemplateParam {
2930            name: Name::new(name),
2931            as_type: Box::new(Type::mixed()),
2932            defining_entity: Name::new("Fn"),
2933        })
2934    }
2935
2936    fn bindings_t_string() -> FxHashMap<Name, Type> {
2937        let mut b = FxHashMap::default();
2938        b.insert(Name::new("T"), Type::single(Atomic::TString));
2939        b
2940    }
2941
2942    #[test]
2943    fn substitute_non_empty_array_key_and_value() {
2944        let ty = Type::single(Atomic::TNonEmptyArray {
2945            key: Box::new(t_param("T")),
2946            value: Box::new(t_param("T")),
2947        });
2948        let result = ty.substitute_templates(&bindings_t_string());
2949        assert_eq!(result.types.len(), 1);
2950        let Atomic::TNonEmptyArray { key, value } = &result.types[0] else {
2951            panic!("expected TNonEmptyArray");
2952        };
2953        assert!(matches!(key.types[0], Atomic::TString));
2954        assert!(matches!(value.types[0], Atomic::TString));
2955    }
2956
2957    #[test]
2958    fn substitute_non_empty_list_value() {
2959        let ty = Type::single(Atomic::TNonEmptyList {
2960            value: Box::new(t_param("T")),
2961        });
2962        let result = ty.substitute_templates(&bindings_t_string());
2963        let Atomic::TNonEmptyList { value } = &result.types[0] else {
2964            panic!("expected TNonEmptyList");
2965        };
2966        assert!(matches!(value.types[0], Atomic::TString));
2967    }
2968
2969    #[test]
2970    fn substitute_keyed_array_property_types() {
2971        use crate::atomic::{ArrayKey, KeyedProperty};
2972        use indexmap::IndexMap;
2973        let mut props = IndexMap::new();
2974        props.insert(
2975            ArrayKey::String(Arc::from("name")),
2976            KeyedProperty {
2977                ty: t_param("T"),
2978                optional: false,
2979            },
2980        );
2981        props.insert(
2982            ArrayKey::String(Arc::from("tag")),
2983            KeyedProperty {
2984                ty: t_param("T"),
2985                optional: true,
2986            },
2987        );
2988        let ty = Type::single(Atomic::TKeyedArray {
2989            properties: Box::new(props),
2990            is_open: true,
2991            is_list: false,
2992        });
2993        let result = ty.substitute_templates(&bindings_t_string());
2994        let Atomic::TKeyedArray {
2995            properties,
2996            is_open,
2997            is_list,
2998        } = &result.types[0]
2999        else {
3000            panic!("expected TKeyedArray");
3001        };
3002        assert!(is_open);
3003        assert!(!is_list);
3004        assert!(matches!(
3005            properties[&ArrayKey::String(Arc::from("name"))].ty.types[0],
3006            Atomic::TString
3007        ));
3008        assert!(properties[&ArrayKey::String(Arc::from("tag"))].optional);
3009        assert!(matches!(
3010            properties[&ArrayKey::String(Arc::from("tag"))].ty.types[0],
3011            Atomic::TString
3012        ));
3013    }
3014
3015    #[test]
3016    fn substitute_callable_params_and_return() {
3017        use crate::atomic::FnParam;
3018        let ty = Type::single(Atomic::TCallable {
3019            params: Some(Box::new([FnParam {
3020                name: Name::new("x"),
3021                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
3022                out_ty: None,
3023                default: None,
3024                is_variadic: false,
3025                is_byref: false,
3026                is_optional: false,
3027            }])),
3028            return_type: Some(Box::new(t_param("T"))),
3029        });
3030        let result = ty.substitute_templates(&bindings_t_string());
3031        let Atomic::TCallable {
3032            params,
3033            return_type,
3034        } = &result.types[0]
3035        else {
3036            panic!("expected TCallable");
3037        };
3038        let param_ty = params.as_ref().unwrap()[0].ty.as_ref().unwrap();
3039        let param_union = param_ty.to_union();
3040        assert!(matches!(param_union.types[0], Atomic::TString));
3041        let ret = return_type.as_ref().unwrap();
3042        assert!(matches!(ret.types[0], Atomic::TString));
3043    }
3044
3045    #[test]
3046    fn substitute_callable_bare_no_panic() {
3047        // callable with no params/return — must not panic and must pass through unchanged
3048        let ty = Type::single(Atomic::TCallable {
3049            params: None,
3050            return_type: None,
3051        });
3052        let result = ty.substitute_templates(&bindings_t_string());
3053        assert!(matches!(
3054            result.types[0],
3055            Atomic::TCallable {
3056                params: None,
3057                return_type: None
3058            }
3059        ));
3060    }
3061
3062    #[test]
3063    fn substitute_closure_params_return_and_this() {
3064        use crate::atomic::FnParam;
3065        let ty = Type::single(Atomic::TClosure {
3066            data: Box::new(crate::atomic::ClosureData {
3067                params: Box::new([FnParam {
3068                    name: Name::new("a"),
3069                    ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
3070                    out_ty: None,
3071                    default: Some(crate::compact::SimpleType::from_union(t_param("T"))),
3072                    is_variadic: true,
3073                    is_byref: true,
3074                    is_optional: true,
3075                }]),
3076                return_type: t_param("T"),
3077                this_type: Some(t_param("T")),
3078            }),
3079        });
3080        let result = ty.substitute_templates(&bindings_t_string());
3081        let Atomic::TClosure { data } = &result.types[0] else {
3082            panic!("expected TClosure");
3083        };
3084        let (params, return_type, this_type) = (&data.params, &data.return_type, &data.this_type);
3085        let p = &params[0];
3086        let ty_union = p.ty.as_ref().unwrap().to_union();
3087        let default_union = p.default.as_ref().unwrap().to_union();
3088        assert!(matches!(ty_union.types[0], Atomic::TString));
3089        assert!(matches!(default_union.types[0], Atomic::TString));
3090        // flags preserved
3091        assert!(p.is_variadic);
3092        assert!(p.is_byref);
3093        assert!(p.is_optional);
3094        assert!(matches!(return_type.types[0], Atomic::TString));
3095        assert!(matches!(
3096            this_type.as_ref().unwrap().types[0],
3097            Atomic::TString
3098        ));
3099    }
3100
3101    #[test]
3102    fn substitute_conditional_all_branches() {
3103        let ty = Type::single(conditional(
3104            None,
3105            t_param("T"),
3106            t_param("T"),
3107            Type::single(Atomic::TInt),
3108        ));
3109        let result = ty.substitute_templates(&bindings_t_string());
3110        let Atomic::TConditional { data } = &result.types[0] else {
3111            panic!("expected TConditional");
3112        };
3113        let (subject, if_true, if_false) = (&data.subject, &data.if_true, &data.if_false);
3114        assert!(matches!(subject.types[0], Atomic::TString));
3115        assert!(matches!(if_true.types[0], Atomic::TString));
3116        assert!(matches!(if_false.types[0], Atomic::TInt));
3117    }
3118
3119    #[test]
3120    fn resolve_conditional_is_null_non_null_arg() {
3121        let ty = Type::single(conditional(
3122            Some(Name::new("x")),
3123            Type::single(Atomic::TNull),
3124            Type::single(Atomic::TInt),
3125            Type::single(Atomic::TString),
3126        ));
3127        let result = ty.resolve_conditional_returns(|name| {
3128            if name == "x" {
3129                Some(Type::single(Atomic::TString)) // definitely not null
3130            } else {
3131                None
3132            }
3133        });
3134        assert!(result.types.len() == 1);
3135        assert!(matches!(result.types[0], Atomic::TString));
3136    }
3137
3138    #[test]
3139    fn resolve_conditional_is_null_null_arg() {
3140        let ty = Type::single(conditional(
3141            Some(Name::new("x")),
3142            Type::single(Atomic::TNull),
3143            Type::single(Atomic::TInt),
3144            Type::single(Atomic::TString),
3145        ));
3146        let result = ty.resolve_conditional_returns(|name| {
3147            if name == "x" {
3148                Some(Type::single(Atomic::TNull)) // definitely null
3149            } else {
3150                None
3151            }
3152        });
3153        assert!(result.types.len() == 1);
3154        assert!(matches!(result.types[0], Atomic::TInt));
3155    }
3156
3157    #[test]
3158    fn resolve_conditional_is_null_nullable_arg_widens_to_branch_union() {
3159        let mut nullable_str = Type::single(Atomic::TString);
3160        nullable_str.add_type(Atomic::TNull);
3161        let ty = Type::single(conditional(
3162            Some(Name::new("x")),
3163            Type::single(Atomic::TNull),
3164            Type::single(Atomic::TInt),
3165            Type::single(Atomic::TString),
3166        ));
3167        let result = ty.resolve_conditional_returns(|name| {
3168            if name == "x" {
3169                Some(nullable_str.clone())
3170            } else {
3171                None
3172            }
3173        });
3174        // uncertain discriminator → widen to if_true | if_false
3175        assert_eq!(result.types.len(), 2);
3176        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
3177        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
3178    }
3179
3180    #[test]
3181    fn resolve_conditional_nested_widens_inner_branch() {
3182        // ($x is null ? int : ($x is string ? string : float))
3183        // When $x is unknown, should widen to int|string|float (no TConditional remaining).
3184        let inner = Type::single(conditional(
3185            Some(Name::new("x")),
3186            Type::single(Atomic::TString),
3187            Type::single(Atomic::TString),
3188            Type::single(Atomic::TFloat),
3189        ));
3190        let ty = Type::single(conditional(
3191            Some(Name::new("x")),
3192            Type::single(Atomic::TNull),
3193            Type::single(Atomic::TInt),
3194            inner,
3195        ));
3196        // unknown arg → widen both outer branches, inner conditional must also be widened
3197        let result = ty.resolve_conditional_returns(|_| None);
3198        assert!(
3199            result
3200                .types
3201                .iter()
3202                .all(|t| !matches!(t, Atomic::TConditional { .. })),
3203            "no TConditional should survive: {:?}",
3204            result.types
3205        );
3206        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
3207        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
3208        assert!(result.types.iter().any(|t| matches!(t, Atomic::TFloat)));
3209    }
3210
3211    #[test]
3212    fn resolve_conditional_nested_resolves_inner_branch() {
3213        // ($x is null ? int : ($x is string ? string : float))
3214        // When $x is definitely not null but unknown string-or-not → resolves outer to inner,
3215        // then inner must also be resolved.
3216        let inner = Type::single(conditional(
3217            Some(Name::new("x")),
3218            Type::single(Atomic::TString),
3219            Type::single(Atomic::TString),
3220            Type::single(Atomic::TFloat),
3221        ));
3222        let ty = Type::single(conditional(
3223            Some(Name::new("x")),
3224            Type::single(Atomic::TNull),
3225            Type::single(Atomic::TInt),
3226            inner,
3227        ));
3228        // $x = string → outer: not null → if_false (inner); inner: is string → if_true = string
3229        let result = ty.resolve_conditional_returns(|name| {
3230            if name == "x" {
3231                Some(Type::single(Atomic::TString))
3232            } else {
3233                None
3234            }
3235        });
3236        assert!(
3237            result
3238                .types
3239                .iter()
3240                .all(|t| !matches!(t, Atomic::TConditional { .. })),
3241            "no TConditional should survive: {:?}",
3242            result.types
3243        );
3244        assert_eq!(result.types.len(), 1);
3245        assert!(matches!(result.types[0], Atomic::TString));
3246    }
3247
3248    #[test]
3249    fn resolve_conditional_true_subject_bool_arg_widens() {
3250        // A `bool` argument carries the value classes `{true, false}`:
3251        // `false` is not contained in the `true` class, and `true`
3252        // overlaps it, so neither branch is ruled out.
3253        let ty = Type::single(conditional(
3254            Some(Name::new("x")),
3255            Type::single(Atomic::TTrue),
3256            Type::single(Atomic::TInt),
3257            Type::single(Atomic::TString),
3258        ));
3259        let result = ty.resolve_conditional_returns(|name| {
3260            if name == "x" {
3261                Some(Type::single(Atomic::TBool))
3262            } else {
3263                None
3264            }
3265        });
3266        assert_eq!(result.types.len(), 2);
3267        assert!(result.contains(|t| matches!(t, Atomic::TInt)));
3268        assert!(result.contains(|t| matches!(t, Atomic::TString)));
3269    }
3270
3271    #[test]
3272    fn resolve_conditional_true_subject_mixed_arg_widens() {
3273        // `mixed` maps to the `Top` value class, which overlaps every
3274        // other class, so it can never rule a branch out.
3275        let ty = Type::single(conditional(
3276            Some(Name::new("x")),
3277            Type::single(Atomic::TTrue),
3278            Type::single(Atomic::TInt),
3279            Type::single(Atomic::TString),
3280        ));
3281        let result = ty.resolve_conditional_returns(|name| {
3282            if name == "x" {
3283                Some(Type::mixed())
3284            } else {
3285                None
3286            }
3287        });
3288        assert_eq!(result.types.len(), 2);
3289        assert!(result.contains(|t| matches!(t, Atomic::TInt)));
3290        assert!(result.contains(|t| matches!(t, Atomic::TString)));
3291    }
3292
3293    #[test]
3294    fn resolve_conditional_true_subject_literal_args() {
3295        // Literals decide by value class: `false` is contained in
3296        // `{false}` and disjoint from `{true}`, and vice versa.
3297        let ty = Type::single(conditional(
3298            Some(Name::new("x")),
3299            Type::single(Atomic::TTrue),
3300            Type::single(Atomic::TInt),
3301            Type::single(Atomic::TString),
3302        ));
3303        let false_arg = ty.clone().resolve_conditional_returns(|name| {
3304            if name == "x" {
3305                Some(Type::single(Atomic::TFalse))
3306            } else {
3307                None
3308            }
3309        });
3310        assert_eq!(false_arg.types.len(), 1);
3311        assert!(matches!(false_arg.types[0], Atomic::TString));
3312
3313        let true_arg = ty.resolve_conditional_returns(|name| {
3314            if name == "x" {
3315                Some(Type::single(Atomic::TTrue))
3316            } else {
3317                None
3318            }
3319        });
3320        assert_eq!(true_arg.types.len(), 1);
3321        assert!(matches!(true_arg.types[0], Atomic::TInt));
3322    }
3323
3324    #[test]
3325    fn resolve_conditional_bool_subject_scalar_arg_widens() {
3326        // `scalar` carries all five scalar classes: `true`/`false`
3327        // overlap the `bool` subject, but `int`/`float`/`string` are
3328        // not contained in `{true, false}`, so neither branch commits.
3329        let ty = Type::single(conditional(
3330            Some(Name::new("x")),
3331            Type::single(Atomic::TBool),
3332            Type::single(Atomic::TInt),
3333            Type::single(Atomic::TString),
3334        ));
3335        let result = ty.resolve_conditional_returns(|name| {
3336            if name == "x" {
3337                Some(Type::single(Atomic::TScalar))
3338            } else {
3339                None
3340            }
3341        });
3342        assert_eq!(result.types.len(), 2);
3343        assert!(result.contains(|t| matches!(t, Atomic::TInt)));
3344        assert!(result.contains(|t| matches!(t, Atomic::TString)));
3345    }
3346
3347    #[test]
3348    fn resolve_conditional_string_subject_bool_arg_false_branch() {
3349        // Disjoint value classes still commit to the false branch: a
3350        // `bool` argument carries `{true, false}`, none of which is a
3351        // string, and strings are not bools.
3352        let ty = Type::single(conditional(
3353            Some(Name::new("x")),
3354            Type::single(Atomic::TString),
3355            Type::single(Atomic::TInt),
3356            Type::single(Atomic::TBool),
3357        ));
3358        let result = ty.resolve_conditional_returns(|name| {
3359            if name == "x" {
3360                Some(Type::single(Atomic::TBool))
3361            } else {
3362                None
3363            }
3364        });
3365        assert_eq!(result.types.len(), 1);
3366        assert!(matches!(result.types[0], Atomic::TBool));
3367    }
3368
3369    #[test]
3370    fn resolve_conditional_list_subject_bare_array_arg_widens() {
3371        // A bare `array` is the supertype of `list`, so it may or may
3372        // not be a list: both branches stay live.
3373        let ty = Type::single(conditional(
3374            Some(Name::new("x")),
3375            Type::single(Atomic::TList {
3376                value: Box::new(Type::mixed()),
3377            }),
3378            Type::single(Atomic::TInt),
3379            Type::single(Atomic::TString),
3380        ));
3381        let result = ty.resolve_conditional_returns(|name| {
3382            if name == "x" {
3383                Some(Type::single(Atomic::TArray {
3384                    key: Box::new(Type::mixed()),
3385                    value: Box::new(Type::mixed()),
3386                }))
3387            } else {
3388                None
3389            }
3390        });
3391        assert_eq!(result.types.len(), 2);
3392        assert!(result.contains(|t| matches!(t, Atomic::TInt)));
3393        assert!(result.contains(|t| matches!(t, Atomic::TString)));
3394    }
3395
3396    #[test]
3397    fn resolve_conditional_float_subject_int_literal_false_branch() {
3398        // A `float` subject is the `Float` class; an int literal is
3399        // `LitInt`, which is not a float, and floats are not ints.
3400        let ty = resolve_conditional_branch(
3401            &Atomic::TFloat,
3402            &Type::single(Atomic::TLiteralInt(42)),
3403            &Type::single(Atomic::TInt),
3404            &Type::single(Atomic::TString),
3405        );
3406        assert!(matches!(
3407            ty.as_ref(),
3408            Some(t) if t.types.len() == 1 && matches!(t.types[0], Atomic::TString)
3409        ));
3410    }
3411
3412    #[test]
3413    fn resolve_conditional_int_subject_float_literal_false_branch() {
3414        let ty = resolve_conditional_branch(
3415            &Atomic::TInt,
3416            &Type::single(Atomic::TLiteralFloat(1, 0)),
3417            &Type::single(Atomic::TInt),
3418            &Type::single(Atomic::TString),
3419        );
3420        assert!(matches!(
3421            ty.as_ref(),
3422            Some(t) if t.types.len() == 1 && matches!(t.types[0], Atomic::TString)
3423        ));
3424    }
3425
3426    #[test]
3427    fn resolve_conditional_float_subject_float_literal_then_branch() {
3428        let ty = resolve_conditional_branch(
3429            &Atomic::TFloat,
3430            &Type::single(Atomic::TLiteralFloat(1, 5)),
3431            &Type::single(Atomic::TInt),
3432            &Type::single(Atomic::TString),
3433        );
3434        assert!(matches!(
3435            ty.as_ref(),
3436            Some(t) if t.types.len() == 1 && matches!(t.types[0], Atomic::TInt)
3437        ));
3438    }
3439
3440    #[test]
3441    fn resolve_conditional_int_subject_int_literal_then_branch() {
3442        let ty = resolve_conditional_branch(
3443            &Atomic::TInt,
3444            &Type::single(Atomic::TLiteralInt(7)),
3445            &Type::single(Atomic::TInt),
3446            &Type::single(Atomic::TString),
3447        );
3448        assert!(matches!(
3449            ty.as_ref(),
3450            Some(t) if t.types.len() == 1 && matches!(t.types[0], Atomic::TInt)
3451        ));
3452    }
3453
3454    #[test]
3455    fn resolve_conditional_int_subject_float_arg_false_branch() {
3456        // A bare `float` argument is never an `int`: disjoint value
3457        // classes commit to the false branch.
3458        let ty = resolve_conditional_branch(
3459            &Atomic::TInt,
3460            &Type::single(Atomic::TFloat),
3461            &Type::single(Atomic::TInt),
3462            &Type::single(Atomic::TString),
3463        );
3464        assert!(matches!(
3465            ty.as_ref(),
3466            Some(t) if t.types.len() == 1 && matches!(t.types[0], Atomic::TString)
3467        ));
3468    }
3469
3470    #[test]
3471    fn resolve_conditional_array_subject_string_arg_false_branch() {
3472        let ty = resolve_conditional_branch(
3473            &Atomic::TArray {
3474                key: Box::new(Type::mixed()),
3475                value: Box::new(Type::mixed()),
3476            },
3477            &Type::single(Atomic::TString),
3478            &Type::single(Atomic::TInt),
3479            &Type::single(Atomic::TString),
3480        );
3481        assert!(matches!(
3482            ty.as_ref(),
3483            Some(t) if t.types.len() == 1 && matches!(t.types[0], Atomic::TString)
3484        ));
3485    }
3486
3487    #[test]
3488    fn resolve_conditional_array_subject_bool_arg_false_branch() {
3489        let ty = resolve_conditional_branch(
3490            &Atomic::TArray {
3491                key: Box::new(Type::mixed()),
3492                value: Box::new(Type::mixed()),
3493            },
3494            &Type::single(Atomic::TBool),
3495            &Type::single(Atomic::TInt),
3496            &Type::single(Atomic::TString),
3497        );
3498        assert!(matches!(
3499            ty.as_ref(),
3500            Some(t) if t.types.len() == 1 && matches!(t.types[0], Atomic::TString)
3501        ));
3502    }
3503
3504    #[test]
3505    fn resolve_conditional_array_subject_mixed_arg_widens() {
3506        // A `mixed` argument is `Top`: it overlaps `array`, so the then
3507        // branch is never ruled out, and it is not contained in `array`,
3508        // so the false branch is never ruled out either.
3509        let ty = resolve_conditional_branch(
3510            &Atomic::TArray {
3511                key: Box::new(Type::mixed()),
3512                value: Box::new(Type::mixed()),
3513            },
3514            &Type::mixed(),
3515            &Type::single(Atomic::TInt),
3516            &Type::single(Atomic::TString),
3517        );
3518        assert!(ty.is_none());
3519    }
3520
3521    #[test]
3522    fn resolve_conditional_array_subject_list_arg_then_branch() {
3523        // A `list` argument is an `array` (`List` is contained in
3524        // `Array`), so every argument class is in the subject's class.
3525        let ty = resolve_conditional_branch(
3526            &Atomic::TArray {
3527                key: Box::new(Type::mixed()),
3528                value: Box::new(Type::mixed()),
3529            },
3530            &Type::single(Atomic::TList {
3531                value: Box::new(Type::mixed()),
3532            }),
3533            &Type::single(Atomic::TInt),
3534            &Type::single(Atomic::TString),
3535        );
3536        assert!(matches!(
3537            ty.as_ref(),
3538            Some(t) if t.types.len() == 1 && matches!(t.types[0], Atomic::TInt)
3539        ));
3540    }
3541
3542    #[test]
3543    fn resolve_conditional_list_subject_keyed_list_arg_then_branch() {
3544        // A keyed array with `is_list` is a list at runtime.
3545        let ty = resolve_conditional_branch(
3546            &Atomic::TList {
3547                value: Box::new(Type::mixed()),
3548            },
3549            &Type::single(Atomic::TKeyedArray {
3550                properties: Box::default(),
3551                is_open: false,
3552                is_list: true,
3553            }),
3554            &Type::single(Atomic::TInt),
3555            &Type::single(Atomic::TString),
3556        );
3557        assert!(matches!(
3558            ty.as_ref(),
3559            Some(t) if t.types.len() == 1 && matches!(t.types[0], Atomic::TInt)
3560        ));
3561    }
3562
3563    #[test]
3564    fn resolve_conditional_list_subject_list_string_union_widens() {
3565        // A `list|string` argument carries `{List, String}`: `List`
3566        // overlaps the `list` subject, `String` does not, so neither
3567        // branch is ruled out.
3568        let ty = resolve_conditional_branch(
3569            &Atomic::TList {
3570                value: Box::new(Type::mixed()),
3571            },
3572            &Type::from_vec(vec![
3573                Atomic::TList {
3574                    value: Box::new(Type::mixed()),
3575                },
3576                Atomic::TString,
3577            ]),
3578            &Type::single(Atomic::TInt),
3579            &Type::single(Atomic::TString),
3580        );
3581        assert!(ty.is_none());
3582    }
3583
3584    #[test]
3585    fn resolve_conditional_numeric_subject_widens() {
3586        // `numeric` is a refined subject (it admits numeric strings such
3587        // as `"123"`), so the value-class lattice cannot reduce it: the
3588        // branch stays undecidable even for a literal string argument.
3589        let ty = resolve_conditional_branch(
3590            &Atomic::TNumeric,
3591            &Type::single(Atomic::TLiteralString("123".into())),
3592            &Type::single(Atomic::TInt),
3593            &Type::single(Atomic::TString),
3594        );
3595        assert!(ty.is_none());
3596    }
3597
3598    #[test]
3599    fn resolve_conditional_non_empty_string_subject_widens() {
3600        // A `non-empty-string` subject is refined (`""` is a string but
3601        // not non-empty), so even a bare string argument is undecidable.
3602        let ty = resolve_conditional_branch(
3603            &Atomic::TNonEmptyString,
3604            &Type::single(Atomic::TString),
3605            &Type::single(Atomic::TInt),
3606            &Type::single(Atomic::TString),
3607        );
3608        assert!(ty.is_none());
3609    }
3610
3611    #[test]
3612    fn resolve_conditional_template_arg_widens() {
3613        // A template parameter has no value classes at all (opaque), so
3614        // it is undecidable against any subject.
3615        let ty = resolve_conditional_branch(
3616            &Atomic::TString,
3617            &t_param("T"),
3618            &Type::single(Atomic::TInt),
3619            &Type::single(Atomic::TString),
3620        );
3621        assert!(ty.is_none());
3622    }
3623
3624    #[test]
3625    fn resolve_conditional_mixed_subject_bool_arg_then_branch() {
3626        // `mixed` is a decidable subject: every value class is contained
3627        // in `Top`, so a bool argument is always `mixed`.
3628        let ty = resolve_conditional_branch(
3629            &Atomic::TMixed,
3630            &Type::single(Atomic::TBool),
3631            &Type::single(Atomic::TInt),
3632            &Type::single(Atomic::TString),
3633        );
3634        assert!(matches!(
3635            ty.as_ref(),
3636            Some(t) if t.types.len() == 1 && matches!(t.types[0], Atomic::TInt)
3637        ));
3638    }
3639
3640    #[test]
3641    fn resolve_conditional_scalar_subject_null_arg_false_branch() {
3642        // `null` is not a scalar value class: the argument is disjoint
3643        // from the subject, so the false branch is taken.
3644        let ty = resolve_conditional_branch(
3645            &Atomic::TScalar,
3646            &Type::single(Atomic::TNull),
3647            &Type::single(Atomic::TInt),
3648            &Type::single(Atomic::TString),
3649        );
3650        assert!(matches!(
3651            ty.as_ref(),
3652            Some(t) if t.types.len() == 1 && matches!(t.types[0], Atomic::TString)
3653        ));
3654    }
3655
3656    #[test]
3657    fn resolve_conditional_object_subject_named_arg_then_branch() {
3658        // A named class instance is an object at runtime.
3659        let ty = resolve_conditional_branch(
3660            &Atomic::TObject,
3661            &Type::single(Atomic::TNamedObject {
3662                fqcn: Name::new("Foo"),
3663                type_params: empty_type_params(),
3664            }),
3665            &Type::single(Atomic::TInt),
3666            &Type::single(Atomic::TString),
3667        );
3668        assert!(matches!(
3669            ty.as_ref(),
3670            Some(t) if t.types.len() == 1 && matches!(t.types[0], Atomic::TInt)
3671        ));
3672    }
3673
3674    #[test]
3675    fn resolve_conditional_named_subject_other_class_widens() {
3676        // A named-object subject is refined (one specific class), so an
3677        // instance of another class is undecidable even though both are
3678        // objects.
3679        let ty = resolve_conditional_branch(
3680            &Atomic::TNamedObject {
3681                fqcn: Name::new("Foo"),
3682                type_params: empty_type_params(),
3683            },
3684            &Type::single(Atomic::TNamedObject {
3685                fqcn: Name::new("Bar"),
3686                type_params: empty_type_params(),
3687            }),
3688            &Type::single(Atomic::TInt),
3689            &Type::single(Atomic::TString),
3690        );
3691        assert!(ty.is_none());
3692    }
3693
3694    #[test]
3695    fn resolve_conditional_enum_case_subject_widens() {
3696        // An enum-case subject is refined: the value-class lattice cannot
3697        // reduce a specific enum case, so an int argument is undecidable.
3698        let ty = resolve_conditional_branch(
3699            &Atomic::TLiteralEnumCase {
3700                enum_fqcn: Name::new("RoundingMode"),
3701                case_name: Name::new("Unnecessary"),
3702            },
3703            &Type::single(Atomic::TInt),
3704            &Type::single(Atomic::TInt),
3705            &Type::single(Atomic::TString),
3706        );
3707        assert!(ty.is_none());
3708    }
3709
3710    #[test]
3711    fn resolve_conditional_null_subject_bool_arg_false_branch() {
3712        // `null` and bool are disjoint value classes.
3713        let ty = resolve_conditional_branch(
3714            &Atomic::TNull,
3715            &Type::single(Atomic::TBool),
3716            &Type::single(Atomic::TInt),
3717            &Type::single(Atomic::TString),
3718        );
3719        assert!(matches!(
3720            ty.as_ref(),
3721            Some(t) if t.types.len() == 1 && matches!(t.types[0], Atomic::TString)
3722        ));
3723    }
3724
3725    #[test]
3726    fn resolve_conditional_bool_subject_true_arg_then_branch() {
3727        // `true` is contained in `bool`'s `{true, false}` classes, so
3728        // the then branch is taken.
3729        let ty = resolve_conditional_branch(
3730            &Atomic::TBool,
3731            &Type::single(Atomic::TTrue),
3732            &Type::single(Atomic::TInt),
3733            &Type::single(Atomic::TString),
3734        );
3735        assert!(matches!(
3736            ty.as_ref(),
3737            Some(t) if t.types.len() == 1 && matches!(t.types[0], Atomic::TInt)
3738        ));
3739    }
3740
3741    #[test]
3742    fn substitute_intersection_parts() {
3743        let ty = Type::single(Atomic::TIntersection {
3744            parts: vec_to_type_params(vec![
3745                Type::single(Atomic::TNamedObject {
3746                    fqcn: Name::new("Countable"),
3747                    type_params: empty_type_params(),
3748                }),
3749                t_param("T"),
3750            ]),
3751        });
3752        let result = ty.substitute_templates(&bindings_t_string());
3753        let Atomic::TIntersection { parts } = &result.types[0] else {
3754            panic!("expected TIntersection");
3755        };
3756        assert_eq!(parts.len(), 2);
3757        assert!(matches!(parts[0].types[0], Atomic::TNamedObject { .. }));
3758        assert!(matches!(parts[1].types[0], Atomic::TString));
3759    }
3760
3761    #[test]
3762    fn substitute_no_template_params_identity() {
3763        let ty = Type::single(Atomic::TInt);
3764        let result = ty.substitute_templates(&bindings_t_string());
3765        assert!(matches!(result.types[0], Atomic::TInt));
3766    }
3767}