Skip to main content

backhopper_xref_reader/reader/
mod.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//! The `SourceReader` orchestrator.
6//!
7//! For each `.erl` source it produces a `ModuleData` with exports, imports,
8//! behaviour declarations, callback declarations, call sites, and unresolved
9//! call sites. Header (`.hrl`) files contribute `-define` macros and
10//! `-include`-chain entries via the macro table.
11//!
12//! Submodules: `scan` (top-level driver and `ModuleBuilder`), `attributes`
13//! (`-module`, `-export`, `-define`, `-include`, ...), and `calls`
14//! (call-site detection).
15
16use std::collections::{BTreeMap, BTreeSet};
17use std::mem;
18use std::path::{Path, PathBuf};
19use std::str;
20
21use backhopper_xref_graph::{PathId, PathInterner};
22
23use crate::application::{ApplicationAssignment, ProjectLayout};
24use crate::errors::{ReadError, ReadWarning};
25use crate::model::{ModuleData, ReadOutput};
26
27mod attributes;
28mod calls;
29mod scan;
30
31#[derive(Debug, Clone, Default)]
32pub struct SourceReader {
33    layout: ProjectLayout,
34}
35
36impl SourceReader {
37    pub fn new() -> Self {
38        Self::default()
39    }
40
41    pub fn with_layout(layout: ProjectLayout) -> Self {
42        Self { layout }
43    }
44
45    /// Read a single source. Locations are tagged with `PathId::new(0)`;
46    /// for multi-file reads use `read_tree`, which threads a real
47    /// `PathInterner` so each file gets a distinct id.
48    pub fn read_one(
49        &self,
50        path: &Path,
51        source: &str,
52    ) -> Result<(Option<ModuleData>, Vec<ReadWarning>), ReadError> {
53        self.read_one_with_path_id(PathId::new(0), path, source, &HeaderIndex::empty())
54    }
55
56    fn read_one_with_path_id(
57        &self,
58        path_id: PathId,
59        path: &Path,
60        source: &str,
61        headers: &HeaderIndex,
62    ) -> Result<(Option<ModuleData>, Vec<ReadWarning>), ReadError> {
63        let mut warnings = Vec::new();
64
65        if source.trim().is_empty() {
66            warnings.push(ReadWarning::EmptyFile {
67                path: path.to_path_buf(),
68            });
69            return Ok((None, warnings));
70        }
71
72        let app = self.layout.resolve(path);
73        if let ApplicationAssignment::Ambiguous { candidates } = &app {
74            warnings.push(ReadWarning::AmbiguousApplicationAssignment {
75                path: path.to_path_buf(),
76                candidates: candidates.iter().map(|a| a.as_str().to_owned()).collect(),
77            });
78        }
79
80        let mut state = scan::ModuleBuilder::new(path.to_path_buf(), path_id, app);
81        scan::scan_attributes(source, &mut state, &mut warnings);
82        resolve_includes(&mut state, headers, &mut warnings);
83        scan::scan_bodies(source, &mut state);
84        Ok((state.into_module(&mut warnings), warnings))
85    }
86
87    pub fn read_tree<I>(&self, files: I) -> Result<ReadOutput, ReadError>
88    where
89        I: IntoIterator<Item = (PathBuf, Vec<u8>)>,
90    {
91        let collected: Vec<(PathBuf, Vec<u8>)> = files.into_iter().collect();
92        let headers = HeaderIndex::build(&collected);
93        let mut paths = PathInterner::new();
94        let mut modules = Vec::new();
95        let mut warnings = Vec::new();
96        for (path, bytes) in &collected {
97            if !path
98                .extension()
99                .is_some_and(|e| e.eq_ignore_ascii_case("erl"))
100            {
101                continue;
102            }
103            let text = str::from_utf8(bytes).map_err(|e| ReadError::InvalidUtf8 {
104                path: path.clone(),
105                offset: e.valid_up_to(),
106            })?;
107            let path_id = paths.intern(path);
108            let (module, ws) = self.read_one_with_path_id(path_id, path, text, &headers)?;
109            warnings.extend(ws);
110            if let Some(m) = module {
111                modules.push(m);
112            }
113        }
114        modules.sort_by(|a, b| a.module.cmp(&b.module));
115        Ok(ReadOutput {
116            modules,
117            warnings,
118            paths,
119        })
120    }
121}
122
123/// Pre-built lookup over `.hrl` content keyed by basename and by the
124/// `app/include/name.hrl` suffix that `-include_lib` directives use.
125struct HeaderIndex {
126    by_basename: BTreeMap<String, Vec<HeaderEntry>>,
127    by_include_lib: BTreeMap<String, HeaderEntry>,
128    by_full_path: BTreeMap<PathBuf, HeaderEntry>,
129}
130
131#[derive(Clone)]
132struct HeaderEntry {
133    path: PathBuf,
134    body: String,
135}
136
137impl HeaderIndex {
138    fn empty() -> Self {
139        Self {
140            by_basename: BTreeMap::new(),
141            by_include_lib: BTreeMap::new(),
142            by_full_path: BTreeMap::new(),
143        }
144    }
145
146    fn build(files: &[(PathBuf, Vec<u8>)]) -> Self {
147        let mut out = Self::empty();
148        for (path, bytes) in files {
149            if !path
150                .extension()
151                .is_some_and(|e| e.eq_ignore_ascii_case("hrl"))
152            {
153                continue;
154            }
155            let Ok(text) = str::from_utf8(bytes) else {
156                continue;
157            };
158            let entry = HeaderEntry {
159                path: path.clone(),
160                body: text.to_owned(),
161            };
162            out.by_full_path.insert(path.clone(), entry.clone());
163            if let Some(base) = path.file_name().and_then(|n| n.to_str()) {
164                out.by_basename
165                    .entry(base.to_owned())
166                    .or_default()
167                    .push(entry.clone());
168            }
169            if let Some(key) = include_lib_key(path) {
170                out.by_include_lib.insert(key, entry);
171            }
172        }
173        out
174    }
175
176    fn find_for_include(&self, source_dir: Option<&Path>, raw: &Path) -> Option<&HeaderEntry> {
177        if let Some(dir) = source_dir {
178            let candidate = dir.join(raw);
179            if let Some(entry) = self.by_full_path.get(&candidate) {
180                return Some(entry);
181            }
182        }
183        if let Some(entry) = self.by_full_path.get(raw) {
184            return Some(entry);
185        }
186        let base = raw.file_name().and_then(|n| n.to_str())?;
187        self.by_basename.get(base).and_then(|v| v.first())
188    }
189
190    fn find_for_include_lib(&self, raw: &Path) -> Option<&HeaderEntry> {
191        let key = raw.to_string_lossy();
192        if let Some(entry) = self.by_include_lib.get(key.as_ref()) {
193            return Some(entry);
194        }
195        let normalized = key.trim_start_matches("./");
196        if let Some(entry) = self.by_include_lib.get(normalized) {
197            return Some(entry);
198        }
199        let base = raw.file_name().and_then(|n| n.to_str())?;
200        self.by_basename.get(base).and_then(|v| v.first())
201    }
202}
203
204fn include_lib_key(path: &Path) -> Option<String> {
205    let s = path.to_string_lossy();
206    let parts: Vec<&str> = s.split('/').collect();
207    let include_idx = parts.iter().position(|p| *p == "include")?;
208    if include_idx == 0 {
209        return None;
210    }
211    let app = parts[include_idx - 1];
212    let rest = &parts[include_idx..];
213    Some(format!("{}/{}", app, rest.join("/")))
214}
215
216fn resolve_includes(
217    state: &mut scan::ModuleBuilder,
218    headers: &HeaderIndex,
219    warnings: &mut Vec<ReadWarning>,
220) {
221    let source_dir = state.path.parent().map(Path::to_path_buf);
222    let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
223    while !state.include_paths.is_empty() {
224        let pending: Vec<PathBuf> = mem::take(&mut state.include_paths);
225        for raw in pending {
226            let entry = match resolve_one(headers, source_dir.as_deref(), &raw) {
227                Some(e) => e,
228                None => {
229                    warnings.push(ReadWarning::UnresolvedInclude {
230                        path: state.path.clone(),
231                        target: raw,
232                    });
233                    continue;
234                }
235            };
236            if !seen.insert(entry.path.clone()) {
237                continue;
238            }
239            scan::fold_header_into(&entry.body, state);
240        }
241    }
242}
243
244fn resolve_one<'a>(
245    headers: &'a HeaderIndex,
246    source_dir: Option<&Path>,
247    raw: &Path,
248) -> Option<&'a HeaderEntry> {
249    if is_include_lib_shape(raw) {
250        if let Some(e) = headers.find_for_include_lib(raw) {
251            return Some(e);
252        }
253    }
254    headers.find_for_include(source_dir, raw)
255}
256
257fn is_include_lib_shape(raw: &Path) -> bool {
258    let s = raw.to_string_lossy();
259    s.contains("/include/")
260}