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(_), Atomic::TScalar) => true,
1047        (Atomic::TNonEmptyString, Atomic::TString) => true,
1048        (Atomic::TCallableString, Atomic::TString) => true,
1049        (Atomic::TNumericString, Atomic::TString) => true,
1050        (Atomic::TClassString(_), Atomic::TString) => true,
1051        (Atomic::TInterfaceString, Atomic::TString) => true,
1052        (Atomic::TEnumString, Atomic::TString) => true,
1053        (Atomic::TTraitString, Atomic::TString) => true,
1054
1055        (Atomic::TTrue, Atomic::TBool) => true,
1056        (Atomic::TFalse, Atomic::TBool) => true,
1057
1058        (Atomic::TInt, Atomic::TNumeric) => true,
1059        (Atomic::TFloat, Atomic::TNumeric) => true,
1060        (Atomic::TNumericString, Atomic::TNumeric) => true,
1061
1062        (Atomic::TInt, Atomic::TScalar) => true,
1063        (Atomic::TFloat, Atomic::TScalar) => true,
1064        (Atomic::TString, Atomic::TScalar) => true,
1065        (Atomic::TBool, Atomic::TScalar) => true,
1066        (Atomic::TNumeric, Atomic::TScalar) => true,
1067        (Atomic::TTrue, Atomic::TScalar) => true,
1068        (Atomic::TFalse, Atomic::TScalar) => true,
1069
1070        // Object hierarchy (structural, no codebase)
1071        (Atomic::TNamedObject { .. }, Atomic::TObject) => true,
1072        (Atomic::TStaticObject { .. }, Atomic::TObject) => true,
1073        (Atomic::TSelf { .. }, Atomic::TObject) => true,
1074        // self(X) and static(X) satisfy TNamedObject(X) with same FQCN
1075        (Atomic::TSelf { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
1076        (Atomic::TStaticObject { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
1077        // TNamedObject(X) satisfies self(X) / static(X) with same FQCN
1078        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TSelf { fqcn: b }) => a == b,
1079        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TStaticObject { fqcn: b }) => a == b,
1080        // Bare generic property accepts parameterized value: Box accepts Box<string>.
1081        // The reverse is NOT true — bare Box value does not satisfy Box<string> property
1082        // (invariant check). Only sup being bare (empty type_params) is the wildcard.
1083        (
1084            Atomic::TNamedObject {
1085                fqcn: sub_fqcn,
1086                type_params: sub_params,
1087            },
1088            Atomic::TNamedObject {
1089                fqcn: sup_fqcn,
1090                type_params: sup_params,
1091            },
1092        ) => sub_fqcn == sup_fqcn && (sup_params.is_empty() || sub_params == sup_params),
1093
1094        // Literal int widens to float in PHP
1095        (Atomic::TLiteralInt(_), Atomic::TFloat) => true,
1096        (Atomic::TPositiveInt, Atomic::TFloat) => true,
1097        (Atomic::TInt, Atomic::TFloat) => true,
1098
1099        // Literal int satisfies int ranges
1100        (Atomic::TLiteralInt(_), Atomic::TIntRange { .. }) => true,
1101
1102        // PHP callables: string and array are valid callable values
1103        (Atomic::TString, Atomic::TCallable { .. }) => true,
1104        (Atomic::TNonEmptyString, Atomic::TCallable { .. }) => true,
1105        (Atomic::TLiteralString(_), Atomic::TCallable { .. }) => true,
1106        (Atomic::TArray { .. }, Atomic::TCallable { .. }) => true,
1107        (Atomic::TNonEmptyArray { .. }, Atomic::TCallable { .. }) => true,
1108
1109        // Closure <: callable, typed Closure <: Closure
1110        (Atomic::TClosure { .. }, Atomic::TCallable { .. }) => true,
1111        // callable <: Closure: callable is wider but not flagged at default error level
1112        (Atomic::TCallable { .. }, Atomic::TClosure { .. }) => true,
1113        // Any TClosure satisfies another TClosure (structural compatibility simplified)
1114        (Atomic::TClosure { .. }, Atomic::TClosure { .. }) => true,
1115        // callable <: callable (trivial)
1116        (Atomic::TCallable { .. }, Atomic::TCallable { .. }) => true,
1117        // TClosure satisfies `Closure` named object or `object`
1118        (Atomic::TClosure { .. }, Atomic::TNamedObject { fqcn, .. }) => {
1119            fqcn.as_ref().eq_ignore_ascii_case("closure")
1120        }
1121        (Atomic::TClosure { .. }, Atomic::TObject) => true,
1122        // bare `Closure` (named object without signature) satisfies any typed Closure(): T
1123        (Atomic::TNamedObject { fqcn, .. }, Atomic::TClosure { .. }) => {
1124            fqcn.as_ref().eq_ignore_ascii_case("closure")
1125        }
1126
1127        // List <: array
1128        (Atomic::TList { value }, Atomic::TArray { key, value: av }) => {
1129            // list key is always int
1130            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1131                && value.is_subtype_structural(av)
1132        }
1133        (Atomic::TNonEmptyList { value }, Atomic::TList { value: lv }) => {
1134            value.is_subtype_structural(lv)
1135        }
1136        // array<int, X> is accepted where list<X> or non-empty-list<X> expected
1137        (Atomic::TArray { key, value: av }, Atomic::TList { value: lv }) => {
1138            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1139                && av.is_subtype_structural(lv)
1140        }
1141        (Atomic::TArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1142            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1143                && av.is_subtype_structural(lv)
1144        }
1145        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TList { value: lv }) => {
1146            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1147                && av.is_subtype_structural(lv)
1148        }
1149        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1150            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1151                && av.is_subtype_structural(lv)
1152        }
1153        // TList <: TList value covariance
1154        (Atomic::TList { value: v1 }, Atomic::TList { value: v2 }) => v1.is_subtype_structural(v2),
1155        (Atomic::TNonEmptyArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1156            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1157        }
1158
1159        // array<A, B> <: array<C, D>  iff  A <: C && B <: D
1160        (Atomic::TArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1161            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1162        }
1163
1164        // A keyed/shape array (array{...} or array{}) is a subtype of any generic array.
1165        (Atomic::TKeyedArray { .. }, Atomic::TArray { .. }) => true,
1166
1167        // A list-shaped keyed array (is_list=true, all int keys) is a subtype of list<X>.
1168        (
1169            Atomic::TKeyedArray {
1170                properties,
1171                is_list,
1172                ..
1173            },
1174            Atomic::TList { value: lv },
1175        ) => *is_list && properties.values().all(|p| p.ty.is_subtype_structural(lv)),
1176        (
1177            Atomic::TKeyedArray {
1178                properties,
1179                is_list,
1180                ..
1181            },
1182            Atomic::TNonEmptyList { value: lv },
1183        ) => {
1184            *is_list
1185                && !properties.is_empty()
1186                && properties.values().all(|p| p.ty.is_subtype_structural(lv))
1187        }
1188
1189        _ => false,
1190    }
1191}
1192
1193// ---------------------------------------------------------------------------
1194// Tests
1195// ---------------------------------------------------------------------------
1196
1197#[cfg(test)]
1198mod tests {
1199    use std::sync::Arc;
1200
1201    use super::*;
1202
1203    #[test]
1204    fn single_is_single() {
1205        let u = Type::single(Atomic::TString);
1206        assert!(u.is_single());
1207        assert!(!u.is_nullable());
1208    }
1209
1210    #[test]
1211    fn nullable_has_null() {
1212        let u = Type::nullable(Atomic::TString);
1213        assert!(u.is_nullable());
1214        assert_eq!(u.types.len(), 2);
1215    }
1216
1217    #[test]
1218    fn add_type_deduplicates() {
1219        let mut u = Type::single(Atomic::TString);
1220        u.add_type(Atomic::TString);
1221        assert_eq!(u.types.len(), 1);
1222    }
1223
1224    #[test]
1225    fn add_type_literal_subsumed_by_base() {
1226        let mut u = Type::single(Atomic::TInt);
1227        u.add_type(Atomic::TLiteralInt(42));
1228        assert_eq!(u.types.len(), 1);
1229        assert!(matches!(u.types[0], Atomic::TInt));
1230    }
1231
1232    #[test]
1233    fn add_type_base_widens_literals() {
1234        let mut u = Type::single(Atomic::TLiteralInt(1));
1235        u.add_type(Atomic::TLiteralInt(2));
1236        u.add_type(Atomic::TInt);
1237        assert_eq!(u.types.len(), 1);
1238        assert!(matches!(u.types[0], Atomic::TInt));
1239    }
1240
1241    #[test]
1242    fn mixed_subsumes_everything() {
1243        let mut u = Type::single(Atomic::TString);
1244        u.add_type(Atomic::TMixed);
1245        assert_eq!(u.types.len(), 1);
1246        assert!(u.is_mixed());
1247    }
1248
1249    #[test]
1250    fn remove_null() {
1251        let u = Type::nullable(Atomic::TString);
1252        let narrowed = u.remove_null();
1253        assert!(!narrowed.is_nullable());
1254        assert_eq!(narrowed.types.len(), 1);
1255    }
1256
1257    #[test]
1258    fn narrow_to_truthy_removes_null_false() {
1259        let mut u = Type::empty();
1260        u.add_type(Atomic::TString);
1261        u.add_type(Atomic::TNull);
1262        u.add_type(Atomic::TFalse);
1263        let truthy = u.narrow_to_truthy();
1264        assert!(!truthy.is_nullable());
1265        assert!(!truthy.contains(|t| matches!(t, Atomic::TFalse)));
1266    }
1267
1268    #[test]
1269    fn merge_combines_types() {
1270        let a = Type::single(Atomic::TString);
1271        let b = Type::single(Atomic::TInt);
1272        let merged = Type::merge(&a, &b);
1273        assert_eq!(merged.types.len(), 2);
1274    }
1275
1276    #[test]
1277    fn subtype_literal_int_under_int() {
1278        let sub = Type::single(Atomic::TLiteralInt(5));
1279        let sup = Type::single(Atomic::TInt);
1280        assert!(sub.is_subtype_structural(&sup));
1281    }
1282
1283    #[test]
1284    fn subtype_never_is_bottom() {
1285        let never = Type::never();
1286        let string = Type::single(Atomic::TString);
1287        assert!(never.is_subtype_structural(&string));
1288    }
1289
1290    #[test]
1291    fn subtype_everything_under_mixed() {
1292        let string = Type::single(Atomic::TString);
1293        let mixed = Type::mixed();
1294        assert!(string.is_subtype_structural(&mixed));
1295    }
1296
1297    #[test]
1298    fn template_substitution() {
1299        let mut bindings = FxHashMap::default();
1300        bindings.insert(Name::new("T"), Type::single(Atomic::TString));
1301
1302        let tmpl = Type::single(Atomic::TTemplateParam {
1303            name: Name::new("T"),
1304            as_type: Box::new(Type::mixed()),
1305            defining_entity: Name::new("MyClass"),
1306        });
1307
1308        let resolved = tmpl.substitute_templates(&bindings);
1309        assert_eq!(resolved.types.len(), 1);
1310        assert!(matches!(resolved.types[0], Atomic::TString));
1311    }
1312
1313    #[test]
1314    fn intersection_is_object() {
1315        let parts = vec![
1316            Type::single(Atomic::TNamedObject {
1317                fqcn: Name::new("Iterator"),
1318                type_params: empty_type_params(),
1319            }),
1320            Type::single(Atomic::TNamedObject {
1321                fqcn: Name::new("Countable"),
1322                type_params: empty_type_params(),
1323            }),
1324        ];
1325        let atomic = Atomic::TIntersection {
1326            parts: vec_to_type_params(parts),
1327        };
1328        assert!(atomic.is_object());
1329        assert!(!atomic.can_be_falsy());
1330        assert!(atomic.can_be_truthy());
1331    }
1332
1333    #[test]
1334    fn intersection_display_two_parts() {
1335        let parts = vec![
1336            Type::single(Atomic::TNamedObject {
1337                fqcn: Name::new("Iterator"),
1338                type_params: empty_type_params(),
1339            }),
1340            Type::single(Atomic::TNamedObject {
1341                fqcn: Name::new("Countable"),
1342                type_params: empty_type_params(),
1343            }),
1344        ];
1345        let u = Type::single(Atomic::TIntersection {
1346            parts: vec_to_type_params(parts),
1347        });
1348        assert_eq!(format!("{u}"), "Iterator&Countable");
1349    }
1350
1351    #[test]
1352    fn intersection_display_three_parts() {
1353        let parts = vec![
1354            Type::single(Atomic::TNamedObject {
1355                fqcn: Name::new("A"),
1356                type_params: empty_type_params(),
1357            }),
1358            Type::single(Atomic::TNamedObject {
1359                fqcn: Name::new("B"),
1360                type_params: empty_type_params(),
1361            }),
1362            Type::single(Atomic::TNamedObject {
1363                fqcn: Name::new("C"),
1364                type_params: empty_type_params(),
1365            }),
1366        ];
1367        let u = Type::single(Atomic::TIntersection {
1368            parts: vec_to_type_params(parts),
1369        });
1370        assert_eq!(format!("{u}"), "A&B&C");
1371    }
1372
1373    #[test]
1374    fn intersection_in_nullable_union_display() {
1375        let intersection = Atomic::TIntersection {
1376            parts: vec_to_type_params(vec![
1377                Type::single(Atomic::TNamedObject {
1378                    fqcn: Name::new("Iterator"),
1379                    type_params: empty_type_params(),
1380                }),
1381                Type::single(Atomic::TNamedObject {
1382                    fqcn: Name::new("Countable"),
1383                    type_params: empty_type_params(),
1384                }),
1385            ]),
1386        };
1387        let mut u = Type::single(intersection);
1388        u.add_type(Atomic::TNull);
1389        assert!(u.is_nullable());
1390        assert!(u.contains(|t| matches!(t, Atomic::TIntersection { .. })));
1391    }
1392
1393    // --- substitute_templates coverage for previously-missing arms ----------
1394
1395    fn t_param(name: &str) -> Type {
1396        Type::single(Atomic::TTemplateParam {
1397            name: Name::new(name),
1398            as_type: Box::new(Type::mixed()),
1399            defining_entity: Name::new("Fn"),
1400        })
1401    }
1402
1403    fn bindings_t_string() -> FxHashMap<Name, Type> {
1404        let mut b = FxHashMap::default();
1405        b.insert(Name::new("T"), Type::single(Atomic::TString));
1406        b
1407    }
1408
1409    #[test]
1410    fn substitute_non_empty_array_key_and_value() {
1411        let ty = Type::single(Atomic::TNonEmptyArray {
1412            key: Box::new(t_param("T")),
1413            value: Box::new(t_param("T")),
1414        });
1415        let result = ty.substitute_templates(&bindings_t_string());
1416        assert_eq!(result.types.len(), 1);
1417        let Atomic::TNonEmptyArray { key, value } = &result.types[0] else {
1418            panic!("expected TNonEmptyArray");
1419        };
1420        assert!(matches!(key.types[0], Atomic::TString));
1421        assert!(matches!(value.types[0], Atomic::TString));
1422    }
1423
1424    #[test]
1425    fn substitute_non_empty_list_value() {
1426        let ty = Type::single(Atomic::TNonEmptyList {
1427            value: Box::new(t_param("T")),
1428        });
1429        let result = ty.substitute_templates(&bindings_t_string());
1430        let Atomic::TNonEmptyList { value } = &result.types[0] else {
1431            panic!("expected TNonEmptyList");
1432        };
1433        assert!(matches!(value.types[0], Atomic::TString));
1434    }
1435
1436    #[test]
1437    fn substitute_keyed_array_property_types() {
1438        use crate::atomic::{ArrayKey, KeyedProperty};
1439        use indexmap::IndexMap;
1440        let mut props = IndexMap::new();
1441        props.insert(
1442            ArrayKey::String(Arc::from("name")),
1443            KeyedProperty {
1444                ty: t_param("T"),
1445                optional: false,
1446            },
1447        );
1448        props.insert(
1449            ArrayKey::String(Arc::from("tag")),
1450            KeyedProperty {
1451                ty: t_param("T"),
1452                optional: true,
1453            },
1454        );
1455        let ty = Type::single(Atomic::TKeyedArray {
1456            properties: props,
1457            is_open: true,
1458            is_list: false,
1459        });
1460        let result = ty.substitute_templates(&bindings_t_string());
1461        let Atomic::TKeyedArray {
1462            properties,
1463            is_open,
1464            is_list,
1465        } = &result.types[0]
1466        else {
1467            panic!("expected TKeyedArray");
1468        };
1469        assert!(is_open);
1470        assert!(!is_list);
1471        assert!(matches!(
1472            properties[&ArrayKey::String(Arc::from("name"))].ty.types[0],
1473            Atomic::TString
1474        ));
1475        assert!(properties[&ArrayKey::String(Arc::from("tag"))].optional);
1476        assert!(matches!(
1477            properties[&ArrayKey::String(Arc::from("tag"))].ty.types[0],
1478            Atomic::TString
1479        ));
1480    }
1481
1482    #[test]
1483    fn substitute_callable_params_and_return() {
1484        use crate::atomic::FnParam;
1485        let ty = Type::single(Atomic::TCallable {
1486            params: Some(vec![FnParam {
1487                name: Name::new("x"),
1488                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1489                default: None,
1490                is_variadic: false,
1491                is_byref: false,
1492                is_optional: false,
1493            }]),
1494            return_type: Some(Box::new(t_param("T"))),
1495        });
1496        let result = ty.substitute_templates(&bindings_t_string());
1497        let Atomic::TCallable {
1498            params,
1499            return_type,
1500        } = &result.types[0]
1501        else {
1502            panic!("expected TCallable");
1503        };
1504        let param_ty = params.as_ref().unwrap()[0].ty.as_ref().unwrap();
1505        let param_union = param_ty.to_union();
1506        assert!(matches!(param_union.types[0], Atomic::TString));
1507        let ret = return_type.as_ref().unwrap();
1508        assert!(matches!(ret.types[0], Atomic::TString));
1509    }
1510
1511    #[test]
1512    fn substitute_callable_bare_no_panic() {
1513        // callable with no params/return — must not panic and must pass through unchanged
1514        let ty = Type::single(Atomic::TCallable {
1515            params: None,
1516            return_type: None,
1517        });
1518        let result = ty.substitute_templates(&bindings_t_string());
1519        assert!(matches!(
1520            result.types[0],
1521            Atomic::TCallable {
1522                params: None,
1523                return_type: None
1524            }
1525        ));
1526    }
1527
1528    #[test]
1529    fn substitute_closure_params_return_and_this() {
1530        use crate::atomic::FnParam;
1531        let ty = Type::single(Atomic::TClosure {
1532            params: vec![FnParam {
1533                name: Name::new("a"),
1534                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1535                default: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1536                is_variadic: true,
1537                is_byref: true,
1538                is_optional: true,
1539            }],
1540            return_type: Box::new(t_param("T")),
1541            this_type: Some(Box::new(t_param("T"))),
1542        });
1543        let result = ty.substitute_templates(&bindings_t_string());
1544        let Atomic::TClosure {
1545            params,
1546            return_type,
1547            this_type,
1548        } = &result.types[0]
1549        else {
1550            panic!("expected TClosure");
1551        };
1552        let p = &params[0];
1553        let ty_union = p.ty.as_ref().unwrap().to_union();
1554        let default_union = p.default.as_ref().unwrap().to_union();
1555        assert!(matches!(ty_union.types[0], Atomic::TString));
1556        assert!(matches!(default_union.types[0], Atomic::TString));
1557        // flags preserved
1558        assert!(p.is_variadic);
1559        assert!(p.is_byref);
1560        assert!(p.is_optional);
1561        assert!(matches!(return_type.types[0], Atomic::TString));
1562        assert!(matches!(
1563            this_type.as_ref().unwrap().types[0],
1564            Atomic::TString
1565        ));
1566    }
1567
1568    #[test]
1569    fn substitute_conditional_all_branches() {
1570        let ty = Type::single(Atomic::TConditional {
1571            param_name: None,
1572            subject: Box::new(t_param("T")),
1573            if_true: Box::new(t_param("T")),
1574            if_false: Box::new(Type::single(Atomic::TInt)),
1575        });
1576        let result = ty.substitute_templates(&bindings_t_string());
1577        let Atomic::TConditional {
1578            param_name: _,
1579            subject,
1580            if_true,
1581            if_false,
1582        } = &result.types[0]
1583        else {
1584            panic!("expected TConditional");
1585        };
1586        assert!(matches!(subject.types[0], Atomic::TString));
1587        assert!(matches!(if_true.types[0], Atomic::TString));
1588        assert!(matches!(if_false.types[0], Atomic::TInt));
1589    }
1590
1591    #[test]
1592    fn resolve_conditional_is_null_non_null_arg() {
1593        let ty = Type::single(Atomic::TConditional {
1594            param_name: Some(Name::new("x")),
1595            subject: Box::new(Type::single(Atomic::TNull)),
1596            if_true: Box::new(Type::single(Atomic::TInt)),
1597            if_false: Box::new(Type::single(Atomic::TString)),
1598        });
1599        let result = ty.resolve_conditional_returns(|name| {
1600            if name == "x" {
1601                Some(Type::single(Atomic::TString)) // definitely not null
1602            } else {
1603                None
1604            }
1605        });
1606        assert!(result.types.len() == 1);
1607        assert!(matches!(result.types[0], Atomic::TString));
1608    }
1609
1610    #[test]
1611    fn resolve_conditional_is_null_null_arg() {
1612        let ty = Type::single(Atomic::TConditional {
1613            param_name: Some(Name::new("x")),
1614            subject: Box::new(Type::single(Atomic::TNull)),
1615            if_true: Box::new(Type::single(Atomic::TInt)),
1616            if_false: Box::new(Type::single(Atomic::TString)),
1617        });
1618        let result = ty.resolve_conditional_returns(|name| {
1619            if name == "x" {
1620                Some(Type::single(Atomic::TNull)) // definitely null
1621            } else {
1622                None
1623            }
1624        });
1625        assert!(result.types.len() == 1);
1626        assert!(matches!(result.types[0], Atomic::TInt));
1627    }
1628
1629    #[test]
1630    fn resolve_conditional_is_null_nullable_arg_widens_to_branch_union() {
1631        let mut nullable_str = Type::single(Atomic::TString);
1632        nullable_str.add_type(Atomic::TNull);
1633        let ty = Type::single(Atomic::TConditional {
1634            param_name: Some(Name::new("x")),
1635            subject: Box::new(Type::single(Atomic::TNull)),
1636            if_true: Box::new(Type::single(Atomic::TInt)),
1637            if_false: Box::new(Type::single(Atomic::TString)),
1638        });
1639        let result = ty.resolve_conditional_returns(|name| {
1640            if name == "x" {
1641                Some(nullable_str.clone())
1642            } else {
1643                None
1644            }
1645        });
1646        // uncertain discriminator → widen to if_true | if_false
1647        assert_eq!(result.types.len(), 2);
1648        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
1649        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
1650    }
1651
1652    #[test]
1653    fn resolve_conditional_nested_widens_inner_branch() {
1654        // ($x is null ? int : ($x is string ? string : float))
1655        // When $x is unknown, should widen to int|string|float (no TConditional remaining).
1656        let inner = Type::single(Atomic::TConditional {
1657            param_name: Some(Name::new("x")),
1658            subject: Box::new(Type::single(Atomic::TString)),
1659            if_true: Box::new(Type::single(Atomic::TString)),
1660            if_false: Box::new(Type::single(Atomic::TFloat)),
1661        });
1662        let ty = Type::single(Atomic::TConditional {
1663            param_name: Some(Name::new("x")),
1664            subject: Box::new(Type::single(Atomic::TNull)),
1665            if_true: Box::new(Type::single(Atomic::TInt)),
1666            if_false: Box::new(inner),
1667        });
1668        // unknown arg → widen both outer branches, inner conditional must also be widened
1669        let result = ty.resolve_conditional_returns(|_| None);
1670        assert!(
1671            result
1672                .types
1673                .iter()
1674                .all(|t| !matches!(t, Atomic::TConditional { .. })),
1675            "no TConditional should survive: {:?}",
1676            result.types
1677        );
1678        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
1679        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
1680        assert!(result.types.iter().any(|t| matches!(t, Atomic::TFloat)));
1681    }
1682
1683    #[test]
1684    fn resolve_conditional_nested_resolves_inner_branch() {
1685        // ($x is null ? int : ($x is string ? string : float))
1686        // When $x is definitely not null but unknown string-or-not → resolves outer to inner,
1687        // then inner must also be resolved.
1688        let inner = Type::single(Atomic::TConditional {
1689            param_name: Some(Name::new("x")),
1690            subject: Box::new(Type::single(Atomic::TString)),
1691            if_true: Box::new(Type::single(Atomic::TString)),
1692            if_false: Box::new(Type::single(Atomic::TFloat)),
1693        });
1694        let ty = Type::single(Atomic::TConditional {
1695            param_name: Some(Name::new("x")),
1696            subject: Box::new(Type::single(Atomic::TNull)),
1697            if_true: Box::new(Type::single(Atomic::TInt)),
1698            if_false: Box::new(inner),
1699        });
1700        // $x = string → outer: not null → if_false (inner); inner: is string → if_true = string
1701        let result = ty.resolve_conditional_returns(|name| {
1702            if name == "x" {
1703                Some(Type::single(Atomic::TString))
1704            } else {
1705                None
1706            }
1707        });
1708        assert!(
1709            result
1710                .types
1711                .iter()
1712                .all(|t| !matches!(t, Atomic::TConditional { .. })),
1713            "no TConditional should survive: {:?}",
1714            result.types
1715        );
1716        assert_eq!(result.types.len(), 1);
1717        assert!(matches!(result.types[0], Atomic::TString));
1718    }
1719
1720    #[test]
1721    fn substitute_intersection_parts() {
1722        let ty = Type::single(Atomic::TIntersection {
1723            parts: vec_to_type_params(vec![
1724                Type::single(Atomic::TNamedObject {
1725                    fqcn: Name::new("Countable"),
1726                    type_params: empty_type_params(),
1727                }),
1728                t_param("T"),
1729            ]),
1730        });
1731        let result = ty.substitute_templates(&bindings_t_string());
1732        let Atomic::TIntersection { parts } = &result.types[0] else {
1733            panic!("expected TIntersection");
1734        };
1735        assert_eq!(parts.len(), 2);
1736        assert!(matches!(parts[0].types[0], Atomic::TNamedObject { .. }));
1737        assert!(matches!(parts[1].types[0], Atomic::TString));
1738    }
1739
1740    #[test]
1741    fn substitute_no_template_params_identity() {
1742        let ty = Type::single(Atomic::TInt);
1743        let result = ty.substitute_templates(&bindings_t_string());
1744        assert!(matches!(result.types[0], Atomic::TInt));
1745    }
1746}