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            ea.emit(
136                IssueKind::MixedMethodCall {
137                    method: method_name.to_string(),
138                },
139                Severity::Info,
140                span,
141            );
142            return Union::mixed();
143        }
144
145        let receiver = obj_ty.remove_null();
146        let mut result = Union::empty();
147
148        for atomic in &receiver.types {
149            match atomic {
150                mir_types::Atomic::TNamedObject {
151                    fqcn,
152                    type_params: receiver_type_params,
153                } => {
154                    let fqcn_resolved = crate::db::resolve_name_via_db(ea.db, &ea.file, fqcn);
155                    let fqcn = &std::sync::Arc::from(fqcn_resolved.as_str());
156                    result = Union::merge(
157                        &result,
158                        &resolve_method_return(
159                            ea,
160                            ctx,
161                            call,
162                            span,
163                            method_name,
164                            fqcn,
165                            receiver_type_params.as_slice(),
166                            &arg_types,
167                            &arg_spans,
168                        ),
169                    );
170                }
171                mir_types::Atomic::TSelf { fqcn }
172                | mir_types::Atomic::TStaticObject { fqcn }
173                | mir_types::Atomic::TParent { fqcn } => {
174                    let fqcn_resolved = crate::db::resolve_name_via_db(ea.db, &ea.file, fqcn);
175                    let fqcn = &std::sync::Arc::from(fqcn_resolved.as_str());
176                    result = Union::merge(
177                        &result,
178                        &resolve_method_return(
179                            ea,
180                            ctx,
181                            call,
182                            span,
183                            method_name,
184                            fqcn,
185                            &[],
186                            &arg_types,
187                            &arg_spans,
188                        ),
189                    );
190                }
191                mir_types::Atomic::TIntersection { parts } => {
192                    let mut intersection_result = Union::empty();
193                    let mut found_method = false;
194                    for part in parts {
195                        for inner_atomic in &part.types {
196                            if let mir_types::Atomic::TNamedObject {
197                                fqcn,
198                                type_params: receiver_type_params,
199                            } = inner_atomic
200                            {
201                                let fqcn_resolved =
202                                    crate::db::resolve_name_via_db(ea.db, &ea.file, fqcn);
203                                let resolved_arc = Arc::from(fqcn_resolved.as_str());
204                                if crate::db::lookup_method_in_chain(
205                                    ea.db,
206                                    &resolved_arc,
207                                    method_name,
208                                )
209                                .is_some()
210                                {
211                                    found_method = true;
212                                    intersection_result = Union::merge(
213                                        &intersection_result,
214                                        &resolve_method_return(
215                                            ea,
216                                            ctx,
217                                            call,
218                                            span,
219                                            method_name,
220                                            &resolved_arc,
221                                            receiver_type_params.as_slice(),
222                                            &arg_types,
223                                            &arg_spans,
224                                        ),
225                                    );
226                                }
227                            }
228                        }
229                    }
230                    if found_method {
231                        result = Union::merge(&result, &intersection_result);
232                    } else {
233                        result = Union::merge(&result, &Union::mixed());
234                    }
235                }
236                mir_types::Atomic::TObject | mir_types::Atomic::TTemplateParam { .. } => {
237                    result = Union::merge(&result, &Union::mixed());
238                }
239                _ => {
240                    result = Union::merge(&result, &Union::mixed());
241                }
242            }
243        }
244
245        if nullsafe && obj_ty.is_nullable() {
246            result.add_type(mir_types::Atomic::TNull);
247        }
248
249        let final_ty = if result.is_empty() {
250            Union::mixed()
251        } else {
252            result
253        };
254
255        for atomic in &obj_ty.types {
256            if let mir_types::Atomic::TNamedObject { fqcn, .. } = atomic {
257                ea.record_symbol(
258                    call.method.span,
259                    SymbolKind::MethodCall {
260                        class: fqcn.clone(),
261                        method: Arc::from(method_name),
262                    },
263                    final_ty.clone(),
264                );
265                break;
266            }
267        }
268        final_ty
269    }
270}
271
272/// Resolves method return type for a known receiver FQCN, shared between the
273/// `TNamedObject` and `TSelf`/`TStaticObject`/`TParent` branches.
274#[allow(clippy::too_many_arguments)]
275fn resolve_method_return<'a, 'arena, 'src>(
276    ea: &mut ExpressionAnalyzer<'a>,
277    ctx: &Context,
278    call: &MethodCallExpr<'arena, 'src>,
279    span: Span,
280    method_name: &str,
281    fqcn: &Arc<str>,
282    receiver_type_params: &[Union],
283    arg_types: &[Union],
284    arg_spans: &[Span],
285) -> Union {
286    let method_name_lower = method_name.to_lowercase();
287    let resolved = resolve_method_from_db(ea, fqcn, &method_name_lower);
288
289    if let Some(resolved) = resolved {
290        if !ea.inference_only {
291            let (line, col_start, col_end) = ea.span_to_ref_loc(call.method.span);
292            ea.db.record_reference_location(crate::db::RefLoc {
293                symbol_key: Arc::from(format!(
294                    "{}::{}",
295                    &resolved.owner_fqcn,
296                    resolved.name.to_lowercase()
297                )),
298                file: ea.file.clone(),
299                line,
300                col_start,
301                col_end,
302            });
303        }
304        if let Some(msg) = resolved.deprecated.clone() {
305            ea.emit(
306                IssueKind::DeprecatedMethodCall {
307                    class: fqcn.to_string(),
308                    method: method_name.to_string(),
309                    message: Some(msg).filter(|m| !m.is_empty()),
310                },
311                Severity::Info,
312                span,
313            );
314        }
315        if resolved.is_internal {
316            let calling_namespace = ea.db.file_namespace(&ea.file).map(|ns| ns.to_string());
317            let method_namespace = extract_namespace(&resolved.owner_fqcn).map(|s| s.to_string());
318            if calling_namespace != method_namespace {
319                ea.emit(
320                    IssueKind::InternalMethod {
321                        class: fqcn.to_string(),
322                        method: method_name.to_string(),
323                    },
324                    Severity::Warning,
325                    span,
326                );
327            }
328        }
329        check_method_visibility(
330            ea,
331            resolved.visibility,
332            &resolved.owner_fqcn,
333            &resolved.name,
334            ctx,
335            span,
336        );
337
338        let arg_names: Vec<Option<String>> = call
339            .args
340            .iter()
341            .map(|a| a.name.as_ref().map(|n| n.to_string_repr().into_owned()))
342            .collect();
343        let arg_can_be_byref: Vec<bool> = call
344            .args
345            .iter()
346            .map(|a| expr_can_be_passed_by_reference(&a.value))
347            .collect();
348        // Build class-level template bindings before arg-checking so we can substitute
349        // template params (e.g. T → int from Box<int>) into param types.
350        let class_tps = crate::db::class_template_params_via_db(ea.db, fqcn)
351            .map(|tps| tps.to_vec())
352            .unwrap_or_default();
353        let mut bindings = build_class_bindings(&class_tps, receiver_type_params);
354        for (k, v) in crate::db::inherited_template_bindings_via_db(ea.db, fqcn) {
355            bindings.entry(k).or_insert(v);
356        }
357
358        // Substitute class bindings into param types so argument checking resolves T → int etc.
359        let substituted_params: Vec<FnParam>;
360        let effective_params: &[FnParam] = if bindings.is_empty() {
361            &resolved.params
362        } else {
363            substituted_params = resolved
364                .params
365                .iter()
366                .map(|p| FnParam {
367                    ty: mir_codebase::wrap_param_type(
368                        p.ty.as_ref().map(|t| t.substitute_templates(&bindings)),
369                    ),
370                    ..p.clone()
371                })
372                .collect();
373            &substituted_params
374        };
375
376        check_args(
377            ea,
378            CheckArgsParams {
379                fn_name: method_name,
380                params: effective_params,
381                arg_types,
382                arg_spans,
383                arg_names: &arg_names,
384                arg_can_be_byref: &arg_can_be_byref,
385                call_span: span,
386                has_spread: call.args.iter().any(|a| a.unpack),
387            },
388        );
389
390        let ret_raw = substitute_static_in_return(resolved.return_ty_raw, fqcn);
391
392        if !resolved.template_params.is_empty() {
393            let method_bindings =
394                infer_template_bindings(&resolved.template_params, &resolved.params, arg_types);
395            for key in method_bindings.keys() {
396                if bindings.contains_key(key) {
397                    ea.emit(
398                        IssueKind::ShadowedTemplateParam {
399                            name: key.to_string(),
400                        },
401                        Severity::Info,
402                        span,
403                    );
404                }
405            }
406            bindings.extend(method_bindings);
407            for (name, inferred, bound) in
408                check_template_bounds(&bindings, &resolved.template_params)
409            {
410                ea.emit(
411                    IssueKind::InvalidTemplateParam {
412                        name: name.to_string(),
413                        expected_bound: format!("{bound}"),
414                        actual: format!("{inferred}"),
415                    },
416                    Severity::Error,
417                    span,
418                );
419            }
420        }
421
422        // Check inter-procedural throws: if callee declares @throws, check if caller covers them
423        for callee_throw in resolved.throws.iter() {
424            if !ctx.fn_declared_throws.iter().any(|declared| {
425                declared.as_ref() == callee_throw.as_ref()
426                    || crate::db::extends_or_implements_via_db(
427                        ea.db,
428                        callee_throw.as_ref(),
429                        declared.as_ref(),
430                    )
431            }) {
432                ea.emit(
433                    IssueKind::MissingThrowsDocblock {
434                        class: callee_throw.to_string(),
435                    },
436                    Severity::Info,
437                    span,
438                );
439            }
440        }
441
442        if !bindings.is_empty() {
443            ret_raw.substitute_templates(&bindings)
444        } else {
445            ret_raw
446        }
447    } else if crate::db::type_exists_via_db(ea.db, fqcn)
448        && !crate::db::has_unknown_ancestor_via_db(ea.db, fqcn)
449    {
450        let (is_interface, is_abstract) = crate::db::class_kind_via_db(ea.db, fqcn)
451            .map(|k| (k.is_interface, k.is_abstract))
452            .unwrap_or((false, false));
453        // Check for __call in the full inheritance chain (not just direct methods)
454        let has_call_magic = crate::db::lookup_method_in_chain(ea.db, fqcn, "__call").is_some();
455        if is_interface || is_abstract || has_call_magic {
456            Union::mixed()
457        } else {
458            ea.emit(
459                IssueKind::UndefinedMethod {
460                    class: fqcn.to_string(),
461                    method: method_name.to_string(),
462                },
463                Severity::Error,
464                span,
465            );
466            Union::mixed()
467        }
468    } else {
469        Union::mixed()
470    }
471}