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