Skip to main content

mir_analyzer/
narrowing.rs

1/// Type narrowing — refines variable types based on conditional expressions.
2///
3/// Given a condition expression and a branch direction (true/false), this
4/// module updates the `Context` to narrow variable types accordingly.
5use php_ast::ast::{BinaryOp, ExprKind, UnaryPrefixOp};
6
7use mir_codebase::Codebase;
8use mir_types::{Atomic, Union};
9
10use crate::context::Context;
11
12// ---------------------------------------------------------------------------
13// Public entry point
14// ---------------------------------------------------------------------------
15
16/// Narrow the types in `ctx` as if `expr` evaluates to `is_true`.
17pub fn narrow_from_condition<'arena, 'src>(
18    expr: &php_ast::ast::Expr<'arena, 'src>,
19    ctx: &mut Context,
20    is_true: bool,
21    codebase: &Codebase,
22    file: &str,
23) {
24    match &expr.kind {
25        // Parenthesized — unwrap and narrow the inner expression
26        ExprKind::Parenthesized(inner) => {
27            narrow_from_condition(inner, ctx, is_true, codebase, file);
28        }
29
30        // !expr  →  narrow as if expr is !is_true
31        ExprKind::UnaryPrefix(u) if u.op == UnaryPrefixOp::BooleanNot => {
32            narrow_from_condition(u.operand, ctx, !is_true, codebase, file);
33        }
34
35        // $a && $b  →  if true: narrow both; if false: no constraint
36        ExprKind::Binary(b) if b.op == BinaryOp::BooleanAnd || b.op == BinaryOp::LogicalAnd => {
37            if is_true {
38                narrow_from_condition(b.left, ctx, true, codebase, file);
39                narrow_from_condition(b.right, ctx, true, codebase, file);
40            }
41        }
42
43        // $a || $b  →  if false: narrow both; if true: try to narrow same-var instanceof union
44        ExprKind::Binary(b) if b.op == BinaryOp::BooleanOr || b.op == BinaryOp::LogicalOr => {
45            if !is_true {
46                narrow_from_condition(b.left, ctx, false, codebase, file);
47                narrow_from_condition(b.right, ctx, false, codebase, file);
48            } else {
49                // For `$x instanceof A || $x instanceof B` in true-branch: narrow $x to A|B
50                narrow_or_instanceof_true(b.left, b.right, ctx, codebase, file);
51            }
52        }
53
54        // $x === null / $x !== null
55        ExprKind::Binary(b) if b.op == BinaryOp::Identical || b.op == BinaryOp::NotIdentical => {
56            let is_identical = b.op == BinaryOp::Identical;
57            let effective_true = if is_identical { is_true } else { !is_true };
58
59            // `$x === null`
60            if matches!(b.right.kind, ExprKind::Null) {
61                if let Some(name) = extract_var_name(b.left) {
62                    narrow_var_null(ctx, &name, effective_true);
63                }
64            } else if matches!(b.left.kind, ExprKind::Null) {
65                if let Some(name) = extract_var_name(b.right) {
66                    narrow_var_null(ctx, &name, effective_true);
67                }
68            }
69            // `$x === true` / `$x === false`
70            else if matches!(b.right.kind, ExprKind::Bool(true)) {
71                if let Some(name) = extract_var_name(b.left) {
72                    narrow_var_bool(ctx, &name, true, effective_true);
73                }
74            } else if matches!(b.right.kind, ExprKind::Bool(false)) {
75                if let Some(name) = extract_var_name(b.left) {
76                    narrow_var_bool(ctx, &name, false, effective_true);
77                }
78            }
79            // `$x === 'literal'`
80            else if let ExprKind::String(s) = &b.right.kind {
81                if let Some(name) = extract_var_name(b.left) {
82                    narrow_var_literal_string(ctx, &name, s, effective_true);
83                }
84            } else if let ExprKind::String(s) = &b.left.kind {
85                if let Some(name) = extract_var_name(b.right) {
86                    narrow_var_literal_string(ctx, &name, s, effective_true);
87                }
88            }
89            // `$x === 42`
90            else if let ExprKind::Int(n) = &b.right.kind {
91                if let Some(name) = extract_var_name(b.left) {
92                    narrow_var_literal_int(ctx, &name, *n, effective_true);
93                }
94            } else if let ExprKind::Int(n) = &b.left.kind {
95                if let Some(name) = extract_var_name(b.right) {
96                    narrow_var_literal_int(ctx, &name, *n, effective_true);
97                }
98            }
99        }
100
101        // $x == null  (loose equality)
102        ExprKind::Binary(b) if b.op == BinaryOp::Equal || b.op == BinaryOp::NotEqual => {
103            let is_equal = b.op == BinaryOp::Equal;
104            let effective_true = if is_equal { is_true } else { !is_true };
105            if matches!(b.right.kind, ExprKind::Null) {
106                if let Some(name) = extract_var_name(b.left) {
107                    narrow_var_null(ctx, &name, effective_true);
108                }
109            } else if matches!(b.left.kind, ExprKind::Null) {
110                if let Some(name) = extract_var_name(b.right) {
111                    narrow_var_null(ctx, &name, effective_true);
112                }
113            }
114        }
115
116        // $x instanceof ClassName
117        // Also handles `!$x instanceof ClassName` which the parser produces as
118        // `(!$x) instanceof ClassName` due to PHP operator precedence. The developer
119        // intent is always `!($x instanceof ClassName)`, so we flip is_true.
120        ExprKind::Binary(b) if b.op == BinaryOp::Instanceof => {
121            // Unwrap `(!$x)` on the left side — treat as negated instanceof
122            let (lhs, extra_negation) = match &b.left.kind {
123                ExprKind::UnaryPrefix(u) if u.op == UnaryPrefixOp::BooleanNot => (u.operand, true),
124                ExprKind::Parenthesized(inner) => match &inner.kind {
125                    ExprKind::UnaryPrefix(u) if u.op == UnaryPrefixOp::BooleanNot => {
126                        (u.operand, true)
127                    }
128                    _ => (b.left, false),
129                },
130                _ => (b.left, false),
131            };
132            let effective_is_true = if extra_negation { !is_true } else { is_true };
133            if let Some(var_name) = extract_var_name(lhs) {
134                if let Some(raw_name) = extract_class_name(b.right) {
135                    // Resolve the short name to its FQCN using file imports
136                    let class_name = codebase.resolve_class_name(file, &raw_name);
137                    let current = ctx.get_var(&var_name);
138                    let narrowed = if effective_is_true {
139                        current.narrow_instanceof(&class_name)
140                    } else {
141                        // remove that specific named object type
142                        current.filter_out_named_object(&class_name)
143                    };
144                    ctx.set_var(&var_name, narrowed);
145                }
146            }
147        }
148
149        // is_string($x), is_int($x), is_null($x), is_array($x), etc.
150        // Also handles assert($x instanceof Y) — narrows like a bare condition.
151        ExprKind::FunctionCall(call) => {
152            let fn_name_opt: Option<&str> = match &call.name.kind {
153                ExprKind::Identifier(name) => Some(name),
154                ExprKind::Variable(name) => Some(name.as_ref()),
155                _ => None,
156            };
157            if let Some(fn_name) = fn_name_opt {
158                if fn_name.eq_ignore_ascii_case("assert") {
159                    // assert($condition) — narrow as if the condition is is_true
160                    if let Some(arg_expr) = call.args.first() {
161                        narrow_from_condition(&arg_expr.value, ctx, is_true, codebase, file);
162                    }
163                } else if let Some(arg_expr) = call.args.first() {
164                    if let Some(var_name) = extract_var_name(&arg_expr.value) {
165                        narrow_from_type_fn(ctx, fn_name, &var_name, is_true);
166                    }
167                }
168            }
169        }
170
171        // isset($x)
172        ExprKind::Isset(vars) => {
173            for var_expr in vars.iter() {
174                if let Some(var_name) = extract_var_name(var_expr) {
175                    if is_true {
176                        // remove null; mark as definitely assigned
177                        let current = ctx.get_var(&var_name);
178                        ctx.set_var(&var_name, current.remove_null());
179                        ctx.assigned_vars.insert(var_name);
180                    }
181                }
182            }
183        }
184
185        // if ($x)  — truthy/falsy narrowing
186        _ => {
187            if let Some(var_name) = extract_var_name(expr) {
188                let current = ctx.get_var(&var_name);
189                let narrowed = if is_true {
190                    current.narrow_to_truthy()
191                } else {
192                    current.narrow_to_falsy()
193                };
194                if !narrowed.is_empty() {
195                    ctx.set_var(&var_name, narrowed);
196                } else if !current.is_empty() && !current.is_mixed() {
197                    // The variable's type can never satisfy this truthiness
198                    // constraint → this branch is statically unreachable.
199                    ctx.diverges = true;
200                }
201            }
202        }
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Helpers
208// ---------------------------------------------------------------------------
209
210/// For `$x instanceof A || $x instanceof B` (true branch): narrow $x to A|B.
211/// Handles OR chains recursively, e.g. `$x instanceof A || $x instanceof B || $x instanceof C`.
212fn narrow_or_instanceof_true<'arena, 'src>(
213    left: &php_ast::ast::Expr<'arena, 'src>,
214    right: &php_ast::ast::Expr<'arena, 'src>,
215    ctx: &mut Context,
216    codebase: &Codebase,
217    file: &str,
218) {
219    // Collect all class names from instanceof checks on the same variable.
220    let mut var_name: Option<String> = None;
221    let mut class_names: Vec<String> = vec![];
222
223    fn collect_instanceof<'a, 's>(
224        expr: &php_ast::ast::Expr<'a, 's>,
225        var_name: &mut Option<String>,
226        class_names: &mut Vec<String>,
227        codebase: &Codebase,
228        file: &str,
229    ) -> bool {
230        match &expr.kind {
231            ExprKind::Binary(b) if b.op == BinaryOp::Instanceof => {
232                if let (Some(vn), Some(cn)) =
233                    (extract_var_name(b.left), extract_class_name(b.right))
234                {
235                    let resolved = codebase.resolve_class_name(file, &cn);
236                    match var_name {
237                        None => {
238                            *var_name = Some(vn);
239                            class_names.push(resolved);
240                            true
241                        }
242                        Some(existing) if existing == &vn => {
243                            class_names.push(resolved);
244                            true
245                        }
246                        _ => false, // different variable — bail out
247                    }
248                } else {
249                    false
250                }
251            }
252            ExprKind::Binary(b) if b.op == BinaryOp::BooleanOr || b.op == BinaryOp::LogicalOr => {
253                collect_instanceof(b.left, var_name, class_names, codebase, file)
254                    && collect_instanceof(b.right, var_name, class_names, codebase, file)
255            }
256            ExprKind::Parenthesized(inner) => {
257                collect_instanceof(inner, var_name, class_names, codebase, file)
258            }
259            _ => false,
260        }
261    }
262
263    // Wrap left and right into a fake OR so we can reuse the collector
264    let left_ok = collect_instanceof(left, &mut var_name, &mut class_names, codebase, file);
265    let right_ok = collect_instanceof(right, &mut var_name, &mut class_names, codebase, file);
266
267    if left_ok && right_ok {
268        if let Some(vn) = var_name {
269            if !class_names.is_empty() {
270                let current = ctx.get_var(&vn);
271                // Narrow to the union of all instanceof types: take union of narrow_instanceof results
272                let mut narrowed = Union::empty();
273                for cn in &class_names {
274                    let n = current.narrow_instanceof(cn);
275                    narrowed = Union::merge(&narrowed, &n);
276                }
277                // Fall back to current if narrowed is empty (e.g. mixed)
278                let result = if narrowed.is_empty() {
279                    current.clone()
280                } else {
281                    narrowed
282                };
283                if !result.is_empty() {
284                    ctx.set_var(&vn, result);
285                }
286            }
287        }
288    }
289}
290
291fn narrow_var_null(ctx: &mut Context, name: &str, is_null: bool) {
292    let current = ctx.get_var(name);
293    let narrowed = if is_null {
294        current.narrow_to_null()
295    } else {
296        current.remove_null()
297    };
298    if !narrowed.is_empty() {
299        ctx.set_var(name, narrowed);
300    } else if !current.is_empty() && !current.is_mixed() {
301        // The type cannot satisfy this nullness constraint → dead branch.
302        ctx.diverges = true;
303    }
304}
305
306fn narrow_var_bool(ctx: &mut Context, name: &str, value: bool, is_value: bool) {
307    let current = ctx.get_var(name);
308    let narrowed = if is_value {
309        if value {
310            current.filter(|t| matches!(t, Atomic::TTrue | Atomic::TBool | Atomic::TMixed))
311        } else {
312            current.filter(|t| matches!(t, Atomic::TFalse | Atomic::TBool | Atomic::TMixed))
313        }
314    } else if value {
315        current.filter(|t| !matches!(t, Atomic::TTrue))
316    } else {
317        current.filter(|t| !matches!(t, Atomic::TFalse))
318    };
319    if !narrowed.is_empty() {
320        ctx.set_var(name, narrowed);
321    }
322}
323
324fn narrow_from_type_fn(ctx: &mut Context, fn_name: &str, var_name: &str, is_true: bool) {
325    let current = ctx.get_var(var_name);
326    let narrowed = match fn_name.to_lowercase().as_str() {
327        "is_string" => {
328            if is_true {
329                current.narrow_to_string()
330            } else {
331                current.filter(|t| !t.is_string())
332            }
333        }
334        "is_int" | "is_integer" | "is_long" => {
335            if is_true {
336                current.narrow_to_int()
337            } else {
338                current.filter(|t| !t.is_int())
339            }
340        }
341        "is_float" | "is_double" | "is_real" => {
342            if is_true {
343                current.narrow_to_float()
344            } else {
345                current.filter(|t| !matches!(t, Atomic::TFloat | Atomic::TLiteralFloat(..)))
346            }
347        }
348        "is_bool" => {
349            if is_true {
350                current.narrow_to_bool()
351            } else {
352                current.filter(|t| !matches!(t, Atomic::TBool | Atomic::TTrue | Atomic::TFalse))
353            }
354        }
355        "is_null" => {
356            if is_true {
357                current.narrow_to_null()
358            } else {
359                current.remove_null()
360            }
361        }
362        "is_array" => {
363            if is_true {
364                current.narrow_to_array()
365            } else {
366                current.filter(|t| !t.is_array())
367            }
368        }
369        "is_object" => {
370            if is_true {
371                current.narrow_to_object()
372            } else {
373                current.filter(|t| !t.is_object())
374            }
375        }
376        "is_callable" => {
377            if is_true {
378                current.narrow_to_callable()
379            } else {
380                current.filter(|t| !t.is_callable())
381            }
382        }
383        "is_numeric" => {
384            if is_true {
385                current.filter(|t| {
386                    matches!(
387                        t,
388                        Atomic::TInt
389                            | Atomic::TFloat
390                            | Atomic::TNumeric
391                            | Atomic::TNumericString
392                            | Atomic::TLiteralInt(_)
393                            | Atomic::TMixed
394                    )
395                })
396            } else {
397                current.filter(|t| {
398                    !matches!(
399                        t,
400                        Atomic::TInt
401                            | Atomic::TFloat
402                            | Atomic::TNumeric
403                            | Atomic::TNumericString
404                            | Atomic::TLiteralInt(_)
405                    )
406                })
407            }
408        }
409        // method_exists($obj, 'method') — if true, narrow to TObject (suppresses
410        // UndefinedMethod; the concrete type is unresolvable without knowing the method arg)
411        "method_exists" | "property_exists" => {
412            if is_true {
413                Union::single(Atomic::TObject)
414            } else {
415                current.clone()
416            }
417        }
418        _ => return,
419    };
420    if !narrowed.is_empty() {
421        ctx.set_var(var_name, narrowed);
422    } else if !current.is_empty() && !current.is_mixed() {
423        // The type cannot satisfy this type-function constraint → dead branch.
424        ctx.diverges = true;
425    }
426}
427
428fn narrow_var_literal_string(ctx: &mut Context, name: &str, value: &str, is_value: bool) {
429    let current = ctx.get_var(name);
430    let narrowed = if is_value {
431        // Keep the specific literal, plus catch-all types that could contain it
432        current.filter(|t| match t {
433            Atomic::TLiteralString(s) => s.as_ref() == value,
434            Atomic::TString | Atomic::TScalar | Atomic::TMixed => true,
435            _ => false,
436        })
437    } else {
438        // Remove only this specific literal; leave TString/TMixed intact
439        current.filter(|t| !matches!(t, Atomic::TLiteralString(s) if s.as_ref() == value))
440    };
441    if !narrowed.is_empty() {
442        ctx.set_var(name, narrowed);
443    }
444}
445
446fn narrow_var_literal_int(ctx: &mut Context, name: &str, value: i64, is_value: bool) {
447    let current = ctx.get_var(name);
448    let narrowed = if is_value {
449        current.filter(|t| match t {
450            Atomic::TLiteralInt(n) => *n == value,
451            Atomic::TInt | Atomic::TScalar | Atomic::TNumeric | Atomic::TMixed => true,
452            _ => false,
453        })
454    } else {
455        current.filter(|t| !matches!(t, Atomic::TLiteralInt(n) if *n == value))
456    };
457    if !narrowed.is_empty() {
458        ctx.set_var(name, narrowed);
459    }
460}
461
462fn extract_var_name<'a, 'arena, 'src>(
463    expr: &'a php_ast::ast::Expr<'arena, 'src>,
464) -> Option<String> {
465    match &expr.kind {
466        ExprKind::Variable(name) => Some(name.as_str().trim_start_matches('$').to_string()),
467        ExprKind::Parenthesized(inner) => extract_var_name(inner),
468        _ => None,
469    }
470}
471
472fn extract_class_name<'arena, 'src>(expr: &php_ast::ast::Expr<'arena, 'src>) -> Option<String> {
473    match &expr.kind {
474        ExprKind::Identifier(name) => Some(name.to_string()),
475        ExprKind::Variable(_name) => None, // dynamic class — can't narrow
476        _ => None,
477    }
478}
479
480// ---------------------------------------------------------------------------
481// Extension methods on Union used only in narrowing
482// ---------------------------------------------------------------------------
483
484trait UnionNarrowExt {
485    fn filter<F: Fn(&Atomic) -> bool>(&self, f: F) -> Union;
486    fn filter_out_named_object(&self, fqcn: &str) -> Union;
487}
488
489impl UnionNarrowExt for Union {
490    fn filter<F: Fn(&Atomic) -> bool>(&self, f: F) -> Union {
491        let mut result = Union::empty();
492        result.possibly_undefined = self.possibly_undefined;
493        result.from_docblock = self.from_docblock;
494        for atomic in &self.types {
495            if f(atomic) {
496                result.types.push(atomic.clone());
497            }
498        }
499        result
500    }
501
502    fn filter_out_named_object(&self, fqcn: &str) -> Union {
503        self.filter(|t| match t {
504            Atomic::TNamedObject { fqcn: f, .. } => f.as_ref() != fqcn,
505            _ => true,
506        })
507    }
508}