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