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