use std::collections::{HashMap, HashSet};
use tree_sitter::Node;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct FunctionMacro {
pub params: Vec<String>,
pub body: String,
}
const MAX_EXPAND_DEPTH: usize = 32;
fn is_ident_start(c: char) -> bool {
c.is_ascii_alphabetic() || c == '_'
}
fn is_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
pub fn collect_function_macros(root: &Node, source: &str) -> HashMap<String, FunctionMacro> {
let mut out = HashMap::new();
collect_rec(root, source, &mut out);
for (name, m) in collect_function_macros_textual(source) {
out.entry(name).or_insert(m);
}
out
}
pub fn collect_function_macros_textual(source: &str) -> HashMap<String, FunctionMacro> {
let lines: Vec<&str> = source.lines().collect();
let mut out = HashMap::new();
let mut i = 0;
while i < lines.len() {
let (logical, next) = join_continuation(&lines, i);
i = next;
if let Some((name, m)) = parse_define_line(&logical) {
out.entry(name).or_insert(m);
}
}
out
}
fn join_continuation(lines: &[&str], start: usize) -> (String, usize) {
let mut buf = String::new();
let mut i = start;
while i < lines.len() {
let line = lines[i];
let te = line.trim_end();
if let Some(stripped) = te.strip_suffix('\\') {
buf.push_str(stripped);
buf.push(' ');
i += 1;
} else {
buf.push_str(line);
i += 1;
break;
}
}
(buf, i)
}
fn parse_define_line(line: &str) -> Option<(String, FunctionMacro)> {
let s = line.trim_start();
let s = s.strip_prefix('#')?;
let s = s.trim_start().strip_prefix("define")?;
if !s.starts_with(|c: char| c.is_whitespace()) {
return None;
}
let s = s.trim_start();
let chars: Vec<char> = s.chars().collect();
if chars.is_empty() || !is_ident_start(chars[0]) {
return None;
}
let mut k = 0;
while k < chars.len() && is_ident_char(chars[k]) {
k += 1;
}
let name: String = chars[..k].iter().collect();
if k >= chars.len() || chars[k] != '(' {
return None;
}
let (params, body_start) = parse_param_list(&chars, k)?;
let body_raw: String = chars[body_start..].iter().collect();
let body = strip_comments(&body_raw).trim().to_string();
if body_uses_paste_or_stringize(&body) {
return None;
}
Some((name, FunctionMacro { params, body }))
}
fn parse_param_list(chars: &[char], open: usize) -> Option<(Vec<String>, usize)> {
debug_assert_eq!(chars[open], '(');
let mut params = Vec::new();
let mut cur = String::new();
let mut i = open + 1;
let mut depth = 1i32;
while i < chars.len() {
match chars[i] {
'(' => {
depth += 1;
cur.push('(');
}
')' => {
depth -= 1;
if depth == 0 {
let t = cur.trim();
if !t.is_empty() {
params.push(t.to_string());
}
if params.iter().any(|p| p.contains("...")) {
return None;
}
return Some((params, i + 1));
}
cur.push(')');
}
',' if depth == 1 => {
params.push(cur.trim().to_string());
cur.clear();
}
c => cur.push(c),
}
i += 1;
}
None }
fn strip_comments(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < chars.len() {
if chars[i] == '/' && i + 1 < chars.len() && chars[i + 1] == '*' {
i += 2;
while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') {
i += 1;
}
i += 2;
out.push(' ');
} else if chars[i] == '/' && i + 1 < chars.len() && chars[i + 1] == '/' {
break;
} else {
out.push(chars[i]);
i += 1;
}
}
out
}
fn collect_rec(node: &Node, source: &str, out: &mut HashMap<String, FunctionMacro>) {
for i in 0..node.child_count() {
if let Some(child) = node.child(i) {
match child.kind() {
"preproc_function_def" => {
if let Some((name, m)) = parse_function_def(&child, source) {
out.entry(name).or_insert(m);
}
}
kind if kind.starts_with("preproc_") => collect_rec(&child, source, out),
_ => {}
}
}
}
}
fn parse_function_def(node: &Node, source: &str) -> Option<(String, FunctionMacro)> {
let name = node
.child_by_field_name("name")?
.utf8_text(source.as_bytes())
.ok()?
.to_string();
let params_node = node.child_by_field_name("parameters")?;
let mut params = Vec::new();
for i in 0..params_node.child_count() {
if let Some(p) = params_node.child(i) {
match p.kind() {
"identifier" => params.push(p.utf8_text(source.as_bytes()).ok()?.to_string()),
"..." => return None,
_ => {}
}
}
}
let body = node
.child_by_field_name("value")
.and_then(|v| v.utf8_text(source.as_bytes()).ok())
.unwrap_or("")
.trim()
.to_string();
if body_uses_paste_or_stringize(&body) {
return None;
}
Some((name, FunctionMacro { params, body }))
}
fn body_uses_paste_or_stringize(body: &str) -> bool {
let bytes: Vec<char> = body.chars().collect();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
'"' | '\'' => {
let quote = bytes[i];
i += 1;
while i < bytes.len() && bytes[i] != quote {
if bytes[i] == '\\' {
i += 1;
}
i += 1;
}
i += 1;
}
'#' => return true,
_ => i += 1,
}
}
false
}
pub fn expand_invocation(
table: &HashMap<String, FunctionMacro>,
name: &str,
args: &[String],
) -> Option<String> {
let mut active = HashSet::new();
expand_named(table, name, args, &mut active, 0)
}
fn expand_named(
table: &HashMap<String, FunctionMacro>,
name: &str,
args: &[String],
active: &mut HashSet<String>,
depth: usize,
) -> Option<String> {
if depth >= MAX_EXPAND_DEPTH || active.contains(name) {
return None;
}
let m = table.get(name)?;
if m.params.len() != args.len() {
return None; }
let mut map = HashMap::new();
for (p, a) in m.params.iter().zip(args.iter()) {
map.insert(p.clone(), a.clone());
}
let substituted = substitute_params(&m.body, &map);
active.insert(name.to_string());
let rescanned = rescan(table, &substituted, active, depth + 1);
active.remove(name);
Some(rescanned)
}
fn substitute_params(body: &str, map: &HashMap<String, String>) -> String {
let chars: Vec<char> = body.chars().collect();
let mut out = String::with_capacity(body.len());
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '"' || c == '\'' {
let quote = c;
out.push(c);
i += 1;
while i < chars.len() {
out.push(chars[i]);
if chars[i] == '\\' && i + 1 < chars.len() {
out.push(chars[i + 1]);
i += 2;
continue;
}
if chars[i] == quote {
i += 1;
break;
}
i += 1;
}
} else if is_ident_start(c) {
let start = i;
while i < chars.len() && is_ident_char(chars[i]) {
i += 1;
}
let ident: String = chars[start..i].iter().collect();
if let Some(repl) = map.get(&ident) {
out.push_str(repl);
} else {
out.push_str(&ident);
}
} else {
out.push(c);
i += 1;
}
}
out
}
fn rescan(
table: &HashMap<String, FunctionMacro>,
text: &str,
active: &mut HashSet<String>,
depth: usize,
) -> String {
if depth >= MAX_EXPAND_DEPTH {
return text.to_string();
}
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '"' || c == '\'' {
let quote = c;
out.push(c);
i += 1;
while i < chars.len() {
out.push(chars[i]);
if chars[i] == '\\' && i + 1 < chars.len() {
out.push(chars[i + 1]);
i += 2;
continue;
}
if chars[i] == quote {
i += 1;
break;
}
i += 1;
}
} else if is_ident_start(c) {
let start = i;
while i < chars.len() && is_ident_char(chars[i]) {
i += 1;
}
let ident: String = chars[start..i].iter().collect();
let mut j = i;
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
if table.contains_key(&ident)
&& !active.contains(&ident)
&& j < chars.len()
&& chars[j] == '('
{
if let Some((args, end)) = parse_call_args(&chars, j) {
if let Some(expanded) = expand_named(table, &ident, &args, active, depth) {
out.push_str(&expanded);
i = end;
continue;
}
}
}
out.push_str(&ident);
} else {
out.push(c);
i += 1;
}
}
out
}
fn parse_call_args(chars: &[char], open: usize) -> Option<(Vec<String>, usize)> {
debug_assert_eq!(chars[open], '(');
let mut args = Vec::new();
let mut cur = String::new();
let mut depth = 0i32;
let mut i = open;
while i < chars.len() {
let c = chars[i];
match c {
'"' | '\'' => {
let quote = c;
cur.push(c);
i += 1;
while i < chars.len() {
cur.push(chars[i]);
if chars[i] == '\\' && i + 1 < chars.len() {
cur.push(chars[i + 1]);
i += 2;
continue;
}
if chars[i] == quote {
i += 1;
break;
}
i += 1;
}
}
'(' | '[' | '{' => {
depth += 1;
if depth > 1 {
cur.push(c);
}
i += 1;
}
')' | ']' | '}' => {
depth -= 1;
if depth == 0 {
let trimmed = cur.trim();
if !(args.is_empty() && trimmed.is_empty()) {
args.push(trimmed.to_string());
}
return Some((args, i + 1));
}
cur.push(c);
i += 1;
}
',' if depth == 1 => {
args.push(cur.trim().to_string());
cur.clear();
i += 1;
}
_ => {
cur.push(c);
i += 1;
}
}
}
None }
pub fn macro_output_param_indices(
table: &HashMap<String, FunctionMacro>,
name: &str,
) -> Vec<usize> {
let m = match table.get(name) {
Some(m) => m,
None => return Vec::new(),
};
if m.params.is_empty() {
return Vec::new();
}
let sentinels: Vec<String> = (0..m.params.len())
.map(|i| format!("__SQC_MOUT_{i}__"))
.collect();
let expanded = match expand_invocation(table, name, &sentinels) {
Some(e) => e,
None => return Vec::new(),
};
let mut out = Vec::new();
for (i, sent) in sentinels.iter().enumerate() {
if is_whole_assignment_target(&expanded, sent) {
out.push(i);
}
}
out
}
pub fn macro_nulls_param_indices(table: &HashMap<String, FunctionMacro>, name: &str) -> Vec<usize> {
let m = match table.get(name) {
Some(m) => m,
None => return Vec::new(),
};
if m.params.is_empty() {
return Vec::new();
}
let sentinels: Vec<String> = (0..m.params.len())
.map(|i| format!("__SQC_MNULL_{i}__"))
.collect();
let expanded = match expand_invocation(table, name, &sentinels) {
Some(e) => e,
None => return Vec::new(),
};
let mut out = Vec::new();
for (i, sent) in sentinels.iter().enumerate() {
if is_null_assignment_target(&expanded, sent) {
out.push(i);
}
}
out
}
fn rhs_is_null_constant(chars: &[char], start: usize) -> bool {
let n = chars.len();
let mut j = start;
while j < n && chars[j].is_whitespace() {
j += 1;
}
let null_kw = ['N', 'U', 'L', 'L'];
if j + 4 <= n && chars[j..j + 4] == null_kw && (j + 4 >= n || !is_ident_char(chars[j + 4])) {
return true;
}
if j < n && chars[j] == '0' {
let after = if j + 1 < n { chars[j + 1] } else { ' ' };
if !after.is_ascii_digit() && after != '.' && after != 'x' && after != 'X' {
return true;
}
}
false
}
fn is_whole_assignment_target(text: &str, ident: &str) -> bool {
find_assignment_targets(text, ident, |_| true)
}
fn is_null_assignment_target(text: &str, ident: &str) -> bool {
let chars: Vec<char> = text.chars().collect();
find_assignment_targets(text, ident, |rhs_start| {
rhs_is_null_constant(&chars, rhs_start)
})
}
fn find_assignment_targets(text: &str, ident: &str, rhs_ok: impl Fn(usize) -> bool) -> bool {
let chars: Vec<char> = text.chars().collect();
let id: Vec<char> = ident.chars().collect();
let (n, m) = (chars.len(), id.len());
if m == 0 {
return false;
}
let mut i = 0;
while i + m <= n {
if chars[i..i + m] == id[..] {
let prev_ok = i == 0 || !is_ident_char(chars[i - 1]);
let next_ok = i + m >= n || !is_ident_char(chars[i + m]);
let mut b = i;
while b > 0 && (chars[b - 1].is_whitespace() || chars[b - 1] == '(') {
b -= 1;
}
let prev_c = if b > 0 { chars[b - 1] } else { ' ' };
let arrow = b >= 2 && chars[b - 1] == '>' && chars[b - 2] == '-';
if prev_ok && next_ok && prev_c != '.' && prev_c != '*' && !arrow {
let mut j = i + m;
while j < n && (chars[j].is_whitespace() || chars[j] == ')') {
j += 1;
}
if j < n && chars[j] == '=' && (j + 1 >= n || chars[j + 1] != '=') && rhs_ok(j + 1)
{
return true;
}
}
}
i += 1;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::CParser;
fn table(src: &str) -> HashMap<String, FunctionMacro> {
let mut p = CParser::new().unwrap();
let tree = p.parse_source(src).unwrap();
collect_function_macros(&tree.root_node(), src)
}
#[test]
fn collects_simple_function_macro() {
let t = table("#define MIN(x,y) (((x) < (y)) ? (x) : (y))\n");
let m = t.get("MIN").expect("MIN collected");
assert_eq!(m.params, vec!["x", "y"]);
assert!(m.body.contains("(x) < (y)"));
}
#[test]
fn skips_stringize_and_paste() {
let t = table("#define STR(x) #x\n#define CAT(a,b) a##b\n#define OK(a) ((a)+1)\n");
assert!(!t.contains_key("STR"));
assert!(!t.contains_key("CAT"));
assert!(t.contains_key("OK"));
}
#[test]
fn skips_variadic() {
let t = table("#define LOG(fmt, ...) printf(fmt, __VA_ARGS__)\n");
assert!(!t.contains_key("LOG"));
}
#[test]
fn expands_simple() {
let t = table("#define MIN(x,y) (((x) < (y)) ? (x) : (y))\n");
let out = expand_invocation(&t, "MIN", &["a".into(), "b+1".into()]).unwrap();
assert_eq!(out, "(((a) < (b+1)) ? (a) : (b+1))");
}
#[test]
fn expands_deref_macro() {
let t = table("#define ORIGVFS(p) ((sqlite3_vfs*)((p)->pAppData))\n");
let out = expand_invocation(&t, "ORIGVFS", &["pFile".into()]).unwrap();
assert_eq!(out, "((sqlite3_vfs*)((pFile)->pAppData))");
}
#[test]
fn arity_mismatch_returns_none() {
let t = table("#define MIN(x,y) ((x)<(y)?(x):(y))\n");
assert!(expand_invocation(&t, "MIN", &["a".into()]).is_none());
}
#[test]
fn does_not_substitute_inside_string() {
let t = table("#define TAG(x) \"x is here\" x\n");
let out = expand_invocation(&t, "TAG", &["v".into()]).unwrap();
assert_eq!(out, "\"x is here\" v");
}
#[test]
fn recursive_rescan_nested_macro() {
let t = table("#define SQUARE(z) ((z)*(z))\n#define DIST(a) SQUARE(a)\n");
let out = expand_invocation(&t, "DIST", &["n+1".into()]).unwrap();
assert_eq!(out, "((n+1)*(n+1))");
}
#[test]
fn self_reference_does_not_loop() {
let t = table("#define A(x) A((x)+1)\n");
let out = expand_invocation(&t, "A", &["v".into()]).unwrap();
assert_eq!(out, "A((v)+1)");
}
#[test]
fn nested_call_args_with_commas() {
let t = table("#define ADD(a,b) ((a)+(b))\n#define ID(x) (x)\n");
let out = expand_invocation(&t, "ID", &["ADD(1,2)".into()]).unwrap();
assert_eq!(out, "(((1)+(2)))");
}
#[test]
fn textual_collects_function_like() {
let t = collect_function_macros_textual(
"#define curlx_free(ptr) curl_dbg_free(ptr, __LINE__, __FILE__)\n",
);
let m = t.get("curlx_free").expect("curlx_free collected");
assert_eq!(m.params, vec!["ptr"]);
assert_eq!(m.body, "curl_dbg_free(ptr, __LINE__, __FILE__)");
}
#[test]
fn textual_skips_object_like() {
let t = collect_function_macros_textual("#define curlx_free Curl_cfree\n");
assert!(!t.contains_key("curlx_free"));
let t2 = collect_function_macros_textual("#define PAREN (1 + 2)\n");
assert!(!t2.contains_key("PAREN"));
}
#[test]
fn textual_skips_variadic_and_paste() {
let t = collect_function_macros_textual(
"#define LOG(fmt, ...) printf(fmt, __VA_ARGS__)\n#define CAT(a,b) a##b\n",
);
assert!(!t.contains_key("LOG"));
assert!(!t.contains_key("CAT"));
}
#[test]
fn textual_joins_continuation() {
let t = collect_function_macros_textual(
"#define curlx_calloc(nbelem, size) \\\n curl_dbg_calloc(nbelem, size, __LINE__, __FILE__)\n",
);
let m = t
.get("curlx_calloc")
.expect("collected across continuation");
assert_eq!(m.params, vec!["nbelem", "size"]);
assert!(m.body.contains("curl_dbg_calloc(nbelem, size"));
}
#[test]
fn textual_strips_comments() {
let t = collect_function_macros_textual("#define WRAP(x) real(x) /* trailing */\n");
assert_eq!(t.get("WRAP").unwrap().body, "real(x)");
}
#[test]
fn textual_indented_define_with_space_after_hash() {
let t = collect_function_macros_textual(" # define INDENT(x) ((x)+1)\n");
assert!(t.contains_key("INDENT"));
}
#[test]
fn does_not_match_defined_operator() {
let t = collect_function_macros_textual("#if defined(FOO)\n#endif\n");
assert!(t.is_empty());
}
#[test]
fn output_param_simple_assignment() {
let t = table("#define SAVE(out, a, b) do { (out) = (a) + (b); } while(0)\n");
assert_eq!(macro_output_param_indices(&t, "SAVE"), vec![0]);
}
#[test]
fn output_param_cf_data_save_shape() {
let t = table(
"#define CF_CTX_CALL_DATA(cf) ((cf)->ctx->call_data)\n\
#define CF_DATA_SAVE(save, cf, data) do { (save) = CF_CTX_CALL_DATA(cf); CF_CTX_CALL_DATA(cf).data = (data); } while(0)\n",
);
assert_eq!(macro_output_param_indices(&t, "CF_DATA_SAVE"), vec![0]);
}
#[test]
fn output_param_excludes_field_and_deref_writes() {
let t = table(
"#define FW(p) do { (p)->f = 1; } while(0)\n\
#define EW(p) do { (p)[0] = 1; } while(0)\n\
#define DW(p) do { *(p) = 1; } while(0)\n",
);
assert!(macro_output_param_indices(&t, "FW").is_empty());
assert!(macro_output_param_indices(&t, "EW").is_empty());
assert!(macro_output_param_indices(&t, "DW").is_empty());
}
#[test]
fn output_param_excludes_compound_and_comparison() {
let t = table(
"#define ADDEQ(x, y) do { (x) += (y); } while(0)\n\
#define CMP(x, y) ((x) == (y))\n",
);
assert!(macro_output_param_indices(&t, "ADDEQ").is_empty());
assert!(macro_output_param_indices(&t, "CMP").is_empty());
}
#[test]
fn output_param_multiple_outputs() {
let t = table("#define BOTH(a, b, c) do { a = 1; b = 2; (void)c; } while(0)\n");
assert_eq!(macro_output_param_indices(&t, "BOTH"), vec![0, 1]);
}
#[test]
fn output_param_unknown_macro_is_empty() {
let t = table("#define X(a) (a)\n");
assert!(macro_output_param_indices(&t, "NOPE").is_empty());
}
#[test]
fn nulls_param_curl_safefree_shape() {
let t = table(
"#define curlx_free(p) free(p)\n\
#define Curl_safefree(ptr) do { curlx_free(ptr); (ptr) = NULL; } while(0)\n",
);
assert_eq!(macro_nulls_param_indices(&t, "Curl_safefree"), vec![0]);
}
#[test]
fn nulls_param_zero_literal() {
let t = table("#define SAFE_FREE(x) do { free(x); (x) = 0; } while(0)\n");
assert_eq!(macro_nulls_param_indices(&t, "SAFE_FREE"), vec![0]);
}
#[test]
fn nulls_param_excludes_plain_free_no_null() {
let t = table("#define just_free(p) free(p)\n");
assert!(macro_nulls_param_indices(&t, "just_free").is_empty());
}
#[test]
fn nulls_param_excludes_nonzero_and_field_assign() {
let t = table(
"#define SETONE(x) do { (x) = 1; } while(0)\n\
#define CLEARF(p) do { (p)->next = NULL; } while(0)\n",
);
assert!(macro_nulls_param_indices(&t, "SETONE").is_empty());
assert!(macro_nulls_param_indices(&t, "CLEARF").is_empty());
}
#[test]
fn nulls_param_only_nulled_arg() {
let t = table("#define FN(a, b) do { free(a); (b) = NULL; } while(0)\n");
assert_eq!(macro_nulls_param_indices(&t, "FN"), vec![1]);
}
#[test]
fn merge_recovers_macro_in_error_region() {
let src = "#define BROKEN(a) a +++ ++ +\n\
int f(void) { return 1 } }\n\
#define recovered_free(ptr) free(ptr)\n";
let mut p = CParser::new().unwrap();
let tree = p.parse_source(src).unwrap();
let ast_only = {
let mut out = HashMap::new();
collect_rec(&tree.root_node(), src, &mut out);
out
};
let merged = collect_function_macros(&tree.root_node(), src);
assert!(
merged.contains_key("recovered_free"),
"textual pass should recover recovered_free; ast_only={:?}",
ast_only.keys().collect::<Vec<_>>()
);
assert_eq!(merged["recovered_free"].body, "free(ptr)");
}
}