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