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
14use backhopper_erlang_scan::{for_each_top_level_byte, is_bare_atom, remove_line_comments};
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
17pub struct MacroKey {
18    pub name: String,
19    pub arity: Option<u8>,
20}
21
22pub type MacroTable = BTreeMap<MacroKey, String>;
23
24/// Parses the body of a `-define(...)` attribute. Accepts the raw
25/// `(NAME, body)` form and the inner `NAME, body` form that
26/// `handle_attribute`-style callers pass after stripping the parens.
27pub fn parse_define(body: &str) -> Option<(MacroKey, String)> {
28    let s = body.trim();
29    let s = s
30        .strip_prefix('(')
31        .map(|x| x.strip_suffix(')').unwrap_or(x))
32        .unwrap_or(s);
33    let (name_part, value_part) = split_define_head(s)?;
34    // a `%` note between the name and the separator is not part of the name
35    let name_part = remove_line_comments(name_part);
36    let name_part = name_part.as_ref();
37    if let Some((name, params)) = parse_parameterized_head(name_part) {
38        let arity = u8::try_from(params).ok()?;
39        return Some((
40            MacroKey {
41                name,
42                arity: Some(arity),
43            },
44            value_part.trim().to_owned(),
45        ));
46    }
47    let name = name_part.trim().to_owned();
48    if name.is_empty() {
49        return None;
50    }
51    Some((MacroKey { name, arity: None }, value_part.trim().to_owned()))
52}
53
54// the first top-level comma separates the macro head from its body
55fn split_define_head(s: &str) -> Option<(&str, &str)> {
56    let bytes = s.as_bytes();
57    let i = for_each_top_level_byte(s, |i| bytes[i] == b',')?;
58    Some((&s[..i], &s[i + 1..]))
59}
60
61fn parse_parameterized_head(s: &str) -> Option<(String, usize)> {
62    let s = s.trim();
63    let (name, after) = s.split_once('(')?;
64    let after = after.strip_suffix(')')?;
65    let name = name.trim().to_owned();
66    if name.is_empty() {
67        return None;
68    }
69    let trimmed = after.trim();
70    if trimmed.is_empty() {
71        return Some((name, 0));
72    }
73    let params = trimmed.split(',').count();
74    Some((name, params))
75}
76
77/// Resolves a value macro to a single lowercase atom when its body is
78/// exactly that. Returns `None` for parameterized macros and bodies
79/// that parse as something else.
80pub fn expand_value_macro_to_atom(table: &MacroTable, name: &str) -> Option<String> {
81    let body = table.get(&MacroKey {
82        name: name.to_owned(),
83        arity: None,
84    })?;
85    let trimmed = body.trim();
86    if is_bare_atom(trimmed) {
87        Some(trimmed.to_owned())
88    } else {
89        None
90    }
91}
92
93/// Resolves a value macro to a `(module, function)` pair when its body
94/// is `mod:fn`.
95pub fn expand_value_macro_to_mf(table: &MacroTable, name: &str) -> Option<(String, String)> {
96    let body = table.get(&MacroKey {
97        name: name.to_owned(),
98        arity: None,
99    })?;
100    let trimmed = body.trim();
101    let (m, f) = trimmed.split_once(':')?;
102    let m = m.trim();
103    let f = f.trim();
104    if is_bare_atom(m) && is_bare_atom(f) {
105        Some((m.to_owned(), f.to_owned()))
106    } else {
107        None
108    }
109}