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