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