Skip to main content

backhopper_core/compat/
source_macros.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//! Build a per-file `MacroTable` from raw `.erl` and `.hrl` source.
6//!
7//! Scans the file for `-define`, `-include`, and `-include_lib`
8//! attributes and folds resolved header content back in until the
9//! include chain stabilises. Cycles are broken by a `seen` set.
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::{Path, PathBuf};
13
14use backhopper_erlang_scan::{
15    dot_terminates, is_name_byte, number_span, quoted_atom_span, skip_char_literal_span,
16    string_span,
17};
18
19use crate::erlang_macros::{MacroTable, parse_define};
20
21/// Look up the body of a header by its raw `-include(...)` or
22/// `-include_lib(...)` argument, given the directory of the source
23/// file that named it.
24pub trait HeaderResolver {
25    fn resolve(&self, source_dir: Option<&Path>, raw: &Path) -> Option<HeaderBody>;
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct HeaderBody {
30    pub path: PathBuf,
31    pub body: String,
32}
33
34/// Builds the macro table for `source` (presumed `.erl`), resolving
35/// included headers through `resolver`. `source_path` is the path of
36/// the file being analyzed: it's used as the include-search anchor.
37pub fn build_macro_table(
38    source: &str,
39    source_path: &Path,
40    resolver: &dyn HeaderResolver,
41) -> MacroTable {
42    let mut macros = MacroTable::new();
43    let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
44    let mut pending: Vec<PathBuf> = Vec::new();
45    fold_attributes(source, &mut macros, &mut pending);
46    let source_dir = source_path.parent().map(Path::to_path_buf);
47    while let Some(raw) = pending.pop() {
48        let Some(entry) = resolver.resolve(source_dir.as_deref(), &raw) else {
49            continue;
50        };
51        if !seen.insert(entry.path.clone()) {
52            continue;
53        }
54        fold_attributes(&entry.body, &mut macros, &mut pending);
55    }
56    macros
57}
58
59/// Scans `source` for `-define`, `-include`, and `-include_lib`. Other
60/// attribute kinds are ignored. Bodies are kept verbatim.
61fn fold_attributes(source: &str, macros: &mut MacroTable, pending: &mut Vec<PathBuf>) {
62    let bytes = source.as_bytes();
63    let mut i = 0usize;
64    let mut at_line_start = true;
65    while i < bytes.len() {
66        let c = bytes[i];
67        if c == b'\n' {
68            at_line_start = true;
69            i += 1;
70            continue;
71        }
72        if c == b'%' {
73            while i < bytes.len() && bytes[i] != b'\n' {
74                i += 1;
75            }
76            continue;
77        }
78        if c.is_ascii_whitespace() {
79            i += 1;
80            continue;
81        }
82        if at_line_start && c == b'-' {
83            i += 1;
84            let name_start = i;
85            while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
86                i += 1;
87            }
88            let name = &source[name_start..i];
89            while i < bytes.len() && bytes[i].is_ascii_whitespace() {
90                i += 1;
91            }
92            let body_start = i;
93            i = scan_to_terminator(source, i);
94            let body = &source[body_start..i];
95            if i < bytes.len() && bytes[i] == b'.' {
96                i += 1;
97            }
98            match name {
99                "define" => {
100                    let trimmed = body
101                        .trim()
102                        .strip_prefix('(')
103                        .map(|x| x.strip_suffix(')').unwrap_or(x))
104                        .unwrap_or(body);
105                    if let Some((key, value)) = parse_define(trimmed) {
106                        macros.insert(key, value);
107                    }
108                }
109                "include" | "include_lib" => {
110                    if let Some(path) = parse_include_path(body) {
111                        pending.push(path);
112                    }
113                }
114                _ => {}
115            }
116            at_line_start = false;
117            continue;
118        }
119        at_line_start = false;
120        match c {
121            b'"' | b'~' => i += string_span(bytes, i).unwrap_or(1),
122            b'\'' => i += quoted_atom_span(bytes, i).unwrap_or(1),
123            b'$' => i += skip_char_literal_span(bytes, i),
124            _ => i += 1,
125        }
126    }
127}
128
129fn scan_to_terminator(source: &str, start: usize) -> usize {
130    let bytes = source.as_bytes();
131    let mut i = start;
132    let mut depth_paren = 0i32;
133    let mut depth_brack = 0i32;
134    let mut depth_brace = 0i32;
135    while i < bytes.len() {
136        let c = bytes[i];
137        if c.is_ascii_digit() && (i == start || !is_name_byte(bytes[i - 1])) {
138            i += number_span(bytes, i).unwrap_or(1);
139            continue;
140        }
141        match c {
142            b'(' => {
143                depth_paren += 1;
144                i += 1;
145            }
146            b'[' => {
147                depth_brack += 1;
148                i += 1;
149            }
150            b'{' => {
151                depth_brace += 1;
152                i += 1;
153            }
154            b')' => {
155                depth_paren -= 1;
156                i += 1;
157            }
158            b']' => {
159                depth_brack -= 1;
160                i += 1;
161            }
162            b'}' => {
163                depth_brace -= 1;
164                i += 1;
165            }
166            b'.' if depth_paren <= 0
167                && depth_brack <= 0
168                && depth_brace <= 0
169                && dot_terminates(bytes, i) =>
170            {
171                return i;
172            }
173            b'"' | b'~' => i += string_span(bytes, i).unwrap_or(1),
174            b'\'' => i += quoted_atom_span(bytes, i).unwrap_or(1),
175            b'$' => i += skip_char_literal_span(bytes, i),
176            b'%' => {
177                while i < bytes.len() && bytes[i] != b'\n' {
178                    i += 1;
179                }
180            }
181            _ => i += 1,
182        }
183    }
184    i
185}
186
187fn parse_include_path(body: &str) -> Option<PathBuf> {
188    let s = body
189        .trim()
190        .trim_start_matches('(')
191        .trim_end_matches(')')
192        .trim();
193    let s = s.strip_prefix('"')?;
194    let s = s.strip_suffix('"')?;
195    if s.is_empty() {
196        return None;
197    }
198    Some(PathBuf::from(s))
199}
200
201/// Convenience wrapper: a flat map from path → file body, with two
202/// resolution lookups (relative to the source file, then global).
203#[derive(Debug, Default)]
204pub struct FileMap {
205    by_path: BTreeMap<PathBuf, String>,
206    by_basename: BTreeMap<String, Vec<HeaderBody>>,
207    by_include_lib: BTreeMap<String, HeaderBody>,
208}
209
210impl FileMap {
211    pub fn new() -> Self {
212        Self::default()
213    }
214
215    pub fn insert(&mut self, path: PathBuf, body: String) {
216        let base = path.file_name().and_then(|n| n.to_str()).map(str::to_owned);
217        let lib_key = include_lib_key(&path);
218        if let Some(base) = base {
219            self.by_basename.entry(base).or_default().push(HeaderBody {
220                path: path.clone(),
221                body: body.clone(),
222            });
223        }
224        if let Some(key) = lib_key {
225            self.by_include_lib.insert(
226                key,
227                HeaderBody {
228                    path: path.clone(),
229                    body: body.clone(),
230                },
231            );
232        }
233        self.by_path.insert(path, body);
234    }
235
236    pub fn get(&self, path: &Path) -> Option<&str> {
237        self.by_path.get(path).map(String::as_str)
238    }
239}
240
241impl HeaderResolver for FileMap {
242    fn resolve(&self, source_dir: Option<&Path>, raw: &Path) -> Option<HeaderBody> {
243        if let Some(dir) = source_dir {
244            let candidate = dir.join(raw);
245            if let Some(body) = self.by_path.get(&candidate) {
246                return Some(HeaderBody {
247                    path: candidate,
248                    body: body.clone(),
249                });
250            }
251        }
252        if let Some(body) = self.by_path.get(raw) {
253            return Some(HeaderBody {
254                path: raw.to_path_buf(),
255                body: body.clone(),
256            });
257        }
258        let key = raw.to_string_lossy();
259        if let Some(entry) = self.by_include_lib.get(key.as_ref()) {
260            return Some(entry.clone());
261        }
262        let base = raw.file_name().and_then(|n| n.to_str())?;
263        self.by_basename.get(base).and_then(|v| v.first().cloned())
264    }
265}
266
267fn include_lib_key(path: &Path) -> Option<String> {
268    let s = path.to_string_lossy();
269    let parts: Vec<&str> = s.split('/').collect();
270    let include_idx = parts.iter().position(|p| *p == "include")?;
271    if include_idx == 0 {
272        return None;
273    }
274    let app = parts[include_idx - 1];
275    let rest = &parts[include_idx..];
276    let tail = rest.join("/");
277    Some(format!("{app}/{tail}"))
278}