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