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