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