Skip to main content

mir_types/
union.rs

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