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