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