Skip to main content

backhopper_core/compat/
added_file.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//! Per-`.erl` and per-`.hrl` 018 findings: runs the three resolvers
6//! (test-module helpers, missing behaviours, missing headers) on a
7//! single added file and returns the typed reasons plus the
8//! diagnostic counters the caller merges into `Diagnostics`.
9//!
10//! The function is pure: no I/O beyond reading the supplied
11//! `TargetTreeIndex`. The CLI is the orchestrator that decides which
12//! `PatchedFile` shapes (fully added, modified, renamed) qualify.
13//!
14//! "Added file only" is the v1 scope: for a fully-added file the
15//! post-patch source equals the concatenation of `HunkLine::Added`
16//! lines. Modified files would need diff application against a
17//! baseline read from the target tree, which is a follow-up.
18
19use std::collections::BTreeMap;
20
21use crate::compat::source_attributes::{
22    behaviour_resolves, extract_behaviours, extract_includes, resolve_include,
23};
24use crate::compat::target_tree_index::TargetTreeIndex;
25use crate::compat::test_suite::{TestSuiteFile, is_stdlib_or_otp};
26use crate::model::names::{ModuleName, RelativePath};
27use crate::model::verdict::{IncludeDirective, Reason};
28
29#[non_exhaustive]
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct AddedFileFindings {
32    pub reasons: Vec<Reason>,
33    /// Per-suite `helper -> call_site_count` map, in the same shape
34    /// as `Diagnostics.missing_test_modules`. Always-on counterpart of
35    /// `Reason::TestModuleSymbolMissing`: emitted even when the
36    /// target tree resolves the helper (in that case the inner map
37    /// stays empty for that suite).
38    pub missing_test_modules: BTreeMap<RelativePath, BTreeMap<ModuleName, usize>>,
39}
40
41impl AddedFileFindings {
42    pub fn new(
43        reasons: Vec<Reason>,
44        missing_test_modules: BTreeMap<RelativePath, BTreeMap<ModuleName, usize>>,
45    ) -> Self {
46        Self {
47            reasons,
48            missing_test_modules,
49        }
50    }
51
52    pub fn is_empty(&self) -> bool {
53        self.reasons.is_empty() && self.missing_test_modules.is_empty()
54    }
55
56    fn merge(&mut self, other: AddedFileFindings) {
57        self.reasons.extend(other.reasons);
58        for (suite, by_mod) in other.missing_test_modules {
59            let entry = self.missing_test_modules.entry(suite).or_default();
60            for (m, count) in by_mod {
61                *entry.entry(m).or_insert(0) += count;
62            }
63        }
64    }
65}
66
67/// Run the three 018 resolver passes on one added file and return
68/// the merged findings. `path` identifies the file relative to the
69/// repo root; `content` is the post-patch source.
70pub fn analyse_added_file(
71    path: &RelativePath,
72    content: &str,
73    target: &TargetTreeIndex,
74    search_path_globs: &[String],
75) -> AddedFileFindings {
76    let mut out = AddedFileFindings::default();
77    let is_erl = ends_with_ignore_case(path.as_str(), ".erl");
78    let is_hrl = ends_with_ignore_case(path.as_str(), ".hrl");
79    if !is_erl && !is_hrl {
80        return out;
81    }
82    if is_erl && is_test_suite_path(path) {
83        out.merge(analyse_test_suite(path, content, target, search_path_globs));
84    }
85    if is_erl {
86        out.merge(analyse_behaviours(path, content, target, search_path_globs));
87    }
88    out.merge(analyse_includes(path, content, target));
89    out
90}
91
92/// Run `analyse_added_file` for every entry in `files` and merge the
93/// results into one envelope.
94pub fn analyse_added_files<'a, I>(
95    files: I,
96    target: &TargetTreeIndex,
97    search_path_globs: &[String],
98) -> AddedFileFindings
99where
100    I: IntoIterator<Item = (&'a RelativePath, &'a str)>,
101{
102    let mut out = AddedFileFindings::default();
103    for (path, content) in files {
104        out.merge(analyse_added_file(path, content, target, search_path_globs));
105    }
106    out
107}
108
109fn analyse_test_suite(
110    path: &RelativePath,
111    content: &str,
112    target: &TargetTreeIndex,
113    search_path_globs: &[String],
114) -> AddedFileFindings {
115    let parsed = match TestSuiteFile::new(path.clone(), content.to_owned()).parse() {
116        Ok(p) => p,
117        Err(_) => return AddedFileFindings::default(),
118    };
119    let resolved = parsed.resolve(target, search_path_globs);
120    let findings = resolved.into_findings();
121    let mut missing = BTreeMap::new();
122    if let Some((suite, by_mod)) = findings.diagnostic_entry {
123        missing.insert(suite, by_mod);
124    }
125    AddedFileFindings {
126        reasons: findings.reasons,
127        missing_test_modules: missing,
128    }
129}
130
131fn analyse_behaviours(
132    path: &RelativePath,
133    content: &str,
134    target: &TargetTreeIndex,
135    search_path_globs: &[String],
136) -> AddedFileFindings {
137    let mut reasons = Vec::new();
138    for b in extract_behaviours(content) {
139        if is_stdlib_or_otp(&b.behaviour) {
140            continue;
141        }
142        if behaviour_resolves(target, search_path_globs, &b.behaviour) {
143            continue;
144        }
145        reasons.push(Reason::BehaviourModuleMissing {
146            source_path: path.clone(),
147            behaviour: b.behaviour,
148        });
149    }
150    AddedFileFindings {
151        reasons,
152        missing_test_modules: BTreeMap::new(),
153    }
154}
155
156fn analyse_includes(
157    path: &RelativePath,
158    content: &str,
159    target: &TargetTreeIndex,
160) -> AddedFileFindings {
161    let mut reasons = Vec::new();
162    for inc in extract_includes(content) {
163        if is_stdlib_include_lib(&inc.directive) {
164            continue;
165        }
166        if let Err(attempted) = resolve_include(target, path, &inc.directive) {
167            reasons.push(Reason::HeaderFileMissing {
168                source_path: path.clone(),
169                include_directive: inc.directive,
170                attempted_paths: attempted,
171            });
172        }
173    }
174    AddedFileFindings {
175        reasons,
176        missing_test_modules: BTreeMap::new(),
177    }
178}
179
180/// `-include_lib("kernel/include/file.hrl")` and friends live outside
181/// the repo's target tree, so we never flag them missing even when
182/// they fail to resolve under `deps/`, `apps/`, `lib/`. Only the
183/// `IncludeLib` form gets this exemption: a `-include("…")` always
184/// points at a project-local path.
185pub fn is_stdlib_include_lib(directive: &IncludeDirective) -> bool {
186    let path = match directive {
187        IncludeDirective::IncludeLib { path } => path,
188        IncludeDirective::Include { .. } => return false,
189    };
190    let app = path.split('/').next().unwrap_or("");
191    matches!(
192        app,
193        "kernel"
194            | "stdlib"
195            | "compiler"
196            | "common_test"
197            | "ct"
198            | "eunit"
199            | "crypto"
200            | "ssl"
201            | "inets"
202            | "public_key"
203            | "asn1"
204            | "snmp"
205            | "tools"
206            | "wx"
207            | "xmerl"
208            | "syntax_tools"
209            | "edoc"
210            | "debugger"
211            | "diameter"
212            | "erts"
213            | "et"
214            | "ftp"
215            | "megaco"
216            | "mnesia"
217            | "observer"
218            | "odbc"
219            | "os_mon"
220            | "parsetools"
221            | "reltool"
222            | "runtime_tools"
223            | "sasl"
224            | "ssh"
225            | "tftp"
226            | "proper"
227            | "meck"
228    )
229}
230
231fn is_test_suite_path(path: &RelativePath) -> bool {
232    ends_with_ignore_case(path.as_str(), "_SUITE.erl")
233}
234
235fn ends_with_ignore_case(s: &str, suffix: &str) -> bool {
236    let sb = s.as_bytes();
237    let suf = suffix.as_bytes();
238    sb.len() >= suf.len() && sb[sb.len() - suf.len()..].eq_ignore_ascii_case(suf)
239}