use std::path::{Path, PathBuf};
use oxc::ast::ast::{
BindingPattern, Expression, ImportDeclarationSpecifier, Program, Statement, TemplateLiteral,
VariableDeclarationKind,
};
use oxc::span::GetSpan;
use crate::ts_syn::abi::{Diagnostic, DiagnosticLevel, SpanIR};
use crate::ts_syn::declarative::{
DeclarativeError, MacroArm, MacroDef, MacroKind, MacroMode, parse_macro_def,
};
use crate::ts_syn::import_registry::collect_macro_import_comments_pub;
use super::project_registry::ProjectDeclarativeRegistry;
#[derive(Debug, Clone)]
pub struct DiscoveredMacro {
pub def: MacroDef,
pub def_span: SpanIR,
pub scope_span: SpanIR,
}
pub const RULES_MODULE: &str = "macroforge/rules";
pub const MACRO_RULES_IDENT: &str = "macroRules";
pub fn discover(
program: &Program<'_>,
source: &str,
) -> Result<Vec<DiscoveredMacro>, DeclarativeError> {
if !has_macro_import(program) {
return Ok(Vec::new());
}
let file_scope = SpanIR::new(1, program.span.end + 1);
let mut visitor = DiscoveryVisitor {
source,
scope_stack: vec![file_scope],
discovered: Vec::new(),
error: None,
};
oxc::ast_visit::Visit::visit_program(&mut visitor, program);
if let Some(err) = visitor.error {
return Err(err);
}
Ok(visitor.discovered)
}
struct DiscoveryVisitor<'s> {
source: &'s str,
scope_stack: Vec<SpanIR>,
discovered: Vec<DiscoveredMacro>,
error: Option<DeclarativeError>,
}
impl<'s> DiscoveryVisitor<'s> {
fn current_scope(&self) -> SpanIR {
*self
.scope_stack
.last()
.expect("scope stack must never be empty")
}
fn try_collect_decl(
&mut self,
var_decl: &oxc::ast::ast::VariableDeclaration<'_>,
stmt_span: oxc::span::Span,
) {
if self.error.is_some() {
return;
}
if var_decl.kind != VariableDeclarationKind::Const {
return;
}
if var_decl.declarations.len() != 1 {
return;
}
let declarator = &var_decl.declarations[0];
let BindingPattern::BindingIdentifier(binding) = &declarator.id else {
return;
};
let binding_name = binding.name.as_str();
if !binding_name.starts_with('$') {
return;
}
let Some(init) = &declarator.init else {
return;
};
let decl_span = oxc_span_to_ir(stmt_span);
let def_span = extend_to_semicolon(decl_span, self.source);
let def_result = match init {
Expression::TaggedTemplateExpression(tagged) => {
let Expression::Identifier(tag_ident) = &tagged.tag else {
return;
};
if tag_ident.name.as_str() != MACRO_RULES_IDENT {
return;
}
parse_tag_form_macro(tagged, self.source)
}
Expression::CallExpression(call) => {
let Expression::Identifier(callee) = &call.callee else {
return;
};
if callee.name.as_str() != MACRO_RULES_IDENT {
return;
}
parse_object_form_macro(call, self.source)
}
_ => return,
};
let mut def = match def_result {
Ok(def) => def,
Err(e) => {
self.error = Some(e);
return;
}
};
def.name = binding_name[1..].to_string();
self.discovered.push(DiscoveredMacro {
def,
def_span,
scope_span: self.current_scope(),
});
}
}
impl<'a> oxc::ast_visit::Visit<'a> for DiscoveryVisitor<'_> {
fn visit_variable_declaration(&mut self, decl: &oxc::ast::ast::VariableDeclaration<'a>) {
self.try_collect_decl(decl, decl.span);
}
fn visit_export_named_declaration(&mut self, decl: &oxc::ast::ast::ExportNamedDeclaration<'a>) {
if let Some(oxc::ast::ast::Declaration::VariableDeclaration(var_decl)) = &decl.declaration {
self.try_collect_decl(var_decl, decl.span);
return;
}
oxc::ast_visit::walk::walk_export_named_declaration(self, decl);
}
fn visit_block_statement(&mut self, block: &oxc::ast::ast::BlockStatement<'a>) {
self.scope_stack.push(oxc_span_to_ir(block.span));
oxc::ast_visit::walk::walk_block_statement(self, block);
self.scope_stack.pop();
}
fn visit_function_body(&mut self, body: &oxc::ast::ast::FunctionBody<'a>) {
self.scope_stack.push(oxc_span_to_ir(body.span));
oxc::ast_visit::walk::walk_function_body(self, body);
self.scope_stack.pop();
}
fn visit_static_block(&mut self, block: &oxc::ast::ast::StaticBlock<'a>) {
self.scope_stack.push(oxc_span_to_ir(block.span));
oxc::ast_visit::walk::walk_static_block(self, block);
self.scope_stack.pop();
}
}
fn parse_tag_form_macro(
tagged: &oxc::ast::ast::TaggedTemplateExpression<'_>,
source: &str,
) -> Result<MacroDef, DeclarativeError> {
let quasi_text = extract_static_quasi(&tagged.quasi, source)?;
let quasi_span_raw = oxc_span_to_ir(tagged.quasi.span);
let inner_start = quasi_span_raw.start.saturating_add(1);
let inner_end = quasi_span_raw.end.saturating_sub(1);
let template_span = SpanIR::new(inner_start.min(inner_end), inner_end);
parse_macro_def(quasi_text, template_span)
}
fn parse_object_form_macro(
call: &oxc::ast::ast::CallExpression<'_>,
source: &str,
) -> Result<MacroDef, DeclarativeError> {
if call.arguments.len() != 1 {
return Err(DeclarativeError::new(
oxc_span_to_ir(call.span),
"macroRules(...) expects exactly one object argument",
));
}
let Some(arg_expr) = call.arguments[0].as_expression() else {
return Err(DeclarativeError::new(
oxc_span_to_ir(call.span),
"macroRules(...) argument must be an object expression",
));
};
let Expression::ObjectExpression(obj) = arg_expr else {
return Err(DeclarativeError::new(
oxc_span_to_ir(arg_expr.span()),
"macroRules(...) argument must be an object literal",
));
};
let mut mode: Option<MacroMode> = None;
let mut kind: Option<MacroKind> = None;
let mut expand_arms: Option<Vec<MacroArm>> = None;
let mut expand_span: SpanIR = oxc_span_to_ir(obj.span);
let mut runtime: Option<String> = None;
let mut call_arms: Option<Vec<MacroArm>> = None;
let mut megamorphism_threshold: Option<u8> = None;
let mut runtime_name_template: Option<String> = None;
for prop in &obj.properties {
use oxc::ast::ast::{ObjectPropertyKind, PropertyKey};
let ObjectPropertyKind::ObjectProperty(p) = prop else {
return Err(DeclarativeError::new(
oxc_span_to_ir(prop.span()),
"spread properties are not allowed in the macroRules object form",
));
};
let key_name = match &p.key {
PropertyKey::StaticIdentifier(id) => id.name.as_str().to_string(),
PropertyKey::StringLiteral(s) => s.value.as_str().to_string(),
_ => {
return Err(DeclarativeError::new(
oxc_span_to_ir(p.key.span()),
"macroRules object form only supports static string / identifier keys",
));
}
};
match key_name.as_str() {
"mode" => {
let Expression::StringLiteral(lit) = &p.value else {
return Err(DeclarativeError::new(
oxc_span_to_ir(p.value.span()),
format!(
"`mode` must be a string literal; expected one of {}",
MacroMode::known_values()
),
));
};
let parsed = MacroMode::from_str_value(lit.value.as_str()).ok_or_else(|| {
DeclarativeError::new(
oxc_span_to_ir(lit.span),
format!(
"unknown mode `{}`; expected one of {}",
lit.value,
MacroMode::known_values()
),
)
})?;
mode = Some(parsed);
}
"expand" => {
let Expression::TaggedTemplateExpression(tagged) = &p.value else {
return Err(DeclarativeError::new(
oxc_span_to_ir(p.value.span()),
"`expand` must be a `macroRules`...`` tag-form template",
));
};
let Expression::Identifier(tag_ident) = &tagged.tag else {
return Err(DeclarativeError::new(
oxc_span_to_ir(tagged.tag.span()),
"`expand` tag must be `macroRules`",
));
};
if tag_ident.name.as_str() != MACRO_RULES_IDENT {
return Err(DeclarativeError::new(
oxc_span_to_ir(tagged.tag.span()),
"`expand` tag must be `macroRules`",
));
}
let nested = parse_tag_form_macro(tagged, source)?;
expand_arms = Some(nested.arms);
expand_span = nested.span;
}
"runtime" => {
let text = extract_plain_string(&p.value)?;
runtime = Some(text);
}
"call" => {
let Expression::TaggedTemplateExpression(tagged) = &p.value else {
return Err(DeclarativeError::new(
oxc_span_to_ir(p.value.span()),
"`call` must be a `macroRules`...`` tag-form template",
));
};
let Expression::Identifier(tag_ident) = &tagged.tag else {
return Err(DeclarativeError::new(
oxc_span_to_ir(tagged.tag.span()),
"`call` tag must be `macroRules`",
));
};
if tag_ident.name.as_str() != MACRO_RULES_IDENT {
return Err(DeclarativeError::new(
oxc_span_to_ir(tagged.tag.span()),
"`call` tag must be `macroRules`",
));
}
let nested = parse_tag_form_macro(tagged, source)?;
call_arms = Some(nested.arms);
}
"kind" => {
let Expression::StringLiteral(lit) = &p.value else {
return Err(DeclarativeError::new(
oxc_span_to_ir(p.value.span()),
"`kind` must be a string literal; expected `\"value\"` or `\"type\"`",
));
};
kind = Some(match lit.value.as_str() {
"value" => MacroKind::Value,
"type" => MacroKind::Type,
other => {
return Err(DeclarativeError::new(
oxc_span_to_ir(lit.span),
format!(
"unknown kind `{}`; expected `\"value\"` or `\"type\"`",
other
),
));
}
});
}
"megamorphismThreshold" | "megamorphism_threshold" => {
let Expression::NumericLiteral(n) = &p.value else {
return Err(DeclarativeError::new(
oxc_span_to_ir(p.value.span()),
"`megamorphismThreshold` must be a numeric literal",
));
};
let v = n.value as i64;
if !(0..=255).contains(&v) {
return Err(DeclarativeError::new(
oxc_span_to_ir(n.span),
"`megamorphismThreshold` must fit in a u8 (0..=255)",
));
}
megamorphism_threshold = Some(v as u8);
}
"runtimeName" | "runtime_name" => {
let text = extract_plain_string(&p.value)?;
if !text.contains("$__cluster__") {
return Err(DeclarativeError::new(
oxc_span_to_ir(p.value.span()),
"`runtimeName` must contain the literal token `$__cluster__` exactly once; the rewriter substitutes it with the cluster id per call site",
)
.with_help(
"example: `runtimeName: \"__serialize_$__cluster__\"` — the `$__cluster__` placeholder is replaced with the cluster id (e.g. `a`, `struct_User_Person`) per cluster variant.",
));
}
runtime_name_template = Some(text);
}
other => {
return Err(DeclarativeError::new(
oxc_span_to_ir(p.key.span()),
format!(
"unknown macroRules option `{}`; expected one of: mode, kind, expand, runtime, call, megamorphismThreshold, runtimeName",
other
),
));
}
}
}
let effective_kind = kind.unwrap_or(MacroKind::Value);
let effective_mode = match mode {
Some(m) => m,
None => {
if runtime.is_some() && call_arms.is_some() {
MacroMode::Auto
} else {
MacroMode::ExpandOnly
}
}
};
let Some(expand_arms) = expand_arms else {
return Err(DeclarativeError::new(
oxc_span_to_ir(obj.span),
"macroRules object form requires an `expand` template",
)
.with_help(
"add `expand: macroRules`(args) => body`` to the object passed to `macroRules({...})`",
));
};
if effective_mode.is_sharing() && (runtime.is_none() || call_arms.is_none()) {
return Err(DeclarativeError::new(
oxc_span_to_ir(obj.span),
format!(
"mode `{:?}` requires both `runtime` and `call` to be set",
effective_mode
),
)
.with_help(
"sharing-mode macros need a `runtime` string (the shared helper body) AND a `call` template (how each call site invokes the helper). Add both to the `macroRules({...})` object, or switch to `mode: \"expand-only\"` if inline expansion is what you want.",
));
}
if effective_kind == MacroKind::Type {
if effective_mode.is_sharing() {
return Err(DeclarativeError::new(
oxc_span_to_ir(obj.span),
"type-position macros cannot use sharing modes (share-only / share-anyway / auto); type expansions have no runtime",
)
.with_help(
"type-position macros expand into TypeScript types, which don't exist at runtime, so there's nothing to share. Remove `mode` (defaulting to `expand-only`) or drop `kind: \"type\"` if you meant a value-position macro.",
));
}
if runtime.is_some() || call_arms.is_some() {
return Err(DeclarativeError::new(
oxc_span_to_ir(obj.span),
"type-position macros cannot declare `runtime` or `call` — those fields only apply in value position",
)
.with_help(
"remove the `runtime` and `call` fields, or drop `kind: \"type\"` if you're writing a value-position macro.",
));
}
}
let mut def = MacroDef::from_arms(String::new(), expand_arms, effective_mode, expand_span);
def.kind = effective_kind;
def.runtime = runtime;
def.call_arms = call_arms;
if let Some(t) = megamorphism_threshold {
def.megamorphism_threshold = t;
}
def.runtime_name_template = runtime_name_template;
Ok(def)
}
fn extract_plain_string(expr: &Expression<'_>) -> Result<String, DeclarativeError> {
match expr {
Expression::StringLiteral(s) => Ok(s.value.as_str().to_string()),
Expression::TemplateLiteral(tpl) => {
if !tpl.expressions.is_empty() {
return Err(DeclarativeError::new(
oxc_span_to_ir(tpl.expressions[0].span()),
"`runtime` template literals may not contain `${...}` interpolations",
));
}
if tpl.quasis.len() != 1 {
return Err(DeclarativeError::new(
oxc_span_to_ir(tpl.span),
"malformed template literal in `runtime`",
));
}
Ok(tpl.quasis[0].value.raw.as_str().to_string())
}
_ => Err(DeclarativeError::new(
oxc_span_to_ir(expr.span()),
"`runtime` must be a string literal or a template literal without interpolations",
)),
}
}
pub fn collect_dollar_imports(program: &Program<'_>) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
for stmt in &program.body {
let Statement::ImportDeclaration(import) = stmt else {
continue;
};
let module = import.source.value.as_str();
let Some(specifiers) = &import.specifiers else {
continue;
};
for spec in specifiers {
if let ImportDeclarationSpecifier::ImportSpecifier(named) = spec {
let local = named.local.name.as_str();
if local.starts_with('$') {
out.insert(local.to_string(), module.to_string());
}
}
}
}
out
}
fn has_macro_import(program: &Program<'_>) -> bool {
for stmt in &program.body {
let Statement::ImportDeclaration(import) = stmt else {
continue;
};
if import.source.value.as_str() != RULES_MODULE {
continue;
}
let Some(specifiers) = &import.specifiers else {
continue;
};
for spec in specifiers {
match spec {
ImportDeclarationSpecifier::ImportSpecifier(named) => {
if named.local.name.as_str() == MACRO_RULES_IDENT {
return true;
}
}
ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => {
if default.local.name.as_str() == MACRO_RULES_IDENT {
return true;
}
}
ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => {
continue;
}
}
}
}
false
}
pub(super) fn find_macro_rules_import_span(program: &Program<'_>) -> Option<SpanIR> {
for stmt in &program.body {
let Statement::ImportDeclaration(import) = stmt else {
continue;
};
if import.source.value.as_str() != RULES_MODULE {
continue;
}
let Some(specifiers) = &import.specifiers else {
continue;
};
if specifiers.len() != 1 {
continue;
}
let spec = &specifiers[0];
let is_macro_rules = match spec {
ImportDeclarationSpecifier::ImportSpecifier(named) => {
named.local.name.as_str() == MACRO_RULES_IDENT && named.import_kind.is_value()
}
ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => {
default.local.name.as_str() == MACRO_RULES_IDENT
}
ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => false,
};
if is_macro_rules {
return Some(oxc_span_to_ir(import.span));
}
}
None
}
fn extract_static_quasi<'a>(
quasi: &TemplateLiteral<'a>,
source: &'a str,
) -> Result<&'a str, DeclarativeError> {
if !quasi.expressions.is_empty() {
let first_interp = quasi.expressions[0].span();
return Err(DeclarativeError::new(
oxc_span_to_ir(first_interp),
"macro template body cannot contain `${...}` interpolations",
));
}
if quasi.quasis.len() != 1 {
return Err(DeclarativeError::new(
oxc_span_to_ir(quasi.span),
"malformed macro template literal",
));
}
let span = quasi.span;
let start = span.start as usize;
let end = span.end as usize;
if end <= start + 2 {
return Ok("");
}
Ok(&source[start + 1..end - 1])
}
fn oxc_span_to_ir(span: oxc::span::Span) -> SpanIR {
SpanIR::new(span.start + 1, span.end + 1)
}
fn extend_to_semicolon(span: SpanIR, source: &str) -> SpanIR {
let mut idx = (span.end as usize).saturating_sub(1);
let bytes = source.as_bytes();
while idx < bytes.len() && matches!(bytes[idx], b' ' | b'\t') {
idx += 1;
}
if idx < bytes.len() && bytes[idx] == b';' {
SpanIR::new(span.start, (idx as u32) + 2)
} else {
span
}
}
#[derive(Debug, Clone)]
pub struct ImportedMacro {
pub def: MacroDef,
pub source_file: PathBuf,
}
#[derive(Debug, Default)]
pub struct ResolvedImports {
pub imported: Vec<ImportedMacro>,
pub diagnostics: Vec<Diagnostic>,
}
pub fn resolve_cross_file_imports(
source: &str,
importer_path: &Path,
project_registry: &ProjectDeclarativeRegistry,
) -> ResolvedImports {
let mut out = ResolvedImports::default();
let imports = collect_macro_import_comments_pub(source);
for (name, module) in imports {
if !name.starts_with('$') {
continue;
}
let Some(resolved_path) = project_registry.resolve_specifier(importer_path, &module) else {
if module.starts_with("./") || module.starts_with("../") {
out.diagnostics.push(Diagnostic {
level: DiagnosticLevel::Error,
message: format!(
"cannot resolve declarative macro module `{}` imported as `{}`",
module, name
),
span: None,
notes: vec![],
help: Some(format!(
"verify the file exists relative to `{}`",
importer_path.display()
)),
});
}
continue;
};
let bare_name = &name[1..];
let Some(file_macros) = project_registry.file_macros(&resolved_path) else {
out.diagnostics.push(Diagnostic {
level: DiagnosticLevel::Error,
message: format!(
"declarative macro `{}` imported from `{}` but no macros found in that file",
name, module
),
span: None,
notes: vec![],
help: None,
});
continue;
};
let Some(def) = file_macros.get(bare_name) else {
out.diagnostics.push(Diagnostic {
level: DiagnosticLevel::Error,
message: format!("declarative macro `{}` not defined in `{}`", name, module),
span: None,
notes: vec![],
help: Some(format!(
"`{}` declares these macros: {}",
module,
file_macros
.keys()
.map(|k| format!("${}", k))
.collect::<Vec<_>>()
.join(", ")
)),
});
continue;
};
out.imported.push(ImportedMacro {
def: def.clone(),
source_file: resolved_path,
});
}
out
}