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