Skip to main content

mir_analyzer/call/
function.rs

1use php_ast::ast::{ExprKind, FunctionCallExpr};
2use php_ast::Span;
3
4use std::sync::Arc;
5
6use mir_codebase::storage::{Assertion, AssertionKind, FnParam, TemplateParam};
7use mir_issues::{IssueKind, Severity};
8use mir_types::{Atomic, Union};
9
10use crate::context::Context;
11use crate::expr::ExpressionAnalyzer;
12use crate::generic::{check_template_bounds, infer_template_bindings};
13use crate::symbol::SymbolKind;
14use crate::taint::{classify_sink, is_expr_tainted, SinkKind};
15
16use super::args::{
17    check_args, expr_can_be_passed_by_reference, spread_element_type, CheckArgsParams,
18};
19use super::CallAnalyzer;
20
21struct ResolvedFn {
22    fqn: std::sync::Arc<str>,
23    deprecated: Option<std::sync::Arc<str>>,
24    params: Vec<FnParam>,
25    template_params: Vec<TemplateParam>,
26    assertions: Vec<Assertion>,
27    return_ty_raw: Union,
28}
29
30fn resolve_fn(ea: &ExpressionAnalyzer<'_>, fqn: &str) -> Option<ResolvedFn> {
31    let db = ea.db;
32    let node = db.lookup_function_node(fqn).filter(|n| n.active(db))?;
33    // `inferred_return_type` is the priming-sweep-derived type, published
34    // on `FunctionNode` via `MirDb::commit_inferred_return_types` after
35    // each priming sweep returns.  Every entry path (batch `analyze`,
36    // `re_analyze_file`, lazy-load reanalysis sweep, `analyze_source`)
37    // runs a priming-sweep + commit before the issue-emitting pass.
38    let inferred = node.inferred_return_type(db);
39    let return_ty_raw = node
40        .return_type(db)
41        .or(inferred)
42        .map(|t| (*t).clone())
43        .unwrap_or_else(Union::mixed);
44    Some(ResolvedFn {
45        fqn: node.fqn(db),
46        deprecated: node.deprecated(db),
47        params: node.params(db).to_vec(),
48        template_params: node.template_params(db).to_vec(),
49        assertions: node.assertions(db).to_vec(),
50        return_ty_raw,
51    })
52}
53
54impl CallAnalyzer {
55    pub fn analyze_function_call<'a, 'arena, 'src>(
56        ea: &mut ExpressionAnalyzer<'a>,
57        call: &FunctionCallExpr<'arena, 'src>,
58        ctx: &mut Context,
59        span: Span,
60    ) -> Union {
61        let fn_name = match &call.name.kind {
62            ExprKind::Identifier(name) => (*name).to_string(),
63            _ => {
64                let callee_ty = ea.analyze(call.name, ctx);
65                for arg in call.args.iter() {
66                    ea.analyze(&arg.value, ctx);
67                }
68                for atomic in &callee_ty.types {
69                    match atomic {
70                        Atomic::TClosure { return_type, .. } => return *return_type.clone(),
71                        Atomic::TCallable {
72                            return_type: Some(rt),
73                            ..
74                        } => return *rt.clone(),
75                        _ => {}
76                    }
77                }
78                return Union::mixed();
79            }
80        };
81
82        // Taint sink check (M19): before evaluating args so we can inspect raw exprs
83        if let Some(sink_kind) = classify_sink(&fn_name) {
84            for arg in call.args.iter() {
85                if is_expr_tainted(&arg.value, ctx) {
86                    let issue_kind = match sink_kind {
87                        SinkKind::Html => IssueKind::TaintedHtml,
88                        SinkKind::Sql => IssueKind::TaintedSql,
89                        SinkKind::Shell => IssueKind::TaintedShell,
90                    };
91                    ea.emit(issue_kind, Severity::Error, span);
92                    break;
93                }
94            }
95        }
96
97        // PHP resolves `foo()` as `\App\Ns\foo` first, then `\foo` if not found.
98        // A leading `\` means explicit global namespace.
99        let fn_name = fn_name
100            .strip_prefix('\\')
101            .map(|s: &str| s.to_string())
102            .unwrap_or(fn_name);
103        let resolved_fn_name: String = {
104            let imports = ea.db.file_imports(&ea.file);
105            let qualified = if let Some(imported) = imports.get(fn_name.as_str()) {
106                imported.clone()
107            } else if fn_name.contains('\\') {
108                crate::db::resolve_name_via_db(ea.db, &ea.file, &fn_name)
109            } else if let Some(ns) = ea.db.file_namespace(&ea.file) {
110                format!("{}\\{}", ns, fn_name)
111            } else {
112                fn_name.clone()
113            };
114            let fn_exists = |name: &str| -> bool {
115                let db = ea.db;
116                db.lookup_function_node(name).is_some_and(|n| n.active(db))
117            };
118            if fn_exists(qualified.as_str()) {
119                qualified
120            } else if fn_exists(fn_name.as_str()) {
121                fn_name.clone()
122            } else {
123                qualified
124            }
125        };
126
127        // Resolve once; reused below for by-ref pre-marking and full analysis.
128        let resolved = resolve_fn(ea, resolved_fn_name.as_str());
129
130        // Pre-mark by-reference parameter variables as defined BEFORE evaluating args
131        if let Some(ref resolved) = resolved {
132            for (i, param) in resolved.params.iter().enumerate() {
133                if param.is_byref {
134                    if param.is_variadic {
135                        for arg in call.args.iter().skip(i) {
136                            if let ExprKind::Variable(name) = &arg.value.kind {
137                                let var_name = name.as_str().trim_start_matches('$');
138                                if !ctx.var_is_defined(var_name) {
139                                    ctx.set_var(var_name, Union::mixed());
140                                }
141                            }
142                        }
143                    } else if let Some(arg) = call.args.get(i) {
144                        if let ExprKind::Variable(name) = &arg.value.kind {
145                            let var_name = name.as_str().trim_start_matches('$');
146                            if !ctx.var_is_defined(var_name) {
147                                ctx.set_var(var_name, Union::mixed());
148                            }
149                        }
150                    }
151                }
152            }
153        }
154
155        let arg_types: Vec<Union> = call
156            .args
157            .iter()
158            .map(|arg| {
159                let ty = ea.analyze(&arg.value, ctx);
160                if arg.unpack {
161                    spread_element_type(&ty)
162                } else {
163                    ty
164                }
165            })
166            .collect();
167
168        // When call_user_func / call_user_func_array is called with a bare string
169        // literal as the callable argument, treat that string as a direct FQN
170        // reference so the named function is not flagged as dead code.
171        // Note: 'helper' always resolves to \helper (global) — no namespace
172        // fallback applies to runtime callable strings.
173        if matches!(
174            resolved_fn_name.as_str(),
175            "call_user_func" | "call_user_func_array"
176        ) {
177            if let Some(arg) = call.args.first() {
178                if let ExprKind::String(name) = &arg.value.kind {
179                    let fqn = name.strip_prefix('\\').unwrap_or(name);
180                    if let Some(node) = ea.db.lookup_function_node(fqn).filter(|n| n.active(ea.db))
181                    {
182                        if !ea.inference_only {
183                            let (line, col_start, col_end) = ea.span_to_ref_loc(arg.span);
184                            ea.db.record_reference_location(crate::db::RefLoc {
185                                symbol_key: Arc::from(node.fqn(ea.db).as_ref()),
186                                file: ea.file.clone(),
187                                line,
188                                col_start,
189                                col_end,
190                            });
191                        }
192                    }
193                }
194            }
195        }
196
197        // compact() reads variables by string name at runtime; mark each string-literal arg as read
198        if fn_name == "compact" {
199            for arg in call.args.iter() {
200                if let ExprKind::String(name) = &arg.value.kind {
201                    ctx.read_vars.insert((*name).to_string());
202                }
203            }
204        }
205
206        if let Some(resolved) = resolved {
207            if !ea.inference_only {
208                let (line, col_start, col_end) = ea.span_to_ref_loc(call.name.span);
209                ea.db.record_reference_location(crate::db::RefLoc {
210                    symbol_key: resolved.fqn.clone(),
211                    file: ea.file.clone(),
212                    line,
213                    col_start,
214                    col_end,
215                });
216            }
217            let deprecated = resolved.deprecated;
218            let params = resolved.params;
219            let template_params = resolved.template_params;
220            let return_ty_raw = resolved.return_ty_raw;
221
222            if let Some(msg) = deprecated {
223                ea.emit(
224                    IssueKind::DeprecatedCall {
225                        name: resolved_fn_name.clone(),
226                        message: Some(msg).filter(|m| !m.is_empty()),
227                    },
228                    Severity::Info,
229                    span,
230                );
231            }
232
233            check_args(
234                ea,
235                CheckArgsParams {
236                    fn_name: &fn_name,
237                    params: &params,
238                    arg_types: &arg_types,
239                    arg_spans: &call.args.iter().map(|a| a.span).collect::<Vec<_>>(),
240                    arg_names: &call
241                        .args
242                        .iter()
243                        .map(|a| a.name.as_ref().map(|n| n.to_string_repr().into_owned()))
244                        .collect::<Vec<_>>(),
245                    arg_can_be_byref: &call
246                        .args
247                        .iter()
248                        .map(|a| expr_can_be_passed_by_reference(&a.value))
249                        .collect::<Vec<_>>(),
250                    call_span: span,
251                    has_spread: call.args.iter().any(|a| a.unpack),
252                },
253            );
254
255            for (i, param) in params.iter().enumerate() {
256                if param.is_byref {
257                    if param.is_variadic {
258                        for arg in call.args.iter().skip(i) {
259                            if let ExprKind::Variable(name) = &arg.value.kind {
260                                let var_name = name.as_str().trim_start_matches('$');
261                                ctx.set_var(var_name, Union::mixed());
262                            }
263                        }
264                    } else if let Some(arg) = call.args.get(i) {
265                        if let ExprKind::Variable(name) = &arg.value.kind {
266                            let var_name = name.as_str().trim_start_matches('$');
267                            ctx.set_var(var_name, Union::mixed());
268                        }
269                    }
270                }
271            }
272
273            let template_bindings = if !template_params.is_empty() {
274                let bindings = infer_template_bindings(&template_params, &params, &arg_types);
275                for (name, inferred, bound) in check_template_bounds(&bindings, &template_params) {
276                    ea.emit(
277                        IssueKind::InvalidTemplateParam {
278                            name: name.to_string(),
279                            expected_bound: format!("{bound}"),
280                            actual: format!("{inferred}"),
281                        },
282                        Severity::Error,
283                        span,
284                    );
285                }
286                Some(bindings)
287            } else {
288                None
289            };
290
291            for assertion in resolved
292                .assertions
293                .iter()
294                .filter(|a| a.kind == AssertionKind::Assert)
295            {
296                if let Some(index) = params.iter().position(|p| p.name == assertion.param) {
297                    if let Some(arg) = call.args.get(index) {
298                        if let ExprKind::Variable(name) = &arg.value.kind {
299                            let asserted_ty = match &template_bindings {
300                                Some(b) => assertion.ty.substitute_templates(b),
301                                None => assertion.ty.clone(),
302                            };
303                            ctx.set_var(name.as_str().trim_start_matches('$'), asserted_ty);
304                        }
305                    }
306                }
307            }
308
309            let return_ty = match &template_bindings {
310                Some(bindings) => return_ty_raw.substitute_templates(bindings),
311                None => return_ty_raw,
312            };
313
314            ea.record_symbol(
315                call.name.span,
316                SymbolKind::FunctionCall(resolved.fqn.clone()),
317                return_ty.clone(),
318            );
319            return return_ty;
320        }
321
322        ea.emit(
323            IssueKind::UndefinedFunction { name: fn_name },
324            Severity::Error,
325            span,
326        );
327        Union::mixed()
328    }
329}