Skip to main content

backhopper_core/compat/
indirect_calls.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! Extractor for `m:f/a` references carried as arguments to a known
6//! indirection form: meck expectations, `rpc:call`, `erpc:call`,
7//! `rabbit_misc:rpc_call`, and the RabbitMQ CT broker rpc helpers. The
8//! atoms naming the referenced function are argument payload to the
9//! call-site extractor, so without this pass a test ported from a newer
10//! branch can reference a function the target branch never had and the
11//! target-tree axis reports nothing. One level of unwrapping covers a
12//! meck expectation reached through an rpc helper, the observed
13//! incident's shape.
14//!
15//! The same forms are read in Elixir sources, where the call is written
16//! `:module.function(args)` and the argument atoms carry a leading `:`.
17//! The referenced module is Erlang whatever the source language, so the
18//! form table, the arity readers, and the unwrap are shared; only the
19//! call-site matcher, the atom parser, and the comment stripper differ.
20//!
21//! A non-literal module or function is skipped without counting: it is
22//! not a reference this axis claims to read. A literal `m:f` whose arity
23//! source is unreadable is counted as withheld, never guessed.
24
25use std::str::FromStr;
26use std::sync::OnceLock;
27
28use backhopper_erlang_scan::{
29    ScannedArgs, ScannedList, is_bare_atom, scan_list_elements, scan_top_level_args,
30};
31use regex::Regex;
32
33use crate::compat::call_sites::{
34    BodyRun, body_runs_from_context, body_runs_with, call_re, line_context, run_line_at,
35};
36use crate::model::names::{Arity, FunctionName, Mfa, ModuleName};
37use crate::model::symbol::LineClass;
38use crate::model::verdict::IndirectCallForm;
39
40/// Which source language a form was written in: selects the atom parser
41/// and the fun-arity policy, nothing else.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum Syntax {
44    Erlang,
45    Elixir,
46}
47
48impl Syntax {
49    fn parse_atom(self, raw: &str) -> Option<String> {
50        match self {
51            Self::Erlang => parse_atom(raw),
52            Self::Elixir => parse_elixir_atom(raw),
53        }
54    }
55
56    /// Erlang reads a `fun` head or capture; Elixir withholds: no
57    /// observed case passes a fun through an Elixir meck expectation,
58    /// and the grammar is separate.
59    fn fun_arity(self, raw: Option<&&str>) -> Option<Arity> {
60        match self {
61            Self::Erlang => raw.and_then(|a| fun_arity(a)),
62            Self::Elixir => None,
63        }
64    }
65}
66
67/// One statically readable `m:f/a` carried as arguments to a
68/// recognized indirection form.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct IndirectCallSite {
71    pub mfa: Mfa,
72    pub via: IndirectCallForm,
73    pub line: u32,
74}
75
76/// An occurrence whose module and function were literal atoms but whose
77/// arity source was not readable.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct WithheldIndirectCall {
80    pub module: ModuleName,
81    pub function: FunctionName,
82    pub line: u32,
83}
84
85/// Extraction result: the readable sites plus the occurrences whose arity
86/// source was not readable.
87#[derive(Debug, Default, PartialEq, Eq)]
88pub struct IndirectExtraction {
89    pub sites: Vec<IndirectCallSite>,
90    pub withheld_dynamic: Vec<WithheldIndirectCall>,
91}
92
93impl IndirectExtraction {
94    fn withhold(&mut self, module: ModuleName, function: FunctionName, line: u32) {
95        self.withheld_dynamic.push(WithheldIndirectCall {
96            module,
97            function,
98            line,
99        });
100    }
101}
102
103/// Where a recognized form carries its module, function, and arity.
104/// Positions are argument indexes; the arity source names both the
105/// index and how to read it.
106struct FormSpec {
107    via: IndirectCallForm,
108    module_pos: usize,
109    function_pos: usize,
110    arity: AritySource,
111}
112
113enum AritySource {
114    IntegerLiteral(usize),
115    FunExpression(usize),
116    ArgumentList(usize),
117}
118
119/// The form table, keyed by the called module, function, and argument
120/// count. The CT broker helper is overloaded with the module at
121/// different positions per arity, which is why the count is part of
122/// the key.
123fn form_spec(module: &str, function: &str, argc: usize) -> Option<FormSpec> {
124    let spec = |via, module_pos, function_pos, arity| FormSpec {
125        via,
126        module_pos,
127        function_pos,
128        arity,
129    };
130    match (module, function, argc) {
131        ("meck", "expect", 3) => Some(spec(
132            IndirectCallForm::MeckExpect,
133            0,
134            1,
135            AritySource::FunExpression(2),
136        )),
137        ("meck", "expect", 4) => Some(spec(
138            IndirectCallForm::MeckExpect,
139            0,
140            1,
141            AritySource::IntegerLiteral(2),
142        )),
143        ("rpc", "call", 4 | 5) => Some(spec(
144            IndirectCallForm::RpcCall,
145            1,
146            2,
147            AritySource::ArgumentList(3),
148        )),
149        ("rabbit_misc", "rpc_call", 4 | 5) => Some(spec(
150            IndirectCallForm::RabbitMiscRpcCall,
151            1,
152            2,
153            AritySource::ArgumentList(3),
154        )),
155        ("erpc", "call", 4 | 5) => Some(spec(
156            IndirectCallForm::ErpcCall,
157            1,
158            2,
159            AritySource::ArgumentList(3),
160        )),
161        ("rabbit_ct_broker_helpers", "rpc", 4) | ("rabbit_ct_broker_helpers", "rpc_all", 4 | 5) => {
162            Some(spec(
163                IndirectCallForm::CtBrokerHelperRpc,
164                1,
165                2,
166                AritySource::ArgumentList(3),
167            ))
168        }
169        ("rabbit_ct_broker_helpers", "rpc", 5 | 6) => Some(spec(
170            IndirectCallForm::CtBrokerHelperRpc,
171            2,
172            3,
173            AritySource::ArgumentList(4),
174        )),
175        _ => None,
176    }
177}
178
179/// Every statically readable indirect MFA reference in `src`, scanned
180/// over the same joined body runs as the qualified-call extractor so a
181/// wrapped form recovers its exact arity. `line_map[i]` is text line
182/// `i`'s file line.
183pub fn extract_indirect_calls(src: &str, line_map: &[u32]) -> IndirectExtraction {
184    extract_indirect_calls_with_context(src, line_map, &line_context(src))
185}
186
187/// `extract_indirect_calls` over a precomputed per-line classification
188/// rather than one derived from `src` alone, for the same reason
189/// `extract_qualified_calls_with_context` exists: a wrapped form whose
190/// opener is in a hunk's `Context` line, outside `src`, still
191/// classifies against its real attribute region.
192pub fn extract_indirect_calls_with_context(
193    src: &str,
194    line_map: &[u32],
195    ctx: &[LineClass],
196) -> IndirectExtraction {
197    let mut out = IndirectExtraction::default();
198    for run in body_runs_from_context(src, line_map, ctx) {
199        for caps in call_re().captures_iter(&run.text) {
200            let group = caps.get(0).expect("capture");
201            let ScannedArgs::Terminated { args, .. } =
202                scan_top_level_args(&run.text[group.end()..])
203            else {
204                continue;
205            };
206            let Some(spec) = form_spec(&caps[1], &caps[2], args.len()) else {
207                continue;
208            };
209            let line = run_line_at(&run.line_starts, group.start());
210            read_reference(&spec, &args, line, Syntax::Erlang, &mut out);
211        }
212    }
213    out
214}
215
216/// Every statically readable indirect MFA reference in an Elixir
217/// `src`, from `:module.function(args)` calls whose form is in the
218/// shared table. The referenced module resolves against the same Erlang
219/// target tree as the Erlang sites.
220pub fn extract_indirect_calls_elixir(src: &str, line_map: &[u32]) -> IndirectExtraction {
221    let mut out = IndirectExtraction::default();
222    for run in elixir_body_runs(src, line_map) {
223        for caps in elixir_call_re().captures_iter(&run.text) {
224            let group = caps.get(0).expect("capture");
225            let ScannedArgs::Terminated { args, .. } =
226                scan_top_level_args(&run.text[group.end()..])
227            else {
228                continue;
229            };
230            let Some(spec) = form_spec(&caps[1], &caps[2], args.len()) else {
231                continue;
232            };
233            let line = run_line_at(&run.line_starts, group.start());
234            read_reference(&spec, &args, line, Syntax::Elixir, &mut out);
235        }
236    }
237    out
238}
239
240/// Reads one occurrence of a recognized form. A non-atom module or
241/// function skips silently; a readable pair with an unreadable arity
242/// counts as withheld.
243fn read_reference(
244    spec: &FormSpec,
245    args: &[&str],
246    line: u32,
247    syntax: Syntax,
248    out: &mut IndirectExtraction,
249) {
250    let (Some(module), Some(function)) = (
251        args.get(spec.module_pos).and_then(|a| syntax.parse_atom(a)),
252        args.get(spec.function_pos)
253            .and_then(|a| syntax.parse_atom(a)),
254    ) else {
255        return;
256    };
257    let (Ok(module), Ok(function)) = (
258        ModuleName::from_str(&module),
259        FunctionName::from_str(&function),
260    ) else {
261        return;
262    };
263    let arity = match &spec.arity {
264        AritySource::IntegerLiteral(pos) => integer_arity(args.get(*pos)),
265        AritySource::FunExpression(pos) => syntax.fun_arity(args.get(*pos)),
266        AritySource::ArgumentList(pos) => match list_elements(args.get(*pos)) {
267            Some(elements) => {
268                // the rpc'd callee is itself a meck expectation: unwrap one level
269                if module.as_str() == "meck" && function.as_str() == "expect" {
270                    if let Some(inner) = form_spec("meck", "expect", elements.len()) {
271                        let elements: Vec<&str> = elements.iter().map(String::as_str).collect();
272                        read_reference(&inner, &elements, line, syntax, out);
273                    } else {
274                        out.withhold(module, function, line);
275                    }
276                    return;
277                }
278                Arity::try_from(elements.len()).ok()
279            }
280            None => None,
281        },
282    };
283    match arity {
284        Some(arity) => out.sites.push(IndirectCallSite {
285            mfa: Mfa::new(module, function, arity),
286            via: spec.via,
287            line,
288        }),
289        None => out.withhold(module, function, line),
290    }
291}
292
293/// A bare or quoted literal atom, or `None` for anything else.
294fn parse_atom(raw: &str) -> Option<String> {
295    let raw = raw.trim();
296    if let Some(inner) = raw.strip_prefix('\'') {
297        let inner = inner.strip_suffix('\'')?;
298        if inner.is_empty() || inner.contains('\'') {
299            return None;
300        }
301        return Some(inner.to_owned());
302    }
303    if is_bare_atom(raw) {
304        Some(raw.to_owned())
305    } else {
306        None
307    }
308}
309
310/// An Elixir atom naming an Erlang module or function: `:name`,
311/// `:"name"`, or `:'name'`. A bare identifier without the leading `:`
312/// is a variable or an alias in Elixir, not an atom, so it is `None`
313/// and the occurrence skips uncounted.
314fn parse_elixir_atom(raw: &str) -> Option<String> {
315    let rest = raw.trim().strip_prefix(':')?;
316    for quote in ['"', '\''] {
317        if let Some(inner) = rest.strip_prefix(quote) {
318            let inner = inner.strip_suffix(quote)?;
319            if inner.contains(quote) {
320                return None;
321            }
322            return Some(inner.to_owned());
323        }
324    }
325    let mut chars = rest.chars();
326    let first = chars.next()?;
327    if (first.is_ascii_lowercase() || first == '_')
328        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '@')
329    {
330        Some(rest.to_owned())
331    } else {
332        None
333    }
334}
335
336/// `:module.function(` for an Erlang module called from Elixir. The
337/// module and function are bare lowercase atoms, the same shape the
338/// Erlang matcher expects for the argument positions.
339fn elixir_call_re() -> &'static Regex {
340    static R: OnceLock<Regex> = OnceLock::new();
341    R.get_or_init(|| {
342        Regex::new(r":([a-z][a-zA-Z0-9_@]*)\.([a-z][a-zA-Z0-9_@]*)\s*\(").expect("regex")
343    })
344}
345
346/// Elixir run assembly: join file-adjacent added lines after stripping
347/// `#` comments, so a form wrapped across lines is scanned whole. No
348/// attribute classification, since every Elixir added line is body.
349fn elixir_body_runs(src: &str, line_map: &[u32]) -> Vec<BodyRun> {
350    body_runs_with(src, line_map, strip_elixir_line_comment, |_| true)
351}
352
353/// The prefix of `line` before an Elixir `#` line comment, leaving a
354/// `#` inside a string, charlist, or `?#` char literal in place.
355fn strip_elixir_line_comment(line: &str) -> &str {
356    let bytes = line.as_bytes();
357    let mut in_str = false;
358    let mut in_charlist = false;
359    let mut prev_ident = false;
360    let mut i = 0usize;
361    while i < bytes.len() {
362        let b = bytes[i];
363        if in_str || in_charlist {
364            match b {
365                b'\\' => i += 1,
366                b'"' if in_str => in_str = false,
367                b'\'' if in_charlist => in_charlist = false,
368                _ => {}
369            }
370            i += 1;
371            prev_ident = false;
372            continue;
373        }
374        match b {
375            b'"' => in_str = true,
376            b'\'' => in_charlist = true,
377            // a ?c char literal only at a token boundary, so a foo? suffix is not one
378            b'?' if !prev_ident => {
379                i += 1;
380                if bytes.get(i) == Some(&b'\\') {
381                    i += 1;
382                }
383                i += 1;
384                prev_ident = false;
385                continue;
386            }
387            b'#' => return &line[..i],
388            _ => {}
389        }
390        prev_ident = b.is_ascii_alphanumeric() || b == b'_';
391        i += 1;
392    }
393    line
394}
395
396/// An integer-literal arity, or `None`.
397fn integer_arity(raw: Option<&&str>) -> Option<Arity> {
398    let raw = raw?.trim();
399    let value: usize = raw.parse().ok()?;
400    Arity::try_from(value).ok()
401}
402
403/// Arity of a `fun` expression: a `fun(...) ->` head or a `fun [m:]f/N`
404/// capture. `None` for anything else, including a variable.
405fn fun_arity(raw: &str) -> Option<Arity> {
406    let rest = raw.trim().strip_prefix("fun")?;
407    // only an open paren or a space follows the fun keyword: funny(...) must not match
408    if !rest.starts_with('(') && !rest.starts_with(char::is_whitespace) {
409        return None;
410    }
411    let rest = rest.trim_start();
412    if let Some(after_paren) = rest.strip_prefix('(') {
413        let ScannedArgs::Terminated { args, .. } = scan_top_level_args(after_paren) else {
414            return None;
415        };
416        return Arity::try_from(args.len()).ok();
417    }
418    // a capture's arity is the digits after the final slash
419    let (_, digits) = rest.rsplit_once('/')?;
420    let value: usize = digits.trim().parse().ok()?;
421    Arity::try_from(value).ok()
422}
423
424/// The elements of a literal list argument, or `None` when the
425/// argument is not a plain terminated list.
426fn list_elements(raw: Option<&&str>) -> Option<Vec<String>> {
427    let raw = raw?.trim();
428    let after_bracket = raw.strip_prefix('[')?;
429    match scan_list_elements(after_bracket) {
430        // trailing text after the close bracket means the argument is not just this list
431        ScannedList::Terminated { elements, consumed }
432            if after_bracket[consumed..].trim().is_empty() =>
433        {
434            Some(elements.into_iter().map(str::to_owned).collect())
435        }
436        ScannedList::Terminated { .. } | ScannedList::ImproperTail | ScannedList::Unterminated => {
437            None
438        }
439    }
440}