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