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