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::ops::Range;
23use std::str::FromStr;
24use std::sync::OnceLock;
25
26use regex::Regex;
27
28use crate::compat::arg_shape::ArgShape;
29use crate::erlang_macros::{MacroTable, expand_value_macro_to_atom, expand_value_macro_to_mf};
30use crate::model::names::{Arity, FunctionName, Mfa, ModuleName, RecordName, TypeName};
31use crate::model::symbol::{LineClass, RefContext, RefOrigin, SymbolRef};
32
33// re-exported from the scan crate so the import path stays stable
34pub use backhopper_erlang_scan::{
35    ScanArity, ScannedArgs, scan_top_level_args, split_top_level_args,
36};
37use backhopper_erlang_scan::{
38    count_top_level_items, hash_inside_number, is_bare_atom, literal_span, matching_bracket,
39    opens_type_ref_context, quoted_atom_span, scan_arity, skip_char_literal_span,
40    split_top_level_commas, string_span,
41};
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum DynamicCall {
45    /// One of the apply-family BIFs: `apply/2`, `apply/3`, `spawn/3`,
46    /// `spawn_link/3`, `spawn_monitor/3`, `spawn_opt/4`, `hibernate/3`.
47    Apply,
48    /// A call whose module or function name is a variable: `Mod:foo(...)`,
49    /// `mod:F(...)`, or `Mod:F(...)`.
50    VariableDispatch,
51}
52
53pub(crate) fn call_re() -> &'static Regex {
54    static R: OnceLock<Regex> = OnceLock::new();
55    R.get_or_init(|| {
56        Regex::new(r"\b([a-z][a-zA-Z0-9_@]*)\s*:\s*([a-z][a-zA-Z0-9_@]*)\s*\(").expect("regex")
57    })
58}
59
60fn record_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
65fn macro_re() -> &'static Regex {
66    static R: OnceLock<Regex> = OnceLock::new();
67    R.get_or_init(|| Regex::new(r"\?([A-Z_][A-Za-z0-9_]*)\b").expect("regex"))
68}
69
70// multi-line mode: a joined run holds several clause heads, each at line start
71fn fun_def_re() -> &'static Regex {
72    static R: OnceLock<Regex> = OnceLock::new();
73    R.get_or_init(|| Regex::new(r"(?m)^([a-z][a-zA-Z0-9_@]*)\s*\(").expect("regex"))
74}
75
76fn apply_bif_re() -> &'static Regex {
77    static R: OnceLock<Regex> = OnceLock::new();
78    R.get_or_init(|| {
79        Regex::new(r"\b(apply|spawn|spawn_link|spawn_monitor|spawn_opt|hibernate)\s*\(")
80            .expect("regex")
81    })
82}
83
84fn var_module_call_re() -> &'static Regex {
85    static R: OnceLock<Regex> = OnceLock::new();
86    R.get_or_init(|| {
87        Regex::new(r"\b[A-Z][a-zA-Z0-9_@]*\s*:\s*[a-zA-Z_][a-zA-Z0-9_@]*\s*\(").expect("regex")
88    })
89}
90
91fn var_function_call_re() -> &'static Regex {
92    static R: OnceLock<Regex> = OnceLock::new();
93    R.get_or_init(|| {
94        Regex::new(r"\b[a-z][a-zA-Z0-9_@]*\s*:\s*[A-Z][a-zA-Z0-9_@]*\s*\(").expect("regex")
95    })
96}
97
98pub fn extract_into(line: &str, out: &mut Vec<SymbolRef>) {
99    extract_into_with_macros(line, empty_macros(), out);
100}
101
102/// One statically-named `mod:fun(Args)` call with its source line.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct QualifiedCallSite {
105    pub mfa: Mfa,
106    pub line: u32,
107}
108
109/// Every statically-named `mod:fun(Args)` call in `src`, with line and
110/// arity. Type-attribute lines (`-spec`, `-callback`, `-type`,
111/// `-opaque`) are skipped: a qualified `mod:t()` there is a type
112/// reference, not a call. `line_map[i]` is text line `i`'s file line:
113/// consecutive `Body` lines join into one scanned run only when
114/// file-adjacent, so a call whose argument list wraps yields its exact
115/// arity while fragments from different hunks never concatenate into a
116/// false construct. The scanner's classification state persists across
117/// gaps on purpose: resetting would misread a still-open `-spec` as
118/// body text.
119pub fn extract_qualified_calls(src: &str, line_map: &[u32]) -> Vec<QualifiedCallSite> {
120    extract_qualified_calls_with_context(src, line_map, &line_context(src))
121}
122
123/// `extract_qualified_calls` over a precomputed per-line classification
124/// rather than one derived from `src` alone: a caller that classified
125/// against fuller context (a hunk's `Context` lines, which never enter
126/// `src`) passes it here so an orphaned continuation line still
127/// resolves against its real attribute region.
128pub fn extract_qualified_calls_with_context(
129    src: &str,
130    line_map: &[u32],
131    ctx: &[LineClass],
132) -> Vec<QualifiedCallSite> {
133    let mut out = Vec::new();
134    for run in body_runs_from_context(src, line_map, ctx) {
135        let mut calls = Vec::new();
136        push_call_args_with_offsets(&run.text, &mut calls);
137        for ((mfa, _shapes), offset) in calls {
138            out.push(QualifiedCallSite {
139                mfa,
140                line: run_line_at(&run.line_starts, offset),
141            });
142        }
143    }
144    for run in attribute_runs_from_context(src, line_map, ctx) {
145        let mut tuples = Vec::new();
146        push_mfa_tuples_with_offsets(&run.text, empty_macros(), &mut tuples);
147        for (mfa, offset) in tuples {
148            out.push(QualifiedCallSite {
149                mfa,
150                line: run_line_at(&run.line_starts, offset),
151            });
152        }
153    }
154    out
155}
156
157/// One joined run of file-adjacent body lines, comment-stripped, with
158/// each line's byte offset into `text` paired with its 1-based text
159/// line. Shared by the qualified-call and indirect-reference
160/// extractors so both scan wrapped constructs whole.
161#[derive(Debug)]
162pub(crate) struct BodyRun {
163    pub(crate) text: String,
164    pub(crate) line_starts: Vec<(usize, u32)>,
165}
166
167/// The accumulate-and-flush core for body runs: `strip` removes each
168/// line's comment and `is_body` classifies the stripped line;
169/// consecutive body lines with adjacent file lines join into one run,
170/// so fragments from different hunks never concatenate into a false
171/// construct.
172pub(crate) fn body_runs_with<'a>(
173    src: &'a str,
174    line_map: &[u32],
175    strip: impl Fn(&'a str) -> &'a str,
176    mut is_body: impl FnMut(&str) -> bool,
177) -> Vec<BodyRun> {
178    debug_assert_eq!(
179        line_map.len(),
180        src.lines().count(),
181        "line map length must equal the line count"
182    );
183    let mut out = Vec::new();
184    let mut run = String::new();
185    let mut run_lines: Vec<(usize, u32)> = Vec::new();
186    let mut prev_body_file_line: Option<u32> = None;
187    for (idx, line) in src.lines().enumerate() {
188        let stripped = strip(line);
189        let body = is_body(stripped);
190        let file_line = line_map.get(idx).copied();
191        let adjacent =
192            matches!((prev_body_file_line, file_line), (Some(prev), Some(cur)) if cur == prev + 1);
193        if (!body || !adjacent) && !run.is_empty() {
194            out.push(BodyRun {
195                text: mem::take(&mut run),
196                line_starts: mem::take(&mut run_lines),
197            });
198        }
199        prev_body_file_line = body.then_some(file_line).flatten();
200        if !body {
201            continue;
202        }
203        run_lines.push((run.len(), (idx + 1) as u32));
204        run.push_str(stripped);
205        run.push('\n');
206    }
207    if !run.is_empty() {
208        out.push(BodyRun {
209            text: run,
210            line_starts: run_lines,
211        });
212    }
213    out
214}
215
216/// Per-line attribute-region classification of `src`'s own lines, with
217/// no outside context: the classifier starts fresh and scans `src` top
218/// to bottom. Correct when `src` already holds every line whose
219/// classification matters; a caller whose relevant lines extend beyond
220/// `src` (a hunk's `Context` lines, dropped from the added-only blob)
221/// should classify those too and feed the result to
222/// `body_runs_from_context` instead.
223pub fn line_context(src: &str) -> Vec<LineClass> {
224    let mut scanner = AttrCtxScanner::new();
225    src.lines()
226        .map(|line| scanner.classify(strip_line_comment(line)))
227        .collect()
228}
229
230/// Joins consecutive body-holding lines into runs, breaking on
231/// attribute context and on file-line gaps, from a precomputed
232/// classification rather than one derived from `src` alone: `ctx[i]` is
233/// text line `i`'s classification. A record field's `::` annotation is
234/// masked out with spaces before the run is scanned, so the call view
235/// never sees the type half of the line: masking preserves every
236/// offset the run's line map and origin lookups depend on.
237pub(crate) fn body_runs_from_context(
238    src: &str,
239    line_map: &[u32],
240    ctx: &[LineClass],
241) -> Vec<BodyRun> {
242    let has_spans = ctx.iter().any(|c| !c.type_spans.is_empty());
243    let masked;
244    let text: &str = if has_spans {
245        masked = mask_call_view(src, ctx);
246        &masked
247    } else {
248        src
249    };
250    // other-attribute and record lines scan like body: a `-define`
251    // body holds real calls, and a record field's default does too
252    runs_of_lines(text, line_map, ctx, |c| c.context.holds_calls())
253}
254
255/// Runs built from `OtherAttribute` lines alone. `holds_calls` admits
256/// `Body` too, so sharing `body_runs_from_context`'s runs would let a
257/// body tuple read as an attribute reference.
258pub(crate) fn attribute_runs_from_context(
259    src: &str,
260    line_map: &[u32],
261    ctx: &[LineClass],
262) -> Vec<BodyRun> {
263    runs_of_lines(src, line_map, ctx, |c| {
264        c.context == RefContext::OtherAttribute
265    })
266}
267
268/// `body_runs_with` driven by the per-line classification rather than
269/// the line text. The predicate runs once per line in order, so a
270/// positional counter stays in step with `ctx`.
271fn runs_of_lines(
272    text: &str,
273    line_map: &[u32],
274    ctx: &[LineClass],
275    keep: impl Fn(&LineClass) -> bool,
276) -> Vec<BodyRun> {
277    let mut idx = 0usize;
278    body_runs_with(text, line_map, strip_line_comment, |_line| {
279        let keep = ctx.get(idx).is_some_and(&keep);
280        idx += 1;
281        keep
282    })
283}
284
285/// `src` with each line's `type_spans` overwritten by ASCII spaces, so
286/// a call scan over the result never matches inside a `::` annotation.
287/// Length is preserved line for line, so every downstream byte offset
288/// stays valid.
289fn mask_call_view(src: &str, ctx: &[LineClass]) -> String {
290    let mut out = String::with_capacity(src.len());
291    for (idx, line) in src.lines().enumerate() {
292        let spans: &[Range<usize>] = ctx.get(idx).map_or(&[], |c| &c.type_spans);
293        out.push_str(&mask_ranges(line, spans));
294        out.push('\n');
295    }
296    out
297}
298
299/// `line` with each of `spans` overwritten by ASCII spaces. A span
300/// boundary always sits next to an ASCII delimiter byte (`::`, `,`,
301/// `{`, `}`), which is always a valid UTF-8 boundary, so the rebuilt
302/// line is guaranteed well-formed.
303fn mask_ranges(line: &str, spans: &[Range<usize>]) -> String {
304    if spans.is_empty() {
305        return line.to_owned();
306    }
307    let mut bytes = line.as_bytes().to_vec();
308    for span in spans {
309        let end = span.end.min(bytes.len());
310        let start = span.start.min(end);
311        bytes[start..end].fill(b' ');
312    }
313    String::from_utf8(bytes).expect("masking only touches ascii delimiter boundaries")
314}
315
316/// The complement of `mask_ranges`: only `spans` keep their text,
317/// everything else becomes an ASCII space.
318fn mask_outside_ranges(line: &str, spans: &[Range<usize>]) -> String {
319    let mut bytes = vec![b' '; line.len()];
320    for span in spans {
321        let end = span.end.min(line.len());
322        let start = span.start.min(end);
323        bytes[start..end].copy_from_slice(&line.as_bytes()[start..end]);
324    }
325    String::from_utf8(bytes).expect("masking only touches ascii delimiter boundaries")
326}
327
328/// The value of the last `(start, value)` entry whose start is at or
329/// before `offset`, or `default` when `offset` precedes every entry.
330/// Entries are sorted by start.
331fn value_at_or_before<T: Copy>(entries: &[(usize, T)], offset: usize, default: T) -> T {
332    match entries.partition_point(|(start, _)| *start <= offset) {
333        0 => default,
334        count => entries[count - 1].1,
335    }
336}
337
338/// The `SymbolRef` for a scanned `m:f` call: exact arity when the
339/// argument list terminated on the line, else any-arity.
340fn scanned_call(scan: ScanArity, module: ModuleName, function: FunctionName) -> SymbolRef {
341    match scan {
342        ScanArity::Exact(arity) => {
343            SymbolRef::function(Mfa::new(module, function, Arity::new(arity)))
344        }
345        ScanArity::Unterminated => SymbolRef::function_any_arity(module, function),
346    }
347}
348
349// before the first entry, fall back to the first run's line, or 1 with no runs
350pub(crate) fn run_line_at(run_lines: &[(usize, u32)], offset: usize) -> u32 {
351    value_at_or_before(run_lines, offset, run_lines.first().map_or(1, |(_, l)| *l))
352}
353
354/// Cross-line classifier that decides which `RefContext` a hunk line
355/// sits in, and, inside a `-record(...)` attribute, the byte ranges of
356/// its fields' `::` type annotations. `-spec`, `-callback`, `-type`,
357/// and `-opaque` open a type-attribute region; `-record` opens its own
358/// region and is split field by field; any other `-` attribute form
359/// opens an other-attribute region. All three run until the
360/// terminating `.` at the top level, and a single line may both open
361/// and close one. Multi-line attributes flow through the stored region
362/// so their later lines classify correctly.
363///
364/// A triple-quoted string suspends the close rule while it is open,
365/// because documentation prose ends lines with full stops constantly
366/// and would otherwise close the region at its first sentence.
367#[derive(Debug, Default, Clone, Copy)]
368pub struct AttrCtxScanner {
369    region: Option<AttrRegion>,
370}
371
372/// The region to resume once a suspended multi-line string closes.
373#[derive(Debug, Clone, Copy)]
374enum Resume {
375    Open(RefContext),
376    Record(FieldSplit),
377}
378
379/// The open region, if any. A multi-line string only exists inside an
380/// attribute form, so it carries the form to resume rather than
381/// sitting in a second field that could contradict it.
382#[derive(Debug, Clone, Copy)]
383enum AttrRegion {
384    /// An attribute form, closed by a line ending in `.`.
385    Open(RefContext),
386    /// A `-record(...)` attribute, tracking the field splitter's state.
387    Record(FieldSplit),
388    /// A multi-line string in one, closed by a line whose first content
389    /// is a run of at least `quotes` quotes.
390    String { resume: Resume, quotes: usize },
391}
392
393/// Cross-line state of the `::` splitter inside a `-record` region.
394/// `depth` is `-1` before the field list's own `{` is seen, and from
395/// then on the bracket depth relative to it: `0` is the top level
396/// between fields, deeper is a nested tuple, list, map, or call. A
397/// closer with no matching opener on the visible lines cannot push
398/// `depth` below `-1`, so malformed or partially visible input
399/// degrades to over-wide call context rather than a misread field
400/// separator. `angle` is the `<<`/`>>` depth, tracked apart from
401/// `depth` so a bit-size `:` inside a binary pattern is never mistaken
402/// for `::`. `in_type` is whether the walk is inside an annotation
403/// begun on this or an earlier line.
404#[derive(Debug, Clone, Copy)]
405struct FieldSplit {
406    depth: i32,
407    angle: i32,
408    in_type: bool,
409}
410
411impl FieldSplit {
412    fn new() -> Self {
413        Self {
414            depth: -1,
415            angle: 0,
416            in_type: false,
417        }
418    }
419}
420
421impl AttrCtxScanner {
422    pub fn new() -> Self {
423        Self { region: None }
424    }
425
426    pub fn classify(&mut self, line: &str) -> LineClass {
427        match self.region {
428            Some(AttrRegion::String { resume, quotes }) => {
429                if line_closes_quote_run(line, quotes) {
430                    self.region = (!line_closes_attribute(line)).then_some(match resume {
431                        Resume::Open(attr) => AttrRegion::Open(attr),
432                        Resume::Record(split) => AttrRegion::Record(split),
433                    });
434                }
435                LineClass::new(RefContext::AttributeString)
436            }
437            Some(AttrRegion::Open(attr)) => self.continue_open(attr, line),
438            Some(AttrRegion::Record(mut split)) => self.continue_record(&mut split, line),
439            None if line_opens_type_attr(line) => {
440                self.continue_open(RefContext::TypeAttribute, line)
441            }
442            None if line_opens_record_attr(line) => {
443                self.continue_record(&mut FieldSplit::new(), line)
444            }
445            None if line_opens_other_attr(line) => {
446                self.continue_open(RefContext::OtherAttribute, line)
447            }
448            None => LineClass::new(RefContext::Body),
449        }
450    }
451
452    /// Advance an attribute region across `line`: a trailing quote run
453    /// opens a string, a terminating `.` closes the region, and
454    /// anything else carries it to the next line.
455    fn continue_open(&mut self, attr: RefContext, line: &str) -> LineClass {
456        self.region = match trailing_quote_run(line) {
457            Some(quotes) => Some(AttrRegion::String {
458                resume: Resume::Open(attr),
459                quotes,
460            }),
461            None if line_closes_attribute(line) => None,
462            None => Some(AttrRegion::Open(attr)),
463        };
464        LineClass::new(attr)
465    }
466
467    /// Advance a record region across `line`: splits the field list's
468    /// `::` annotations from their default expressions, then applies
469    /// the same string-suspend and close rule `continue_open` does.
470    fn continue_record(&mut self, split: &mut FieldSplit, line: &str) -> LineClass {
471        let type_spans = record_field_spans(line, split);
472        self.region = match trailing_quote_run(line) {
473            Some(quotes) => Some(AttrRegion::String {
474                resume: Resume::Record(*split),
475                quotes,
476            }),
477            None if line_closes_attribute(line) => None,
478            None => Some(AttrRegion::Record(*split)),
479        };
480        LineClass {
481            context: RefContext::RecordAttribute,
482            type_spans,
483        }
484    }
485}
486
487/// The `::` type-annotation ranges on one physical, comment-stripped
488/// line of a record's field list, threading `split` across lines so a
489/// wrapped annotation, or a field list opener on an earlier line,
490/// still splits correctly. Literal strings, quoted atoms, and char
491/// literals are skipped whole so a `::` or `,` inside one is never
492/// mistaken for field punctuation.
493fn record_field_spans(line: &str, split: &mut FieldSplit) -> Vec<Range<usize>> {
494    let bytes = line.as_bytes();
495    let mut spans = Vec::new();
496    let mut span_start = split.in_type.then_some(0);
497    let mut i = 0usize;
498    while i < bytes.len() {
499        match bytes[i] {
500            b'"' | b'~' => {
501                if let Some(len) = string_span(bytes, i) {
502                    i += len;
503                    continue;
504                }
505            }
506            b'\'' => {
507                if let Some(len) = quoted_atom_span(bytes, i) {
508                    i += len;
509                    continue;
510                }
511            }
512            b'$' => {
513                i += skip_char_literal_span(bytes, i);
514                continue;
515            }
516            _ => {}
517        }
518        let at_field_level = split.depth == 0 && split.angle == 0;
519        match bytes[i] {
520            b'<' if bytes.get(i + 1) == Some(&b'<') => {
521                split.angle += 1;
522                i += 2;
523                continue;
524            }
525            b'>' if bytes.get(i + 1) == Some(&b'>') && split.angle > 0 => {
526                split.angle -= 1;
527                i += 2;
528                continue;
529            }
530            b'{' if split.depth < 0 => {
531                // the field list opens here: the record name and
532                // whatever precedes it are not field-relative structure
533                split.depth = 0;
534                i += 1;
535                continue;
536            }
537            b'(' | b'[' | b')' | b']' if split.depth < 0 => {
538                // still inside the `-record(Name` head, before the field list
539                i += 1;
540                continue;
541            }
542            b'(' | b'[' | b'{' => {
543                split.depth += 1;
544                i += 1;
545                continue;
546            }
547            b')' | b']' | b'}' => {
548                if at_field_level && split.in_type {
549                    spans.push(close_span(&mut span_start, &mut split.in_type, i));
550                }
551                split.depth = (split.depth - 1).max(-1);
552                i += 1;
553                continue;
554            }
555            b':' if at_field_level && !split.in_type && bytes.get(i + 1) == Some(&b':') => {
556                i += 2;
557                span_start = Some(i);
558                split.in_type = true;
559                continue;
560            }
561            b',' if at_field_level && split.in_type => {
562                spans.push(close_span(&mut span_start, &mut split.in_type, i));
563                i += 1;
564                continue;
565            }
566            _ => {}
567        }
568        i += 1;
569    }
570    if split.in_type {
571        let start = span_start.expect("in_type implies span_start is set");
572        spans.push(start..bytes.len());
573    }
574    spans.retain(|span| !span.is_empty());
575    spans
576}
577
578/// Closes the type span open since `span_start`, at `at`: `in_type`
579/// only ever holds with `span_start` set, so the two always clear
580/// together.
581fn close_span(span_start: &mut Option<usize>, in_type: &mut bool, at: usize) -> Range<usize> {
582    let start = span_start
583        .take()
584        .expect("in_type implies span_start is set");
585    *in_type = false;
586    start..at
587}
588
589/// The length of a trailing run of three or more `"`, which opens a
590/// triple-quoted string that the rest of the line does not close. The
591/// rule is name-independent: such a string can open inside any
592/// attribute, not only the documentation forms.
593fn trailing_quote_run(line: &str) -> Option<usize> {
594    let run = strip_line_comment(line)
595        .trim_end()
596        .bytes()
597        .rev()
598        .take_while(|b| *b == b'"')
599        .count();
600    (run >= 3).then_some(run)
601}
602
603/// Whether `line` starts, after leading whitespace, with a run of at
604/// least `open` quotes: the EEP-64 closing form.
605fn line_closes_quote_run(line: &str, open: usize) -> bool {
606    line.trim_start().bytes().take_while(|b| *b == b'"').count() >= open
607}
608
609fn line_opens_type_attr(line: &str) -> bool {
610    let trimmed = line.trim_start();
611    let Some(rest) = trimmed.strip_prefix('-') else {
612        return false;
613    };
614    let name_end = rest
615        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '@'))
616        .unwrap_or(rest.len());
617    if !opens_type_ref_context(&rest[..name_end]) {
618        return false;
619    }
620    matches!(rest[name_end..].chars().next(), Some(c) if c == '(' || c.is_whitespace())
621}
622
623/// A line-leading `-record` followed by `(` or whitespace opens the
624/// record region; checked before `line_opens_other_attr` so `-record`
625/// does not fall through to the generic attribute case.
626fn line_opens_record_attr(line: &str) -> bool {
627    let trimmed = line.trim_start();
628    let Some(rest) = trimmed.strip_prefix("-record") else {
629        return false;
630    };
631    matches!(rest.chars().next(), Some(c) if c == '(' || c.is_whitespace())
632}
633
634/// A line-leading `-` followed by a lowercase letter opens an
635/// attribute form; `-1` or `- X` on a wrapped expression line does not.
636fn line_opens_other_attr(line: &str) -> bool {
637    let trimmed = line.trim_start();
638    let mut chars = trimmed.chars();
639    chars.next() == Some('-') && chars.next().is_some_and(|c| c.is_ascii_lowercase())
640}
641
642fn line_closes_attribute(line: &str) -> bool {
643    let stripped = strip_line_comment(line);
644    stripped.trim_end().ends_with('.')
645}
646
647/// Cuts an unquoted `%` comment off a source line. Strings, quoted
648/// atoms, escapes, and `$c` char literals (`$%`, `$"`) stay intact.
649pub fn strip_line_comment(line: &str) -> &str {
650    let bytes = line.as_bytes();
651    let mut in_str = false;
652    let mut in_atom = false;
653    let mut i = 0usize;
654    while i < bytes.len() {
655        let b = bytes[i];
656        if in_str || in_atom {
657            match b {
658                b'\\' => i += 1,
659                b'"' if in_str => in_str = false,
660                b'\'' if in_atom => in_atom = false,
661                _ => {}
662            }
663            i += 1;
664            continue;
665        }
666        match b {
667            b'"' => in_str = true,
668            b'\'' => in_atom = true,
669            b'$' => {
670                i += skip_char_literal_span(bytes, i);
671                continue;
672            }
673            b'%' => return &line[..i],
674            _ => {}
675        }
676        i += 1;
677    }
678    line
679}
680
681/// Type-attribute variant of `extract_into_with_macros`.
682///
683/// Inside `-spec`, `-callback`, `-type`, and `-opaque` declarations the
684/// `mod:ident(...)` shape is a type reference (`ra:index()` is the
685/// type `index/0`, not a call). Records and apply-family BIFs are
686/// syntactically impossible there, so this extractor only emits type
687/// refs and macro uses. Macro values are not resolved into MFAs in
688/// this context: a `?MOD:fn` expansion in a spec position would be a
689/// type, but the macro tables today carry value-to-atom bindings
690/// only, with no type-side equivalent.
691pub fn extract_type_refs_into(line: &str, out: &mut Vec<SymbolRef>) {
692    let line = strip_line_comment(line);
693    let mut with_offsets = Vec::new();
694    push_type_refs_with_offsets(line, &mut with_offsets);
695    out.extend(with_offsets.into_iter().map(|(r, _)| r));
696}
697
698/// `extract_type_refs_into` over already-stripped text, with each
699/// reference's match offset for origin mapping.
700fn push_type_refs_with_offsets(text: &str, out: &mut Vec<(SymbolRef, usize)>) {
701    for caps in call_re().captures_iter(text) {
702        let group = caps.get(0).expect("capture");
703        let module = &caps[1];
704        let ident = &caps[2];
705        // There is no any-arity type kind, so a type ref that does not
706        // close even across the run is skipped, not guessed.
707        let ScanArity::Exact(arity) = scan_arity(&text[group.end()..]) else {
708            continue;
709        };
710        if let (Ok(m), Ok(t)) = (ModuleName::from_str(module), TypeName::from_str(ident)) {
711            out.push((SymbolRef::type_ref(m, t, Arity::new(arity)), group.start()));
712        }
713    }
714    for caps in macro_re().captures_iter(text) {
715        let group = caps.get(0).expect("capture");
716        out.push((SymbolRef::macro_use(caps[1].to_owned()), group.start()));
717    }
718}
719
720fn empty_macros() -> &'static MacroTable {
721    static EMPTY: OnceLock<MacroTable> = OnceLock::new();
722    EMPTY.get_or_init(MacroTable::new)
723}
724
725/// Same as `extract_into` but consults `macros` so `?Name`-style
726/// references resolve into concrete `mod:fn(...)` symbols when the
727/// macro's body is a bare atom (module) or `mod:fn` pair. Apply-family
728/// BIFs whose `M`, `F`, and arg-list slots are literals also resolve
729/// into the matching `Mfa` and are added to `out` as normal function refs.
730pub fn extract_into_with_macros(line: &str, macros: &MacroTable, out: &mut Vec<SymbolRef>) {
731    let line = strip_line_comment(line);
732    let mut with_offsets = Vec::new();
733    push_refs_with_offsets(line, macros, &mut with_offsets);
734    out.extend(with_offsets.into_iter().map(|(r, _)| r));
735}
736
737/// `extract_into_with_macros` over already-stripped text, pairing each
738/// reference with the byte offset its match started at, so the caller
739/// can map it to the origin of the physical line that offset lands on.
740fn push_refs_with_offsets(text: &str, macros: &MacroTable, out: &mut Vec<(SymbolRef, usize)>) {
741    for caps in call_re().captures_iter(text) {
742        let group = caps.get(0).expect("capture");
743        let module = &caps[1];
744        let function = &caps[2];
745        let after = &text[group.end()..];
746        let (Ok(m), Ok(f)) = (
747            ModuleName::from_str(module),
748            FunctionName::from_str(function),
749        ) else {
750            continue;
751        };
752        out.push((scanned_call(scan_arity(after), m, f), group.start()));
753    }
754    push_fun_refs_with_offsets(text, macros, out);
755    for caps in record_re().captures_iter(text) {
756        let start = caps.get(0).expect("capture").start();
757        // a based literal's `#` is not a record reference
758        if hash_inside_number(text.as_bytes(), start) {
759            continue;
760        }
761        if let Ok(name) = RecordName::from_str(&caps[1]) {
762            out.push((SymbolRef::record(name), start));
763        }
764    }
765    for caps in macro_re().captures_iter(text) {
766        let group = caps.get(0).expect("capture");
767        let name = &caps[1];
768        out.push((SymbolRef::macro_use(name.to_owned()), group.start()));
769        let mut resolved = Vec::new();
770        try_resolve_macro_call(name, &text[group.end()..], macros, &mut resolved);
771        out.extend(resolved.into_iter().map(|r| (r, group.start())));
772    }
773    push_apply_family_with_offsets(text, macros, out);
774}
775
776fn fun_ref_re() -> &'static Regex {
777    static R: OnceLock<Regex> = OnceLock::new();
778    R.get_or_init(|| {
779        Regex::new(
780            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_@]*)",
781        )
782        .expect("regex")
783    })
784}
785
786/// `fun M:F/A` remote fun references. The arity is written in the
787/// source, so it is exact. Variable-shaped slots are classified as
788/// dynamic calls instead.
789fn push_fun_refs_with_offsets(text: &str, macros: &MacroTable, out: &mut Vec<(SymbolRef, usize)>) {
790    for caps in fun_ref_re().captures_iter(text) {
791        let module_raw = &caps[1];
792        let function_raw = &caps[2];
793        let arity_raw = &caps[3];
794        let Ok(arity) = arity_raw.parse::<u8>() else {
795            continue;
796        };
797        if !function_raw.starts_with(|c: char| c.is_ascii_lowercase()) {
798            continue;
799        }
800        let module = if let Some(macro_name) = module_raw.strip_prefix('?') {
801            let Some(expanded) = expand_value_macro_to_atom(macros, macro_name) else {
802                continue;
803            };
804            let Ok(m) = ModuleName::from_str(&expanded) else {
805                continue;
806            };
807            m
808        } else if module_raw.starts_with(|c: char| c.is_ascii_lowercase()) {
809            let Ok(m) = ModuleName::from_str(module_raw) else {
810                continue;
811            };
812            m
813        } else {
814            continue;
815        };
816        let Ok(f) = FunctionName::from_str(function_raw) else {
817            continue;
818        };
819        let start = caps.get(0).expect("capture").start();
820        out.push((
821            SymbolRef::function(Mfa::new(module, f, Arity::new(arity))),
822            start,
823        ));
824    }
825}
826
827fn try_resolve_macro_call(name: &str, tail: &str, macros: &MacroTable, out: &mut Vec<SymbolRef>) {
828    let trimmed = tail.trim_start();
829    if let Some(rest) = trimmed.strip_prefix('(') {
830        let Some((m, f)) = expand_value_macro_to_mf(macros, name) else {
831            return;
832        };
833        let (Ok(mn), Ok(fn_)) = (ModuleName::from_str(&m), FunctionName::from_str(&f)) else {
834            return;
835        };
836        out.push(scanned_call(scan_arity(rest), mn, fn_));
837        return;
838    }
839    if let Some(rest) = trimmed.strip_prefix(':') {
840        let rest = rest.trim_start();
841        let (function_str, after_fn) = split_lowercase_ident(rest);
842        if function_str.is_empty() {
843            return;
844        }
845        let after = after_fn.trim_start();
846        if let Some(after_open) = after.strip_prefix('(') {
847            if let Some(m) = expand_value_macro_to_atom(macros, name) {
848                if let (Ok(mn), Ok(fn_)) = (
849                    ModuleName::from_str(&m),
850                    FunctionName::from_str(function_str),
851                ) {
852                    out.push(scanned_call(scan_arity(after_open), mn, fn_));
853                }
854            }
855        }
856    }
857}
858
859fn split_lowercase_ident(s: &str) -> (&str, &str) {
860    let end = s
861        .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '@'))
862        .unwrap_or(s.len());
863    if end == 0 || !s.starts_with(|c: char| c.is_ascii_lowercase()) {
864        return ("", s);
865    }
866    s.split_at(end)
867}
868
869fn push_apply_family_with_offsets(
870    text: &str,
871    macros: &MacroTable,
872    out: &mut Vec<(SymbolRef, usize)>,
873) {
874    for caps in apply_family_re().captures_iter(text) {
875        let group = caps.get(0).expect("capture");
876        let name = &caps[1];
877        let after_open = &text[group.end()..];
878        // resolving from a wrapped call's partial slot set would fabricate an Mfa
879        let ScannedArgs::Terminated { args, .. } = scan_top_level_args(after_open) else {
880            continue;
881        };
882        if let Some(mfa) = resolve_apply_family(name, &args, macros) {
883            out.push((SymbolRef::function(mfa), group.start()));
884        }
885    }
886}
887
888fn apply_family_re() -> &'static Regex {
889    static R: OnceLock<Regex> = OnceLock::new();
890    R.get_or_init(|| {
891        Regex::new(
892            r"\b(?:erlang\s*:\s*)?(apply|spawn|spawn_link|spawn_monitor|spawn_opt|hibernate)\s*\(",
893        )
894        .expect("regex")
895    })
896}
897
898fn resolve_apply_family(name: &str, args: &[&str], macros: &MacroTable) -> Option<Mfa> {
899    let (m_raw, f_raw, a_raw) = match (name, args.len()) {
900        ("apply", 3)
901        | ("spawn" | "spawn_link" | "spawn_monitor" | "hibernate", 3)
902        | ("spawn_opt", 4) => (args[0], args[1], args[2]),
903        ("spawn" | "spawn_link" | "spawn_monitor", 4) | ("spawn_opt", 5) => {
904            (args[1], args[2], args[3])
905        }
906        _ => return None,
907    };
908    let module = atom_or_macro_to::<ModuleName>(m_raw, macros)?;
909    let function = atom_or_macro_to::<FunctionName>(f_raw, macros)?;
910    let arity = literal_list_length(a_raw)?;
911    Some(Mfa::new(module, function, Arity::new(arity)))
912}
913
914// ModuleName and FunctionName are both atoms: one resolver covers both
915fn atom_or_macro_to<T: FromStr>(raw: &str, macros: &MacroTable) -> Option<T> {
916    let s = raw.trim();
917    if let Some(name) = s.strip_prefix('?') {
918        return expand_value_macro_to_atom(macros, name).and_then(|a| T::from_str(&a).ok());
919    }
920    if is_bare_atom(s) {
921        return T::from_str(s).ok();
922    }
923    if s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2 {
924        return T::from_str(&s[1..s.len() - 1]).ok();
925    }
926    None
927}
928
929fn literal_list_length(raw: &str) -> Option<u8> {
930    let s = raw.trim();
931    let inner = s.strip_prefix('[')?.strip_suffix(']')?;
932    let trimmed = inner.trim();
933    if trimmed.is_empty() {
934        return Some(0);
935    }
936    u8::try_from(count_top_level_items(s, '[', ']')).ok()
937}
938
939/// Every `{M, F, Args}` tuple in `text`, at any nesting depth: the walk
940/// advances one byte past each `{` rather than over the whole group, so
941/// a tuple inside a boot-step's option list is found along with a
942/// top-level one.
943fn push_mfa_tuples_with_offsets(text: &str, macros: &MacroTable, out: &mut Vec<(Mfa, usize)>) {
944    let bytes = text.as_bytes();
945    let mut i = 0usize;
946    while i < bytes.len() {
947        if let Some(span) = literal_span(bytes, i) {
948            i += span;
949            continue;
950        }
951        if bytes[i] == b'{'
952            && let Some(close) = matching_bracket(bytes, i)
953        {
954            try_push_mfa_tuple(&text[i + 1..close], i, macros, out);
955        }
956        i += 1;
957    }
958}
959
960/// The references every `{M, F, Args}` tuple in `text` names, with
961/// `?Name` module and function heads resolved through `macros`.
962pub fn extract_mfa_tuples_with_macros(text: &str, macros: &MacroTable) -> Vec<Mfa> {
963    let mut out = Vec::new();
964    push_mfa_tuples_with_offsets(text, macros, &mut out);
965    out.into_iter().map(|(mfa, _)| mfa).collect()
966}
967
968/// The reference `inner` names, when it has the `M, F, Args` shape. A
969/// bare integer in the third slot is not accepted: `{M, F, Arity}`
970/// overlaps too much with plain data to read as a reference.
971fn try_push_mfa_tuple(
972    inner: &str,
973    tuple_offset: usize,
974    macros: &MacroTable,
975    out: &mut Vec<(Mfa, usize)>,
976) {
977    let items = split_top_level_commas(inner);
978    let [m_raw, f_raw, args_raw] = items.as_slice() else {
979        return;
980    };
981    let Some(module) = atom_or_macro_to::<ModuleName>(m_raw, macros) else {
982        return;
983    };
984    let Some(function) = atom_or_macro_to::<FunctionName>(f_raw, macros) else {
985        return;
986    };
987    let Some(arity) = literal_list_length(args_raw) else {
988        return;
989    };
990    out.push((Mfa::new(module, function, Arity::new(arity)), tuple_offset));
991}
992
993/// Collects per-call argument shapes from one source line. Mirrors
994/// `extract_into` for the `mod:fun(args)` case; classifies each actual
995/// into an `ArgShape` so the analyzer can match against pin-side
996/// clause-head patterns.
997pub fn extract_call_args_into(line: &str, out: &mut Vec<(Mfa, Vec<ArgShape>)>) {
998    let line = strip_line_comment(line);
999    let mut with_offsets = Vec::new();
1000    push_call_args_with_offsets(line, &mut with_offsets);
1001    out.extend(with_offsets.into_iter().map(|(c, _)| c));
1002}
1003
1004/// `extract_call_args_into` over already-stripped text, with each call's
1005/// match offset so the caller can keep only `Added`-origin calls.
1006fn push_call_args_with_offsets(text: &str, out: &mut Vec<((Mfa, Vec<ArgShape>), usize)>) {
1007    for caps in call_re().captures_iter(text) {
1008        let group = caps.get(0).expect("capture");
1009        let module = &caps[1];
1010        let function = &caps[2];
1011        // an unterminated arg list has no complete shape list to compare
1012        let Some((args, _consumed)) = extract_arg_shapes(&text[group.end()..]) else {
1013            continue;
1014        };
1015        let arity = u8::try_from(args.len()).unwrap_or(u8::MAX);
1016        if let (Ok(m), Ok(f)) = (
1017            ModuleName::from_str(module),
1018            FunctionName::from_str(function),
1019        ) {
1020            out.push(((Mfa::new(m, f, Arity::new(arity)), args), group.start()));
1021        }
1022    }
1023}
1024
1025fn extract_arg_shapes(after_open_paren: &str) -> Option<(Vec<ArgShape>, usize)> {
1026    match scan_top_level_args(after_open_paren) {
1027        ScannedArgs::Terminated { args, consumed } => {
1028            Some((args.iter().map(|a| classify_arg(a)).collect(), consumed))
1029        }
1030        ScannedArgs::Unterminated { .. } => None,
1031    }
1032}
1033
1034pub fn classify_arg(raw: &str) -> ArgShape {
1035    let s = raw.trim();
1036    if s.is_empty() {
1037        return ArgShape::Unknown;
1038    }
1039    let first = s.chars().next().expect("non-empty");
1040    if first == '#' {
1041        let rest = &s[1..];
1042        let name_end = rest
1043            .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '@'))
1044            .unwrap_or(rest.len());
1045        let name = &rest[..name_end];
1046        if let Ok(record_name) = RecordName::from_str(name) {
1047            return ArgShape::Record { name: record_name };
1048        }
1049        return ArgShape::Unknown;
1050    }
1051    if first == '{' {
1052        let size = count_top_level_items(s, '{', '}');
1053        return ArgShape::Tuple { size };
1054    }
1055    if first == '[' {
1056        return ArgShape::List;
1057    }
1058    if first == '"' {
1059        return ArgShape::String;
1060    }
1061    if first == '<' && s.starts_with("<<") {
1062        return ArgShape::Binary;
1063    }
1064    if first == '\'' {
1065        let inner = s.trim_start_matches('\'').trim_end_matches('\'');
1066        return ArgShape::Atom {
1067            name: inner.to_string(),
1068        };
1069    }
1070    if first.is_ascii_digit()
1071        || (first == '-' && s.chars().nth(1).is_some_and(|c| c.is_ascii_digit()))
1072    {
1073        return if s.contains('.') {
1074            ArgShape::Float
1075        } else {
1076            ArgShape::Integer
1077        };
1078    }
1079    if first.is_ascii_uppercase() || first == '_' {
1080        return ArgShape::Variable;
1081    }
1082    if first.is_ascii_lowercase() {
1083        let bare = is_bare_identifier(s);
1084        if bare {
1085            return ArgShape::Atom {
1086                name: s.to_string(),
1087            };
1088        }
1089        if s.starts_with("fun ") || s == "fun" {
1090            return ArgShape::Fun;
1091        }
1092        return ArgShape::Unknown;
1093    }
1094    ArgShape::Unknown
1095}
1096
1097fn is_bare_identifier(s: &str) -> bool {
1098    s.chars()
1099        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '@')
1100}
1101
1102pub fn extract_dynamic_into(line: &str, out: &mut Vec<DynamicCall>) {
1103    let line = strip_line_comment(line);
1104    for _ in apply_bif_re().captures_iter(line) {
1105        out.push(DynamicCall::Apply);
1106    }
1107    for _ in var_module_call_re().find_iter(line) {
1108        out.push(DynamicCall::VariableDispatch);
1109    }
1110    for _ in var_function_call_re().find_iter(line) {
1111        out.push(DynamicCall::VariableDispatch);
1112    }
1113    for caps in fun_ref_re().captures_iter(line) {
1114        let variable_slot = |s: &str| s.starts_with(|c: char| c.is_ascii_uppercase() || c == '_');
1115        let arity_raw = &caps[3];
1116        let variable_arity = !arity_raw.bytes().all(|b| b.is_ascii_digit());
1117        if variable_slot(&caps[1]) || variable_slot(&caps[2]) || variable_arity {
1118            out.push(DynamicCall::VariableDispatch);
1119        }
1120    }
1121}
1122
1123pub fn extract_definitions_into(text: &str, out: &mut Vec<SymbolRef>) {
1124    for caps in fun_def_re().captures_iter(text) {
1125        let head_end = caps.get(0).expect("capture").end();
1126        let after = &text[head_end..];
1127        let Ok(f) = FunctionName::from_str(&caps[1]) else {
1128            continue;
1129        };
1130        let module = local_placeholder_module().clone();
1131        match scan_arity(after) {
1132            ScanArity::Exact(arity) => {
1133                out.push(SymbolRef::function(Mfa::new(module, f, Arity::new(arity))));
1134            }
1135            ScanArity::Unterminated => {
1136                out.push(SymbolRef::function_any_arity(module, f));
1137            }
1138        }
1139    }
1140}
1141
1142fn local_placeholder_module() -> &'static ModuleName {
1143    static M: OnceLock<ModuleName> = OnceLock::new();
1144    M.get_or_init(|| ModuleName::from_str("_local").expect("valid"))
1145}
1146
1147/// Origin-tagged references, definitions, dynamic calls, and `Added`
1148/// call-arg shapes pulled from one Erlang hunk.
1149#[derive(Debug, Default)]
1150pub struct HunkScan {
1151    pub referenced: Vec<SymbolRef>,
1152    pub defined: Vec<SymbolRef>,
1153    pub dynamic_calls: Vec<DynamicCall>,
1154    pub call_args: Vec<(Mfa, Vec<ArgShape>)>,
1155}
1156
1157/// Scan one Erlang hunk's lines as logical units, not one diff line at a
1158/// time. `lines` is the hunk's `Added` and `Context` lines in order,
1159/// `Removed` already dropped. Consecutive lines of the same context
1160/// (`Body`, `OtherAttribute`, or `RecordAttribute`) are joined and
1161/// scanned together, so a call or clause head whose argument list wraps
1162/// across lines resolves at exact arity. A `RecordAttribute` line
1163/// contributes its masked call view to that run and its masked `::`
1164/// annotation, if any, to a separate run flushed as type references
1165/// once the record region ends: no type reference can wrap from a
1166/// record into a different form. Each reference takes the origin of
1167/// the physical line its match started on.
1168pub fn scan_hunk(lines: &[(RefOrigin, &str)], macros: &MacroTable) -> HunkScan {
1169    let mut out = HunkScan::default();
1170    let mut scanner = AttrCtxScanner::new();
1171    let mut text = String::new();
1172    let mut line_origins: Vec<(usize, RefOrigin)> = Vec::new();
1173    // runs group on the type-attribute boundary alone, so other-attribute
1174    // and record-attribute lines join body runs exactly as they did
1175    // before the extra variants
1176    let mut run_ctx: Option<bool> = None;
1177    let mut record_text = String::new();
1178    let mut record_origins: Vec<(usize, RefOrigin)> = Vec::new();
1179    for &(origin, raw) in lines {
1180        let stripped = strip_line_comment(raw);
1181        let class = scanner.classify(stripped);
1182        if class.context != RefContext::RecordAttribute && !record_text.is_empty() {
1183            flush_run(true, &record_text, &record_origins, macros, &mut out);
1184            record_text.clear();
1185            record_origins.clear();
1186        }
1187        // a multi-line string in an attribute holds no references at all,
1188        // and joining its prose to the lines around it would build one
1189        // construct out of two unrelated halves
1190        if class.context == RefContext::AttributeString {
1191            if let Some(prev) = run_ctx.take() {
1192                flush_run(prev, &text, &line_origins, macros, &mut out);
1193                text.clear();
1194                line_origins.clear();
1195            }
1196            continue;
1197        }
1198        let type_attr = class.context == RefContext::TypeAttribute;
1199        if run_ctx != Some(type_attr) {
1200            if let Some(prev) = run_ctx {
1201                flush_run(prev, &text, &line_origins, macros, &mut out);
1202            }
1203            text.clear();
1204            line_origins.clear();
1205            run_ctx = Some(type_attr);
1206        }
1207        if class.context == RefContext::RecordAttribute {
1208            line_origins.push((text.len(), origin));
1209            text.push_str(&mask_ranges(stripped, &class.type_spans));
1210            text.push('\n');
1211            if !class.type_spans.is_empty() {
1212                record_origins.push((record_text.len(), origin));
1213                record_text.push_str(&mask_outside_ranges(stripped, &class.type_spans));
1214                record_text.push('\n');
1215            }
1216        } else {
1217            line_origins.push((text.len(), origin));
1218            text.push_str(stripped);
1219            text.push('\n');
1220        }
1221    }
1222    if let Some(prev) = run_ctx {
1223        flush_run(prev, &text, &line_origins, macros, &mut out);
1224    }
1225    if !record_text.is_empty() {
1226        flush_run(true, &record_text, &record_origins, macros, &mut out);
1227    }
1228    out
1229}
1230
1231fn flush_run(
1232    type_attr: bool,
1233    text: &str,
1234    line_origins: &[(usize, RefOrigin)],
1235    macros: &MacroTable,
1236    out: &mut HunkScan,
1237) {
1238    if type_attr {
1239        let mut refs = Vec::new();
1240        push_type_refs_with_offsets(text, &mut refs);
1241        tag_by_origin(refs, line_origins, &mut out.referenced);
1242        return;
1243    }
1244    let mut refs = Vec::new();
1245    push_refs_with_offsets(text, macros, &mut refs);
1246    tag_by_origin(refs, line_origins, &mut out.referenced);
1247    extract_definitions_into(text, &mut out.defined);
1248    extract_dynamic_into(text, &mut out.dynamic_calls);
1249    let mut args = Vec::new();
1250    push_call_args_with_offsets(text, &mut args);
1251    // context-line calls are pre-existing target facts, not clause-mismatch input
1252    for (call, offset) in args {
1253        if origin_at(line_origins, offset) == RefOrigin::Added {
1254            out.call_args.push(call);
1255        }
1256    }
1257}
1258
1259fn tag_by_origin(
1260    refs: Vec<(SymbolRef, usize)>,
1261    line_origins: &[(usize, RefOrigin)],
1262    out: &mut Vec<SymbolRef>,
1263) {
1264    for (r, offset) in refs {
1265        out.push(r.with_origin(origin_at(line_origins, offset)));
1266    }
1267}
1268
1269/// The origin of the physical line a match offset lands on: the last
1270/// line whose start is at or before the offset, else `Context`.
1271fn origin_at(line_origins: &[(usize, RefOrigin)], offset: usize) -> RefOrigin {
1272    value_at_or_before(line_origins, offset, RefOrigin::Context)
1273}