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        let mut types = SmallVec::new();
112        types.push(atomic);
113        types.push(Atomic::TNull);
114        Self {
115            types,
116            possibly_undefined: false,
117            from_docblock: false,
118        }
119    }
120
121    /// Build a union from multiple atomics, de-duplicating on the fly.
122    pub fn from_vec(atomics: Vec<Atomic>) -> Self {
123        let mut u = Self::empty();
124        for a in atomics {
125            u.add_type(a);
126        }
127        u
128    }
129
130    // --- Introspection -------------------------------------------------------
131
132    pub fn is_empty(&self) -> bool {
133        self.types.is_empty()
134    }
135
136    pub fn is_single(&self) -> bool {
137        self.types.len() == 1
138    }
139
140    pub fn is_nullable(&self) -> bool {
141        self.types.iter().any(|t| matches!(t, Atomic::TNull))
142    }
143
144    pub fn is_mixed(&self) -> bool {
145        self.types.iter().any(|t| match t {
146            Atomic::TMixed => true,
147            Atomic::TTemplateParam { as_type, .. } => as_type.is_mixed(),
148            _ => false,
149        })
150    }
151
152    pub fn is_never(&self) -> bool {
153        self.types.iter().all(|t| matches!(t, Atomic::TNever)) && !self.types.is_empty()
154    }
155
156    /// Classify this type for `clone` validity. Recurses into template-param
157    /// bounds (like [`Type::is_mixed`]). Callers handle `mixed` separately.
158    pub fn clone_validity(&self) -> CloneValidity {
159        if self.types.is_empty() {
160            return CloneValidity::Unknown;
161        }
162        let mut has_non_object = false;
163        let mut has_other = false; // object or ambiguous (callable, mixed, conditional, …)
164        for t in &self.types {
165            match t {
166                Atomic::TTemplateParam { as_type, .. } => match as_type.clone_validity() {
167                    CloneValidity::Invalid => has_non_object = true,
168                    CloneValidity::PossiblyInvalid => {
169                        has_non_object = true;
170                        has_other = true;
171                    }
172                    CloneValidity::Cloneable | CloneValidity::Unknown => has_other = true,
173                },
174                other if other.is_definitely_non_object() => has_non_object = true,
175                _ => has_other = true,
176            }
177        }
178        match (has_non_object, has_other) {
179            (true, false) => CloneValidity::Invalid,
180            (true, true) => CloneValidity::PossiblyInvalid,
181            _ => CloneValidity::Cloneable,
182        }
183    }
184
185    pub fn is_void(&self) -> bool {
186        self.is_single() && matches!(self.types[0], Atomic::TVoid)
187    }
188
189    pub fn can_be_falsy(&self) -> bool {
190        self.types.iter().any(|t| t.can_be_falsy())
191    }
192
193    pub fn can_be_truthy(&self) -> bool {
194        self.types.iter().any(|t| t.can_be_truthy())
195    }
196
197    pub fn contains<F: Fn(&Atomic) -> bool>(&self, f: F) -> bool {
198        self.types.iter().any(f)
199    }
200
201    pub fn has_named_object(&self, fqcn: &str) -> bool {
202        self.types.iter().any(|t| match t {
203            Atomic::TNamedObject { fqcn: f, .. } => f.as_ref() == fqcn,
204            _ => false,
205        })
206    }
207
208    // --- Mutation ------------------------------------------------------------
209
210    /// Add an atomic to this union, skipping duplicates.
211    /// Subsumption rules: anything ⊆ TMixed; TLiteralInt ⊆ TInt; etc.
212    pub fn add_type(&mut self, atomic: Atomic) {
213        // If we already have TMixed, nothing to add.
214        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
215            return;
216        }
217
218        // Adding TMixed subsumes everything.
219        if matches!(atomic, Atomic::TMixed) {
220            self.types.clear();
221            self.types.push(Atomic::TMixed);
222            return;
223        }
224
225        // Simplify trivial conditional types: (X is ? T : T) → T
226        // Recursively simplify branches first so nested trivial conditionals collapse.
227        let atomic = if let Atomic::TConditional {
228            param_name: _,
229            subject: _,
230            if_true,
231            if_false,
232        } = &atomic
233        {
234            let mut simplified_true = Type::empty();
235            for t in &if_true.types {
236                simplified_true.add_type(t.clone());
237            }
238            let mut simplified_false = Type::empty();
239            for t in &if_false.types {
240                simplified_false.add_type(t.clone());
241            }
242            if simplified_true == simplified_false {
243                for t in simplified_true.types {
244                    self.add_type(t);
245                }
246                return;
247            }
248            atomic
249        } else {
250            atomic
251        };
252
253        // Avoid exact duplicates.
254        if self.types.contains(&atomic) {
255            return;
256        }
257
258        // TLiteralInt(n) is subsumed by TInt.
259        if let Atomic::TLiteralInt(_) = &atomic {
260            if self.types.iter().any(|t| matches!(t, Atomic::TInt)) {
261                return;
262            }
263        }
264        // TLiteralString(s) is subsumed by TString.
265        if let Atomic::TLiteralString(_) = &atomic {
266            if self.types.iter().any(|t| matches!(t, Atomic::TString)) {
267                return;
268            }
269        }
270        // TTrue / TFalse are subsumed by TBool.
271        if matches!(atomic, Atomic::TTrue | Atomic::TFalse)
272            && self.types.iter().any(|t| matches!(t, Atomic::TBool))
273        {
274            return;
275        }
276        // Adding TInt widens away all TLiteralInt variants.
277        if matches!(atomic, Atomic::TInt) {
278            self.types.retain(|t| !matches!(t, Atomic::TLiteralInt(_)));
279        }
280        // Adding TString widens away all TLiteralString variants.
281        if matches!(atomic, Atomic::TString) {
282            self.types
283                .retain(|t| !matches!(t, Atomic::TLiteralString(_)));
284        }
285        // Adding TBool widens away TTrue/TFalse.
286        if matches!(atomic, Atomic::TBool) {
287            self.types
288                .retain(|t| !matches!(t, Atomic::TTrue | Atomic::TFalse));
289        }
290
291        // TNever is the bottom type: T | never = T.
292        if matches!(atomic, Atomic::TNever) {
293            if !self.types.is_empty() {
294                return;
295            }
296        } else {
297            self.types.retain(|t| !matches!(t, Atomic::TNever));
298        }
299
300        // Empty keyed array (array{}) is a subtype of any generic array or list.
301        // Remove array{} if we already have a generic array<K,V> or list<V>.
302        if let Atomic::TKeyedArray { properties, .. } = &atomic {
303            if properties.is_empty() {
304                for existing in &self.types {
305                    match existing {
306                        Atomic::TArray { .. }
307                        | Atomic::TNonEmptyArray { .. }
308                        | Atomic::TList { .. }
309                        | Atomic::TNonEmptyList { .. } => {
310                            return; // Don't add empty array, it's subsumed
311                        }
312                        _ => {}
313                    }
314                }
315            }
316        }
317
318        // When adding a generic array or list, remove any empty keyed arrays since they're subtypes.
319        let is_generic_array_or_list = matches!(
320            &atomic,
321            Atomic::TArray { .. }
322                | Atomic::TNonEmptyArray { .. }
323                | Atomic::TList { .. }
324                | Atomic::TNonEmptyList { .. }
325        );
326        if is_generic_array_or_list {
327            self.types.retain(|t| {
328                if let Atomic::TKeyedArray { properties, .. } = t {
329                    !properties.is_empty()
330                } else {
331                    true
332                }
333            });
334        }
335
336        self.types.push(atomic);
337    }
338
339    // --- Narrowing -----------------------------------------------------------
340
341    /// Remove `null` from the union (e.g. after a null check).
342    pub fn remove_null(&self) -> Type {
343        self.filter(|t| !matches!(t, Atomic::TNull))
344    }
345
346    /// Remove `false` from the union.
347    pub fn remove_false(&self) -> Type {
348        self.filter(|t| !matches!(t, Atomic::TFalse | Atomic::TBool))
349    }
350
351    /// Remove both `null` and `false` from the union (core type without nullable/falsy variants).
352    pub fn core_type(&self) -> Type {
353        self.remove_null().remove_false()
354    }
355
356    /// Keep only truthy atomics (e.g. after `if ($x)`).
357    pub fn narrow_to_truthy(&self) -> Type {
358        if self.is_mixed() {
359            return Type::mixed();
360        }
361        let narrowed = self.filter(|t| t.can_be_truthy());
362        // Remove specific falsy literals from string/int
363        narrowed.filter(|t| match t {
364            Atomic::TLiteralInt(0) => false,
365            Atomic::TLiteralString(s) if s.as_ref() == "" || s.as_ref() == "0" => false,
366            Atomic::TLiteralFloat(0, 0) => false,
367            _ => true,
368        })
369    }
370
371    /// Keep only falsy atomics (e.g. after `if (!$x)`).
372    pub fn narrow_to_falsy(&self) -> Type {
373        if self.is_mixed() {
374            return Type::from_vec(vec![
375                Atomic::TNull,
376                Atomic::TFalse,
377                Atomic::TLiteralInt(0),
378                Atomic::TLiteralString("".into()),
379            ]);
380        }
381        self.filter(|t| t.can_be_falsy())
382    }
383
384    /// Narrow this type as if `$x instanceof ClassName` is true.
385    ///
386    /// The instanceof check guarantees the value IS an instance of `class`, so we
387    /// replace any object / mixed constituents with the specific named object.  Scalar
388    /// constituents are dropped (they can never satisfy instanceof).
389    pub fn narrow_instanceof(&self, class: &str) -> Type {
390        let narrowed_ty = Atomic::TNamedObject {
391            fqcn: class.into(),
392            type_params: empty_type_params(),
393        };
394        // If any constituent is an object-like type, the result is the specific class.
395        let has_object = self.types.iter().any(|t| {
396            matches!(
397                t,
398                Atomic::TObject | Atomic::TNamedObject { .. } | Atomic::TMixed | Atomic::TNull // null fails instanceof, but mixed/object may include null
399            )
400        });
401        if has_object || self.is_empty() {
402            Type::single(narrowed_ty)
403        } else {
404            // Pure scalars — instanceof is always false here, but return the class
405            // defensively so callers don't see an empty union.
406            Type::single(narrowed_ty)
407        }
408    }
409
410    /// Narrow as if `is_string($x)` is true.
411    pub fn narrow_to_string(&self) -> Type {
412        self.filter(|t| t.is_string() || matches!(t, Atomic::TMixed | Atomic::TScalar))
413    }
414
415    /// Narrow as if `is_int($x)` is true.
416    pub fn narrow_to_int(&self) -> Type {
417        self.filter(|t| {
418            t.is_int() || matches!(t, Atomic::TMixed | Atomic::TScalar | Atomic::TNumeric)
419        })
420    }
421
422    /// Narrow as if `is_float($x)` is true.
423    pub fn narrow_to_float(&self) -> Type {
424        self.filter(|t| {
425            matches!(
426                t,
427                Atomic::TFloat
428                    | Atomic::TLiteralFloat(..)
429                    | Atomic::TMixed
430                    | Atomic::TScalar
431                    | Atomic::TNumeric
432            )
433        })
434    }
435
436    /// Narrow as if `is_bool($x)` is true.
437    pub fn narrow_to_bool(&self) -> Type {
438        self.filter(|t| {
439            matches!(
440                t,
441                Atomic::TBool | Atomic::TTrue | Atomic::TFalse | Atomic::TMixed | Atomic::TScalar
442            )
443        })
444    }
445
446    /// Narrow as if `is_null($x)` is true.
447    pub fn narrow_to_null(&self) -> Type {
448        self.filter(|t| matches!(t, Atomic::TNull | Atomic::TMixed))
449    }
450
451    /// Narrow as if `is_array($x)` is true.
452    pub fn narrow_to_array(&self) -> Type {
453        self.filter(|t| t.is_array() || matches!(t, Atomic::TMixed))
454    }
455
456    /// Narrow as if `is_object($x)` is true.
457    pub fn narrow_to_object(&self) -> Type {
458        self.filter(|t| t.is_object() || matches!(t, Atomic::TMixed))
459    }
460
461    /// Narrow as if `is_callable($x)` is true.
462    pub fn narrow_to_callable(&self) -> Type {
463        self.filter(|t| t.is_callable() || matches!(t, Atomic::TMixed))
464    }
465
466    /// Narrow as if `is_scalar($x)` is true (int | string | float | bool).
467    pub fn narrow_to_scalar(&self) -> Type {
468        self.filter(|t| {
469            matches!(
470                t,
471                Atomic::TString
472                    | Atomic::TLiteralString(..)
473                    | Atomic::TNumericString
474                    | Atomic::TInt
475                    | Atomic::TLiteralInt(..)
476                    | Atomic::TFloat
477                    | Atomic::TLiteralFloat(..)
478                    | Atomic::TBool
479                    | Atomic::TTrue
480                    | Atomic::TFalse
481                    | Atomic::TScalar
482                    | Atomic::TMixed
483            )
484        })
485    }
486
487    /// Narrow as if `is_iterable($x)` is true (array | Traversable).
488    /// For simplicity, this narrows to arrays or objects (can't easily verify interfaces).
489    pub fn narrow_to_iterable(&self) -> Type {
490        self.filter(|t| t.is_array() || t.is_object() || matches!(t, Atomic::TMixed))
491    }
492
493    /// Narrow as if `is_countable($x)` is true (array | Countable).
494    /// For simplicity, this narrows to arrays or objects (can't easily verify Countable interface).
495    pub fn narrow_to_countable(&self) -> Type {
496        self.filter(|t| t.is_array() || t.is_object() || matches!(t, Atomic::TMixed))
497    }
498
499    /// Narrow as if `is_resource($x)` is true.
500    /// Note: No TResource atomic type exists in the type system; this is a no-op.
501    /// Resources are declining in modern PHP and not actively tracked.
502    pub fn narrow_to_resource(&self) -> Type {
503        // No resource type in the system; just return mixed (allows any type)
504        self.filter(|t| matches!(t, Atomic::TMixed))
505    }
506
507    // --- Merge (branch join) ------------------------------------------------
508
509    /// Merge two unions at a branch join point (e.g. after if/else).
510    /// The result is the union of all types in both.
511    pub fn merge(a: &Type, b: &Type) -> Type {
512        // Fast path: b is empty — nothing to add.
513        if b.types.is_empty() {
514            let mut result = a.clone();
515            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
516            return result;
517        }
518        // Fast path: a is empty — clone b.
519        if a.types.is_empty() {
520            let mut result = b.clone();
521            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
522            return result;
523        }
524        // Fast path: a is already mixed — b cannot widen it further.
525        if a.types.len() == 1 && matches!(a.types[0], Atomic::TMixed) {
526            let mut result = a.clone();
527            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
528            return result;
529        }
530        // Fast path: b contains mixed — result collapses to mixed.
531        if b.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
532            return Type {
533                types: smallvec::smallvec![Atomic::TMixed],
534                possibly_undefined: a.possibly_undefined || b.possibly_undefined,
535                from_docblock: a.from_docblock || b.from_docblock,
536            };
537        }
538        let mut result = a.clone();
539        result.merge_with(b);
540        result
541    }
542
543    /// Merge `other` into `self` in-place (avoids cloning `self`).
544    pub fn merge_with(&mut self, other: &Type) {
545        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
546            self.possibly_undefined |= other.possibly_undefined;
547            return;
548        }
549        if other.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
550            self.types.clear();
551            self.types.push(Atomic::TMixed);
552            self.possibly_undefined |= other.possibly_undefined;
553            return;
554        }
555        for atomic in &other.types {
556            self.add_type(atomic.clone());
557        }
558        self.possibly_undefined |= other.possibly_undefined;
559    }
560
561    /// Intersect with another union: keep only types present in `other`, widening
562    /// where `self` contains `mixed` (which is compatible with everything).
563    /// Used for match-arm subject narrowing.
564    pub fn intersect_with(&self, other: &Type) -> Type {
565        if self.is_mixed() {
566            return other.clone();
567        }
568        if other.is_mixed() {
569            return self.clone();
570        }
571        // Keep atomics from self that are also in other (by equality or subtype)
572        let mut result = Type::empty();
573        for a in &self.types {
574            for b in &other.types {
575                if a == b || atomic_subtype(a, b) || atomic_subtype(b, a) {
576                    result.add_type(a.clone());
577                    break;
578                }
579            }
580        }
581        if result.is_empty() {
582            Type::never()
583        } else {
584            result
585        }
586    }
587
588    // --- Template substitution ----------------------------------------------
589
590    /// Replace template param references with their resolved types.
591    pub fn substitute_templates(&self, bindings: &FxHashMap<Name, Type>) -> Type {
592        if bindings.is_empty() {
593            return self.clone();
594        }
595        let mut result = Type::empty();
596        result.possibly_undefined = self.possibly_undefined;
597        result.from_docblock = self.from_docblock;
598        for atomic in &self.types {
599            match atomic {
600                Atomic::TTemplateParam { name, .. } => {
601                    if let Some(resolved) = bindings.get(name) {
602                        for t in &resolved.types {
603                            result.add_type(t.clone());
604                        }
605                    } else {
606                        result.add_type(atomic.clone());
607                    }
608                }
609                Atomic::TArray { key, value } => {
610                    result.add_type(Atomic::TArray {
611                        key: Box::new(key.substitute_templates(bindings)),
612                        value: Box::new(value.substitute_templates(bindings)),
613                    });
614                }
615                Atomic::TList { value } => {
616                    result.add_type(Atomic::TList {
617                        value: Box::new(value.substitute_templates(bindings)),
618                    });
619                }
620                Atomic::TNonEmptyArray { key, value } => {
621                    result.add_type(Atomic::TNonEmptyArray {
622                        key: Box::new(key.substitute_templates(bindings)),
623                        value: Box::new(value.substitute_templates(bindings)),
624                    });
625                }
626                Atomic::TNonEmptyList { value } => {
627                    result.add_type(Atomic::TNonEmptyList {
628                        value: Box::new(value.substitute_templates(bindings)),
629                    });
630                }
631                Atomic::TKeyedArray {
632                    properties,
633                    is_open,
634                    is_list,
635                } => {
636                    use crate::atomic::KeyedProperty;
637                    let new_props = properties
638                        .iter()
639                        .map(|(k, prop)| {
640                            (
641                                k.clone(),
642                                KeyedProperty {
643                                    ty: prop.ty.substitute_templates(bindings),
644                                    optional: prop.optional,
645                                },
646                            )
647                        })
648                        .collect();
649                    result.add_type(Atomic::TKeyedArray {
650                        properties: new_props,
651                        is_open: *is_open,
652                        is_list: *is_list,
653                    });
654                }
655                Atomic::TCallable {
656                    params,
657                    return_type,
658                } => {
659                    result.add_type(Atomic::TCallable {
660                        params: params.as_ref().map(|ps| {
661                            ps.iter()
662                                .map(|p| substitute_in_fn_param(p, bindings))
663                                .collect()
664                        }),
665                        return_type: return_type
666                            .as_ref()
667                            .map(|r| Box::new(r.substitute_templates(bindings))),
668                    });
669                }
670                Atomic::TClosure {
671                    params,
672                    return_type,
673                    this_type,
674                } => {
675                    result.add_type(Atomic::TClosure {
676                        params: params
677                            .iter()
678                            .map(|p| substitute_in_fn_param(p, bindings))
679                            .collect(),
680                        return_type: Box::new(return_type.substitute_templates(bindings)),
681                        this_type: this_type
682                            .as_ref()
683                            .map(|t| Box::new(t.substitute_templates(bindings))),
684                    });
685                }
686                Atomic::TConditional {
687                    param_name,
688                    subject,
689                    if_true,
690                    if_false,
691                } => {
692                    let new_subject = subject.substitute_templates(bindings);
693                    let new_if_true = if_true.substitute_templates(bindings);
694                    let new_if_false = if_false.substitute_templates(bindings);
695
696                    // If param_name names a template that is bound in this substitution,
697                    // resolve the conditional immediately using the same predicate logic as
698                    // `resolve_conditional_returns` for the $param form.
699                    let resolved = if let Some(name) = param_name {
700                        if let Some(bound) = bindings.get(name) {
701                            if new_subject.types.len() == 1 {
702                                resolve_conditional_branch(
703                                    &new_subject.types[0],
704                                    bound,
705                                    &new_if_true,
706                                    &new_if_false,
707                                )
708                            } else {
709                                None
710                            }
711                        } else {
712                            None
713                        }
714                    } else {
715                        None
716                    };
717
718                    if let Some(branch) = resolved {
719                        for t in branch.types {
720                            result.add_type(t);
721                        }
722                    } else {
723                        result.add_type(Atomic::TConditional {
724                            param_name: *param_name,
725                            subject: Box::new(new_subject),
726                            if_true: Box::new(new_if_true),
727                            if_false: Box::new(new_if_false),
728                        });
729                    }
730                }
731                Atomic::TIntersection { parts } => {
732                    result.add_type(Atomic::TIntersection {
733                        parts: vec_to_type_params(
734                            parts
735                                .iter()
736                                .map(|p| p.substitute_templates(bindings))
737                                .collect(),
738                        ),
739                    });
740                }
741                Atomic::TNamedObject { fqcn, type_params } => {
742                    // TODO: the docblock parser emits TNamedObject { fqcn: "T" } for bare @return T
743                    // annotations instead of TTemplateParam, because it lacks template context at
744                    // parse time. This block works around that by treating bare unqualified names
745                    // as template param references when they appear in the binding map. Proper fix:
746                    // make the docblock parser template-aware so it emits TTemplateParam directly.
747                    // See issue #26 for context.
748                    if type_params.is_empty() && !fqcn.contains('\\') {
749                        if let Some(resolved) = bindings.get(fqcn) {
750                            for t in &resolved.types {
751                                result.add_type(t.clone());
752                            }
753                            continue;
754                        }
755                    }
756                    let new_params: Vec<Type> = type_params
757                        .iter()
758                        .map(|p| p.substitute_templates(bindings))
759                        .collect();
760                    result.add_type(Atomic::TNamedObject {
761                        fqcn: *fqcn,
762                        type_params: vec_to_type_params(new_params),
763                    });
764                }
765                // class-string<T> → substitute T from bindings
766                Atomic::TClassString(Some(param_name)) => {
767                    if let Some(resolved) = bindings.get(param_name) {
768                        for r_atomic in &resolved.types {
769                            let cls_name = if let Atomic::TNamedObject { fqcn, .. } = r_atomic {
770                                Some(*fqcn)
771                            } else {
772                                None
773                            };
774                            result.add_type(Atomic::TClassString(cls_name));
775                        }
776                    } else {
777                        result.add_type(atomic.clone());
778                    }
779                }
780                _ => {
781                    result.add_type(atomic.clone());
782                }
783            }
784        }
785        result
786    }
787
788    /// Resolves `TConditional` atoms whose discriminator is known at the call site.
789    ///
790    /// `lookup(param_name)` returns the call-site argument type for the named parameter,
791    /// or `None` if the argument is not available. Handles `is null`, `is string`, and
792    /// `is array` conditions; other condition types pass through unchanged.
793    pub fn resolve_conditional_returns<F>(self, lookup: F) -> Type
794    where
795        F: Fn(&str) -> Option<Type>,
796    {
797        self.resolve_conditional_inner(&lookup)
798    }
799
800    fn resolve_conditional_inner<F>(self, lookup: &F) -> Type
801    where
802        F: Fn(&str) -> Option<Type>,
803    {
804        let mut result = Type::empty();
805        for atomic in self.types {
806            match atomic {
807                Atomic::TConditional {
808                    ref param_name,
809                    ref subject,
810                    ref if_true,
811                    ref if_false,
812                } => {
813                    let resolved = if subject.types.len() == 1 {
814                        if let Some(name) = param_name {
815                            if let Some(arg_ty) = lookup(name.as_ref()) {
816                                resolve_conditional_branch(
817                                    &subject.types[0],
818                                    &arg_ty,
819                                    if_true,
820                                    if_false,
821                                )
822                            } else {
823                                None
824                            }
825                        } else {
826                            None
827                        }
828                    } else {
829                        None
830                    };
831
832                    if let Some(branch) = resolved {
833                        // Recursively resolve nested conditionals in the selected branch.
834                        for t in branch.resolve_conditional_inner(lookup).types {
835                            result.add_type(t);
836                        }
837                    } else {
838                        // Cannot resolve at this call site: widen to the union of both branches.
839                        // Recursively resolve nested conditionals in each branch.
840                        for t in if_true.clone().resolve_conditional_inner(lookup).types {
841                            result.add_type(t);
842                        }
843                        for t in if_false.clone().resolve_conditional_inner(lookup).types {
844                            result.add_type(t);
845                        }
846                    }
847                }
848                other => result.add_type(other),
849            }
850        }
851        result
852    }
853
854    // --- Subtype check -------------------------------------------------------
855
856    /// Returns true if every atomic in `self` is a subtype of some atomic in `other`,
857    /// using **only structural rules** — no `extends` / `implements` walk.
858    ///
859    /// Two distinct user-defined classes are never related here, even when one
860    /// extends the other. Within `mir-analyzer`, when a `db` is in scope,
861    /// prefer `crate::subtype::is_subtype(db, sub, sup)` which layers
862    /// inheritance resolution on top of this check.
863    pub fn is_subtype_structural(&self, other: &Type) -> bool {
864        if other.is_mixed() {
865            return true;
866        }
867        if self.is_never() {
868            return true; // never <: everything
869        }
870        self.types
871            .iter()
872            .all(|a| other.types.iter().any(|b| atomic_subtype(a, b)))
873    }
874
875    // --- Utilities ----------------------------------------------------------
876
877    fn filter<F: Fn(&Atomic) -> bool>(&self, f: F) -> Type {
878        let mut result = Type::empty();
879        result.possibly_undefined = self.possibly_undefined;
880        result.from_docblock = self.from_docblock;
881        for atomic in &self.types {
882            if f(atomic) {
883                result.types.push(atomic.clone());
884            }
885        }
886        result
887    }
888
889    /// Mark this union as possibly-undefined and return it.
890    pub fn possibly_undefined(mut self) -> Self {
891        self.possibly_undefined = true;
892        self
893    }
894
895    /// Mark this union as coming from a docblock annotation.
896    pub fn from_docblock(mut self) -> Self {
897        self.from_docblock = true;
898        self
899    }
900}
901
902// ---------------------------------------------------------------------------
903// Conditional return resolution helpers
904// ---------------------------------------------------------------------------
905
906fn is_string_atomic(a: &Atomic) -> bool {
907    matches!(
908        a,
909        Atomic::TString
910            | Atomic::TNonEmptyString
911            | Atomic::TLiteralString(_)
912            | Atomic::TNumericString
913            | Atomic::TClassString(_)
914            | Atomic::TCallableString
915    )
916}
917
918fn is_array_atomic(a: &Atomic) -> bool {
919    matches!(
920        a,
921        Atomic::TArray { .. }
922            | Atomic::TNonEmptyArray { .. }
923            | Atomic::TKeyedArray { .. }
924            | Atomic::TList { .. }
925            | Atomic::TNonEmptyList { .. }
926    )
927}
928
929fn is_list_atomic(a: &Atomic) -> bool {
930    match a {
931        Atomic::TList { .. } | Atomic::TNonEmptyList { .. } => true,
932        Atomic::TKeyedArray { is_list, .. } => *is_list,
933        _ => false,
934    }
935}
936
937/// Resolve one branch of a conditional return type given the subject discriminant
938/// atomic and the actual argument type at the call site.
939///
940/// Returns `Some(branch)` when the branch can be determined statically, or `None`
941/// to signal that the caller should widen to the union of both branches.
942fn resolve_conditional_branch(
943    subject: &Atomic,
944    arg_ty: &Type,
945    if_true: &Type,
946    if_false: &Type,
947) -> Option<Type> {
948    let predicate: fn(&Atomic) -> bool = match subject {
949        Atomic::TNull => |a| matches!(a, Atomic::TNull),
950        Atomic::TTrue => |a| matches!(a, Atomic::TTrue),
951        Atomic::TFalse => |a| matches!(a, Atomic::TFalse),
952        Atomic::TString => is_string_atomic,
953        Atomic::TList { .. } => is_list_atomic,
954        Atomic::TArray { .. } => is_array_atomic,
955        _ => return None,
956    };
957
958    if arg_ty.types.is_empty() {
959        return None;
960    }
961    let all_match = arg_ty.types.iter().all(&predicate);
962    let none_match = !arg_ty.types.iter().any(predicate);
963    if all_match {
964        Some(if_true.clone())
965    } else if none_match {
966        Some(if_false.clone())
967    } else {
968        None
969    }
970}
971
972// ---------------------------------------------------------------------------
973// Template substitution helpers
974// ---------------------------------------------------------------------------
975
976fn substitute_in_fn_param(
977    p: &crate::atomic::FnParam,
978    bindings: &FxHashMap<Name, Type>,
979) -> crate::atomic::FnParam {
980    crate::atomic::FnParam {
981        name: p.name,
982        ty: p.ty.as_ref().map(|t| {
983            let u = t.to_union();
984            let substituted = u.substitute_templates(bindings);
985            crate::compact::SimpleType::from_union(substituted)
986        }),
987        default: p.default.as_ref().map(|d| {
988            let u = d.to_union();
989            let substituted = u.substitute_templates(bindings);
990            crate::compact::SimpleType::from_union(substituted)
991        }),
992        is_variadic: p.is_variadic,
993        is_byref: p.is_byref,
994        is_optional: p.is_optional,
995    }
996}
997
998// ---------------------------------------------------------------------------
999// Atomic subtype (no codebase — structural check only)
1000// ---------------------------------------------------------------------------
1001
1002fn atomic_subtype(sub: &Atomic, sup: &Atomic) -> bool {
1003    if sub == sup {
1004        return true;
1005    }
1006    match (sub, sup) {
1007        // Bottom type
1008        (Atomic::TNever, _) => true,
1009        // Top types — anything goes in both directions for mixed
1010        (_, Atomic::TMixed) => true,
1011        (Atomic::TMixed, _) => true,
1012        // Template param in supertype position: any value satisfies an unconstrained
1013        // template (as_type = mixed), or a constrained one if it satisfies the bound.
1014        // This handles union bounds like `T of string|list<I>|array<K, V>` where
1015        // I/K/V are free template params — any type satisfies them structurally.
1016        (_, Atomic::TTemplateParam { as_type, .. }) => {
1017            as_type.is_mixed() || as_type.types.iter().any(|b| atomic_subtype(sub, b))
1018        }
1019
1020        // Scalars
1021        (Atomic::TLiteralInt(_), Atomic::TInt) => true,
1022        (Atomic::TLiteralInt(_), Atomic::TNumeric) => true,
1023        (Atomic::TLiteralInt(_), Atomic::TScalar) => true,
1024        (Atomic::TLiteralInt(n), Atomic::TPositiveInt) => *n > 0,
1025        (Atomic::TLiteralInt(n), Atomic::TNonNegativeInt) => *n >= 0,
1026        (Atomic::TLiteralInt(n), Atomic::TNegativeInt) => *n < 0,
1027        (Atomic::TPositiveInt, Atomic::TInt) => true,
1028        (Atomic::TPositiveInt, Atomic::TNonNegativeInt) => true,
1029        (Atomic::TNegativeInt, Atomic::TInt) => true,
1030        (Atomic::TNonNegativeInt, Atomic::TInt) => true,
1031        (Atomic::TIntRange { .. }, Atomic::TInt) => true,
1032
1033        (Atomic::TLiteralFloat(..), Atomic::TFloat) => true,
1034        (Atomic::TLiteralFloat(..), Atomic::TNumeric) => true,
1035        (Atomic::TLiteralFloat(..), Atomic::TScalar) => true,
1036
1037        (Atomic::TLiteralString(s), Atomic::TString) => {
1038            let _ = s;
1039            true
1040        }
1041        (Atomic::TLiteralString(s), Atomic::TCallableString) => {
1042            let _ = s;
1043            true
1044        }
1045        (Atomic::TLiteralString(s), Atomic::TNonEmptyString) => !s.is_empty(),
1046        (Atomic::TLiteralString(s), Atomic::TNumericString) => s.parse::<f64>().is_ok(),
1047        (Atomic::TLiteralString(_), Atomic::TScalar) => true,
1048        (Atomic::TNonEmptyString, Atomic::TString) => true,
1049        (Atomic::TCallableString, Atomic::TString) => true,
1050        (Atomic::TNumericString, Atomic::TString) => true,
1051        (Atomic::TClassString(_), Atomic::TString) => true,
1052        (Atomic::TInterfaceString, Atomic::TString) => true,
1053        (Atomic::TEnumString, Atomic::TString) => true,
1054        (Atomic::TTraitString, Atomic::TString) => true,
1055
1056        (Atomic::TTrue, Atomic::TBool) => true,
1057        (Atomic::TFalse, Atomic::TBool) => true,
1058
1059        (Atomic::TInt, Atomic::TNumeric) => true,
1060        (Atomic::TFloat, Atomic::TNumeric) => true,
1061        (Atomic::TNumericString, Atomic::TNumeric) => true,
1062
1063        (Atomic::TInt, Atomic::TScalar) => true,
1064        (Atomic::TFloat, Atomic::TScalar) => true,
1065        (Atomic::TString, Atomic::TScalar) => true,
1066        (Atomic::TBool, Atomic::TScalar) => true,
1067        (Atomic::TNumeric, Atomic::TScalar) => true,
1068        (Atomic::TTrue, Atomic::TScalar) => true,
1069        (Atomic::TFalse, Atomic::TScalar) => true,
1070
1071        // Object hierarchy (structural, no codebase)
1072        (Atomic::TNamedObject { .. }, Atomic::TObject) => true,
1073        (Atomic::TStaticObject { .. }, Atomic::TObject) => true,
1074        (Atomic::TSelf { .. }, Atomic::TObject) => true,
1075        // self(X) and static(X) satisfy TNamedObject(X) with same FQCN
1076        (Atomic::TSelf { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
1077        (Atomic::TStaticObject { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
1078        // TNamedObject(X) satisfies self(X) / static(X) with same FQCN
1079        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TSelf { fqcn: b }) => a == b,
1080        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TStaticObject { fqcn: b }) => a == b,
1081        // Bare generic property accepts parameterized value: Box accepts Box<string>.
1082        // The reverse is NOT true — bare Box value does not satisfy Box<string> property
1083        // (invariant check). Only sup being bare (empty type_params) is the wildcard.
1084        (
1085            Atomic::TNamedObject {
1086                fqcn: sub_fqcn,
1087                type_params: sub_params,
1088            },
1089            Atomic::TNamedObject {
1090                fqcn: sup_fqcn,
1091                type_params: sup_params,
1092            },
1093        ) => sub_fqcn == sup_fqcn && (sup_params.is_empty() || sub_params == sup_params),
1094
1095        // Literal int widens to float in PHP
1096        (Atomic::TLiteralInt(_), Atomic::TFloat) => true,
1097        (Atomic::TPositiveInt, Atomic::TFloat) => true,
1098        (Atomic::TInt, Atomic::TFloat) => true,
1099
1100        // Literal int satisfies int ranges
1101        (Atomic::TLiteralInt(_), Atomic::TIntRange { .. }) => true,
1102
1103        // PHP callables: string and array are valid callable values
1104        (Atomic::TString, Atomic::TCallable { .. }) => true,
1105        (Atomic::TNonEmptyString, Atomic::TCallable { .. }) => true,
1106        (Atomic::TLiteralString(_), Atomic::TCallable { .. }) => true,
1107        (Atomic::TArray { .. }, Atomic::TCallable { .. }) => true,
1108        (Atomic::TNonEmptyArray { .. }, Atomic::TCallable { .. }) => true,
1109
1110        // Closure <: callable, typed Closure <: Closure
1111        (Atomic::TClosure { .. }, Atomic::TCallable { .. }) => true,
1112        // callable <: Closure: callable is wider but not flagged at default error level
1113        (Atomic::TCallable { .. }, Atomic::TClosure { .. }) => true,
1114        // Any TClosure satisfies another TClosure (structural compatibility simplified)
1115        (Atomic::TClosure { .. }, Atomic::TClosure { .. }) => true,
1116        // callable <: callable (trivial)
1117        (Atomic::TCallable { .. }, Atomic::TCallable { .. }) => true,
1118        // TClosure satisfies `Closure` named object or `object`
1119        (Atomic::TClosure { .. }, Atomic::TNamedObject { fqcn, .. }) => {
1120            fqcn.as_ref().eq_ignore_ascii_case("closure")
1121        }
1122        (Atomic::TClosure { .. }, Atomic::TObject) => true,
1123        // bare `Closure` (named object without signature) satisfies any typed Closure(): T
1124        (Atomic::TNamedObject { fqcn, .. }, Atomic::TClosure { .. }) => {
1125            fqcn.as_ref().eq_ignore_ascii_case("closure")
1126        }
1127
1128        // List <: array
1129        (Atomic::TList { value }, Atomic::TArray { key, value: av }) => {
1130            // list key is always int
1131            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1132                && value.is_subtype_structural(av)
1133        }
1134        (Atomic::TNonEmptyList { value }, Atomic::TList { value: lv }) => {
1135            value.is_subtype_structural(lv)
1136        }
1137        // array<int, X> is accepted where list<X> or non-empty-list<X> expected
1138        (Atomic::TArray { key, value: av }, Atomic::TList { value: lv }) => {
1139            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1140                && av.is_subtype_structural(lv)
1141        }
1142        (Atomic::TArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1143            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1144                && av.is_subtype_structural(lv)
1145        }
1146        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TList { value: lv }) => {
1147            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1148                && av.is_subtype_structural(lv)
1149        }
1150        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1151            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1152                && av.is_subtype_structural(lv)
1153        }
1154        // TList <: TList value covariance
1155        (Atomic::TList { value: v1 }, Atomic::TList { value: v2 }) => v1.is_subtype_structural(v2),
1156        (Atomic::TNonEmptyArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1157            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1158        }
1159
1160        // array<A, B> <: array<C, D>  iff  A <: C && B <: D
1161        (Atomic::TArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1162            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1163        }
1164
1165        // A keyed/shape array (array{...} or array{}) is a subtype of any generic array.
1166        (Atomic::TKeyedArray { .. }, Atomic::TArray { .. }) => true,
1167
1168        // A list-shaped keyed array (is_list=true, all int keys) is a subtype of list<X>.
1169        (
1170            Atomic::TKeyedArray {
1171                properties,
1172                is_list,
1173                ..
1174            },
1175            Atomic::TList { value: lv },
1176        ) => *is_list && properties.values().all(|p| p.ty.is_subtype_structural(lv)),
1177        (
1178            Atomic::TKeyedArray {
1179                properties,
1180                is_list,
1181                ..
1182            },
1183            Atomic::TNonEmptyList { value: lv },
1184        ) => {
1185            *is_list
1186                && !properties.is_empty()
1187                && properties.values().all(|p| p.ty.is_subtype_structural(lv))
1188        }
1189
1190        _ => false,
1191    }
1192}
1193
1194// ---------------------------------------------------------------------------
1195// Tests
1196// ---------------------------------------------------------------------------
1197
1198#[cfg(test)]
1199mod tests {
1200    use std::sync::Arc;
1201
1202    use super::*;
1203
1204    #[test]
1205    fn single_is_single() {
1206        let u = Type::single(Atomic::TString);
1207        assert!(u.is_single());
1208        assert!(!u.is_nullable());
1209    }
1210
1211    #[test]
1212    fn nullable_has_null() {
1213        let u = Type::nullable(Atomic::TString);
1214        assert!(u.is_nullable());
1215        assert_eq!(u.types.len(), 2);
1216    }
1217
1218    #[test]
1219    fn add_type_deduplicates() {
1220        let mut u = Type::single(Atomic::TString);
1221        u.add_type(Atomic::TString);
1222        assert_eq!(u.types.len(), 1);
1223    }
1224
1225    #[test]
1226    fn add_type_literal_subsumed_by_base() {
1227        let mut u = Type::single(Atomic::TInt);
1228        u.add_type(Atomic::TLiteralInt(42));
1229        assert_eq!(u.types.len(), 1);
1230        assert!(matches!(u.types[0], Atomic::TInt));
1231    }
1232
1233    #[test]
1234    fn add_type_base_widens_literals() {
1235        let mut u = Type::single(Atomic::TLiteralInt(1));
1236        u.add_type(Atomic::TLiteralInt(2));
1237        u.add_type(Atomic::TInt);
1238        assert_eq!(u.types.len(), 1);
1239        assert!(matches!(u.types[0], Atomic::TInt));
1240    }
1241
1242    #[test]
1243    fn mixed_subsumes_everything() {
1244        let mut u = Type::single(Atomic::TString);
1245        u.add_type(Atomic::TMixed);
1246        assert_eq!(u.types.len(), 1);
1247        assert!(u.is_mixed());
1248    }
1249
1250    #[test]
1251    fn remove_null() {
1252        let u = Type::nullable(Atomic::TString);
1253        let narrowed = u.remove_null();
1254        assert!(!narrowed.is_nullable());
1255        assert_eq!(narrowed.types.len(), 1);
1256    }
1257
1258    #[test]
1259    fn narrow_to_truthy_removes_null_false() {
1260        let mut u = Type::empty();
1261        u.add_type(Atomic::TString);
1262        u.add_type(Atomic::TNull);
1263        u.add_type(Atomic::TFalse);
1264        let truthy = u.narrow_to_truthy();
1265        assert!(!truthy.is_nullable());
1266        assert!(!truthy.contains(|t| matches!(t, Atomic::TFalse)));
1267    }
1268
1269    #[test]
1270    fn merge_combines_types() {
1271        let a = Type::single(Atomic::TString);
1272        let b = Type::single(Atomic::TInt);
1273        let merged = Type::merge(&a, &b);
1274        assert_eq!(merged.types.len(), 2);
1275    }
1276
1277    #[test]
1278    fn subtype_literal_int_under_int() {
1279        let sub = Type::single(Atomic::TLiteralInt(5));
1280        let sup = Type::single(Atomic::TInt);
1281        assert!(sub.is_subtype_structural(&sup));
1282    }
1283
1284    #[test]
1285    fn subtype_never_is_bottom() {
1286        let never = Type::never();
1287        let string = Type::single(Atomic::TString);
1288        assert!(never.is_subtype_structural(&string));
1289    }
1290
1291    #[test]
1292    fn subtype_everything_under_mixed() {
1293        let string = Type::single(Atomic::TString);
1294        let mixed = Type::mixed();
1295        assert!(string.is_subtype_structural(&mixed));
1296    }
1297
1298    #[test]
1299    fn template_substitution() {
1300        let mut bindings = FxHashMap::default();
1301        bindings.insert(Name::new("T"), Type::single(Atomic::TString));
1302
1303        let tmpl = Type::single(Atomic::TTemplateParam {
1304            name: Name::new("T"),
1305            as_type: Box::new(Type::mixed()),
1306            defining_entity: Name::new("MyClass"),
1307        });
1308
1309        let resolved = tmpl.substitute_templates(&bindings);
1310        assert_eq!(resolved.types.len(), 1);
1311        assert!(matches!(resolved.types[0], Atomic::TString));
1312    }
1313
1314    #[test]
1315    fn intersection_is_object() {
1316        let parts = vec![
1317            Type::single(Atomic::TNamedObject {
1318                fqcn: Name::new("Iterator"),
1319                type_params: empty_type_params(),
1320            }),
1321            Type::single(Atomic::TNamedObject {
1322                fqcn: Name::new("Countable"),
1323                type_params: empty_type_params(),
1324            }),
1325        ];
1326        let atomic = Atomic::TIntersection {
1327            parts: vec_to_type_params(parts),
1328        };
1329        assert!(atomic.is_object());
1330        assert!(!atomic.can_be_falsy());
1331        assert!(atomic.can_be_truthy());
1332    }
1333
1334    #[test]
1335    fn intersection_display_two_parts() {
1336        let parts = vec![
1337            Type::single(Atomic::TNamedObject {
1338                fqcn: Name::new("Iterator"),
1339                type_params: empty_type_params(),
1340            }),
1341            Type::single(Atomic::TNamedObject {
1342                fqcn: Name::new("Countable"),
1343                type_params: empty_type_params(),
1344            }),
1345        ];
1346        let u = Type::single(Atomic::TIntersection {
1347            parts: vec_to_type_params(parts),
1348        });
1349        assert_eq!(format!("{u}"), "Iterator&Countable");
1350    }
1351
1352    #[test]
1353    fn intersection_display_three_parts() {
1354        let parts = vec![
1355            Type::single(Atomic::TNamedObject {
1356                fqcn: Name::new("A"),
1357                type_params: empty_type_params(),
1358            }),
1359            Type::single(Atomic::TNamedObject {
1360                fqcn: Name::new("B"),
1361                type_params: empty_type_params(),
1362            }),
1363            Type::single(Atomic::TNamedObject {
1364                fqcn: Name::new("C"),
1365                type_params: empty_type_params(),
1366            }),
1367        ];
1368        let u = Type::single(Atomic::TIntersection {
1369            parts: vec_to_type_params(parts),
1370        });
1371        assert_eq!(format!("{u}"), "A&B&C");
1372    }
1373
1374    #[test]
1375    fn intersection_in_nullable_union_display() {
1376        let intersection = Atomic::TIntersection {
1377            parts: vec_to_type_params(vec![
1378                Type::single(Atomic::TNamedObject {
1379                    fqcn: Name::new("Iterator"),
1380                    type_params: empty_type_params(),
1381                }),
1382                Type::single(Atomic::TNamedObject {
1383                    fqcn: Name::new("Countable"),
1384                    type_params: empty_type_params(),
1385                }),
1386            ]),
1387        };
1388        let mut u = Type::single(intersection);
1389        u.add_type(Atomic::TNull);
1390        assert!(u.is_nullable());
1391        assert!(u.contains(|t| matches!(t, Atomic::TIntersection { .. })));
1392    }
1393
1394    // --- substitute_templates coverage for previously-missing arms ----------
1395
1396    fn t_param(name: &str) -> Type {
1397        Type::single(Atomic::TTemplateParam {
1398            name: Name::new(name),
1399            as_type: Box::new(Type::mixed()),
1400            defining_entity: Name::new("Fn"),
1401        })
1402    }
1403
1404    fn bindings_t_string() -> FxHashMap<Name, Type> {
1405        let mut b = FxHashMap::default();
1406        b.insert(Name::new("T"), Type::single(Atomic::TString));
1407        b
1408    }
1409
1410    #[test]
1411    fn substitute_non_empty_array_key_and_value() {
1412        let ty = Type::single(Atomic::TNonEmptyArray {
1413            key: Box::new(t_param("T")),
1414            value: Box::new(t_param("T")),
1415        });
1416        let result = ty.substitute_templates(&bindings_t_string());
1417        assert_eq!(result.types.len(), 1);
1418        let Atomic::TNonEmptyArray { key, value } = &result.types[0] else {
1419            panic!("expected TNonEmptyArray");
1420        };
1421        assert!(matches!(key.types[0], Atomic::TString));
1422        assert!(matches!(value.types[0], Atomic::TString));
1423    }
1424
1425    #[test]
1426    fn substitute_non_empty_list_value() {
1427        let ty = Type::single(Atomic::TNonEmptyList {
1428            value: Box::new(t_param("T")),
1429        });
1430        let result = ty.substitute_templates(&bindings_t_string());
1431        let Atomic::TNonEmptyList { value } = &result.types[0] else {
1432            panic!("expected TNonEmptyList");
1433        };
1434        assert!(matches!(value.types[0], Atomic::TString));
1435    }
1436
1437    #[test]
1438    fn substitute_keyed_array_property_types() {
1439        use crate::atomic::{ArrayKey, KeyedProperty};
1440        use indexmap::IndexMap;
1441        let mut props = IndexMap::new();
1442        props.insert(
1443            ArrayKey::String(Arc::from("name")),
1444            KeyedProperty {
1445                ty: t_param("T"),
1446                optional: false,
1447            },
1448        );
1449        props.insert(
1450            ArrayKey::String(Arc::from("tag")),
1451            KeyedProperty {
1452                ty: t_param("T"),
1453                optional: true,
1454            },
1455        );
1456        let ty = Type::single(Atomic::TKeyedArray {
1457            properties: props,
1458            is_open: true,
1459            is_list: false,
1460        });
1461        let result = ty.substitute_templates(&bindings_t_string());
1462        let Atomic::TKeyedArray {
1463            properties,
1464            is_open,
1465            is_list,
1466        } = &result.types[0]
1467        else {
1468            panic!("expected TKeyedArray");
1469        };
1470        assert!(is_open);
1471        assert!(!is_list);
1472        assert!(matches!(
1473            properties[&ArrayKey::String(Arc::from("name"))].ty.types[0],
1474            Atomic::TString
1475        ));
1476        assert!(properties[&ArrayKey::String(Arc::from("tag"))].optional);
1477        assert!(matches!(
1478            properties[&ArrayKey::String(Arc::from("tag"))].ty.types[0],
1479            Atomic::TString
1480        ));
1481    }
1482
1483    #[test]
1484    fn substitute_callable_params_and_return() {
1485        use crate::atomic::FnParam;
1486        let ty = Type::single(Atomic::TCallable {
1487            params: Some(vec![FnParam {
1488                name: Name::new("x"),
1489                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1490                default: None,
1491                is_variadic: false,
1492                is_byref: false,
1493                is_optional: false,
1494            }]),
1495            return_type: Some(Box::new(t_param("T"))),
1496        });
1497        let result = ty.substitute_templates(&bindings_t_string());
1498        let Atomic::TCallable {
1499            params,
1500            return_type,
1501        } = &result.types[0]
1502        else {
1503            panic!("expected TCallable");
1504        };
1505        let param_ty = params.as_ref().unwrap()[0].ty.as_ref().unwrap();
1506        let param_union = param_ty.to_union();
1507        assert!(matches!(param_union.types[0], Atomic::TString));
1508        let ret = return_type.as_ref().unwrap();
1509        assert!(matches!(ret.types[0], Atomic::TString));
1510    }
1511
1512    #[test]
1513    fn substitute_callable_bare_no_panic() {
1514        // callable with no params/return — must not panic and must pass through unchanged
1515        let ty = Type::single(Atomic::TCallable {
1516            params: None,
1517            return_type: None,
1518        });
1519        let result = ty.substitute_templates(&bindings_t_string());
1520        assert!(matches!(
1521            result.types[0],
1522            Atomic::TCallable {
1523                params: None,
1524                return_type: None
1525            }
1526        ));
1527    }
1528
1529    #[test]
1530    fn substitute_closure_params_return_and_this() {
1531        use crate::atomic::FnParam;
1532        let ty = Type::single(Atomic::TClosure {
1533            params: vec![FnParam {
1534                name: Name::new("a"),
1535                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1536                default: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1537                is_variadic: true,
1538                is_byref: true,
1539                is_optional: true,
1540            }],
1541            return_type: Box::new(t_param("T")),
1542            this_type: Some(Box::new(t_param("T"))),
1543        });
1544        let result = ty.substitute_templates(&bindings_t_string());
1545        let Atomic::TClosure {
1546            params,
1547            return_type,
1548            this_type,
1549        } = &result.types[0]
1550        else {
1551            panic!("expected TClosure");
1552        };
1553        let p = &params[0];
1554        let ty_union = p.ty.as_ref().unwrap().to_union();
1555        let default_union = p.default.as_ref().unwrap().to_union();
1556        assert!(matches!(ty_union.types[0], Atomic::TString));
1557        assert!(matches!(default_union.types[0], Atomic::TString));
1558        // flags preserved
1559        assert!(p.is_variadic);
1560        assert!(p.is_byref);
1561        assert!(p.is_optional);
1562        assert!(matches!(return_type.types[0], Atomic::TString));
1563        assert!(matches!(
1564            this_type.as_ref().unwrap().types[0],
1565            Atomic::TString
1566        ));
1567    }
1568
1569    #[test]
1570    fn substitute_conditional_all_branches() {
1571        let ty = Type::single(Atomic::TConditional {
1572            param_name: None,
1573            subject: Box::new(t_param("T")),
1574            if_true: Box::new(t_param("T")),
1575            if_false: Box::new(Type::single(Atomic::TInt)),
1576        });
1577        let result = ty.substitute_templates(&bindings_t_string());
1578        let Atomic::TConditional {
1579            param_name: _,
1580            subject,
1581            if_true,
1582            if_false,
1583        } = &result.types[0]
1584        else {
1585            panic!("expected TConditional");
1586        };
1587        assert!(matches!(subject.types[0], Atomic::TString));
1588        assert!(matches!(if_true.types[0], Atomic::TString));
1589        assert!(matches!(if_false.types[0], Atomic::TInt));
1590    }
1591
1592    #[test]
1593    fn resolve_conditional_is_null_non_null_arg() {
1594        let ty = Type::single(Atomic::TConditional {
1595            param_name: Some(Name::new("x")),
1596            subject: Box::new(Type::single(Atomic::TNull)),
1597            if_true: Box::new(Type::single(Atomic::TInt)),
1598            if_false: Box::new(Type::single(Atomic::TString)),
1599        });
1600        let result = ty.resolve_conditional_returns(|name| {
1601            if name == "x" {
1602                Some(Type::single(Atomic::TString)) // definitely not null
1603            } else {
1604                None
1605            }
1606        });
1607        assert!(result.types.len() == 1);
1608        assert!(matches!(result.types[0], Atomic::TString));
1609    }
1610
1611    #[test]
1612    fn resolve_conditional_is_null_null_arg() {
1613        let ty = Type::single(Atomic::TConditional {
1614            param_name: Some(Name::new("x")),
1615            subject: Box::new(Type::single(Atomic::TNull)),
1616            if_true: Box::new(Type::single(Atomic::TInt)),
1617            if_false: Box::new(Type::single(Atomic::TString)),
1618        });
1619        let result = ty.resolve_conditional_returns(|name| {
1620            if name == "x" {
1621                Some(Type::single(Atomic::TNull)) // definitely null
1622            } else {
1623                None
1624            }
1625        });
1626        assert!(result.types.len() == 1);
1627        assert!(matches!(result.types[0], Atomic::TInt));
1628    }
1629
1630    #[test]
1631    fn resolve_conditional_is_null_nullable_arg_widens_to_branch_union() {
1632        let mut nullable_str = Type::single(Atomic::TString);
1633        nullable_str.add_type(Atomic::TNull);
1634        let ty = Type::single(Atomic::TConditional {
1635            param_name: Some(Name::new("x")),
1636            subject: Box::new(Type::single(Atomic::TNull)),
1637            if_true: Box::new(Type::single(Atomic::TInt)),
1638            if_false: Box::new(Type::single(Atomic::TString)),
1639        });
1640        let result = ty.resolve_conditional_returns(|name| {
1641            if name == "x" {
1642                Some(nullable_str.clone())
1643            } else {
1644                None
1645            }
1646        });
1647        // uncertain discriminator → widen to if_true | if_false
1648        assert_eq!(result.types.len(), 2);
1649        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
1650        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
1651    }
1652
1653    #[test]
1654    fn resolve_conditional_nested_widens_inner_branch() {
1655        // ($x is null ? int : ($x is string ? string : float))
1656        // When $x is unknown, should widen to int|string|float (no TConditional remaining).
1657        let inner = Type::single(Atomic::TConditional {
1658            param_name: Some(Name::new("x")),
1659            subject: Box::new(Type::single(Atomic::TString)),
1660            if_true: Box::new(Type::single(Atomic::TString)),
1661            if_false: Box::new(Type::single(Atomic::TFloat)),
1662        });
1663        let ty = Type::single(Atomic::TConditional {
1664            param_name: Some(Name::new("x")),
1665            subject: Box::new(Type::single(Atomic::TNull)),
1666            if_true: Box::new(Type::single(Atomic::TInt)),
1667            if_false: Box::new(inner),
1668        });
1669        // unknown arg → widen both outer branches, inner conditional must also be widened
1670        let result = ty.resolve_conditional_returns(|_| None);
1671        assert!(
1672            result
1673                .types
1674                .iter()
1675                .all(|t| !matches!(t, Atomic::TConditional { .. })),
1676            "no TConditional should survive: {:?}",
1677            result.types
1678        );
1679        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
1680        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
1681        assert!(result.types.iter().any(|t| matches!(t, Atomic::TFloat)));
1682    }
1683
1684    #[test]
1685    fn resolve_conditional_nested_resolves_inner_branch() {
1686        // ($x is null ? int : ($x is string ? string : float))
1687        // When $x is definitely not null but unknown string-or-not → resolves outer to inner,
1688        // then inner must also be resolved.
1689        let inner = Type::single(Atomic::TConditional {
1690            param_name: Some(Name::new("x")),
1691            subject: Box::new(Type::single(Atomic::TString)),
1692            if_true: Box::new(Type::single(Atomic::TString)),
1693            if_false: Box::new(Type::single(Atomic::TFloat)),
1694        });
1695        let ty = Type::single(Atomic::TConditional {
1696            param_name: Some(Name::new("x")),
1697            subject: Box::new(Type::single(Atomic::TNull)),
1698            if_true: Box::new(Type::single(Atomic::TInt)),
1699            if_false: Box::new(inner),
1700        });
1701        // $x = string → outer: not null → if_false (inner); inner: is string → if_true = string
1702        let result = ty.resolve_conditional_returns(|name| {
1703            if name == "x" {
1704                Some(Type::single(Atomic::TString))
1705            } else {
1706                None
1707            }
1708        });
1709        assert!(
1710            result
1711                .types
1712                .iter()
1713                .all(|t| !matches!(t, Atomic::TConditional { .. })),
1714            "no TConditional should survive: {:?}",
1715            result.types
1716        );
1717        assert_eq!(result.types.len(), 1);
1718        assert!(matches!(result.types[0], Atomic::TString));
1719    }
1720
1721    #[test]
1722    fn substitute_intersection_parts() {
1723        let ty = Type::single(Atomic::TIntersection {
1724            parts: vec_to_type_params(vec![
1725                Type::single(Atomic::TNamedObject {
1726                    fqcn: Name::new("Countable"),
1727                    type_params: empty_type_params(),
1728                }),
1729                t_param("T"),
1730            ]),
1731        });
1732        let result = ty.substitute_templates(&bindings_t_string());
1733        let Atomic::TIntersection { parts } = &result.types[0] else {
1734            panic!("expected TIntersection");
1735        };
1736        assert_eq!(parts.len(), 2);
1737        assert!(matches!(parts[0].types[0], Atomic::TNamedObject { .. }));
1738        assert!(matches!(parts[1].types[0], Atomic::TString));
1739    }
1740
1741    #[test]
1742    fn substitute_no_template_params_identity() {
1743        let ty = Type::single(Atomic::TInt);
1744        let result = ty.substitute_templates(&bindings_t_string());
1745        assert!(matches!(result.types[0], Atomic::TInt));
1746    }
1747}