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