Skip to main content

mir_types/
union.rs

1use rustc_hash::FxHashMap;
2use serde::{Deserialize, Serialize};
3use smallvec::SmallVec;
4use std::sync::{Arc, OnceLock};
5
6use crate::atomic::Atomic;
7use crate::symbol::Name;
8
9/// Returns a cached empty `Arc<[Type]>` for `type_params` / `parts` fields.
10/// Re-uses a single Arc allocation so all empty parameter lists share one
11/// control block instead of allocating one per TNamedObject construction.
12pub fn empty_type_params() -> Arc<[Type]> {
13    static EMPTY: OnceLock<Arc<[Type]>> = OnceLock::new();
14    EMPTY.get_or_init(|| Arc::from([] as [Type; 0])).clone()
15}
16
17/// Convert a `Vec<Type>` to `Arc<[Type]>`, using the cached empty Arc when
18/// the vec is empty to avoid an allocation for the common no-generic case.
19pub fn vec_to_type_params(v: Vec<Type>) -> Arc<[Type]> {
20    if v.is_empty() {
21        empty_type_params()
22    } else {
23        Arc::from(v)
24    }
25}
26
27// Most unions contain 1-2 atomics (e.g. `string|null`), so we inline two.
28pub type AtomicVec = SmallVec<[Atomic; 2]>;
29
30/// Result of classifying a type for `clone` validity (see [`Type::clone_validity`]).
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum CloneValidity {
33    /// Every member is (or may be) an object — cloning is fine.
34    Cloneable,
35    /// Every member is definitely a non-object — cloning is an error.
36    Invalid,
37    /// Some members are non-objects, some are objects — cloning may be an error.
38    PossiblyInvalid,
39    /// Empty/unknown type — no diagnostic.
40    Unknown,
41}
42
43// ---------------------------------------------------------------------------
44// Type — the primary type carrier
45// ---------------------------------------------------------------------------
46
47#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
48pub struct Type {
49    pub types: AtomicVec,
50    /// The variable holding this type may not be initialized at this point.
51    pub possibly_undefined: bool,
52    /// This type originated from a docblock annotation rather than inference.
53    pub from_docblock: bool,
54}
55
56impl Type {
57    // --- Constructors -------------------------------------------------------
58
59    pub fn empty() -> Self {
60        Self {
61            types: SmallVec::new(),
62            possibly_undefined: false,
63            from_docblock: false,
64        }
65    }
66
67    pub fn single(atomic: Atomic) -> Self {
68        let mut types = SmallVec::new();
69        types.push(atomic);
70        Self {
71            types,
72            possibly_undefined: false,
73            from_docblock: false,
74        }
75    }
76
77    pub fn mixed() -> Self {
78        Self::single(Atomic::TMixed)
79    }
80
81    pub fn void() -> Self {
82        Self::single(Atomic::TVoid)
83    }
84
85    pub fn never() -> Self {
86        Self::single(Atomic::TNever)
87    }
88
89    pub fn null() -> Self {
90        Self::single(Atomic::TNull)
91    }
92
93    pub fn bool() -> Self {
94        Self::single(Atomic::TBool)
95    }
96
97    pub fn int() -> Self {
98        Self::single(Atomic::TInt)
99    }
100
101    pub fn float() -> Self {
102        Self::single(Atomic::TFloat)
103    }
104
105    pub fn string() -> Self {
106        Self::single(Atomic::TString)
107    }
108
109    /// `int|string` — the canonical PHP array-key type, used as the default
110    /// key type when a docblock/inferred array has no more specific key.
111    pub fn array_key() -> Self {
112        let mut u = Self::single(Atomic::TInt);
113        u.add_type(Atomic::TString);
114        u
115    }
116
117    /// `T|null`
118    pub fn nullable(atomic: Atomic) -> Self {
119        // `mixed|null` = `mixed` — null is already included in mixed.
120        if matches!(atomic, Atomic::TMixed) {
121            return Self::mixed();
122        }
123        let mut types = SmallVec::new();
124        types.push(atomic);
125        types.push(Atomic::TNull);
126        Self {
127            types,
128            possibly_undefined: false,
129            from_docblock: false,
130        }
131    }
132
133    /// Build a union from multiple atomics, de-duplicating on the fly.
134    pub fn from_vec(atomics: Vec<Atomic>) -> Self {
135        let mut u = Self::empty();
136        for a in atomics {
137            u.add_type(a);
138        }
139        u
140    }
141
142    // --- Introspection -------------------------------------------------------
143
144    pub fn is_empty(&self) -> bool {
145        self.types.is_empty()
146    }
147
148    pub fn is_single(&self) -> bool {
149        self.types.len() == 1
150    }
151
152    pub fn is_nullable(&self) -> bool {
153        self.types.iter().any(|t| matches!(t, Atomic::TNull))
154    }
155
156    /// True when this is exactly `int|string` — the array-key domain, which
157    /// is already the maximal set of legal PHP array keys and so should be
158    /// treated like a "default"/unconstrained key, same as `mixed` would be
159    /// for a non-key type parameter.
160    pub fn is_array_key(&self) -> bool {
161        self.types.len() == 2
162            && self.types.iter().any(|t| matches!(t, Atomic::TInt))
163            && self.types.iter().any(|t| matches!(t, Atomic::TString))
164    }
165
166    pub fn is_mixed(&self) -> bool {
167        self.types.iter().any(|t| match t {
168            Atomic::TMixed => true,
169            Atomic::TTemplateParam { as_type, .. } => as_type.is_mixed(),
170            _ => false,
171        })
172    }
173
174    /// True only when the type contains `TMixed` atoms and no `TTemplateParam` atoms.
175    /// Unlike [`is_mixed`], this does not treat an unconstrained template parameter as
176    /// "mixed" — a `T` placeholder is an intentionally parameterised type that will be
177    /// instantiated at the call site, so it must not trigger `MixedAssignment` warnings.
178    pub fn is_mixed_not_template(&self) -> bool {
179        self.is_mixed()
180            && !self
181                .types
182                .iter()
183                .any(|t| matches!(t, Atomic::TTemplateParam { .. }))
184    }
185
186    pub fn is_never(&self) -> bool {
187        self.types.iter().all(|t| matches!(t, Atomic::TNever)) && !self.types.is_empty()
188    }
189
190    /// Classify this type for `clone` validity. Recurses into template-param
191    /// bounds (like [`Type::is_mixed`]). Callers handle `mixed` separately.
192    pub fn clone_validity(&self) -> CloneValidity {
193        if self.types.is_empty() {
194            return CloneValidity::Unknown;
195        }
196        let mut has_non_object = false;
197        let mut has_other = false; // object or ambiguous (callable, mixed, conditional, …)
198        for t in &self.types {
199            match t {
200                Atomic::TTemplateParam { as_type, .. } => match as_type.clone_validity() {
201                    CloneValidity::Invalid => has_non_object = true,
202                    CloneValidity::PossiblyInvalid => {
203                        has_non_object = true;
204                        has_other = true;
205                    }
206                    CloneValidity::Cloneable | CloneValidity::Unknown => has_other = true,
207                },
208                other if other.is_definitely_non_object() => has_non_object = true,
209                _ => has_other = true,
210            }
211        }
212        match (has_non_object, has_other) {
213            (true, false) => CloneValidity::Invalid,
214            (true, true) => CloneValidity::PossiblyInvalid,
215            _ => CloneValidity::Cloneable,
216        }
217    }
218
219    pub fn is_void(&self) -> bool {
220        self.is_single() && matches!(self.types[0], Atomic::TVoid)
221    }
222
223    pub fn can_be_falsy(&self) -> bool {
224        self.types.iter().any(|t| t.can_be_falsy())
225    }
226
227    pub fn can_be_truthy(&self) -> bool {
228        self.types.iter().any(|t| t.can_be_truthy())
229    }
230
231    pub fn contains<F: Fn(&Atomic) -> bool>(&self, f: F) -> bool {
232        self.types.iter().any(f)
233    }
234
235    pub fn has_named_object(&self, fqcn: &str) -> bool {
236        self.types.iter().any(|t| match t {
237            Atomic::TNamedObject { fqcn: f, .. } => f.as_ref() == fqcn,
238            _ => false,
239        })
240    }
241
242    // --- Mutation ------------------------------------------------------------
243
244    /// Add an atomic to this union, skipping duplicates.
245    /// Subsumption rules: anything ⊆ TMixed; TLiteralInt ⊆ TInt; etc.
246    pub fn add_type(&mut self, atomic: Atomic) {
247        // If we already have TMixed, nothing to add.
248        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
249            return;
250        }
251
252        // Adding TMixed subsumes everything.
253        if matches!(atomic, Atomic::TMixed) {
254            self.types.clear();
255            self.types.push(Atomic::TMixed);
256            return;
257        }
258
259        // Simplify trivial conditional types: (X is ? T : T) → T
260        // Recursively simplify branches first so nested trivial conditionals collapse.
261        let atomic = if let Atomic::TConditional { data } = &atomic {
262            let (if_true, if_false) = (&data.if_true, &data.if_false);
263            let mut simplified_true = Type::empty();
264            for t in &if_true.types {
265                simplified_true.add_type(t.clone());
266            }
267            let mut simplified_false = Type::empty();
268            for t in &if_false.types {
269                simplified_false.add_type(t.clone());
270            }
271            if simplified_true == simplified_false {
272                for t in simplified_true.types {
273                    self.add_type(t);
274                }
275                return;
276            }
277            atomic
278        } else {
279            atomic
280        };
281
282        // Avoid exact duplicates.
283        if self.types.contains(&atomic) {
284            return;
285        }
286
287        // TLiteralInt(n) is subsumed by TInt.
288        if let Atomic::TLiteralInt(_) = &atomic {
289            if self.types.iter().any(|t| matches!(t, Atomic::TInt)) {
290                return;
291            }
292        }
293        // TLiteralString(s) is subsumed by TString.
294        if let Atomic::TLiteralString(_) = &atomic {
295            if self.types.iter().any(|t| matches!(t, Atomic::TString)) {
296                return;
297            }
298        }
299        // TTrue / TFalse are subsumed by TBool.
300        if matches!(atomic, Atomic::TTrue | Atomic::TFalse)
301            && self.types.iter().any(|t| matches!(t, Atomic::TBool))
302        {
303            return;
304        }
305        // TTrue and TFalse together are exactly TBool — merge rather than
306        // keeping both literals once both are present.
307        if matches!(atomic, Atomic::TTrue) && self.types.iter().any(|t| matches!(t, Atomic::TFalse))
308        {
309            self.types.retain(|t| !matches!(t, Atomic::TFalse));
310            self.types.push(Atomic::TBool);
311            return;
312        }
313        if matches!(atomic, Atomic::TFalse) && self.types.iter().any(|t| matches!(t, Atomic::TTrue))
314        {
315            self.types.retain(|t| !matches!(t, Atomic::TTrue));
316            self.types.push(Atomic::TBool);
317            return;
318        }
319        // Adding TInt widens away all TLiteralInt variants.
320        if matches!(atomic, Atomic::TInt) {
321            self.types.retain(|t| !matches!(t, Atomic::TLiteralInt(_)));
322        }
323        // Adding TString widens away all TLiteralString variants.
324        if matches!(atomic, Atomic::TString) {
325            self.types
326                .retain(|t| !matches!(t, Atomic::TLiteralString(_)));
327        }
328        // Adding TBool widens away TTrue/TFalse.
329        if matches!(atomic, Atomic::TBool) {
330            self.types
331                .retain(|t| !matches!(t, Atomic::TTrue | Atomic::TFalse));
332        }
333
334        // TNever is the bottom type: T | never = T.
335        if matches!(atomic, Atomic::TNever) {
336            if !self.types.is_empty() {
337                return;
338            }
339        } else {
340            self.types.retain(|t| !matches!(t, Atomic::TNever));
341        }
342
343        // Closed empty keyed array (array{}) is a subtype of any generic array or
344        // list. Remove it if we already have a generic array<K,V> or list<V>. An
345        // OPEN empty shape (array{...}) carries real information — it may hold
346        // unknown, possibly non-list, extra keys at runtime — so it's not
347        // subsumed and must not be dropped.
348        if let Atomic::TKeyedArray {
349            properties,
350            is_open,
351            ..
352        } = &atomic
353        {
354            if properties.is_empty() && !is_open {
355                for existing in &self.types {
356                    match existing {
357                        Atomic::TArray { .. }
358                        | Atomic::TNonEmptyArray { .. }
359                        | Atomic::TList { .. }
360                        | Atomic::TNonEmptyList { .. } => {
361                            return; // Don't add empty array, it's subsumed
362                        }
363                        _ => {}
364                    }
365                }
366            }
367        }
368
369        // When adding a generic array or list, remove any CLOSED empty keyed
370        // arrays since they're subtypes (same reasoning as above, mirrored).
371        let is_generic_array_or_list = matches!(
372            &atomic,
373            Atomic::TArray { .. }
374                | Atomic::TNonEmptyArray { .. }
375                | Atomic::TList { .. }
376                | Atomic::TNonEmptyList { .. }
377        );
378        if is_generic_array_or_list {
379            self.types.retain(|t| {
380                if let Atomic::TKeyedArray {
381                    properties,
382                    is_open,
383                    ..
384                } = t
385                {
386                    !properties.is_empty() || *is_open
387                } else {
388                    true
389                }
390            });
391        }
392
393        self.types.push(atomic);
394    }
395
396    // --- Narrowing -----------------------------------------------------------
397
398    /// Remove `null` from the union (e.g. after a null check).
399    pub fn remove_null(&self) -> Type {
400        self.filter(|t| !matches!(t, Atomic::TNull))
401    }
402
403    /// Remove `false` from the union.
404    /// `TFalse` is dropped; `TBool` becomes `TTrue` since `bool - false = true`.
405    pub fn remove_false(&self) -> Type {
406        let mut result = self.filter(|t| !matches!(t, Atomic::TFalse | Atomic::TBool));
407        if self.types.iter().any(|t| matches!(t, Atomic::TBool)) {
408            result.add_type(Atomic::TTrue);
409        }
410        result
411    }
412
413    /// Remove both `null` and `false` from the union (core type without nullable/falsy variants).
414    pub fn core_type(&self) -> Type {
415        self.remove_null().remove_false()
416    }
417
418    /// Keep only truthy atomics (e.g. after `if ($x)`).
419    pub fn narrow_to_truthy(&self) -> Type {
420        if self.is_mixed_not_template() {
421            return Type::mixed();
422        }
423        let mut result = Type::empty();
424        result.from_docblock = self.from_docblock;
425        for t in &self.types {
426            match t {
427                // An unconstrained/bounded template could resolve to anything at
428                // runtime, truthy or falsy — preserve it rather than dropping or
429                // widening it, the same way narrow_to_string/_int/etc. already do.
430                Atomic::TTemplateParam { .. } => result.add_type(t.clone()),
431                // Always-falsy — exclude entirely.
432                Atomic::TLiteralInt(0)
433                | Atomic::TLiteralFloat(0, 0)
434                | Atomic::TNull
435                | Atomic::TFalse => {}
436                Atomic::TLiteralString(s) if s.as_ref() == "" || s.as_ref() == "0" => {}
437                // bool contains both true (truthy) and false (falsy); truthy branch is true.
438                Atomic::TBool => result.add_type(Atomic::TTrue),
439                // array/list: empty ↔ falsy; truthy branch is non-empty-array/list.
440                Atomic::TArray { key, value } => result.add_type(Atomic::TNonEmptyArray {
441                    key: key.clone(),
442                    value: value.clone(),
443                }),
444                Atomic::TList { value } => result.add_type(Atomic::TNonEmptyList {
445                    value: value.clone(),
446                }),
447                // string: only "" and "0" are falsy; truthy branch is non-empty-string.
448                // non-empty-string still includes "0" (which is falsy) but that is the
449                // standard approximation used by Psalm and other analyzers.
450                Atomic::TString => result.add_type(Atomic::TNonEmptyString),
451                // numeric-string: "0" is the only falsy value; non-zero numerics are truthy.
452                // No named "non-zero numeric-string" type exists; keep as-is conservatively.
453                // int<0, max> only has 0 as its falsy value; truthy branch is int<1, max>.
454                // (int<0, 0> is handled by the can_be_truthy() false guard below.)
455                Atomic::TNonNegativeInt => result.add_type(Atomic::TPositiveInt),
456                Atomic::TIntRange { min: Some(0), max } if max.is_none_or(|m| m >= 1) => {
457                    let atom = if max.is_none() {
458                        Atomic::TPositiveInt
459                    } else {
460                        Atomic::TIntRange {
461                            min: Some(1),
462                            max: *max,
463                        }
464                    };
465                    result.add_type(atom);
466                }
467                // int<min, 0>: 0 is the only falsy value; truthy branch excludes it → int<min, -1>.
468                Atomic::TIntRange { min, max: Some(0) } => {
469                    let atom = match min {
470                        None => Atomic::TNegativeInt,
471                        Some(n) if *n <= -1 => Atomic::TIntRange {
472                            min: *min,
473                            max: Some(-1),
474                        },
475                        _ => continue, // min >= 0 with max == 0 → range is {0} — can_be_truthy() handles this
476                    };
477                    result.add_type(atom);
478                }
479                // Anything else that can never be truthy — drop.
480                t if !t.can_be_truthy() => {}
481                _ => result.add_type(t.clone()),
482            }
483        }
484        result
485    }
486
487    /// Keep only falsy atomics (e.g. after `if (!$x)`).
488    pub fn narrow_to_falsy(&self) -> Type {
489        if self.is_mixed_not_template() {
490            return Type::from_vec(vec![
491                Atomic::TNull,
492                Atomic::TFalse,
493                Atomic::TLiteralInt(0),
494                Atomic::TLiteralString("".into()),
495            ]);
496        }
497        let mut result = Type::empty();
498        result.from_docblock = self.from_docblock;
499        for t in &self.types {
500            match t {
501                // An unconstrained/bounded template could resolve to anything at
502                // runtime, truthy or falsy — preserve it rather than dropping it
503                // (its own `can_be_falsy()` conservatively defaults to `false`,
504                // which would otherwise wrongly exclude it here).
505                Atomic::TTemplateParam { .. } => result.add_type(t.clone()),
506                // bool: only false is falsy; falsy branch is false.
507                Atomic::TBool => result.add_type(Atomic::TFalse),
508                // int: only 0 is falsy.
509                Atomic::TInt => result.add_type(Atomic::TLiteralInt(0)),
510                // float: only 0.0 is falsy.
511                Atomic::TFloat => result.add_type(Atomic::TLiteralFloat(0, 0)),
512                // string: only "" and "0" are falsy.
513                Atomic::TString => {
514                    result.add_type(Atomic::TLiteralString("".into()));
515                    result.add_type(Atomic::TLiteralString("0".into()));
516                }
517                // numeric-string: only "0" is a falsy numeric string.
518                Atomic::TNumericString => result.add_type(Atomic::TLiteralString("0".into())),
519                // non-negative-int: only 0 is falsy.
520                Atomic::TNonNegativeInt => result.add_type(Atomic::TLiteralInt(0)),
521                // int<0, hi>: only 0 is falsy.
522                Atomic::TIntRange {
523                    min: Some(0),
524                    max: Some(_) | None,
525                } => result.add_type(Atomic::TLiteralInt(0)),
526                // int<min, 0>: only 0 is falsy.
527                Atomic::TIntRange { max: Some(0), .. } => result.add_type(Atomic::TLiteralInt(0)),
528                t if !t.can_be_falsy() => {} // always truthy — exclude
529                _ => result.add_type(t.clone()),
530            }
531        }
532        result
533    }
534
535    /// Narrow this type as if `$x instanceof ClassName` is true.
536    ///
537    /// The instanceof check guarantees the value IS an instance of `class`, so we
538    /// replace any object / mixed constituents with the specific named object.  Scalar
539    /// constituents are dropped (they can never satisfy instanceof).
540    pub fn narrow_instanceof(&self, class: &str) -> Type {
541        let narrowed_ty = Atomic::TNamedObject {
542            fqcn: class.into(),
543            type_params: empty_type_params(),
544        };
545        // If any constituent is an object-like type, the result is the specific class.
546        let has_object = self.types.iter().any(|t| {
547            matches!(
548                t,
549                Atomic::TObject | Atomic::TNamedObject { .. } | Atomic::TMixed | Atomic::TNull // null fails instanceof, but mixed/object may include null
550            )
551        });
552        if has_object || self.is_empty() {
553            Type::single(narrowed_ty)
554        } else {
555            // Pure scalars — instanceof is always false here, but return the class
556            // defensively so callers don't see an empty union.
557            Type::single(narrowed_ty)
558        }
559    }
560
561    /// Narrow as if `is_string($x)` is true. `mixed`/`scalar` become a concrete
562    /// `string` (rather than staying `mixed`) so downstream string-only
563    /// operations see a usable type instead of reporting `Mixed*`.
564    pub fn narrow_to_string(&self) -> Type {
565        self.filter_replacing(
566            |t| t.is_string() || matches!(t, Atomic::TTemplateParam { .. }),
567            |t| matches!(t, Atomic::TMixed | Atomic::TScalar),
568            Atomic::TString,
569        )
570    }
571
572    /// Narrow as if `is_int($x)` is true.
573    pub fn narrow_to_int(&self) -> Type {
574        self.filter_replacing(
575            |t| t.is_int() || matches!(t, Atomic::TTemplateParam { .. }),
576            |t| matches!(t, Atomic::TMixed | Atomic::TScalar | Atomic::TNumeric),
577            Atomic::TInt,
578        )
579    }
580
581    /// Narrow as if `is_float($x)` is true.
582    pub fn narrow_to_float(&self) -> Type {
583        self.filter_replacing(
584            |t| {
585                matches!(
586                    t,
587                    Atomic::TFloat
588                        | Atomic::TIntegralFloat
589                        | Atomic::TLiteralFloat(..)
590                        | Atomic::TTemplateParam { .. }
591                )
592            },
593            |t| matches!(t, Atomic::TMixed | Atomic::TScalar | Atomic::TNumeric),
594            Atomic::TFloat,
595        )
596    }
597
598    /// Narrow as if `is_bool($x)` is true.
599    pub fn narrow_to_bool(&self) -> Type {
600        self.filter_replacing(
601            |t| {
602                matches!(
603                    t,
604                    Atomic::TBool | Atomic::TTrue | Atomic::TFalse | Atomic::TTemplateParam { .. }
605                )
606            },
607            |t| matches!(t, Atomic::TMixed | Atomic::TScalar),
608            Atomic::TBool,
609        )
610    }
611
612    /// Narrow as if `is_null($x)` is true.
613    pub fn narrow_to_null(&self) -> Type {
614        self.filter_replacing(
615            |t| matches!(t, Atomic::TNull | Atomic::TTemplateParam { .. }),
616            |t| matches!(t, Atomic::TMixed),
617            Atomic::TNull,
618        )
619    }
620
621    /// Narrow as if `is_array($x)` is true.
622    pub fn narrow_to_array(&self) -> Type {
623        self.filter_replacing(
624            |t| t.is_array() || matches!(t, Atomic::TTemplateParam { .. }),
625            |t| matches!(t, Atomic::TMixed),
626            Atomic::TArray {
627                key: Box::new(Type::mixed()),
628                value: Box::new(Type::mixed()),
629            },
630        )
631    }
632
633    /// Narrow array/list types to their non-empty variants (for `count() > 0` etc.).
634    pub fn narrow_to_non_empty_collection(&self) -> Type {
635        let mut out = Type::empty();
636        out.from_docblock = self.from_docblock;
637        for t in &self.types {
638            match t {
639                Atomic::TArray { key, value } => out.add_type(Atomic::TNonEmptyArray {
640                    key: key.clone(),
641                    value: value.clone(),
642                }),
643                Atomic::TList { value } => out.add_type(Atomic::TNonEmptyList {
644                    value: value.clone(),
645                }),
646                _ => out.add_type(t.clone()),
647            }
648        }
649        out
650    }
651
652    /// Narrow array/list types when proven empty (e.g. `array_key_first($x) === null`,
653    /// `$arr === []`). Drops the non-empty variants outright — they can never
654    /// be empty — and narrows a plain `array`/`list` down to the same closed,
655    /// zero-property `TKeyedArray` an empty `[]` literal itself types as, so
656    /// e.g. `$values[0]` on a proven-empty branch is flagged as a
657    /// `NonExistentArrayOffset` instead of silently keeping the pre-narrow
658    /// element type. `TKeyedArray` atoms are left unchanged — narrowing an
659    /// already-shaped array to "empty" when it may declare required
660    /// properties is a separate, more nuanced case.
661    pub fn narrow_to_empty_collection(&self) -> Type {
662        let mut out = Type::empty();
663        out.from_docblock = self.from_docblock;
664        for t in &self.types {
665            match t {
666                Atomic::TNonEmptyArray { .. } | Atomic::TNonEmptyList { .. } => {}
667                Atomic::TArray { .. } | Atomic::TList { .. } => {
668                    out.add_type(Atomic::TKeyedArray {
669                        properties: Box::default(),
670                        is_open: false,
671                        is_list: true,
672                    });
673                }
674                _ => out.add_type(t.clone()),
675            }
676        }
677        out
678    }
679
680    /// Narrow as if `array_is_list($x)` is true.
681    /// Lists have sequential integer keys starting from 0, so:
682    /// - `list<T>` / `non-empty-list<T>` are kept unchanged.
683    /// - `array<int, T>` is narrowed to `list<T>` (could be sequential).
684    /// - `non-empty-array<int, T>` is narrowed to `non-empty-list<T>`.
685    /// - `TKeyedArray` (shape) is kept only when its own `is_list` flag is
686    ///   already true — that flag is precise (set from the actual literal's
687    ///   keys, or an explicit `list{...}` docblock), not a hint, so a
688    ///   string-keyed or non-contiguous shape is correctly excluded.
689    /// - `mixed` becomes `list<mixed>` (array_is_list implies array).
690    /// - All other types (string-keyed arrays, non-arrays) are dropped.
691    pub fn narrow_to_list(&self) -> Type {
692        let mut out = Type::empty();
693        out.from_docblock = self.from_docblock;
694        for t in &self.types {
695            match t {
696                Atomic::TList { .. } | Atomic::TNonEmptyList { .. } => out.add_type(t.clone()),
697                Atomic::TArray { key, value } if matches!(key.types.as_slice(), [Atomic::TInt]) => {
698                    out.add_type(Atomic::TList {
699                        value: value.clone(),
700                    });
701                }
702                Atomic::TNonEmptyArray { key, value }
703                    if matches!(key.types.as_slice(), [Atomic::TInt]) =>
704                {
705                    out.add_type(Atomic::TNonEmptyList {
706                        value: value.clone(),
707                    });
708                }
709                Atomic::TKeyedArray { is_list: true, .. } => out.add_type(t.clone()),
710                Atomic::TMixed => out.add_type(Atomic::TList {
711                    value: Box::new(Type::mixed()),
712                }),
713                _ => {}
714            }
715        }
716        if out.is_empty() {
717            self.filter(|t| matches!(t, Atomic::TList { .. } | Atomic::TNonEmptyList { .. }))
718        } else {
719            out
720        }
721    }
722
723    /// Narrow as if `is_object($x)` is true. A `mixed` becomes a concrete bare
724    /// `object` (rather than staying `mixed`) so downstream object-only
725    /// operations — `clone`, `instanceof`, method calls — see an object type
726    /// instead of reporting `Mixed*`.
727    pub fn narrow_to_object(&self) -> Type {
728        let mut out = Type::empty();
729        for t in &self.types {
730            if matches!(t, Atomic::TMixed) {
731                out.add_type(Atomic::TObject);
732            } else if t.is_object() || matches!(t, Atomic::TTemplateParam { .. }) {
733                out.add_type(t.clone());
734            }
735        }
736        if out.types.is_empty() {
737            self.filter(|t| t.is_object())
738        } else {
739            out
740        }
741    }
742
743    /// Narrow as if `is_callable($x)` is true.
744    ///
745    /// PHP accepts closures, TCallable, strings (function names), arrays
746    /// (['Class', 'method'] or [$obj, 'method']), and objects with __invoke.
747    /// Keep all of these; only drop atoms that are definitely not callable
748    /// (scalars, null, bool, etc.).
749    pub fn narrow_to_callable(&self) -> Type {
750        self.filter(|t| {
751            t.is_callable()
752                || t.is_string()
753                || t.is_array()
754                || t.is_object()
755                || matches!(t, Atomic::TMixed | Atomic::TTemplateParam { .. })
756        })
757    }
758
759    /// Narrow as if `is_scalar($x)` is true (int | string | float | bool).
760    pub fn narrow_to_scalar(&self) -> Type {
761        self.filter_replacing(
762            |t| {
763                t.is_string()
764                    || t.is_int()
765                    || matches!(
766                        t,
767                        Atomic::TFloat
768                            | Atomic::TIntegralFloat
769                            | Atomic::TLiteralFloat(..)
770                            | Atomic::TBool
771                            | Atomic::TTrue
772                            | Atomic::TFalse
773                            | Atomic::TScalar
774                            | Atomic::TNumeric
775                            | Atomic::TNumericString
776                            | Atomic::TTemplateParam { .. }
777                    )
778            },
779            |t| matches!(t, Atomic::TMixed),
780            Atomic::TScalar,
781        )
782    }
783
784    /// Narrow as if `is_iterable($x)` is true (array | Traversable).
785    /// For simplicity, this narrows to arrays or objects (can't easily verify interfaces).
786    pub fn narrow_to_iterable(&self) -> Type {
787        self.filter(|t| {
788            t.is_array()
789                || t.is_object()
790                || matches!(t, Atomic::TMixed | Atomic::TTemplateParam { .. })
791        })
792    }
793
794    /// Narrow as if `is_countable($x)` is true (array | Countable).
795    /// For simplicity, this narrows to arrays or objects (can't easily verify Countable interface).
796    pub fn narrow_to_countable(&self) -> Type {
797        self.filter(|t| {
798            t.is_array()
799                || t.is_object()
800                || matches!(t, Atomic::TMixed | Atomic::TTemplateParam { .. })
801        })
802    }
803
804    /// Narrow as if `is_resource($x)` is true.
805    /// Note: No TResource atomic type exists in the type system; this is a no-op.
806    /// Resources are declining in modern PHP and not actively tracked.
807    pub fn narrow_to_resource(&self) -> Type {
808        // No resource type in the system; just return mixed (allows any type)
809        self.filter(|t| matches!(t, Atomic::TMixed))
810    }
811
812    /// Narrow as if `class_exists($x)` returned true for a string variable.
813    /// String atoms become `class-string`; existing class-string atoms pass through;
814    /// mixed/scalar becomes `class-string`. Non-string atoms are dropped (returning
815    /// empty so the caller can mark the branch as diverging).
816    pub fn narrow_to_class_string(&self) -> Type {
817        let mut out = Type::empty();
818        out.from_docblock = self.from_docblock;
819        for t in &self.types {
820            match t {
821                Atomic::TClassString(_) => out.add_type(t.clone()),
822                _ if t.is_string() || matches!(t, Atomic::TMixed | Atomic::TScalar) => {
823                    out.add_type(Atomic::TClassString(None));
824                }
825                _ => {}
826            }
827        }
828        out
829    }
830
831    /// Narrow as if `interface_exists($x)` returned true for a string variable.
832    /// String atoms become `interface-string`; existing interface-string atoms pass
833    /// through; mixed/scalar becomes `interface-string`. Non-string atoms are dropped
834    /// (returning empty so the caller can mark the branch as diverging).
835    pub fn narrow_to_interface_string(&self) -> Type {
836        let mut out = Type::empty();
837        out.from_docblock = self.from_docblock;
838        for t in &self.types {
839            match t {
840                Atomic::TInterfaceString(_) => out.add_type(t.clone()),
841                // A known class-string keeps its name — every interface-string is
842                // also a valid class-string, so `interface_exists()` returning true
843                // narrows the atom without losing which class it names.
844                Atomic::TClassString(name) => {
845                    out.add_type(Atomic::TInterfaceString(*name));
846                }
847                _ if t.is_string() || matches!(t, Atomic::TMixed | Atomic::TScalar) => {
848                    out.add_type(Atomic::TInterfaceString(None));
849                }
850                _ => {}
851            }
852        }
853        out
854    }
855
856    // --- Merge (branch join) ------------------------------------------------
857
858    /// Merge two unions at a branch join point (e.g. after if/else).
859    /// The result is the union of all types in both.
860    pub fn merge(a: &Type, b: &Type) -> Type {
861        // Fast path: b is empty — nothing to add.
862        if b.types.is_empty() {
863            let mut result = a.clone();
864            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
865            return result;
866        }
867        // Fast path: a is empty — clone b.
868        if a.types.is_empty() {
869            let mut result = b.clone();
870            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
871            return result;
872        }
873        // Fast path: a is already mixed — b cannot widen it further.
874        if a.types.len() == 1 && matches!(a.types[0], Atomic::TMixed) {
875            let mut result = a.clone();
876            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
877            return result;
878        }
879        // Fast path: b contains mixed — result collapses to mixed.
880        if b.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
881            return Type {
882                types: smallvec::smallvec![Atomic::TMixed],
883                possibly_undefined: a.possibly_undefined || b.possibly_undefined,
884                from_docblock: a.from_docblock || b.from_docblock,
885            };
886        }
887        let mut result = a.clone();
888        result.merge_with(b);
889        result
890    }
891
892    /// Merge `other` into `self` in-place (avoids cloning `self`).
893    pub fn merge_with(&mut self, other: &Type) {
894        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
895            self.possibly_undefined |= other.possibly_undefined;
896            return;
897        }
898        if other.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
899            self.types.clear();
900            self.types.push(Atomic::TMixed);
901            self.possibly_undefined |= other.possibly_undefined;
902            return;
903        }
904        for atomic in &other.types {
905            self.add_type(atomic.clone());
906        }
907        self.possibly_undefined |= other.possibly_undefined;
908    }
909
910    /// Intersect with another union: keep only types present in `other`, widening
911    /// where `self` contains `mixed` (which is compatible with everything).
912    /// Used for match-arm subject narrowing.
913    pub fn intersect_with(&self, other: &Type) -> Type {
914        if self.is_mixed() {
915            return other.clone();
916        }
917        if other.is_mixed() {
918            return self.clone();
919        }
920        // Keep the more specific of each overlapping (self, other) atomic
921        // pair — e.g. intersecting `int` with `1|2` must keep `1|2` (the
922        // narrower side), not `int` (self's own, wider atomic): the whole
923        // point of narrowing a variable against a match/switch arm's
924        // literal conditions is to end up with the literal, not the bare
925        // declared type it already had. Every matching pair is kept (not
926        // just the first), so `int ∩ (1|2)` keeps both `1` and `2`.
927        let mut result = Type::empty();
928        for a in &self.types {
929            for b in &other.types {
930                if a == b {
931                    result.add_type(a.clone());
932                } else if atomic_subtype(b, a) {
933                    result.add_type(b.clone());
934                } else if atomic_subtype(a, b) {
935                    result.add_type(a.clone());
936                }
937            }
938        }
939        if result.is_empty() {
940            Type::never()
941        } else {
942            result
943        }
944    }
945
946    // --- Template substitution ----------------------------------------------
947
948    /// Replace template param references with their resolved types.
949    pub fn substitute_templates(&self, bindings: &FxHashMap<Name, Type>) -> Type {
950        if bindings.is_empty() {
951            return self.clone();
952        }
953        // Most argument/return types are plain scalars or resolved objects
954        // with nothing to substitute — skip the rebuild entirely.
955        if !self.types.iter().any(atomic_may_contain_templates) {
956            return self.clone();
957        }
958        let mut result = Type::empty();
959        result.possibly_undefined = self.possibly_undefined;
960        result.from_docblock = self.from_docblock;
961        for atomic in &self.types {
962            match atomic {
963                Atomic::TTemplateParam { name, .. } => {
964                    if let Some(resolved) = bindings.get(name) {
965                        for t in &resolved.types {
966                            result.add_type(t.clone());
967                        }
968                    } else {
969                        result.add_type(atomic.clone());
970                    }
971                }
972                Atomic::TArray { key, value } => {
973                    result.add_type(Atomic::TArray {
974                        key: Box::new(key.substitute_templates(bindings)),
975                        value: Box::new(value.substitute_templates(bindings)),
976                    });
977                }
978                Atomic::TList { value } => {
979                    result.add_type(Atomic::TList {
980                        value: Box::new(value.substitute_templates(bindings)),
981                    });
982                }
983                Atomic::TNonEmptyArray { key, value } => {
984                    result.add_type(Atomic::TNonEmptyArray {
985                        key: Box::new(key.substitute_templates(bindings)),
986                        value: Box::new(value.substitute_templates(bindings)),
987                    });
988                }
989                Atomic::TNonEmptyList { value } => {
990                    result.add_type(Atomic::TNonEmptyList {
991                        value: Box::new(value.substitute_templates(bindings)),
992                    });
993                }
994                Atomic::TKeyedArray {
995                    properties,
996                    is_open,
997                    is_list,
998                } => {
999                    use crate::atomic::KeyedProperty;
1000                    let new_props = properties
1001                        .iter()
1002                        .map(|(k, prop)| {
1003                            (
1004                                k.clone(),
1005                                KeyedProperty {
1006                                    ty: prop.ty.substitute_templates(bindings),
1007                                    optional: prop.optional,
1008                                },
1009                            )
1010                        })
1011                        .collect();
1012                    result.add_type(Atomic::TKeyedArray {
1013                        properties: Box::new(new_props),
1014                        is_open: *is_open,
1015                        is_list: *is_list,
1016                    });
1017                }
1018                Atomic::TCallable {
1019                    params,
1020                    return_type,
1021                } => {
1022                    result.add_type(Atomic::TCallable {
1023                        params: params.as_ref().map(|ps| {
1024                            ps.iter()
1025                                .map(|p| substitute_in_fn_param(p, bindings))
1026                                .collect()
1027                        }),
1028                        return_type: return_type
1029                            .as_ref()
1030                            .map(|r| Box::new(r.substitute_templates(bindings))),
1031                    });
1032                }
1033                Atomic::TClosure { data } => {
1034                    result.add_type(Atomic::TClosure {
1035                        data: Box::new(crate::atomic::ClosureData {
1036                            params: data
1037                                .params
1038                                .iter()
1039                                .map(|p| substitute_in_fn_param(p, bindings))
1040                                .collect(),
1041                            return_type: data.return_type.substitute_templates(bindings),
1042                            this_type: data
1043                                .this_type
1044                                .as_ref()
1045                                .map(|t| t.substitute_templates(bindings)),
1046                        }),
1047                    });
1048                }
1049                Atomic::TConditional { data } => {
1050                    let param_name = &data.param_name;
1051                    let new_subject = data.subject.substitute_templates(bindings);
1052                    let new_if_true = data.if_true.substitute_templates(bindings);
1053                    let new_if_false = data.if_false.substitute_templates(bindings);
1054
1055                    // If param_name names a template that is bound in this substitution,
1056                    // resolve the conditional immediately using the same predicate logic as
1057                    // `resolve_conditional_returns` for the $param form.
1058                    let resolved = if let Some(name) = param_name {
1059                        if let Some(bound) = bindings.get(name) {
1060                            if new_subject.types.len() == 1 {
1061                                resolve_conditional_branch(
1062                                    &new_subject.types[0],
1063                                    bound,
1064                                    &new_if_true,
1065                                    &new_if_false,
1066                                )
1067                            } else {
1068                                None
1069                            }
1070                        } else {
1071                            None
1072                        }
1073                    } else {
1074                        None
1075                    };
1076
1077                    if let Some(branch) = resolved {
1078                        for t in branch.types {
1079                            result.add_type(t);
1080                        }
1081                    } else {
1082                        result.add_type(Atomic::TConditional {
1083                            data: Box::new(crate::atomic::ConditionalData {
1084                                param_name: *param_name,
1085                                subject: new_subject,
1086                                if_true: new_if_true,
1087                                if_false: new_if_false,
1088                            }),
1089                        });
1090                    }
1091                }
1092                Atomic::TIntersection { parts } => {
1093                    result.add_type(Atomic::TIntersection {
1094                        parts: vec_to_type_params(
1095                            parts
1096                                .iter()
1097                                .map(|p| p.substitute_templates(bindings))
1098                                .collect(),
1099                        ),
1100                    });
1101                }
1102                Atomic::TNamedObject { fqcn, type_params } => {
1103                    // TODO: the docblock parser emits TNamedObject { fqcn: "T" } for bare @return T
1104                    // annotations instead of TTemplateParam, because it lacks template context at
1105                    // parse time. This block works around that by treating bare unqualified names
1106                    // as template param references when they appear in the binding map. Proper fix:
1107                    // make the docblock parser template-aware so it emits TTemplateParam directly.
1108                    // See issue #26 for context.
1109                    if type_params.is_empty() && !fqcn.contains('\\') {
1110                        if let Some(resolved) = bindings.get(fqcn) {
1111                            for t in &resolved.types {
1112                                result.add_type(t.clone());
1113                            }
1114                            continue;
1115                        }
1116                    }
1117                    let new_params: Vec<Type> = type_params
1118                        .iter()
1119                        .map(|p| p.substitute_templates(bindings))
1120                        .collect();
1121                    result.add_type(Atomic::TNamedObject {
1122                        fqcn: *fqcn,
1123                        type_params: vec_to_type_params(new_params),
1124                    });
1125                }
1126                // class-string<T> → substitute T from bindings
1127                Atomic::TClassString(Some(param_name)) => {
1128                    if let Some(resolved) = bindings.get(param_name) {
1129                        for r_atomic in &resolved.types {
1130                            let cls_name = if let Atomic::TNamedObject { fqcn, .. } = r_atomic {
1131                                Some(*fqcn)
1132                            } else {
1133                                None
1134                            };
1135                            result.add_type(Atomic::TClassString(cls_name));
1136                        }
1137                    } else {
1138                        result.add_type(atomic.clone());
1139                    }
1140                }
1141                // interface-string<T> → substitute T from bindings
1142                Atomic::TInterfaceString(Some(param_name)) => {
1143                    if let Some(resolved) = bindings.get(param_name) {
1144                        for r_atomic in &resolved.types {
1145                            let iface_name = if let Atomic::TNamedObject { fqcn, .. } = r_atomic {
1146                                Some(*fqcn)
1147                            } else {
1148                                None
1149                            };
1150                            result.add_type(Atomic::TInterfaceString(iface_name));
1151                        }
1152                    } else {
1153                        result.add_type(atomic.clone());
1154                    }
1155                }
1156                _ => {
1157                    result.add_type(atomic.clone());
1158                }
1159            }
1160        }
1161        result
1162    }
1163
1164    /// Resolves `TConditional` atoms whose discriminator is known at the call site.
1165    ///
1166    /// `lookup(param_name)` returns the call-site argument type for the named parameter,
1167    /// or `None` if the argument is not available. Handles `is null`, `is string`, and
1168    /// `is array` conditions; other condition types pass through unchanged.
1169    pub fn resolve_conditional_returns<F>(self, lookup: F) -> Type
1170    where
1171        F: Fn(&str) -> Option<Type>,
1172    {
1173        self.resolve_conditional_inner(&lookup)
1174    }
1175
1176    fn resolve_conditional_inner<F>(self, lookup: &F) -> Type
1177    where
1178        F: Fn(&str) -> Option<Type>,
1179    {
1180        let mut result = Type::empty();
1181        for atomic in self.types {
1182            match atomic {
1183                Atomic::TConditional { ref data } => {
1184                    let (param_name, subject, if_true, if_false) = (
1185                        &data.param_name,
1186                        &data.subject,
1187                        &data.if_true,
1188                        &data.if_false,
1189                    );
1190                    let resolved = if subject.types.len() == 1 {
1191                        if let Some(name) = param_name {
1192                            if let Some(arg_ty) = lookup(name.as_ref()) {
1193                                resolve_conditional_branch(
1194                                    &subject.types[0],
1195                                    &arg_ty,
1196                                    if_true,
1197                                    if_false,
1198                                )
1199                            } else {
1200                                None
1201                            }
1202                        } else {
1203                            None
1204                        }
1205                    } else {
1206                        None
1207                    };
1208
1209                    if let Some(branch) = resolved {
1210                        // Recursively resolve nested conditionals in the selected branch.
1211                        for t in branch.resolve_conditional_inner(lookup).types {
1212                            result.add_type(t);
1213                        }
1214                    } else {
1215                        // Cannot resolve at this call site: widen to the union of both branches.
1216                        // Recursively resolve nested conditionals in each branch.
1217                        for t in if_true.clone().resolve_conditional_inner(lookup).types {
1218                            result.add_type(t);
1219                        }
1220                        for t in if_false.clone().resolve_conditional_inner(lookup).types {
1221                            result.add_type(t);
1222                        }
1223                    }
1224                }
1225                other => result.add_type(other),
1226            }
1227        }
1228        result
1229    }
1230
1231    // --- Subtype check -------------------------------------------------------
1232
1233    /// Returns true if every atomic in `self` is a subtype of some atomic in `other`,
1234    /// using **only structural rules** — no `extends` / `implements` walk.
1235    ///
1236    /// Two distinct user-defined classes are never related here, even when one
1237    /// extends the other. Within `mir-analyzer`, when a `db` is in scope,
1238    /// prefer `crate::subtype::is_subtype(db, sub, sup)` which layers
1239    /// inheritance resolution on top of this check.
1240    pub fn is_subtype_structural(&self, other: &Type) -> bool {
1241        if other.is_mixed() {
1242            return true;
1243        }
1244        if self.is_never() {
1245            return true; // never <: everything
1246        }
1247        self.types
1248            .iter()
1249            .all(|a| other.types.iter().any(|b| atomic_subtype(a, b)))
1250    }
1251
1252    /// `sub <: self`, structurally, for a single atomic — equivalent to
1253    /// `Type::single(sub.clone()).is_subtype_structural(self)` without the
1254    /// clone and the temporary single-atomic union.
1255    pub fn accepts_atomic_structural(&self, sub: &Atomic) -> bool {
1256        if self.is_mixed() {
1257            return true;
1258        }
1259        matches!(sub, Atomic::TNever) || self.types.iter().any(|b| atomic_subtype(sub, b))
1260    }
1261
1262    // --- Utilities ----------------------------------------------------------
1263
1264    fn filter<F: Fn(&Atomic) -> bool>(&self, f: F) -> Type {
1265        let mut result = Type::empty();
1266        result.possibly_undefined = self.possibly_undefined;
1267        result.from_docblock = self.from_docblock;
1268        for atomic in &self.types {
1269            if f(atomic) {
1270                result.types.push(atomic.clone());
1271            }
1272        }
1273        result
1274    }
1275
1276    /// Like `filter`, but atoms matching `placeholder` are substituted with
1277    /// `replacement` instead of passing through unchanged. Used so narrowing
1278    /// an unrefined `mixed`/`scalar`/`numeric` value (e.g. via `is_int($x)`)
1279    /// yields the concrete narrowed type instead of staying `mixed`.
1280    fn filter_replacing<K: Fn(&Atomic) -> bool, P: Fn(&Atomic) -> bool>(
1281        &self,
1282        keep: K,
1283        placeholder: P,
1284        replacement: Atomic,
1285    ) -> Type {
1286        let mut result = Type::empty();
1287        result.possibly_undefined = self.possibly_undefined;
1288        result.from_docblock = self.from_docblock;
1289        for atomic in &self.types {
1290            if keep(atomic) {
1291                result.add_type(atomic.clone());
1292            } else if placeholder(atomic) {
1293                result.add_type(replacement.clone());
1294            }
1295        }
1296        result
1297    }
1298
1299    /// Mark this union as possibly-undefined and return it.
1300    pub fn possibly_undefined(mut self) -> Self {
1301        self.possibly_undefined = true;
1302        self
1303    }
1304
1305    /// Mark this union as coming from a docblock annotation.
1306    pub fn from_docblock(mut self) -> Self {
1307        self.from_docblock = true;
1308        self
1309    }
1310}
1311
1312// ---------------------------------------------------------------------------
1313// Conditional return resolution helpers
1314// ---------------------------------------------------------------------------
1315
1316fn is_string_atomic(a: &Atomic) -> bool {
1317    matches!(
1318        a,
1319        Atomic::TString
1320            | Atomic::TNonEmptyString
1321            | Atomic::TLiteralString(_)
1322            | Atomic::TNumericString
1323            | Atomic::TClassString(_)
1324            | Atomic::TInterfaceString(_)
1325            | Atomic::TCallableString
1326    )
1327}
1328
1329fn is_array_atomic(a: &Atomic) -> bool {
1330    matches!(
1331        a,
1332        Atomic::TArray { .. }
1333            | Atomic::TNonEmptyArray { .. }
1334            | Atomic::TKeyedArray { .. }
1335            | Atomic::TList { .. }
1336            | Atomic::TNonEmptyList { .. }
1337    )
1338}
1339
1340fn is_list_atomic(a: &Atomic) -> bool {
1341    match a {
1342        Atomic::TList { .. } | Atomic::TNonEmptyList { .. } => true,
1343        Atomic::TKeyedArray { is_list, .. } => *is_list,
1344        _ => false,
1345    }
1346}
1347
1348fn is_float_atomic(a: &Atomic) -> bool {
1349    matches!(
1350        a,
1351        Atomic::TFloat | Atomic::TIntegralFloat | Atomic::TLiteralFloat(..)
1352    )
1353}
1354
1355fn is_bool_atomic(a: &Atomic) -> bool {
1356    matches!(a, Atomic::TBool | Atomic::TTrue | Atomic::TFalse)
1357}
1358
1359/// Resolve one branch of a conditional return type given the subject discriminant
1360/// atomic and the actual argument type at the call site.
1361///
1362/// Returns `Some(branch)` when the branch can be determined statically, or `None`
1363/// to signal that the caller should widen to the union of both branches.
1364fn resolve_conditional_branch(
1365    subject: &Atomic,
1366    arg_ty: &Type,
1367    if_true: &Type,
1368    if_false: &Type,
1369) -> Option<Type> {
1370    let predicate: fn(&Atomic) -> bool = match subject {
1371        Atomic::TNull => |a| matches!(a, Atomic::TNull),
1372        Atomic::TTrue => |a| matches!(a, Atomic::TTrue),
1373        Atomic::TFalse => |a| matches!(a, Atomic::TFalse),
1374        Atomic::TString => is_string_atomic,
1375        Atomic::TList { .. } => is_list_atomic,
1376        Atomic::TArray { .. } => is_array_atomic,
1377        Atomic::TInt => Atomic::is_int,
1378        Atomic::TFloat => is_float_atomic,
1379        Atomic::TBool => is_bool_atomic,
1380        _ => return None,
1381    };
1382
1383    if arg_ty.types.is_empty() {
1384        return None;
1385    }
1386    let all_match = arg_ty.types.iter().all(&predicate);
1387    let none_match = !arg_ty.types.iter().any(predicate);
1388    if all_match {
1389        Some(if_true.clone())
1390    } else if none_match {
1391        Some(if_false.clone())
1392    } else {
1393        None
1394    }
1395}
1396
1397// ---------------------------------------------------------------------------
1398// Template substitution helpers
1399// ---------------------------------------------------------------------------
1400
1401/// Whether `substitute_templates` could change this atomic: it is either a
1402/// template reference itself or a container/callable that may hold one.
1403/// Mirrors the substituting arms of that function's match — keep in sync.
1404fn atomic_may_contain_templates(atomic: &Atomic) -> bool {
1405    match atomic {
1406        // Bare unqualified names double as template refs (docblock parser
1407        // workaround, see substitute_templates); qualified names only matter
1408        // when they carry generic params.
1409        Atomic::TNamedObject { fqcn, type_params } => {
1410            !type_params.is_empty() || !fqcn.contains('\\')
1411        }
1412        Atomic::TTemplateParam { .. }
1413        | Atomic::TArray { .. }
1414        | Atomic::TList { .. }
1415        | Atomic::TNonEmptyArray { .. }
1416        | Atomic::TNonEmptyList { .. }
1417        | Atomic::TKeyedArray { .. }
1418        | Atomic::TCallable { .. }
1419        | Atomic::TClosure { .. }
1420        | Atomic::TConditional { .. }
1421        | Atomic::TIntersection { .. }
1422        | Atomic::TClassString(Some(_))
1423        | Atomic::TInterfaceString(Some(_)) => true,
1424        _ => false,
1425    }
1426}
1427
1428fn substitute_in_fn_param(
1429    p: &crate::atomic::FnParam,
1430    bindings: &FxHashMap<Name, Type>,
1431) -> crate::atomic::FnParam {
1432    crate::atomic::FnParam {
1433        name: p.name,
1434        ty: p.ty.as_ref().map(|t| {
1435            let u = t.to_union();
1436            let substituted = u.substitute_templates(bindings);
1437            crate::compact::SimpleType::from_union(substituted)
1438        }),
1439        out_ty: p.out_ty.as_ref().map(|t| {
1440            let u = t.to_union();
1441            let substituted = u.substitute_templates(bindings);
1442            crate::compact::SimpleType::from_union(substituted)
1443        }),
1444        default: p.default.as_ref().map(|d| {
1445            let u = d.to_union();
1446            let substituted = u.substitute_templates(bindings);
1447            crate::compact::SimpleType::from_union(substituted)
1448        }),
1449        is_variadic: p.is_variadic,
1450        is_byref: p.is_byref,
1451        is_optional: p.is_optional,
1452    }
1453}
1454
1455// ---------------------------------------------------------------------------
1456// Atomic subtype (no codebase — structural check only)
1457// ---------------------------------------------------------------------------
1458
1459/// Structural `sub <: sup` for a single atomic pair, without hierarchy resolution.
1460pub fn atomic_subtype(sub: &Atomic, sup: &Atomic) -> bool {
1461    if sub == sup {
1462        return true;
1463    }
1464    match (sub, sup) {
1465        // Bottom type
1466        (Atomic::TNever, _) => true,
1467        // Top types — anything goes in both directions for mixed
1468        (_, Atomic::TMixed) => true,
1469        (Atomic::TMixed, _) => true,
1470        // Template param in supertype position: any value satisfies an unconstrained
1471        // template (as_type = mixed), or a constrained one if it satisfies the bound.
1472        // This handles union bounds like `T of string|list<I>|array<K, V>` where
1473        // I/K/V are free template params — any type satisfies them structurally.
1474        (_, Atomic::TTemplateParam { as_type, .. }) => {
1475            as_type.is_mixed() || as_type.types.iter().any(|b| atomic_subtype(sub, b))
1476        }
1477
1478        // Scalars
1479        (Atomic::TLiteralInt(_), Atomic::TInt) => true,
1480        (Atomic::TLiteralInt(_), Atomic::TNumeric) => true,
1481        (Atomic::TLiteralInt(_), Atomic::TScalar) => true,
1482        (Atomic::TLiteralInt(n), Atomic::TPositiveInt) => *n > 0,
1483        (Atomic::TLiteralInt(n), Atomic::TNonNegativeInt) => *n >= 0,
1484        (Atomic::TLiteralInt(n), Atomic::TNegativeInt) => *n < 0,
1485        (Atomic::TPositiveInt, Atomic::TInt) => true,
1486        (Atomic::TPositiveInt, Atomic::TNonNegativeInt) => true,
1487        (Atomic::TPositiveInt, Atomic::TNumeric) => true,
1488        (Atomic::TPositiveInt, Atomic::TScalar) => true,
1489        (Atomic::TNegativeInt, Atomic::TInt) => true,
1490        (Atomic::TNegativeInt, Atomic::TNumeric) => true,
1491        (Atomic::TNegativeInt, Atomic::TScalar) => true,
1492        (Atomic::TNonNegativeInt, Atomic::TInt) => true,
1493        (Atomic::TNonNegativeInt, Atomic::TNumeric) => true,
1494        (Atomic::TNonNegativeInt, Atomic::TScalar) => true,
1495        (Atomic::TIntRange { .. }, Atomic::TInt) => true,
1496        (Atomic::TIntRange { .. }, Atomic::TNumeric) => true,
1497        (Atomic::TIntRange { .. }, Atomic::TScalar) => true,
1498        // positive-int is int<1, ∞>: subtype of int<sup_min, ∞> when sup_min <= 1
1499        (Atomic::TPositiveInt, Atomic::TIntRange { min, max }) => {
1500            max.is_none() && min.is_none_or(|m| m <= 1)
1501        }
1502        // negative-int is int<-∞, -1>: subtype of int<-∞, sup_max> when sup_max >= -1
1503        (Atomic::TNegativeInt, Atomic::TIntRange { min, max }) => {
1504            min.is_none() && max.is_none_or(|m| m >= -1)
1505        }
1506        // non-negative-int is int<0, ∞>: subtype of int<sup_min, ∞> when sup_min <= 0
1507        (Atomic::TNonNegativeInt, Atomic::TIntRange { min, max }) => {
1508            max.is_none() && min.is_none_or(|m| m <= 0)
1509        }
1510        // A bounded int range is a subtype of a named int subtype when every value fits
1511        (Atomic::TIntRange { min: sub_min, .. }, Atomic::TPositiveInt) => {
1512            sub_min.is_some_and(|lo| lo >= 1)
1513        }
1514        (Atomic::TIntRange { min: sub_min, .. }, Atomic::TNonNegativeInt) => {
1515            sub_min.is_some_and(|lo| lo >= 0)
1516        }
1517        (Atomic::TIntRange { max: sub_max, .. }, Atomic::TNegativeInt) => {
1518            sub_max.is_some_and(|hi| hi <= -1)
1519        }
1520        // int<sub_min, sub_max> <: int<sup_min, sup_max> when ranges nest
1521        (
1522            Atomic::TIntRange {
1523                min: sub_min,
1524                max: sub_max,
1525            },
1526            Atomic::TIntRange {
1527                min: sup_min,
1528                max: sup_max,
1529            },
1530        ) => {
1531            let lower_ok = match (sub_min, sup_min) {
1532                (_, None) => true,
1533                (None, Some(_)) => false,
1534                (Some(sl), Some(su)) => sl >= su,
1535            };
1536            let upper_ok = match (sub_max, sup_max) {
1537                (None, None) | (Some(_), None) => true,
1538                (None, Some(_)) => false,
1539                (Some(sl), Some(su)) => sl <= su,
1540            };
1541            lower_ok && upper_ok
1542        }
1543
1544        (Atomic::TLiteralFloat(..), Atomic::TFloat) => true,
1545        (Atomic::TLiteralFloat(..), Atomic::TNumeric) => true,
1546        (Atomic::TLiteralFloat(..), Atomic::TScalar) => true,
1547
1548        (Atomic::TLiteralString(s), Atomic::TString) => {
1549            let _ = s;
1550            true
1551        }
1552        (Atomic::TLiteralString(s), Atomic::TCallableString) => {
1553            let _ = s;
1554            true
1555        }
1556        (Atomic::TLiteralString(s), Atomic::TNonEmptyString) => !s.is_empty(),
1557        (Atomic::TLiteralString(s), Atomic::TNumericString) => s.parse::<f64>().is_ok(),
1558        // A literal string is type-compatible with class-string; validate_class_string_argument
1559        // separately checks whether the string names a real class (UndefinedClass).
1560        (Atomic::TLiteralString(_), Atomic::TClassString(_)) => true,
1561        // Same, for interface-string; validate_interface_string_argument checks existence
1562        // and that the name actually resolves to an interface.
1563        (Atomic::TLiteralString(_), Atomic::TInterfaceString(_)) => true,
1564        (Atomic::TLiteralString(_), Atomic::TScalar) => true,
1565        (Atomic::TNonEmptyString, Atomic::TString) => true,
1566        (Atomic::TCallableString, Atomic::TString) => true,
1567        // numeric-string is always non-empty (e.g. "42", "-1", "0.5") — "" is not numeric.
1568        (Atomic::TNumericString, Atomic::TNonEmptyString) => true,
1569        (Atomic::TNumericString, Atomic::TString) => true,
1570        (Atomic::TClassString(_), Atomic::TString) => true,
1571        (Atomic::TInterfaceString(_), Atomic::TString) => true,
1572        // Every interface-string is a valid class-string: PHP doesn't distinguish
1573        // the two at runtime — both are just strings naming a class-like symbol.
1574        // Instantiability (`new $x()`) is guarded separately, since an interface
1575        // name can never be `new`-ed even though it satisfies class-string.
1576        (Atomic::TInterfaceString(_), Atomic::TClassString(None)) => true,
1577        (Atomic::TInterfaceString(Some(a)), Atomic::TClassString(Some(b))) => a == b,
1578        (Atomic::TEnumString, Atomic::TString) => true,
1579        (Atomic::TTraitString, Atomic::TString) => true,
1580
1581        (Atomic::TTrue, Atomic::TBool) => true,
1582        (Atomic::TFalse, Atomic::TBool) => true,
1583
1584        (Atomic::TInt, Atomic::TNumeric) => true,
1585        (Atomic::TFloat, Atomic::TNumeric) => true,
1586        (Atomic::TIntegralFloat, Atomic::TNumeric) => true,
1587        (Atomic::TNumericString, Atomic::TNumeric) => true,
1588
1589        (Atomic::TInt, Atomic::TScalar) => true,
1590        (Atomic::TFloat, Atomic::TScalar) => true,
1591        (Atomic::TIntegralFloat, Atomic::TScalar) => true,
1592        (Atomic::TString, Atomic::TScalar) => true,
1593        (Atomic::TBool, Atomic::TScalar) => true,
1594        (Atomic::TNumeric, Atomic::TScalar) => true,
1595        (Atomic::TTrue, Atomic::TScalar) => true,
1596        (Atomic::TFalse, Atomic::TScalar) => true,
1597
1598        // Object hierarchy (structural, no codebase)
1599        (Atomic::TNamedObject { .. }, Atomic::TObject) => true,
1600        (Atomic::TStaticObject { .. }, Atomic::TObject) => true,
1601        (Atomic::TSelf { .. }, Atomic::TObject) => true,
1602        // self(X) and static(X) satisfy TNamedObject(X) with same FQCN
1603        (Atomic::TSelf { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
1604        (Atomic::TStaticObject { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
1605        // TNamedObject(X) satisfies self(X) / static(X) with same FQCN
1606        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TSelf { fqcn: b }) => a == b,
1607        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TStaticObject { fqcn: b }) => a == b,
1608        // Bare generic property accepts parameterized value: Box accepts Box<string>.
1609        // The reverse is NOT true — bare Box value does not satisfy Box<string> property
1610        // (invariant check). Only sup being bare (empty type_params) is the wildcard.
1611        (
1612            Atomic::TNamedObject {
1613                fqcn: sub_fqcn,
1614                type_params: sub_params,
1615            },
1616            Atomic::TNamedObject {
1617                fqcn: sup_fqcn,
1618                type_params: sup_params,
1619            },
1620        ) => {
1621            sub_fqcn == sup_fqcn
1622                && (sup_params.is_empty() || type_params_compatible(sub_params, sup_params))
1623        }
1624
1625        // TIntegralFloat is a subtype of float (all integral floats are floats)
1626        (Atomic::TIntegralFloat, Atomic::TFloat) => true,
1627
1628        // Literal int widens to float in PHP
1629        (Atomic::TLiteralInt(_), Atomic::TFloat) => true,
1630        (Atomic::TPositiveInt, Atomic::TFloat) => true,
1631        (Atomic::TNegativeInt, Atomic::TFloat) => true,
1632        (Atomic::TNonNegativeInt, Atomic::TFloat) => true,
1633        (Atomic::TInt, Atomic::TFloat) => true,
1634        (Atomic::TIntRange { .. }, Atomic::TFloat) => true,
1635
1636        // Literal int satisfies an int range only when the value is within bounds
1637        (Atomic::TLiteralInt(n), Atomic::TIntRange { min, max }) => {
1638            min.is_none_or(|lo| *n >= lo) && max.is_none_or(|hi| *n <= hi)
1639        }
1640
1641        // PHP callables: string and array are valid callable values
1642        (Atomic::TString, Atomic::TCallable { .. }) => true,
1643        (Atomic::TNonEmptyString, Atomic::TCallable { .. }) => true,
1644        (Atomic::TLiteralString(_), Atomic::TCallable { .. }) => true,
1645        (Atomic::TArray { .. }, Atomic::TCallable { .. }) => true,
1646        (Atomic::TNonEmptyArray { .. }, Atomic::TCallable { .. }) => true,
1647        (Atomic::TKeyedArray { .. }, Atomic::TCallable { .. }) => true,
1648
1649        // Closure <: callable, typed Closure <: Closure
1650        (Atomic::TClosure { .. }, Atomic::TCallable { .. }) => true,
1651        // callable <: Closure: callable is wider but not flagged at default error level
1652        (Atomic::TCallable { .. }, Atomic::TClosure { .. }) => true,
1653        // TClosure <: TClosure: check arity, per-parameter contravariance, and
1654        // return covariance for scalar/array-shaped types, where a purely
1655        // structural check is reliable. A named-class (or nested-callable)
1656        // param/return is skipped rather than checked — this checker has no
1657        // database access to walk `extends`/`implements`, so it can't safely
1658        // tell a real Liskov violation apart from a legitimate subclass/
1659        // superclass substitution; treating it as compatible avoids false
1660        // positives on that far more common case at the cost of missing the
1661        // narrower nominal-variance violation.
1662        (Atomic::TClosure { data: sub }, Atomic::TClosure { data: sup }) => {
1663            fn has_nominal_type(t: &Type) -> bool {
1664                t.types.iter().any(|a| {
1665                    matches!(
1666                        a,
1667                        Atomic::TNamedObject { .. }
1668                            | Atomic::TSelf { .. }
1669                            | Atomic::TStaticObject { .. }
1670                            | Atomic::TTemplateParam { .. }
1671                            | Atomic::TClosure { .. }
1672                            | Atomic::TCallable { .. }
1673                    )
1674                })
1675            }
1676            let sub_required = sub
1677                .params
1678                .iter()
1679                .filter(|p| !p.is_optional && !p.is_variadic)
1680                .count();
1681            if sub_required > sup.params.len() {
1682                false
1683            } else {
1684                let params_ok = sup.params.iter().enumerate().all(|(i, sup_param)| {
1685                    let Some(sub_param) = sub.params.get(i) else {
1686                        return true;
1687                    };
1688                    if sub_param.is_optional || sub_param.is_variadic {
1689                        return true;
1690                    }
1691                    let (Some(sub_ty), Some(sup_ty)) =
1692                        (sub_param.ty.as_ref(), sup_param.ty.as_ref())
1693                    else {
1694                        return true;
1695                    };
1696                    let (sub_u, sup_u) = (sub_ty.to_union(), sup_ty.to_union());
1697                    if has_nominal_type(&sub_u) || has_nominal_type(&sup_u) {
1698                        return true;
1699                    }
1700                    // Contravariance: whatever `sup` promises to pass must be
1701                    // acceptable to `sub`'s declared parameter type.
1702                    sup_u.is_subtype_structural(&sub_u)
1703                });
1704                params_ok
1705                    && (sub.return_type.is_mixed()
1706                        || sup.return_type.is_mixed()
1707                        || has_nominal_type(&sub.return_type)
1708                        || has_nominal_type(&sup.return_type)
1709                        || sub.return_type.is_subtype_structural(&sup.return_type))
1710            }
1711        }
1712        // callable <: callable (trivial)
1713        (Atomic::TCallable { .. }, Atomic::TCallable { .. }) => true,
1714        // TClosure satisfies `Closure` named object or `object`
1715        (Atomic::TClosure { .. }, Atomic::TNamedObject { fqcn, .. }) => {
1716            fqcn.as_ref().eq_ignore_ascii_case("closure")
1717        }
1718        (Atomic::TClosure { .. }, Atomic::TObject) => true,
1719        // bare `Closure` (named object without signature) satisfies any typed Closure(): T
1720        (Atomic::TNamedObject { fqcn, .. }, Atomic::TClosure { .. }) => {
1721            fqcn.as_ref().eq_ignore_ascii_case("closure")
1722        }
1723        // `Closure` named-object satisfies `callable`
1724        (Atomic::TNamedObject { fqcn, .. }, Atomic::TCallable { .. }) => {
1725            fqcn.as_ref().eq_ignore_ascii_case("closure")
1726        }
1727
1728        // A&B&C <: D&E iff every part of the supertype is satisfied by some
1729        // part of the subtype — an intersection with MORE conjuncts is the
1730        // more specific (sub)type, so `Countable&ArrayAccess&Iterator` is a
1731        // subtype of `Countable&ArrayAccess`. Purely structural (each part's
1732        // own `is_subtype_structural` recurses, so e.g. two differently-named
1733        // interfaces only match when equal — same conservative stance as the
1734        // TClosure<:TClosure arm above for named types).
1735        (
1736            Atomic::TIntersection { parts: sub_parts },
1737            Atomic::TIntersection { parts: sup_parts },
1738        ) => sup_parts.iter().all(|sup_part| {
1739            sub_parts
1740                .iter()
1741                .any(|sub_part| sub_part.is_subtype_structural(sup_part))
1742        }),
1743
1744        // List <: array  (list key is always int; int must satisfy the array's key type)
1745        (Atomic::TList { value }, Atomic::TArray { key, value: av }) => {
1746            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
1747        }
1748        (Atomic::TNonEmptyList { value }, Atomic::TArray { key, value: av }) => {
1749            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
1750        }
1751        (Atomic::TNonEmptyList { value }, Atomic::TNonEmptyArray { key, value: av }) => {
1752            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
1753        }
1754        (Atomic::TNonEmptyList { value }, Atomic::TList { value: lv }) => {
1755            value.is_subtype_structural(lv)
1756        }
1757        // array<int, X> is accepted where list<X> or non-empty-list<X> expected
1758        (Atomic::TArray { key, value: av }, Atomic::TList { value: lv }) => {
1759            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1760                && av.is_subtype_structural(lv)
1761        }
1762        (Atomic::TArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1763            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1764                && av.is_subtype_structural(lv)
1765        }
1766        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TList { value: lv }) => {
1767            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1768                && av.is_subtype_structural(lv)
1769        }
1770        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1771            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1772                && av.is_subtype_structural(lv)
1773        }
1774        // TList <: TList value covariance
1775        (Atomic::TList { value: v1 }, Atomic::TList { value: v2 }) => v1.is_subtype_structural(v2),
1776        (Atomic::TNonEmptyArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1777            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1778        }
1779
1780        // array<A, B> <: array<C, D>  iff  A <: C && B <: D
1781        (Atomic::TArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1782            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1783        }
1784
1785        // A keyed/shape array is a subtype of array<K, V> / non-empty-array<K, V>
1786        // when all property KEYS are subtypes of K. Value compatibility is checked
1787        // structurally only for scalar types; named-object values are deferred to
1788        // class-hierarchy checks in return_arrays_compatible (mir-analyzer).
1789        // Open shapes (is_open=true) may have extra unknown keys beyond `properties`:
1790        // those stay unchecked (permissive), but every KNOWN property must still
1791        // satisfy K/V regardless of openness — an open shape isn't a license to skip
1792        // checking the keys it does declare.
1793        (Atomic::TKeyedArray { properties, .. }, Atomic::TArray { key, value }) => {
1794            properties.iter().all(|(prop_key, prop)| {
1795                let key_atomic = match prop_key {
1796                    crate::atomic::ArrayKey::String(s) => Atomic::TLiteralString(s.clone()),
1797                    crate::atomic::ArrayKey::Int(n) => Atomic::TLiteralInt(*n),
1798                };
1799                if !Type::single(key_atomic).is_subtype_structural(key) {
1800                    return false; // key mismatch — definitively incompatible
1801                }
1802                // Named-object values require class-hierarchy checks not available here.
1803                let has_named_obj = prop.ty.types.iter().any(|a| {
1804                    matches!(
1805                        a,
1806                        Atomic::TNamedObject { .. }
1807                            | Atomic::TSelf { .. }
1808                            | Atomic::TStaticObject { .. }
1809                            | Atomic::TClosure { .. }
1810                            | Atomic::TTemplateParam { .. }
1811                    )
1812                });
1813                has_named_obj || prop.ty.is_subtype_structural(value)
1814            })
1815        }
1816        (
1817            Atomic::TKeyedArray {
1818                properties,
1819                is_open,
1820                ..
1821            },
1822            Atomic::TNonEmptyArray { key, value },
1823        ) => {
1824            (*is_open || properties.iter().any(|(_, p)| !p.optional))
1825                && properties.iter().all(|(prop_key, prop)| {
1826                    let key_atomic = match prop_key {
1827                        crate::atomic::ArrayKey::String(s) => Atomic::TLiteralString(s.clone()),
1828                        crate::atomic::ArrayKey::Int(n) => Atomic::TLiteralInt(*n),
1829                    };
1830                    if !Type::single(key_atomic).is_subtype_structural(key) {
1831                        return false;
1832                    }
1833                    let has_named_obj = prop.ty.types.iter().any(|a| {
1834                        matches!(
1835                            a,
1836                            Atomic::TNamedObject { .. }
1837                                | Atomic::TSelf { .. }
1838                                | Atomic::TStaticObject { .. }
1839                                | Atomic::TClosure { .. }
1840                                | Atomic::TTemplateParam { .. }
1841                        )
1842                    });
1843                    has_named_obj || prop.ty.is_subtype_structural(value)
1844                })
1845        }
1846
1847        // A list-shaped keyed array (is_list=true, all int keys) is a subtype of list<X>.
1848        (
1849            Atomic::TKeyedArray {
1850                properties,
1851                is_list,
1852                ..
1853            },
1854            Atomic::TList { value: lv },
1855        ) => *is_list && properties.values().all(|p| p.ty.is_subtype_structural(lv)),
1856        (
1857            Atomic::TKeyedArray {
1858                properties,
1859                is_list,
1860                ..
1861            },
1862            Atomic::TNonEmptyList { value: lv },
1863        ) => {
1864            *is_list
1865                && !properties.is_empty()
1866                && properties.values().all(|p| p.ty.is_subtype_structural(lv))
1867        }
1868
1869        // Two shapes: every sup key must be satisfied (present+compatible, or
1870        // absent-but-optional/sub-open), and sub may not have keys sup doesn't
1871        // declare unless sup itself is open. Named-object values are deferred
1872        // to class-hierarchy checks, same as the TArray/TList sup arms above.
1873        (
1874            Atomic::TKeyedArray {
1875                properties: sub_props,
1876                is_open: sub_open,
1877                ..
1878            },
1879            Atomic::TKeyedArray {
1880                properties: sup_props,
1881                is_open: sup_open,
1882                ..
1883            },
1884        ) => {
1885            let keys_satisfied = sup_props
1886                .iter()
1887                .all(|(key, sup_prop)| match sub_props.get(key) {
1888                    Some(sub_prop) => {
1889                        // A key merely optional on the sub side may legally be
1890                        // absent at runtime, so it can't satisfy a sup key that
1891                        // requires it present.
1892                        if !sup_prop.optional && sub_prop.optional {
1893                            return false;
1894                        }
1895                        let has_named_obj = sup_prop.ty.types.iter().any(|a| {
1896                            matches!(
1897                                a,
1898                                Atomic::TNamedObject { .. }
1899                                    | Atomic::TSelf { .. }
1900                                    | Atomic::TStaticObject { .. }
1901                                    | Atomic::TClosure { .. }
1902                                    | Atomic::TTemplateParam { .. }
1903                            )
1904                        });
1905                        has_named_obj || sub_prop.ty.is_subtype_structural(&sup_prop.ty)
1906                    }
1907                    None => sup_prop.optional || *sub_open,
1908                });
1909            let no_undeclared_extras =
1910                *sup_open || sub_props.keys().all(|k| sup_props.contains_key(k));
1911            keys_satisfied && no_undeclared_extras
1912        }
1913
1914        _ => false,
1915    }
1916}
1917
1918/// Whether each generic type-argument in `sub` is compatible with the
1919/// corresponding argument in `sup`. Arguments are invariant (require structural
1920/// equality) with one exception: an empty array literal (`array{}`) is accepted
1921/// against any array/list argument, so `new Box([])` — inferred as
1922/// `Box<array{}>` — satisfies a declared `Box<list<T>>` for any `T`.
1923fn type_params_compatible(sub: &[Type], sup: &[Type]) -> bool {
1924    if sub.len() != sup.len() {
1925        return false;
1926    }
1927    sub.iter()
1928        .zip(sup.iter())
1929        .all(|(a, b)| a == b || (is_empty_array_literal(a) && is_array_like(b)))
1930}
1931
1932/// True for a non-empty union whose atoms are all empty keyed arrays (`array{}`),
1933/// i.e. the type of an empty array literal `[]`.
1934fn is_empty_array_literal(t: &Type) -> bool {
1935    !t.types.is_empty()
1936        && t.types.iter().all(
1937            |atom| matches!(atom, Atomic::TKeyedArray { properties, .. } if properties.is_empty()),
1938        )
1939}
1940
1941/// True for a non-empty union whose atoms are all array/list types.
1942fn is_array_like(t: &Type) -> bool {
1943    !t.types.is_empty() && t.types.iter().all(|atom| atom.is_array())
1944}
1945
1946// ---------------------------------------------------------------------------
1947// Tests
1948// ---------------------------------------------------------------------------
1949
1950#[cfg(test)]
1951mod tests {
1952    use std::sync::Arc;
1953
1954    use super::*;
1955
1956    fn conditional(
1957        param_name: Option<Name>,
1958        subject: Type,
1959        if_true: Type,
1960        if_false: Type,
1961    ) -> Atomic {
1962        Atomic::TConditional {
1963            data: Box::new(crate::atomic::ConditionalData {
1964                param_name,
1965                subject,
1966                if_true,
1967                if_false,
1968            }),
1969        }
1970    }
1971
1972    #[test]
1973    fn single_is_single() {
1974        let u = Type::single(Atomic::TString);
1975        assert!(u.is_single());
1976        assert!(!u.is_nullable());
1977    }
1978
1979    #[test]
1980    fn nullable_has_null() {
1981        let u = Type::nullable(Atomic::TString);
1982        assert!(u.is_nullable());
1983        assert_eq!(u.types.len(), 2);
1984    }
1985
1986    #[test]
1987    fn add_type_deduplicates() {
1988        let mut u = Type::single(Atomic::TString);
1989        u.add_type(Atomic::TString);
1990        assert_eq!(u.types.len(), 1);
1991    }
1992
1993    #[test]
1994    fn array_key_is_int_string() {
1995        let k = Type::array_key();
1996        assert!(k.is_array_key());
1997        assert_eq!(k.types.len(), 2);
1998    }
1999
2000    #[test]
2001    fn is_array_key_false_for_plain_int() {
2002        assert!(!Type::int().is_array_key());
2003    }
2004
2005    #[test]
2006    fn is_array_key_false_for_mixed() {
2007        assert!(!Type::mixed().is_array_key());
2008    }
2009
2010    #[test]
2011    fn is_array_key_false_for_int_string_null() {
2012        let mut u = Type::array_key();
2013        u.add_type(Atomic::TNull);
2014        assert!(!u.is_array_key());
2015    }
2016
2017    #[test]
2018    fn add_type_literal_subsumed_by_base() {
2019        let mut u = Type::single(Atomic::TInt);
2020        u.add_type(Atomic::TLiteralInt(42));
2021        assert_eq!(u.types.len(), 1);
2022        assert!(matches!(u.types[0], Atomic::TInt));
2023    }
2024
2025    #[test]
2026    fn true_then_false_merges_to_bool() {
2027        let mut u = Type::single(Atomic::TTrue);
2028        u.add_type(Atomic::TFalse);
2029        assert_eq!(u.types.len(), 1);
2030        assert!(matches!(u.types[0], Atomic::TBool));
2031    }
2032
2033    #[test]
2034    fn false_then_true_merges_to_bool() {
2035        let mut u = Type::single(Atomic::TFalse);
2036        u.add_type(Atomic::TTrue);
2037        assert_eq!(u.types.len(), 1);
2038        assert!(matches!(u.types[0], Atomic::TBool));
2039    }
2040
2041    #[test]
2042    fn true_alone_stays_true() {
2043        let u = Type::single(Atomic::TTrue);
2044        assert_eq!(u.types.len(), 1);
2045        assert!(matches!(u.types[0], Atomic::TTrue));
2046    }
2047
2048    #[test]
2049    fn true_false_merge_preserves_other_union_members() {
2050        let mut u = Type::single(Atomic::TTrue);
2051        u.add_type(Atomic::TNull);
2052        u.add_type(Atomic::TFalse);
2053        assert_eq!(u.types.len(), 2);
2054        assert!(u.contains(|t| matches!(t, Atomic::TBool)));
2055        assert!(u.contains(|t| matches!(t, Atomic::TNull)));
2056    }
2057
2058    #[test]
2059    fn add_type_base_widens_literals() {
2060        let mut u = Type::single(Atomic::TLiteralInt(1));
2061        u.add_type(Atomic::TLiteralInt(2));
2062        u.add_type(Atomic::TInt);
2063        assert_eq!(u.types.len(), 1);
2064        assert!(matches!(u.types[0], Atomic::TInt));
2065    }
2066
2067    #[test]
2068    fn mixed_subsumes_everything() {
2069        let mut u = Type::single(Atomic::TString);
2070        u.add_type(Atomic::TMixed);
2071        assert_eq!(u.types.len(), 1);
2072        assert!(u.is_mixed());
2073    }
2074
2075    #[test]
2076    fn remove_null() {
2077        let u = Type::nullable(Atomic::TString);
2078        let narrowed = u.remove_null();
2079        assert!(!narrowed.is_nullable());
2080        assert_eq!(narrowed.types.len(), 1);
2081    }
2082
2083    #[test]
2084    fn narrow_to_truthy_removes_null_false() {
2085        let mut u = Type::empty();
2086        u.add_type(Atomic::TString);
2087        u.add_type(Atomic::TNull);
2088        u.add_type(Atomic::TFalse);
2089        let truthy = u.narrow_to_truthy();
2090        assert!(!truthy.is_nullable());
2091        assert!(!truthy.contains(|t| matches!(t, Atomic::TFalse)));
2092    }
2093
2094    #[test]
2095    fn merge_combines_types() {
2096        let a = Type::single(Atomic::TString);
2097        let b = Type::single(Atomic::TInt);
2098        let merged = Type::merge(&a, &b);
2099        assert_eq!(merged.types.len(), 2);
2100    }
2101
2102    #[test]
2103    fn intersect_keeps_narrower_side_not_self() {
2104        // int ∩ (1|2) must keep the narrower `1|2`, not the wider `int` —
2105        // this is exactly what a `match ($x) { 1, 2 => ... }` arm relies on
2106        // to narrow $x inside its body.
2107        let int_ty = Type::single(Atomic::TInt);
2108        let mut literals = Type::empty();
2109        literals.add_type(Atomic::TLiteralInt(1));
2110        literals.add_type(Atomic::TLiteralInt(2));
2111
2112        let narrowed = int_ty.intersect_with(&literals);
2113        assert_eq!(narrowed.types.len(), 2);
2114        assert!(narrowed.contains(|t| matches!(t, Atomic::TLiteralInt(1))));
2115        assert!(narrowed.contains(|t| matches!(t, Atomic::TLiteralInt(2))));
2116        assert!(!narrowed.contains(|t| matches!(t, Atomic::TInt)));
2117    }
2118
2119    #[test]
2120    fn subtype_literal_int_under_int() {
2121        let sub = Type::single(Atomic::TLiteralInt(5));
2122        let sup = Type::single(Atomic::TInt);
2123        assert!(sub.is_subtype_structural(&sup));
2124    }
2125
2126    #[test]
2127    fn subtype_never_is_bottom() {
2128        let never = Type::never();
2129        let string = Type::single(Atomic::TString);
2130        assert!(never.is_subtype_structural(&string));
2131    }
2132
2133    #[test]
2134    fn subtype_everything_under_mixed() {
2135        let string = Type::single(Atomic::TString);
2136        let mixed = Type::mixed();
2137        assert!(string.is_subtype_structural(&mixed));
2138    }
2139
2140    #[test]
2141    fn template_substitution() {
2142        let mut bindings = FxHashMap::default();
2143        bindings.insert(Name::new("T"), Type::single(Atomic::TString));
2144
2145        let tmpl = Type::single(Atomic::TTemplateParam {
2146            name: Name::new("T"),
2147            as_type: Box::new(Type::mixed()),
2148            defining_entity: Name::new("MyClass"),
2149        });
2150
2151        let resolved = tmpl.substitute_templates(&bindings);
2152        assert_eq!(resolved.types.len(), 1);
2153        assert!(matches!(resolved.types[0], Atomic::TString));
2154    }
2155
2156    #[test]
2157    fn intersection_is_object() {
2158        let parts = vec![
2159            Type::single(Atomic::TNamedObject {
2160                fqcn: Name::new("Iterator"),
2161                type_params: empty_type_params(),
2162            }),
2163            Type::single(Atomic::TNamedObject {
2164                fqcn: Name::new("Countable"),
2165                type_params: empty_type_params(),
2166            }),
2167        ];
2168        let atomic = Atomic::TIntersection {
2169            parts: vec_to_type_params(parts),
2170        };
2171        assert!(atomic.is_object());
2172        assert!(!atomic.can_be_falsy());
2173        assert!(atomic.can_be_truthy());
2174    }
2175
2176    #[test]
2177    fn intersection_display_two_parts() {
2178        let parts = vec![
2179            Type::single(Atomic::TNamedObject {
2180                fqcn: Name::new("Iterator"),
2181                type_params: empty_type_params(),
2182            }),
2183            Type::single(Atomic::TNamedObject {
2184                fqcn: Name::new("Countable"),
2185                type_params: empty_type_params(),
2186            }),
2187        ];
2188        let u = Type::single(Atomic::TIntersection {
2189            parts: vec_to_type_params(parts),
2190        });
2191        assert_eq!(format!("{u}"), "Iterator&Countable");
2192    }
2193
2194    #[test]
2195    fn intersection_display_three_parts() {
2196        let parts = vec![
2197            Type::single(Atomic::TNamedObject {
2198                fqcn: Name::new("A"),
2199                type_params: empty_type_params(),
2200            }),
2201            Type::single(Atomic::TNamedObject {
2202                fqcn: Name::new("B"),
2203                type_params: empty_type_params(),
2204            }),
2205            Type::single(Atomic::TNamedObject {
2206                fqcn: Name::new("C"),
2207                type_params: empty_type_params(),
2208            }),
2209        ];
2210        let u = Type::single(Atomic::TIntersection {
2211            parts: vec_to_type_params(parts),
2212        });
2213        assert_eq!(format!("{u}"), "A&B&C");
2214    }
2215
2216    #[test]
2217    fn intersection_in_nullable_union_display() {
2218        let intersection = Atomic::TIntersection {
2219            parts: vec_to_type_params(vec![
2220                Type::single(Atomic::TNamedObject {
2221                    fqcn: Name::new("Iterator"),
2222                    type_params: empty_type_params(),
2223                }),
2224                Type::single(Atomic::TNamedObject {
2225                    fqcn: Name::new("Countable"),
2226                    type_params: empty_type_params(),
2227                }),
2228            ]),
2229        };
2230        let mut u = Type::single(intersection);
2231        u.add_type(Atomic::TNull);
2232        assert!(u.is_nullable());
2233        assert!(u.contains(|t| matches!(t, Atomic::TIntersection { .. })));
2234    }
2235
2236    // --- substitute_templates coverage for previously-missing arms ----------
2237
2238    fn t_param(name: &str) -> Type {
2239        Type::single(Atomic::TTemplateParam {
2240            name: Name::new(name),
2241            as_type: Box::new(Type::mixed()),
2242            defining_entity: Name::new("Fn"),
2243        })
2244    }
2245
2246    fn bindings_t_string() -> FxHashMap<Name, Type> {
2247        let mut b = FxHashMap::default();
2248        b.insert(Name::new("T"), Type::single(Atomic::TString));
2249        b
2250    }
2251
2252    #[test]
2253    fn substitute_non_empty_array_key_and_value() {
2254        let ty = Type::single(Atomic::TNonEmptyArray {
2255            key: Box::new(t_param("T")),
2256            value: Box::new(t_param("T")),
2257        });
2258        let result = ty.substitute_templates(&bindings_t_string());
2259        assert_eq!(result.types.len(), 1);
2260        let Atomic::TNonEmptyArray { key, value } = &result.types[0] else {
2261            panic!("expected TNonEmptyArray");
2262        };
2263        assert!(matches!(key.types[0], Atomic::TString));
2264        assert!(matches!(value.types[0], Atomic::TString));
2265    }
2266
2267    #[test]
2268    fn substitute_non_empty_list_value() {
2269        let ty = Type::single(Atomic::TNonEmptyList {
2270            value: Box::new(t_param("T")),
2271        });
2272        let result = ty.substitute_templates(&bindings_t_string());
2273        let Atomic::TNonEmptyList { value } = &result.types[0] else {
2274            panic!("expected TNonEmptyList");
2275        };
2276        assert!(matches!(value.types[0], Atomic::TString));
2277    }
2278
2279    #[test]
2280    fn substitute_keyed_array_property_types() {
2281        use crate::atomic::{ArrayKey, KeyedProperty};
2282        use indexmap::IndexMap;
2283        let mut props = IndexMap::new();
2284        props.insert(
2285            ArrayKey::String(Arc::from("name")),
2286            KeyedProperty {
2287                ty: t_param("T"),
2288                optional: false,
2289            },
2290        );
2291        props.insert(
2292            ArrayKey::String(Arc::from("tag")),
2293            KeyedProperty {
2294                ty: t_param("T"),
2295                optional: true,
2296            },
2297        );
2298        let ty = Type::single(Atomic::TKeyedArray {
2299            properties: Box::new(props),
2300            is_open: true,
2301            is_list: false,
2302        });
2303        let result = ty.substitute_templates(&bindings_t_string());
2304        let Atomic::TKeyedArray {
2305            properties,
2306            is_open,
2307            is_list,
2308        } = &result.types[0]
2309        else {
2310            panic!("expected TKeyedArray");
2311        };
2312        assert!(is_open);
2313        assert!(!is_list);
2314        assert!(matches!(
2315            properties[&ArrayKey::String(Arc::from("name"))].ty.types[0],
2316            Atomic::TString
2317        ));
2318        assert!(properties[&ArrayKey::String(Arc::from("tag"))].optional);
2319        assert!(matches!(
2320            properties[&ArrayKey::String(Arc::from("tag"))].ty.types[0],
2321            Atomic::TString
2322        ));
2323    }
2324
2325    #[test]
2326    fn substitute_callable_params_and_return() {
2327        use crate::atomic::FnParam;
2328        let ty = Type::single(Atomic::TCallable {
2329            params: Some(Box::new([FnParam {
2330                name: Name::new("x"),
2331                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
2332                out_ty: None,
2333                default: None,
2334                is_variadic: false,
2335                is_byref: false,
2336                is_optional: false,
2337            }])),
2338            return_type: Some(Box::new(t_param("T"))),
2339        });
2340        let result = ty.substitute_templates(&bindings_t_string());
2341        let Atomic::TCallable {
2342            params,
2343            return_type,
2344        } = &result.types[0]
2345        else {
2346            panic!("expected TCallable");
2347        };
2348        let param_ty = params.as_ref().unwrap()[0].ty.as_ref().unwrap();
2349        let param_union = param_ty.to_union();
2350        assert!(matches!(param_union.types[0], Atomic::TString));
2351        let ret = return_type.as_ref().unwrap();
2352        assert!(matches!(ret.types[0], Atomic::TString));
2353    }
2354
2355    #[test]
2356    fn substitute_callable_bare_no_panic() {
2357        // callable with no params/return — must not panic and must pass through unchanged
2358        let ty = Type::single(Atomic::TCallable {
2359            params: None,
2360            return_type: None,
2361        });
2362        let result = ty.substitute_templates(&bindings_t_string());
2363        assert!(matches!(
2364            result.types[0],
2365            Atomic::TCallable {
2366                params: None,
2367                return_type: None
2368            }
2369        ));
2370    }
2371
2372    #[test]
2373    fn substitute_closure_params_return_and_this() {
2374        use crate::atomic::FnParam;
2375        let ty = Type::single(Atomic::TClosure {
2376            data: Box::new(crate::atomic::ClosureData {
2377                params: Box::new([FnParam {
2378                    name: Name::new("a"),
2379                    ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
2380                    out_ty: None,
2381                    default: Some(crate::compact::SimpleType::from_union(t_param("T"))),
2382                    is_variadic: true,
2383                    is_byref: true,
2384                    is_optional: true,
2385                }]),
2386                return_type: t_param("T"),
2387                this_type: Some(t_param("T")),
2388            }),
2389        });
2390        let result = ty.substitute_templates(&bindings_t_string());
2391        let Atomic::TClosure { data } = &result.types[0] else {
2392            panic!("expected TClosure");
2393        };
2394        let (params, return_type, this_type) = (&data.params, &data.return_type, &data.this_type);
2395        let p = &params[0];
2396        let ty_union = p.ty.as_ref().unwrap().to_union();
2397        let default_union = p.default.as_ref().unwrap().to_union();
2398        assert!(matches!(ty_union.types[0], Atomic::TString));
2399        assert!(matches!(default_union.types[0], Atomic::TString));
2400        // flags preserved
2401        assert!(p.is_variadic);
2402        assert!(p.is_byref);
2403        assert!(p.is_optional);
2404        assert!(matches!(return_type.types[0], Atomic::TString));
2405        assert!(matches!(
2406            this_type.as_ref().unwrap().types[0],
2407            Atomic::TString
2408        ));
2409    }
2410
2411    #[test]
2412    fn substitute_conditional_all_branches() {
2413        let ty = Type::single(conditional(
2414            None,
2415            t_param("T"),
2416            t_param("T"),
2417            Type::single(Atomic::TInt),
2418        ));
2419        let result = ty.substitute_templates(&bindings_t_string());
2420        let Atomic::TConditional { data } = &result.types[0] else {
2421            panic!("expected TConditional");
2422        };
2423        let (subject, if_true, if_false) = (&data.subject, &data.if_true, &data.if_false);
2424        assert!(matches!(subject.types[0], Atomic::TString));
2425        assert!(matches!(if_true.types[0], Atomic::TString));
2426        assert!(matches!(if_false.types[0], Atomic::TInt));
2427    }
2428
2429    #[test]
2430    fn resolve_conditional_is_null_non_null_arg() {
2431        let ty = Type::single(conditional(
2432            Some(Name::new("x")),
2433            Type::single(Atomic::TNull),
2434            Type::single(Atomic::TInt),
2435            Type::single(Atomic::TString),
2436        ));
2437        let result = ty.resolve_conditional_returns(|name| {
2438            if name == "x" {
2439                Some(Type::single(Atomic::TString)) // definitely not null
2440            } else {
2441                None
2442            }
2443        });
2444        assert!(result.types.len() == 1);
2445        assert!(matches!(result.types[0], Atomic::TString));
2446    }
2447
2448    #[test]
2449    fn resolve_conditional_is_null_null_arg() {
2450        let ty = Type::single(conditional(
2451            Some(Name::new("x")),
2452            Type::single(Atomic::TNull),
2453            Type::single(Atomic::TInt),
2454            Type::single(Atomic::TString),
2455        ));
2456        let result = ty.resolve_conditional_returns(|name| {
2457            if name == "x" {
2458                Some(Type::single(Atomic::TNull)) // definitely null
2459            } else {
2460                None
2461            }
2462        });
2463        assert!(result.types.len() == 1);
2464        assert!(matches!(result.types[0], Atomic::TInt));
2465    }
2466
2467    #[test]
2468    fn resolve_conditional_is_null_nullable_arg_widens_to_branch_union() {
2469        let mut nullable_str = Type::single(Atomic::TString);
2470        nullable_str.add_type(Atomic::TNull);
2471        let ty = Type::single(conditional(
2472            Some(Name::new("x")),
2473            Type::single(Atomic::TNull),
2474            Type::single(Atomic::TInt),
2475            Type::single(Atomic::TString),
2476        ));
2477        let result = ty.resolve_conditional_returns(|name| {
2478            if name == "x" {
2479                Some(nullable_str.clone())
2480            } else {
2481                None
2482            }
2483        });
2484        // uncertain discriminator → widen to if_true | if_false
2485        assert_eq!(result.types.len(), 2);
2486        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
2487        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
2488    }
2489
2490    #[test]
2491    fn resolve_conditional_nested_widens_inner_branch() {
2492        // ($x is null ? int : ($x is string ? string : float))
2493        // When $x is unknown, should widen to int|string|float (no TConditional remaining).
2494        let inner = Type::single(conditional(
2495            Some(Name::new("x")),
2496            Type::single(Atomic::TString),
2497            Type::single(Atomic::TString),
2498            Type::single(Atomic::TFloat),
2499        ));
2500        let ty = Type::single(conditional(
2501            Some(Name::new("x")),
2502            Type::single(Atomic::TNull),
2503            Type::single(Atomic::TInt),
2504            inner,
2505        ));
2506        // unknown arg → widen both outer branches, inner conditional must also be widened
2507        let result = ty.resolve_conditional_returns(|_| None);
2508        assert!(
2509            result
2510                .types
2511                .iter()
2512                .all(|t| !matches!(t, Atomic::TConditional { .. })),
2513            "no TConditional should survive: {:?}",
2514            result.types
2515        );
2516        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
2517        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
2518        assert!(result.types.iter().any(|t| matches!(t, Atomic::TFloat)));
2519    }
2520
2521    #[test]
2522    fn resolve_conditional_nested_resolves_inner_branch() {
2523        // ($x is null ? int : ($x is string ? string : float))
2524        // When $x is definitely not null but unknown string-or-not → resolves outer to inner,
2525        // then inner must also be resolved.
2526        let inner = Type::single(conditional(
2527            Some(Name::new("x")),
2528            Type::single(Atomic::TString),
2529            Type::single(Atomic::TString),
2530            Type::single(Atomic::TFloat),
2531        ));
2532        let ty = Type::single(conditional(
2533            Some(Name::new("x")),
2534            Type::single(Atomic::TNull),
2535            Type::single(Atomic::TInt),
2536            inner,
2537        ));
2538        // $x = string → outer: not null → if_false (inner); inner: is string → if_true = string
2539        let result = ty.resolve_conditional_returns(|name| {
2540            if name == "x" {
2541                Some(Type::single(Atomic::TString))
2542            } else {
2543                None
2544            }
2545        });
2546        assert!(
2547            result
2548                .types
2549                .iter()
2550                .all(|t| !matches!(t, Atomic::TConditional { .. })),
2551            "no TConditional should survive: {:?}",
2552            result.types
2553        );
2554        assert_eq!(result.types.len(), 1);
2555        assert!(matches!(result.types[0], Atomic::TString));
2556    }
2557
2558    #[test]
2559    fn substitute_intersection_parts() {
2560        let ty = Type::single(Atomic::TIntersection {
2561            parts: vec_to_type_params(vec![
2562                Type::single(Atomic::TNamedObject {
2563                    fqcn: Name::new("Countable"),
2564                    type_params: empty_type_params(),
2565                }),
2566                t_param("T"),
2567            ]),
2568        });
2569        let result = ty.substitute_templates(&bindings_t_string());
2570        let Atomic::TIntersection { parts } = &result.types[0] else {
2571            panic!("expected TIntersection");
2572        };
2573        assert_eq!(parts.len(), 2);
2574        assert!(matches!(parts[0].types[0], Atomic::TNamedObject { .. }));
2575        assert!(matches!(parts[1].types[0], Atomic::TString));
2576    }
2577
2578    #[test]
2579    fn substitute_no_template_params_identity() {
2580        let ty = Type::single(Atomic::TInt);
2581        let result = ty.substitute_templates(&bindings_t_string());
2582        assert!(matches!(result.types[0], Atomic::TInt));
2583    }
2584}