Skip to main content

backhopper_core/compat/
call_sites.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//! Call-site extractor for Erlang source lines.
6//!
7//! Given a hunk line of source text, finds references to:
8//!   * `mod:fun(args)` calls
9//!   * `fun mod:fun/N` remote fun references
10//!   * `?MACRO_USE`
11//!   * `#record_use{}`
12//! and definitions:
13//!   * `name(...)` heads on lines that start at column 0 (function clauses)
14//!
15//! Also classifies dynamic dispatch that the static analyzer cannot resolve:
16//!   * apply-family BIFs (`apply`, `spawn`, `spawn_link`, ...)
17//!   * variable-module and variable-function calls (`Mod:foo(...)`, `mod:F(...)`)
18//!
19//! These flow into a separate diagnostic envelope and never feed `Verdict`.
20
21use std::mem;
22use std::str::FromStr;
23use std::sync::OnceLock;
24
25use regex::Regex;
26
27use crate::compat::arg_shape::ArgShape;
28use crate::erlang_macros::{MacroTable, expand_value_macro_to_atom, expand_value_macro_to_mf};
29use crate::model::names::{Arity, FunctionName, Mfa, ModuleName, RecordName, TypeName};
30use crate::model::symbol::{RefContext, RefOrigin, SymbolRef};
31
32// The lexical scanners live in the leaf crate; re-exported so the
33// `compat::call_sites` import path stays stable for callers.
34pub use backhopper_erlang_scan::{
35    ScanArity, ScannedArgs, scan_top_level_args, split_top_level_args,
36};
37use backhopper_erlang_scan::{count_top_level_items, scan_arity};
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub enum DynamicCall {
41    /// One of the apply-family BIFs: `apply/2`, `apply/3`, `spawn/3`,
42    /// `spawn_link/3`, `spawn_monitor/3`, `spawn_opt/4`, `hibernate/3`.
43    Apply,
44    /// A call whose module or function name is a variable: `Mod:foo(...)`,
45    /// `mod:F(...)`, or `Mod:F(...)`.
46    VariableDispatch,
47}
48
49pub(crate) fn call_re() -> &'static Regex {
50    static R: OnceLock<Regex> = OnceLock::new();
51    R.get_or_init(|| {
52        Regex::new(r"\b([a-z][a-zA-Z0-9_@]*)\s*:\s*([a-z][a-zA-Z0-9_@]*)\s*\(").expect("regex")
53    })
54}
55
56fn record_re() -> &'static Regex {
57    static R: OnceLock<Regex> = OnceLock::new();
58    R.get_or_init(|| Regex::new(r"#([a-z][a-zA-Z0-9_@]*)\b").expect("regex"))
59}
60
61fn macro_re() -> &'static Regex {
62    static R: OnceLock<Regex> = OnceLock::new();
63    R.get_or_init(|| Regex::new(r"\?([A-Z_][A-Za-z0-9_]*)\b").expect("regex"))
64}
65
66// Multi-line `^`: a joined run holds several clause heads, each at the
67// start of its physical line.
68fn fun_def_re() -> &'static Regex {
69    static R: OnceLock<Regex> = OnceLock::new();
70    R.get_or_init(|| Regex::new(r"(?m)^([a-z][a-zA-Z0-9_@]*)\s*\(").expect("regex"))
71}
72
73fn apply_bif_re() -> &'static Regex {
74    static R: OnceLock<Regex> = OnceLock::new();
75    R.get_or_init(|| {
76        Regex::new(r"\b(apply|spawn|spawn_link|spawn_monitor|spawn_opt|hibernate)\s*\(")
77            .expect("regex")
78    })
79}
80
81fn var_module_call_re() -> &'static Regex {
82    static R: OnceLock<Regex> = OnceLock::new();
83    R.get_or_init(|| {
84        Regex::new(r"\b[A-Z][a-zA-Z0-9_@]*\s*:\s*[a-zA-Z_][a-zA-Z0-9_@]*\s*\(").expect("regex")
85    })
86}
87
88fn var_function_call_re() -> &'static Regex {
89    static R: OnceLock<Regex> = OnceLock::new();
90    R.get_or_init(|| {
91        Regex::new(r"\b[a-z][a-zA-Z0-9_@]*\s*:\s*[A-Z][a-zA-Z0-9_@]*\s*\(").expect("regex")
92    })
93}
94
95pub fn extract_into(line: &str, out: &mut Vec<SymbolRef>) {
96    extract_into_with_macros(line, empty_macros(), out);
97}
98
99/// One statically-named `mod:fun(Args)` call with its source line.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct QualifiedCallSite {
102    pub mfa: Mfa,
103    pub line: u32,
104}
105
106/// Every statically-named `mod:fun(Args)` call in `src`, with line and
107/// arity. Type-attribute lines (`-spec`, `-callback`, `-type`,
108/// `-opaque`) are skipped: a qualified `mod:t()` there is a type
109/// reference, not a call. `line_map[i]` is text line `i`'s file line:
110/// consecutive `Body` lines join into one scanned run only when
111/// file-adjacent, so a call whose argument list wraps yields its exact
112/// arity while fragments from different hunks never concatenate into a
113/// false construct. The scanner's classification state persists across
114/// gaps on purpose: resetting would misread a still-open `-spec` as
115/// body text.
116pub fn extract_qualified_calls(src: &str, line_map: &[u32]) -> Vec<QualifiedCallSite> {
117    let mut out = Vec::new();
118    for run in body_runs(src, line_map) {
119        let mut calls = Vec::new();
120        push_call_args_with_offsets(&run.text, &mut calls);
121        for ((mfa, _shapes), offset) in calls {
122            out.push(QualifiedCallSite {
123                mfa,
124                line: run_line_at(&run.line_starts, offset),
125            });
126        }
127    }
128    out
129}
130
131/// One joined run of file-adjacent body lines, comment-stripped, with
132/// each line's byte offset into `text` paired with its 1-based text
133/// line. Shared by the qualified-call and indirect-reference
134/// extractors so both see wrapped constructs whole.
135#[derive(Debug)]
136pub(crate) struct BodyRun {
137    pub(crate) text: String,
138    pub(crate) line_starts: Vec<(usize, u32)>,
139}
140
141/// The accumulate-and-flush core for body runs: `strip` removes each
142/// line's comment and `is_body` classifies the stripped line;
143/// consecutive body lines with adjacent file lines join into one run,
144/// so fragments from different hunks never concatenate into a false
145/// construct.
146pub(crate) fn body_runs_with<'a>(
147    src: &'a str,
148    line_map: &[u32],
149    strip: impl Fn(&'a str) -> &'a str,
150    mut is_body: impl FnMut(&str) -> bool,
151) -> Vec<BodyRun> {
152    debug_assert_eq!(
153        line_map.len(),
154        src.lines().count(),
155        "line map length must equal the line count"
156    );
157    let mut out = Vec::new();
158    let mut run = String::new();
159    let mut run_lines: Vec<(usize, u32)> = Vec::new();
160    let mut prev_body_file_line: Option<u32> = None;
161    for (idx, line) in src.lines().enumerate() {
162        let stripped = strip(line);
163        let body = is_body(stripped);
164        let file_line = line_map.get(idx).copied();
165        let adjacent =
166            matches!((prev_body_file_line, file_line), (Some(prev), Some(cur)) if cur == prev + 1);
167        if (!body || !adjacent) && !run.is_empty() {
168            out.push(BodyRun {
169                text: mem::take(&mut run),
170                line_starts: mem::take(&mut run_lines),
171            });
172        }
173        prev_body_file_line = body.then_some(file_line).flatten();
174        if !body {
175            continue;
176        }
177        run_lines.push((run.len(), (idx + 1) as u32));
178        run.push_str(stripped);
179        run.push('\n');
180    }
181    if !run.is_empty() {
182        out.push(BodyRun {
183            text: run,
184            line_starts: run_lines,
185        });
186    }
187    out
188}
189
190/// Joins consecutive `Body` lines into runs, breaking on attribute
191/// context and on file-line gaps.
192pub(crate) fn body_runs(src: &str, line_map: &[u32]) -> Vec<BodyRun> {
193    let mut scanner = AttrCtxScanner::new();
194    body_runs_with(src, line_map, strip_line_comment, |s| {
195        scanner.classify(s) == RefContext::Body
196    })
197}
198
199/// The value of the last `(start, value)` entry whose start is at or
200/// before `offset`, or `default` when `offset` precedes every entry.
201/// Entries are sorted by start.
202fn value_at_or_before<T: Copy>(entries: &[(usize, T)], offset: usize, default: T) -> T {
203    match entries.partition_point(|(start, _)| *start <= offset) {
204        0 => default,
205        count => entries[count - 1].1,
206    }
207}
208
209/// The `SymbolRef` for a scanned `m:f` call: exact arity when the
210/// argument list terminated on the line, else any-arity.
211fn scanned_call(scan: ScanArity, module: ModuleName, function: FunctionName) -> SymbolRef {
212    match scan {
213        ScanArity::Exact(arity) => {
214            SymbolRef::function(Mfa::new(module, function, Arity::new(arity)))
215        }
216        ScanArity::Unterminated => SymbolRef::function_any_arity(module, function),
217    }
218}
219
220// Match-start origin: the run line whose start offset is the last one
221// at or before the match offset. Before the first run, the first run's
222// line clamps in (or 1 when there are no runs).
223pub(crate) fn run_line_at(run_lines: &[(usize, u32)], offset: usize) -> u32 {
224    value_at_or_before(run_lines, offset, run_lines.first().map_or(1, |(_, l)| *l))
225}
226
227/// Cross-line classifier that decides whether a hunk line is body or
228/// type-attribute context. `-spec`, `-callback`, `-type`, and
229/// `-opaque` open a type-attribute region that runs until the
230/// terminating `.` at the top level; a single line may both open and
231/// close one. Multi-line type attributes flow through `in_attr` so
232/// the second and later lines of a wrapped `-spec` classify
233/// correctly.
234#[derive(Debug, Default, Clone, Copy)]
235pub struct AttrCtxScanner {
236    in_attr: bool,
237}
238
239impl AttrCtxScanner {
240    pub fn new() -> Self {
241        Self { in_attr: false }
242    }
243
244    pub fn classify(&mut self, line: &str) -> RefContext {
245        if self.in_attr {
246            if line_closes_attribute(line) {
247                self.in_attr = false;
248            }
249            return RefContext::TypeAttribute;
250        }
251        if line_opens_type_attr(line) {
252            if !line_closes_attribute(line) {
253                self.in_attr = true;
254            }
255            return RefContext::TypeAttribute;
256        }
257        RefContext::Body
258    }
259}
260
261fn line_opens_type_attr(line: &str) -> bool {
262    let trimmed = line.trim_start();
263    if !trimmed.starts_with('-') {
264        return false;
265    }
266    for prefix in ["-spec", "-callback", "-type", "-opaque"] {
267        if let Some(rest) = trimmed.strip_prefix(prefix) {
268            match rest.chars().next() {
269                Some(c) if c == '(' || c.is_whitespace() => return true,
270                _ => {}
271            }
272        }
273    }
274    false
275}
276
277fn line_closes_attribute(line: &str) -> bool {
278    let stripped = strip_line_comment(line);
279    stripped.trim_end().ends_with('.')
280}
281
282/// Cuts an unquoted `%` comment off a source line. Strings, quoted
283/// atoms, escapes, and `$c` char literals (`$%`, `$"`) stay intact.
284pub fn strip_line_comment(line: &str) -> &str {
285    let bytes = line.as_bytes();
286    let mut in_str = false;
287    let mut in_atom = false;
288    let mut i = 0usize;
289    while i < bytes.len() {
290        let b = bytes[i];
291        if in_str || in_atom {
292            match b {
293                b'\\' => i += 1,
294                b'"' if in_str => in_str = false,
295                b'\'' if in_atom => in_atom = false,
296                _ => {}
297            }
298            i += 1;
299            continue;
300        }
301        match b {
302            b'"' => in_str = true,
303            b'\'' => in_atom = true,
304            b'$' => {
305                i += 1;
306                if bytes.get(i) == Some(&b'\\') {
307                    i += 1;
308                }
309            }
310            b'%' => return &line[..i],
311            _ => {}
312        }
313        i += 1;
314    }
315    line
316}
317
318/// Type-attribute counterpart to `extract_into_with_macros`.
319///
320/// Inside `-spec`, `-callback`, `-type`, and `-opaque` declarations the
321/// `mod:ident(...)` shape is a type reference (`ra:index()` is the
322/// type `index/0`, not a call). Records and apply-family BIFs are
323/// syntactically impossible there, so this extractor only emits type
324/// refs and macro uses. Macro values are not resolved into MFAs in
325/// this context: a `?MOD:fn` expansion in a spec position would be a
326/// type, but the macro tables today carry value-to-atom bindings
327/// only, with no type-side equivalent.
328pub fn extract_type_refs_into(line: &str, out: &mut Vec<SymbolRef>) {
329    let line = strip_line_comment(line);
330    let mut with_offsets = Vec::new();
331    push_type_refs_with_offsets(line, &mut with_offsets);
332    out.extend(with_offsets.into_iter().map(|(r, _)| r));
333}
334
335/// `extract_type_refs_into` over already-stripped text, with each
336/// reference's match offset for origin mapping.
337fn push_type_refs_with_offsets(text: &str, out: &mut Vec<(SymbolRef, usize)>) {
338    for caps in call_re().captures_iter(text) {
339        let group = caps.get(0).expect("capture");
340        let module = &caps[1];
341        let ident = &caps[2];
342        // There is no any-arity type kind, so a type ref that does not
343        // close even across the run is skipped, not guessed.
344        let ScanArity::Exact(arity) = scan_arity(&text[group.end()..]) else {
345            continue;
346        };
347        if let (Ok(m), Ok(t)) = (ModuleName::from_str(module), TypeName::from_str(ident)) {
348            out.push((SymbolRef::type_ref(m, t, Arity::new(arity)), group.start()));
349        }
350    }
351    for caps in macro_re().captures_iter(text) {
352        let group = caps.get(0).expect("capture");
353        out.push((SymbolRef::macro_use(caps[1].to_owned()), group.start()));
354    }
355}
356
357fn empty_macros() -> &'static MacroTable {
358    static EMPTY: OnceLock<MacroTable> = OnceLock::new();
359    EMPTY.get_or_init(MacroTable::new)
360}
361
362/// Same as `extract_into` but consults `macros` so `?Name`-style
363/// references resolve into concrete `mod:fn(...)` symbols when the
364/// macro's body is a bare atom (module) or `mod:fn` pair. Apply-family
365/// BIFs whose `M`, `F`, and arg-list slots are literals also resolve
366/// into the matching `Mfa` and land in `out` as normal function refs.
367pub fn extract_into_with_macros(line: &str, macros: &MacroTable, out: &mut Vec<SymbolRef>) {
368    let line = strip_line_comment(line);
369    let mut with_offsets = Vec::new();
370    push_refs_with_offsets(line, macros, &mut with_offsets);
371    out.extend(with_offsets.into_iter().map(|(r, _)| r));
372}
373
374/// `extract_into_with_macros` over already-stripped text, pairing each
375/// reference with the byte offset its match started at, so the caller
376/// can map it to the origin of the physical line that offset lands on.
377fn push_refs_with_offsets(text: &str, macros: &MacroTable, out: &mut Vec<(SymbolRef, usize)>) {
378    for caps in call_re().captures_iter(text) {
379        let group = caps.get(0).expect("capture");
380        let module = &caps[1];
381        let function = &caps[2];
382        let after = &text[group.end()..];
383        let (Ok(m), Ok(f)) = (
384            ModuleName::from_str(module),
385            FunctionName::from_str(function),
386        ) else {
387            continue;
388        };
389        out.push((scanned_call(scan_arity(after), m, f), group.start()));
390    }
391    push_fun_refs_with_offsets(text, macros, out);
392    for caps in record_re().captures_iter(text) {
393        let start = caps.get(0).expect("capture").start();
394        if let Ok(name) = RecordName::from_str(&caps[1]) {
395            out.push((SymbolRef::record(name), start));
396        }
397    }
398    for caps in macro_re().captures_iter(text) {
399        let group = caps.get(0).expect("capture");
400        let name = &caps[1];
401        out.push((SymbolRef::macro_use(name.to_owned()), group.start()));
402        let mut resolved = Vec::new();
403        try_resolve_macro_call(name, &text[group.end()..], macros, &mut resolved);
404        out.extend(resolved.into_iter().map(|r| (r, group.start())));
405    }
406    push_apply_family_with_offsets(text, macros, out);
407}
408
409fn fun_ref_re() -> &'static Regex {
410    static R: OnceLock<Regex> = OnceLock::new();
411    R.get_or_init(|| {
412        Regex::new(
413            r"\bfun\s+(\??[A-Za-z_][a-zA-Z0-9_@]*)\s*:\s*([A-Za-z_][a-zA-Z0-9_@]*)\s*/\s*([0-9]+|[A-Za-z_][a-zA-Z0-9_@]*)",
414        )
415        .expect("regex")
416    })
417}
418
419/// `fun M:F/A` remote fun references. The arity is written in the
420/// source, so these land at full precision. Variable-shaped slots are
421/// `extract_dynamic_into`'s business.
422fn push_fun_refs_with_offsets(text: &str, macros: &MacroTable, out: &mut Vec<(SymbolRef, usize)>) {
423    for caps in fun_ref_re().captures_iter(text) {
424        let module_raw = &caps[1];
425        let function_raw = &caps[2];
426        let arity_raw = &caps[3];
427        let Ok(arity) = arity_raw.parse::<u8>() else {
428            continue;
429        };
430        if !function_raw.starts_with(|c: char| c.is_ascii_lowercase()) {
431            continue;
432        }
433        let module = if let Some(macro_name) = module_raw.strip_prefix('?') {
434            let Some(expanded) = expand_value_macro_to_atom(macros, macro_name) else {
435                continue;
436            };
437            let Ok(m) = ModuleName::from_str(&expanded) else {
438                continue;
439            };
440            m
441        } else if module_raw.starts_with(|c: char| c.is_ascii_lowercase()) {
442            let Ok(m) = ModuleName::from_str(module_raw) else {
443                continue;
444            };
445            m
446        } else {
447            continue;
448        };
449        let Ok(f) = FunctionName::from_str(function_raw) else {
450            continue;
451        };
452        let start = caps.get(0).expect("capture").start();
453        out.push((
454            SymbolRef::function(Mfa::new(module, f, Arity::new(arity))),
455            start,
456        ));
457    }
458}
459
460fn try_resolve_macro_call(name: &str, tail: &str, macros: &MacroTable, out: &mut Vec<SymbolRef>) {
461    let trimmed = tail.trim_start();
462    if let Some(rest) = trimmed.strip_prefix('(') {
463        let Some((m, f)) = expand_value_macro_to_mf(macros, name) else {
464            return;
465        };
466        let (Ok(mn), Ok(fn_)) = (ModuleName::from_str(&m), FunctionName::from_str(&f)) else {
467            return;
468        };
469        out.push(scanned_call(scan_arity(rest), mn, fn_));
470        return;
471    }
472    if let Some(rest) = trimmed.strip_prefix(':') {
473        let rest = rest.trim_start();
474        let (function_str, after_fn) = split_lowercase_ident(rest);
475        if function_str.is_empty() {
476            return;
477        }
478        let after = after_fn.trim_start();
479        if let Some(after_open) = after.strip_prefix('(') {
480            if let Some(m) = expand_value_macro_to_atom(macros, name) {
481                if let (Ok(mn), Ok(fn_)) = (
482                    ModuleName::from_str(&m),
483                    FunctionName::from_str(function_str),
484                ) {
485                    out.push(scanned_call(scan_arity(after_open), mn, fn_));
486                }
487            }
488        }
489    }
490}
491
492fn split_lowercase_ident(s: &str) -> (&str, &str) {
493    let end = s
494        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '@'))
495        .unwrap_or(s.len());
496    if end == 0 || !s.starts_with(|c: char| c.is_ascii_lowercase()) {
497        return ("", s);
498    }
499    s.split_at(end)
500}
501
502fn push_apply_family_with_offsets(
503    text: &str,
504    macros: &MacroTable,
505    out: &mut Vec<(SymbolRef, usize)>,
506) {
507    for caps in apply_family_re().captures_iter(text) {
508        let group = caps.get(0).expect("capture");
509        let name = &caps[1];
510        let after_open = &text[group.end()..];
511        // A wrapped apply-family call has an incomplete arg list here;
512        // resolving from a partial slot set would fabricate an Mfa.
513        let ScannedArgs::Terminated { args, .. } = scan_top_level_args(after_open) else {
514            continue;
515        };
516        if let Some(mfa) = resolve_apply_family(name, &args, macros) {
517            out.push((SymbolRef::function(mfa), group.start()));
518        }
519    }
520}
521
522fn apply_family_re() -> &'static Regex {
523    static R: OnceLock<Regex> = OnceLock::new();
524    R.get_or_init(|| {
525        Regex::new(
526            r"\b(?:erlang\s*:\s*)?(apply|spawn|spawn_link|spawn_monitor|spawn_opt|hibernate)\s*\(",
527        )
528        .expect("regex")
529    })
530}
531
532fn resolve_apply_family(name: &str, args: &[&str], macros: &MacroTable) -> Option<Mfa> {
533    let (m_raw, f_raw, a_raw) = match (name, args.len()) {
534        ("apply", 3)
535        | ("spawn" | "spawn_link" | "spawn_monitor" | "hibernate", 3)
536        | ("spawn_opt", 4) => (args[0], args[1], args[2]),
537        ("spawn" | "spawn_link" | "spawn_monitor", 4) | ("spawn_opt", 5) => {
538            (args[1], args[2], args[3])
539        }
540        _ => return None,
541    };
542    let module = atom_or_macro_to::<ModuleName>(m_raw, macros)?;
543    let function = atom_or_macro_to::<FunctionName>(f_raw, macros)?;
544    let arity = literal_list_length(a_raw)?;
545    Some(Mfa::new(module, function, Arity::new(arity)))
546}
547
548// Both `ModuleName` and `FunctionName` are atoms: one resolver, two targets.
549fn atom_or_macro_to<T: FromStr>(raw: &str, macros: &MacroTable) -> Option<T> {
550    let s = raw.trim();
551    if let Some(name) = s.strip_prefix('?') {
552        return expand_value_macro_to_atom(macros, name).and_then(|a| T::from_str(&a).ok());
553    }
554    if looks_like_lowercase_atom(s) {
555        return T::from_str(s).ok();
556    }
557    if s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2 {
558        return T::from_str(&s[1..s.len() - 1]).ok();
559    }
560    None
561}
562
563fn literal_list_length(raw: &str) -> Option<u8> {
564    let s = raw.trim();
565    let inner = s.strip_prefix('[')?.strip_suffix(']')?;
566    let trimmed = inner.trim();
567    if trimmed.is_empty() {
568        return Some(0);
569    }
570    u8::try_from(count_top_level_items(s, '[', ']')).ok()
571}
572
573fn looks_like_lowercase_atom(s: &str) -> bool {
574    let mut chars = s.chars();
575    match chars.next() {
576        Some(c) if c.is_ascii_lowercase() => {}
577        _ => return false,
578    }
579    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '@')
580}
581
582/// Collects per-call argument shapes from one source line. Mirrors
583/// `extract_into` for the `mod:fun(args)` case; classifies each actual
584/// into an `ArgShape` so the analyzer can match against pin-side
585/// clause-head patterns.
586pub fn extract_call_args_into(line: &str, out: &mut Vec<(Mfa, Vec<ArgShape>)>) {
587    let line = strip_line_comment(line);
588    let mut with_offsets = Vec::new();
589    push_call_args_with_offsets(line, &mut with_offsets);
590    out.extend(with_offsets.into_iter().map(|(c, _)| c));
591}
592
593/// `extract_call_args_into` over already-stripped text, with each call's
594/// match offset so the caller can keep only `Added`-origin calls.
595fn push_call_args_with_offsets(text: &str, out: &mut Vec<((Mfa, Vec<ArgShape>), usize)>) {
596    for caps in call_re().captures_iter(text) {
597        let group = caps.get(0).expect("capture");
598        let module = &caps[1];
599        let function = &caps[2];
600        // A call whose arg list never closes in the run has no complete
601        // shape list, and a partial one would feed `ClauseMismatch` garbage.
602        let Some((args, _consumed)) = extract_arg_shapes(&text[group.end()..]) else {
603            continue;
604        };
605        let arity = u8::try_from(args.len()).unwrap_or(u8::MAX);
606        if let (Ok(m), Ok(f)) = (
607            ModuleName::from_str(module),
608            FunctionName::from_str(function),
609        ) {
610            out.push(((Mfa::new(m, f, Arity::new(arity)), args), group.start()));
611        }
612    }
613}
614
615fn extract_arg_shapes(after_open_paren: &str) -> Option<(Vec<ArgShape>, usize)> {
616    match scan_top_level_args(after_open_paren) {
617        ScannedArgs::Terminated { args, consumed } => {
618            Some((args.iter().map(|a| classify_arg(a)).collect(), consumed))
619        }
620        ScannedArgs::Unterminated { .. } => None,
621    }
622}
623
624pub fn classify_arg(raw: &str) -> ArgShape {
625    let s = raw.trim();
626    if s.is_empty() {
627        return ArgShape::Unknown;
628    }
629    let first = s.chars().next().expect("non-empty");
630    if first == '#' {
631        let rest = &s[1..];
632        let name_end = rest
633            .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '@'))
634            .unwrap_or(rest.len());
635        let name = &rest[..name_end];
636        if let Ok(record_name) = RecordName::from_str(name) {
637            return ArgShape::Record { name: record_name };
638        }
639        return ArgShape::Unknown;
640    }
641    if first == '{' {
642        let size = count_top_level_items(s, '{', '}');
643        return ArgShape::Tuple { size };
644    }
645    if first == '[' {
646        return ArgShape::List;
647    }
648    if first == '"' {
649        return ArgShape::String;
650    }
651    if first == '<' && s.starts_with("<<") {
652        return ArgShape::Binary;
653    }
654    if first == '\'' {
655        let inner = s.trim_start_matches('\'').trim_end_matches('\'');
656        return ArgShape::Atom {
657            name: inner.to_string(),
658        };
659    }
660    if first.is_ascii_digit()
661        || (first == '-' && s.chars().nth(1).is_some_and(|c| c.is_ascii_digit()))
662    {
663        return if s.contains('.') {
664            ArgShape::Float
665        } else {
666            ArgShape::Integer
667        };
668    }
669    if first.is_ascii_uppercase() || first == '_' {
670        return ArgShape::Variable;
671    }
672    if first.is_ascii_lowercase() {
673        let bare = is_bare_identifier(s);
674        if bare {
675            return ArgShape::Atom {
676                name: s.to_string(),
677            };
678        }
679        if s.starts_with("fun ") || s == "fun" {
680            return ArgShape::Fun;
681        }
682        return ArgShape::Unknown;
683    }
684    ArgShape::Unknown
685}
686
687fn is_bare_identifier(s: &str) -> bool {
688    s.chars()
689        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '@')
690}
691
692pub fn extract_dynamic_into(line: &str, out: &mut Vec<DynamicCall>) {
693    let line = strip_line_comment(line);
694    for _ in apply_bif_re().captures_iter(line) {
695        out.push(DynamicCall::Apply);
696    }
697    for _ in var_module_call_re().find_iter(line) {
698        out.push(DynamicCall::VariableDispatch);
699    }
700    for _ in var_function_call_re().find_iter(line) {
701        out.push(DynamicCall::VariableDispatch);
702    }
703    for caps in fun_ref_re().captures_iter(line) {
704        let variable_slot = |s: &str| s.starts_with(|c: char| c.is_ascii_uppercase() || c == '_');
705        let arity_raw = &caps[3];
706        let variable_arity = !arity_raw.bytes().all(|b| b.is_ascii_digit());
707        if variable_slot(&caps[1]) || variable_slot(&caps[2]) || variable_arity {
708            out.push(DynamicCall::VariableDispatch);
709        }
710    }
711}
712
713pub fn extract_definitions_into(text: &str, out: &mut Vec<SymbolRef>) {
714    for caps in fun_def_re().captures_iter(text) {
715        let head_end = caps.get(0).expect("capture").end();
716        let after = &text[head_end..];
717        let Ok(f) = FunctionName::from_str(&caps[1]) else {
718            continue;
719        };
720        let module = local_placeholder_module().clone();
721        match scan_arity(after) {
722            ScanArity::Exact(arity) => {
723                out.push(SymbolRef::function(Mfa::new(module, f, Arity::new(arity))));
724            }
725            ScanArity::Unterminated => {
726                out.push(SymbolRef::function_any_arity(module, f));
727            }
728        }
729    }
730}
731
732fn local_placeholder_module() -> &'static ModuleName {
733    static M: OnceLock<ModuleName> = OnceLock::new();
734    M.get_or_init(|| ModuleName::from_str("_local").expect("valid"))
735}
736
737/// Origin-tagged references, definitions, dynamic calls, and `Added`
738/// call-arg shapes pulled from one Erlang hunk.
739#[derive(Debug, Default)]
740pub struct HunkScan {
741    pub referenced: Vec<SymbolRef>,
742    pub defined: Vec<SymbolRef>,
743    pub dynamic_calls: Vec<DynamicCall>,
744    pub call_args: Vec<(Mfa, Vec<ArgShape>)>,
745}
746
747/// Scan one Erlang hunk's lines as logical units, not one diff line at a
748/// time. `lines` is the hunk's `Added` and `Context` lines in order,
749/// `Removed` already dropped. Consecutive lines of the same context
750/// (`Body` or `TypeAttribute`) are joined and scanned together, so a
751/// call or clause head whose argument list wraps across lines resolves
752/// at exact arity. Each reference takes the origin of the physical line
753/// its match started on.
754pub fn scan_hunk(lines: &[(RefOrigin, &str)], macros: &MacroTable) -> HunkScan {
755    let mut out = HunkScan::default();
756    let mut scanner = AttrCtxScanner::new();
757    let mut text = String::new();
758    let mut line_origins: Vec<(usize, RefOrigin)> = Vec::new();
759    let mut run_ctx: Option<RefContext> = None;
760    for &(origin, raw) in lines {
761        let stripped = strip_line_comment(raw);
762        let ctx = scanner.classify(stripped);
763        if run_ctx != Some(ctx) {
764            if let Some(prev) = run_ctx {
765                flush_run(prev, &text, &line_origins, macros, &mut out);
766            }
767            text.clear();
768            line_origins.clear();
769            run_ctx = Some(ctx);
770        }
771        line_origins.push((text.len(), origin));
772        text.push_str(stripped);
773        text.push('\n');
774    }
775    if let Some(prev) = run_ctx {
776        flush_run(prev, &text, &line_origins, macros, &mut out);
777    }
778    out
779}
780
781fn flush_run(
782    ctx: RefContext,
783    text: &str,
784    line_origins: &[(usize, RefOrigin)],
785    macros: &MacroTable,
786    out: &mut HunkScan,
787) {
788    match ctx {
789        RefContext::Body => {
790            let mut refs = Vec::new();
791            push_refs_with_offsets(text, macros, &mut refs);
792            tag_by_origin(refs, line_origins, &mut out.referenced);
793            extract_definitions_into(text, &mut out.defined);
794            extract_dynamic_into(text, &mut out.dynamic_calls);
795            let mut args = Vec::new();
796            push_call_args_with_offsets(text, &mut args);
797            // Only an added-head call feeds the clause-mismatch comparison:
798            // a context-line call is a pre-existing target fact.
799            for (call, offset) in args {
800                if origin_at(line_origins, offset) == RefOrigin::Added {
801                    out.call_args.push(call);
802                }
803            }
804        }
805        RefContext::TypeAttribute => {
806            let mut refs = Vec::new();
807            push_type_refs_with_offsets(text, &mut refs);
808            tag_by_origin(refs, line_origins, &mut out.referenced);
809        }
810    }
811}
812
813fn tag_by_origin(
814    refs: Vec<(SymbolRef, usize)>,
815    line_origins: &[(usize, RefOrigin)],
816    out: &mut Vec<SymbolRef>,
817) {
818    for (r, offset) in refs {
819        out.push(r.with_origin(origin_at(line_origins, offset)));
820    }
821}
822
823/// The origin of the physical line a match offset lands on: the last
824/// line whose start is at or before the offset, else `Context`.
825fn origin_at(line_origins: &[(usize, RefOrigin)], offset: usize) -> RefOrigin {
826    value_at_or_before(line_origins, offset, RefOrigin::Context)
827}