Skip to main content

mir_types/
union.rs

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