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