1use 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#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct IndirectCallSite {
71 pub mfa: Mfa,
72 pub via: IndirectCallForm,
73 pub line: u32,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct WithheldIndirectCall {
80 pub module: ModuleName,
81 pub function: FunctionName,
82 pub line: u32,
83}
84
85#[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
103struct 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
119fn 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
179pub fn extract_indirect_calls(src: &str, line_map: &[u32]) -> IndirectExtraction {
184 extract_indirect_calls_with_context(src, line_map, &line_context(src))
185}
186
187pub 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
216pub 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
240fn 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 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
293fn 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
310fn 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
336fn 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
346fn elixir_body_runs(src: &str, line_map: &[u32]) -> Vec<BodyRun> {
350 body_runs_with(src, line_map, strip_elixir_line_comment, |_| true)
351}
352
353fn 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 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
396fn 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
403fn fun_arity(raw: &str) -> Option<Arity> {
406 let rest = raw.trim().strip_prefix("fun")?;
407 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 let (_, digits) = rest.rsplit_once('/')?;
420 let value: usize = digits.trim().parse().ok()?;
421 Arity::try_from(value).ok()
422}
423
424fn 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 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}