Skip to main content

mir_types/
union.rs

1use std::sync::Arc;
2
3use serde::{Deserialize, Serialize};
4use smallvec::SmallVec;
5
6use crate::atomic::Atomic;
7
8// Most unions contain 1-2 atomics (e.g. `string|null`), so we inline two.
9pub type AtomicVec = SmallVec<[Atomic; 2]>;
10
11// ---------------------------------------------------------------------------
12// Union — the primary type carrier
13// ---------------------------------------------------------------------------
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct Union {
17    pub types: AtomicVec,
18    /// The variable holding this type may not be initialized at this point.
19    pub possibly_undefined: bool,
20    /// This union originated from a docblock annotation rather than inference.
21    pub from_docblock: bool,
22}
23
24impl Union {
25    // --- Constructors -------------------------------------------------------
26
27    pub fn empty() -> Self {
28        Self {
29            types: SmallVec::new(),
30            possibly_undefined: false,
31            from_docblock: false,
32        }
33    }
34
35    pub fn single(atomic: Atomic) -> Self {
36        let mut types = SmallVec::new();
37        types.push(atomic);
38        Self {
39            types,
40            possibly_undefined: false,
41            from_docblock: false,
42        }
43    }
44
45    pub fn mixed() -> Self {
46        Self::single(Atomic::TMixed)
47    }
48
49    pub fn void() -> Self {
50        Self::single(Atomic::TVoid)
51    }
52
53    pub fn never() -> Self {
54        Self::single(Atomic::TNever)
55    }
56
57    pub fn null() -> Self {
58        Self::single(Atomic::TNull)
59    }
60
61    pub fn bool() -> Self {
62        Self::single(Atomic::TBool)
63    }
64
65    pub fn int() -> Self {
66        Self::single(Atomic::TInt)
67    }
68
69    pub fn float() -> Self {
70        Self::single(Atomic::TFloat)
71    }
72
73    pub fn string() -> Self {
74        Self::single(Atomic::TString)
75    }
76
77    /// `T|null`
78    pub fn nullable(atomic: Atomic) -> Self {
79        let mut types = SmallVec::new();
80        types.push(atomic);
81        types.push(Atomic::TNull);
82        Self {
83            types,
84            possibly_undefined: false,
85            from_docblock: false,
86        }
87    }
88
89    /// Build a union from multiple atomics, de-duplicating on the fly.
90    pub fn from_vec(atomics: Vec<Atomic>) -> Self {
91        let mut u = Self::empty();
92        for a in atomics {
93            u.add_type(a);
94        }
95        u
96    }
97
98    // --- Introspection -------------------------------------------------------
99
100    pub fn is_empty(&self) -> bool {
101        self.types.is_empty()
102    }
103
104    pub fn is_single(&self) -> bool {
105        self.types.len() == 1
106    }
107
108    pub fn is_nullable(&self) -> bool {
109        self.types.iter().any(|t| matches!(t, Atomic::TNull))
110    }
111
112    pub fn is_mixed(&self) -> bool {
113        self.types.iter().any(|t| matches!(t, Atomic::TMixed))
114    }
115
116    pub fn is_never(&self) -> bool {
117        self.types.iter().all(|t| matches!(t, Atomic::TNever)) && !self.types.is_empty()
118    }
119
120    pub fn is_void(&self) -> bool {
121        self.is_single() && matches!(self.types[0], Atomic::TVoid)
122    }
123
124    pub fn can_be_falsy(&self) -> bool {
125        self.types.iter().any(|t| t.can_be_falsy())
126    }
127
128    pub fn can_be_truthy(&self) -> bool {
129        self.types.iter().any(|t| t.can_be_truthy())
130    }
131
132    pub fn contains<F: Fn(&Atomic) -> bool>(&self, f: F) -> bool {
133        self.types.iter().any(f)
134    }
135
136    pub fn has_named_object(&self, fqcn: &str) -> bool {
137        self.types.iter().any(|t| match t {
138            Atomic::TNamedObject { fqcn: f, .. } => f.as_ref() == fqcn,
139            _ => false,
140        })
141    }
142
143    // --- Mutation ------------------------------------------------------------
144
145    /// Add an atomic to this union, skipping duplicates.
146    /// Subsumption rules: anything ⊆ TMixed; TLiteralInt ⊆ TInt; etc.
147    pub fn add_type(&mut self, atomic: Atomic) {
148        // If we already have TMixed, nothing to add.
149        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
150            return;
151        }
152
153        // Adding TMixed subsumes everything.
154        if matches!(atomic, Atomic::TMixed) {
155            self.types.clear();
156            self.types.push(Atomic::TMixed);
157            return;
158        }
159
160        // Avoid exact duplicates.
161        if self.types.contains(&atomic) {
162            return;
163        }
164
165        // TLiteralInt(n) is subsumed by TInt.
166        if let Atomic::TLiteralInt(_) = &atomic {
167            if self.types.iter().any(|t| matches!(t, Atomic::TInt)) {
168                return;
169            }
170        }
171        // TLiteralString(s) is subsumed by TString.
172        if let Atomic::TLiteralString(_) = &atomic {
173            if self.types.iter().any(|t| matches!(t, Atomic::TString)) {
174                return;
175            }
176        }
177        // TTrue / TFalse are subsumed by TBool.
178        if matches!(atomic, Atomic::TTrue | Atomic::TFalse)
179            && self.types.iter().any(|t| matches!(t, Atomic::TBool))
180        {
181            return;
182        }
183        // Adding TInt widens away all TLiteralInt variants.
184        if matches!(atomic, Atomic::TInt) {
185            self.types.retain(|t| !matches!(t, Atomic::TLiteralInt(_)));
186        }
187        // Adding TString widens away all TLiteralString variants.
188        if matches!(atomic, Atomic::TString) {
189            self.types
190                .retain(|t| !matches!(t, Atomic::TLiteralString(_)));
191        }
192        // Adding TBool widens away TTrue/TFalse.
193        if matches!(atomic, Atomic::TBool) {
194            self.types
195                .retain(|t| !matches!(t, Atomic::TTrue | Atomic::TFalse));
196        }
197
198        self.types.push(atomic);
199    }
200
201    // --- Narrowing -----------------------------------------------------------
202
203    /// Remove `null` from the union (e.g. after a null check).
204    pub fn remove_null(&self) -> Union {
205        self.filter(|t| !matches!(t, Atomic::TNull))
206    }
207
208    /// Remove `false` from the union.
209    pub fn remove_false(&self) -> Union {
210        self.filter(|t| !matches!(t, Atomic::TFalse | Atomic::TBool))
211    }
212
213    /// Remove both `null` and `false` from the union (core type without nullable/falsy variants).
214    pub fn core_type(&self) -> Union {
215        self.remove_null().remove_false()
216    }
217
218    /// Keep only truthy atomics (e.g. after `if ($x)`).
219    pub fn narrow_to_truthy(&self) -> Union {
220        if self.is_mixed() {
221            return Union::mixed();
222        }
223        let narrowed = self.filter(|t| t.can_be_truthy());
224        // Remove specific falsy literals from string/int
225        narrowed.filter(|t| match t {
226            Atomic::TLiteralInt(0) => false,
227            Atomic::TLiteralString(s) if s.as_ref() == "" || s.as_ref() == "0" => false,
228            Atomic::TLiteralFloat(0, 0) => false,
229            _ => true,
230        })
231    }
232
233    /// Keep only falsy atomics (e.g. after `if (!$x)`).
234    pub fn narrow_to_falsy(&self) -> Union {
235        if self.is_mixed() {
236            return Union::from_vec(vec![
237                Atomic::TNull,
238                Atomic::TFalse,
239                Atomic::TLiteralInt(0),
240                Atomic::TLiteralString("".into()),
241            ]);
242        }
243        self.filter(|t| t.can_be_falsy())
244    }
245
246    /// Narrow this type as if `$x instanceof ClassName` is true.
247    ///
248    /// The instanceof check guarantees the value IS an instance of `class`, so we
249    /// replace any object / mixed constituents with the specific named object.  Scalar
250    /// constituents are dropped (they can never satisfy instanceof).
251    pub fn narrow_instanceof(&self, class: &str) -> Union {
252        let narrowed_ty = Atomic::TNamedObject {
253            fqcn: class.into(),
254            type_params: vec![],
255        };
256        // If any constituent is an object-like type, the result is the specific class.
257        let has_object = self.types.iter().any(|t| {
258            matches!(
259                t,
260                Atomic::TObject | Atomic::TNamedObject { .. } | Atomic::TMixed | Atomic::TNull // null fails instanceof, but mixed/object may include null
261            )
262        });
263        if has_object || self.is_empty() {
264            Union::single(narrowed_ty)
265        } else {
266            // Pure scalars — instanceof is always false here, but return the class
267            // defensively so callers don't see an empty union.
268            Union::single(narrowed_ty)
269        }
270    }
271
272    /// Narrow as if `is_string($x)` is true.
273    pub fn narrow_to_string(&self) -> Union {
274        self.filter(|t| t.is_string() || matches!(t, Atomic::TMixed | Atomic::TScalar))
275    }
276
277    /// Narrow as if `is_int($x)` is true.
278    pub fn narrow_to_int(&self) -> Union {
279        self.filter(|t| {
280            t.is_int() || matches!(t, Atomic::TMixed | Atomic::TScalar | Atomic::TNumeric)
281        })
282    }
283
284    /// Narrow as if `is_float($x)` is true.
285    pub fn narrow_to_float(&self) -> Union {
286        self.filter(|t| {
287            matches!(
288                t,
289                Atomic::TFloat
290                    | Atomic::TLiteralFloat(..)
291                    | Atomic::TMixed
292                    | Atomic::TScalar
293                    | Atomic::TNumeric
294            )
295        })
296    }
297
298    /// Narrow as if `is_bool($x)` is true.
299    pub fn narrow_to_bool(&self) -> Union {
300        self.filter(|t| {
301            matches!(
302                t,
303                Atomic::TBool | Atomic::TTrue | Atomic::TFalse | Atomic::TMixed | Atomic::TScalar
304            )
305        })
306    }
307
308    /// Narrow as if `is_null($x)` is true.
309    pub fn narrow_to_null(&self) -> Union {
310        self.filter(|t| matches!(t, Atomic::TNull | Atomic::TMixed))
311    }
312
313    /// Narrow as if `is_array($x)` is true.
314    pub fn narrow_to_array(&self) -> Union {
315        self.filter(|t| t.is_array() || matches!(t, Atomic::TMixed))
316    }
317
318    /// Narrow as if `is_object($x)` is true.
319    pub fn narrow_to_object(&self) -> Union {
320        self.filter(|t| t.is_object() || matches!(t, Atomic::TMixed))
321    }
322
323    /// Narrow as if `is_callable($x)` is true.
324    pub fn narrow_to_callable(&self) -> Union {
325        self.filter(|t| t.is_callable() || matches!(t, Atomic::TMixed))
326    }
327
328    // --- Merge (branch join) ------------------------------------------------
329
330    /// Merge two unions at a branch join point (e.g. after if/else).
331    /// The result is the union of all types in both.
332    pub fn merge(a: &Union, b: &Union) -> Union {
333        let mut result = a.clone();
334        for atomic in &b.types {
335            result.add_type(atomic.clone());
336        }
337        result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
338        result
339    }
340
341    /// Intersect with another union: keep only types present in `other`, widening
342    /// where `self` contains `mixed` (which is compatible with everything).
343    /// Used for match-arm subject narrowing.
344    pub fn intersect_with(&self, other: &Union) -> Union {
345        if self.is_mixed() {
346            return other.clone();
347        }
348        if other.is_mixed() {
349            return self.clone();
350        }
351        // Keep atomics from self that are also in other (by equality or subtype)
352        let mut result = Union::empty();
353        for a in &self.types {
354            for b in &other.types {
355                if a == b || atomic_subtype(a, b) || atomic_subtype(b, a) {
356                    result.add_type(a.clone());
357                    break;
358                }
359            }
360        }
361        // If nothing matched, fall back to other (conservative)
362        if result.is_empty() {
363            other.clone()
364        } else {
365            result
366        }
367    }
368
369    // --- Template substitution ----------------------------------------------
370
371    /// Replace template param references with their resolved types.
372    pub fn substitute_templates(
373        &self,
374        bindings: &std::collections::HashMap<Arc<str>, Union>,
375    ) -> Union {
376        if bindings.is_empty() {
377            return self.clone();
378        }
379        let mut result = Union::empty();
380        result.possibly_undefined = self.possibly_undefined;
381        result.from_docblock = self.from_docblock;
382        for atomic in &self.types {
383            match atomic {
384                Atomic::TTemplateParam { name, .. } => {
385                    if let Some(resolved) = bindings.get(name) {
386                        for t in &resolved.types {
387                            result.add_type(t.clone());
388                        }
389                    } else {
390                        result.add_type(atomic.clone());
391                    }
392                }
393                Atomic::TArray { key, value } => {
394                    result.add_type(Atomic::TArray {
395                        key: Box::new(key.substitute_templates(bindings)),
396                        value: Box::new(value.substitute_templates(bindings)),
397                    });
398                }
399                Atomic::TList { value } => {
400                    result.add_type(Atomic::TList {
401                        value: Box::new(value.substitute_templates(bindings)),
402                    });
403                }
404                Atomic::TNonEmptyArray { key, value } => {
405                    result.add_type(Atomic::TNonEmptyArray {
406                        key: Box::new(key.substitute_templates(bindings)),
407                        value: Box::new(value.substitute_templates(bindings)),
408                    });
409                }
410                Atomic::TNonEmptyList { value } => {
411                    result.add_type(Atomic::TNonEmptyList {
412                        value: Box::new(value.substitute_templates(bindings)),
413                    });
414                }
415                Atomic::TKeyedArray {
416                    properties,
417                    is_open,
418                    is_list,
419                } => {
420                    use crate::atomic::KeyedProperty;
421                    let new_props = properties
422                        .iter()
423                        .map(|(k, prop)| {
424                            (
425                                k.clone(),
426                                KeyedProperty {
427                                    ty: prop.ty.substitute_templates(bindings),
428                                    optional: prop.optional,
429                                },
430                            )
431                        })
432                        .collect();
433                    result.add_type(Atomic::TKeyedArray {
434                        properties: new_props,
435                        is_open: *is_open,
436                        is_list: *is_list,
437                    });
438                }
439                Atomic::TCallable {
440                    params,
441                    return_type,
442                } => {
443                    result.add_type(Atomic::TCallable {
444                        params: params.as_ref().map(|ps| {
445                            ps.iter()
446                                .map(|p| substitute_in_fn_param(p, bindings))
447                                .collect()
448                        }),
449                        return_type: return_type
450                            .as_ref()
451                            .map(|r| Box::new(r.substitute_templates(bindings))),
452                    });
453                }
454                Atomic::TClosure {
455                    params,
456                    return_type,
457                    this_type,
458                } => {
459                    result.add_type(Atomic::TClosure {
460                        params: params
461                            .iter()
462                            .map(|p| substitute_in_fn_param(p, bindings))
463                            .collect(),
464                        return_type: Box::new(return_type.substitute_templates(bindings)),
465                        this_type: this_type
466                            .as_ref()
467                            .map(|t| Box::new(t.substitute_templates(bindings))),
468                    });
469                }
470                Atomic::TConditional {
471                    subject,
472                    if_true,
473                    if_false,
474                } => {
475                    result.add_type(Atomic::TConditional {
476                        subject: Box::new(subject.substitute_templates(bindings)),
477                        if_true: Box::new(if_true.substitute_templates(bindings)),
478                        if_false: Box::new(if_false.substitute_templates(bindings)),
479                    });
480                }
481                Atomic::TIntersection { parts } => {
482                    result.add_type(Atomic::TIntersection {
483                        parts: parts
484                            .iter()
485                            .map(|p| p.substitute_templates(bindings))
486                            .collect(),
487                    });
488                }
489                Atomic::TNamedObject { fqcn, type_params } => {
490                    // TODO: the docblock parser emits TNamedObject { fqcn: "T" } for bare @return T
491                    // annotations instead of TTemplateParam, because it lacks template context at
492                    // parse time. This block works around that by treating bare unqualified names
493                    // as template param references when they appear in the binding map. Proper fix:
494                    // make the docblock parser template-aware so it emits TTemplateParam directly.
495                    // See issue #26 for context.
496                    if type_params.is_empty() && !fqcn.contains('\\') {
497                        if let Some(resolved) = bindings.get(fqcn.as_ref()) {
498                            for t in &resolved.types {
499                                result.add_type(t.clone());
500                            }
501                            continue;
502                        }
503                    }
504                    let new_params = type_params
505                        .iter()
506                        .map(|p| p.substitute_templates(bindings))
507                        .collect();
508                    result.add_type(Atomic::TNamedObject {
509                        fqcn: fqcn.clone(),
510                        type_params: new_params,
511                    });
512                }
513                _ => {
514                    result.add_type(atomic.clone());
515                }
516            }
517        }
518        result
519    }
520
521    // --- Subtype check -------------------------------------------------------
522
523    /// Returns true if every atomic in `self` is a subtype of some atomic in `other`.
524    /// Does not require a Codebase (no inheritance check); use the codebase-aware
525    /// version in mir-analyzer for full checks.
526    pub fn is_subtype_of_simple(&self, other: &Union) -> bool {
527        if other.is_mixed() {
528            return true;
529        }
530        if self.is_never() {
531            return true; // never <: everything
532        }
533        self.types
534            .iter()
535            .all(|a| other.types.iter().any(|b| atomic_subtype(a, b)))
536    }
537
538    // --- Utilities ----------------------------------------------------------
539
540    fn filter<F: Fn(&Atomic) -> bool>(&self, f: F) -> Union {
541        let mut result = Union::empty();
542        result.possibly_undefined = self.possibly_undefined;
543        result.from_docblock = self.from_docblock;
544        for atomic in &self.types {
545            if f(atomic) {
546                result.types.push(atomic.clone());
547            }
548        }
549        result
550    }
551
552    /// Mark this union as possibly-undefined and return it.
553    pub fn possibly_undefined(mut self) -> Self {
554        self.possibly_undefined = true;
555        self
556    }
557
558    /// Mark this union as coming from a docblock annotation.
559    pub fn from_docblock(mut self) -> Self {
560        self.from_docblock = true;
561        self
562    }
563}
564
565// ---------------------------------------------------------------------------
566// Template substitution helpers
567// ---------------------------------------------------------------------------
568
569fn substitute_in_fn_param(
570    p: &crate::atomic::FnParam,
571    bindings: &std::collections::HashMap<Arc<str>, Union>,
572) -> crate::atomic::FnParam {
573    crate::atomic::FnParam {
574        name: p.name.clone(),
575        ty: p.ty.as_ref().map(|t| {
576            let u = t.to_union();
577            let substituted = u.substitute_templates(bindings);
578            crate::compact::SimpleType::from_union(substituted)
579        }),
580        default: p.default.as_ref().map(|d| {
581            let u = d.to_union();
582            let substituted = u.substitute_templates(bindings);
583            crate::compact::SimpleType::from_union(substituted)
584        }),
585        is_variadic: p.is_variadic,
586        is_byref: p.is_byref,
587        is_optional: p.is_optional,
588    }
589}
590
591// ---------------------------------------------------------------------------
592// Atomic subtype (no codebase — structural check only)
593// ---------------------------------------------------------------------------
594
595fn atomic_subtype(sub: &Atomic, sup: &Atomic) -> bool {
596    if sub == sup {
597        return true;
598    }
599    match (sub, sup) {
600        // Bottom type
601        (Atomic::TNever, _) => true,
602        // Top types — anything goes in both directions for mixed
603        (_, Atomic::TMixed) => true,
604        (Atomic::TMixed, _) => true,
605
606        // Scalars
607        (Atomic::TLiteralInt(_), Atomic::TInt) => true,
608        (Atomic::TLiteralInt(_), Atomic::TNumeric) => true,
609        (Atomic::TLiteralInt(_), Atomic::TScalar) => true,
610        (Atomic::TLiteralInt(n), Atomic::TPositiveInt) => *n > 0,
611        (Atomic::TLiteralInt(n), Atomic::TNonNegativeInt) => *n >= 0,
612        (Atomic::TLiteralInt(n), Atomic::TNegativeInt) => *n < 0,
613        (Atomic::TPositiveInt, Atomic::TInt) => true,
614        (Atomic::TPositiveInt, Atomic::TNonNegativeInt) => true,
615        (Atomic::TNegativeInt, Atomic::TInt) => true,
616        (Atomic::TNonNegativeInt, Atomic::TInt) => true,
617        (Atomic::TIntRange { .. }, Atomic::TInt) => true,
618
619        (Atomic::TLiteralFloat(..), Atomic::TFloat) => true,
620        (Atomic::TLiteralFloat(..), Atomic::TNumeric) => true,
621        (Atomic::TLiteralFloat(..), Atomic::TScalar) => true,
622
623        (Atomic::TLiteralString(s), Atomic::TString) => {
624            let _ = s;
625            true
626        }
627        (Atomic::TLiteralString(s), Atomic::TNonEmptyString) => !s.is_empty(),
628        (Atomic::TLiteralString(_), Atomic::TScalar) => true,
629        (Atomic::TNonEmptyString, Atomic::TString) => true,
630        (Atomic::TNumericString, Atomic::TString) => true,
631        (Atomic::TClassString(_), Atomic::TString) => true,
632        (Atomic::TInterfaceString, Atomic::TString) => true,
633        (Atomic::TEnumString, Atomic::TString) => true,
634        (Atomic::TTraitString, Atomic::TString) => true,
635
636        (Atomic::TTrue, Atomic::TBool) => true,
637        (Atomic::TFalse, Atomic::TBool) => true,
638
639        (Atomic::TInt, Atomic::TNumeric) => true,
640        (Atomic::TFloat, Atomic::TNumeric) => true,
641        (Atomic::TNumericString, Atomic::TNumeric) => true,
642
643        (Atomic::TInt, Atomic::TScalar) => true,
644        (Atomic::TFloat, Atomic::TScalar) => true,
645        (Atomic::TString, Atomic::TScalar) => true,
646        (Atomic::TBool, Atomic::TScalar) => true,
647        (Atomic::TNumeric, Atomic::TScalar) => true,
648        (Atomic::TTrue, Atomic::TScalar) => true,
649        (Atomic::TFalse, Atomic::TScalar) => true,
650
651        // Object hierarchy (structural, no codebase)
652        (Atomic::TNamedObject { .. }, Atomic::TObject) => true,
653        (Atomic::TStaticObject { .. }, Atomic::TObject) => true,
654        (Atomic::TSelf { .. }, Atomic::TObject) => true,
655        // self(X) and static(X) satisfy TNamedObject(X) with same FQCN
656        (Atomic::TSelf { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
657        (Atomic::TStaticObject { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
658        // TNamedObject(X) satisfies self(X) / static(X) with same FQCN
659        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TSelf { fqcn: b }) => a == b,
660        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TStaticObject { fqcn: b }) => a == b,
661
662        // Literal int widens to float in PHP
663        (Atomic::TLiteralInt(_), Atomic::TFloat) => true,
664        (Atomic::TPositiveInt, Atomic::TFloat) => true,
665        (Atomic::TInt, Atomic::TFloat) => true,
666
667        // Literal int satisfies int ranges
668        (Atomic::TLiteralInt(_), Atomic::TIntRange { .. }) => true,
669
670        // PHP callables: string and array are valid callable values
671        (Atomic::TString, Atomic::TCallable { .. }) => true,
672        (Atomic::TNonEmptyString, Atomic::TCallable { .. }) => true,
673        (Atomic::TLiteralString(_), Atomic::TCallable { .. }) => true,
674        (Atomic::TArray { .. }, Atomic::TCallable { .. }) => true,
675        (Atomic::TNonEmptyArray { .. }, Atomic::TCallable { .. }) => true,
676
677        // Closure <: callable, typed Closure <: Closure
678        (Atomic::TClosure { .. }, Atomic::TCallable { .. }) => true,
679        // callable <: Closure: callable is wider but not flagged at default error level
680        (Atomic::TCallable { .. }, Atomic::TClosure { .. }) => true,
681        // Any TClosure satisfies another TClosure (structural compatibility simplified)
682        (Atomic::TClosure { .. }, Atomic::TClosure { .. }) => true,
683        // callable <: callable (trivial)
684        (Atomic::TCallable { .. }, Atomic::TCallable { .. }) => true,
685        // TClosure satisfies `Closure` named object or `object`
686        (Atomic::TClosure { .. }, Atomic::TNamedObject { fqcn, .. }) => {
687            fqcn.as_ref().eq_ignore_ascii_case("closure")
688        }
689        (Atomic::TClosure { .. }, Atomic::TObject) => true,
690
691        // List <: array
692        (Atomic::TList { value }, Atomic::TArray { key, value: av }) => {
693            // list key is always int
694            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
695                && value.is_subtype_of_simple(av)
696        }
697        (Atomic::TNonEmptyList { value }, Atomic::TList { value: lv }) => {
698            value.is_subtype_of_simple(lv)
699        }
700        // array<int, X> is accepted where list<X> or non-empty-list<X> expected
701        (Atomic::TArray { key, value: av }, Atomic::TList { value: lv }) => {
702            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
703                && av.is_subtype_of_simple(lv)
704        }
705        (Atomic::TArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
706            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
707                && av.is_subtype_of_simple(lv)
708        }
709        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TList { value: lv }) => {
710            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
711                && av.is_subtype_of_simple(lv)
712        }
713        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
714            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
715                && av.is_subtype_of_simple(lv)
716        }
717        // TList <: TList value covariance
718        (Atomic::TList { value: v1 }, Atomic::TList { value: v2 }) => v1.is_subtype_of_simple(v2),
719        (Atomic::TNonEmptyArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
720            k1.is_subtype_of_simple(k2) && v1.is_subtype_of_simple(v2)
721        }
722
723        // array<A, B> <: array<C, D>  iff  A <: C && B <: D
724        (Atomic::TArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
725            k1.is_subtype_of_simple(k2) && v1.is_subtype_of_simple(v2)
726        }
727
728        // A keyed/shape array (array{...} or array{}) is a subtype of any generic array.
729        (Atomic::TKeyedArray { .. }, Atomic::TArray { .. }) => true,
730
731        // A list-shaped keyed array (is_list=true, all int keys) is a subtype of list<X>.
732        (
733            Atomic::TKeyedArray {
734                properties,
735                is_list,
736                ..
737            },
738            Atomic::TList { value: lv },
739        ) => *is_list && properties.values().all(|p| p.ty.is_subtype_of_simple(lv)),
740        (
741            Atomic::TKeyedArray {
742                properties,
743                is_list,
744                ..
745            },
746            Atomic::TNonEmptyList { value: lv },
747        ) => {
748            *is_list
749                && !properties.is_empty()
750                && properties.values().all(|p| p.ty.is_subtype_of_simple(lv))
751        }
752
753        // A template parameter T acts as a wildcard — any type satisfies it.
754        (_, Atomic::TTemplateParam { .. }) => true,
755
756        _ => false,
757    }
758}
759
760// ---------------------------------------------------------------------------
761// Tests
762// ---------------------------------------------------------------------------
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767
768    #[test]
769    fn single_is_single() {
770        let u = Union::single(Atomic::TString);
771        assert!(u.is_single());
772        assert!(!u.is_nullable());
773    }
774
775    #[test]
776    fn nullable_has_null() {
777        let u = Union::nullable(Atomic::TString);
778        assert!(u.is_nullable());
779        assert_eq!(u.types.len(), 2);
780    }
781
782    #[test]
783    fn add_type_deduplicates() {
784        let mut u = Union::single(Atomic::TString);
785        u.add_type(Atomic::TString);
786        assert_eq!(u.types.len(), 1);
787    }
788
789    #[test]
790    fn add_type_literal_subsumed_by_base() {
791        let mut u = Union::single(Atomic::TInt);
792        u.add_type(Atomic::TLiteralInt(42));
793        assert_eq!(u.types.len(), 1);
794        assert!(matches!(u.types[0], Atomic::TInt));
795    }
796
797    #[test]
798    fn add_type_base_widens_literals() {
799        let mut u = Union::single(Atomic::TLiteralInt(1));
800        u.add_type(Atomic::TLiteralInt(2));
801        u.add_type(Atomic::TInt);
802        assert_eq!(u.types.len(), 1);
803        assert!(matches!(u.types[0], Atomic::TInt));
804    }
805
806    #[test]
807    fn mixed_subsumes_everything() {
808        let mut u = Union::single(Atomic::TString);
809        u.add_type(Atomic::TMixed);
810        assert_eq!(u.types.len(), 1);
811        assert!(u.is_mixed());
812    }
813
814    #[test]
815    fn remove_null() {
816        let u = Union::nullable(Atomic::TString);
817        let narrowed = u.remove_null();
818        assert!(!narrowed.is_nullable());
819        assert_eq!(narrowed.types.len(), 1);
820    }
821
822    #[test]
823    fn narrow_to_truthy_removes_null_false() {
824        let mut u = Union::empty();
825        u.add_type(Atomic::TString);
826        u.add_type(Atomic::TNull);
827        u.add_type(Atomic::TFalse);
828        let truthy = u.narrow_to_truthy();
829        assert!(!truthy.is_nullable());
830        assert!(!truthy.contains(|t| matches!(t, Atomic::TFalse)));
831    }
832
833    #[test]
834    fn merge_combines_types() {
835        let a = Union::single(Atomic::TString);
836        let b = Union::single(Atomic::TInt);
837        let merged = Union::merge(&a, &b);
838        assert_eq!(merged.types.len(), 2);
839    }
840
841    #[test]
842    fn subtype_literal_int_under_int() {
843        let sub = Union::single(Atomic::TLiteralInt(5));
844        let sup = Union::single(Atomic::TInt);
845        assert!(sub.is_subtype_of_simple(&sup));
846    }
847
848    #[test]
849    fn subtype_never_is_bottom() {
850        let never = Union::never();
851        let string = Union::single(Atomic::TString);
852        assert!(never.is_subtype_of_simple(&string));
853    }
854
855    #[test]
856    fn subtype_everything_under_mixed() {
857        let string = Union::single(Atomic::TString);
858        let mixed = Union::mixed();
859        assert!(string.is_subtype_of_simple(&mixed));
860    }
861
862    #[test]
863    fn template_substitution() {
864        let mut bindings = std::collections::HashMap::new();
865        bindings.insert(Arc::from("T"), Union::single(Atomic::TString));
866
867        let tmpl = Union::single(Atomic::TTemplateParam {
868            name: Arc::from("T"),
869            as_type: Box::new(Union::mixed()),
870            defining_entity: Arc::from("MyClass"),
871        });
872
873        let resolved = tmpl.substitute_templates(&bindings);
874        assert_eq!(resolved.types.len(), 1);
875        assert!(matches!(resolved.types[0], Atomic::TString));
876    }
877
878    #[test]
879    fn intersection_is_object() {
880        let parts = vec![
881            Union::single(Atomic::TNamedObject {
882                fqcn: Arc::from("Iterator"),
883                type_params: vec![],
884            }),
885            Union::single(Atomic::TNamedObject {
886                fqcn: Arc::from("Countable"),
887                type_params: vec![],
888            }),
889        ];
890        let atomic = Atomic::TIntersection { parts };
891        assert!(atomic.is_object());
892        assert!(!atomic.can_be_falsy());
893        assert!(atomic.can_be_truthy());
894    }
895
896    #[test]
897    fn intersection_display_two_parts() {
898        let parts = vec![
899            Union::single(Atomic::TNamedObject {
900                fqcn: Arc::from("Iterator"),
901                type_params: vec![],
902            }),
903            Union::single(Atomic::TNamedObject {
904                fqcn: Arc::from("Countable"),
905                type_params: vec![],
906            }),
907        ];
908        let u = Union::single(Atomic::TIntersection { parts });
909        assert_eq!(format!("{u}"), "Iterator&Countable");
910    }
911
912    #[test]
913    fn intersection_display_three_parts() {
914        let parts = vec![
915            Union::single(Atomic::TNamedObject {
916                fqcn: Arc::from("A"),
917                type_params: vec![],
918            }),
919            Union::single(Atomic::TNamedObject {
920                fqcn: Arc::from("B"),
921                type_params: vec![],
922            }),
923            Union::single(Atomic::TNamedObject {
924                fqcn: Arc::from("C"),
925                type_params: vec![],
926            }),
927        ];
928        let u = Union::single(Atomic::TIntersection { parts });
929        assert_eq!(format!("{u}"), "A&B&C");
930    }
931
932    #[test]
933    fn intersection_in_nullable_union_display() {
934        let intersection = Atomic::TIntersection {
935            parts: vec![
936                Union::single(Atomic::TNamedObject {
937                    fqcn: Arc::from("Iterator"),
938                    type_params: vec![],
939                }),
940                Union::single(Atomic::TNamedObject {
941                    fqcn: Arc::from("Countable"),
942                    type_params: vec![],
943                }),
944            ],
945        };
946        let mut u = Union::single(intersection);
947        u.add_type(Atomic::TNull);
948        assert!(u.is_nullable());
949        assert!(u.contains(|t| matches!(t, Atomic::TIntersection { .. })));
950    }
951
952    // --- substitute_templates coverage for previously-missing arms ----------
953
954    fn t_param(name: &str) -> Union {
955        Union::single(Atomic::TTemplateParam {
956            name: Arc::from(name),
957            as_type: Box::new(Union::mixed()),
958            defining_entity: Arc::from("Fn"),
959        })
960    }
961
962    fn bindings_t_string() -> std::collections::HashMap<Arc<str>, Union> {
963        let mut b = std::collections::HashMap::new();
964        b.insert(Arc::from("T"), Union::single(Atomic::TString));
965        b
966    }
967
968    #[test]
969    fn substitute_non_empty_array_key_and_value() {
970        let ty = Union::single(Atomic::TNonEmptyArray {
971            key: Box::new(t_param("T")),
972            value: Box::new(t_param("T")),
973        });
974        let result = ty.substitute_templates(&bindings_t_string());
975        assert_eq!(result.types.len(), 1);
976        let Atomic::TNonEmptyArray { key, value } = &result.types[0] else {
977            panic!("expected TNonEmptyArray");
978        };
979        assert!(matches!(key.types[0], Atomic::TString));
980        assert!(matches!(value.types[0], Atomic::TString));
981    }
982
983    #[test]
984    fn substitute_non_empty_list_value() {
985        let ty = Union::single(Atomic::TNonEmptyList {
986            value: Box::new(t_param("T")),
987        });
988        let result = ty.substitute_templates(&bindings_t_string());
989        let Atomic::TNonEmptyList { value } = &result.types[0] else {
990            panic!("expected TNonEmptyList");
991        };
992        assert!(matches!(value.types[0], Atomic::TString));
993    }
994
995    #[test]
996    fn substitute_keyed_array_property_types() {
997        use crate::atomic::{ArrayKey, KeyedProperty};
998        use indexmap::IndexMap;
999        let mut props = IndexMap::new();
1000        props.insert(
1001            ArrayKey::String(Arc::from("name")),
1002            KeyedProperty {
1003                ty: t_param("T"),
1004                optional: false,
1005            },
1006        );
1007        props.insert(
1008            ArrayKey::String(Arc::from("tag")),
1009            KeyedProperty {
1010                ty: t_param("T"),
1011                optional: true,
1012            },
1013        );
1014        let ty = Union::single(Atomic::TKeyedArray {
1015            properties: props,
1016            is_open: true,
1017            is_list: false,
1018        });
1019        let result = ty.substitute_templates(&bindings_t_string());
1020        let Atomic::TKeyedArray {
1021            properties,
1022            is_open,
1023            is_list,
1024        } = &result.types[0]
1025        else {
1026            panic!("expected TKeyedArray");
1027        };
1028        assert!(is_open);
1029        assert!(!is_list);
1030        assert!(matches!(
1031            properties[&ArrayKey::String(Arc::from("name"))].ty.types[0],
1032            Atomic::TString
1033        ));
1034        assert!(properties[&ArrayKey::String(Arc::from("tag"))].optional);
1035        assert!(matches!(
1036            properties[&ArrayKey::String(Arc::from("tag"))].ty.types[0],
1037            Atomic::TString
1038        ));
1039    }
1040
1041    #[test]
1042    fn substitute_callable_params_and_return() {
1043        use crate::atomic::FnParam;
1044        let ty = Union::single(Atomic::TCallable {
1045            params: Some(vec![FnParam {
1046                name: Arc::from("x"),
1047                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1048                default: None,
1049                is_variadic: false,
1050                is_byref: false,
1051                is_optional: false,
1052            }]),
1053            return_type: Some(Box::new(t_param("T"))),
1054        });
1055        let result = ty.substitute_templates(&bindings_t_string());
1056        let Atomic::TCallable {
1057            params,
1058            return_type,
1059        } = &result.types[0]
1060        else {
1061            panic!("expected TCallable");
1062        };
1063        let param_ty = params.as_ref().unwrap()[0].ty.as_ref().unwrap();
1064        let param_union = param_ty.to_union();
1065        assert!(matches!(param_union.types[0], Atomic::TString));
1066        let ret = return_type.as_ref().unwrap();
1067        assert!(matches!(ret.types[0], Atomic::TString));
1068    }
1069
1070    #[test]
1071    fn substitute_callable_bare_no_panic() {
1072        // callable with no params/return — must not panic and must pass through unchanged
1073        let ty = Union::single(Atomic::TCallable {
1074            params: None,
1075            return_type: None,
1076        });
1077        let result = ty.substitute_templates(&bindings_t_string());
1078        assert!(matches!(
1079            result.types[0],
1080            Atomic::TCallable {
1081                params: None,
1082                return_type: None
1083            }
1084        ));
1085    }
1086
1087    #[test]
1088    fn substitute_closure_params_return_and_this() {
1089        use crate::atomic::FnParam;
1090        let ty = Union::single(Atomic::TClosure {
1091            params: vec![FnParam {
1092                name: Arc::from("a"),
1093                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1094                default: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1095                is_variadic: true,
1096                is_byref: true,
1097                is_optional: true,
1098            }],
1099            return_type: Box::new(t_param("T")),
1100            this_type: Some(Box::new(t_param("T"))),
1101        });
1102        let result = ty.substitute_templates(&bindings_t_string());
1103        let Atomic::TClosure {
1104            params,
1105            return_type,
1106            this_type,
1107        } = &result.types[0]
1108        else {
1109            panic!("expected TClosure");
1110        };
1111        let p = &params[0];
1112        let ty_union = p.ty.as_ref().unwrap().to_union();
1113        let default_union = p.default.as_ref().unwrap().to_union();
1114        assert!(matches!(ty_union.types[0], Atomic::TString));
1115        assert!(matches!(default_union.types[0], Atomic::TString));
1116        // flags preserved
1117        assert!(p.is_variadic);
1118        assert!(p.is_byref);
1119        assert!(p.is_optional);
1120        assert!(matches!(return_type.types[0], Atomic::TString));
1121        assert!(matches!(
1122            this_type.as_ref().unwrap().types[0],
1123            Atomic::TString
1124        ));
1125    }
1126
1127    #[test]
1128    fn substitute_conditional_all_branches() {
1129        let ty = Union::single(Atomic::TConditional {
1130            subject: Box::new(t_param("T")),
1131            if_true: Box::new(t_param("T")),
1132            if_false: Box::new(Union::single(Atomic::TInt)),
1133        });
1134        let result = ty.substitute_templates(&bindings_t_string());
1135        let Atomic::TConditional {
1136            subject,
1137            if_true,
1138            if_false,
1139        } = &result.types[0]
1140        else {
1141            panic!("expected TConditional");
1142        };
1143        assert!(matches!(subject.types[0], Atomic::TString));
1144        assert!(matches!(if_true.types[0], Atomic::TString));
1145        assert!(matches!(if_false.types[0], Atomic::TInt));
1146    }
1147
1148    #[test]
1149    fn substitute_intersection_parts() {
1150        let ty = Union::single(Atomic::TIntersection {
1151            parts: vec![
1152                Union::single(Atomic::TNamedObject {
1153                    fqcn: Arc::from("Countable"),
1154                    type_params: vec![],
1155                }),
1156                t_param("T"),
1157            ],
1158        });
1159        let result = ty.substitute_templates(&bindings_t_string());
1160        let Atomic::TIntersection { parts } = &result.types[0] else {
1161            panic!("expected TIntersection");
1162        };
1163        assert_eq!(parts.len(), 2);
1164        assert!(matches!(parts[0].types[0], Atomic::TNamedObject { .. }));
1165        assert!(matches!(parts[1].types[0], Atomic::TString));
1166    }
1167
1168    #[test]
1169    fn substitute_no_template_params_identity() {
1170        let ty = Union::single(Atomic::TInt);
1171        let result = ty.substitute_templates(&bindings_t_string());
1172        assert!(matches!(result.types[0], Atomic::TInt));
1173    }
1174}