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/// Result of classifying a type for `clone` validity (see [`Type::clone_validity`]).
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum CloneValidity {
33    /// Every member is (or may be) an object — cloning is fine.
34    Cloneable,
35    /// Every member is definitely a non-object — cloning is an error.
36    Invalid,
37    /// Some members are non-objects, some are objects — cloning may be an error.
38    PossiblyInvalid,
39    /// Empty/unknown type — no diagnostic.
40    Unknown,
41}
42
43// ---------------------------------------------------------------------------
44// Type — the primary type carrier
45// ---------------------------------------------------------------------------
46
47#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
48pub struct Type {
49    pub types: AtomicVec,
50    /// The variable holding this type may not be initialized at this point.
51    pub possibly_undefined: bool,
52    /// This type originated from a docblock annotation rather than inference.
53    pub from_docblock: bool,
54    /// A `false`/`null` failure variant was deliberately stripped from this type
55    /// (e.g. `preg_split`'s regex-error `false`) because real code overwhelmingly
56    /// never checks for it. A defensive `=== false`/`=== null` check or a
57    /// `(string)`/`(array)` cast guarding against that exact stripped variant is
58    /// still legitimate, so the impossibility/redundancy checks (`ImpossibleIdenticalComparison`,
59    /// `RedundantCast`, the `=== null` narrowing divergence) exempt it instead of
60    /// flagging the caller's own defensive code.
61    pub falsy_stripped: bool,
62}
63
64impl Type {
65    // --- Constructors -------------------------------------------------------
66
67    pub fn empty() -> Self {
68        Self {
69            types: SmallVec::new(),
70            possibly_undefined: false,
71            from_docblock: false,
72            falsy_stripped: false,
73        }
74    }
75
76    pub fn single(atomic: Atomic) -> Self {
77        let mut types = SmallVec::new();
78        types.push(atomic);
79        Self {
80            types,
81            possibly_undefined: false,
82            from_docblock: false,
83            falsy_stripped: false,
84        }
85    }
86
87    pub fn mixed() -> Self {
88        Self::single(Atomic::TMixed)
89    }
90
91    pub fn void() -> Self {
92        Self::single(Atomic::TVoid)
93    }
94
95    pub fn never() -> Self {
96        Self::single(Atomic::TNever)
97    }
98
99    pub fn null() -> Self {
100        Self::single(Atomic::TNull)
101    }
102
103    pub fn bool() -> Self {
104        Self::single(Atomic::TBool)
105    }
106
107    pub fn int() -> Self {
108        Self::single(Atomic::TInt)
109    }
110
111    pub fn float() -> Self {
112        Self::single(Atomic::TFloat)
113    }
114
115    pub fn string() -> Self {
116        Self::single(Atomic::TString)
117    }
118
119    /// `int|string` — the canonical PHP array-key type, used as the default
120    /// key type when a docblock/inferred array has no more specific key.
121    pub fn array_key() -> Self {
122        let mut u = Self::single(Atomic::TInt);
123        u.add_type(Atomic::TString);
124        u
125    }
126
127    /// `T|null`
128    pub fn nullable(atomic: Atomic) -> Self {
129        // `mixed|null` = `mixed` — null is already included in mixed.
130        if matches!(atomic, Atomic::TMixed) {
131            return Self::mixed();
132        }
133        let mut types = SmallVec::new();
134        types.push(atomic);
135        types.push(Atomic::TNull);
136        Self {
137            types,
138            possibly_undefined: false,
139            from_docblock: false,
140            falsy_stripped: false,
141        }
142    }
143
144    /// Build a union from multiple atomics, de-duplicating on the fly.
145    pub fn from_vec(atomics: Vec<Atomic>) -> Self {
146        let mut u = Self::empty();
147        for a in atomics {
148            u.add_type(a);
149        }
150        u
151    }
152
153    // --- Introspection -------------------------------------------------------
154
155    pub fn is_empty(&self) -> bool {
156        self.types.is_empty()
157    }
158
159    pub fn is_single(&self) -> bool {
160        self.types.len() == 1
161    }
162
163    pub fn is_nullable(&self) -> bool {
164        self.types.iter().any(|t| matches!(t, Atomic::TNull))
165    }
166
167    /// True when this is exactly `int|string` — the array-key domain, which
168    /// is already the maximal set of legal PHP array keys and so should be
169    /// treated like a "default"/unconstrained key, same as `mixed` would be
170    /// for a non-key type parameter.
171    pub fn is_array_key(&self) -> bool {
172        self.types.len() == 2
173            && self.types.iter().any(|t| matches!(t, Atomic::TInt))
174            && self.types.iter().any(|t| matches!(t, Atomic::TString))
175    }
176
177    pub fn is_mixed(&self) -> bool {
178        self.types.iter().any(|t| match t {
179            Atomic::TMixed => true,
180            Atomic::TTemplateParam { as_type, .. } => as_type.is_mixed(),
181            _ => false,
182        })
183    }
184
185    /// True only when the type contains `TMixed` atoms and no `TTemplateParam` atoms.
186    /// Unlike [`is_mixed`], this does not treat an unconstrained template parameter as
187    /// "mixed" — a `T` placeholder is an intentionally parameterised type that will be
188    /// instantiated at the call site, so it must not trigger `MixedAssignment` warnings.
189    pub fn is_mixed_not_template(&self) -> bool {
190        self.is_mixed()
191            && !self
192                .types
193                .iter()
194                .any(|t| matches!(t, Atomic::TTemplateParam { .. }))
195    }
196
197    pub fn is_never(&self) -> bool {
198        self.types.iter().all(|t| matches!(t, Atomic::TNever)) && !self.types.is_empty()
199    }
200
201    /// Classify this type for `clone` validity. Recurses into template-param
202    /// bounds (like [`Type::is_mixed`]). Callers handle `mixed` separately.
203    pub fn clone_validity(&self) -> CloneValidity {
204        if self.types.is_empty() {
205            return CloneValidity::Unknown;
206        }
207        let mut has_non_object = false;
208        let mut has_other = false; // object or ambiguous (callable, mixed, conditional, …)
209        for t in &self.types {
210            match t {
211                Atomic::TTemplateParam { as_type, .. } => match as_type.clone_validity() {
212                    CloneValidity::Invalid => has_non_object = true,
213                    CloneValidity::PossiblyInvalid => {
214                        has_non_object = true;
215                        has_other = true;
216                    }
217                    CloneValidity::Cloneable | CloneValidity::Unknown => has_other = true,
218                },
219                other if other.is_definitely_non_object() => has_non_object = true,
220                _ => has_other = true,
221            }
222        }
223        match (has_non_object, has_other) {
224            (true, false) => CloneValidity::Invalid,
225            (true, true) => CloneValidity::PossiblyInvalid,
226            _ => CloneValidity::Cloneable,
227        }
228    }
229
230    pub fn is_void(&self) -> bool {
231        self.is_single() && matches!(self.types[0], Atomic::TVoid)
232    }
233
234    pub fn can_be_falsy(&self) -> bool {
235        self.types.iter().any(|t| t.can_be_falsy())
236    }
237
238    pub fn can_be_truthy(&self) -> bool {
239        self.types.iter().any(|t| t.can_be_truthy())
240    }
241
242    pub fn contains<F: Fn(&Atomic) -> bool>(&self, f: F) -> bool {
243        self.types.iter().any(f)
244    }
245
246    pub fn has_named_object(&self, fqcn: &str) -> bool {
247        self.types.iter().any(|t| match t {
248            Atomic::TNamedObject { fqcn: f, .. } => f.as_ref() == fqcn,
249            _ => false,
250        })
251    }
252
253    // --- Mutation ------------------------------------------------------------
254
255    /// Add an atomic to this union, skipping duplicates.
256    /// Subsumption rules: anything ⊆ TMixed; TLiteralInt ⊆ TInt; etc.
257    pub fn add_type(&mut self, atomic: Atomic) {
258        // If we already have TMixed, nothing to add.
259        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
260            return;
261        }
262
263        // Adding TMixed subsumes everything.
264        if matches!(atomic, Atomic::TMixed) {
265            self.types.clear();
266            self.types.push(Atomic::TMixed);
267            return;
268        }
269
270        // Simplify trivial conditional types: (X is ? T : T) → T
271        // Recursively simplify branches first so nested trivial conditionals collapse.
272        let atomic = if let Atomic::TConditional { data } = &atomic {
273            let (if_true, if_false) = (&data.if_true, &data.if_false);
274            let mut simplified_true = Type::empty();
275            for t in &if_true.types {
276                simplified_true.add_type(t.clone());
277            }
278            let mut simplified_false = Type::empty();
279            for t in &if_false.types {
280                simplified_false.add_type(t.clone());
281            }
282            if simplified_true == simplified_false {
283                for t in simplified_true.types {
284                    self.add_type(t);
285                }
286                return;
287            }
288            atomic
289        } else {
290            atomic
291        };
292
293        // Avoid exact duplicates.
294        if self.types.contains(&atomic) {
295            return;
296        }
297
298        // TLiteralInt(n) is subsumed by TInt.
299        if let Atomic::TLiteralInt(_) = &atomic {
300            if self.types.iter().any(|t| matches!(t, Atomic::TInt)) {
301                return;
302            }
303        }
304        // TLiteralString(s) is subsumed by TString.
305        if let Atomic::TLiteralString(_) = &atomic {
306            if self.types.iter().any(|t| matches!(t, Atomic::TString)) {
307                return;
308            }
309        }
310        // TTrue / TFalse are subsumed by TBool.
311        if matches!(atomic, Atomic::TTrue | Atomic::TFalse)
312            && self.types.iter().any(|t| matches!(t, Atomic::TBool))
313        {
314            return;
315        }
316        // TTrue and TFalse together are exactly TBool — merge rather than
317        // keeping both literals once both are present.
318        if matches!(atomic, Atomic::TTrue) && self.types.iter().any(|t| matches!(t, Atomic::TFalse))
319        {
320            self.types.retain(|t| !matches!(t, Atomic::TFalse));
321            self.types.push(Atomic::TBool);
322            return;
323        }
324        if matches!(atomic, Atomic::TFalse) && self.types.iter().any(|t| matches!(t, Atomic::TTrue))
325        {
326            self.types.retain(|t| !matches!(t, Atomic::TTrue));
327            self.types.push(Atomic::TBool);
328            return;
329        }
330        // Adding TInt widens away all TLiteralInt variants.
331        if matches!(atomic, Atomic::TInt) {
332            self.types.retain(|t| !matches!(t, Atomic::TLiteralInt(_)));
333        }
334        // Adding TString widens away all TLiteralString variants.
335        if matches!(atomic, Atomic::TString) {
336            self.types
337                .retain(|t| !matches!(t, Atomic::TLiteralString(_)));
338        }
339        // Adding TBool widens away TTrue/TFalse.
340        if matches!(atomic, Atomic::TBool) {
341            self.types
342                .retain(|t| !matches!(t, Atomic::TTrue | Atomic::TFalse));
343        }
344
345        // TNever is the bottom type: T | never = T.
346        if matches!(atomic, Atomic::TNever) {
347            if !self.types.is_empty() {
348                return;
349            }
350        } else {
351            self.types.retain(|t| !matches!(t, Atomic::TNever));
352        }
353
354        // Closed empty keyed array (array{}) is a subtype of any generic array or
355        // list. Remove it if we already have a generic array<K,V> or list<V>. An
356        // OPEN empty shape (array{...}) carries real information — it may hold
357        // unknown, possibly non-list, extra keys at runtime — so it's not
358        // subsumed and must not be dropped.
359        if let Atomic::TKeyedArray {
360            properties,
361            is_open,
362            ..
363        } = &atomic
364        {
365            if properties.is_empty() && !is_open {
366                for existing in &self.types {
367                    match existing {
368                        Atomic::TArray { .. }
369                        | Atomic::TNonEmptyArray { .. }
370                        | Atomic::TList { .. }
371                        | Atomic::TNonEmptyList { .. } => {
372                            return; // Don't add empty array, it's subsumed
373                        }
374                        _ => {}
375                    }
376                }
377            }
378        }
379
380        // When adding a generic array or list, remove any CLOSED empty keyed
381        // arrays since they're subtypes (same reasoning as above, mirrored).
382        let is_generic_array_or_list = matches!(
383            &atomic,
384            Atomic::TArray { .. }
385                | Atomic::TNonEmptyArray { .. }
386                | Atomic::TList { .. }
387                | Atomic::TNonEmptyList { .. }
388        );
389        if is_generic_array_or_list {
390            self.types.retain(|t| {
391                if let Atomic::TKeyedArray {
392                    properties,
393                    is_open,
394                    ..
395                } = t
396                {
397                    !properties.is_empty() || *is_open
398                } else {
399                    true
400                }
401            });
402        }
403
404        self.types.push(atomic);
405    }
406
407    // --- Narrowing -----------------------------------------------------------
408
409    /// Remove `null` from the union (e.g. after a null check).
410    pub fn remove_null(&self) -> Type {
411        self.filter(|t| !matches!(t, Atomic::TNull))
412    }
413
414    /// Remove `false` from the union.
415    /// `TFalse` is dropped; `TBool` becomes `TTrue` since `bool - false = true`.
416    pub fn remove_false(&self) -> Type {
417        let mut result = self.filter(|t| !matches!(t, Atomic::TFalse | Atomic::TBool));
418        if self.types.iter().any(|t| matches!(t, Atomic::TBool)) {
419            result.add_type(Atomic::TTrue);
420        }
421        result
422    }
423
424    /// Remove `true` from the union.
425    /// `TTrue` is dropped; `TBool` becomes `TFalse` since `bool - true = false`.
426    pub fn remove_true(&self) -> Type {
427        let mut result = self.filter(|t| !matches!(t, Atomic::TTrue | Atomic::TBool));
428        if self.types.iter().any(|t| matches!(t, Atomic::TBool)) {
429            result.add_type(Atomic::TFalse);
430        }
431        result
432    }
433
434    /// Remove both `null` and `false` from the union (core type without nullable/falsy variants).
435    pub fn core_type(&self) -> Type {
436        self.remove_null().remove_false()
437    }
438
439    /// Keep only truthy atomics (e.g. after `if ($x)`).
440    pub fn narrow_to_truthy(&self) -> Type {
441        if self.is_mixed_not_template() {
442            return Type::mixed();
443        }
444        let mut result = Type::empty();
445        result.from_docblock = self.from_docblock;
446        for t in &self.types {
447            match t {
448                // An unconstrained/bounded template could resolve to anything at
449                // runtime, truthy or falsy — preserve it rather than dropping or
450                // widening it, the same way narrow_to_string/_int/etc. already do.
451                Atomic::TTemplateParam { .. } => result.add_type(t.clone()),
452                // Always-falsy — exclude entirely.
453                Atomic::TLiteralInt(0)
454                | Atomic::TLiteralFloat(0, 0)
455                | Atomic::TNull
456                | Atomic::TFalse => {}
457                Atomic::TLiteralString(s) if s.as_ref() == "" || s.as_ref() == "0" => {}
458                // bool contains both true (truthy) and false (falsy); truthy branch is true.
459                Atomic::TBool => result.add_type(Atomic::TTrue),
460                // array/list: empty ↔ falsy; truthy branch is non-empty-array/list.
461                Atomic::TArray { key, value } => result.add_type(Atomic::TNonEmptyArray {
462                    key: key.clone(),
463                    value: value.clone(),
464                }),
465                Atomic::TList { value } => result.add_type(Atomic::TNonEmptyList {
466                    value: value.clone(),
467                }),
468                // string: only "" and "0" are falsy; truthy branch is non-empty-string.
469                // non-empty-string still includes "0" (which is falsy) but that is the
470                // standard approximation used by Psalm and other analyzers.
471                Atomic::TString => result.add_type(Atomic::TNonEmptyString),
472                // numeric-string: "0" is the only falsy value; non-zero numerics are truthy.
473                // No named "non-zero numeric-string" type exists; keep as-is conservatively.
474                // int<0, max> only has 0 as its falsy value; truthy branch is int<1, max>.
475                // (int<0, 0> is handled by the can_be_truthy() false guard below.)
476                Atomic::TNonNegativeInt => result.add_type(Atomic::TPositiveInt),
477                Atomic::TIntRange { min: Some(0), max } if max.is_none_or(|m| m >= 1) => {
478                    let atom = if max.is_none() {
479                        Atomic::TPositiveInt
480                    } else {
481                        Atomic::TIntRange {
482                            min: Some(1),
483                            max: *max,
484                        }
485                    };
486                    result.add_type(atom);
487                }
488                // int<min, 0>: 0 is the only falsy value; truthy branch excludes it → int<min, -1>.
489                Atomic::TIntRange { min, max: Some(0) } => {
490                    let atom = match min {
491                        None => Atomic::TNegativeInt,
492                        Some(n) if *n <= -1 => Atomic::TIntRange {
493                            min: *min,
494                            max: Some(-1),
495                        },
496                        _ => continue, // min >= 0 with max == 0 → range is {0} — can_be_truthy() handles this
497                    };
498                    result.add_type(atom);
499                }
500                // Anything else that can never be truthy — drop.
501                t if !t.can_be_truthy() => {}
502                _ => result.add_type(t.clone()),
503            }
504        }
505        result
506    }
507
508    /// Keep only falsy atomics (e.g. after `if (!$x)`).
509    pub fn narrow_to_falsy(&self) -> Type {
510        if self.is_mixed_not_template() {
511            return Type::from_vec(vec![
512                Atomic::TNull,
513                Atomic::TFalse,
514                Atomic::TLiteralInt(0),
515                Atomic::TLiteralString("".into()),
516            ]);
517        }
518        let mut result = Type::empty();
519        result.from_docblock = self.from_docblock;
520        for t in &self.types {
521            match t {
522                // An unconstrained/bounded template could resolve to anything at
523                // runtime, truthy or falsy — preserve it rather than dropping it
524                // (its own `can_be_falsy()` conservatively defaults to `false`,
525                // which would otherwise wrongly exclude it here).
526                Atomic::TTemplateParam { .. } => result.add_type(t.clone()),
527                // bool: only false is falsy; falsy branch is false.
528                Atomic::TBool => result.add_type(Atomic::TFalse),
529                // int: only 0 is falsy.
530                Atomic::TInt => result.add_type(Atomic::TLiteralInt(0)),
531                // float: only 0.0 is falsy.
532                Atomic::TFloat => result.add_type(Atomic::TLiteralFloat(0, 0)),
533                // string: only "" and "0" are falsy.
534                Atomic::TString => {
535                    result.add_type(Atomic::TLiteralString("".into()));
536                    result.add_type(Atomic::TLiteralString("0".into()));
537                }
538                // numeric-string: only "0" is a falsy numeric string.
539                Atomic::TNumericString => result.add_type(Atomic::TLiteralString("0".into())),
540                // non-negative-int: only 0 is falsy.
541                Atomic::TNonNegativeInt => result.add_type(Atomic::TLiteralInt(0)),
542                // int<0, hi>: only 0 is falsy.
543                Atomic::TIntRange {
544                    min: Some(0),
545                    max: Some(_) | None,
546                } => result.add_type(Atomic::TLiteralInt(0)),
547                // int<min, 0>: only 0 is falsy.
548                Atomic::TIntRange { max: Some(0), .. } => result.add_type(Atomic::TLiteralInt(0)),
549                t if !t.can_be_falsy() => {} // always truthy — exclude
550                _ => result.add_type(t.clone()),
551            }
552        }
553        result
554    }
555
556    /// Narrow this type as if `$x instanceof ClassName` is true.
557    ///
558    /// The instanceof check guarantees the value IS an instance of `class`, so we
559    /// replace any object / mixed constituents with the specific named object.  Scalar
560    /// constituents are dropped (they can never satisfy instanceof).
561    pub fn narrow_instanceof(&self, class: &str) -> Type {
562        let narrowed_ty = Atomic::TNamedObject {
563            fqcn: class.into(),
564            type_params: empty_type_params(),
565        };
566        // If any constituent is an object-like type, the result is the specific class.
567        let has_object = self.types.iter().any(|t| {
568            matches!(
569                t,
570                Atomic::TObject | Atomic::TNamedObject { .. } | Atomic::TMixed | Atomic::TNull // null fails instanceof, but mixed/object may include null
571            )
572        });
573        if has_object || self.is_empty() {
574            Type::single(narrowed_ty)
575        } else {
576            // Pure scalars — instanceof is always false here, but return the class
577            // defensively so callers don't see an empty union.
578            Type::single(narrowed_ty)
579        }
580    }
581
582    /// Narrow as if `is_string($x)` is true. `mixed`/`scalar` become a concrete
583    /// `string` (rather than staying `mixed`) so downstream string-only
584    /// operations see a usable type instead of reporting `Mixed*`.
585    pub fn narrow_to_string(&self) -> Type {
586        self.filter_replacing(
587            |t| t.is_string() || matches!(t, Atomic::TTemplateParam { .. }),
588            |t| matches!(t, Atomic::TMixed | Atomic::TScalar),
589            Atomic::TString,
590        )
591    }
592
593    /// Narrow as if `is_int($x)` is true.
594    pub fn narrow_to_int(&self) -> Type {
595        self.filter_replacing(
596            |t| t.is_int() || matches!(t, Atomic::TTemplateParam { .. }),
597            |t| matches!(t, Atomic::TMixed | Atomic::TScalar | Atomic::TNumeric),
598            Atomic::TInt,
599        )
600    }
601
602    /// Narrow as if `is_float($x)` is true.
603    pub fn narrow_to_float(&self) -> Type {
604        self.filter_replacing(
605            |t| {
606                matches!(
607                    t,
608                    Atomic::TFloat
609                        | Atomic::TIntegralFloat
610                        | Atomic::TLiteralFloat(..)
611                        | Atomic::TTemplateParam { .. }
612                )
613            },
614            |t| matches!(t, Atomic::TMixed | Atomic::TScalar | Atomic::TNumeric),
615            Atomic::TFloat,
616        )
617    }
618
619    /// Narrow as if `is_bool($x)` is true.
620    pub fn narrow_to_bool(&self) -> Type {
621        self.filter_replacing(
622            |t| {
623                matches!(
624                    t,
625                    Atomic::TBool | Atomic::TTrue | Atomic::TFalse | Atomic::TTemplateParam { .. }
626                )
627            },
628            |t| matches!(t, Atomic::TMixed | Atomic::TScalar),
629            Atomic::TBool,
630        )
631    }
632
633    /// Narrow as if `is_null($x)` is true.
634    pub fn narrow_to_null(&self) -> Type {
635        self.filter_replacing(
636            |t| matches!(t, Atomic::TNull | Atomic::TTemplateParam { .. }),
637            |t| matches!(t, Atomic::TMixed),
638            Atomic::TNull,
639        )
640    }
641
642    /// Narrow as if `is_array($x)` is true.
643    pub fn narrow_to_array(&self) -> Type {
644        self.filter_replacing(
645            |t| t.is_array() || matches!(t, Atomic::TTemplateParam { .. }),
646            |t| matches!(t, Atomic::TMixed),
647            Atomic::TArray {
648                key: Box::new(Type::mixed()),
649                value: Box::new(Type::mixed()),
650            },
651        )
652    }
653
654    /// Narrow array/list types to their non-empty variants (for `count() > 0` etc.).
655    pub fn narrow_to_non_empty_collection(&self) -> Type {
656        let mut out = Type::empty();
657        out.from_docblock = self.from_docblock;
658        for t in &self.types {
659            match t {
660                Atomic::TArray { key, value } => out.add_type(Atomic::TNonEmptyArray {
661                    key: key.clone(),
662                    value: value.clone(),
663                }),
664                Atomic::TList { value } => out.add_type(Atomic::TNonEmptyList {
665                    value: value.clone(),
666                }),
667                _ => out.add_type(t.clone()),
668            }
669        }
670        out
671    }
672
673    /// Narrow array/list types when proven empty (e.g. `array_key_first($x) === null`,
674    /// `$arr === []`). Drops the non-empty variants outright — they can never
675    /// be empty — and narrows a plain `array`/`list` down to the same closed,
676    /// zero-property `TKeyedArray` an empty `[]` literal itself types as, so
677    /// e.g. `$values[0]` on a proven-empty branch is flagged as a
678    /// `NonExistentArrayOffset` instead of silently keeping the pre-narrow
679    /// element type. `TKeyedArray` atoms are left unchanged — narrowing an
680    /// already-shaped array to "empty" when it may declare required
681    /// properties is a separate, more nuanced case.
682    pub fn narrow_to_empty_collection(&self) -> Type {
683        let mut out = Type::empty();
684        out.from_docblock = self.from_docblock;
685        for t in &self.types {
686            match t {
687                Atomic::TNonEmptyArray { .. } | Atomic::TNonEmptyList { .. } => {}
688                Atomic::TArray { .. } | Atomic::TList { .. } => {
689                    out.add_type(Atomic::TKeyedArray {
690                        properties: Box::default(),
691                        is_open: false,
692                        is_list: true,
693                    });
694                }
695                _ => out.add_type(t.clone()),
696            }
697        }
698        out
699    }
700
701    /// Narrow as if `array_is_list($x)` is true.
702    /// Lists have sequential integer keys starting from 0, so:
703    /// - `list<T>` / `non-empty-list<T>` are kept unchanged.
704    /// - `array<int, T>` is narrowed to `list<T>` (could be sequential).
705    /// - `non-empty-array<int, T>` is narrowed to `non-empty-list<T>`.
706    /// - `TKeyedArray` (shape) is kept only when its own `is_list` flag is
707    ///   already true — that flag is precise (set from the actual literal's
708    ///   keys, or an explicit `list{...}` docblock), not a hint, so a
709    ///   string-keyed or non-contiguous shape is correctly excluded.
710    /// - `mixed` becomes `list<mixed>` (array_is_list implies array).
711    /// - All other types (string-keyed arrays, non-arrays) are dropped.
712    pub fn narrow_to_list(&self) -> Type {
713        let mut out = Type::empty();
714        out.from_docblock = self.from_docblock;
715        for t in &self.types {
716            match t {
717                Atomic::TList { .. } | Atomic::TNonEmptyList { .. } => out.add_type(t.clone()),
718                // Guard on "key admits int" rather than "key is exactly TInt" —
719                // `is_array($mixed)` narrows an unknown key to `Type::mixed()`,
720                // which is a list candidate (the runtime shape is still unknown),
721                // unlike a key statically known to exclude int entirely (e.g. a
722                // docblock-declared `array<string, T>`), which must stay excluded.
723                Atomic::TArray { key, value } if !key.narrow_to_int().is_empty() => {
724                    out.add_type(Atomic::TList {
725                        value: value.clone(),
726                    });
727                }
728                Atomic::TNonEmptyArray { key, value } if !key.narrow_to_int().is_empty() => {
729                    out.add_type(Atomic::TNonEmptyList {
730                        value: value.clone(),
731                    });
732                }
733                Atomic::TKeyedArray { is_list: true, .. } => out.add_type(t.clone()),
734                Atomic::TMixed => out.add_type(Atomic::TList {
735                    value: Box::new(Type::mixed()),
736                }),
737                _ => {}
738            }
739        }
740        if out.is_empty() {
741            self.filter(|t| matches!(t, Atomic::TList { .. } | Atomic::TNonEmptyList { .. }))
742        } else {
743            out
744        }
745    }
746
747    /// Narrow as if `is_object($x)` is true. A `mixed` becomes a concrete bare
748    /// `object` (rather than staying `mixed`) so downstream object-only
749    /// operations — `clone`, `instanceof`, method calls — see an object type
750    /// instead of reporting `Mixed*`.
751    pub fn narrow_to_object(&self) -> Type {
752        let mut out = Type::empty();
753        for t in &self.types {
754            if matches!(t, Atomic::TMixed) {
755                out.add_type(Atomic::TObject);
756            } else if t.is_object() || matches!(t, Atomic::TTemplateParam { .. }) {
757                out.add_type(t.clone());
758            }
759        }
760        if out.types.is_empty() {
761            self.filter(|t| t.is_object())
762        } else {
763            out
764        }
765    }
766
767    /// Narrow as if `is_callable($x)` is true.
768    ///
769    /// PHP accepts closures, TCallable, strings (function names), arrays
770    /// (['Class', 'method'] or [$obj, 'method']), and objects with __invoke.
771    /// Keep all of these; only drop atoms that are definitely not callable
772    /// (scalars, null, bool, etc.).
773    pub fn narrow_to_callable(&self) -> Type {
774        let narrowed = self.filter(|t| {
775            t.is_callable()
776                || t.is_string()
777                || t.is_array()
778                || t.is_object()
779                || matches!(t, Atomic::TMixed | Atomic::TTemplateParam { .. })
780        });
781        // A bare `object` carries no known `__invoke` signature to compare
782        // against a `callable`-typed target, so it stayed an object rather
783        // than satisfying one — represent it as a generic callable instead,
784        // matching what `is_callable()` actually proved. A `TNamedObject`
785        // keeps its own class (its real `__invoke`, if any, is more precise
786        // than a generic callable).
787        let mut result = Type::empty();
788        result.possibly_undefined = narrowed.possibly_undefined;
789        result.from_docblock = narrowed.from_docblock;
790        for atomic in narrowed.types {
791            if matches!(atomic, Atomic::TObject) {
792                result.add_type(Atomic::TCallable {
793                    params: None,
794                    return_type: None,
795                });
796            } else {
797                result.add_type(atomic);
798            }
799        }
800        result
801    }
802
803    /// Narrow as if `is_scalar($x)` is true (int | string | float | bool).
804    pub fn narrow_to_scalar(&self) -> Type {
805        self.filter_replacing(
806            |t| {
807                t.is_string()
808                    || t.is_int()
809                    || matches!(
810                        t,
811                        Atomic::TFloat
812                            | Atomic::TIntegralFloat
813                            | Atomic::TLiteralFloat(..)
814                            | Atomic::TBool
815                            | Atomic::TTrue
816                            | Atomic::TFalse
817                            | Atomic::TScalar
818                            | Atomic::TNumeric
819                            | Atomic::TNumericString
820                            | Atomic::TTemplateParam { .. }
821                    )
822            },
823            |t| matches!(t, Atomic::TMixed),
824            Atomic::TScalar,
825        )
826    }
827
828    /// Narrow as if `is_iterable($x)` is true (array | Traversable).
829    /// For simplicity, this narrows to arrays or objects (can't easily verify interfaces).
830    pub fn narrow_to_iterable(&self) -> Type {
831        self.filter(|t| {
832            t.is_array()
833                || t.is_object()
834                || matches!(t, Atomic::TMixed | Atomic::TTemplateParam { .. })
835        })
836    }
837
838    /// Narrow as if `is_countable($x)` is true (array | Countable).
839    /// For simplicity, this narrows to arrays or objects (can't easily verify Countable interface).
840    pub fn narrow_to_countable(&self) -> Type {
841        self.filter(|t| {
842            t.is_array()
843                || t.is_object()
844                || matches!(t, Atomic::TMixed | Atomic::TTemplateParam { .. })
845        })
846    }
847
848    /// Narrow as if `is_resource($x)` is true.
849    /// Note: No TResource atomic type exists in the type system; this is a no-op.
850    /// Resources are declining in modern PHP and not actively tracked.
851    pub fn narrow_to_resource(&self) -> Type {
852        // No resource type in the system; just return mixed (allows any type)
853        self.filter(|t| matches!(t, Atomic::TMixed))
854    }
855
856    /// Narrow as if `class_exists($x)` returned true for a string variable.
857    /// String atoms become `class-string`; existing class-string atoms pass through;
858    /// mixed/scalar becomes `class-string`. Non-string atoms are dropped (returning
859    /// empty so the caller can mark the branch as diverging).
860    pub fn narrow_to_class_string(&self) -> Type {
861        let mut out = Type::empty();
862        out.from_docblock = self.from_docblock;
863        for t in &self.types {
864            match t {
865                Atomic::TClassString(_) => out.add_type(t.clone()),
866                _ if t.is_string() || matches!(t, Atomic::TMixed | Atomic::TScalar) => {
867                    out.add_type(Atomic::TClassString(None));
868                }
869                _ => {}
870            }
871        }
872        out
873    }
874
875    /// Narrow as if `interface_exists($x)` returned true for a string variable.
876    /// String atoms become `interface-string`; existing interface-string atoms pass
877    /// through; mixed/scalar becomes `interface-string`. Non-string atoms are dropped
878    /// (returning empty so the caller can mark the branch as diverging).
879    pub fn narrow_to_interface_string(&self) -> Type {
880        let mut out = Type::empty();
881        out.from_docblock = self.from_docblock;
882        for t in &self.types {
883            match t {
884                Atomic::TInterfaceString(_) => out.add_type(t.clone()),
885                // A known class-string keeps its name — every interface-string is
886                // also a valid class-string, so `interface_exists()` returning true
887                // narrows the atom without losing which class it names.
888                Atomic::TClassString(name) => {
889                    out.add_type(Atomic::TInterfaceString(*name));
890                }
891                _ if t.is_string() || matches!(t, Atomic::TMixed | Atomic::TScalar) => {
892                    out.add_type(Atomic::TInterfaceString(None));
893                }
894                _ => {}
895            }
896        }
897        out
898    }
899
900    // --- Merge (branch join) ------------------------------------------------
901
902    /// Merge two unions at a branch join point (e.g. after if/else).
903    /// The result is the union of all types in both.
904    pub fn merge(a: &Type, b: &Type) -> Type {
905        // Fast path: b is empty — nothing to add.
906        if b.types.is_empty() {
907            let mut result = a.clone();
908            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
909            return result;
910        }
911        // Fast path: a is empty — clone b.
912        if a.types.is_empty() {
913            let mut result = b.clone();
914            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
915            return result;
916        }
917        // Fast path: a is already mixed — b cannot widen it further.
918        if a.types.len() == 1 && matches!(a.types[0], Atomic::TMixed) {
919            let mut result = a.clone();
920            result.possibly_undefined = a.possibly_undefined || b.possibly_undefined;
921            return result;
922        }
923        // Fast path: b contains mixed — result collapses to mixed.
924        if b.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
925            return Type {
926                types: smallvec::smallvec![Atomic::TMixed],
927                possibly_undefined: a.possibly_undefined || b.possibly_undefined,
928                from_docblock: a.from_docblock || b.from_docblock,
929                falsy_stripped: false,
930            };
931        }
932        let mut result = a.clone();
933        result.merge_with(b);
934        result
935    }
936
937    /// Merge `other` into `self` in-place (avoids cloning `self`).
938    pub fn merge_with(&mut self, other: &Type) {
939        if self.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
940            self.possibly_undefined |= other.possibly_undefined;
941            return;
942        }
943        if other.types.iter().any(|t| matches!(t, Atomic::TMixed)) {
944            self.types.clear();
945            self.types.push(Atomic::TMixed);
946            self.possibly_undefined |= other.possibly_undefined;
947            return;
948        }
949        for atomic in &other.types {
950            self.add_type(atomic.clone());
951        }
952        self.possibly_undefined |= other.possibly_undefined;
953    }
954
955    /// Intersect with another union: keep only types present in `other`, widening
956    /// where `self` contains `mixed` (which is compatible with everything).
957    /// Used for match-arm subject narrowing.
958    pub fn intersect_with(&self, other: &Type) -> Type {
959        if self.is_mixed() {
960            return other.clone();
961        }
962        if other.is_mixed() {
963            return self.clone();
964        }
965        // Keep the more specific of each overlapping (self, other) atomic
966        // pair — e.g. intersecting `int` with `1|2` must keep `1|2` (the
967        // narrower side), not `int` (self's own, wider atomic): the whole
968        // point of narrowing a variable against a match/switch arm's
969        // literal conditions is to end up with the literal, not the bare
970        // declared type it already had. Every matching pair is kept (not
971        // just the first), so `int ∩ (1|2)` keeps both `1` and `2`.
972        let mut result = Type::empty();
973        for a in &self.types {
974            for b in &other.types {
975                if a == b {
976                    result.add_type(a.clone());
977                } else if atomic_subtype(b, a) {
978                    result.add_type(b.clone());
979                } else if atomic_subtype(a, b) {
980                    result.add_type(a.clone());
981                }
982            }
983        }
984        if result.is_empty() {
985            Type::never()
986        } else {
987            result
988        }
989    }
990
991    // --- Template substitution ----------------------------------------------
992
993    /// Replace template param references with their resolved types.
994    pub fn substitute_templates(&self, bindings: &FxHashMap<Name, Type>) -> Type {
995        if bindings.is_empty() {
996            return self.clone();
997        }
998        // Most argument/return types are plain scalars or resolved objects
999        // with nothing to substitute — skip the rebuild entirely.
1000        if !self.types.iter().any(atomic_may_contain_templates) {
1001            return self.clone();
1002        }
1003        let mut result = Type::empty();
1004        result.possibly_undefined = self.possibly_undefined;
1005        result.from_docblock = self.from_docblock;
1006        for atomic in &self.types {
1007            match atomic {
1008                Atomic::TTemplateParam { name, .. } => {
1009                    if let Some(resolved) = bindings.get(name) {
1010                        for t in &resolved.types {
1011                            result.add_type(t.clone());
1012                        }
1013                    } else {
1014                        result.add_type(atomic.clone());
1015                    }
1016                }
1017                Atomic::TArray { key, value } => {
1018                    result.add_type(Atomic::TArray {
1019                        key: Box::new(key.substitute_templates(bindings)),
1020                        value: Box::new(value.substitute_templates(bindings)),
1021                    });
1022                }
1023                Atomic::TList { value } => {
1024                    result.add_type(Atomic::TList {
1025                        value: Box::new(value.substitute_templates(bindings)),
1026                    });
1027                }
1028                Atomic::TNonEmptyArray { key, value } => {
1029                    result.add_type(Atomic::TNonEmptyArray {
1030                        key: Box::new(key.substitute_templates(bindings)),
1031                        value: Box::new(value.substitute_templates(bindings)),
1032                    });
1033                }
1034                Atomic::TNonEmptyList { value } => {
1035                    result.add_type(Atomic::TNonEmptyList {
1036                        value: Box::new(value.substitute_templates(bindings)),
1037                    });
1038                }
1039                Atomic::TKeyedArray {
1040                    properties,
1041                    is_open,
1042                    is_list,
1043                } => {
1044                    use crate::atomic::KeyedProperty;
1045                    let new_props = properties
1046                        .iter()
1047                        .map(|(k, prop)| {
1048                            (
1049                                k.clone(),
1050                                KeyedProperty {
1051                                    ty: prop.ty.substitute_templates(bindings),
1052                                    optional: prop.optional,
1053                                },
1054                            )
1055                        })
1056                        .collect();
1057                    result.add_type(Atomic::TKeyedArray {
1058                        properties: Box::new(new_props),
1059                        is_open: *is_open,
1060                        is_list: *is_list,
1061                    });
1062                }
1063                Atomic::TCallable {
1064                    params,
1065                    return_type,
1066                } => {
1067                    result.add_type(Atomic::TCallable {
1068                        params: params.as_ref().map(|ps| {
1069                            ps.iter()
1070                                .map(|p| substitute_in_fn_param(p, bindings))
1071                                .collect()
1072                        }),
1073                        return_type: return_type
1074                            .as_ref()
1075                            .map(|r| Box::new(r.substitute_templates(bindings))),
1076                    });
1077                }
1078                Atomic::TClosure { data } => {
1079                    result.add_type(Atomic::TClosure {
1080                        data: Box::new(crate::atomic::ClosureData {
1081                            params: data
1082                                .params
1083                                .iter()
1084                                .map(|p| substitute_in_fn_param(p, bindings))
1085                                .collect(),
1086                            return_type: data.return_type.substitute_templates(bindings),
1087                            this_type: data
1088                                .this_type
1089                                .as_ref()
1090                                .map(|t| t.substitute_templates(bindings)),
1091                        }),
1092                    });
1093                }
1094                Atomic::TConditional { data } => {
1095                    let param_name = &data.param_name;
1096                    let new_subject = data.subject.substitute_templates(bindings);
1097                    let new_if_true = data.if_true.substitute_templates(bindings);
1098                    let new_if_false = data.if_false.substitute_templates(bindings);
1099
1100                    // If param_name names a template that is bound in this substitution,
1101                    // resolve the conditional immediately using the same predicate logic as
1102                    // `resolve_conditional_returns` for the $param form.
1103                    let resolved = if let Some(name) = param_name {
1104                        if let Some(bound) = bindings.get(name) {
1105                            if new_subject.types.len() == 1 {
1106                                resolve_conditional_branch(
1107                                    &new_subject.types[0],
1108                                    bound,
1109                                    &new_if_true,
1110                                    &new_if_false,
1111                                )
1112                            } else {
1113                                None
1114                            }
1115                        } else {
1116                            None
1117                        }
1118                    } else {
1119                        None
1120                    };
1121
1122                    if let Some(branch) = resolved {
1123                        for t in branch.types {
1124                            result.add_type(t);
1125                        }
1126                    } else {
1127                        result.add_type(Atomic::TConditional {
1128                            data: Box::new(crate::atomic::ConditionalData {
1129                                param_name: *param_name,
1130                                subject: new_subject,
1131                                if_true: new_if_true,
1132                                if_false: new_if_false,
1133                            }),
1134                        });
1135                    }
1136                }
1137                Atomic::TIntersection { parts } => {
1138                    result.add_type(Atomic::TIntersection {
1139                        parts: vec_to_type_params(
1140                            parts
1141                                .iter()
1142                                .map(|p| p.substitute_templates(bindings))
1143                                .collect(),
1144                        ),
1145                    });
1146                }
1147                Atomic::TNamedObject { fqcn, type_params } => {
1148                    // TODO: the docblock parser emits TNamedObject { fqcn: "T" } for bare @return T
1149                    // annotations instead of TTemplateParam, because it lacks template context at
1150                    // parse time. This block works around that by treating bare unqualified names
1151                    // as template param references when they appear in the binding map. Proper fix:
1152                    // make the docblock parser template-aware so it emits TTemplateParam directly.
1153                    // See issue #26 for context.
1154                    if type_params.is_empty() && !fqcn.contains('\\') {
1155                        if let Some(resolved) = bindings.get(fqcn) {
1156                            for t in &resolved.types {
1157                                result.add_type(t.clone());
1158                            }
1159                            continue;
1160                        }
1161                    }
1162                    let new_params: Vec<Type> = type_params
1163                        .iter()
1164                        .map(|p| p.substitute_templates(bindings))
1165                        .collect();
1166                    result.add_type(Atomic::TNamedObject {
1167                        fqcn: *fqcn,
1168                        type_params: vec_to_type_params(new_params),
1169                    });
1170                }
1171                // class-string<T> → substitute T from bindings
1172                Atomic::TClassString(Some(param_name)) => {
1173                    if let Some(resolved) = bindings.get(param_name) {
1174                        for r_atomic in &resolved.types {
1175                            let cls_name = if let Atomic::TNamedObject { fqcn, .. } = r_atomic {
1176                                Some(*fqcn)
1177                            } else {
1178                                None
1179                            };
1180                            result.add_type(Atomic::TClassString(cls_name));
1181                        }
1182                    } else {
1183                        result.add_type(atomic.clone());
1184                    }
1185                }
1186                // interface-string<T> → substitute T from bindings
1187                Atomic::TInterfaceString(Some(param_name)) => {
1188                    if let Some(resolved) = bindings.get(param_name) {
1189                        for r_atomic in &resolved.types {
1190                            let iface_name = if let Atomic::TNamedObject { fqcn, .. } = r_atomic {
1191                                Some(*fqcn)
1192                            } else {
1193                                None
1194                            };
1195                            result.add_type(Atomic::TInterfaceString(iface_name));
1196                        }
1197                    } else {
1198                        result.add_type(atomic.clone());
1199                    }
1200                }
1201                _ => {
1202                    result.add_type(atomic.clone());
1203                }
1204            }
1205        }
1206        result
1207    }
1208
1209    /// Resolves `TConditional` atoms whose discriminator is known at the call site.
1210    ///
1211    /// `lookup(param_name)` returns the call-site argument type for the named parameter,
1212    /// or `None` if the argument is not available. Handles `is null`, `is string`, and
1213    /// `is array` conditions; other condition types pass through unchanged.
1214    pub fn resolve_conditional_returns<F>(self, lookup: F) -> Type
1215    where
1216        F: Fn(&str) -> Option<Type>,
1217    {
1218        self.resolve_conditional_inner(&lookup)
1219    }
1220
1221    fn resolve_conditional_inner<F>(self, lookup: &F) -> Type
1222    where
1223        F: Fn(&str) -> Option<Type>,
1224    {
1225        let mut result = Type::empty();
1226        for atomic in self.types {
1227            match atomic {
1228                Atomic::TConditional { ref data } => {
1229                    let (param_name, subject, if_true, if_false) = (
1230                        &data.param_name,
1231                        &data.subject,
1232                        &data.if_true,
1233                        &data.if_false,
1234                    );
1235                    let resolved = if subject.types.len() == 1 {
1236                        if let Some(name) = param_name {
1237                            if let Some(arg_ty) = lookup(name.as_ref()) {
1238                                resolve_conditional_branch(
1239                                    &subject.types[0],
1240                                    &arg_ty,
1241                                    if_true,
1242                                    if_false,
1243                                )
1244                            } else {
1245                                None
1246                            }
1247                        } else {
1248                            None
1249                        }
1250                    } else {
1251                        None
1252                    };
1253
1254                    if let Some(branch) = resolved {
1255                        // Recursively resolve nested conditionals in the selected branch.
1256                        for t in branch.resolve_conditional_inner(lookup).types {
1257                            result.add_type(t);
1258                        }
1259                    } else {
1260                        // Cannot resolve at this call site: widen to the union of both branches.
1261                        // Recursively resolve nested conditionals in each branch.
1262                        for t in if_true.clone().resolve_conditional_inner(lookup).types {
1263                            result.add_type(t);
1264                        }
1265                        for t in if_false.clone().resolve_conditional_inner(lookup).types {
1266                            result.add_type(t);
1267                        }
1268                    }
1269                }
1270                other => result.add_type(other),
1271            }
1272        }
1273        result
1274    }
1275
1276    // --- Subtype check -------------------------------------------------------
1277
1278    /// Returns true if every atomic in `self` is a subtype of some atomic in `other`,
1279    /// using **only structural rules** — no `extends` / `implements` walk.
1280    ///
1281    /// Two distinct user-defined classes are never related here, even when one
1282    /// extends the other. Within `mir-analyzer`, when a `db` is in scope,
1283    /// prefer `crate::subtype::is_subtype(db, sub, sup)` which layers
1284    /// inheritance resolution on top of this check.
1285    pub fn is_subtype_structural(&self, other: &Type) -> bool {
1286        if other.is_mixed() {
1287            return true;
1288        }
1289        if self.is_never() {
1290            return true; // never <: everything
1291        }
1292        self.types
1293            .iter()
1294            .all(|a| other.types.iter().any(|b| atomic_subtype(a, b)))
1295    }
1296
1297    /// `sub <: self`, structurally, for a single atomic — equivalent to
1298    /// `Type::single(sub.clone()).is_subtype_structural(self)` without the
1299    /// clone and the temporary single-atomic union.
1300    pub fn accepts_atomic_structural(&self, sub: &Atomic) -> bool {
1301        if self.is_mixed() {
1302            return true;
1303        }
1304        matches!(sub, Atomic::TNever) || self.types.iter().any(|b| atomic_subtype(sub, b))
1305    }
1306
1307    // --- Utilities ----------------------------------------------------------
1308
1309    fn filter<F: Fn(&Atomic) -> bool>(&self, f: F) -> Type {
1310        let mut result = Type::empty();
1311        result.possibly_undefined = self.possibly_undefined;
1312        result.from_docblock = self.from_docblock;
1313        for atomic in &self.types {
1314            if f(atomic) {
1315                result.types.push(atomic.clone());
1316            }
1317        }
1318        result
1319    }
1320
1321    /// Like `filter`, but atoms matching `placeholder` are substituted with
1322    /// `replacement` instead of passing through unchanged. Used so narrowing
1323    /// an unrefined `mixed`/`scalar`/`numeric` value (e.g. via `is_int($x)`)
1324    /// yields the concrete narrowed type instead of staying `mixed`.
1325    fn filter_replacing<K: Fn(&Atomic) -> bool, P: Fn(&Atomic) -> bool>(
1326        &self,
1327        keep: K,
1328        placeholder: P,
1329        replacement: Atomic,
1330    ) -> Type {
1331        let mut result = Type::empty();
1332        result.possibly_undefined = self.possibly_undefined;
1333        result.from_docblock = self.from_docblock;
1334        for atomic in &self.types {
1335            if keep(atomic) {
1336                result.add_type(atomic.clone());
1337            } else if placeholder(atomic) {
1338                result.add_type(replacement.clone());
1339            }
1340        }
1341        result
1342    }
1343
1344    /// Mark this union as possibly-undefined and return it.
1345    pub fn possibly_undefined(mut self) -> Self {
1346        self.possibly_undefined = true;
1347        self
1348    }
1349
1350    /// Mark this union as coming from a docblock annotation.
1351    pub fn from_docblock(mut self) -> Self {
1352        self.from_docblock = true;
1353        self
1354    }
1355
1356    /// Mark this union as having had a `false`/`null` failure variant stripped
1357    /// for flow purposes (see the field doc on [`Type::falsy_stripped`]).
1358    pub fn falsy_stripped(mut self) -> Self {
1359        self.falsy_stripped = true;
1360        self
1361    }
1362}
1363
1364// ---------------------------------------------------------------------------
1365// Conditional return resolution helpers
1366// ---------------------------------------------------------------------------
1367
1368fn is_string_atomic(a: &Atomic) -> bool {
1369    matches!(
1370        a,
1371        Atomic::TString
1372            | Atomic::TNonEmptyString
1373            | Atomic::TLiteralString(_)
1374            | Atomic::TNumericString
1375            | Atomic::TClassString(_)
1376            | Atomic::TInterfaceString(_)
1377            | Atomic::TCallableString
1378    )
1379}
1380
1381fn is_array_atomic(a: &Atomic) -> bool {
1382    matches!(
1383        a,
1384        Atomic::TArray { .. }
1385            | Atomic::TNonEmptyArray { .. }
1386            | Atomic::TKeyedArray { .. }
1387            | Atomic::TList { .. }
1388            | Atomic::TNonEmptyList { .. }
1389    )
1390}
1391
1392fn is_list_atomic(a: &Atomic) -> bool {
1393    match a {
1394        Atomic::TList { .. } | Atomic::TNonEmptyList { .. } => true,
1395        Atomic::TKeyedArray { is_list, .. } => *is_list,
1396        _ => false,
1397    }
1398}
1399
1400fn is_float_atomic(a: &Atomic) -> bool {
1401    matches!(
1402        a,
1403        Atomic::TFloat | Atomic::TIntegralFloat | Atomic::TLiteralFloat(..)
1404    )
1405}
1406
1407fn is_bool_atomic(a: &Atomic) -> bool {
1408    matches!(a, Atomic::TBool | Atomic::TTrue | Atomic::TFalse)
1409}
1410
1411/// Resolve one branch of a conditional return type given the subject discriminant
1412/// atomic and the actual argument type at the call site.
1413///
1414/// Returns `Some(branch)` when the branch can be determined statically, or `None`
1415/// to signal that the caller should widen to the union of both branches.
1416fn resolve_conditional_branch(
1417    subject: &Atomic,
1418    arg_ty: &Type,
1419    if_true: &Type,
1420    if_false: &Type,
1421) -> Option<Type> {
1422    let predicate: fn(&Atomic) -> bool = match subject {
1423        Atomic::TNull => |a| matches!(a, Atomic::TNull),
1424        Atomic::TTrue => |a| matches!(a, Atomic::TTrue),
1425        Atomic::TFalse => |a| matches!(a, Atomic::TFalse),
1426        Atomic::TString => is_string_atomic,
1427        Atomic::TList { .. } => is_list_atomic,
1428        Atomic::TArray { .. } => is_array_atomic,
1429        Atomic::TInt => Atomic::is_int,
1430        Atomic::TFloat => is_float_atomic,
1431        Atomic::TBool => is_bool_atomic,
1432        _ => return None,
1433    };
1434
1435    if arg_ty.types.is_empty() {
1436        return None;
1437    }
1438    let all_match = arg_ty.types.iter().all(&predicate);
1439    let none_match = !arg_ty.types.iter().any(predicate);
1440    if all_match {
1441        Some(if_true.clone())
1442    } else if none_match {
1443        Some(if_false.clone())
1444    } else {
1445        None
1446    }
1447}
1448
1449// ---------------------------------------------------------------------------
1450// Template substitution helpers
1451// ---------------------------------------------------------------------------
1452
1453/// Whether `substitute_templates` could change this atomic: it is either a
1454/// template reference itself or a container/callable that may hold one.
1455/// Mirrors the substituting arms of that function's match — keep in sync.
1456fn atomic_may_contain_templates(atomic: &Atomic) -> bool {
1457    match atomic {
1458        // Bare unqualified names double as template refs (docblock parser
1459        // workaround, see substitute_templates); qualified names only matter
1460        // when they carry generic params.
1461        Atomic::TNamedObject { fqcn, type_params } => {
1462            !type_params.is_empty() || !fqcn.contains('\\')
1463        }
1464        Atomic::TTemplateParam { .. }
1465        | Atomic::TArray { .. }
1466        | Atomic::TList { .. }
1467        | Atomic::TNonEmptyArray { .. }
1468        | Atomic::TNonEmptyList { .. }
1469        | Atomic::TKeyedArray { .. }
1470        | Atomic::TCallable { .. }
1471        | Atomic::TClosure { .. }
1472        | Atomic::TConditional { .. }
1473        | Atomic::TIntersection { .. }
1474        | Atomic::TClassString(Some(_))
1475        | Atomic::TInterfaceString(Some(_)) => true,
1476        _ => false,
1477    }
1478}
1479
1480fn substitute_in_fn_param(
1481    p: &crate::atomic::FnParam,
1482    bindings: &FxHashMap<Name, Type>,
1483) -> crate::atomic::FnParam {
1484    crate::atomic::FnParam {
1485        name: p.name,
1486        ty: p.ty.as_ref().map(|t| {
1487            let u = t.to_union();
1488            let substituted = u.substitute_templates(bindings);
1489            crate::compact::SimpleType::from_union(substituted)
1490        }),
1491        out_ty: p.out_ty.as_ref().map(|t| {
1492            let u = t.to_union();
1493            let substituted = u.substitute_templates(bindings);
1494            crate::compact::SimpleType::from_union(substituted)
1495        }),
1496        default: p.default.as_ref().map(|d| {
1497            let u = d.to_union();
1498            let substituted = u.substitute_templates(bindings);
1499            crate::compact::SimpleType::from_union(substituted)
1500        }),
1501        is_variadic: p.is_variadic,
1502        is_byref: p.is_byref,
1503        is_optional: p.is_optional,
1504    }
1505}
1506
1507// ---------------------------------------------------------------------------
1508// Atomic subtype (no codebase — structural check only)
1509// ---------------------------------------------------------------------------
1510
1511/// Structural `sub <: sup` for a single atomic pair, without hierarchy resolution.
1512pub fn atomic_subtype(sub: &Atomic, sup: &Atomic) -> bool {
1513    if sub == sup {
1514        return true;
1515    }
1516    match (sub, sup) {
1517        // Bottom type
1518        (Atomic::TNever, _) => true,
1519        // Top types — anything goes in both directions for mixed
1520        (_, Atomic::TMixed) => true,
1521        (Atomic::TMixed, _) => true,
1522        // Template param in supertype position: any value satisfies an unconstrained
1523        // template (as_type = mixed), or a constrained one if it satisfies the bound.
1524        // This handles union bounds like `T of string|list<I>|array<K, V>` where
1525        // I/K/V are free template params — any type satisfies them structurally.
1526        (_, Atomic::TTemplateParam { as_type, .. }) => {
1527            as_type.is_mixed() || as_type.types.iter().any(|b| atomic_subtype(sub, b))
1528        }
1529
1530        // Scalars
1531        (Atomic::TLiteralInt(_), Atomic::TInt) => true,
1532        (Atomic::TLiteralInt(_), Atomic::TNumeric) => true,
1533        (Atomic::TLiteralInt(_), Atomic::TScalar) => true,
1534        (Atomic::TLiteralInt(n), Atomic::TPositiveInt) => *n > 0,
1535        (Atomic::TLiteralInt(n), Atomic::TNonNegativeInt) => *n >= 0,
1536        (Atomic::TLiteralInt(n), Atomic::TNegativeInt) => *n < 0,
1537        (Atomic::TPositiveInt, Atomic::TInt) => true,
1538        (Atomic::TPositiveInt, Atomic::TNonNegativeInt) => true,
1539        (Atomic::TPositiveInt, Atomic::TNumeric) => true,
1540        (Atomic::TPositiveInt, Atomic::TScalar) => true,
1541        (Atomic::TNegativeInt, Atomic::TInt) => true,
1542        (Atomic::TNegativeInt, Atomic::TNumeric) => true,
1543        (Atomic::TNegativeInt, Atomic::TScalar) => true,
1544        (Atomic::TNonNegativeInt, Atomic::TInt) => true,
1545        (Atomic::TNonNegativeInt, Atomic::TNumeric) => true,
1546        (Atomic::TNonNegativeInt, Atomic::TScalar) => true,
1547        (Atomic::TIntRange { .. }, Atomic::TInt) => true,
1548        (Atomic::TIntRange { .. }, Atomic::TNumeric) => true,
1549        (Atomic::TIntRange { .. }, Atomic::TScalar) => true,
1550        // positive-int is int<1, ∞>: subtype of int<sup_min, ∞> when sup_min <= 1
1551        (Atomic::TPositiveInt, Atomic::TIntRange { min, max }) => {
1552            max.is_none() && min.is_none_or(|m| m <= 1)
1553        }
1554        // negative-int is int<-∞, -1>: subtype of int<-∞, sup_max> when sup_max >= -1
1555        (Atomic::TNegativeInt, Atomic::TIntRange { min, max }) => {
1556            min.is_none() && max.is_none_or(|m| m >= -1)
1557        }
1558        // non-negative-int is int<0, ∞>: subtype of int<sup_min, ∞> when sup_min <= 0
1559        (Atomic::TNonNegativeInt, Atomic::TIntRange { min, max }) => {
1560            max.is_none() && min.is_none_or(|m| m <= 0)
1561        }
1562        // A bounded int range is a subtype of a named int subtype when every value fits
1563        (Atomic::TIntRange { min: sub_min, .. }, Atomic::TPositiveInt) => {
1564            sub_min.is_some_and(|lo| lo >= 1)
1565        }
1566        (Atomic::TIntRange { min: sub_min, .. }, Atomic::TNonNegativeInt) => {
1567            sub_min.is_some_and(|lo| lo >= 0)
1568        }
1569        (Atomic::TIntRange { max: sub_max, .. }, Atomic::TNegativeInt) => {
1570            sub_max.is_some_and(|hi| hi <= -1)
1571        }
1572        // int<sub_min, sub_max> <: int<sup_min, sup_max> when ranges nest
1573        (
1574            Atomic::TIntRange {
1575                min: sub_min,
1576                max: sub_max,
1577            },
1578            Atomic::TIntRange {
1579                min: sup_min,
1580                max: sup_max,
1581            },
1582        ) => {
1583            let lower_ok = match (sub_min, sup_min) {
1584                (_, None) => true,
1585                (None, Some(_)) => false,
1586                (Some(sl), Some(su)) => sl >= su,
1587            };
1588            let upper_ok = match (sub_max, sup_max) {
1589                (None, None) | (Some(_), None) => true,
1590                (None, Some(_)) => false,
1591                (Some(sl), Some(su)) => sl <= su,
1592            };
1593            lower_ok && upper_ok
1594        }
1595
1596        (Atomic::TLiteralFloat(..), Atomic::TFloat) => true,
1597        (Atomic::TLiteralFloat(..), Atomic::TNumeric) => true,
1598        (Atomic::TLiteralFloat(..), Atomic::TScalar) => true,
1599
1600        (Atomic::TLiteralString(s), Atomic::TString) => {
1601            let _ = s;
1602            true
1603        }
1604        (Atomic::TLiteralString(s), Atomic::TCallableString) => {
1605            let _ = s;
1606            true
1607        }
1608        (Atomic::TLiteralString(s), Atomic::TNonEmptyString) => !s.is_empty(),
1609        (Atomic::TLiteralString(s), Atomic::TNumericString) => s.parse::<f64>().is_ok(),
1610        // A literal string is type-compatible with class-string; validate_class_string_argument
1611        // separately checks whether the string names a real class (UndefinedClass).
1612        (Atomic::TLiteralString(_), Atomic::TClassString(_)) => true,
1613        // Same, for interface-string; validate_interface_string_argument checks existence
1614        // and that the name actually resolves to an interface.
1615        (Atomic::TLiteralString(_), Atomic::TInterfaceString(_)) => true,
1616        (Atomic::TLiteralString(_), Atomic::TScalar) => true,
1617        (Atomic::TNonEmptyString, Atomic::TString) => true,
1618        (Atomic::TCallableString, Atomic::TString) => true,
1619        // numeric-string is always non-empty (e.g. "42", "-1", "0.5") — "" is not numeric.
1620        (Atomic::TNumericString, Atomic::TNonEmptyString) => true,
1621        (Atomic::TNumericString, Atomic::TString) => true,
1622        // A class/interface/callable/enum/trait name can never be the empty
1623        // string in real PHP — every one of these string-subtype atoms is
1624        // always non-empty, same reasoning as numeric-string above.
1625        (Atomic::TClassString(_), Atomic::TNonEmptyString) => true,
1626        (Atomic::TInterfaceString(_), Atomic::TNonEmptyString) => true,
1627        (Atomic::TCallableString, Atomic::TNonEmptyString) => true,
1628        (Atomic::TEnumString, Atomic::TNonEmptyString) => true,
1629        (Atomic::TTraitString, Atomic::TNonEmptyString) => true,
1630        (Atomic::TClassString(_), Atomic::TString) => true,
1631        (Atomic::TInterfaceString(_), Atomic::TString) => true,
1632        // Every interface-string is a valid class-string: PHP doesn't distinguish
1633        // the two at runtime — both are just strings naming a class-like symbol.
1634        // Instantiability (`new $x()`) is guarded separately, since an interface
1635        // name can never be `new`-ed even though it satisfies class-string.
1636        (Atomic::TInterfaceString(_), Atomic::TClassString(None)) => true,
1637        (Atomic::TInterfaceString(Some(a)), Atomic::TClassString(Some(b))) => a == b,
1638        (Atomic::TEnumString, Atomic::TString) => true,
1639        (Atomic::TTraitString, Atomic::TString) => true,
1640
1641        (Atomic::TTrue, Atomic::TBool) => true,
1642        (Atomic::TFalse, Atomic::TBool) => true,
1643
1644        (Atomic::TInt, Atomic::TNumeric) => true,
1645        (Atomic::TFloat, Atomic::TNumeric) => true,
1646        (Atomic::TIntegralFloat, Atomic::TNumeric) => true,
1647        (Atomic::TNumericString, Atomic::TNumeric) => true,
1648
1649        (Atomic::TInt, Atomic::TScalar) => true,
1650        (Atomic::TFloat, Atomic::TScalar) => true,
1651        (Atomic::TIntegralFloat, Atomic::TScalar) => true,
1652        (Atomic::TString, Atomic::TScalar) => true,
1653        (Atomic::TBool, Atomic::TScalar) => true,
1654        (Atomic::TNumeric, Atomic::TScalar) => true,
1655        (Atomic::TTrue, Atomic::TScalar) => true,
1656        (Atomic::TFalse, Atomic::TScalar) => true,
1657        // Every refined string atom is, at runtime, still just a `string` —
1658        // and therefore a `scalar` — same as the already-covered TLiteralString
1659        // and int-family refinements just above/below.
1660        (Atomic::TNonEmptyString, Atomic::TScalar) => true,
1661        (Atomic::TNumericString, Atomic::TScalar) => true,
1662        (Atomic::TCallableString, Atomic::TScalar) => true,
1663        (Atomic::TClassString(_), Atomic::TScalar) => true,
1664        (Atomic::TInterfaceString(_), Atomic::TScalar) => true,
1665        (Atomic::TEnumString, Atomic::TScalar) => true,
1666        (Atomic::TTraitString, Atomic::TScalar) => true,
1667
1668        // Object hierarchy (structural, no codebase)
1669        (Atomic::TNamedObject { .. }, Atomic::TObject) => true,
1670        (Atomic::TStaticObject { .. }, Atomic::TObject) => true,
1671        (Atomic::TSelf { .. }, Atomic::TObject) => true,
1672        // An enum-case literal is, at runtime, an object.
1673        (Atomic::TLiteralEnumCase { .. }, Atomic::TObject) => true,
1674        // self(X) and static(X) satisfy TNamedObject(X) with same FQCN
1675        (Atomic::TSelf { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
1676        (Atomic::TStaticObject { fqcn: a }, Atomic::TNamedObject { fqcn: b, .. }) => a == b,
1677        // TNamedObject(X) satisfies self(X) / static(X) with same FQCN
1678        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TSelf { fqcn: b }) => a == b,
1679        (Atomic::TNamedObject { fqcn: a, .. }, Atomic::TStaticObject { fqcn: b }) => a == b,
1680        // An enum-case literal satisfies its own bare enum type (enums are
1681        // represented as TNamedObject).
1682        (Atomic::TLiteralEnumCase { enum_fqcn, .. }, Atomic::TNamedObject { fqcn, .. }) => {
1683            enum_fqcn == fqcn
1684        }
1685        // Bare generic property accepts parameterized value: Box accepts Box<string>.
1686        // The reverse is NOT true — bare Box value does not satisfy Box<string> property
1687        // (invariant check). Only sup being bare (empty type_params) is the wildcard.
1688        (
1689            Atomic::TNamedObject {
1690                fqcn: sub_fqcn,
1691                type_params: sub_params,
1692            },
1693            Atomic::TNamedObject {
1694                fqcn: sup_fqcn,
1695                type_params: sup_params,
1696            },
1697        ) => {
1698            sub_fqcn == sup_fqcn
1699                && (sup_params.is_empty() || type_params_compatible(sub_params, sup_params))
1700        }
1701
1702        // TIntegralFloat is a subtype of float (all integral floats are floats)
1703        (Atomic::TIntegralFloat, Atomic::TFloat) => true,
1704
1705        // Literal int widens to float in PHP
1706        (Atomic::TLiteralInt(_), Atomic::TFloat) => true,
1707        (Atomic::TPositiveInt, Atomic::TFloat) => true,
1708        (Atomic::TNegativeInt, Atomic::TFloat) => true,
1709        (Atomic::TNonNegativeInt, Atomic::TFloat) => true,
1710        (Atomic::TInt, Atomic::TFloat) => true,
1711        (Atomic::TIntRange { .. }, Atomic::TFloat) => true,
1712
1713        // Literal int satisfies an int range only when the value is within bounds
1714        (Atomic::TLiteralInt(n), Atomic::TIntRange { min, max }) => {
1715            min.is_none_or(|lo| *n >= lo) && max.is_none_or(|hi| *n <= hi)
1716        }
1717
1718        // PHP callables: string and array are valid callable values
1719        (Atomic::TString, Atomic::TCallable { .. }) => true,
1720        (Atomic::TNonEmptyString, Atomic::TCallable { .. }) => true,
1721        (Atomic::TLiteralString(_), Atomic::TCallable { .. }) => true,
1722        (Atomic::TArray { .. }, Atomic::TCallable { .. }) => true,
1723        (Atomic::TNonEmptyArray { .. }, Atomic::TCallable { .. }) => true,
1724        (Atomic::TKeyedArray { .. }, Atomic::TCallable { .. }) => true,
1725
1726        // Closure <: callable, typed Closure <: Closure
1727        (Atomic::TClosure { .. }, Atomic::TCallable { .. }) => true,
1728        // callable <: Closure: callable is wider but not flagged at default error level
1729        (Atomic::TCallable { .. }, Atomic::TClosure { .. }) => true,
1730        // TClosure <: TClosure: check arity, per-parameter contravariance, and
1731        // return covariance for scalar/array-shaped types, where a purely
1732        // structural check is reliable. A named-class (or nested-callable)
1733        // param/return is skipped rather than checked — this checker has no
1734        // database access to walk `extends`/`implements`, so it can't safely
1735        // tell a real Liskov violation apart from a legitimate subclass/
1736        // superclass substitution; treating it as compatible avoids false
1737        // positives on that far more common case at the cost of missing the
1738        // narrower nominal-variance violation.
1739        (Atomic::TClosure { data: sub }, Atomic::TClosure { data: sup }) => {
1740            fn has_nominal_type(t: &Type) -> bool {
1741                t.types.iter().any(|a| {
1742                    matches!(
1743                        a,
1744                        Atomic::TNamedObject { .. }
1745                            | Atomic::TSelf { .. }
1746                            | Atomic::TStaticObject { .. }
1747                            | Atomic::TTemplateParam { .. }
1748                            | Atomic::TClosure { .. }
1749                            | Atomic::TCallable { .. }
1750                    )
1751                })
1752            }
1753            let sub_required = sub
1754                .params
1755                .iter()
1756                .filter(|p| !p.is_optional && !p.is_variadic)
1757                .count();
1758            if sub_required > sup.params.len() {
1759                false
1760            } else {
1761                let params_ok = sup.params.iter().enumerate().all(|(i, sup_param)| {
1762                    let Some(sub_param) = sub.params.get(i) else {
1763                        return true;
1764                    };
1765                    if sub_param.is_optional || sub_param.is_variadic {
1766                        return true;
1767                    }
1768                    let (Some(sub_ty), Some(sup_ty)) =
1769                        (sub_param.ty.as_ref(), sup_param.ty.as_ref())
1770                    else {
1771                        return true;
1772                    };
1773                    let (sub_u, sup_u) = (sub_ty.to_union(), sup_ty.to_union());
1774                    if has_nominal_type(&sub_u) || has_nominal_type(&sup_u) {
1775                        return true;
1776                    }
1777                    // Contravariance: whatever `sup` promises to pass must be
1778                    // acceptable to `sub`'s declared parameter type.
1779                    sup_u.is_subtype_structural(&sub_u)
1780                });
1781                params_ok
1782                    && (sub.return_type.is_mixed()
1783                        || sup.return_type.is_mixed()
1784                        || has_nominal_type(&sub.return_type)
1785                        || has_nominal_type(&sup.return_type)
1786                        || sub.return_type.is_subtype_structural(&sup.return_type))
1787            }
1788        }
1789        // callable <: callable (trivial)
1790        (Atomic::TCallable { .. }, Atomic::TCallable { .. }) => true,
1791        // TClosure satisfies `Closure` named object or `object`
1792        (Atomic::TClosure { .. }, Atomic::TNamedObject { fqcn, .. }) => {
1793            fqcn.as_ref().eq_ignore_ascii_case("closure")
1794        }
1795        (Atomic::TClosure { .. }, Atomic::TObject) => true,
1796        // bare `Closure` (named object without signature) satisfies any typed Closure(): T
1797        (Atomic::TNamedObject { fqcn, .. }, Atomic::TClosure { .. }) => {
1798            fqcn.as_ref().eq_ignore_ascii_case("closure")
1799        }
1800        // `Closure` named-object satisfies `callable`
1801        (Atomic::TNamedObject { fqcn, .. }, Atomic::TCallable { .. }) => {
1802            fqcn.as_ref().eq_ignore_ascii_case("closure")
1803        }
1804
1805        // A&B&C <: D&E iff every part of the supertype is satisfied by some
1806        // part of the subtype — an intersection with MORE conjuncts is the
1807        // more specific (sub)type, so `Countable&ArrayAccess&Iterator` is a
1808        // subtype of `Countable&ArrayAccess`. Purely structural (each part's
1809        // own `is_subtype_structural` recurses, so e.g. two differently-named
1810        // interfaces only match when equal — same conservative stance as the
1811        // TClosure<:TClosure arm above for named types).
1812        (
1813            Atomic::TIntersection { parts: sub_parts },
1814            Atomic::TIntersection { parts: sup_parts },
1815        ) => sup_parts.iter().all(|sup_part| {
1816            sub_parts
1817                .iter()
1818                .any(|sub_part| sub_part.is_subtype_structural(sup_part))
1819        }),
1820
1821        // List <: array  (list key is always int; int must satisfy the array's key type)
1822        (Atomic::TList { value }, Atomic::TArray { key, value: av }) => {
1823            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
1824        }
1825        (Atomic::TNonEmptyList { value }, Atomic::TArray { key, value: av }) => {
1826            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
1827        }
1828        (Atomic::TNonEmptyList { value }, Atomic::TNonEmptyArray { key, value: av }) => {
1829            Type::single(Atomic::TInt).is_subtype_structural(key) && value.is_subtype_structural(av)
1830        }
1831        (Atomic::TNonEmptyList { value }, Atomic::TList { value: lv }) => {
1832            value.is_subtype_structural(lv)
1833        }
1834        // array<int, X> is accepted where list<X> or non-empty-list<X> expected
1835        (Atomic::TArray { key, value: av }, Atomic::TList { value: lv }) => {
1836            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1837                && av.is_subtype_structural(lv)
1838        }
1839        (Atomic::TArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1840            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1841                && av.is_subtype_structural(lv)
1842        }
1843        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TList { value: lv }) => {
1844            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1845                && av.is_subtype_structural(lv)
1846        }
1847        (Atomic::TNonEmptyArray { key, value: av }, Atomic::TNonEmptyList { value: lv }) => {
1848            matches!(key.types.as_slice(), [Atomic::TInt | Atomic::TMixed])
1849                && av.is_subtype_structural(lv)
1850        }
1851        // TList <: TList value covariance
1852        (Atomic::TList { value: v1 }, Atomic::TList { value: v2 }) => v1.is_subtype_structural(v2),
1853        (Atomic::TNonEmptyArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1854            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1855        }
1856
1857        // array<A, B> <: array<C, D>  iff  A <: C && B <: D
1858        (Atomic::TArray { key: k1, value: v1 }, Atomic::TArray { key: k2, value: v2 }) => {
1859            k1.is_subtype_structural(k2) && v1.is_subtype_structural(v2)
1860        }
1861
1862        // A keyed/shape array is a subtype of array<K, V> / non-empty-array<K, V>
1863        // when all property KEYS are subtypes of K. Value compatibility is checked
1864        // structurally only for scalar types; named-object values are deferred to
1865        // class-hierarchy checks in return_arrays_compatible (mir-analyzer).
1866        // Open shapes (is_open=true) may have extra unknown keys beyond `properties`:
1867        // those stay unchecked (permissive), but every KNOWN property must still
1868        // satisfy K/V regardless of openness — an open shape isn't a license to skip
1869        // checking the keys it does declare.
1870        (Atomic::TKeyedArray { properties, .. }, Atomic::TArray { key, value }) => {
1871            properties.iter().all(|(prop_key, prop)| {
1872                let key_atomic = match prop_key {
1873                    crate::atomic::ArrayKey::String(s) => Atomic::TLiteralString(s.clone()),
1874                    crate::atomic::ArrayKey::Int(n) => Atomic::TLiteralInt(*n),
1875                };
1876                if !Type::single(key_atomic).is_subtype_structural(key) {
1877                    return false; // key mismatch — definitively incompatible
1878                }
1879                // Named-object values require class-hierarchy checks not available here.
1880                let has_named_obj = prop.ty.types.iter().any(|a| {
1881                    matches!(
1882                        a,
1883                        Atomic::TNamedObject { .. }
1884                            | Atomic::TSelf { .. }
1885                            | Atomic::TStaticObject { .. }
1886                            | Atomic::TClosure { .. }
1887                            | Atomic::TTemplateParam { .. }
1888                    )
1889                });
1890                has_named_obj || prop.ty.is_subtype_structural(value)
1891            })
1892        }
1893        (
1894            Atomic::TKeyedArray {
1895                properties,
1896                is_open,
1897                ..
1898            },
1899            Atomic::TNonEmptyArray { key, value },
1900        ) => {
1901            (*is_open || properties.iter().any(|(_, p)| !p.optional))
1902                && properties.iter().all(|(prop_key, prop)| {
1903                    let key_atomic = match prop_key {
1904                        crate::atomic::ArrayKey::String(s) => Atomic::TLiteralString(s.clone()),
1905                        crate::atomic::ArrayKey::Int(n) => Atomic::TLiteralInt(*n),
1906                    };
1907                    if !Type::single(key_atomic).is_subtype_structural(key) {
1908                        return false;
1909                    }
1910                    let has_named_obj = prop.ty.types.iter().any(|a| {
1911                        matches!(
1912                            a,
1913                            Atomic::TNamedObject { .. }
1914                                | Atomic::TSelf { .. }
1915                                | Atomic::TStaticObject { .. }
1916                                | Atomic::TClosure { .. }
1917                                | Atomic::TTemplateParam { .. }
1918                        )
1919                    });
1920                    has_named_obj || prop.ty.is_subtype_structural(value)
1921                })
1922        }
1923
1924        // A list-shaped keyed array (is_list=true, all int keys) is a subtype of list<X>.
1925        (
1926            Atomic::TKeyedArray {
1927                properties,
1928                is_list,
1929                ..
1930            },
1931            Atomic::TList { value: lv },
1932        ) => *is_list && properties.values().all(|p| p.ty.is_subtype_structural(lv)),
1933        (
1934            Atomic::TKeyedArray {
1935                properties,
1936                is_list,
1937                ..
1938            },
1939            Atomic::TNonEmptyList { value: lv },
1940        ) => {
1941            *is_list
1942                && !properties.is_empty()
1943                && properties.values().all(|p| p.ty.is_subtype_structural(lv))
1944        }
1945
1946        // Two shapes: every sup key must be satisfied (present+compatible, or
1947        // absent-but-optional/sub-open), and sub may not have keys sup doesn't
1948        // declare unless sup itself is open. Named-object values are deferred
1949        // to class-hierarchy checks, same as the TArray/TList sup arms above.
1950        (
1951            Atomic::TKeyedArray {
1952                properties: sub_props,
1953                is_open: sub_open,
1954                ..
1955            },
1956            Atomic::TKeyedArray {
1957                properties: sup_props,
1958                is_open: sup_open,
1959                ..
1960            },
1961        ) => {
1962            let keys_satisfied = sup_props
1963                .iter()
1964                .all(|(key, sup_prop)| match sub_props.get(key) {
1965                    Some(sub_prop) => {
1966                        // A key merely optional on the sub side may legally be
1967                        // absent at runtime, so it can't satisfy a sup key that
1968                        // requires it present.
1969                        if !sup_prop.optional && sub_prop.optional {
1970                            return false;
1971                        }
1972                        let has_named_obj = sup_prop.ty.types.iter().any(|a| {
1973                            matches!(
1974                                a,
1975                                Atomic::TNamedObject { .. }
1976                                    | Atomic::TSelf { .. }
1977                                    | Atomic::TStaticObject { .. }
1978                                    | Atomic::TClosure { .. }
1979                                    | Atomic::TTemplateParam { .. }
1980                            )
1981                        });
1982                        has_named_obj || sub_prop.ty.is_subtype_structural(&sup_prop.ty)
1983                    }
1984                    None => sup_prop.optional || *sub_open,
1985                });
1986            let no_undeclared_extras =
1987                *sup_open || sub_props.keys().all(|k| sup_props.contains_key(k));
1988            keys_satisfied && no_undeclared_extras
1989        }
1990
1991        _ => false,
1992    }
1993}
1994
1995/// Whether each generic type-argument in `sub` is compatible with the
1996/// corresponding argument in `sup`. Arguments are invariant (require structural
1997/// equality) with one exception: an empty array literal (`array{}`) is accepted
1998/// against any array/list argument, so `new Box([])` — inferred as
1999/// `Box<array{}>` — satisfies a declared `Box<list<T>>` for any `T`.
2000fn type_params_compatible(sub: &[Type], sup: &[Type]) -> bool {
2001    if sub.len() != sup.len() {
2002        return false;
2003    }
2004    sub.iter()
2005        .zip(sup.iter())
2006        .all(|(a, b)| a == b || (is_empty_array_literal(a) && is_array_like(b)))
2007}
2008
2009/// True for a non-empty union whose atoms are all empty keyed arrays (`array{}`),
2010/// i.e. the type of an empty array literal `[]`.
2011fn is_empty_array_literal(t: &Type) -> bool {
2012    !t.types.is_empty()
2013        && t.types.iter().all(
2014            |atom| matches!(atom, Atomic::TKeyedArray { properties, .. } if properties.is_empty()),
2015        )
2016}
2017
2018/// True for a non-empty union whose atoms are all array/list types.
2019fn is_array_like(t: &Type) -> bool {
2020    !t.types.is_empty() && t.types.iter().all(|atom| atom.is_array())
2021}
2022
2023// ---------------------------------------------------------------------------
2024// Tests
2025// ---------------------------------------------------------------------------
2026
2027#[cfg(test)]
2028mod tests {
2029    use std::sync::Arc;
2030
2031    use super::*;
2032
2033    fn conditional(
2034        param_name: Option<Name>,
2035        subject: Type,
2036        if_true: Type,
2037        if_false: Type,
2038    ) -> Atomic {
2039        Atomic::TConditional {
2040            data: Box::new(crate::atomic::ConditionalData {
2041                param_name,
2042                subject,
2043                if_true,
2044                if_false,
2045            }),
2046        }
2047    }
2048
2049    #[test]
2050    fn single_is_single() {
2051        let u = Type::single(Atomic::TString);
2052        assert!(u.is_single());
2053        assert!(!u.is_nullable());
2054    }
2055
2056    #[test]
2057    fn nullable_has_null() {
2058        let u = Type::nullable(Atomic::TString);
2059        assert!(u.is_nullable());
2060        assert_eq!(u.types.len(), 2);
2061    }
2062
2063    #[test]
2064    fn add_type_deduplicates() {
2065        let mut u = Type::single(Atomic::TString);
2066        u.add_type(Atomic::TString);
2067        assert_eq!(u.types.len(), 1);
2068    }
2069
2070    #[test]
2071    fn array_key_is_int_string() {
2072        let k = Type::array_key();
2073        assert!(k.is_array_key());
2074        assert_eq!(k.types.len(), 2);
2075    }
2076
2077    #[test]
2078    fn is_array_key_false_for_plain_int() {
2079        assert!(!Type::int().is_array_key());
2080    }
2081
2082    #[test]
2083    fn is_array_key_false_for_mixed() {
2084        assert!(!Type::mixed().is_array_key());
2085    }
2086
2087    #[test]
2088    fn is_array_key_false_for_int_string_null() {
2089        let mut u = Type::array_key();
2090        u.add_type(Atomic::TNull);
2091        assert!(!u.is_array_key());
2092    }
2093
2094    #[test]
2095    fn add_type_literal_subsumed_by_base() {
2096        let mut u = Type::single(Atomic::TInt);
2097        u.add_type(Atomic::TLiteralInt(42));
2098        assert_eq!(u.types.len(), 1);
2099        assert!(matches!(u.types[0], Atomic::TInt));
2100    }
2101
2102    #[test]
2103    fn true_then_false_merges_to_bool() {
2104        let mut u = Type::single(Atomic::TTrue);
2105        u.add_type(Atomic::TFalse);
2106        assert_eq!(u.types.len(), 1);
2107        assert!(matches!(u.types[0], Atomic::TBool));
2108    }
2109
2110    #[test]
2111    fn false_then_true_merges_to_bool() {
2112        let mut u = Type::single(Atomic::TFalse);
2113        u.add_type(Atomic::TTrue);
2114        assert_eq!(u.types.len(), 1);
2115        assert!(matches!(u.types[0], Atomic::TBool));
2116    }
2117
2118    #[test]
2119    fn true_alone_stays_true() {
2120        let u = Type::single(Atomic::TTrue);
2121        assert_eq!(u.types.len(), 1);
2122        assert!(matches!(u.types[0], Atomic::TTrue));
2123    }
2124
2125    #[test]
2126    fn true_false_merge_preserves_other_union_members() {
2127        let mut u = Type::single(Atomic::TTrue);
2128        u.add_type(Atomic::TNull);
2129        u.add_type(Atomic::TFalse);
2130        assert_eq!(u.types.len(), 2);
2131        assert!(u.contains(|t| matches!(t, Atomic::TBool)));
2132        assert!(u.contains(|t| matches!(t, Atomic::TNull)));
2133    }
2134
2135    #[test]
2136    fn add_type_base_widens_literals() {
2137        let mut u = Type::single(Atomic::TLiteralInt(1));
2138        u.add_type(Atomic::TLiteralInt(2));
2139        u.add_type(Atomic::TInt);
2140        assert_eq!(u.types.len(), 1);
2141        assert!(matches!(u.types[0], Atomic::TInt));
2142    }
2143
2144    #[test]
2145    fn mixed_subsumes_everything() {
2146        let mut u = Type::single(Atomic::TString);
2147        u.add_type(Atomic::TMixed);
2148        assert_eq!(u.types.len(), 1);
2149        assert!(u.is_mixed());
2150    }
2151
2152    #[test]
2153    fn remove_null() {
2154        let u = Type::nullable(Atomic::TString);
2155        let narrowed = u.remove_null();
2156        assert!(!narrowed.is_nullable());
2157        assert_eq!(narrowed.types.len(), 1);
2158    }
2159
2160    #[test]
2161    fn narrow_to_truthy_removes_null_false() {
2162        let mut u = Type::empty();
2163        u.add_type(Atomic::TString);
2164        u.add_type(Atomic::TNull);
2165        u.add_type(Atomic::TFalse);
2166        let truthy = u.narrow_to_truthy();
2167        assert!(!truthy.is_nullable());
2168        assert!(!truthy.contains(|t| matches!(t, Atomic::TFalse)));
2169    }
2170
2171    #[test]
2172    fn merge_combines_types() {
2173        let a = Type::single(Atomic::TString);
2174        let b = Type::single(Atomic::TInt);
2175        let merged = Type::merge(&a, &b);
2176        assert_eq!(merged.types.len(), 2);
2177    }
2178
2179    #[test]
2180    fn intersect_keeps_narrower_side_not_self() {
2181        // int ∩ (1|2) must keep the narrower `1|2`, not the wider `int` —
2182        // this is exactly what a `match ($x) { 1, 2 => ... }` arm relies on
2183        // to narrow $x inside its body.
2184        let int_ty = Type::single(Atomic::TInt);
2185        let mut literals = Type::empty();
2186        literals.add_type(Atomic::TLiteralInt(1));
2187        literals.add_type(Atomic::TLiteralInt(2));
2188
2189        let narrowed = int_ty.intersect_with(&literals);
2190        assert_eq!(narrowed.types.len(), 2);
2191        assert!(narrowed.contains(|t| matches!(t, Atomic::TLiteralInt(1))));
2192        assert!(narrowed.contains(|t| matches!(t, Atomic::TLiteralInt(2))));
2193        assert!(!narrowed.contains(|t| matches!(t, Atomic::TInt)));
2194    }
2195
2196    #[test]
2197    fn subtype_literal_int_under_int() {
2198        let sub = Type::single(Atomic::TLiteralInt(5));
2199        let sup = Type::single(Atomic::TInt);
2200        assert!(sub.is_subtype_structural(&sup));
2201    }
2202
2203    #[test]
2204    fn subtype_never_is_bottom() {
2205        let never = Type::never();
2206        let string = Type::single(Atomic::TString);
2207        assert!(never.is_subtype_structural(&string));
2208    }
2209
2210    #[test]
2211    fn subtype_everything_under_mixed() {
2212        let string = Type::single(Atomic::TString);
2213        let mixed = Type::mixed();
2214        assert!(string.is_subtype_structural(&mixed));
2215    }
2216
2217    #[test]
2218    fn subtype_enum_case_under_own_enum() {
2219        let sub = Type::single(Atomic::TLiteralEnumCase {
2220            enum_fqcn: Name::new("RoundingMode"),
2221            case_name: Name::new("Unnecessary"),
2222        });
2223        let sup = Type::single(Atomic::TNamedObject {
2224            fqcn: Name::new("RoundingMode"),
2225            type_params: empty_type_params(),
2226        });
2227        assert!(sub.is_subtype_structural(&sup));
2228    }
2229
2230    #[test]
2231    fn enum_case_not_subtype_of_unrelated_enum() {
2232        let sub = Type::single(Atomic::TLiteralEnumCase {
2233            enum_fqcn: Name::new("RoundingMode"),
2234            case_name: Name::new("Unnecessary"),
2235        });
2236        let sup = Type::single(Atomic::TNamedObject {
2237            fqcn: Name::new("Suit"),
2238            type_params: empty_type_params(),
2239        });
2240        assert!(!sub.is_subtype_structural(&sup));
2241    }
2242
2243    #[test]
2244    fn subtype_enum_case_under_bare_object() {
2245        let sub = Type::single(Atomic::TLiteralEnumCase {
2246            enum_fqcn: Name::new("RoundingMode"),
2247            case_name: Name::new("Unnecessary"),
2248        });
2249        let sup = Type::single(Atomic::TObject);
2250        assert!(sub.is_subtype_structural(&sup));
2251    }
2252
2253    #[test]
2254    fn template_substitution() {
2255        let mut bindings = FxHashMap::default();
2256        bindings.insert(Name::new("T"), Type::single(Atomic::TString));
2257
2258        let tmpl = Type::single(Atomic::TTemplateParam {
2259            name: Name::new("T"),
2260            as_type: Box::new(Type::mixed()),
2261            defining_entity: Name::new("MyClass"),
2262        });
2263
2264        let resolved = tmpl.substitute_templates(&bindings);
2265        assert_eq!(resolved.types.len(), 1);
2266        assert!(matches!(resolved.types[0], Atomic::TString));
2267    }
2268
2269    #[test]
2270    fn intersection_is_object() {
2271        let parts = vec![
2272            Type::single(Atomic::TNamedObject {
2273                fqcn: Name::new("Iterator"),
2274                type_params: empty_type_params(),
2275            }),
2276            Type::single(Atomic::TNamedObject {
2277                fqcn: Name::new("Countable"),
2278                type_params: empty_type_params(),
2279            }),
2280        ];
2281        let atomic = Atomic::TIntersection {
2282            parts: vec_to_type_params(parts),
2283        };
2284        assert!(atomic.is_object());
2285        assert!(!atomic.can_be_falsy());
2286        assert!(atomic.can_be_truthy());
2287    }
2288
2289    #[test]
2290    fn intersection_display_two_parts() {
2291        let parts = vec![
2292            Type::single(Atomic::TNamedObject {
2293                fqcn: Name::new("Iterator"),
2294                type_params: empty_type_params(),
2295            }),
2296            Type::single(Atomic::TNamedObject {
2297                fqcn: Name::new("Countable"),
2298                type_params: empty_type_params(),
2299            }),
2300        ];
2301        let u = Type::single(Atomic::TIntersection {
2302            parts: vec_to_type_params(parts),
2303        });
2304        assert_eq!(format!("{u}"), "Iterator&Countable");
2305    }
2306
2307    #[test]
2308    fn intersection_display_three_parts() {
2309        let parts = vec![
2310            Type::single(Atomic::TNamedObject {
2311                fqcn: Name::new("A"),
2312                type_params: empty_type_params(),
2313            }),
2314            Type::single(Atomic::TNamedObject {
2315                fqcn: Name::new("B"),
2316                type_params: empty_type_params(),
2317            }),
2318            Type::single(Atomic::TNamedObject {
2319                fqcn: Name::new("C"),
2320                type_params: empty_type_params(),
2321            }),
2322        ];
2323        let u = Type::single(Atomic::TIntersection {
2324            parts: vec_to_type_params(parts),
2325        });
2326        assert_eq!(format!("{u}"), "A&B&C");
2327    }
2328
2329    #[test]
2330    fn intersection_in_nullable_union_display() {
2331        let intersection = Atomic::TIntersection {
2332            parts: vec_to_type_params(vec![
2333                Type::single(Atomic::TNamedObject {
2334                    fqcn: Name::new("Iterator"),
2335                    type_params: empty_type_params(),
2336                }),
2337                Type::single(Atomic::TNamedObject {
2338                    fqcn: Name::new("Countable"),
2339                    type_params: empty_type_params(),
2340                }),
2341            ]),
2342        };
2343        let mut u = Type::single(intersection);
2344        u.add_type(Atomic::TNull);
2345        assert!(u.is_nullable());
2346        assert!(u.contains(|t| matches!(t, Atomic::TIntersection { .. })));
2347    }
2348
2349    // --- substitute_templates coverage for previously-missing arms ----------
2350
2351    fn t_param(name: &str) -> Type {
2352        Type::single(Atomic::TTemplateParam {
2353            name: Name::new(name),
2354            as_type: Box::new(Type::mixed()),
2355            defining_entity: Name::new("Fn"),
2356        })
2357    }
2358
2359    fn bindings_t_string() -> FxHashMap<Name, Type> {
2360        let mut b = FxHashMap::default();
2361        b.insert(Name::new("T"), Type::single(Atomic::TString));
2362        b
2363    }
2364
2365    #[test]
2366    fn substitute_non_empty_array_key_and_value() {
2367        let ty = Type::single(Atomic::TNonEmptyArray {
2368            key: Box::new(t_param("T")),
2369            value: Box::new(t_param("T")),
2370        });
2371        let result = ty.substitute_templates(&bindings_t_string());
2372        assert_eq!(result.types.len(), 1);
2373        let Atomic::TNonEmptyArray { key, value } = &result.types[0] else {
2374            panic!("expected TNonEmptyArray");
2375        };
2376        assert!(matches!(key.types[0], Atomic::TString));
2377        assert!(matches!(value.types[0], Atomic::TString));
2378    }
2379
2380    #[test]
2381    fn substitute_non_empty_list_value() {
2382        let ty = Type::single(Atomic::TNonEmptyList {
2383            value: Box::new(t_param("T")),
2384        });
2385        let result = ty.substitute_templates(&bindings_t_string());
2386        let Atomic::TNonEmptyList { value } = &result.types[0] else {
2387            panic!("expected TNonEmptyList");
2388        };
2389        assert!(matches!(value.types[0], Atomic::TString));
2390    }
2391
2392    #[test]
2393    fn substitute_keyed_array_property_types() {
2394        use crate::atomic::{ArrayKey, KeyedProperty};
2395        use indexmap::IndexMap;
2396        let mut props = IndexMap::new();
2397        props.insert(
2398            ArrayKey::String(Arc::from("name")),
2399            KeyedProperty {
2400                ty: t_param("T"),
2401                optional: false,
2402            },
2403        );
2404        props.insert(
2405            ArrayKey::String(Arc::from("tag")),
2406            KeyedProperty {
2407                ty: t_param("T"),
2408                optional: true,
2409            },
2410        );
2411        let ty = Type::single(Atomic::TKeyedArray {
2412            properties: Box::new(props),
2413            is_open: true,
2414            is_list: false,
2415        });
2416        let result = ty.substitute_templates(&bindings_t_string());
2417        let Atomic::TKeyedArray {
2418            properties,
2419            is_open,
2420            is_list,
2421        } = &result.types[0]
2422        else {
2423            panic!("expected TKeyedArray");
2424        };
2425        assert!(is_open);
2426        assert!(!is_list);
2427        assert!(matches!(
2428            properties[&ArrayKey::String(Arc::from("name"))].ty.types[0],
2429            Atomic::TString
2430        ));
2431        assert!(properties[&ArrayKey::String(Arc::from("tag"))].optional);
2432        assert!(matches!(
2433            properties[&ArrayKey::String(Arc::from("tag"))].ty.types[0],
2434            Atomic::TString
2435        ));
2436    }
2437
2438    #[test]
2439    fn substitute_callable_params_and_return() {
2440        use crate::atomic::FnParam;
2441        let ty = Type::single(Atomic::TCallable {
2442            params: Some(Box::new([FnParam {
2443                name: Name::new("x"),
2444                ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
2445                out_ty: None,
2446                default: None,
2447                is_variadic: false,
2448                is_byref: false,
2449                is_optional: false,
2450            }])),
2451            return_type: Some(Box::new(t_param("T"))),
2452        });
2453        let result = ty.substitute_templates(&bindings_t_string());
2454        let Atomic::TCallable {
2455            params,
2456            return_type,
2457        } = &result.types[0]
2458        else {
2459            panic!("expected TCallable");
2460        };
2461        let param_ty = params.as_ref().unwrap()[0].ty.as_ref().unwrap();
2462        let param_union = param_ty.to_union();
2463        assert!(matches!(param_union.types[0], Atomic::TString));
2464        let ret = return_type.as_ref().unwrap();
2465        assert!(matches!(ret.types[0], Atomic::TString));
2466    }
2467
2468    #[test]
2469    fn substitute_callable_bare_no_panic() {
2470        // callable with no params/return — must not panic and must pass through unchanged
2471        let ty = Type::single(Atomic::TCallable {
2472            params: None,
2473            return_type: None,
2474        });
2475        let result = ty.substitute_templates(&bindings_t_string());
2476        assert!(matches!(
2477            result.types[0],
2478            Atomic::TCallable {
2479                params: None,
2480                return_type: None
2481            }
2482        ));
2483    }
2484
2485    #[test]
2486    fn substitute_closure_params_return_and_this() {
2487        use crate::atomic::FnParam;
2488        let ty = Type::single(Atomic::TClosure {
2489            data: Box::new(crate::atomic::ClosureData {
2490                params: Box::new([FnParam {
2491                    name: Name::new("a"),
2492                    ty: Some(crate::compact::SimpleType::from_union(t_param("T"))),
2493                    out_ty: None,
2494                    default: Some(crate::compact::SimpleType::from_union(t_param("T"))),
2495                    is_variadic: true,
2496                    is_byref: true,
2497                    is_optional: true,
2498                }]),
2499                return_type: t_param("T"),
2500                this_type: Some(t_param("T")),
2501            }),
2502        });
2503        let result = ty.substitute_templates(&bindings_t_string());
2504        let Atomic::TClosure { data } = &result.types[0] else {
2505            panic!("expected TClosure");
2506        };
2507        let (params, return_type, this_type) = (&data.params, &data.return_type, &data.this_type);
2508        let p = &params[0];
2509        let ty_union = p.ty.as_ref().unwrap().to_union();
2510        let default_union = p.default.as_ref().unwrap().to_union();
2511        assert!(matches!(ty_union.types[0], Atomic::TString));
2512        assert!(matches!(default_union.types[0], Atomic::TString));
2513        // flags preserved
2514        assert!(p.is_variadic);
2515        assert!(p.is_byref);
2516        assert!(p.is_optional);
2517        assert!(matches!(return_type.types[0], Atomic::TString));
2518        assert!(matches!(
2519            this_type.as_ref().unwrap().types[0],
2520            Atomic::TString
2521        ));
2522    }
2523
2524    #[test]
2525    fn substitute_conditional_all_branches() {
2526        let ty = Type::single(conditional(
2527            None,
2528            t_param("T"),
2529            t_param("T"),
2530            Type::single(Atomic::TInt),
2531        ));
2532        let result = ty.substitute_templates(&bindings_t_string());
2533        let Atomic::TConditional { data } = &result.types[0] else {
2534            panic!("expected TConditional");
2535        };
2536        let (subject, if_true, if_false) = (&data.subject, &data.if_true, &data.if_false);
2537        assert!(matches!(subject.types[0], Atomic::TString));
2538        assert!(matches!(if_true.types[0], Atomic::TString));
2539        assert!(matches!(if_false.types[0], Atomic::TInt));
2540    }
2541
2542    #[test]
2543    fn resolve_conditional_is_null_non_null_arg() {
2544        let ty = Type::single(conditional(
2545            Some(Name::new("x")),
2546            Type::single(Atomic::TNull),
2547            Type::single(Atomic::TInt),
2548            Type::single(Atomic::TString),
2549        ));
2550        let result = ty.resolve_conditional_returns(|name| {
2551            if name == "x" {
2552                Some(Type::single(Atomic::TString)) // definitely not null
2553            } else {
2554                None
2555            }
2556        });
2557        assert!(result.types.len() == 1);
2558        assert!(matches!(result.types[0], Atomic::TString));
2559    }
2560
2561    #[test]
2562    fn resolve_conditional_is_null_null_arg() {
2563        let ty = Type::single(conditional(
2564            Some(Name::new("x")),
2565            Type::single(Atomic::TNull),
2566            Type::single(Atomic::TInt),
2567            Type::single(Atomic::TString),
2568        ));
2569        let result = ty.resolve_conditional_returns(|name| {
2570            if name == "x" {
2571                Some(Type::single(Atomic::TNull)) // definitely null
2572            } else {
2573                None
2574            }
2575        });
2576        assert!(result.types.len() == 1);
2577        assert!(matches!(result.types[0], Atomic::TInt));
2578    }
2579
2580    #[test]
2581    fn resolve_conditional_is_null_nullable_arg_widens_to_branch_union() {
2582        let mut nullable_str = Type::single(Atomic::TString);
2583        nullable_str.add_type(Atomic::TNull);
2584        let ty = Type::single(conditional(
2585            Some(Name::new("x")),
2586            Type::single(Atomic::TNull),
2587            Type::single(Atomic::TInt),
2588            Type::single(Atomic::TString),
2589        ));
2590        let result = ty.resolve_conditional_returns(|name| {
2591            if name == "x" {
2592                Some(nullable_str.clone())
2593            } else {
2594                None
2595            }
2596        });
2597        // uncertain discriminator → widen to if_true | if_false
2598        assert_eq!(result.types.len(), 2);
2599        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
2600        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
2601    }
2602
2603    #[test]
2604    fn resolve_conditional_nested_widens_inner_branch() {
2605        // ($x is null ? int : ($x is string ? string : float))
2606        // When $x is unknown, should widen to int|string|float (no TConditional remaining).
2607        let inner = Type::single(conditional(
2608            Some(Name::new("x")),
2609            Type::single(Atomic::TString),
2610            Type::single(Atomic::TString),
2611            Type::single(Atomic::TFloat),
2612        ));
2613        let ty = Type::single(conditional(
2614            Some(Name::new("x")),
2615            Type::single(Atomic::TNull),
2616            Type::single(Atomic::TInt),
2617            inner,
2618        ));
2619        // unknown arg → widen both outer branches, inner conditional must also be widened
2620        let result = ty.resolve_conditional_returns(|_| None);
2621        assert!(
2622            result
2623                .types
2624                .iter()
2625                .all(|t| !matches!(t, Atomic::TConditional { .. })),
2626            "no TConditional should survive: {:?}",
2627            result.types
2628        );
2629        assert!(result.types.iter().any(|t| matches!(t, Atomic::TInt)));
2630        assert!(result.types.iter().any(|t| matches!(t, Atomic::TString)));
2631        assert!(result.types.iter().any(|t| matches!(t, Atomic::TFloat)));
2632    }
2633
2634    #[test]
2635    fn resolve_conditional_nested_resolves_inner_branch() {
2636        // ($x is null ? int : ($x is string ? string : float))
2637        // When $x is definitely not null but unknown string-or-not → resolves outer to inner,
2638        // then inner must also be resolved.
2639        let inner = Type::single(conditional(
2640            Some(Name::new("x")),
2641            Type::single(Atomic::TString),
2642            Type::single(Atomic::TString),
2643            Type::single(Atomic::TFloat),
2644        ));
2645        let ty = Type::single(conditional(
2646            Some(Name::new("x")),
2647            Type::single(Atomic::TNull),
2648            Type::single(Atomic::TInt),
2649            inner,
2650        ));
2651        // $x = string → outer: not null → if_false (inner); inner: is string → if_true = string
2652        let result = ty.resolve_conditional_returns(|name| {
2653            if name == "x" {
2654                Some(Type::single(Atomic::TString))
2655            } else {
2656                None
2657            }
2658        });
2659        assert!(
2660            result
2661                .types
2662                .iter()
2663                .all(|t| !matches!(t, Atomic::TConditional { .. })),
2664            "no TConditional should survive: {:?}",
2665            result.types
2666        );
2667        assert_eq!(result.types.len(), 1);
2668        assert!(matches!(result.types[0], Atomic::TString));
2669    }
2670
2671    #[test]
2672    fn substitute_intersection_parts() {
2673        let ty = Type::single(Atomic::TIntersection {
2674            parts: vec_to_type_params(vec![
2675                Type::single(Atomic::TNamedObject {
2676                    fqcn: Name::new("Countable"),
2677                    type_params: empty_type_params(),
2678                }),
2679                t_param("T"),
2680            ]),
2681        });
2682        let result = ty.substitute_templates(&bindings_t_string());
2683        let Atomic::TIntersection { parts } = &result.types[0] else {
2684            panic!("expected TIntersection");
2685        };
2686        assert_eq!(parts.len(), 2);
2687        assert!(matches!(parts[0].types[0], Atomic::TNamedObject { .. }));
2688        assert!(matches!(parts[1].types[0], Atomic::TString));
2689    }
2690
2691    #[test]
2692    fn substitute_no_template_params_identity() {
2693        let ty = Type::single(Atomic::TInt);
2694        let result = ty.substitute_templates(&bindings_t_string());
2695        assert!(matches!(result.types[0], Atomic::TInt));
2696    }
2697}