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