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