Skip to main content

mir_analyzer/call/
method.rs

1use std::sync::Arc;
2
3use php_ast::ast::{ExprKind, MethodCallExpr};
4use php_ast::Span;
5
6use mir_codebase::storage::{FnParam, TemplateParam, Visibility};
7use mir_issues::{IssueKind, Severity};
8use mir_types::Union;
9
10use crate::context::Context;
11use crate::expr::ExpressionAnalyzer;
12use crate::generic::{build_class_bindings, check_template_bounds, infer_template_bindings};
13use crate::symbol::SymbolKind;
14
15use super::args::{
16    check_args, check_method_visibility, expr_can_be_passed_by_reference, spread_element_type,
17    substitute_static_in_return, CheckArgsParams,
18};
19use super::CallAnalyzer;
20
21fn extract_namespace(fqcn: &str) -> Option<&str> {
22    if let Some(pos) = fqcn.rfind('\\') {
23        Some(&fqcn[..pos])
24    } else {
25        None
26    }
27}
28
29pub(super) struct ResolvedMethod {
30    pub(super) owner_fqcn: Arc<str>,
31    pub(super) name: Arc<str>,
32    pub(super) visibility: Visibility,
33    pub(super) deprecated: Option<Arc<str>>,
34    pub(super) is_internal: bool,
35    pub(super) params: Vec<FnParam>,
36    pub(super) template_params: Vec<TemplateParam>,
37    pub(super) return_ty_raw: Union,
38    pub(super) throws: Arc<[Arc<str>]>,
39}
40
41/// Resolve a method via the Salsa db, walking the class ancestor chain.
42pub(super) fn resolve_method_from_db(
43    ea: &ExpressionAnalyzer<'_>,
44    fqcn: &Arc<str>,
45    method_name_lower: &str,
46) -> Option<ResolvedMethod> {
47    let db = ea.db;
48
49    // Walk own → mixins → traits → ancestors via the canonical chain helper.
50    let node = crate::db::lookup_method_in_chain(db, fqcn, method_name_lower)?;
51    let owner_fqcn = node.fqcn(db);
52    let name = node.name(db);
53
54    // `inferred_return_type` is published on `MethodNode` by the priming
55    // sweep's serial commit phase; see `MirDb::commit_inferred_return_types`.
56    // Every analyzer entry path runs a priming sweep + commit before the
57    // issue-emitting pass, so the read-side codebase fallback is gone.
58    let inferred = node.inferred_return_type(db);
59    let return_ty_raw = node
60        .return_type(db)
61        .or(inferred)
62        .map(|t| (*t).clone())
63        .unwrap_or_else(Union::mixed);
64
65    Some(ResolvedMethod {
66        owner_fqcn,
67        name,
68        visibility: node.visibility(db),
69        deprecated: node.deprecated(db),
70        is_internal: node.is_internal(db),
71        params: node.params(db).to_vec(),
72        template_params: node.template_params(db).to_vec(),
73        return_ty_raw,
74        throws: node.throws(db),
75    })
76}
77
78impl CallAnalyzer {
79    pub fn analyze_method_call<'a, 'arena, 'src>(
80        ea: &mut ExpressionAnalyzer<'a>,
81        call: &MethodCallExpr<'arena, 'src>,
82        ctx: &mut Context,
83        span: Span,
84        nullsafe: bool,
85    ) -> Union {
86        let obj_ty = ea.analyze(call.object, ctx);
87
88        let method_name = match &call.method.kind {
89            ExprKind::Identifier(name) => name.as_str(),
90            _ => return Union::mixed(),
91        };
92
93        // Always analyze arguments — even when the receiver is null/mixed and we
94        // return early — so that variable reads inside args are tracked and side
95        // effects (taint, etc.) are recorded.
96        let arg_types: Vec<Union> = call
97            .args
98            .iter()
99            .map(|arg| {
100                let ty = ea.analyze(&arg.value, ctx);
101                if arg.unpack {
102                    spread_element_type(&ty)
103                } else {
104                    ty
105                }
106            })
107            .collect();
108
109        let arg_spans: Vec<Span> = call.args.iter().map(|a| a.span).collect();
110
111        if obj_ty.contains(|t| matches!(t, mir_types::Atomic::TNull)) {
112            if nullsafe {
113                // ?-> is fine, just returns null on null receiver
114            } else if obj_ty.is_single() {
115                ea.emit(
116                    IssueKind::NullMethodCall {
117                        method: method_name.to_string(),
118                    },
119                    Severity::Error,
120                    span,
121                );
122                return Union::mixed();
123            } else {
124                ea.emit(
125                    IssueKind::PossiblyNullMethodCall {
126                        method: method_name.to_string(),
127                    },
128                    Severity::Info,
129                    span,
130                );
131            }
132        }
133
134        if obj_ty.is_mixed() {
135            // Don't report MixedMethodCall on template parameters, since they can be any type
136            let is_only_template_params = obj_ty
137                .types
138                .iter()
139                .all(|t| matches!(t, mir_types::Atomic::TTemplateParam { .. }));
140            if !is_only_template_params {
141                ea.emit(
142                    IssueKind::MixedMethodCall {
143                        method: method_name.to_string(),
144                    },
145                    Severity::Info,
146                    span,
147                );
148            }
149            return Union::mixed();
150        }
151
152        let receiver = obj_ty.remove_null();
153        let mut result = Union::empty();
154
155        for atomic in &receiver.types {
156            match atomic {
157                mir_types::Atomic::TNamedObject {
158                    fqcn,
159                    type_params: receiver_type_params,
160                } => {
161                    let fqcn_resolved = crate::db::resolve_name_via_db(ea.db, &ea.file, fqcn);
162                    let fqcn = &std::sync::Arc::from(fqcn_resolved.as_str());
163                    result = Union::merge(
164                        &result,
165                        &resolve_method_return(
166                            ea,
167                            ctx,
168                            call,
169                            span,
170                            method_name,
171                            fqcn,
172                            receiver_type_params.as_slice(),
173                            &arg_types,
174                            &arg_spans,
175                        ),
176                    );
177                }
178                mir_types::Atomic::TSelf { fqcn }
179                | mir_types::Atomic::TStaticObject { fqcn }
180                | mir_types::Atomic::TParent { fqcn } => {
181                    let fqcn_resolved = crate::db::resolve_name_via_db(ea.db, &ea.file, fqcn);
182                    let fqcn = &std::sync::Arc::from(fqcn_resolved.as_str());
183                    result = Union::merge(
184                        &result,
185                        &resolve_method_return(
186                            ea,
187                            ctx,
188                            call,
189                            span,
190                            method_name,
191                            fqcn,
192                            &[],
193                            &arg_types,
194                            &arg_spans,
195                        ),
196                    );
197                }
198                mir_types::Atomic::TIntersection { parts } => {
199                    let mut intersection_result = Union::empty();
200                    let mut found_method = false;
201                    for part in parts {
202                        for inner_atomic in &part.types {
203                            if let mir_types::Atomic::TNamedObject {
204                                fqcn,
205                                type_params: receiver_type_params,
206                            } = inner_atomic
207                            {
208                                let fqcn_resolved =
209                                    crate::db::resolve_name_via_db(ea.db, &ea.file, fqcn);
210                                let resolved_arc = Arc::from(fqcn_resolved.as_str());
211                                if crate::db::lookup_method_in_chain(
212                                    ea.db,
213                                    &resolved_arc,
214                                    method_name,
215                                )
216                                .is_some()
217                                {
218                                    found_method = true;
219                                    intersection_result = Union::merge(
220                                        &intersection_result,
221                                        &resolve_method_return(
222                                            ea,
223                                            ctx,
224                                            call,
225                                            span,
226                                            method_name,
227                                            &resolved_arc,
228                                            receiver_type_params.as_slice(),
229                                            &arg_types,
230                                            &arg_spans,
231                                        ),
232                                    );
233                                }
234                            }
235                        }
236                    }
237                    if found_method {
238                        result = Union::merge(&result, &intersection_result);
239                    } else {
240                        result = Union::merge(&result, &Union::mixed());
241                    }
242                }
243                mir_types::Atomic::TObject | mir_types::Atomic::TTemplateParam { .. } => {
244                    result = Union::merge(&result, &Union::mixed());
245                }
246                _ => {
247                    result = Union::merge(&result, &Union::mixed());
248                }
249            }
250        }
251
252        if nullsafe && obj_ty.is_nullable() {
253            result.add_type(mir_types::Atomic::TNull);
254        }
255
256        let final_ty = if result.is_empty() {
257            Union::mixed()
258        } else {
259            result
260        };
261
262        for atomic in &obj_ty.types {
263            if let mir_types::Atomic::TNamedObject { fqcn, .. } = atomic {
264                ea.record_symbol(
265                    call.method.span,
266                    SymbolKind::MethodCall {
267                        class: fqcn.clone(),
268                        method: Arc::from(method_name),
269                    },
270                    final_ty.clone(),
271                );
272                break;
273            }
274        }
275        final_ty
276    }
277}
278
279/// Resolves method return type for a known receiver FQCN, shared between the
280/// `TNamedObject` and `TSelf`/`TStaticObject`/`TParent` branches.
281#[allow(clippy::too_many_arguments)]
282fn resolve_method_return<'a, 'arena, 'src>(
283    ea: &mut ExpressionAnalyzer<'a>,
284    ctx: &Context,
285    call: &MethodCallExpr<'arena, 'src>,
286    span: Span,
287    method_name: &str,
288    fqcn: &Arc<str>,
289    receiver_type_params: &[Union],
290    arg_types: &[Union],
291    arg_spans: &[Span],
292) -> Union {
293    let method_name_lower = method_name.to_lowercase();
294    let resolved = resolve_method_from_db(ea, fqcn, &method_name_lower);
295
296    if let Some(resolved) = resolved {
297        if !ea.inference_only {
298            let (line, col_start, col_end) = ea.span_to_ref_loc(call.method.span);
299            ea.db.record_reference_location(crate::db::RefLoc {
300                symbol_key: Arc::from(format!(
301                    "{}::{}",
302                    &resolved.owner_fqcn,
303                    resolved.name.to_lowercase()
304                )),
305                file: ea.file.clone(),
306                line,
307                col_start,
308                col_end,
309            });
310        }
311        if let Some(msg) = resolved.deprecated.clone() {
312            ea.emit(
313                IssueKind::DeprecatedMethodCall {
314                    class: fqcn.to_string(),
315                    method: method_name.to_string(),
316                    message: Some(msg).filter(|m| !m.is_empty()),
317                },
318                Severity::Info,
319                span,
320            );
321        }
322        if resolved.is_internal {
323            let calling_namespace = ea.db.file_namespace(&ea.file).map(|ns| ns.to_string());
324            let method_namespace = extract_namespace(&resolved.owner_fqcn).map(|s| s.to_string());
325            if calling_namespace != method_namespace {
326                ea.emit(
327                    IssueKind::InternalMethod {
328                        class: fqcn.to_string(),
329                        method: method_name.to_string(),
330                    },
331                    Severity::Warning,
332                    span,
333                );
334            }
335        }
336        check_method_visibility(
337            ea,
338            resolved.visibility,
339            &resolved.owner_fqcn,
340            &resolved.name,
341            ctx,
342            span,
343        );
344
345        let arg_names: Vec<Option<String>> = call
346            .args
347            .iter()
348            .map(|a| a.name.as_ref().map(|n| n.to_string_repr().into_owned()))
349            .collect();
350        let arg_can_be_byref: Vec<bool> = call
351            .args
352            .iter()
353            .map(|a| expr_can_be_passed_by_reference(&a.value))
354            .collect();
355        // Build class-level template bindings before arg-checking so we can substitute
356        // template params (e.g. T → int from Box<int>) into param types.
357        let class_tps = crate::db::class_template_params_via_db(ea.db, fqcn)
358            .map(|tps| tps.to_vec())
359            .unwrap_or_default();
360        let mut bindings = build_class_bindings(&class_tps, receiver_type_params);
361        for (k, v) in crate::db::inherited_template_bindings_via_db(ea.db, fqcn) {
362            bindings.entry(k).or_insert(v);
363        }
364
365        // Substitute class bindings into param types so argument checking resolves T → int etc.
366        let substituted_params: Vec<FnParam>;
367        let effective_params: &[FnParam] = if bindings.is_empty() {
368            &resolved.params
369        } else {
370            substituted_params = resolved
371                .params
372                .iter()
373                .map(|p| FnParam {
374                    ty: mir_codebase::wrap_param_type(
375                        p.ty.as_ref().map(|t| t.substitute_templates(&bindings)),
376                    ),
377                    ..p.clone()
378                })
379                .collect();
380            &substituted_params
381        };
382
383        check_args(
384            ea,
385            CheckArgsParams {
386                fn_name: method_name,
387                params: effective_params,
388                arg_types,
389                arg_spans,
390                arg_names: &arg_names,
391                arg_can_be_byref: &arg_can_be_byref,
392                call_span: span,
393                has_spread: call.args.iter().any(|a| a.unpack),
394            },
395        );
396
397        let ret_raw = substitute_static_in_return(resolved.return_ty_raw, fqcn);
398
399        if !resolved.template_params.is_empty() {
400            let method_bindings =
401                infer_template_bindings(&resolved.template_params, &resolved.params, arg_types);
402            for key in method_bindings.keys() {
403                if bindings.contains_key(key) {
404                    ea.emit(
405                        IssueKind::ShadowedTemplateParam {
406                            name: key.to_string(),
407                        },
408                        Severity::Info,
409                        span,
410                    );
411                }
412            }
413            bindings.extend(method_bindings);
414            for (name, inferred, bound) in
415                check_template_bounds(&bindings, &resolved.template_params)
416            {
417                ea.emit(
418                    IssueKind::InvalidTemplateParam {
419                        name: name.to_string(),
420                        expected_bound: format!("{bound}"),
421                        actual: format!("{inferred}"),
422                    },
423                    Severity::Error,
424                    span,
425                );
426            }
427        }
428
429        // Check inter-procedural throws: if callee declares @throws, check if caller covers them
430        for callee_throw in resolved.throws.iter() {
431            if !ctx.fn_declared_throws.iter().any(|declared| {
432                declared.as_ref() == callee_throw.as_ref()
433                    || crate::db::extends_or_implements_via_db(
434                        ea.db,
435                        callee_throw.as_ref(),
436                        declared.as_ref(),
437                    )
438            }) {
439                ea.emit(
440                    IssueKind::MissingThrowsDocblock {
441                        class: callee_throw.to_string(),
442                    },
443                    Severity::Info,
444                    span,
445                );
446            }
447        }
448
449        if !bindings.is_empty() {
450            ret_raw.substitute_templates(&bindings)
451        } else {
452            ret_raw
453        }
454    } else if crate::db::type_exists_via_db(ea.db, fqcn)
455        && !crate::db::has_unknown_ancestor_via_db(ea.db, fqcn)
456    {
457        let (is_interface, is_abstract) = crate::db::class_kind_via_db(ea.db, fqcn)
458            .map(|k| (k.is_interface, k.is_abstract))
459            .unwrap_or((false, false));
460        // Check for __call in the full inheritance chain (not just direct methods)
461        let has_call_magic = crate::db::lookup_method_in_chain(ea.db, fqcn, "__call").is_some();
462        if is_interface || is_abstract || has_call_magic {
463            Union::mixed()
464        } else {
465            ea.emit(
466                IssueKind::UndefinedMethod {
467                    class: fqcn.to_string(),
468                    method: method_name.to_string(),
469                },
470                Severity::Error,
471                span,
472            );
473            Union::mixed()
474        }
475    } else {
476        Union::mixed()
477    }
478}