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