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.
259        // Remove array{} if we already have an array<K, V> with matching structure.
260        if let Atomic::TKeyedArray { properties, .. } = &atomic {
261            if properties.is_empty() {
262                // Check if any existing type is a generic array variant
263                for existing in &self.types {
264                    match existing {
265                        Atomic::TArray { .. } | Atomic::TNonEmptyArray { .. } => {
266                            return; // Don't add empty array, it's subsumed
267                        }
268                        _ => {}
269                    }
270                }
271            }
272        }
273
274        // When adding a generic array, remove any empty keyed arrays since they're subtypes.
275        let is_generic_array = matches!(
276            &atomic,
277            Atomic::TArray { .. } | Atomic::TNonEmptyArray { .. }
278        );
279        if is_generic_array {
280            self.types.retain(|t| {
281                if let Atomic::TKeyedArray { properties, .. } = t {
282                    !properties.is_empty()
283                } else {
284                    true
285                }
286            });
287        }
288
289        self.types.push(atomic);
290    }
291
292    // --- Narrowing -----------------------------------------------------------
293
294    /// Remove `null` from the union (e.g. after a null check).
295    pub fn remove_null(&self) -> Type {
296        self.filter(|t| !matches!(t, Atomic::TNull))
297    }
298
299    /// Remove `false` from the union.
300    pub fn remove_false(&self) -> Type {
301        self.filter(|t| !matches!(t, Atomic::TFalse | Atomic::TBool))
302    }
303
304    /// Remove both `null` and `false` from the union (core type without nullable/falsy variants).
305    pub fn core_type(&self) -> Type {
306        self.remove_null().remove_false()
307    }
308
309    /// Keep only truthy atomics (e.g. after `if ($x)`).
310    pub fn narrow_to_truthy(&self) -> Type {
311        if self.is_mixed() {
312            return Type::mixed();
313        }
314        let narrowed = self.filter(|t| t.can_be_truthy());
315        // Remove specific falsy literals from string/int
316        narrowed.filter(|t| match t {
317            Atomic::TLiteralInt(0) => false,
318            Atomic::TLiteralString(s) if s.as_ref() == "" || s.as_ref() == "0" => false,
319            Atomic::TLiteralFloat(0, 0) => false,
320            _ => true,
321        })
322    }
323
324    /// Keep only falsy atomics (e.g. after `if (!$x)`).
325    pub fn narrow_to_falsy(&self) -> Type {
326        if self.is_mixed() {
327            return Type::from_vec(vec![
328                Atomic::TNull,
329                Atomic::TFalse,
330                Atomic::TLiteralInt(0),
331                Atomic::TLiteralString("".into()),
332            ]);
333        }
334        self.filter(|t| t.can_be_falsy())
335    }
336
337    /// Narrow this type as if `$x instanceof ClassName` is true.
338    ///
339    /// The instanceof check guarantees the value IS an instance of `class`, so we
340    /// replace any object / mixed constituents with the specific named object.  Scalar
341    /// constituents are dropped (they can never satisfy instanceof).
342    pub fn narrow_instanceof(&self, class: &str) -> Type {
343        let narrowed_ty = Atomic::TNamedObject {
344            fqcn: class.into(),
345            type_params: empty_type_params(),
346        };
347        // If any constituent is an object-like type, the result is the specific class.
348        let has_object = self.types.iter().any(|t| {
349            matches!(
350                t,
351                Atomic::TObject | Atomic::TNamedObject { .. } | Atomic::TMixed | Atomic::TNull // null fails instanceof, but mixed/object may include null
352            )
353        });
354        if has_object || self.is_empty() {
355            Type::single(narrowed_ty)
356        } else {
357            // Pure scalars — instanceof is always false here, but return the class
358            // defensively so callers don't see an empty union.
359            Type::single(narrowed_ty)
360        }
361    }
362
363    /// Narrow as if `is_string($x)` is true.
364    pub fn narrow_to_string(&self) -> Type {
365        self.filter(|t| t.is_string() || matches!(t, Atomic::TMixed | Atomic::TScalar))
366    }
367
368    /// Narrow as if `is_int($x)` is true.
369    pub fn narrow_to_int(&self) -> Type {
370        self.filter(|t| {
371            t.is_int() || matches!(t, Atomic::TMixed | Atomic::TScalar | Atomic::TNumeric)
372        })
373    }
374
375    /// Narrow as if `is_float($x)` is true.
376    pub fn narrow_to_float(&self) -> Type {
377        self.filter(|t| {
378            matches!(
379                t,
380                Atomic::TFloat
381                    | Atomic::TLiteralFloat(..)
382                    | Atomic::TMixed
383                    | Atomic::TScalar
384                    | Atomic::TNumeric
385            )
386        })
387    }
388
389    /// Narrow as if `is_bool($x)` is true.
390    pub fn narrow_to_bool(&self) -> Type {
391        self.filter(|t| {
392            matches!(
393                t,
394                Atomic::TBool | Atomic::TTrue | Atomic::TFalse | Atomic::TMixed | Atomic::TScalar
395            )
396        })
397    }
398
399    /// Narrow as if `is_null($x)` is true.
400    pub fn narrow_to_null(&self) -> Type {
401        self.filter(|t| matches!(t, Atomic::TNull | Atomic::TMixed))
402    }
403
404    /// Narrow as if `is_array($x)` is true.
405    pub fn narrow_to_array(&self) -> Type {
406        self.filter(|t| t.is_array() || matches!(t, Atomic::TMixed))
407    }
408
409    /// Narrow as if `is_object($x)` is true.
410    pub fn narrow_to_object(&self) -> Type {
411        self.filter(|t| t.is_object() || matches!(t, Atomic::TMixed))
412    }
413
414    /// Narrow as if `is_callable($x)` is true.
415    pub fn narrow_to_callable(&self) -> Type {
416        self.filter(|t| t.is_callable() || matches!(t, Atomic::TMixed))
417    }
418
419    /// Narrow as if `is_scalar($x)` is true (int | string | float | bool).
420    pub fn narrow_to_scalar(&self) -> Type {
421        self.filter(|t| {
422            matches!(
423                t,
424                Atomic::TString
425                    | Atomic::TLiteralString(..)
426                    | Atomic::TNumericString
427                    | Atomic::TInt
428                    | Atomic::TLiteralInt(..)
429                    | Atomic::TFloat
430                    | Atomic::TLiteralFloat(..)
431                    | Atomic::TBool
432                    | Atomic::TTrue
433                    | Atomic::TFalse
434                    | Atomic::TScalar
435                    | Atomic::TMixed
436            )
437        })
438    }
439
440    /// Narrow as if `is_iterable($x)` is true (array | Traversable).
441    /// For simplicity, this narrows to arrays or objects (can't easily verify interfaces).
442    pub fn narrow_to_iterable(&self) -> Type {
443        self.filter(|t| t.is_array() || t.is_object() || matches!(t, Atomic::TMixed))
444    }
445
446    /// Narrow as if `is_countable($x)` is true (array | Countable).
447    /// For simplicity, this narrows to arrays or objects (can't easily verify Countable interface).
448    pub fn narrow_to_countable(&self) -> Type {
449        self.filter(|t| t.is_array() || t.is_object() || matches!(t, Atomic::TMixed))
450    }
451
452    /// Narrow as if `is_resource($x)` is true.
453    /// Note: No TResource atomic type exists in the type system; this is a no-op.
454    /// Resources are declining in modern PHP and not actively tracked.
455    pub fn narrow_to_resource(&self) -> Type {
456        // No resource type in the system; just return mixed (allows any type)
457        self.filter(|t| matches!(t, Atomic::TMixed))
458    }
459
460    // --- Merge (branch join) ------------------------------------------------
461
462    /// Merge two unions at a branch join point (e.g. after if/else).
463    /// The result is the union of all types in both.
464    pub fn merge(a: &Type, b: &Type) -> Type {
465        // Fast path: b is empty — nothing to add.
466        if b.types.is_empty() {
467            let mut result = a.clone();
468            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
469            return result;
470        }
471        // Fast path: a is empty — clone b.
472        if a.types.is_empty() {
473            let mut result = b.clone();
474            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
475            return result;
476        }
477        // Fast path: a is already mixed — b cannot widen it further.
478        if a.types.len() == 1 && matches!(a.types[0], Atomic::TMixed) {
479            let mut result = a.clone();
480            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
481            return result;
482        }
483        // Fast path: b contains mixed — result collapses to mixed.
484        if b.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
485            return Type {
486                types: smallvec::smallvec![Atomic::TMixed],
487                possibly_undefined: a.possibly_undefined || b.possibly_undefined,
488                from_docblock: a.from_docblock || b.from_docblock,
489            };
490        }
491        let mut result = a.clone();
492        result.merge_with(b);
493        result
494    }
495
496    /// Merge `other` into `self` in-place (avoids cloning `self`).
497    pub fn merge_with(&mut self, other: &Type) {
498        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
499            self.possibly_undefined |= other.possibly_undefined;
500            return;
501        }
502        if other.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
503            self.types.clear();
504            self.types.push(Atomic::TMixed);
505            self.possibly_undefined |= other.possibly_undefined;
506            return;
507        }
508        for atomic in &other.types {
509            self.add_type(atomic.clone());
510        }
511        self.possibly_undefined |= other.possibly_undefined;
512    }
513
514    /// Intersect with another union: keep only types present in `other`, widening
515    /// where `self` contains `mixed` (which is compatible with everything).
516    /// Used for match-arm subject narrowing.
517    pub fn intersect_with(&self, other: &Type) -> Type {
518        if self.is_mixed() {
519            return other.clone();
520        }
521        if other.is_mixed() {
522            return self.clone();
523        }
524        // Keep atomics from self that are also in other (by equality or subtype)
525        let mut result = Type::empty();
526        for a in &self.types {
527            for b in &other.types {
528                if a == b || atomic_subtype(a, b) || atomic_subtype(b, a) {
529                    result.add_type(a.clone());
530                    break;
531                }
532            }
533        }
534        if result.is_empty() {
535            Type::never()
536        } else {
537            result
538        }
539    }
540
541    // --- Template substitution ----------------------------------------------
542
543    /// Replace template param references with their resolved types.
544    pub fn substitute_templates(&self, bindings: &FxHashMap<Name, Type>) -> Type {
545        if bindings.is_empty() {
546            return self.clone();
547        }
548        let mut result = Type::empty();
549        result.possibly_undefined = self.possibly_undefined;
550        result.from_docblock = self.from_docblock;
551        for atomic in &self.types {
552            match atomic {
553                Atomic::TTemplateParam { name, .. } => {
554                    if let Some(resolved) = bindings.get(name) {
555                        for t in &resolved.types {
556                            result.add_type(t.clone());
557                        }
558                    } else {
559                        result.add_type(atomic.clone());
560                    }
561                }
562                Atomic::TArray { key, value } => {
563                    result.add_type(Atomic::TArray {
564                        key: Box::new(key.substitute_templates(bindings)),
565                        value: Box::new(value.substitute_templates(bindings)),
566                    });
567                }
568                Atomic::TList { value } => {
569                    result.add_type(Atomic::TList {
570                        value: Box::new(value.substitute_templates(bindings)),
571                    });
572                }
573                Atomic::TNonEmptyArray { key, value } => {
574                    result.add_type(Atomic::TNonEmptyArray {
575                        key: Box::new(key.substitute_templates(bindings)),
576                        value: Box::new(value.substitute_templates(bindings)),
577                    });
578                }
579                Atomic::TNonEmptyList { value } => {
580                    result.add_type(Atomic::TNonEmptyList {
581                        value: Box::new(value.substitute_templates(bindings)),
582                    });
583                }
584                Atomic::TKeyedArray {
585                    properties,
586                    is_open,
587                    is_list,
588                } => {
589                    use crate::atomic::KeyedProperty;
590                    let new_props = properties
591                        .iter()
592                        .map(|(k, prop)| {
593                            (
594                                k.clone(),
595                                KeyedProperty {
596                                    ty: prop.ty.substitute_templates(bindings),
597                                    optional: prop.optional,
598                                },
599                            )
600                        })
601                        .collect();
602                    result.add_type(Atomic::TKeyedArray {
603                        properties: new_props,
604                        is_open: *is_open,
605                        is_list: *is_list,
606                    });
607                }
608                Atomic::TCallable {
609                    params,
610                    return_type,
611                } => {
612                    result.add_type(Atomic::TCallable {
613                        params: params.as_ref().map(|ps| {
614                            ps.iter()
615                                .map(|p| substitute_in_fn_param(p, bindings))
616                                .collect()
617                        }),
618                        return_type: return_type
619                            .as_ref()
620                            .map(|r| Box::new(r.substitute_templates(bindings))),
621                    });
622                }
623                Atomic::TClosure {
624                    params,
625                    return_type,
626                    this_type,
627                } => {
628                    result.add_type(Atomic::TClosure {
629                        params: params
630                            .iter()
631                            .map(|p| substitute_in_fn_param(p, bindings))
632                            .collect(),
633                        return_type: Box::new(return_type.substitute_templates(bindings)),
634                        this_type: this_type
635                            .as_ref()
636                            .map(|t| Box::new(t.substitute_templates(bindings))),
637                    });
638                }
639                Atomic::TConditional {
640                    param_name,
641                    subject,
642                    if_true,
643                    if_false,
644                } => {
645                    let new_subject = subject.substitute_templates(bindings);
646                    let new_if_true = if_true.substitute_templates(bindings);
647                    let new_if_false = if_false.substitute_templates(bindings);
648
649                    // If param_name names a template that is bound in this substitution,
650                    // resolve the conditional immediately (same logic as
651                    // `resolve_conditional_returns` for the $param form).
652                    let resolved = if let Some(name) = param_name {
653                        if let Some(bound) = bindings.get(name) {
654                            let subject_is_null = new_subject.types.len() == 1
655                                && matches!(new_subject.types[0], Atomic::TNull);
656                            if subject_is_null {
657                                let only_null = !bound.types.is_empty()
658                                    && bound.types.iter().all(|t| matches!(t, Atomic::TNull));
659                                let has_null =
660                                    bound.types.iter().any(|t| matches!(t, Atomic::TNull));
661                                if only_null {
662                                    Some(new_if_true.clone())
663                                } else if !has_null {
664                                    Some(new_if_false.clone())
665                                } else {
666                                    None
667                                }
668                            } else {
669                                None
670                            }
671                        } else {
672                            None
673                        }
674                    } else {
675                        None
676                    };
677
678                    if let Some(branch) = resolved {
679                        for t in branch.types {
680                            result.add_type(t);
681                        }
682                    } else {
683                        result.add_type(Atomic::TConditional {
684                            param_name: *param_name,
685                            subject: Box::new(new_subject),
686                            if_true: Box::new(new_if_true),
687                            if_false: Box::new(new_if_false),
688                        });
689                    }
690                }
691                Atomic::TIntersection { parts } => {
692                    result.add_type(Atomic::TIntersection {
693                        parts: vec_to_type_params(
694                            parts
695                                .iter()
696                                .map(|p| p.substitute_templates(bindings))
697                                .collect(),
698                        ),
699                    });
700                }
701                Atomic::TNamedObject { fqcn, type_params } => {
702                    // TODO: the docblock parser emits TNamedObject { fqcn: "T" } for bare @return T
703                    // annotations instead of TTemplateParam, because it lacks template context at
704                    // parse time. This block works around that by treating bare unqualified names
705                    // as template param references when they appear in the binding map. Proper fix:
706                    // make the docblock parser template-aware so it emits TTemplateParam directly.
707                    // See issue #26 for context.
708                    if type_params.is_empty() && !fqcn.contains('\\') {
709                        if let Some(resolved) = bindings.get(fqcn) {
710                            for t in &resolved.types {
711                                result.add_type(t.clone());
712                            }
713                            continue;
714                        }
715                    }
716                    let new_params: Vec<Type> = type_params
717                        .iter()
718                        .map(|p| p.substitute_templates(bindings))
719                        .collect();
720                    result.add_type(Atomic::TNamedObject {
721                        fqcn: *fqcn,
722                        type_params: vec_to_type_params(new_params),
723                    });
724                }
725                // class-string<T> → substitute T from bindings
726                Atomic::TClassString(Some(param_name)) => {
727                    if let Some(resolved) = bindings.get(param_name) {
728                        for r_atomic in &resolved.types {
729                            let cls_name = if let Atomic::TNamedObject { fqcn, .. } = r_atomic {
730                                Some(*fqcn)
731                            } else {
732                                None
733                            };
734                            result.add_type(Atomic::TClassString(cls_name));
735                        }
736                    } else {
737                        result.add_type(atomic.clone());
738                    }
739                }
740                _ => {
741                    result.add_type(atomic.clone());
742                }
743            }
744        }
745        result
746    }
747
748    /// Resolves `TConditional` atoms whose discriminator is known at the call site.
749    ///
750    /// `lookup(param_name)` returns the call-site argument type for the named parameter,
751    /// or `None` if the argument is not available. Currently only `is null` conditions
752    /// are resolved; other condition types pass through unchanged.
753    pub fn resolve_conditional_returns<F>(self, lookup: F) -> Type
754    where
755        F: Fn(&str) -> Option<Type>,
756    {
757        let mut result = Type::empty();
758        for atomic in self.types {
759            match atomic {
760                Atomic::TConditional {
761                    ref param_name,
762                    ref subject,
763                    ref if_true,
764                    ref if_false,
765                } => {
766                    // Only handle `is null` for now — the dominant case for Prophecy FPs.
767                    let subject_is_null =
768                        subject.types.len() == 1 && matches!(subject.types[0], Atomic::TNull);
769                    let resolved = if subject_is_null {
770                        if let Some(name) = param_name {
771                            if let Some(arg_ty) = lookup(name.as_ref()) {
772                                let has_null =
773                                    arg_ty.types.iter().any(|t| matches!(t, Atomic::TNull));
774                                let only_null = !arg_ty.types.is_empty()
775                                    && arg_ty.types.iter().all(|t| matches!(t, Atomic::TNull));
776                                if only_null {
777                                    Some((**if_true).clone())
778                                } else if !has_null {
779                                    Some((**if_false).clone())
780                                } else {
781                                    None
782                                }
783                            } else {
784                                None
785                            }
786                        } else {
787                            None
788                        }
789                    } else {
790                        None
791                    };
792
793                    if let Some(branch) = resolved {
794                        for t in branch.types {
795                            result.add_type(t);
796                        }
797                    } else {
798                        // Cannot resolve at this call site: widen to the union of both branches
799                        // so downstream callers see a concrete type rather than an opaque conditional.
800                        for t in if_true.types.iter() {
801                            result.add_type(t.clone());
802                        }
803                        for t in if_false.types.iter() {
804                            result.add_type(t.clone());
805                        }
806                    }
807                }
808                other => result.add_type(other),
809            }
810        }
811        result
812    }
813
814    // --- Subtype check -------------------------------------------------------
815
816    /// Returns true if every atomic in `self` is a subtype of some atomic in `other`,
817    /// using **only structural rules** — no `extends` / `implements` walk.
818    ///
819    /// Two distinct user-defined classes are never related here, even when one
820    /// extends the other. Within `mir-analyzer`, when a `db` is in scope,
821    /// prefer `crate::subtype::is_subtype(db, sub, sup)` which layers
822    /// inheritance resolution on top of this check.
823    pub fn is_subtype_structural(&self, other: &Type) -> bool {
824        if other.is_mixed() {
825            return true;
826        }
827        if self.is_never() {
828            return true; // never <: everything
829        }
830        self.types
831            .iter()
832            .all(|a| other.types.iter().any(|b| atomic_subtype(a, b)))
833    }
834
835    // --- Utilities ----------------------------------------------------------
836
837    fn filter<F: Fn(&Atomic) -> bool>(&self, f: F) -> Type {
838        let mut result = Type::empty();
839        result.possibly_undefined = self.possibly_undefined;
840        result.from_docblock = self.from_docblock;
841        for atomic in &self.types {
842            if f(atomic) {
843                result.types.push(atomic.clone());
844            }
845        }
846        result
847    }
848
849    /// Mark this union as possibly-undefined and return it.
850    pub fn possibly_undefined(mut self) -> Self {
851        self.possibly_undefined = true;
852        self
853    }
854
855    /// Mark this union as coming from a docblock annotation.
856    pub fn from_docblock(mut self) -> Self {
857        self.from_docblock = true;
858        self
859    }
860}
861
862// ---------------------------------------------------------------------------
863// Template substitution helpers
864// ---------------------------------------------------------------------------
865
866fn substitute_in_fn_param(
867    p: &crate::atomic::FnParam,
868    bindings: &FxHashMap<Name, Type>,
869) -> crate::atomic::FnParam {
870    crate::atomic::FnParam {
871        name: p.name,
872        ty: p.ty.as_ref().map(|t| {
873            let u = t.to_union();
874            let substituted = u.substitute_templates(bindings);
875            crate::compact::SimpleType::from_union(substituted)
876        }),
877        default: p.default.as_ref().map(|d| {
878            let u = d.to_union();
879            let substituted = u.substitute_templates(bindings);
880            crate::compact::SimpleType::from_union(substituted)
881        }),
882        is_variadic: p.is_variadic,
883        is_byref: p.is_byref,
884        is_optional: p.is_optional,
885    }
886}
887
888// ---------------------------------------------------------------------------
889// Atomic subtype (no codebase — structural check only)
890// ---------------------------------------------------------------------------
891
892fn atomic_subtype(sub: &Atomic, sup: &Atomic) -> bool {
893    if sub == sup {
894        return true;
895    }
896    match (sub, sup) {
897        // Bottom type
898        (Atomic::TNever, _) => true,
899        // Top types — anything goes in both directions for mixed
900        (_, Atomic::TMixed) => true,
901        (Atomic::TMixed, _) => true,
902
903        // Scalars
904        (Atomic::TLiteralInt(_), Atomic::TInt) => true,
905        (Atomic::TLiteralInt(_), Atomic::TNumeric) => true,
906        (Atomic::TLiteralInt(_), Atomic::TScalar) => true,
907        (Atomic::TLiteralInt(n), Atomic::TPositiveInt) => *n > 0,
908        (Atomic::TLiteralInt(n), Atomic::TNonNegativeInt) => *n >= 0,
909        (Atomic::TLiteralInt(n), Atomic::TNegativeInt) => *n < 0,
910        (Atomic::TPositiveInt, Atomic::TInt) => true,
911        (Atomic::TPositiveInt, Atomic::TNonNegativeInt) => true,
912        (Atomic::TNegativeInt, Atomic::TInt) => true,
913        (Atomic::TNonNegativeInt, Atomic::TInt) => true,
914        (Atomic::TIntRange { .. }, Atomic::TInt) => true,
915
916        (Atomic::TLiteralFloat(..), Atomic::TFloat) => true,
917        (Atomic::TLiteralFloat(..), Atomic::TNumeric) => true,
918        (Atomic::TLiteralFloat(..), Atomic::TScalar) => true,
919
920        (Atomic::TLiteralString(s), Atomic::TString) => {
921            let _ = s;
922            true
923        }
924        (Atomic::TLiteralString(s), Atomic::TCallableString) => {
925            let _ = s;
926            true
927        }
928        (Atomic::TLiteralString(s), Atomic::TNonEmptyString) => !s.is_empty(),
929        (Atomic::TLiteralString(_), Atomic::TScalar) => true,
930        (Atomic::TNonEmptyString, Atomic::TString) => true,
931        (Atomic::TCallableString, Atomic::TString) => true,
932        (Atomic::TNumericString, Atomic::TString) => true,
933        (Atomic::TClassString(_), Atomic::TString) => true,
934        (Atomic::TInterfaceString, Atomic::TString) => true,
935        (Atomic::TEnumString, Atomic::TString) => true,
936        (Atomic::TTraitString, Atomic::TString) => true,
937
938        (Atomic::TTrue, Atomic::TBool) => true,
939        (Atomic::TFalse, Atomic::TBool) => true,
940
941        (Atomic::TInt, Atomic::TNumeric) => true,
942        (Atomic::TFloat, Atomic::TNumeric) => true,
943        (Atomic::TNumericString, Atomic::TNumeric) => true,
944
945        (Atomic::TInt, Atomic::TScalar) => true,
946        (Atomic::TFloat, Atomic::TScalar) => true,
947        (Atomic::TString, Atomic::TScalar) => true,
948        (Atomic::TBool, Atomic::TScalar) => true,
949        (Atomic::TNumeric, Atomic::TScalar) => true,
950        (Atomic::TTrue, Atomic::TScalar) => true,
951        (Atomic::TFalse, Atomic::TScalar) => true,
952
953        // Object hierarchy (structural, no codebase)
954        (Atomic::TNamedObject { .. }, Atomic::TObject) => true,
955        (Atomic::TStaticObject { .. }, Atomic::TObject) => true,
956        (Atomic::TSelf { .. }, Atomic::TObject) => true,
957        // self(X) and static(X) satisfy TNamedObject(X) with same FQCN
958        (Atomic::TSelf { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
959        (Atomic::TStaticObject { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
960        // TNamedObject(X) satisfies self(X) / static(X) with same FQCN
961        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TSelf { fqcn: b }) => a == b,
962        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TStaticObject { fqcn: b }) => a == b,
963        // Bare generic property accepts parameterized value: Box accepts Box<string>.
964        // The reverse is NOT true — bare Box value does not satisfy Box<string> property
965        // (invariant check). Only sup being bare (empty type_params) is the wildcard.
966        (
967            Atomic::TNamedObject {
968                fqcn: sub_fqcn,
969                type_params: sub_params,
970            },
971            Atomic::TNamedObject {
972                fqcn: sup_fqcn,
973                type_params: sup_params,
974            },
975        ) => sub_fqcn == sup_fqcn && (sup_params.is_empty() || sub_params == sup_params),
976
977        // Literal int widens to float in PHP
978        (Atomic::TLiteralInt(_), Atomic::TFloat) => true,
979        (Atomic::TPositiveInt, Atomic::TFloat) => true,
980        (Atomic::TInt, Atomic::TFloat) => true,
981
982        // Literal int satisfies int ranges
983        (Atomic::TLiteralInt(_), Atomic::TIntRange { .. }) => true,
984
985        // PHP callables: string and array are valid callable values
986        (Atomic::TString, Atomic::TCallable { .. }) => true,
987        (Atomic::TNonEmptyString, Atomic::TCallable { .. }) => true,
988        (Atomic::TLiteralString(_), Atomic::TCallable { .. }) => true,
989        (Atomic::TArray { .. }, Atomic::TCallable { .. }) => true,
990        (Atomic::TNonEmptyArray { .. }, Atomic::TCallable { .. }) => true,
991
992        // Closure <: callable, typed Closure <: Closure
993        (Atomic::TClosure { .. }, Atomic::TCallable { .. }) => true,
994        // callable <: Closure: callable is wider but not flagged at default error level
995        (Atomic::TCallable { .. }, Atomic::TClosure { .. }) => true,
996        // Any TClosure satisfies another TClosure (structural compatibility simplified)
997        (Atomic::TClosure { .. }, Atomic::TClosure { .. }) => true,
998        // callable <: callable (trivial)
999        (Atomic::TCallable { .. }, Atomic::TCallable { .. }) => true,
1000        // TClosure satisfies `Closure` named object or `object`
1001        (Atomic::TClosure { .. }, Atomic::TNamedObject { fqcn, .. }) => {
1002            fqcn.as_ref().eq_ignore_ascii_case("closure")
1003        }
1004        (Atomic::TClosure { .. }, Atomic::TObject) => true,
1005
1006        // List <: array
1007        (Atomic::TList { value }, Atomic::TArray { key, value: av }) => {
1008            // list key is always int
1009            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1010                && value.is_subtype_structural(av)
1011        }
1012        (Atomic::TNonEmptyList { value }, Atomic::TList { value: lv }) => {
1013            value.is_subtype_structural(lv)
1014        }
1015        // array<int, X> is accepted where list<X> or non-empty-list<X> expected
1016        (Atomic::TArray { key, value: av }, Atomic::TList { value: lv }) => {
1017            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1018                && av.is_subtype_structural(lv)
1019        }
1020        (Atomic::TArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1021            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1022                && av.is_subtype_structural(lv)
1023        }
1024        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TList { value: lv }) => {
1025            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1026                && av.is_subtype_structural(lv)
1027        }
1028        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1029            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1030                && av.is_subtype_structural(lv)
1031        }
1032        // TList <: TList value covariance
1033        (Atomic::TList { value: v1 }, Atomic::TList { value: v2 }) => v1.is_subtype_structural(v2),
1034        (Atomic::TNonEmptyArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1035            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1036        }
1037
1038        // array<A, B> <: array<C, D>  iff  A <: C && B <: D
1039        (Atomic::TArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1040            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1041        }
1042
1043        // A keyed/shape array (array{...} or array{}) is a subtype of any generic array.
1044        (Atomic::TKeyedArray { .. }, Atomic::TArray { .. }) => true,
1045
1046        // A list-shaped keyed array (is_list=true, all int keys) is a subtype of list<X>.
1047        (
1048            Atomic::TKeyedArray {
1049                properties,
1050                is_list,
1051                ..
1052            },
1053            Atomic::TList { value: lv },
1054        ) => *is_list && properties.values().all(|p| p.ty.is_subtype_structural(lv)),
1055        (
1056            Atomic::TKeyedArray {
1057                properties,
1058                is_list,
1059                ..
1060            },
1061            Atomic::TNonEmptyList { value: lv },
1062        ) => {
1063            *is_list
1064                && !properties.is_empty()
1065                && properties.values().all(|p| p.ty.is_subtype_structural(lv))
1066        }
1067
1068        // A template parameter T acts as a wildcard — any type satisfies it.
1069        (_, Atomic::TTemplateParam { .. }) => true,
1070
1071        _ => false,
1072    }
1073}
1074
1075// ---------------------------------------------------------------------------
1076// Tests
1077// ---------------------------------------------------------------------------
1078
1079#[cfg(test)]
1080mod tests {
1081    use std::sync::Arc;
1082
1083    use super::*;
1084
1085    #[test]
1086    fn single_is_single() {
1087        let u = Type::single(Atomic::TString);
1088        assert!(u.is_single());
1089        assert!(!u.is_nullable());
1090    }
1091
1092    #[test]
1093    fn nullable_has_null() {
1094        let u = Type::nullable(Atomic::TString);
1095        assert!(u.is_nullable());
1096        assert_eq!(u.types.len(), 2);
1097    }
1098
1099    #[test]
1100    fn add_type_deduplicates() {
1101        let mut u = Type::single(Atomic::TString);
1102        u.add_type(Atomic::TString);
1103        assert_eq!(u.types.len(), 1);
1104    }
1105
1106    #[test]
1107    fn add_type_literal_subsumed_by_base() {
1108        let mut u = Type::single(Atomic::TInt);
1109        u.add_type(Atomic::TLiteralInt(42));
1110        assert_eq!(u.types.len(), 1);
1111        assert!(matches!(u.types[0], Atomic::TInt));
1112    }
1113
1114    #[test]
1115    fn add_type_base_widens_literals() {
1116        let mut u = Type::single(Atomic::TLiteralInt(1));
1117        u.add_type(Atomic::TLiteralInt(2));
1118        u.add_type(Atomic::TInt);
1119        assert_eq!(u.types.len(), 1);
1120        assert!(matches!(u.types[0], Atomic::TInt));
1121    }
1122
1123    #[test]
1124    fn mixed_subsumes_everything() {
1125        let mut u = Type::single(Atomic::TString);
1126        u.add_type(Atomic::TMixed);
1127        assert_eq!(u.types.len(), 1);
1128        assert!(u.is_mixed());
1129    }
1130
1131    #[test]
1132    fn remove_null() {
1133        let u = Type::nullable(Atomic::TString);
1134        let narrowed = u.remove_null();
1135        assert!(!narrowed.is_nullable());
1136        assert_eq!(narrowed.types.len(), 1);
1137    }
1138
1139    #[test]
1140    fn narrow_to_truthy_removes_null_false() {
1141        let mut u = Type::empty();
1142        u.add_type(Atomic::TString);
1143        u.add_type(Atomic::TNull);
1144        u.add_type(Atomic::TFalse);
1145        let truthy = u.narrow_to_truthy();
1146        assert!(!truthy.is_nullable());
1147        assert!(!truthy.contains(|t| matches!(t, Atomic::TFalse)));
1148    }
1149
1150    #[test]
1151    fn merge_combines_types() {
1152        let a = Type::single(Atomic::TString);
1153        let b = Type::single(Atomic::TInt);
1154        let merged = Type::merge(&a, &b);
1155        assert_eq!(merged.types.len(), 2);
1156    }
1157
1158    #[test]
1159    fn subtype_literal_int_under_int() {
1160        let sub = Type::single(Atomic::TLiteralInt(5));
1161        let sup = Type::single(Atomic::TInt);
1162        assert!(sub.is_subtype_structural(&sup));
1163    }
1164
1165    #[test]
1166    fn subtype_never_is_bottom() {
1167        let never = Type::never();
1168        let string = Type::single(Atomic::TString);
1169        assert!(never.is_subtype_structural(&string));
1170    }
1171
1172    #[test]
1173    fn subtype_everything_under_mixed() {
1174        let string = Type::single(Atomic::TString);
1175        let mixed = Type::mixed();
1176        assert!(string.is_subtype_structural(&mixed));
1177    }
1178
1179    #[test]
1180    fn template_substitution() {
1181        let mut bindings = FxHashMap::default();
1182        bindings.insert(Name::new("T"), Type::single(Atomic::TString));
1183
1184        let tmpl = Type::single(Atomic::TTemplateParam {
1185            name: Name::new("T"),
1186            as_type: Box::new(Type::mixed()),
1187            defining_entity: Name::new("MyClass"),
1188        });
1189
1190        let resolved = tmpl.substitute_templates(&bindings);
1191        assert_eq!(resolved.types.len(), 1);
1192        assert!(matches!(resolved.types[0], Atomic::TString));
1193    }
1194
1195    #[test]
1196    fn intersection_is_object() {
1197        let parts = vec![
1198            Type::single(Atomic::TNamedObject {
1199                fqcn: Name::new("Iterator"),
1200                type_params: empty_type_params(),
1201            }),
1202            Type::single(Atomic::TNamedObject {
1203                fqcn: Name::new("Countable"),
1204                type_params: empty_type_params(),
1205            }),
1206        ];
1207        let atomic = Atomic::TIntersection {
1208            parts: vec_to_type_params(parts),
1209        };
1210        assert!(atomic.is_object());
1211        assert!(!atomic.can_be_falsy());
1212        assert!(atomic.can_be_truthy());
1213    }
1214
1215    #[test]
1216    fn intersection_display_two_parts() {
1217        let parts = vec![
1218            Type::single(Atomic::TNamedObject {
1219                fqcn: Name::new("Iterator"),
1220                type_params: empty_type_params(),
1221            }),
1222            Type::single(Atomic::TNamedObject {
1223                fqcn: Name::new("Countable"),
1224                type_params: empty_type_params(),
1225            }),
1226        ];
1227        let u = Type::single(Atomic::TIntersection {
1228            parts: vec_to_type_params(parts),
1229        });
1230        assert_eq!(format!("{u}"), "Iterator&Countable");
1231    }
1232
1233    #[test]
1234    fn intersection_display_three_parts() {
1235        let parts = vec![
1236            Type::single(Atomic::TNamedObject {
1237                fqcn: Name::new("A"),
1238                type_params: empty_type_params(),
1239            }),
1240            Type::single(Atomic::TNamedObject {
1241                fqcn: Name::new("B"),
1242                type_params: empty_type_params(),
1243            }),
1244            Type::single(Atomic::TNamedObject {
1245                fqcn: Name::new("C"),
1246                type_params: empty_type_params(),
1247            }),
1248        ];
1249        let u = Type::single(Atomic::TIntersection {
1250            parts: vec_to_type_params(parts),
1251        });
1252        assert_eq!(format!("{u}"), "A&B&C");
1253    }
1254
1255    #[test]
1256    fn intersection_in_nullable_union_display() {
1257        let intersection = Atomic::TIntersection {
1258            parts: vec_to_type_params(vec![
1259                Type::single(Atomic::TNamedObject {
1260                    fqcn: Name::new("Iterator"),
1261                    type_params: empty_type_params(),
1262                }),
1263                Type::single(Atomic::TNamedObject {
1264                    fqcn: Name::new("Countable"),
1265                    type_params: empty_type_params(),
1266                }),
1267            ]),
1268        };
1269        let mut u = Type::single(intersection);
1270        u.add_type(Atomic::TNull);
1271        assert!(u.is_nullable());
1272        assert!(u.contains(|t| matches!(t, Atomic::TIntersection { .. })));
1273    }
1274
1275    // --- substitute_templates coverage for previously-missing arms ----------
1276
1277    fn t_param(name: &str) -> Type {
1278        Type::single(Atomic::TTemplateParam {
1279            name: Name::new(name),
1280            as_type: Box::new(Type::mixed()),
1281            defining_entity: Name::new("Fn"),
1282        })
1283    }
1284
1285    fn bindings_t_string() -> FxHashMap<Name, Type> {
1286        let mut b = FxHashMap::default();
1287        b.insert(Name::new("T"), Type::single(Atomic::TString));
1288        b
1289    }
1290
1291    #[test]
1292    fn substitute_non_empty_array_key_and_value() {
1293        let ty = Type::single(Atomic::TNonEmptyArray {
1294            key: Box::new(t_param("T")),
1295            value: Box::new(t_param("T")),
1296        });
1297        let result = ty.substitute_templates(&bindings_t_string());
1298        assert_eq!(result.types.len(), 1);
1299        let Atomic::TNonEmptyArray { key, value } = &result.types[0] else {
1300            panic!("expected TNonEmptyArray");
1301        };
1302        assert!(matches!(key.types[0], Atomic::TString));
1303        assert!(matches!(value.types[0], Atomic::TString));
1304    }
1305
1306    #[test]
1307    fn substitute_non_empty_list_value() {
1308        let ty = Type::single(Atomic::TNonEmptyList {
1309            value: Box::new(t_param("T")),
1310        });
1311        let result = ty.substitute_templates(&bindings_t_string());
1312        let Atomic::TNonEmptyList { value } = &result.types[0] else {
1313            panic!("expected TNonEmptyList");
1314        };
1315        assert!(matches!(value.types[0], Atomic::TString));
1316    }
1317
1318    #[test]
1319    fn substitute_keyed_array_property_types() {
1320        use crate::atomic::{ArrayKey, KeyedProperty};
1321        use indexmap::IndexMap;
1322        let mut props = IndexMap::new();
1323        props.insert(
1324            ArrayKey::String(Arc::from("name")),
1325            KeyedProperty {
1326                ty: t_param("T"),
1327                optional: false,
1328            },
1329        );
1330        props.insert(
1331            ArrayKey::String(Arc::from("tag")),
1332            KeyedProperty {
1333                ty: t_param("T"),
1334                optional: true,
1335            },
1336        );
1337        let ty = Type::single(Atomic::TKeyedArray {
1338            properties: props,
1339            is_open: true,
1340            is_list: false,
1341        });
1342        let result = ty.substitute_templates(&bindings_t_string());
1343        let Atomic::TKeyedArray {
1344            properties,
1345            is_open,
1346            is_list,
1347        } = &result.types[0]
1348        else {
1349            panic!("expected TKeyedArray");
1350        };
1351        assert!(is_open);
1352        assert!(!is_list);
1353        assert!(matches!(
1354            properties[&ArrayKey::String(Arc::from("name"))].ty.types[0],
1355            Atomic::TString
1356        ));
1357        assert!(properties[&ArrayKey::String(Arc::from("tag"))].optional);
1358        assert!(matches!(
1359            properties[&ArrayKey::String(Arc::from("tag"))].ty.types[0],
1360            Atomic::TString
1361        ));
1362    }
1363
1364    #[test]
1365    fn substitute_callable_params_and_return() {
1366        use crate::atomic::FnParam;
1367        let ty = Type::single(Atomic::TCallable {
1368            params: Some(vec![FnParam {
1369                name: Name::new("x"),
1370                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1371                default: None,
1372                is_variadic: false,
1373                is_byref: false,
1374                is_optional: false,
1375            }]),
1376            return_type: Some(Box::new(t_param("T"))),
1377        });
1378        let result = ty.substitute_templates(&bindings_t_string());
1379        let Atomic::TCallable {
1380            params,
1381            return_type,
1382        } = &result.types[0]
1383        else {
1384            panic!("expected TCallable");
1385        };
1386        let param_ty = params.as_ref().unwrap()[0].ty.as_ref().unwrap();
1387        let param_union = param_ty.to_union();
1388        assert!(matches!(param_union.types[0], Atomic::TString));
1389        let ret = return_type.as_ref().unwrap();
1390        assert!(matches!(ret.types[0], Atomic::TString));
1391    }
1392
1393    #[test]
1394    fn substitute_callable_bare_no_panic() {
1395        // callable with no params/return — must not panic and must pass through unchanged
1396        let ty = Type::single(Atomic::TCallable {
1397            params: None,
1398            return_type: None,
1399        });
1400        let result = ty.substitute_templates(&bindings_t_string());
1401        assert!(matches!(
1402            result.types[0],
1403            Atomic::TCallable {
1404                params: None,
1405                return_type: None
1406            }
1407        ));
1408    }
1409
1410    #[test]
1411    fn substitute_closure_params_return_and_this() {
1412        use crate::atomic::FnParam;
1413        let ty = Type::single(Atomic::TClosure {
1414            params: vec![FnParam {
1415                name: Name::new("a"),
1416                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1417                default: Some(crate::compact::SimpleType::from_union(t_param("T"))),
1418                is_variadic: true,
1419                is_byref: true,
1420                is_optional: true,
1421            }],
1422            return_type: Box::new(t_param("T")),
1423            this_type: Some(Box::new(t_param("T"))),
1424        });
1425        let result = ty.substitute_templates(&bindings_t_string());
1426        let Atomic::TClosure {
1427            params,
1428            return_type,
1429            this_type,
1430        } = &result.types[0]
1431        else {
1432            panic!("expected TClosure");
1433        };
1434        let p = &params[0];
1435        let ty_union = p.ty.as_ref().unwrap().to_union();
1436        let default_union = p.default.as_ref().unwrap().to_union();
1437        assert!(matches!(ty_union.types[0], Atomic::TString));
1438        assert!(matches!(default_union.types[0], Atomic::TString));
1439        // flags preserved
1440        assert!(p.is_variadic);
1441        assert!(p.is_byref);
1442        assert!(p.is_optional);
1443        assert!(matches!(return_type.types[0], Atomic::TString));
1444        assert!(matches!(
1445            this_type.as_ref().unwrap().types[0],
1446            Atomic::TString
1447        ));
1448    }
1449
1450    #[test]
1451    fn substitute_conditional_all_branches() {
1452        let ty = Type::single(Atomic::TConditional {
1453            param_name: None,
1454            subject: Box::new(t_param("T")),
1455            if_true: Box::new(t_param("T")),
1456            if_false: Box::new(Type::single(Atomic::TInt)),
1457        });
1458        let result = ty.substitute_templates(&bindings_t_string());
1459        let Atomic::TConditional {
1460            param_name: _,
1461            subject,
1462            if_true,
1463            if_false,
1464        } = &result.types[0]
1465        else {
1466            panic!("expected TConditional");
1467        };
1468        assert!(matches!(subject.types[0], Atomic::TString));
1469        assert!(matches!(if_true.types[0], Atomic::TString));
1470        assert!(matches!(if_false.types[0], Atomic::TInt));
1471    }
1472
1473    #[test]
1474    fn resolve_conditional_is_null_non_null_arg() {
1475        let ty = Type::single(Atomic::TConditional {
1476            param_name: Some(Name::new("x")),
1477            subject: Box::new(Type::single(Atomic::TNull)),
1478            if_true: Box::new(Type::single(Atomic::TInt)),
1479            if_false: Box::new(Type::single(Atomic::TString)),
1480        });
1481        let result = ty.resolve_conditional_returns(|name| {
1482            if name == "x" {
1483                Some(Type::single(Atomic::TString)) // definitely not null
1484            } else {
1485                None
1486            }
1487        });
1488        assert!(result.types.len() == 1);
1489        assert!(matches!(result.types[0], Atomic::TString));
1490    }
1491
1492    #[test]
1493    fn resolve_conditional_is_null_null_arg() {
1494        let ty = Type::single(Atomic::TConditional {
1495            param_name: Some(Name::new("x")),
1496            subject: Box::new(Type::single(Atomic::TNull)),
1497            if_true: Box::new(Type::single(Atomic::TInt)),
1498            if_false: Box::new(Type::single(Atomic::TString)),
1499        });
1500        let result = ty.resolve_conditional_returns(|name| {
1501            if name == "x" {
1502                Some(Type::single(Atomic::TNull)) // definitely null
1503            } else {
1504                None
1505            }
1506        });
1507        assert!(result.types.len() == 1);
1508        assert!(matches!(result.types[0], Atomic::TInt));
1509    }
1510
1511    #[test]
1512    fn resolve_conditional_is_null_nullable_arg_widens_to_branch_union() {
1513        let mut nullable_str = Type::single(Atomic::TString);
1514        nullable_str.add_type(Atomic::TNull);
1515        let ty = Type::single(Atomic::TConditional {
1516            param_name: Some(Name::new("x")),
1517            subject: Box::new(Type::single(Atomic::TNull)),
1518            if_true: Box::new(Type::single(Atomic::TInt)),
1519            if_false: Box::new(Type::single(Atomic::TString)),
1520        });
1521        let result = ty.resolve_conditional_returns(|name| {
1522            if name == "x" {
1523                Some(nullable_str.clone())
1524            } else {
1525                None
1526            }
1527        });
1528        // uncertain discriminator → widen to if_true | if_false
1529        assert_eq!(result.types.len(), 2);
1530        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
1531        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
1532    }
1533
1534    #[test]
1535    fn substitute_intersection_parts() {
1536        let ty = Type::single(Atomic::TIntersection {
1537            parts: vec_to_type_params(vec![
1538                Type::single(Atomic::TNamedObject {
1539                    fqcn: Name::new("Countable"),
1540                    type_params: empty_type_params(),
1541                }),
1542                t_param("T"),
1543            ]),
1544        });
1545        let result = ty.substitute_templates(&bindings_t_string());
1546        let Atomic::TIntersection { parts } = &result.types[0] else {
1547            panic!("expected TIntersection");
1548        };
1549        assert_eq!(parts.len(), 2);
1550        assert!(matches!(parts[0].types[0], Atomic::TNamedObject { .. }));
1551        assert!(matches!(parts[1].types[0], Atomic::TString));
1552    }
1553
1554    #[test]
1555    fn substitute_no_template_params_identity() {
1556        let ty = Type::single(Atomic::TInt);
1557        let result = ty.substitute_templates(&bindings_t_string());
1558        assert!(matches!(result.types[0], Atomic::TInt));
1559    }
1560}