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, MacroName, 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.coverage.hides_macros_or_records() {
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.as_str())
58                || !flagged.insert(u.name.clone())
59            {
60                continue;
61            }
62            // a name `MacroName` refuses could not have entered `defs.macros`, so its absence there says nothing
63            if MacroName::from_str(&u.name).is_err() {
64                continue;
65            }
66            reasons.push(Reason::MacroUndefinedOnTarget {
67                source_path: subject.source_path.clone(),
68                macro_name: u.name,
69                line: file_line(subject.line_map, u.line),
70            });
71        }
72        let patch_records = extract_defined_records(subject.added_text);
73        let mut flagged = BTreeSet::new();
74        for u in record_uses {
75            if patch_records.contains(&u.name)
76                || defs.records.contains(u.name.as_str())
77                || !flagged.insert(u.name.clone())
78            {
79                continue;
80            }
81            let Ok(record_name) = RecordName::from_str(&u.name) else {
82                continue;
83            };
84            reasons.push(Reason::RecordUndefinedOnTarget {
85                source_path: subject.source_path.clone(),
86                record_name,
87                line: file_line(subject.line_map, u.line),
88            });
89        }
90    }
91    reasons
92}
93
94/// Whether the `-include` closure was read in full, and which side of
95/// it a skipped header falls on. A first-party header can hide a
96/// macro, a record, or a type; a skipped stdlib header can only hide a
97/// macro or a record, since a module can only export a type declared
98/// in its own text.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
100pub enum IncludeCoverage {
101    #[default]
102    Complete,
103    FirstPartyUnread,
104    StdlibUnread,
105    BothUnread,
106}
107
108impl IncludeCoverage {
109    fn from_flags(first_party_unread: bool, stdlib_unread: bool) -> Self {
110        match (first_party_unread, stdlib_unread) {
111            (false, false) => Self::Complete,
112            (true, false) => Self::FirstPartyUnread,
113            (false, true) => Self::StdlibUnread,
114            (true, true) => Self::BothUnread,
115        }
116    }
117
118    /// True for every arm but `Complete`: an unread header of either
119    /// kind can hide a macro or a record.
120    #[must_use]
121    pub fn hides_macros_or_records(self) -> bool {
122        !matches!(self, Self::Complete)
123    }
124
125    /// True only when a first-party header went unread: a skipped
126    /// stdlib header cannot hide a type a first-party module re-exports.
127    #[must_use]
128    pub fn hides_types(self) -> bool {
129        matches!(self, Self::FirstPartyUnread | Self::BothUnread)
130    }
131}
132
133/// Macros, records, and types defined in the target version of the
134/// subject's file or a header it transitively includes, with the
135/// include set taken from the target text and the patch's added text
136/// alike.
137#[derive(Debug, Default)]
138pub struct TargetDefines {
139    pub macros: BTreeSet<MacroName>,
140    pub records: BTreeSet<RecordName>,
141    pub types: BTreeSet<(TypeName, Arity)>,
142    pub coverage: IncludeCoverage,
143    /// A macro-expanded attribute form in the closure: any
144    /// attribute-derived set here may be missing what it expands to.
145    pub macro_attributes: bool,
146}
147
148/// Which side of the pick a header's text comes from.
149enum HeaderSource {
150    Target,
151    PatchAdded,
152}
153
154struct IncludeWalk<'a> {
155    macros: BTreeSet<MacroName>,
156    records: BTreeSet<RecordName>,
157    types: BTreeSet<(TypeName, Arity)>,
158    macro_attributes: bool,
159    first_party_unread: bool,
160    stdlib_unread: bool,
161    visited: BTreeSet<RelativePath>,
162    stack: Vec<(RelativePath, usize, HeaderSource)>,
163    patch_added: &'a BTreeMap<RelativePath, String>,
164    target: &'a TargetTreeIndex,
165}
166
167impl IncludeWalk<'_> {
168    fn absorb(&mut self, content: &str) {
169        self.macros.extend(
170            extract_defined_macros(content)
171                .into_iter()
172                .filter_map(|m| MacroName::new(m).ok()),
173        );
174        self.records.extend(
175            extract_defined_records(content)
176                .into_iter()
177                .filter_map(|r| RecordName::new(r).ok()),
178        );
179        self.types.extend(extract_defined_types(content));
180        self.macro_attributes |= has_macro_expanded_attribute(content);
181    }
182
183    fn follow_includes(&mut self, content: &str, from: &RelativePath, depth: usize) {
184        for inc in extract_includes(content) {
185            if is_stdlib_include_lib(&inc.directive) {
186                self.stdlib_unread = true;
187                continue;
188            }
189            // Target-first: a path present on both sides reads the target text.
190            match resolve_include(self.target, from, &inc.directive) {
191                Ok(resolved) => self.push(resolved, depth, HeaderSource::Target),
192                Err(candidates) => {
193                    let patch_hit = candidates
194                        .into_iter()
195                        .find(|c| self.patch_added.contains_key(c));
196                    match patch_hit {
197                        Some(path) => self.push(path, depth, HeaderSource::PatchAdded),
198                        None => self.first_party_unread = true,
199                    }
200                }
201            }
202        }
203    }
204
205    fn push(&mut self, path: RelativePath, depth: usize, source: HeaderSource) {
206        if !self.visited.insert(path.clone()) {
207            return;
208        }
209        if depth > MAX_INCLUDE_DEPTH {
210            self.first_party_unread = true;
211            return;
212        }
213        self.stack.push((path, depth, source));
214    }
215
216    fn finish(self) -> TargetDefines {
217        TargetDefines {
218            macros: self.macros,
219            records: self.records,
220            types: self.types,
221            coverage: IncludeCoverage::from_flags(self.first_party_unread, self.stdlib_unread),
222            macro_attributes: self.macro_attributes,
223        }
224    }
225}
226
227/// `patch_added` holds the full text of every file the patch creates,
228/// so an include that fails to resolve on the target can still resolve
229/// against a header the same patch adds.
230pub fn collect_target_defines(
231    subject: &AddedLinesSubject<'_>,
232    patch_added: &BTreeMap<RelativePath, String>,
233    target: &TargetTreeIndex,
234    read_target: &dyn Fn(&RelativePath) -> Option<String>,
235) -> TargetDefines {
236    let mut walk = IncludeWalk {
237        macros: BTreeSet::new(),
238        records: BTreeSet::new(),
239        types: BTreeSet::new(),
240        macro_attributes: false,
241        first_party_unread: false,
242        stdlib_unread: false,
243        visited: BTreeSet::new(),
244        stack: Vec::new(),
245        patch_added,
246        target,
247    };
248    walk.visited.insert(subject.source_path.clone());
249    // 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.
250    if let Some(content) = read_target(subject.source_path) {
251        walk.absorb(&content);
252        walk.follow_includes(&content, subject.source_path, 1);
253    }
254    walk.follow_includes(subject.added_text, subject.source_path, 1);
255    while let Some((path, depth, source)) = walk.stack.pop() {
256        let content = match source {
257            HeaderSource::Target => read_target(&path),
258            HeaderSource::PatchAdded => walk.patch_added.get(&path).cloned(),
259        };
260        let Some(content) = content else {
261            walk.first_party_unread = true;
262            continue;
263        };
264        walk.absorb(&content);
265        walk.follow_includes(&content, &path, depth + 1);
266    }
267    walk.finish()
268}
269
270/// What the macro-value check produced: the drift reasons plus the
271/// tally of what it did with each used name.
272#[derive(Debug, Default)]
273pub struct MacroValueAnalysis {
274    pub reasons: Vec<Reason>,
275    pub checks: MacroValueTally,
276}
277
278/// Compare the `-define` value of each macro the patch uses between
279/// the two trees, when the definition is in the touched file itself
280/// on both sides, exactly once each. Everything else withholds and is
281/// counted: a definition reached through an include (or present
282/// same-file on one side only), an `-ifdef`-duplicated name, an
283/// unreadable source side. A macro the patch itself redefines is
284/// skipped: the patch carries the new value with it. One outcome per
285/// `(file, name)`.
286pub fn analyse_macro_values(
287    subjects: &[AddedLinesSubject<'_>],
288    read_target: TreeReader<'_>,
289    read_source: Option<TreeReader<'_>>,
290) -> MacroValueAnalysis {
291    let mut analysis = MacroValueAnalysis::default();
292    for subject in subjects {
293        let uses = extract_macro_uses(subject.added_text);
294        if uses.is_empty() {
295            continue;
296        }
297        // An absent top file is the new-file case: nothing pre-existing to compare; the existence axis owns the rest.
298        let Some(target_text) = read_target(subject.source_path) else {
299            continue;
300        };
301        let patch_macros = extract_defined_macros(subject.added_text);
302        let target_values = extract_defined_macro_values(&target_text);
303        let mut source_values: Option<Option<_>> = None;
304        let mut seen = BTreeSet::new();
305        for u in uses {
306            if is_predefined_macro(&u.name)
307                || patch_macros.contains(&u.name)
308                || !seen.insert(u.name.clone())
309            {
310                continue;
311            }
312            let Some(read_source) = read_source else {
313                analysis.checks.withheld_no_source += 1;
314                continue;
315            };
316            let source_values = source_values.get_or_insert_with(|| {
317                read_source(subject.source_path).map(|t| extract_defined_macro_values(&t))
318            });
319            let Some(source_values) = source_values.as_ref() else {
320                analysis.checks.withheld_no_source += 1;
321                continue;
322            };
323            match (
324                target_values.get(u.name.as_str()),
325                source_values.get(u.name.as_str()),
326            ) {
327                (Some(target_defs), Some(source_defs)) => {
328                    let (Some(target_def), Some(source_def)) =
329                        (single(target_defs), single(source_defs))
330                    else {
331                        analysis.checks.withheld_multiple_defines += 1;
332                        continue;
333                    };
334                    analysis.checks.compared += 1;
335                    if target_def != source_def {
336                        analysis.reasons.push(Reason::MacroValueDrift {
337                            source_path: subject.source_path.clone(),
338                            macro_name: u.name,
339                            source_value: rendered(source_def),
340                            target_value: rendered(target_def),
341                            line: file_line(subject.line_map, u.line),
342                        });
343                    }
344                }
345                _ => analysis.checks.withheld_definition_elsewhere += 1,
346            }
347        }
348    }
349    analysis
350}
351
352fn single(defs: &[MacroDef]) -> Option<&MacroDef> {
353    match defs {
354        [one] => Some(one),
355        _ => None,
356    }
357}
358
359/// A function-like define renders with its parameter count, so a
360/// form-only difference still shows two distinguishable values.
361fn rendered(def: &MacroDef) -> String {
362    match def.key.arity {
363        Some(n) => format!("({n} args) {}", def.body),
364        None => def.body.to_string(),
365    }
366}