Skip to main content

backhopper_core/compat/
local_call_resolve.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//! Resolves unqualified `f(Args)` calls a patch adds against the target
6//! module's own function set: the case where a hunk calls a local
7//! function a sibling commit introduced that the target branch lacks.
8//! A call that does resolve against the module's own definitions gets
9//! its `-spec` return shape compared between the checkouts, with the
10//! same comparator and tally semantics as the qualified axis.
11//!
12//! Unlike macros and records, functions live in the `.erl` file (a
13//! header almost never defines one), so this reads only the target
14//! module, not its includes. To keep false positives near zero it is
15//! conservative: it suppresses when the target file is unreadable or
16//! declares a `parse_transform` (which injects unseeable functions),
17//! and never flags an auto-imported BIF or a call resolved through the
18//! target's own `-import`. A target-import resolution is withheld from
19//! the shape check too: the callee's `-spec` is in another module.
20//!
21//! An `-import` the patch adds beside its first use is a cross-module
22//! reference to `m:f/arity` once applied, so it is resolved against the
23//! imported module's exports on the target tree through the shared
24//! qualified-reference gate: a function the target's copy of the module
25//! still exports is clean, one it lacks is flagged.
26
27use std::collections::{BTreeMap, BTreeSet};
28use std::str::FromStr;
29
30use crate::compat::added_lines::{AddedLinesSubject, file_line};
31use crate::compat::qualified_call_resolve::{
32    ContextAwareSubject, ReferenceCaches, ReferenceContext, ShapeComparison, TreeReader,
33    compare_return_shapes, resolve_qualified_reference,
34};
35use crate::compat::source_attributes::{
36    FunctionSignature, ImportedFunction, SpecTable, declares_parse_transform,
37    extract_function_signatures, extract_function_signatures_with_context, extract_imports,
38    extract_specs,
39};
40use crate::model::names::{Arity, FunctionName};
41use crate::model::verdict::{Reason, ShapeCheckTally};
42
43/// What the local-call gate produced: the reasons plus the shape-check
44/// tally, mirroring `QualifiedCallAnalysis`.
45#[derive(Debug, Default)]
46pub struct LocalCallAnalysis {
47    pub reasons: Vec<Reason>,
48    pub shape_checks: ShapeCheckTally,
49}
50
51/// Flag each added unqualified call whose `f/arity` the target module
52/// neither defines, imports, nor inherits as a BIF, and that the patch
53/// does not define; compare the `-spec` return shape of each call the
54/// target module itself defines. One reason per `(file, function,
55/// arity)`.
56pub fn analyse_local_calls(
57    subjects: &[ContextAwareSubject<'_>],
58    ctx: &ReferenceContext<'_>,
59) -> LocalCallAnalysis {
60    let mut analysis = LocalCallAnalysis::default();
61    let mut caches = ReferenceCaches::default();
62    for aware in subjects {
63        let subject = &aware.subject;
64        let sigs = extract_function_signatures_with_context(subject.added_text, aware.line_context);
65        if sigs.iter().all(|s| s.is_definition) {
66            continue;
67        }
68        let Some(target) = (ctx.read_target)(subject.source_path) else {
69            continue;
70        };
71        // A parse_transform injects functions the scanner cannot see.
72        if declares_parse_transform(&target) {
73            continue;
74        }
75        let patch_defs = defined_set(&sigs);
76        // A spec the patch rewrites lands on the target with the pick: no pre-existing drift to compare.
77        let patch_specs = extract_specs(subject.added_text);
78        let target_defined = defined_set(&extract_function_signatures(&target));
79        let target_imported = imported_keys(&target);
80        let patch_imports = patch_import_map(subject.added_text);
81        // The callee module is the subject file itself: one table pair per subject, filled on the first resolved call.
82        let mut spec_tables: Option<(SpecTable, Option<SpecTable>)> = None;
83        let mut flagged = BTreeSet::new();
84        let mut shape_seen = BTreeSet::new();
85        caches.begin_subject();
86        for call in sigs.iter().filter(|s| !s.is_definition) {
87            if is_auto_imported_bif(&call.name) {
88                continue;
89            }
90            let Some(key) = signature_key(call) else {
91                continue;
92            };
93            if patch_defs.contains(&key) {
94                continue;
95            }
96            if target_defined.contains(&key) {
97                if !shape_seen.insert(key.clone()) {
98                    continue;
99                }
100                // a spec the patch rewrites lands on the target with the pick: no pre-existing drift to compare
101                if !patch_specs.contains_key(&key) {
102                    check_local_return_shape(
103                        subject,
104                        &target,
105                        call,
106                        &key,
107                        ctx.read_source,
108                        &mut spec_tables,
109                        &mut analysis,
110                    );
111                }
112                continue;
113            }
114            if target_imported.contains(&key) {
115                if shape_seen.insert(key.clone()) {
116                    analysis.shape_checks.withheld_imported += 1;
117                }
118                continue;
119            }
120            // A patch-imported call is a cross-module reference: resolve it
121            // against the imported module on the target tree, not the subject file.
122            if let Some(import) = patch_imports.get(&key) {
123                let qkey = (import.function.clone(), import.arity);
124                resolve_qualified_reference(
125                    subject,
126                    &import.module,
127                    &qkey,
128                    call.line,
129                    ctx,
130                    &mut caches,
131                    &mut analysis.reasons,
132                    &mut analysis.shape_checks,
133                );
134                continue;
135            }
136            if !flagged.insert(key.clone()) {
137                continue;
138            }
139            analysis.reasons.push(Reason::LocalCallUndefinedOnTarget {
140                source_path: subject.source_path.clone(),
141                function: key.0,
142                arity: key.1,
143                line: file_line(subject.line_map, call.line),
144            });
145        }
146    }
147    analysis
148}
149
150/// The `(name, arity)` view of a module's own `-import`s, keyed the way
151/// the call scanner keys a call site.
152fn imported_keys(src: &str) -> BTreeSet<(FunctionName, Arity)> {
153    extract_imports(src)
154        .into_iter()
155        .map(|i| (i.function, i.arity))
156        .collect()
157}
158
159/// The patch's own added `-import`s, keyed by `(name, arity)` so a call
160/// site resolves to the module it names for cross-module resolution.
161fn patch_import_map(src: &str) -> BTreeMap<(FunctionName, Arity), ImportedFunction> {
162    extract_imports(src)
163        .into_iter()
164        .map(|i| ((i.function.clone(), i.arity), i))
165        .collect()
166}
167
168fn check_local_return_shape(
169    subject: &AddedLinesSubject<'_>,
170    target_text: &str,
171    call: &FunctionSignature,
172    key: &(FunctionName, Arity),
173    read_source: Option<TreeReader<'_>>,
174    spec_tables: &mut Option<(SpecTable, Option<SpecTable>)>,
175    analysis: &mut LocalCallAnalysis,
176) {
177    let tally = &mut analysis.shape_checks;
178    let Some(read_source) = read_source else {
179        tally.withheld_no_source += 1;
180        return;
181    };
182    let (target_specs, source_specs) = spec_tables.get_or_insert_with(|| {
183        (
184            extract_specs(target_text),
185            read_source(subject.source_path).map(|t| extract_specs(&t)),
186        )
187    });
188    let Some(source_specs) = source_specs.as_ref() else {
189        tally.withheld_no_source += 1;
190        return;
191    };
192    match compare_return_shapes(target_specs, source_specs, key) {
193        ShapeComparison::Same => tally.compared += 1,
194        ShapeComparison::NoSpec => tally.withheld_no_spec += 1,
195        ShapeComparison::UnknownType => tally.withheld_unknown_type += 1,
196        ShapeComparison::Drift {
197            source_signature,
198            target_signature,
199        } => {
200            tally.compared += 1;
201            analysis.reasons.push(Reason::LocalCallReturnShapeDrift {
202                source_path: subject.source_path.clone(),
203                function: key.0.clone(),
204                arity: key.1,
205                source_signature,
206                target_signature,
207                line: file_line(subject.line_map, call.line),
208            });
209        }
210    }
211}
212
213fn defined_set(sigs: &[FunctionSignature]) -> BTreeSet<(FunctionName, Arity)> {
214    sigs.iter()
215        .filter(|s| s.is_definition)
216        .filter_map(signature_key)
217        .collect()
218}
219
220/// The typed `(name, arity)` key for a signature, `None` when either
221/// part fails to validate: an unvalidatable call can never be flagged,
222/// so it drops out of every set.
223fn signature_key(sig: &FunctionSignature) -> Option<(FunctionName, Arity)> {
224    Some((
225        FunctionName::from_str(&sig.name).ok()?,
226        Arity::try_from(sig.arity).ok()?,
227    ))
228}
229
230/// Functions Erlang auto-imports from `erlang`, callable unqualified.
231/// Matched by name across arities: conservative, so a BIF is never
232/// flagged even at an arity not listed.
233fn is_auto_imported_bif(name: &str) -> bool {
234    matches!(
235        name,
236        "abs"
237            | "apply"
238            | "atom_to_binary"
239            | "atom_to_list"
240            | "binary_part"
241            | "binary_to_atom"
242            | "binary_to_existing_atom"
243            | "binary_to_float"
244            | "binary_to_integer"
245            | "binary_to_list"
246            | "binary_to_term"
247            | "bit_size"
248            | "bitstring_to_list"
249            | "byte_size"
250            | "ceil"
251            | "date"
252            | "element"
253            | "erase"
254            | "error"
255            | "exit"
256            | "float"
257            | "float_to_binary"
258            | "float_to_list"
259            | "floor"
260            | "garbage_collect"
261            | "get"
262            | "get_keys"
263            | "group_leader"
264            | "halt"
265            | "hd"
266            | "integer_to_binary"
267            | "integer_to_list"
268            | "iolist_size"
269            | "iolist_to_binary"
270            | "is_alive"
271            | "is_atom"
272            | "is_binary"
273            | "is_bitstring"
274            | "is_boolean"
275            | "is_float"
276            | "is_function"
277            | "is_integer"
278            | "is_list"
279            | "is_map"
280            | "is_map_key"
281            | "is_number"
282            | "is_pid"
283            | "is_port"
284            | "is_process_alive"
285            | "is_record"
286            | "is_reference"
287            | "is_tuple"
288            | "length"
289            | "link"
290            | "list_to_atom"
291            | "list_to_binary"
292            | "list_to_bitstring"
293            | "list_to_existing_atom"
294            | "list_to_float"
295            | "list_to_integer"
296            | "list_to_pid"
297            | "list_to_tuple"
298            | "make_ref"
299            | "map_get"
300            | "map_size"
301            | "max"
302            | "min"
303            | "monitor"
304            | "node"
305            | "nodes"
306            | "now"
307            | "open_port"
308            | "pid_to_list"
309            | "process_flag"
310            | "process_info"
311            | "processes"
312            | "put"
313            | "register"
314            | "registered"
315            | "round"
316            | "self"
317            | "setelement"
318            | "size"
319            | "spawn"
320            | "spawn_link"
321            | "spawn_monitor"
322            | "spawn_opt"
323            | "split_binary"
324            | "statistics"
325            | "term_to_binary"
326            | "throw"
327            | "time"
328            | "tl"
329            | "trunc"
330            | "tuple_size"
331            | "tuple_to_list"
332            | "unlink"
333            | "unregister"
334            | "whereis"
335    )
336}