use convert_case::{Case, Casing};
use crate::builtin::derive_common::{collection_element_type, standalone_fn_name, type_has_derive};
use crate::macros::{ts_macro_derive, ts_template};
use crate::swc_ecma_ast::Expr;
use crate::ts_syn::abi::ir::type_registry::{ResolvedTypeRef, TypeRegistry};
use crate::ts_syn::ts_ident;
use crate::ts_syn::{Data, DeriveInput, MacroforgeError, TsStream, parse_ts_macro_input};
#[derive(Default)]
struct DebugFieldOptions {
skip: bool,
rename: Option<String>,
}
impl DebugFieldOptions {
fn from_decorators(decorators: &[crate::ts_syn::abi::DecoratorIR]) -> Self {
let mut opts = DebugFieldOptions::default();
for decorator in decorators {
if !decorator.name.eq_ignore_ascii_case("debug") {
continue;
}
let args = decorator.args_src.trim();
if args.is_empty() {
continue;
}
if has_flag(args, "skip") {
opts.skip = true;
}
if let Some(rename) = extract_named_string(args, "rename") {
opts.rename = Some(rename);
}
}
opts
}
}
fn has_flag(args: &str, flag: &str) -> bool {
if flag_explicit_false(args, flag) {
return false;
}
args.split(|c: char| !c.is_alphanumeric() && c != '_')
.any(|token| token.eq_ignore_ascii_case(flag))
}
fn flag_explicit_false(args: &str, flag: &str) -> bool {
let lower = args.to_ascii_lowercase();
let condensed: String = lower.chars().filter(|c| !c.is_whitespace()).collect();
condensed.contains(&format!("{flag}:false")) || condensed.contains(&format!("{flag}=false"))
}
fn extract_named_string(args: &str, name: &str) -> Option<String> {
let lower = args.to_ascii_lowercase();
let idx = lower.find(name)?;
let remainder = &args[idx + name.len()..];
let remainder = remainder.trim_start();
if remainder.starts_with(':') || remainder.starts_with('=') {
let value = remainder[1..].trim_start();
return parse_string_literal(value);
}
if remainder.starts_with('(')
&& let Some(close) = remainder.rfind(')')
{
let inner = remainder[1..close].trim();
return parse_string_literal(inner);
}
None
}
fn parse_string_literal(input: &str) -> Option<String> {
let trimmed = input.trim();
let mut chars = trimmed.chars();
let quote = chars.next()?;
if quote != '"' && quote != '\'' {
return None;
}
let mut escaped = false;
let mut buf = String::new();
for c in chars {
if escaped {
buf.push(c);
escaped = false;
continue;
}
if c == '\\' {
escaped = true;
continue;
}
if c == quote {
return Some(buf);
}
buf.push(c);
}
None
}
type DebugField = (String, String, String);
fn debug_value_expr(
field_name: &str,
_ts_type: &str,
var: &str,
resolved: Option<&ResolvedTypeRef>,
registry: Option<&TypeRegistry>,
) -> String {
let access = format!("{var}.{field_name}");
if let (Some(resolved), Some(registry)) = (resolved, registry) {
if !resolved.is_collection
&& resolved.registry_key.is_some()
&& type_has_derive(registry, &resolved.base_type_name, "Debug")
{
let fn_name = standalone_fn_name(&resolved.base_type_name, "ToString");
return format!("{fn_name}({access})");
}
if resolved.is_collection
&& let Some(elem) = collection_element_type(resolved)
&& elem.registry_key.is_some()
&& type_has_derive(registry, &elem.base_type_name, "Debug")
{
let elem_fn = standalone_fn_name(&elem.base_type_name, "ToString");
return format!("'[' + {access}.map(v => {elem_fn}(v)).join(', ') + ']'");
}
}
access
}
#[ts_macro_derive(
Debug,
description = "Generates a toString() method for debugging",
attributes((debug, "Configure debug output for this field. Options: skip (exclude from output), rename (custom label)"))
)]
pub fn derive_debug_macro(mut input: TsStream) -> Result<TsStream, MacroforgeError> {
let input = parse_ts_macro_input!(input as DeriveInput);
let resolved_fields = input.context.resolved_fields.as_ref();
let type_registry = input.context.type_registry.as_ref();
match &input.data {
Data::Class(class) => {
let class_name = input.name();
let class_ident = ts_ident!(class_name);
let debug_fields: Vec<DebugField> = class
.fields()
.iter()
.filter_map(|field| {
let opts = DebugFieldOptions::from_decorators(&field.decorators);
if opts.skip {
return None;
}
let label = opts.rename.unwrap_or_else(|| field.name.clone());
Some((label, field.name.clone(), field.ts_type.clone()))
})
.collect();
let fn_name_ident = ts_ident!("{}ToString", class_name.to_case(Case::Camel));
let fn_name_expr: Expr = fn_name_ident.clone().into();
let mut push_stmts = String::new();
for (label, name, ts_type) in &debug_fields {
let resolved = resolved_fields.and_then(|rf| rf.get(name));
let val_expr = debug_value_expr(name, ts_type, "value", resolved, type_registry);
push_stmts.push_str(&format!("parts.push(\"{label}: \" + {val_expr});\n"));
}
let standalone = if debug_fields.is_empty() {
ts_template! {
export function @{fn_name_ident}(value: @{class_ident.clone()}): string {
return "@{class_name} {}";
}
}
} else {
ts_template! {
export function @{fn_name_ident}(value: @{class_ident.clone()}): string {
const parts: string[] = [];
{$typescript TsStream::from_string(push_stmts)}
return "@{class_name} { " + parts.join(", ") + " }";
}
}
};
let class_body = ts_template!(Within {
static toString(value: @{class_ident.clone()}): string {
return @{fn_name_expr}(value);
}
});
Ok(ts_template! {
{$typescript standalone}
{$typescript class_body}
})
}
Data::Enum(enum_data) => {
let enum_name = input.name();
let enum_ident = ts_ident!(enum_name);
let _variants: Vec<String> = enum_data
.variants()
.iter()
.map(|v| v.name.clone())
.collect();
let fn_name_ident = ts_ident!("{}ToString", enum_name.to_case(Case::Camel));
let enum_expr: Expr = enum_ident.clone().into();
Ok(ts_template! {
export function @{fn_name_ident}(value: @{enum_ident.clone()}): string {
{#if !_variants.is_empty()}
const key = @{enum_expr.clone()}[value as unknown as keyof typeof @{enum_ident.clone()}];
if (key !== undefined) {
return "@{enum_name}." + key;
}
return "@{enum_name}(" + String(value) + ")";
{:else}
return "@{enum_name}(" + String(value) + ")";
{/if}
}
})
}
Data::Interface(interface) => {
let interface_name = input.name();
let interface_ident = ts_ident!(interface_name);
let debug_fields: Vec<DebugField> = interface
.fields()
.iter()
.filter_map(|field| {
let opts = DebugFieldOptions::from_decorators(&field.decorators);
if opts.skip {
return None;
}
let label = opts.rename.unwrap_or_else(|| field.name.clone());
Some((label, field.name.clone(), field.ts_type.clone()))
})
.collect();
let fn_name_ident = ts_ident!("{}ToString", interface_name.to_case(Case::Camel));
if debug_fields.is_empty() {
Ok(ts_template! {
export function @{fn_name_ident}(value: @{interface_ident.clone()}): string {
return "@{interface_name} {}";
}
})
} else {
let mut push_stmts = String::new();
for (label, name, ts_type) in &debug_fields {
let resolved = resolved_fields.and_then(|rf| rf.get(name));
let val_expr =
debug_value_expr(name, ts_type, "value", resolved, type_registry);
push_stmts.push_str(&format!("parts.push(\"{label}: \" + {val_expr});\n"));
}
Ok(ts_template! {
export function @{fn_name_ident}(value: @{interface_ident.clone()}): string {
const parts: string[] = [];
{$typescript TsStream::from_string(push_stmts)}
return "@{interface_name} { " + parts.join(", ") + " }";
}
})
}
}
Data::TypeAlias(type_alias) => {
let type_name = input.name();
let type_ident = ts_ident!(type_name);
if type_alias.is_object() {
let debug_fields: Vec<DebugField> = type_alias
.as_object()
.unwrap()
.iter()
.filter_map(|field| {
let opts = DebugFieldOptions::from_decorators(&field.decorators);
if opts.skip {
return None;
}
let label = opts.rename.unwrap_or_else(|| field.name.clone());
Some((label, field.name.clone(), field.ts_type.clone()))
})
.collect();
let fn_name_ident = ts_ident!("{}ToString", type_name.to_case(Case::Camel));
if debug_fields.is_empty() {
Ok(ts_template! {
export function @{fn_name_ident}(value: @{type_ident.clone()}): string {
return "@{type_name} {}";
}
})
} else {
let mut push_stmts = String::new();
for (label, name, ts_type) in &debug_fields {
let resolved = resolved_fields.and_then(|rf| rf.get(name));
let val_expr =
debug_value_expr(name, ts_type, "value", resolved, type_registry);
push_stmts.push_str(&format!("parts.push(\"{label}: \" + {val_expr});\n"));
}
Ok(ts_template! {
export function @{fn_name_ident}(value: @{type_ident.clone()}): string {
const parts: string[] = [];
{$typescript TsStream::from_string(push_stmts)}
return "@{type_name} { " + parts.join(", ") + " }";
}
})
}
} else {
let fn_name_ident = ts_ident!("{}ToString", type_name.to_case(Case::Camel));
Ok(ts_template! {
export function @{fn_name_ident}(value: @{type_ident.clone()}): string {
return "@{type_name}(" + JSON.stringify(value) + ")";
}
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ts_syn::abi::{DecoratorIR, SpanIR};
fn span() -> SpanIR {
SpanIR::new(0, 0)
}
#[test]
fn test_skip_flag() {
let decorator = DecoratorIR {
name: "Debug".into(),
args_src: "skip".into(),
span: span(),
node: None,
};
let opts = DebugFieldOptions::from_decorators(&[decorator]);
assert!(opts.skip, "skip flag should be true");
}
#[test]
fn test_skip_false_keeps_field() {
let decorator = DecoratorIR {
name: "Debug".into(),
args_src: r#"{ skip: false }"#.into(),
span: span(),
node: None,
};
let opts = DebugFieldOptions::from_decorators(&[decorator]);
assert!(!opts.skip, "skip: false should not skip the field");
}
#[test]
fn test_rename_option() {
let decorator = DecoratorIR {
name: "Debug".into(),
args_src: r#"{ rename: "identifier" }"#.into(),
span: span(),
node: None,
};
let opts = DebugFieldOptions::from_decorators(&[decorator]);
assert_eq!(opts.rename.as_deref(), Some("identifier"));
}
}