backhopper_xref_reader/reader/
mod.rs1use 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 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
123struct 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}