Skip to main content

backhopper_core/
erlang_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//! `-define` table and macro-body expansion helpers.
6//!
7//! Macros are keyed by `(name, optional arity)`: value macros use
8//! `arity = None`, parameterized macros use `arity = Some(n)`. Bodies
9//! are kept as raw source text and re-scanned at use sites by callers
10//! that want to find calls hidden behind macros.
11
12use std::collections::BTreeMap;
13
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
15pub struct MacroKey {
16    pub name: String,
17    pub arity: Option<u8>,
18}
19
20pub type MacroTable = BTreeMap<MacroKey, String>;
21
22/// Parses the body of a `-define(...)` attribute. Accepts the raw
23/// `(NAME, body)` form and the inner `NAME, body` form that
24/// `handle_attribute`-style callers pass after stripping the parens.
25pub fn parse_define(body: &str) -> Option<(MacroKey, String)> {
26    let s = body.trim();
27    let s = s
28        .strip_prefix('(')
29        .map(|x| x.strip_suffix(')').unwrap_or(x))
30        .unwrap_or(s);
31    let (name_part, value_part) = split_define_head(s)?;
32    if let Some((name, params)) = parse_parameterized_head(name_part) {
33        let arity = u8::try_from(params).ok()?;
34        return Some((
35            MacroKey {
36                name,
37                arity: Some(arity),
38            },
39            value_part.trim().to_owned(),
40        ));
41    }
42    let name = name_part.trim().to_owned();
43    if name.is_empty() {
44        return None;
45    }
46    Some((MacroKey { name, arity: None }, value_part.trim().to_owned()))
47}
48
49fn split_define_head(s: &str) -> Option<(&str, &str)> {
50    let bytes = s.as_bytes();
51    let mut depth = 0i32;
52    let mut in_str = false;
53    let mut in_atom = false;
54    let mut i = 0usize;
55    while i < bytes.len() {
56        let c = bytes[i];
57        if c == b'\\' && (in_str || in_atom) {
58            i = (i + 2).min(bytes.len());
59            continue;
60        }
61        match c {
62            b'"' if !in_atom => in_str = !in_str,
63            b'\'' if !in_str => in_atom = !in_atom,
64            b'(' | b'[' | b'{' if !in_str && !in_atom => depth += 1,
65            b')' | b']' | b'}' if !in_str && !in_atom => depth -= 1,
66            b',' if depth == 0 && !in_str && !in_atom => {
67                return Some((&s[..i], &s[i + 1..]));
68            }
69            _ => {}
70        }
71        i += 1;
72    }
73    None
74}
75
76fn parse_parameterized_head(s: &str) -> Option<(String, usize)> {
77    let s = s.trim();
78    let (name, after) = s.split_once('(')?;
79    let after = after.strip_suffix(')')?;
80    let name = name.trim().to_owned();
81    if name.is_empty() {
82        return None;
83    }
84    let trimmed = after.trim();
85    if trimmed.is_empty() {
86        return Some((name, 0));
87    }
88    let params = trimmed.split(',').count();
89    Some((name, params))
90}
91
92/// Resolves a value macro to a single lowercase atom when its body is
93/// exactly that. Returns `None` for parameterized macros and bodies
94/// that parse as something else.
95pub fn expand_value_macro_to_atom(table: &MacroTable, name: &str) -> Option<String> {
96    let body = table.get(&MacroKey {
97        name: name.to_owned(),
98        arity: None,
99    })?;
100    let trimmed = body.trim();
101    if looks_like_lowercase_atom(trimmed) {
102        Some(trimmed.to_owned())
103    } else {
104        None
105    }
106}
107
108/// Resolves a value macro to a `(module, function)` pair when its body
109/// is `mod:fn`.
110pub fn expand_value_macro_to_mf(table: &MacroTable, name: &str) -> Option<(String, String)> {
111    let body = table.get(&MacroKey {
112        name: name.to_owned(),
113        arity: None,
114    })?;
115    let trimmed = body.trim();
116    let (m, f) = trimmed.split_once(':')?;
117    let m = m.trim();
118    let f = f.trim();
119    if looks_like_lowercase_atom(m) && looks_like_lowercase_atom(f) {
120        Some((m.to_owned(), f.to_owned()))
121    } else {
122        None
123    }
124}
125
126fn looks_like_lowercase_atom(s: &str) -> bool {
127    let mut chars = s.chars();
128    match chars.next() {
129        Some(c) if c.is_ascii_lowercase() => {}
130        _ => return false,
131    }
132    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '@')
133}