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::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    target: &TargetTreeIndex,
37    read_target: &dyn Fn(&RelativePath) -> Option<String>,
38) -> Vec<Reason> {
39    let mut reasons = Vec::new();
40    for subject in subjects {
41        let macro_uses = extract_macro_uses(subject.added_text);
42        let record_uses = extract_record_uses(subject.added_text);
43        if macro_uses.is_empty() && record_uses.is_empty() {
44            continue;
45        }
46        let defs = collect_target_defines(subject.source_path, target, read_target);
47        // Incomplete define set: the symbol could be in an unreadable header, so suppress rather than risk a false positive.
48        if !defs.defines_complete() {
49            continue;
50        }
51        let patch_macros = extract_defined_macros(subject.added_text);
52        let mut flagged = BTreeSet::new();
53        for u in macro_uses {
54            if is_predefined_macro(&u.name)
55                || patch_macros.contains(&u.name)
56                || defs.macros.contains(&u.name)
57                || !flagged.insert(u.name.clone())
58            {
59                continue;
60            }
61            reasons.push(Reason::MacroUndefinedOnTarget {
62                source_path: subject.source_path.clone(),
63                macro_name: u.name,
64                line: file_line(subject.line_map, u.line),
65            });
66        }
67        let patch_records = extract_defined_records(subject.added_text);
68        let mut flagged = BTreeSet::new();
69        for u in record_uses {
70            if patch_records.contains(&u.name)
71                || defs.records.contains(&u.name)
72                || !flagged.insert(u.name.clone())
73            {
74                continue;
75            }
76            let Ok(record_name) = RecordName::from_str(&u.name) else {
77                continue;
78            };
79            reasons.push(Reason::RecordUndefinedOnTarget {
80                source_path: subject.source_path.clone(),
81                record_name,
82                line: file_line(subject.line_map, u.line),
83            });
84        }
85    }
86    reasons
87}
88
89/// Macros, records, and types defined in the target version of
90/// `source_path` or a header it transitively includes. `complete` is
91/// false when a first-party include could not be resolved or read: a
92/// definition might hide in the header we missed. A skipped stdlib
93/// header is tracked separately, since which sets it can hide differs
94/// by axis.
95#[derive(Debug)]
96pub struct TargetDefines {
97    pub macros: BTreeSet<String>,
98    pub records: BTreeSet<String>,
99    pub types: BTreeSet<(TypeName, Arity)>,
100    pub complete: bool,
101    /// A stdlib `include_lib` was skipped: it is outside the repo, so
102    /// it cannot be read. It can hide a macro or a record, so those
103    /// axes must treat it as incomplete. It cannot practically hide a
104    /// type a first-party module then re-exports, since a module can
105    /// only export a type declared in its own text.
106    pub stdlib_unread: bool,
107    /// A macro-expanded attribute form in the closure: any
108    /// attribute-derived set here may be missing what it expands to.
109    pub macro_attributes: bool,
110}
111
112impl TargetDefines {
113    /// Macros and records can hide in any unread header, stdlib or not.
114    #[must_use]
115    pub fn defines_complete(&self) -> bool {
116        self.complete && !self.stdlib_unread
117    }
118}
119
120pub fn collect_target_defines(
121    source_path: &RelativePath,
122    target: &TargetTreeIndex,
123    read_target: &dyn Fn(&RelativePath) -> Option<String>,
124) -> TargetDefines {
125    let mut out = TargetDefines {
126        macros: BTreeSet::new(),
127        records: BTreeSet::new(),
128        types: BTreeSet::new(),
129        complete: true,
130        stdlib_unread: false,
131        macro_attributes: false,
132    };
133    let mut visited: BTreeSet<RelativePath> = BTreeSet::new();
134    let mut stack = vec![(source_path.clone(), 0usize)];
135    while let Some((path, depth)) = stack.pop() {
136        if !visited.insert(path.clone()) {
137            continue;
138        }
139        if depth > MAX_INCLUDE_DEPTH {
140            out.complete = false;
141            continue;
142        }
143        let Some(content) = read_target(&path) else {
144            // An absent top file is the new-file case (complete); an unreadable header leaves the set incomplete.
145            if depth > 0 {
146                out.complete = false;
147            }
148            continue;
149        };
150        out.macros.extend(extract_defined_macros(&content));
151        out.records.extend(extract_defined_records(&content));
152        out.types.extend(extract_defined_types(&content));
153        out.macro_attributes |= has_macro_expanded_attribute(&content);
154        for inc in extract_includes(&content) {
155            if is_stdlib_include_lib(&inc.directive) {
156                out.stdlib_unread = true;
157                continue;
158            }
159            match resolve_include(target, &path, &inc.directive) {
160                Ok(resolved) => stack.push((resolved, depth + 1)),
161                Err(_) => out.complete = false,
162            }
163        }
164    }
165    out
166}
167
168/// What the macro-value check produced: the drift reasons plus the
169/// tally of what it did with each used name.
170#[derive(Debug, Default)]
171pub struct MacroValueAnalysis {
172    pub reasons: Vec<Reason>,
173    pub checks: MacroValueTally,
174}
175
176/// Compare the `-define` value of each macro the patch uses between
177/// the two trees, when the definition is in the touched file itself
178/// on both sides, exactly once each. Everything else withholds and is
179/// counted: a definition reached through an include (or present
180/// same-file on one side only), an `-ifdef`-duplicated name, an
181/// unreadable source side. A macro the patch itself redefines is
182/// skipped: the patch carries the new value with it. One outcome per
183/// `(file, name)`.
184pub fn analyse_macro_values(
185    subjects: &[AddedLinesSubject<'_>],
186    read_target: TreeReader<'_>,
187    read_source: Option<TreeReader<'_>>,
188) -> MacroValueAnalysis {
189    let mut analysis = MacroValueAnalysis::default();
190    for subject in subjects {
191        let uses = extract_macro_uses(subject.added_text);
192        if uses.is_empty() {
193            continue;
194        }
195        // An absent top file is the new-file case: nothing pre-existing to compare; the existence axis owns the rest.
196        let Some(target_text) = read_target(subject.source_path) else {
197            continue;
198        };
199        let patch_macros = extract_defined_macros(subject.added_text);
200        let target_values = extract_defined_macro_values(&target_text);
201        let mut source_values: Option<Option<_>> = None;
202        let mut seen = BTreeSet::new();
203        for u in uses {
204            if is_predefined_macro(&u.name)
205                || patch_macros.contains(&u.name)
206                || !seen.insert(u.name.clone())
207            {
208                continue;
209            }
210            let Some(read_source) = read_source else {
211                analysis.checks.withheld_no_source += 1;
212                continue;
213            };
214            let source_values = source_values.get_or_insert_with(|| {
215                read_source(subject.source_path).map(|t| extract_defined_macro_values(&t))
216            });
217            let Some(source_values) = source_values.as_ref() else {
218                analysis.checks.withheld_no_source += 1;
219                continue;
220            };
221            match (target_values.get(&u.name), source_values.get(&u.name)) {
222                (Some(target_defs), Some(source_defs)) => {
223                    let (Some(target_def), Some(source_def)) =
224                        (single(target_defs), single(source_defs))
225                    else {
226                        analysis.checks.withheld_multiple_defines += 1;
227                        continue;
228                    };
229                    analysis.checks.compared += 1;
230                    if target_def != source_def {
231                        analysis.reasons.push(Reason::MacroValueDrift {
232                            source_path: subject.source_path.clone(),
233                            macro_name: u.name,
234                            source_value: rendered(source_def),
235                            target_value: rendered(target_def),
236                            line: file_line(subject.line_map, u.line),
237                        });
238                    }
239                }
240                _ => analysis.checks.withheld_definition_elsewhere += 1,
241            }
242        }
243    }
244    analysis
245}
246
247fn single(defs: &[MacroDef]) -> Option<&MacroDef> {
248    match defs {
249        [one] => Some(one),
250        _ => None,
251    }
252}
253
254/// A function-like define renders with its parameter count, so a
255/// form-only difference still shows two distinguishable values.
256fn rendered(def: &MacroDef) -> String {
257    match def.params {
258        Some(n) => format!("({n} args) {}", def.body),
259        None => def.body.clone(),
260    }
261}