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