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