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