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