Skip to main content

mir_types/
union.rs

1use rustc_hash::FxHashMap;
2use serde::{Deserialize, Serialize};
3use smallvec::SmallVec;
4use std::sync::{Arc, OnceLock};
5
6use crate::atomic::Atomic;
7use crate::symbol::Name;
8
9/// Returns a cached empty `Arc<[Type]>` for `type_params` / `parts` fields.
10/// Re-uses a single Arc allocation so all empty parameter lists share one
11/// control block instead of allocating one per TNamedObject construction.
12pub fn empty_type_params() -> Arc<[Type]> {
13    static EMPTY: OnceLock<Arc<[Type]>> = OnceLock::new();
14    EMPTY.get_or_init(|| Arc::from([] as [Type; 0])).clone()
15}
16
17/// Convert a `Vec<Type>` to `Arc<[Type]>`, using the cached empty Arc when
18/// the vec is empty to avoid an allocation for the common no-generic case.
19pub fn vec_to_type_params(v: Vec<Type>) -> Arc<[Type]> {
20    if v.is_empty() {
21        empty_type_params()
22    } else {
23        Arc::from(v)
24    }
25}
26
27// Most unions contain 1-2 atomics (e.g. `string|null`), so we inline two.
28pub type AtomicVec = SmallVec<[Atomic; 2]>;
29
30// ---------------------------------------------------------------------------
31// Type — the primary type carrier
32// ---------------------------------------------------------------------------
33
34#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
35pub struct Type {
36    pub types: AtomicVec,
37    /// The variable holding this type may not be initialized at this point.
38    pub possibly_undefined: bool,
39    /// This type originated from a docblock annotation rather than inference.
40    pub from_docblock: bool,
41}
42
43impl Type {
44    // --- Constructors -------------------------------------------------------
45
46    pub fn empty() -> Self {
47        Self {
48            types: SmallVec::new(),
49            possibly_undefined: false,
50            from_docblock: false,
51        }
52    }
53
54    pub fn single(atomic: Atomic) -> Self {
55        let mut types = SmallVec::new();
56        types.push(atomic);
57        Self {
58            types,
59            possibly_undefined: false,
60            from_docblock: false,
61        }
62    }
63
64    pub fn mixed() -> Self {
65        Self::single(Atomic::TMixed)
66    }
67
68    pub fn void() -> Self {
69        Self::single(Atomic::TVoid)
70    }
71
72    pub fn never() -> Self {
73        Self::single(Atomic::TNever)
74    }
75
76    pub fn null() -> Self {
77        Self::single(Atomic::TNull)
78    }
79
80    pub fn bool() -> Self {
81        Self::single(Atomic::TBool)
82    }
83
84    pub fn int() -> Self {
85        Self::single(Atomic::TInt)
86    }
87
88    pub fn float() -> Self {
89        Self::single(Atomic::TFloat)
90    }
91
92    pub fn string() -> Self {
93        Self::single(Atomic::TString)
94    }
95
96    /// `T|null`
97    pub fn nullable(atomic: Atomic) -> Self {
98        let mut types = SmallVec::new();
99        types.push(atomic);
100        types.push(Atomic::TNull);
101        Self {
102            types,
103            possibly_undefined: false,
104            from_docblock: false,
105        }
106    }
107
108    /// Build a union from multiple atomics, de-duplicating on the fly.
109    pub fn from_vec(atomics: Vec<Atomic>) -> Self {
110        let mut u = Self::empty();
111        for a in atomics {
112            u.add_type(a);
113        }
114        u
115    }
116
117    // --- Introspection -------------------------------------------------------
118
119    pub fn is_empty(&self) -> bool {
120        self.types.is_empty()
121    }
122
123    pub fn is_single(&self) -> bool {
124        self.types.len() == 1
125    }
126
127    pub fn is_nullable(&self) -> bool {
128        self.types.iter().any(|t| matches!(t, Atomic::TNull))
129    }
130
131    pub fn is_mixed(&self) -> bool {
132        self.types.iter().any(|t| match t {
133            Atomic::TMixed => true,
134            Atomic::TTemplateParam { as_type, .. } => as_type.is_mixed(),
135            _ => false,
136        })
137    }
138
139    pub fn is_never(&self) -> bool {
140        self.types.iter().all(|t| matches!(t, Atomic::TNever)) && !self.types.is_empty()
141    }
142
143    pub fn is_void(&self) -> bool {
144        self.is_single() && matches!(self.types[0], Atomic::TVoid)
145    }
146
147    pub fn can_be_falsy(&self) -> bool {
148        self.types.iter().any(|t| t.can_be_falsy())
149    }
150
151    pub fn can_be_truthy(&self) -> bool {
152        self.types.iter().any(|t| t.can_be_truthy())
153    }
154
155    pub fn contains<F: Fn(&Atomic) -> bool>(&self, f: F) -> bool {
156        self.types.iter().any(f)
157    }
158
159    pub fn has_named_object(&self, fqcn: &str) -> bool {
160        self.types.iter().any(|t| match t {
161            Atomic::TNamedObject { fqcn: f, .. } => f.as_ref() == fqcn,
162            _ => false,
163        })
164    }
165
166    // --- Mutation ------------------------------------------------------------
167
168    /// Add an atomic to this union, skipping duplicates.
169    /// Subsumption rules: anything ⊆ TMixed; TLiteralInt ⊆ TInt; etc.
170    pub fn add_type(&mut self, atomic: Atomic) {
171        // If we already have TMixed, nothing to add.
172        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
173            return;
174        }
175
176        // Adding TMixed subsumes everything.
177        if matches!(atomic, Atomic::TMixed) {
178            self.types.clear();
179            self.types.push(Atomic::TMixed);
180            return;
181        }
182
183        // Simplify trivial conditional types: (X is ? T : T) → T
184        // Recursively simplify branches first so nested trivial conditionals collapse.
185        let atomic = if let Atomic::TConditional {
186            param_name: _,
187            subject: _,
188            if_true,
189            if_false,
190        } = &atomic
191        {
192            let mut simplified_true = Type::empty();
193            for t in &if_true.types {
194                simplified_true.add_type(t.clone());
195            }
196            let mut simplified_false = Type::empty();
197            for t in &if_false.types {
198                simplified_false.add_type(t.clone());
199            }
200            if simplified_true == simplified_false {
201                for t in simplified_true.types {
202                    self.add_type(t);
203                }
204                return;
205            }
206            atomic
207        } else {
208            atomic
209        };
210
211        // Avoid exact duplicates.
212        if self.types.contains(&atomic) {
213            return;
214        }
215
216        // TLiteralInt(n) is subsumed by TInt.
217        if let Atomic::TLiteralInt(_) = &atomic {
218            if self.types.iter().any(|t| matches!(t, Atomic::TInt)) {
219                return;
220            }
221        }
222        // TLiteralString(s) is subsumed by TString.
223        if let Atomic::TLiteralString(_) = &atomic {
224            if self.types.iter().any(|t| matches!(t, Atomic::TString)) {
225                return;
226            }
227        }
228        // TTrue / TFalse are subsumed by TBool.
229        if matches!(atomic, Atomic::TTrue | Atomic::TFalse)
230            && self.types.iter().any(|t| matches!(t, Atomic::TBool))
231        {
232            return;
233        }
234        // Adding TInt widens away all TLiteralInt variants.
235        if matches!(atomic, Atomic::TInt) {
236            self.types.retain(|t| !matches!(t, Atomic::TLiteralInt(_)));
237        }
238        // Adding TString widens away all TLiteralString variants.
239        if matches!(atomic, Atomic::TString) {
240            self.types
241                .retain(|t| !matches!(t, Atomic::TLiteralString(_)));
242        }
243        // Adding TBool widens away TTrue/TFalse.
244        if matches!(atomic, Atomic::TBool) {
245            self.types
246                .retain(|t| !matches!(t, Atomic::TTrue | Atomic::TFalse));
247        }
248
249        // TNever is the bottom type: T | never = T.
250        if matches!(atomic, Atomic::TNever) {
251            if !self.types.is_empty() {
252                return;
253            }
254        } else {
255            self.types.retain(|t| !matches!(t, Atomic::TNever));
256        }
257
258        // Empty keyed array (array{}) is a subtype of any generic array or list.
259        // Remove array{} if we already have a generic array<K,V> or list<V>.
260        if let Atomic::TKeyedArray { properties, .. } = &atomic {
261            if properties.is_empty() {
262                for existing in &self.types {
263                    match existing {
264                        Atomic::TArray { .. }
265                        | Atomic::TNonEmptyArray { .. }
266                        | Atomic::TList { .. }
267                        | Atomic::TNonEmptyList { .. } => {
268                            return; // Don't add empty array, it's subsumed
269                        }
270                        _ => {}
271                    }
272                }
273            }
274        }
275
276        // When adding a generic array or list, remove any empty keyed arrays since they're subtypes.
277        let is_generic_array_or_list = matches!(
278            &atomic,
279            Atomic::TArray { .. }
280                | Atomic::TNonEmptyArray { .. }
281                | Atomic::TList { .. }
282                | Atomic::TNonEmptyList { .. }
283        );
284        if is_generic_array_or_list {
285            self.types.retain(|t| {
286                if let Atomic::TKeyedArray { properties, .. } = t {
287                    !properties.is_empty()
288                } else {
289                    true
290                }
291            });
292        }
293
294        self.types.push(atomic);
295    }
296
297    // --- Narrowing -----------------------------------------------------------
298
299    /// Remove `null` from the union (e.g. after a null check).
300    pub fn remove_null(&self) -> Type {
301        self.filter(|t| !matches!(t, Atomic::TNull))
302    }
303
304    /// Remove `false` from the union.
305    pub fn remove_false(&self) -> Type {
306        self.filter(|t| !matches!(t, Atomic::TFalse | Atomic::TBool))
307    }
308
309    /// Remove both `null` and `false` from the union (core type without nullable/falsy variants).
310    pub fn core_type(&self) -> Type {
311        self.remove_null().remove_false()
312    }
313
314    /// Keep only truthy atomics (e.g. after `if ($x)`).
315    pub fn narrow_to_truthy(&self) -> Type {
316        if self.is_mixed() {
317            return Type::mixed();
318        }
319        let narrowed = self.filter(|t| t.can_be_truthy());
320        // Remove specific falsy literals from string/int
321        narrowed.filter(|t| match t {
322            Atomic::TLiteralInt(0) => false,
323            Atomic::TLiteralString(s) if s.as_ref() == "" || s.as_ref() == "0" => false,
324            Atomic::TLiteralFloat(0, 0) => false,
325            _ => true,
326        })
327    }
328
329    /// Keep only falsy atomics (e.g. after `if (!$x)`).
330    pub fn narrow_to_falsy(&self) -> Type {
331        if self.is_mixed() {
332            return Type::from_vec(vec![
333                Atomic::TNull,
334                Atomic::TFalse,
335                Atomic::TLiteralInt(0),
336                Atomic::TLiteralString("".into()),
337            ]);
338        }
339        self.filter(|t| t.can_be_falsy())
340    }
341
342    /// Narrow this type as if `$x instanceof ClassName` is true.
343    ///
344    /// The instanceof check guarantees the value IS an instance of `class`, so we
345    /// replace any object / mixed constituents with the specific named object.  Scalar
346    /// constituents are dropped (they can never satisfy instanceof).
347    pub fn narrow_instanceof(&self, class: &str) -> Type {
348        let narrowed_ty = Atomic::TNamedObject {
349            fqcn: class.into(),
350            type_params: empty_type_params(),
351        };
352        // If any constituent is an object-like type, the result is the specific class.
353        let has_object = self.types.iter().any(|t| {
354            matches!(
355                t,
356                Atomic::TObject | Atomic::TNamedObject { .. } | Atomic::TMixed | Atomic::TNull // null fails instanceof, but mixed/object may include null
357            )
358        });
359        if has_object || self.is_empty() {
360            Type::single(narrowed_ty)
361        } else {
362            // Pure scalars — instanceof is always false here, but return the class
363            // defensively so callers don't see an empty union.
364            Type::single(narrowed_ty)
365        }
366    }
367
368    /// Narrow as if `is_string($x)` is true.
369    pub fn narrow_to_string(&self) -> Type {
370        self.filter(|t| t.is_string() || matches!(t, Atomic::TMixed | Atomic::TScalar))
371    }
372
373    /// Narrow as if `is_int($x)` is true.
374    pub fn narrow_to_int(&self) -> Type {
375        self.filter(|t| {
376            t.is_int() || matches!(t, Atomic::TMixed | Atomic::TScalar | Atomic::TNumeric)
377        })
378    }
379
380    /// Narrow as if `is_float($x)` is true.
381    pub fn narrow_to_float(&self) -> Type {
382        self.filter(|t| {
383            matches!(
384                t,
385                Atomic::TFloat
386                    | Atomic::TLiteralFloat(..)
387                    | Atomic::TMixed
388                    | Atomic::TScalar
389                    | Atomic::TNumeric
390            )
391        })
392    }
393
394    /// Narrow as if `is_bool($x)` is true.
395    pub fn narrow_to_bool(&self) -> Type {
396        self.filter(|t| {
397            matches!(
398                t,
399                Atomic::TBool | Atomic::TTrue | Atomic::TFalse | Atomic::TMixed | Atomic::TScalar
400            )
401        })
402    }
403
404    /// Narrow as if `is_null($x)` is true.
405    pub fn narrow_to_null(&self) -> Type {
406        self.filter(|t| matches!(t, Atomic::TNull | Atomic::TMixed))
407    }
408
409    /// Narrow as if `is_array($x)` is true.
410    pub fn narrow_to_array(&self) -> Type {
411        self.filter(|t| t.is_array() || matches!(t, Atomic::TMixed))
412    }
413
414    /// Narrow as if `is_object($x)` is true.
415    pub fn narrow_to_object(&self) -> Type {
416        self.filter(|t| t.is_object() || matches!(t, Atomic::TMixed))
417    }
418
419    /// Narrow as if `is_callable($x)` is true.
420    pub fn narrow_to_callable(&self) -> Type {
421        self.filter(|t| t.is_callable() || matches!(t, Atomic::TMixed))
422    }
423
424    /// Narrow as if `is_scalar($x)` is true (int | string | float | bool).
425    pub fn narrow_to_scalar(&self) -> Type {
426        self.filter(|t| {
427            matches!(
428                t,
429                Atomic::TString
430                    | Atomic::TLiteralString(..)
431                    | Atomic::TNumericString
432                    | Atomic::TInt
433                    | Atomic::TLiteralInt(..)
434                    | Atomic::TFloat
435                    | Atomic::TLiteralFloat(..)
436                    | Atomic::TBool
437                    | Atomic::TTrue
438                    | Atomic::TFalse
439                    | Atomic::TScalar
440                    | Atomic::TMixed
441            )
442        })
443    }
444
445    /// Narrow as if `is_iterable($x)` is true (array | Traversable).
446    /// For simplicity, this narrows to arrays or objects (can't easily verify interfaces).
447    pub fn narrow_to_iterable(&self) -> Type {
448        self.filter(|t| t.is_array() || t.is_object() || matches!(t, Atomic::TMixed))
449    }
450
451    /// Narrow as if `is_countable($x)` is true (array | Countable).
452    /// For simplicity, this narrows to arrays or objects (can't easily verify Countable interface).
453    pub fn narrow_to_countable(&self) -> Type {
454        self.filter(|t| t.is_array() || t.is_object() || matches!(t, Atomic::TMixed))
455    }
456
457    /// Narrow as if `is_resource($x)` is true.
458    /// Note: No TResource atomic type exists in the type system; this is a no-op.
459    /// Resources are declining in modern PHP and not actively tracked.
460    pub fn narrow_to_resource(&self) -> Type {
461        // No resource type in the system; just return mixed (allows any type)
462        self.filter(|t| matches!(t, Atomic::TMixed))
463    }
464
465    // --- Merge (branch join) ------------------------------------------------
466
467    /// Merge two unions at a branch join point (e.g. after if/else).
468    /// The result is the union of all types in both.
469    pub fn merge(a: &Type, b: &Type) -> Type {
470        // Fast path: b is empty — nothing to add.
471        if b.types.is_empty() {
472            let mut result = a.clone();
473            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
474            return result;
475        }
476        // Fast path: a is empty — clone b.
477        if a.types.is_empty() {
478            let mut result = b.clone();
479            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
480            return result;
481        }
482        // Fast path: a is already mixed — b cannot widen it further.
483        if a.types.len() == 1 && matches!(a.types[0], Atomic::TMixed) {
484            let mut result = a.clone();
485            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
486            return result;
487        }
488        // Fast path: b contains mixed — result collapses to mixed.
489        if b.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
490            return Type {
491                types: smallvec::smallvec![Atomic::TMixed],
492                possibly_undefined: a.possibly_undefined || b.possibly_undefined,
493                from_docblock: a.from_docblock || b.from_docblock,
494            };
495        }
496        let mut result = a.clone();
497        result.merge_with(b);
498        result
499    }
500
501    /// Merge `other` into `self` in-place (avoids cloning `self`).
502    pub fn merge_with(&mut self, other: &Type) {
503        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
504            self.possibly_undefined |= other.possibly_undefined;
505            return;
506        }
507        if other.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
508            self.types.clear();
509            self.types.push(Atomic::TMixed);
510            self.possibly_undefined |= other.possibly_undefined;
511            return;
512        }
513        for atomic in &other.types {
514            self.add_type(atomic.clone());
515        }
516        self.possibly_undefined |= other.possibly_undefined;
517    }
518
519    /// Intersect with another union: keep only types present in `other`, widening
520    /// where `self` contains `mixed` (which is compatible with everything).
521    /// Used for match-arm subject narrowing.
522    pub fn intersect_with(&self, other: &Type) -> Type {
523        if self.is_mixed() {
524            return other.clone();
525        }
526        if other.is_mixed() {
527            return self.clone();
528        }
529        // Keep atomics from self that are also in other (by equality or subtype)
530        let mut result = Type::empty();
531        for a in &self.types {
532            for b in &other.types {
533                if a == b || atomic_subtype(a, b) || atomic_subtype(b, a) {
534                    result.add_type(a.clone());
535                    break;
536                }
537            }
538        }
539        if result.is_empty() {
540            Type::never()
541        } else {
542            result
543        }
544    }
545
546    // --- Template substitution ----------------------------------------------
547
548    /// Replace template param references with their resolved types.
549    pub fn substitute_templates(&self, bindings: &FxHashMap<Name, Type>) -> Type {
550        if bindings.is_empty() {
551            return self.clone();
552        }
553        let mut result = Type::empty();
554        result.possibly_undefined = self.possibly_undefined;
555        result.from_docblock = self.from_docblock;
556        for atomic in &self.types {
557            match atomic {
558                Atomic::TTemplateParam { name, .. } => {
559                    if let Some(resolved) = bindings.get(name) {
560                        for t in &resolved.types {
561                            result.add_type(t.clone());
562                        }
563                    } else {
564                        result.add_type(atomic.clone());
565                    }
566                }
567                Atomic::TArray { key, value } => {
568                    result.add_type(Atomic::TArray {
569                        key: Box::new(key.substitute_templates(bindings)),
570                        value: Box::new(value.substitute_templates(bindings)),
571                    });
572                }
573                Atomic::TList { value } => {
574                    result.add_type(Atomic::TList {
575                        value: Box::new(value.substitute_templates(bindings)),
576                    });
577                }
578                Atomic::TNonEmptyArray { key, value } => {
579                    result.add_type(Atomic::TNonEmptyArray {
580                        key: Box::new(key.substitute_templates(bindings)),
581                        value: Box::new(value.substitute_templates(bindings)),
582                    });
583                }
584                Atomic::TNonEmptyList { value } => {
585                    result.add_type(Atomic::TNonEmptyList {
586                        value: Box::new(value.substitute_templates(bindings)),
587                    });
588                }
589                Atomic::TKeyedArray {
590                    properties,
591                    is_open,
592                    is_list,
593                } => {
594                    use crate::atomic::KeyedProperty;
595                    let new_props = properties
596                        .iter()
597                        .map(|(k, prop)| {
598                            (
599                                k.clone(),
600                                KeyedProperty {
601                                    ty: prop.ty.substitute_templates(bindings),
602                                    optional: prop.optional,
603                                },
604                            )
605                        })
606                        .collect();
607                    result.add_type(Atomic::TKeyedArray {
608                        properties: new_props,
609                        is_open: *is_open,
610                        is_list: *is_list,
611                    });
612                }
613                Atomic::TCallable {
614                    params,
615                    return_type,
616                } => {
617                    result.add_type(Atomic::TCallable {
618                        params: params.as_ref().map(|ps| {
619                            ps.iter()
620                                .map(|p| substitute_in_fn_param(p, bindings))
621                                .collect()
622                        }),
623                        return_type: return_type
624                            .as_ref()
625                            .map(|r| Box::new(r.substitute_templates(bindings))),
626                    });
627                }
628                Atomic::TClosure {
629                    params,
630                    return_type,
631                    this_type,
632                } => {
633                    result.add_type(Atomic::TClosure {
634                        params: params
635                            .iter()
636                            .map(|p| substitute_in_fn_param(p, bindings))
637                            .collect(),
638                        return_type: Box::new(return_type.substitute_templates(bindings)),
639                        this_type: this_type
640                            .as_ref()
641                            .map(|t| Box::new(t.substitute_templates(bindings))),
642                    });
643                }
644                Atomic::TConditional {
645                    param_name,
646                    subject,
647                    if_true,
648                    if_false,
649                } => {
650                    let new_subject = subject.substitute_templates(bindings);
651                    let new_if_true = if_true.substitute_templates(bindings);
652                    let new_if_false = if_false.substitute_templates(bindings);
653
654                    // If param_name names a template that is bound in this substitution,
655                    // resolve the conditional immediately using the same predicate logic as
656                    // `resolve_conditional_returns` for the $param form.
657                    let resolved = if let Some(name) = param_name {
658                        if let Some(bound) = bindings.get(name) {
659                            if new_subject.types.len() == 1 {
660                                resolve_conditional_branch(
661                                    &new_subject.types[0],
662                                    bound,
663                                    &new_if_true,
664                                    &new_if_false,
665                                )
666                            } else {
667                                None
668                            }
669                        } else {
670                            None
671                        }
672                    } else {
673                        None
674                    };
675
676                    if let Some(branch) = resolved {
677                        for t in branch.types {
678                            result.add_type(t);
679                        }
680                    } else {
681                        result.add_type(Atomic::TConditional {
682                            param_name: *param_name,
683                            subject: Box::new(new_subject),
684                            if_true: Box::new(new_if_true),
685                            if_false: Box::new(new_if_false),
686                        });
687                    }
688                }
689                Atomic::TIntersection { parts } => {
690                    result.add_type(Atomic::TIntersection {
691                        parts: vec_to_type_params(
692                            parts
693                                .iter()
694                                .map(|p| p.substitute_templates(bindings))
695                                .collect(),
696                        ),
697                    });
698                }
699                Atomic::TNamedObject { fqcn, type_params } => {
700                    // TODO: the docblock parser emits TNamedObject { fqcn: "T" } for bare @return T
701                    // annotations instead of TTemplateParam, because it lacks template context at
702                    // parse time. This block works around that by treating bare unqualified names
703                    // as template param references when they appear in the binding map. Proper fix:
704                    // make the docblock parser template-aware so it emits TTemplateParam directly.
705                    // See issue #26 for context.
706                    if type_params.is_empty() && !fqcn.contains('\\') {
707                        if let Some(resolved) = bindings.get(fqcn) {
708                            for t in &resolved.types {
709                                result.add_type(t.clone());
710                            }
711                            continue;
712                        }
713                    }
714                    let new_params: Vec<Type> = type_params
715                        .iter()
716                        .map(|p| p.substitute_templates(bindings))
717                        .collect();
718                    result.add_type(Atomic::TNamedObject {
719                        fqcn: *fqcn,
720                        type_params: vec_to_type_params(new_params),
721                    });
722                }
723                // class-string<T> → substitute T from bindings
724                Atomic::TClassString(Some(param_name)) => {
725                    if let Some(resolved) = bindings.get(param_name) {
726                        for r_atomic in &resolved.types {
727                            let cls_name = if let Atomic::TNamedObject { fqcn, .. } = r_atomic {
728                                Some(*fqcn)
729                            } else {
730                                None
731                            };
732                            result.add_type(Atomic::TClassString(cls_name));
733                        }
734                    } else {
735                        result.add_type(atomic.clone());
736                    }
737                }
738                _ => {
739                    result.add_type(atomic.clone());
740                }
741            }
742        }
743        result
744    }
745
746    /// Resolves `TConditional` atoms whose discriminator is known at the call site.
747    ///
748    /// `lookup(param_name)` returns the call-site argument type for the named parameter,
749    /// or `None` if the argument is not available. Handles `is null`, `is string`, and
750    /// `is array` conditions; other condition types pass through unchanged.
751    pub fn resolve_conditional_returns<F>(self, lookup: F) -> Type
752    where
753        F: Fn(&str) -> Option<Type>,
754    {
755        self.resolve_conditional_inner(&lookup)
756    }
757
758    fn resolve_conditional_inner<F>(self, lookup: &F) -> Type
759    where
760        F: Fn(&str) -> Option<Type>,
761    {
762        let mut result = Type::empty();
763        for atomic in self.types {
764            match atomic {
765                Atomic::TConditional {
766                    ref param_name,
767                    ref subject,
768                    ref if_true,
769                    ref if_false,
770                } => {
771                    let resolved = if subject.types.len() == 1 {
772                        if let Some(name) = param_name {
773                            if let Some(arg_ty) = lookup(name.as_ref()) {
774                                resolve_conditional_branch(
775                                    &subject.types[0],
776                                    &arg_ty,
777                                    if_true,
778                                    if_false,
779                                )
780                            } else {
781                                None
782                            }
783                        } else {
784                            None
785                        }
786                    } else {
787                        None
788                    };
789
790                    if let Some(branch) = resolved {
791                        // Recursively resolve nested conditionals in the selected branch.
792                        for t in branch.resolve_conditional_inner(lookup).types {
793                            result.add_type(t);
794                        }
795                    } else {
796                        // Cannot resolve at this call site: widen to the union of both branches.
797                        // Recursively resolve nested conditionals in each branch.
798                        for t in if_true.clone().resolve_conditional_inner(lookup).types {
799                            result.add_type(t);
800                        }
801                        for t in if_false.clone().resolve_conditional_inner(lookup).types {
802                            result.add_type(t);
803                        }
804                    }
805                }
806                other => result.add_type(other),
807            }
808        }
809        result
810    }
811
812    // --- Subtype check -------------------------------------------------------
813
814    /// Returns true if every atomic in `self` is a subtype of some atomic in `other`,
815    /// using **only structural rules** — no `extends` / `implements` walk.
816    ///
817    /// Two distinct user-defined classes are never related here, even when one
818    /// extends the other. Within `mir-analyzer`, when a `db` is in scope,
819    /// prefer `crate::subtype::is_subtype(db, sub, sup)` which layers
820    /// inheritance resolution on top of this check.
821    pub fn is_subtype_structural(&self, other: &Type) -> bool {
822        if other.is_mixed() {
823            return true;
824        }
825        if self.is_never() {
826            return true; // never <: everything
827        }
828        self.types
829            .iter()
830            .all(|a| other.types.iter().any(|b| atomic_subtype(a, b)))
831    }
832
833    // --- Utilities ----------------------------------------------------------
834
835    fn filter<F: Fn(&Atomic) -> bool>(&self, f: F) -> Type {
836        let mut result = Type::empty();
837        result.possibly_undefined = self.possibly_undefined;
838        result.from_docblock = self.from_docblock;
839        for atomic in &self.types {
840            if f(atomic) {
841                result.types.push(atomic.clone());
842            }
843        }
844        result
845    }
846
847    /// Mark this union as possibly-undefined and return it.
848    pub fn possibly_undefined(mut self) -> Self {
849        self.possibly_undefined = true;
850        self
851    }
852
853    /// Mark this union as coming from a docblock annotation.
854    pub fn from_docblock(mut self) -> Self {
855        self.from_docblock = true;
856        self
857    }
858}
859
860// ---------------------------------------------------------------------------
861// Conditional return resolution helpers
862// ---------------------------------------------------------------------------
863
864fn is_string_atomic(a: &Atomic) -> bool {
865    matches!(
866        a,
867        Atomic::TString
868            | Atomic::TNonEmptyString
869            | Atomic::TLiteralString(_)
870            | Atomic::TNumericString
871            | Atomic::TClassString(_)
872            | Atomic::TCallableString
873    )
874}
875
876fn is_array_atomic(a: &Atomic) -> bool {
877    matches!(
878        a,
879        Atomic::TArray { .. }
880            | Atomic::TNonEmptyArray { .. }
881            | Atomic::TKeyedArray { .. }
882            | Atomic::TList { .. }
883            | Atomic::TNonEmptyList { .. }
884    )
885}
886
887fn is_list_atomic(a: &Atomic) -> bool {
888    match a {
889        Atomic::TList { .. } | Atomic::TNonEmptyList { .. } => true,
890        Atomic::TKeyedArray { is_list, .. } => *is_list,
891        _ => false,
892    }
893}
894
895/// Resolve one branch of a conditional return type given the subject discriminant
896/// atomic and the actual argument type at the call site.
897///
898/// Returns `Some(branch)` when the branch can be determined statically, or `None`
899/// to signal that the caller should widen to the union of both branches.
900fn resolve_conditional_branch(
901    subject: &Atomic,
902    arg_ty: &Type,
903    if_true: &Type,
904    if_false: &Type,
905) -> Option<Type> {
906    let predicate: fn(&Atomic) -> bool = match subject {
907        Atomic::TNull => |a| matches!(a, Atomic::TNull),
908        Atomic::TTrue => |a| matches!(a, Atomic::TTrue),
909        Atomic::TFalse => |a| matches!(a, Atomic::TFalse),
910        Atomic::TString => is_string_atomic,
911        Atomic::TList { .. } => is_list_atomic,
912        Atomic::TArray { .. } => is_array_atomic,
913        _ => return None,
914    };
915
916    if arg_ty.types.is_empty() {
917        return None;
918    }
919    let all_match = arg_ty.types.iter().all(&predicate);
920    let none_match = !arg_ty.types.iter().any(predicate);
921    if all_match {
922        Some(if_true.clone())
923    } else if none_match {
924        Some(if_false.clone())
925    } else {
926        None
927    }
928}
929
930// ---------------------------------------------------------------------------
931// Template substitution helpers
932// ---------------------------------------------------------------------------
933
934fn substitute_in_fn_param(
935    p: &crate::atomic::FnParam,
936    bindings: &FxHashMap<Name, Type>,
937) -> crate::atomic::FnParam {
938    crate::atomic::FnParam {
939        name: p.name,
940        ty: p.ty.as_ref().map(|t| {
941            let u = t.to_union();
942            let substituted = u.substitute_templates(bindings);
943            crate::compact::SimpleType::from_union(substituted)
944        }),
945        default: p.default.as_ref().map(|d| {
946            let u = d.to_union();
947            let substituted = u.substitute_templates(bindings);
948            crate::compact::SimpleType::from_union(substituted)
949        }),
950        is_variadic: p.is_variadic,
951        is_byref: p.is_byref,
952        is_optional: p.is_optional,
953    }
954}
955
956// ---------------------------------------------------------------------------
957// Atomic subtype (no codebase — structural check only)
958// ---------------------------------------------------------------------------
959
960fn atomic_subtype(sub: &Atomic, sup: &Atomic) -> bool {
961    if sub == sup {
962        return true;
963    }
964    match (sub, sup) {
965        // Bottom type
966        (Atomic::TNever, _) => true,
967        // Top types — anything goes in both directions for mixed
968        (_, Atomic::TMixed) => true,
969        (Atomic::TMixed, _) => true,
970        // Template param in supertype position: any value satisfies an unconstrained
971        // template (as_type = mixed), or a constrained one if it satisfies the bound.
972        // This handles union bounds like `T of string|list<I>|array<K, V>` where
973        // I/K/V are free template params — any type satisfies them structurally.
974        (_, Atomic::TTemplateParam { as_type, .. }) => {
975            as_type.is_mixed() || as_type.types.iter().any(|b| atomic_subtype(sub, b))
976        }
977
978        // Scalars
979        (Atomic::TLiteralInt(_), Atomic::TInt) => true,
980        (Atomic::TLiteralInt(_), Atomic::TNumeric) => true,
981        (Atomic::TLiteralInt(_), Atomic::TScalar) => true,
982        (Atomic::TLiteralInt(n), Atomic::TPositiveInt) => *n > 0,
983        (Atomic::TLiteralInt(n), Atomic::TNonNegativeInt) => *n >= 0,
984        (Atomic::TLiteralInt(n), Atomic::TNegativeInt) => *n < 0,
985        (Atomic::TPositiveInt, Atomic::TInt) => true,
986        (Atomic::TPositiveInt, Atomic::TNonNegativeInt) => true,
987        (Atomic::TNegativeInt, Atomic::TInt) => true,
988        (Atomic::TNonNegativeInt, Atomic::TInt) => true,
989        (Atomic::TIntRange { .. }, Atomic::TInt) => true,
990
991        (Atomic::TLiteralFloat(..), Atomic::TFloat) => true,
992        (Atomic::TLiteralFloat(..), Atomic::TNumeric) => true,
993        (Atomic::TLiteralFloat(..), Atomic::TScalar) => true,
994
995        (Atomic::TLiteralString(s), Atomic::TString) => {
996            let _ = s;
997            true
998        }
999        (Atomic::TLiteralString(s), Atomic::TCallableString) => {
1000            let _ = s;
1001            true
1002        }
1003        (Atomic::TLiteralString(s), Atomic::TNonEmptyString) => !s.is_empty(),
1004        (Atomic::TLiteralString(_), Atomic::TScalar) => true,
1005        (Atomic::TNonEmptyString, Atomic::TString) => true,
1006        (Atomic::TCallableString, Atomic::TString) => true,
1007        (Atomic::TNumericString, Atomic::TString) => true,
1008        (Atomic::TClassString(_), Atomic::TString) => true,
1009        (Atomic::TInterfaceString, Atomic::TString) => true,
1010        (Atomic::TEnumString, Atomic::TString) => true,
1011        (Atomic::TTraitString, Atomic::TString) => true,
1012
1013        (Atomic::TTrue, Atomic::TBool) => true,
1014        (Atomic::TFalse, Atomic::TBool) => true,
1015
1016        (Atomic::TInt, Atomic::TNumeric) => true,
1017        (Atomic::TFloat, Atomic::TNumeric) => true,
1018        (Atomic::TNumericString, Atomic::TNumeric) => true,
1019
1020        (Atomic::TInt, Atomic::TScalar) => true,
1021        (Atomic::TFloat, Atomic::TScalar) => true,
1022        (Atomic::TString, Atomic::TScalar) => true,
1023        (Atomic::TBool, Atomic::TScalar) => true,
1024        (Atomic::TNumeric, Atomic::TScalar) => true,
1025        (Atomic::TTrue, Atomic::TScalar) => true,
1026        (Atomic::TFalse, Atomic::TScalar) => true,
1027
1028        // Object hierarchy (structural, no codebase)
1029        (Atomic::TNamedObject { .. }, Atomic::TObject) => true,
1030        (Atomic::TStaticObject { .. }, Atomic::TObject) => true,
1031        (Atomic::TSelf { .. }, Atomic::TObject) => true,
1032        // self(X) and static(X) satisfy TNamedObject(X) with same FQCN
1033        (Atomic::TSelf { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
1034        (Atomic::TStaticObject { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
1035        // TNamedObject(X) satisfies self(X) / static(X) with same FQCN
1036        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TSelf { fqcn: b }) => a == b,
1037        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TStaticObject { fqcn: b }) => a == b,
1038        // Bare generic property accepts parameterized value: Box accepts Box<string>.
1039        // The reverse is NOT true — bare Box value does not satisfy Box<string> property
1040        // (invariant check). Only sup being bare (empty type_params) is the wildcard.
1041        (
1042            Atomic::TNamedObject {
1043                fqcn: sub_fqcn,
1044                type_params: sub_params,
1045            },
1046            Atomic::TNamedObject {
1047                fqcn: sup_fqcn,
1048                type_params: sup_params,
1049            },
1050        ) => sub_fqcn == sup_fqcn && (sup_params.is_empty() || sub_params == sup_params),
1051
1052        // Literal int widens to float in PHP
1053        (Atomic::TLiteralInt(_), Atomic::TFloat) => true,
1054        (Atomic::TPositiveInt, Atomic::TFloat) => true,
1055        (Atomic::TInt, Atomic::TFloat) => true,
1056
1057        // Literal int satisfies int ranges
1058        (Atomic::TLiteralInt(_), Atomic::TIntRange { .. }) => true,
1059
1060        // PHP callables: string and array are valid callable values
1061        (Atomic::TString, Atomic::TCallable { .. }) => true,
1062        (Atomic::TNonEmptyString, Atomic::TCallable { .. }) => true,
1063        (Atomic::TLiteralString(_), Atomic::TCallable { .. }) => true,
1064        (Atomic::TArray { .. }, Atomic::TCallable { .. }) => true,
1065        (Atomic::TNonEmptyArray { .. }, Atomic::TCallable { .. }) => true,
1066
1067        // Closure <: callable, typed Closure <: Closure
1068        (Atomic::TClosure { .. }, Atomic::TCallable { .. }) => true,
1069        // callable <: Closure: callable is wider but not flagged at default error level
1070        (Atomic::TCallable { .. }, Atomic::TClosure { .. }) => true,
1071        // Any TClosure satisfies another TClosure (structural compatibility simplified)
1072        (Atomic::TClosure { .. }, Atomic::TClosure { .. }) => true,
1073        // callable <: callable (trivial)
1074        (Atomic::TCallable { .. }, Atomic::TCallable { .. }) => true,
1075        // TClosure satisfies `Closure` named object or `object`
1076        (Atomic::TClosure { .. }, Atomic::TNamedObject { fqcn, .. }) => {
1077            fqcn.as_ref().eq_ignore_ascii_case("closure")
1078        }
1079        (Atomic::TClosure { .. }, Atomic::TObject) => true,
1080        // bare `Closure` (named object without signature) satisfies any typed Closure(): T
1081        (Atomic::TNamedObject { fqcn, .. }, Atomic::TClosure { .. }) => {
1082            fqcn.as_ref().eq_ignore_ascii_case("closure")
1083        }
1084
1085        // List <: array
1086        (Atomic::TList { value }, Atomic::TArray { key, value: av }) => {
1087            // list key is always int
1088            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1089                && value.is_subtype_structural(av)
1090        }
1091        (Atomic::TNonEmptyList { value }, Atomic::TList { value: lv }) => {
1092            value.is_subtype_structural(lv)
1093        }
1094        // array<int, X> is accepted where list<X> or non-empty-list<X> expected
1095        (Atomic::TArray { key, value: av }, Atomic::TList { value: lv }) => {
1096            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1097                && av.is_subtype_structural(lv)
1098        }
1099        (Atomic::TArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1100            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1101                && av.is_subtype_structural(lv)
1102        }
1103        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TList { value: lv }) => {
1104            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1105                && av.is_subtype_structural(lv)
1106        }
1107        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1108            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1109                && av.is_subtype_structural(lv)
1110        }
1111        // TList <: TList value covariance
1112        (Atomic::TList { value: v1 }, Atomic::TList { value: v2 }) => v1.is_subtype_structural(v2),
1113        (Atomic::TNonEmptyArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1114            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1115        }
1116
1117        // array<A, B> <: array<C, D>  iff  A <: C && B <: D
1118        (Atomic::TArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1119            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1120        }
1121
1122        // A keyed/shape array (array{...} or array{}) is a subtype of any generic array.
1123        (Atomic::TKeyedArray { .. }, Atomic::TArray { .. }) => true,
1124
1125        // A list-shaped keyed array (is_list=true, all int keys) is a subtype of list<X>.
1126        (
1127            Atomic::TKeyedArray {
1128                properties,
1129                is_list,
1130                ..
1131            },
1132            Atomic::TList { value: lv },
1133        ) => *is_list && properties.values().all(|p| p.ty.is_subtype_structural(lv)),
1134        (
1135            Atomic::TKeyedArray {
1136                properties,
1137                is_list,
1138                ..
1139            },
1140            Atomic::TNonEmptyList { value: lv },
1141        ) => {
1142            *is_list
1143                && !properties.is_empty()
1144                && properties.values().all(|p| p.ty.is_subtype_structural(lv))
1145        }
1146
1147        _ => false,
1148    }
1149}
1150
1151// ---------------------------------------------------------------------------
1152// Tests
1153// ---------------------------------------------------------------------------
1154
1155#[cfg(test)]
1156mod tests {
1157    use std::sync::Arc;
1158
1159    use super::*;
1160
1161    #[test]
1162    fn single_is_single() {
1163        let u = Type::single(Atomic::TString);
1164        assert!(u.is_single());
1165        assert!(!u.is_nullable());
1166    }
1167
1168    #[test]
1169    fn nullable_has_null() {
1170        let u = Type::nullable(Atomic::TString);
1171        assert!(u.is_nullable());
1172        assert_eq!(u.types.len(), 2);
1173    }
1174
1175    #[test]
1176    fn add_type_deduplicates() {
1177        let mut u = Type::single(Atomic::TString);
1178        u.add_type(Atomic::TString);
1179        assert_eq!(u.types.len(), 1);
1180    }
1181
1182    #[test]
1183    fn add_type_literal_subsumed_by_base() {
1184        let mut u = Type::single(Atomic::TInt);
1185        u.add_type(Atomic::TLiteralInt(42));
1186        assert_eq!(u.types.len(), 1);
1187        assert!(matches!(u.types[0], Atomic::TInt));
1188    }
1189
1190    #[test]
1191    fn add_type_base_widens_literals() {
1192        let mut u = Type::single(Atomic::TLiteralInt(1));
1193        u.add_type(Atomic::TLiteralInt(2));
1194        u.add_type(Atomic::TInt);
1195        assert_eq!(u.types.len(), 1);
1196        assert!(matches!(u.types[0], Atomic::TInt));
1197    }
1198
1199    #[test]
1200    fn mixed_subsumes_everything() {
1201        let mut u = Type::single(Atomic::TString);
1202        u.add_type(Atomic::TMixed);
1203        assert_eq!(u.types.len(), 1);
1204        assert!(u.is_mixed());
1205    }
1206
1207    #[test]
1208    fn remove_null() {
1209        let u = Type::nullable(Atomic::TString);
1210        let narrowed = u.remove_null();
1211        assert!(!narrowed.is_nullable());
1212        assert_eq!(narrowed.types.len(), 1);
1213    }
1214
1215    #[test]
1216    fn narrow_to_truthy_removes_null_false() {
1217        let mut u = Type::empty();
1218        u.add_type(Atomic::TString);
1219        u.add_type(Atomic::TNull);
1220        u.add_type(Atomic::TFalse);
1221        let truthy = u.narrow_to_truthy();
1222        assert!(!truthy.is_nullable());
1223        assert!(!truthy.contains(|t| matches!(t, Atomic::TFalse)));
1224    }
1225
1226    #[test]
1227    fn merge_combines_types() {
1228        let a = Type::single(Atomic::TString);
1229        let b = Type::single(Atomic::TInt);
1230        let merged = Type::merge(&a, &b);
1231        assert_eq!(merged.types.len(), 2);
1232    }
1233
1234    #[test]
1235    fn subtype_literal_int_under_int() {
1236        let sub = Type::single(Atomic::TLiteralInt(5));
1237        let sup = Type::single(Atomic::TInt);
1238        assert!(sub.is_subtype_structural(&sup));
1239    }
1240
1241    #[test]
1242    fn subtype_never_is_bottom() {
1243        let never = Type::never();
1244        let string = Type::single(Atomic::TString);
1245        assert!(never.is_subtype_structural(&string));
1246    }
1247
1248    #[test]
1249    fn subtype_everything_under_mixed() {
1250        let string = Type::single(Atomic::TString);
1251        let mixed = Type::mixed();
1252        assert!(string.is_subtype_structural(&mixed));
1253    }
1254
1255    #[test]
1256    fn template_substitution() {
1257        let mut bindings = FxHashMap::default();
1258        bindings.insert(Name::new("T"), Type::single(Atomic::TString));
1259
1260        let tmpl = Type::single(Atomic::TTemplateParam {
1261            name: Name::new("T"),
1262            as_type: Box::new(Type::mixed()),
1263            defining_entity: Name::new("MyClass"),
1264        });
1265
1266        let resolved = tmpl.substitute_templates(&bindings);
1267        assert_eq!(resolved.types.len(), 1);
1268        assert!(matches!(resolved.types[0], Atomic::TString));
1269    }
1270
1271    #[test]
1272    fn intersection_is_object() {
1273        let parts = vec![
1274            Type::single(Atomic::TNamedObject {
1275                fqcn: Name::new("Iterator"),
1276                type_params: empty_type_params(),
1277            }),
1278            Type::single(Atomic::TNamedObject {
1279                fqcn: Name::new("Countable"),
1280                type_params: empty_type_params(),
1281            }),
1282        ];
1283        let atomic = Atomic::TIntersection {
1284            parts: vec_to_type_params(parts),
1285        };
1286        assert!(atomic.is_object());
1287        assert!(!atomic.can_be_falsy());
1288        assert!(atomic.can_be_truthy());
1289    }
1290
1291    #[test]
1292    fn intersection_display_two_parts() {
1293        let parts = vec![
1294            Type::single(Atomic::TNamedObject {
1295                fqcn: Name::new("Iterator"),
1296                type_params: empty_type_params(),
1297            }),
1298            Type::single(Atomic::TNamedObject {
1299                fqcn: Name::new("Countable"),
1300                type_params: empty_type_params(),
1301            }),
1302        ];
1303        let u = Type::single(Atomic::TIntersection {
1304            parts: vec_to_type_params(parts),
1305        });
1306        assert_eq!(format!("{u}"), "Iterator&Countable");
1307    }
1308
1309    #[test]
1310    fn intersection_display_three_parts() {
1311        let parts = vec![
1312            Type::single(Atomic::TNamedObject {
1313                fqcn: Name::new("A"),
1314                type_params: empty_type_params(),
1315            }),
1316            Type::single(Atomic::TNamedObject {
1317                fqcn: Name::new("B"),
1318                type_params: empty_type_params(),
1319            }),
1320            Type::single(Atomic::TNamedObject {
1321                fqcn: Name::new("C"),
1322                type_params: empty_type_params(),
1323            }),
1324        ];
1325        let u = Type::single(Atomic::TIntersection {
1326            parts: vec_to_type_params(parts),
1327        });
1328        assert_eq!(format!("{u}"), "A&B&C");
1329    }
1330
1331    #[test]
1332    fn intersection_in_nullable_union_display() {
1333        let intersection = Atomic::TIntersection {
1334            parts: vec_to_type_params(vec![
1335                Type::single(Atomic::TNamedObject {
1336                    fqcn: Name::new("Iterator"),
1337                    type_params: empty_type_params(),
1338                }),
1339                Type::single(Atomic::TNamedObject {
1340                    fqcn: Name::new("Countable"),
1341                    type_params: empty_type_params(),
1342                }),
1343            ]),
1344        };
1345        let mut u = Type::single(intersection);
1346        u.add_type(Atomic::TNull);
1347        assert!(u.is_nullable());
1348        assert!(u.contains(|t| matches!(t, Atomic::TIntersection { .. })));
1349    }
1350
1351    // --- substitute_templates coverage for previously-missing arms ----------
1352
1353    fn t_param(name: &str) -> Type {
1354        Type::single(Atomic::TTemplateParam {
1355            name: Name::new(name),
1356            as_type: Box::new(Type::mixed()),
1357            defining_entity: Name::new("Fn"),
1358        })
1359    }
1360
1361    fn bindings_t_string() -> FxHashMap<Name, Type> {
1362        let mut b = FxHashMap::default();
1363        b.insert(Name::new("T"), Type::single(Atomic::TString));
1364        b
1365    }
1366
1367    #[test]
1368    fn substitute_non_empty_array_key_and_value() {
1369        let ty = Type::single(Atomic::TNonEmptyArray {
1370            key: Box::new(t_param("T")),
1371            value: Box::new(t_param("T")),
1372        });
1373        let result = ty.substitute_templates(&bindings_t_string());
1374        assert_eq!(result.types.len(), 1);
1375        let Atomic::TNonEmptyArray { key, value } = &result.types[0] else {
1376            panic!("expected TNonEmptyArray");
1377        };
1378        assert!(matches!(key.types[0], Atomic::TString));
1379        assert!(matches!(value.types[0], Atomic::TString));
1380    }
1381
1382    #[test]
1383    fn substitute_non_empty_list_value() {
1384        let ty = Type::single(Atomic::TNonEmptyList {
1385            value: Box::new(t_param("T")),
1386        });
1387        let result = ty.substitute_templates(&bindings_t_string());
1388        let Atomic::TNonEmptyList { value } = &result.types[0] else {
1389            panic!("expected TNonEmptyList");
1390        };
1391        assert!(matches!(value.types[0], Atomic::TString));
1392    }
1393
1394    #[test]
1395    fn substitute_keyed_array_property_types() {
1396        use crate::atomic::{ArrayKey, KeyedProperty};
1397        use indexmap::IndexMap;
1398        let mut props = IndexMap::new();
1399        props.insert(
1400            ArrayKey::String(Arc::from("name")),
1401            KeyedProperty {
1402                ty: t_param("T"),
1403                optional: false,
1404            },
1405        );
1406        props.insert(
1407            ArrayKey::String(Arc::from("tag")),
1408            KeyedProperty {
1409                ty: t_param("T"),
1410                optional: true,
1411            },
1412        );
1413        let ty = Type::single(Atomic::TKeyedArray {
1414            properties: props,
1415            is_open: true,
1416            is_list: false,
1417        });
1418        let result = ty.substitute_templates(&bindings_t_string());
1419        let Atomic::TKeyedArray {
1420            properties,
1421            is_open,
1422            is_list,
1423        } = &result.types[0]
1424        else {
1425            panic!("expected TKeyedArray");
1426        };
1427        assert!(is_open);
1428        assert!(!is_list);
1429        assert!(matches!(
1430            properties[&ArrayKey::String(Arc::from("name"))].ty.types[0],
1431            Atomic::TString
1432        ));
1433        assert!(properties[&ArrayKey::String(Arc::from("tag"))].optional);
1434        assert!(matches!(
1435            properties[&ArrayKey::String(Arc::from("tag"))].ty.types[0],
1436            Atomic::TString
1437        ));
1438    }
1439
1440    #[test]
1441    fn substitute_callable_params_and_return() {
1442        use crate::atomic::FnParam;
1443        let ty = Type::single(Atomic::TCallable {
1444            params: Some(vec![FnParam {
1445                name: Name::new("x"),
1446                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1447                default: None,
1448                is_variadic: false,
1449                is_byref: false,
1450                is_optional: false,
1451            }]),
1452            return_type: Some(Box::new(t_param("T"))),
1453        });
1454        let result = ty.substitute_templates(&bindings_t_string());
1455        let Atomic::TCallable {
1456            params,
1457            return_type,
1458        } = &result.types[0]
1459        else {
1460            panic!("expected TCallable");
1461        };
1462        let param_ty = params.as_ref().unwrap()[0].ty.as_ref().unwrap();
1463        let param_union = param_ty.to_union();
1464        assert!(matches!(param_union.types[0], Atomic::TString));
1465        let ret = return_type.as_ref().unwrap();
1466        assert!(matches!(ret.types[0], Atomic::TString));
1467    }
1468
1469    #[test]
1470    fn substitute_callable_bare_no_panic() {
1471        // callable with no params/return — must not panic and must pass through unchanged
1472        let ty = Type::single(Atomic::TCallable {
1473            params: None,
1474            return_type: None,
1475        });
1476        let result = ty.substitute_templates(&bindings_t_string());
1477        assert!(matches!(
1478            result.types[0],
1479            Atomic::TCallable {
1480                params: None,
1481                return_type: None
1482            }
1483        ));
1484    }
1485
1486    #[test]
1487    fn substitute_closure_params_return_and_this() {
1488        use crate::atomic::FnParam;
1489        let ty = Type::single(Atomic::TClosure {
1490            params: vec![FnParam {
1491                name: Name::new("a"),
1492                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1493                default: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1494                is_variadic: true,
1495                is_byref: true,
1496                is_optional: true,
1497            }],
1498            return_type: Box::new(t_param("T")),
1499            this_type: Some(Box::new(t_param("T"))),
1500        });
1501        let result = ty.substitute_templates(&bindings_t_string());
1502        let Atomic::TClosure {
1503            params,
1504            return_type,
1505            this_type,
1506        } = &result.types[0]
1507        else {
1508            panic!("expected TClosure");
1509        };
1510        let p = &params[0];
1511        let ty_union = p.ty.as_ref().unwrap().to_union();
1512        let default_union = p.default.as_ref().unwrap().to_union();
1513        assert!(matches!(ty_union.types[0], Atomic::TString));
1514        assert!(matches!(default_union.types[0], Atomic::TString));
1515        // flags preserved
1516        assert!(p.is_variadic);
1517        assert!(p.is_byref);
1518        assert!(p.is_optional);
1519        assert!(matches!(return_type.types[0], Atomic::TString));
1520        assert!(matches!(
1521            this_type.as_ref().unwrap().types[0],
1522            Atomic::TString
1523        ));
1524    }
1525
1526    #[test]
1527    fn substitute_conditional_all_branches() {
1528        let ty = Type::single(Atomic::TConditional {
1529            param_name: None,
1530            subject: Box::new(t_param("T")),
1531            if_true: Box::new(t_param("T")),
1532            if_false: Box::new(Type::single(Atomic::TInt)),
1533        });
1534        let result = ty.substitute_templates(&bindings_t_string());
1535        let Atomic::TConditional {
1536            param_name: _,
1537            subject,
1538            if_true,
1539            if_false,
1540        } = &result.types[0]
1541        else {
1542            panic!("expected TConditional");
1543        };
1544        assert!(matches!(subject.types[0], Atomic::TString));
1545        assert!(matches!(if_true.types[0], Atomic::TString));
1546        assert!(matches!(if_false.types[0], Atomic::TInt));
1547    }
1548
1549    #[test]
1550    fn resolve_conditional_is_null_non_null_arg() {
1551        let ty = Type::single(Atomic::TConditional {
1552            param_name: Some(Name::new("x")),
1553            subject: Box::new(Type::single(Atomic::TNull)),
1554            if_true: Box::new(Type::single(Atomic::TInt)),
1555            if_false: Box::new(Type::single(Atomic::TString)),
1556        });
1557        let result = ty.resolve_conditional_returns(|name| {
1558            if name == "x" {
1559                Some(Type::single(Atomic::TString)) // definitely not null
1560            } else {
1561                None
1562            }
1563        });
1564        assert!(result.types.len() == 1);
1565        assert!(matches!(result.types[0], Atomic::TString));
1566    }
1567
1568    #[test]
1569    fn resolve_conditional_is_null_null_arg() {
1570        let ty = Type::single(Atomic::TConditional {
1571            param_name: Some(Name::new("x")),
1572            subject: Box::new(Type::single(Atomic::TNull)),
1573            if_true: Box::new(Type::single(Atomic::TInt)),
1574            if_false: Box::new(Type::single(Atomic::TString)),
1575        });
1576        let result = ty.resolve_conditional_returns(|name| {
1577            if name == "x" {
1578                Some(Type::single(Atomic::TNull)) // definitely null
1579            } else {
1580                None
1581            }
1582        });
1583        assert!(result.types.len() == 1);
1584        assert!(matches!(result.types[0], Atomic::TInt));
1585    }
1586
1587    #[test]
1588    fn resolve_conditional_is_null_nullable_arg_widens_to_branch_union() {
1589        let mut nullable_str = Type::single(Atomic::TString);
1590        nullable_str.add_type(Atomic::TNull);
1591        let ty = Type::single(Atomic::TConditional {
1592            param_name: Some(Name::new("x")),
1593            subject: Box::new(Type::single(Atomic::TNull)),
1594            if_true: Box::new(Type::single(Atomic::TInt)),
1595            if_false: Box::new(Type::single(Atomic::TString)),
1596        });
1597        let result = ty.resolve_conditional_returns(|name| {
1598            if name == "x" {
1599                Some(nullable_str.clone())
1600            } else {
1601                None
1602            }
1603        });
1604        // uncertain discriminator → widen to if_true | if_false
1605        assert_eq!(result.types.len(), 2);
1606        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
1607        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
1608    }
1609
1610    #[test]
1611    fn resolve_conditional_nested_widens_inner_branch() {
1612        // ($x is null ? int : ($x is string ? string : float))
1613        // When $x is unknown, should widen to int|string|float (no TConditional remaining).
1614        let inner = Type::single(Atomic::TConditional {
1615            param_name: Some(Name::new("x")),
1616            subject: Box::new(Type::single(Atomic::TString)),
1617            if_true: Box::new(Type::single(Atomic::TString)),
1618            if_false: Box::new(Type::single(Atomic::TFloat)),
1619        });
1620        let ty = Type::single(Atomic::TConditional {
1621            param_name: Some(Name::new("x")),
1622            subject: Box::new(Type::single(Atomic::TNull)),
1623            if_true: Box::new(Type::single(Atomic::TInt)),
1624            if_false: Box::new(inner),
1625        });
1626        // unknown arg → widen both outer branches, inner conditional must also be widened
1627        let result = ty.resolve_conditional_returns(|_| None);
1628        assert!(
1629            result
1630                .types
1631                .iter()
1632                .all(|t| !matches!(t, Atomic::TConditional { .. })),
1633            "no TConditional should survive: {:?}",
1634            result.types
1635        );
1636        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
1637        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
1638        assert!(result.types.iter().any(|t| matches!(t, Atomic::TFloat)));
1639    }
1640
1641    #[test]
1642    fn resolve_conditional_nested_resolves_inner_branch() {
1643        // ($x is null ? int : ($x is string ? string : float))
1644        // When $x is definitely not null but unknown string-or-not → resolves outer to inner,
1645        // then inner must also be resolved.
1646        let inner = Type::single(Atomic::TConditional {
1647            param_name: Some(Name::new("x")),
1648            subject: Box::new(Type::single(Atomic::TString)),
1649            if_true: Box::new(Type::single(Atomic::TString)),
1650            if_false: Box::new(Type::single(Atomic::TFloat)),
1651        });
1652        let ty = Type::single(Atomic::TConditional {
1653            param_name: Some(Name::new("x")),
1654            subject: Box::new(Type::single(Atomic::TNull)),
1655            if_true: Box::new(Type::single(Atomic::TInt)),
1656            if_false: Box::new(inner),
1657        });
1658        // $x = string → outer: not null → if_false (inner); inner: is string → if_true = string
1659        let result = ty.resolve_conditional_returns(|name| {
1660            if name == "x" {
1661                Some(Type::single(Atomic::TString))
1662            } else {
1663                None
1664            }
1665        });
1666        assert!(
1667            result
1668                .types
1669                .iter()
1670                .all(|t| !matches!(t, Atomic::TConditional { .. })),
1671            "no TConditional should survive: {:?}",
1672            result.types
1673        );
1674        assert_eq!(result.types.len(), 1);
1675        assert!(matches!(result.types[0], Atomic::TString));
1676    }
1677
1678    #[test]
1679    fn substitute_intersection_parts() {
1680        let ty = Type::single(Atomic::TIntersection {
1681            parts: vec_to_type_params(vec![
1682                Type::single(Atomic::TNamedObject {
1683                    fqcn: Name::new("Countable"),
1684                    type_params: empty_type_params(),
1685                }),
1686                t_param("T"),
1687            ]),
1688        });
1689        let result = ty.substitute_templates(&bindings_t_string());
1690        let Atomic::TIntersection { parts } = &result.types[0] else {
1691            panic!("expected TIntersection");
1692        };
1693        assert_eq!(parts.len(), 2);
1694        assert!(matches!(parts[0].types[0], Atomic::TNamedObject { .. }));
1695        assert!(matches!(parts[1].types[0], Atomic::TString));
1696    }
1697
1698    #[test]
1699    fn substitute_no_template_params_identity() {
1700        let ty = Type::single(Atomic::TInt);
1701        let result = ty.substitute_templates(&bindings_t_string());
1702        assert!(matches!(result.types[0], Atomic::TInt));
1703    }
1704}