Skip to main content

backhopper_core/compat/
define_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 `?MACRO` and `#record` references a patch adds against the
6//! target tree: the case where a hunk applies cleanly but the symbol it
7//! references is defined nowhere the target reaches. Both are
8//! directive-defined (`-define`, `-record`) and defined in the file or its
9//! includes, so one tree walk gathers both. It gathers type
10//! declarations too: the exported-type axis needs the same closure.
11//! `read_target` is injected: core stays I/O-free.
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::str::FromStr;
15
16use crate::compat::added_file::is_stdlib_include_lib;
17use crate::compat::added_lines::{AddedLinesSubject, file_line};
18use crate::compat::qualified_call_resolve::TreeReader;
19use crate::compat::source_attributes::{
20    MacroDef, extract_defined_macro_values, extract_defined_macros, extract_defined_records,
21    extract_defined_types, extract_includes, extract_macro_uses, extract_record_uses,
22    has_macro_expanded_attribute, is_predefined_macro, resolve_include,
23};
24use crate::compat::target_tree_index::TargetTreeIndex;
25use crate::model::names::{Arity, RecordName, RelativePath, TypeName};
26use crate::model::verdict::{MacroValueTally, Reason};
27
28/// `-include` follow depth bound: real header chains are a handful deep.
29const MAX_INCLUDE_DEPTH: usize = 16;
30
31/// Flag each added macro or record use that resolves to no definition on
32/// the target and is neither predefined nor defined by the patch. One
33/// reason per `(file, symbol)`.
34pub fn analyse_define_symbols(
35    subjects: &[AddedLinesSubject<'_>],
36    patch_added: &BTreeMap<RelativePath, String>,
37    target: &TargetTreeIndex,
38    read_target: &dyn Fn(&RelativePath) -> Option<String>,
39) -> Vec<Reason> {
40    let mut reasons = Vec::new();
41    for subject in subjects {
42        let macro_uses = extract_macro_uses(subject.added_text);
43        let record_uses = extract_record_uses(subject.added_text);
44        if macro_uses.is_empty() && record_uses.is_empty() {
45            continue;
46        }
47        let defs = collect_target_defines(subject, patch_added, target, read_target);
48        // Incomplete define set: the symbol could be in an unreadable header, so suppress rather than risk a false positive.
49        if !defs.defines_complete() {
50            continue;
51        }
52        let patch_macros = extract_defined_macros(subject.added_text);
53        let mut flagged = BTreeSet::new();
54        for u in macro_uses {
55            if is_predefined_macro(&u.name)
56                || patch_macros.contains(&u.name)
57                || defs.macros.contains(&u.name)
58                || !flagged.insert(u.name.clone())
59            {
60                continue;
61            }
62            reasons.push(Reason::MacroUndefinedOnTarget {
63                source_path: subject.source_path.clone(),
64                macro_name: u.name,
65                line: file_line(subject.line_map, u.line),
66            });
67        }
68        let patch_records = extract_defined_records(subject.added_text);
69        let mut flagged = BTreeSet::new();
70        for u in record_uses {
71            if patch_records.contains(&u.name)
72                || defs.records.contains(&u.name)
73                || !flagged.insert(u.name.clone())
74            {
75                continue;
76            }
77            let Ok(record_name) = RecordName::from_str(&u.name) else {
78                continue;
79            };
80            reasons.push(Reason::RecordUndefinedOnTarget {
81                source_path: subject.source_path.clone(),
82                record_name,
83                line: file_line(subject.line_map, u.line),
84            });
85        }
86    }
87    reasons
88}
89
90/// Macros, records, and types defined in the target version of the
91/// subject's file or a header it transitively includes, with the
92/// include set taken from the target text and the patch's added text
93/// alike. `complete` is
94/// false when a first-party include could not be resolved or read: a
95/// definition might hide in the header we missed. A skipped stdlib
96/// header is tracked separately, since which sets it can hide differs
97/// by axis.
98#[derive(Debug)]
99pub struct TargetDefines {
100    pub macros: BTreeSet<String>,
101    pub records: BTreeSet<String>,
102    pub types: BTreeSet<(TypeName, Arity)>,
103    pub complete: bool,
104    /// A stdlib `include_lib` was skipped: it is outside the repo, so
105    /// it cannot be read. It can hide a macro or a record, so those
106    /// axes must treat it as incomplete. It cannot practically hide a
107    /// type a first-party module then re-exports, since a module can
108    /// only export a type declared in its own text.
109    pub stdlib_unread: bool,
110    /// A macro-expanded attribute form in the closure: any
111    /// attribute-derived set here may be missing what it expands to.
112    pub macro_attributes: bool,
113}
114
115impl TargetDefines {
116    /// Macros and records can hide in any unread header, stdlib or not.
117    #[must_use]
118    pub fn defines_complete(&self) -> bool {
119        self.complete && !self.stdlib_unread
120    }
121}
122
123/// The empty closure is complete: nothing was reached, nothing was missed.
124impl Default for TargetDefines {
125    fn default() -> Self {
126        Self {
127            macros: BTreeSet::new(),
128            records: BTreeSet::new(),
129            types: BTreeSet::new(),
130            complete: true,
131            stdlib_unread: false,
132            macro_attributes: false,
133        }
134    }
135}
136
137/// Which side of the pick a header's text comes from.
138enum HeaderSource {
139    Target,
140    PatchAdded,
141}
142
143struct IncludeWalk<'a> {
144    out: TargetDefines,
145    visited: BTreeSet<RelativePath>,
146    stack: Vec<(RelativePath, usize, HeaderSource)>,
147    patch_added: &'a BTreeMap<RelativePath, String>,
148    target: &'a TargetTreeIndex,
149}
150
151impl IncludeWalk<'_> {
152    fn absorb(&mut self, content: &str) {
153        self.out.macros.extend(extract_defined_macros(content));
154        self.out.records.extend(extract_defined_records(content));
155        self.out.types.extend(extract_defined_types(content));
156        self.out.macro_attributes |= has_macro_expanded_attribute(content);
157    }
158
159    fn follow_includes(&mut self, content: &str, from: &RelativePath, depth: usize) {
160        for inc in extract_includes(content) {
161            if is_stdlib_include_lib(&inc.directive) {
162                self.out.stdlib_unread = true;
163                continue;
164            }
165            // Target-first: a path present on both sides reads the target text.
166            match resolve_include(self.target, from, &inc.directive) {
167                Ok(resolved) => self.push(resolved, depth, HeaderSource::Target),
168                Err(candidates) => {
169                    let patch_hit = candidates
170                        .into_iter()
171                        .find(|c| self.patch_added.contains_key(c));
172                    match patch_hit {
173                        Some(path) => self.push(path, depth, HeaderSource::PatchAdded),
174                        None => self.out.complete = false,
175                    }
176                }
177            }
178        }
179    }
180
181    fn push(&mut self, path: RelativePath, depth: usize, source: HeaderSource) {
182        if !self.visited.insert(path.clone()) {
183            return;
184        }
185        if depth > MAX_INCLUDE_DEPTH {
186            self.out.complete = false;
187            return;
188        }
189        self.stack.push((path, depth, source));
190    }
191}
192
193/// `patch_added` holds the full text of every file the patch creates,
194/// so an include that fails to resolve on the target can still resolve
195/// against a header the same patch adds.
196pub fn collect_target_defines(
197    subject: &AddedLinesSubject<'_>,
198    patch_added: &BTreeMap<RelativePath, String>,
199    target: &TargetTreeIndex,
200    read_target: &dyn Fn(&RelativePath) -> Option<String>,
201) -> TargetDefines {
202    let mut walk = IncludeWalk {
203        out: TargetDefines::default(),
204        visited: BTreeSet::new(),
205        stack: Vec::new(),
206        patch_added,
207        target,
208    };
209    walk.visited.insert(subject.source_path.clone());
210    // At the top file the includes to follow are the union of the target text's and the patch's added text's; an absent top file is the new-file case and stays complete.
211    if let Some(content) = read_target(subject.source_path) {
212        walk.absorb(&content);
213        walk.follow_includes(&content, subject.source_path, 1);
214    }
215    walk.follow_includes(subject.added_text, subject.source_path, 1);
216    while let Some((path, depth, source)) = walk.stack.pop() {
217        let content = match source {
218            HeaderSource::Target => read_target(&path),
219            HeaderSource::PatchAdded => walk.patch_added.get(&path).cloned(),
220        };
221        let Some(content) = content else {
222            walk.out.complete = false;
223            continue;
224        };
225        walk.absorb(&content);
226        walk.follow_includes(&content, &path, depth + 1);
227    }
228    walk.out
229}
230
231/// What the macro-value check produced: the drift reasons plus the
232/// tally of what it did with each used name.
233#[derive(Debug, Default)]
234pub struct MacroValueAnalysis {
235    pub reasons: Vec<Reason>,
236    pub checks: MacroValueTally,
237}
238
239/// Compare the `-define` value of each macro the patch uses between
240/// the two trees, when the definition is in the touched file itself
241/// on both sides, exactly once each. Everything else withholds and is
242/// counted: a definition reached through an include (or present
243/// same-file on one side only), an `-ifdef`-duplicated name, an
244/// unreadable source side. A macro the patch itself redefines is
245/// skipped: the patch carries the new value with it. One outcome per
246/// `(file, name)`.
247pub fn analyse_macro_values(
248    subjects: &[AddedLinesSubject<'_>],
249    read_target: TreeReader<'_>,
250    read_source: Option<TreeReader<'_>>,
251) -> MacroValueAnalysis {
252    let mut analysis = MacroValueAnalysis::default();
253    for subject in subjects {
254        let uses = extract_macro_uses(subject.added_text);
255        if uses.is_empty() {
256            continue;
257        }
258        // An absent top file is the new-file case: nothing pre-existing to compare; the existence axis owns the rest.
259        let Some(target_text) = read_target(subject.source_path) else {
260            continue;
261        };
262        let patch_macros = extract_defined_macros(subject.added_text);
263        let target_values = extract_defined_macro_values(&target_text);
264        let mut source_values: Option<Option<_>> = None;
265        let mut seen = BTreeSet::new();
266        for u in uses {
267            if is_predefined_macro(&u.name)
268                || patch_macros.contains(&u.name)
269                || !seen.insert(u.name.clone())
270            {
271                continue;
272            }
273            let Some(read_source) = read_source else {
274                analysis.checks.withheld_no_source += 1;
275                continue;
276            };
277            let source_values = source_values.get_or_insert_with(|| {
278                read_source(subject.source_path).map(|t| extract_defined_macro_values(&t))
279            });
280            let Some(source_values) = source_values.as_ref() else {
281                analysis.checks.withheld_no_source += 1;
282                continue;
283            };
284            match (target_values.get(&u.name), source_values.get(&u.name)) {
285                (Some(target_defs), Some(source_defs)) => {
286                    let (Some(target_def), Some(source_def)) =
287                        (single(target_defs), single(source_defs))
288                    else {
289                        analysis.checks.withheld_multiple_defines += 1;
290                        continue;
291                    };
292                    analysis.checks.compared += 1;
293                    if target_def != source_def {
294                        analysis.reasons.push(Reason::MacroValueDrift {
295                            source_path: subject.source_path.clone(),
296                            macro_name: u.name,
297                            source_value: rendered(source_def),
298                            target_value: rendered(target_def),
299                            line: file_line(subject.line_map, u.line),
300                        });
301                    }
302                }
303                _ => analysis.checks.withheld_definition_elsewhere += 1,
304            }
305        }
306    }
307    analysis
308}
309
310fn single(defs: &[MacroDef]) -> Option<&MacroDef> {
311    match defs {
312        [one] => Some(one),
313        _ => None,
314    }
315}
316
317/// A function-like define renders with its parameter count, so a
318/// form-only difference still shows two distinguishable values.
319fn rendered(def: &MacroDef) -> String {
320    match def.params {
321        Some(n) => format!("({n} args) {}", def.body),
322        None => def.body.clone(),
323    }
324}