use crate::host::PatchCollector;
use crate::host::config::ForeignTypeConfig;
use crate::host::import_registry::{clear_foreign_types, set_foreign_types};
use crate::ts_syn::abi::{
ClassIR, DiagnosticLevel, MacroContextIR, MacroResult, Patch, PatchCode, SpanIR,
};
use crate::{
GeneratedRegionResult, MappingSegmentResult, NativePositionMapper, SourceMappingResult,
host::MacroExpander, parse_import_sources,
};
use swc_core::ecma::ast::{ClassMember, Program};
use swc_core::{
common::{FileName, GLOBALS, SourceMap, sync::Lrc},
ecma::parser::{Lexer, Parser, StringInput, Syntax, TsSyntax},
};
const DERIVE_MODULE_PATH: &str = "@macro/derive";
fn parse_module(source: &str) -> Program {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(
FileName::Custom("test.ts".into()).into(),
source.to_string(),
);
let lexer = Lexer::new(
Syntax::Typescript(TsSyntax {
decorators: true,
..Default::default()
}),
Default::default(),
StringInput::from(&*fm),
None,
);
let mut parser = Parser::new_from(lexer);
let module = parser.parse_module().expect("should parse");
Program::Module(module)
}
fn base_class(name: &str) -> ClassIR {
ClassIR {
name: name.into(),
span: SpanIR::new(0, 200),
body_span: SpanIR::new(10, 190),
is_abstract: false,
type_params: vec![],
heritage: vec![],
decorators: vec![],
decorators_ast: vec![],
fields: vec![],
methods: vec![],
members: vec![],
}
}
#[test]
fn test_derive_debug_runtime_output() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Debug) */
class Data {
val: number;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed, "expand() should report changes");
assert!(result.code.contains("static toString(value: Data)"));
assert!(result.code.contains("dataToString"));
});
}
#[test]
fn test_derive_debug_dts_output() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Debug) */
class User {
name: string;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed, "expand() should report changes");
let type_output = result.type_output.expect("should have type output");
assert!(
type_output.contains("static toString(value: User): string"),
"should have static toString method"
);
assert!(
type_output.contains("export function userToString"),
"should have standalone function"
);
});
}
#[test]
fn test_derive_clone_dts_output() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Clone) */
class User {
name: string;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed, "expand() should report changes");
let type_output = result.type_output.expect("should have type output");
assert!(
type_output.contains("static clone(value: User): User"),
"should have static clone method"
);
assert!(
type_output.contains("export function userClone"),
"should have standalone function"
);
});
}
#[test]
fn test_derive_partial_eq_hash_dts_output() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(PartialEq, Hash) */
class User {
name: string;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed, "expand() should report changes");
let type_output = result.type_output.expect("should have type output");
assert!(
type_output.contains("static equals(a: User, b: User): boolean"),
"should have static equals method"
);
assert!(
type_output.contains("static hashCode(value: User): number"),
"should have static hashCode method"
);
assert!(
type_output.contains("export function userEquals"),
"should have standalone equals function"
);
assert!(
type_output.contains("export function userHashCode"),
"should have standalone hashCode function"
);
});
}
#[test]
fn test_derive_debug_complex_dts_output() {
let source = r#"
/** @derive(Debug) */
class MacroUser {
/** @debug({ rename: "userId" }) */
id: string;
name: string;
role: string;
favoriteMacro: "Derive" | "JsonNative";
since: string;
/** @debug({ skip: true }) */
apiToken: string;
constructor(
id: string,
name: string,
role: string,
favoriteMacro: "Derive" | "JsonNative",
since: string,
apiToken: string,
) {
this.id = id;
this.name = name;
this.role = role;
this.favoriteMacro = favoriteMacro;
this.since = since;
this.apiToken = apiToken;
}
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed, "expand() should report changes");
let type_output = result.type_output.expect("should have type output");
assert!(
type_output.contains("static toString(value: MacroUser): string"),
"should have static toString method"
);
assert!(
type_output.contains("export function macroUserToString"),
"should have standalone function"
);
});
}
#[test]
fn test_prepare_no_derive() {
let source = "class User { name: string; }";
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.prepare_expansion_context(&program, source).unwrap();
assert!(result.is_some());
}
#[test]
fn test_prepare_no_classes() {
let source = "const x = 1;";
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.prepare_expansion_context(&program, source).unwrap();
assert!(result.is_none());
}
#[test]
fn test_prepare_with_classes() {
let source = "/** @derive(Debug) */ class User {}";
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.prepare_expansion_context(&program, source).unwrap();
assert!(result.is_some());
let (_module, items) = result.unwrap();
assert_eq!(items.classes.len(), 1);
assert_eq!(items.classes[0].name, "User");
}
#[test]
fn test_process_macro_output_converts_tokens_into_patches() {
GLOBALS.set(&Default::default(), || {
let host = MacroExpander::new().unwrap();
let class_ir = base_class("TokenDriven");
let ctx = MacroContextIR::new_derive_class(
"Debug".into(),
DERIVE_MODULE_PATH.into(),
SpanIR::new(0, 5),
class_ir.span,
"token.ts".into(),
class_ir.clone(),
"class TokenDriven {}".into(),
);
let mut result = MacroResult {
tokens: Some(
r#"/* @macroforge:body */
toString() { return `${this.value}`; }
constructor(value: string) { this.value = value; }
"#
.into(),
),
..Default::default()
};
let (runtime, type_patches) = host
.process_macro_output(&mut result, &ctx, &ctx.target_source)
.expect("tokens should parse");
assert_eq!(
runtime.len(),
2,
"expected one runtime patch per generated member"
);
assert_eq!(
type_patches.len(),
2,
"expected one type patch per generated member"
);
for patch in runtime {
match patch {
Patch::Insert {
code: PatchCode::ClassMember(_),
..
} => {}
other => panic!("expected class member insert, got {:?}", other),
}
}
for patch in type_patches {
if let Patch::Insert {
code: PatchCode::ClassMember(member),
..
} = patch
{
match member {
ClassMember::Method(method) => assert!(
method.function.body.is_none(),
"type patch should strip method body"
),
ClassMember::Constructor(cons) => assert!(
cons.body.is_none(),
"type patch should drop constructor body"
),
_ => {}
}
} else {
panic!("expected type patch insert");
}
}
});
}
#[test]
fn test_process_macro_output_reports_parse_errors() {
GLOBALS.set(&Default::default(), || {
let host = MacroExpander::new().unwrap();
let class_ir = base_class("Broken");
let ctx = MacroContextIR::new_derive_class(
"Debug".into(),
DERIVE_MODULE_PATH.into(),
SpanIR::new(0, 5),
class_ir.span,
"broken.ts".into(),
class_ir.clone(),
"class Broken {}".into(),
);
let mut result = MacroResult {
tokens: Some("/* @macroforge:body */this is not valid class member syntax".into()),
..Default::default()
};
let (_runtime, _types) = host
.process_macro_output(&mut result, &ctx, &ctx.target_source)
.expect("process_macro_output should succeed with raw insertion fallback");
let diag = result
.diagnostics
.iter()
.find(|d| d.message.contains("Failed to parse macro output"))
.expect("diagnostic should mention parsing failure");
assert_eq!(diag.level, DiagnosticLevel::Warning);
});
}
#[test]
fn test_collect_constructor_patch() {
let source = "/** @derive(Debug) */ class User { constructor(id: string) { this.id = id; } }";
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let (module, items) = host
.prepare_expansion_context(&program, source)
.unwrap()
.unwrap();
let (collector, _) = host.collect_macro_patches(&module, items, "test.ts", source);
let type_patches = collector.get_type_patches();
assert!(
type_patches.len() >= 2,
"Expected at least 2 patches, got {}",
type_patches.len()
);
let constructor_patch = type_patches.iter().find(|p| {
if let Patch::Replace {
code: PatchCode::Text(text),
..
} = p
{
text.contains("constructor")
} else {
false
}
});
assert!(
constructor_patch.is_some(),
"Should have a constructor patch"
);
if let Some(Patch::Replace { code, .. }) = constructor_patch {
match code {
PatchCode::Text(text) => assert_eq!(text, "constructor(id: string);"),
_ => panic!("Expected textual patch for constructor signature"),
}
}
}
#[test]
fn test_no_patches_for_class_without_derive() {
let source = "class User { constructor(id: string) { this.id = id; } }";
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let (module, items) = host
.prepare_expansion_context(&program, source)
.unwrap()
.unwrap();
let (collector, _) = host.collect_macro_patches(&module, items, "test.ts", source);
let type_patches = collector.get_type_patches();
assert_eq!(
type_patches.len(),
0,
"Class without @derive should get no type patches"
);
assert!(
!collector.has_type_patches(),
"Class without @derive should have no type patches"
);
}
#[test]
fn test_collect_derive_debug_patch() {
let source = "/** @derive(Debug) */ class User { name: string; }";
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let (module, items) = host
.prepare_expansion_context(&program, source)
.unwrap()
.unwrap();
let (collector, _) = host.collect_macro_patches(&module, items, "test.ts", source);
let type_patches = collector.get_type_patches();
assert_eq!(
type_patches.len(),
3,
"Expected 3 patches, got {}",
type_patches.len()
);
assert!(
type_patches
.iter()
.any(|p| matches!(p, Patch::Delete { .. }))
);
assert!(
type_patches
.iter()
.any(|p| matches!(p, Patch::Insert { .. }))
);
}
#[test]
fn test_apply_and_finalize_expansion_no_type_patches() {
let source = "class User {}";
let mut collector = PatchCollector::new();
let mut diagnostics = Vec::new();
let host = MacroExpander::new().unwrap();
let result = host
.apply_and_finalize_expansion(
source,
&mut collector,
&mut diagnostics,
crate::host::expand::LoweredItems {
classes: Vec::new(),
interfaces: Vec::new(),
enums: Vec::new(),
type_aliases: Vec::new(),
imports: crate::host::import_registry::ImportRegistry::new(),
},
)
.unwrap();
assert!(result.type_output.is_none());
}
#[test]
fn test_complex_class_with_multiple_derives() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Debug, Clone, PartialEq, Hash) */
class Product {
id: string;
name: string;
price: number;
private secret: string;
constructor(id: string, name: string, price: number, secret: string) {
this.id = id;
this.name = name;
this.price = price;
this.secret = secret;
}
getDisplayName(): string {
return `${this.name} - $${this.price}`;
}
static fromJSON(json: any): Product {
return new Product(json.id, json.name, json.price, json.secret);
}
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed, "expand() should report changes");
let type_output = result.type_output.expect("should have type output");
assert!(type_output.contains("static toString(value: Product): string"));
assert!(type_output.contains("static clone(value: Product): Product"));
assert!(type_output.contains("static equals(a: Product, b: Product): boolean"));
assert!(type_output.contains("static hashCode(value: Product): number"));
assert!(type_output.contains("export function productToString"));
assert!(type_output.contains("export function productClone"));
assert!(type_output.contains("export function productEquals"));
assert!(type_output.contains("export function productHashCode"));
});
}
#[test]
fn test_complex_method_signatures() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Debug) */
class API {
endpoint: string;
constructor(endpoint: string) {
this.endpoint = endpoint;
}
async fetch<T>(
path: string,
options?: { method?: string; body?: any }
): Promise<T> {
return {} as T;
}
subscribe(
event: "data" | "error",
callback: (data: any) => void,
thisArg?: any
): () => void {
return () => {};
}
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed);
let type_output = result.type_output.expect("should have type output");
assert!(type_output.contains("static toString(value: API): string"));
assert!(type_output.contains("export function apiToString"));
assert!(type_output.contains("async fetch<T>"));
assert!(type_output.contains("subscribe("));
});
}
#[test]
fn test_class_with_visibility_modifiers() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Clone) */
class Account {
public username: string;
protected password: string;
private apiKey: string;
constructor(username: string, password: string, apiKey: string) {
this.username = username;
this.password = password;
this.apiKey = apiKey;
}
public login(): boolean {
return true;
}
protected validatePassword(input: string): boolean {
return this.password === input;
}
private getApiKey(): string {
return this.apiKey;
}
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed);
let type_output = result.type_output.expect("should have type output");
assert!(type_output.contains("static clone(value: Account): Account"));
assert!(type_output.contains("export function accountClone"));
});
}
#[test]
fn test_class_with_optional_and_readonly_fields() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Debug, PartialEq, Hash) */
class Config {
readonly id: string;
name: string;
description?: string;
readonly createdAt: Date;
updatedAt?: Date;
constructor(id: string, name: string, createdAt: Date) {
this.id = id;
this.name = name;
this.createdAt = createdAt;
}
update(name: string, description?: string): void {
this.name = name;
this.description = description;
this.updatedAt = new Date();
}
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed);
let type_output = result.type_output.expect("should have type output");
assert!(type_output.contains("static toString(value: Config): string"));
assert!(type_output.contains("static equals(a: Config, b: Config): boolean"));
assert!(type_output.contains("static hashCode(value: Config): number"));
assert!(type_output.contains("export function configToString"));
assert!(type_output.contains("export function configEquals"));
assert!(type_output.contains("export function configHashCode"));
});
}
#[test]
fn test_empty_constructor_and_no_params_methods() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Debug) */
class Singleton {
private static instance: Singleton;
private constructor() {
// Private constructor
}
static getInstance(): Singleton {
if (!Singleton.instance) {
Singleton.instance = new Singleton();
}
return Singleton.instance;
}
reset(): void {
// Reset logic
}
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed);
let type_output = result.type_output.expect("should have type output");
assert!(type_output.contains("static toString(value: Singleton): string"));
assert!(type_output.contains("export function singletonToString"));
});
}
#[test]
fn test_class_with_field_decorators_and_derive() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Debug) */
class ValidationExample {
/** @debug({ rename: "userId" }) */
id: string;
name: string;
/** @debug({ skip: true }) */
internalFlag: boolean;
constructor(id: string, name: string, internalFlag: boolean) {
this.id = id;
this.name = name;
this.internalFlag = internalFlag;
}
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed);
let type_output = result.type_output.expect("should have type output");
assert!(type_output.contains("static toString(value: ValidationExample): string"));
assert!(type_output.contains("export function validationExampleToString"));
});
}
#[test]
fn test_generated_methods_on_separate_lines() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Debug, Clone) */
class User {
id: number;
name: string;
constructor(id: number, name: string) {
this.id = id;
this.name = name;
}
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed);
let type_output = result.type_output.expect("should have type output");
let lines: Vec<&str> = type_output.lines().collect();
let tostring_line = lines
.iter()
.position(|l| l.contains("static toString(value: User)"))
.expect("should have static toString");
let clone_line = lines
.iter()
.position(|l| l.contains("static clone(value: User)"))
.expect("should have static clone");
assert_ne!(
tostring_line, clone_line,
"toString and clone should be on different lines"
);
});
}
#[test]
fn test_proper_indentation_in_generated_code() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Debug) */
class User {
id: number;
name: string;
constructor(id: number, name: string) {
this.id = id;
this.name = name;
}
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed);
let type_output = result.type_output.expect("should have type output");
let tostring_line = type_output
.lines()
.find(|l| l.contains("static toString(value: User)"))
.expect("should have static toString method");
assert!(
tostring_line.contains("static toString(value: User)"),
"should have static toString method, got: '{}'",
tostring_line
);
});
}
#[test]
fn test_default_parameter_values() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Debug) */
class ServerConfig {
host: string;
port: number;
constructor(
host: string = "localhost",
port: number = 8080,
secure: boolean = false
) {
this.host = host;
this.port = port;
}
connect(
timeout: number = 5000,
retries: number = 3,
onError?: (err: Error) => void
): Promise<void> {
return Promise.resolve();
}
static create(
config: Partial<ServerConfig> = {},
defaults: { host?: string; port?: number } = { host: "0.0.0.0", port: 3000 }
): ServerConfig {
return new ServerConfig();
}
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed);
let type_output = result.type_output.expect("should have type output");
assert!(
type_output.contains("static toString(value: ServerConfig): string"),
"should have static toString method, got:\n{}",
type_output
);
assert!(
type_output.contains("export function serverConfigToString"),
"should have exported serverConfigToString function, got:\n{}",
type_output
);
});
}
#[test]
fn test_rest_parameters_and_destructuring() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Clone) */
class EventEmitter {
listeners: Map<string, Function[]>;
constructor() {
this.listeners = new Map();
}
on(event: string, ...callbacks: Array<(...args: any[]) => void>): void {
const existing = this.listeners.get(event) || [];
this.listeners.set(event, [...existing, ...callbacks]);
}
emit(event: string, ...args: any[]): void {
const callbacks = this.listeners.get(event) || [];
callbacks.forEach(cb => cb(...args));
}
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed);
let type_output = result.type_output.expect("should have type output");
assert!(
type_output.contains("static clone(value: EventEmitter): EventEmitter"),
"should have static clone method, got:\n{}",
type_output
);
assert!(
type_output.contains("export function eventEmitterClone"),
"should have exported eventEmitterClone function, got:\n{}",
type_output
);
});
}
#[test]
fn test_source_mapping_produced() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Debug) */
class User {
name: string;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed, "Expansion should report changes");
let mapping = result
.source_mapping
.expect("Source mapping should be produced");
assert!(!mapping.segments.is_empty(), "Should have mapping segments");
assert!(
!mapping.generated_regions.is_empty(),
"Should have generated regions"
);
});
}
#[test]
fn parse_import_sources_handles_aliases_and_defaults() {
let code = r#"
import { Derive, Debug as Dbg } from "@macro/derive";
import DefaultMacro from "@macro/default";
import * as Everything from "@macro/all";
"#;
let imports = parse_import_sources(code.to_string(), "test.ts".to_string())
.expect("should parse imports");
let map: std::collections::HashMap<_, _> = imports
.into_iter()
.map(|entry| (entry.local, entry.module))
.collect();
assert_eq!(map.get("Derive").map(String::as_str), Some("@macro/derive"));
assert_eq!(map.get("Dbg").map(String::as_str), Some("@macro/derive"));
assert_eq!(
map.get("DefaultMacro").map(String::as_str),
Some("@macro/default")
);
assert_eq!(
map.get("Everything").map(String::as_str),
Some("@macro/all")
);
}
#[test]
fn native_position_mapper_matches_js_logic() {
let mapping = SourceMappingResult {
segments: vec![
MappingSegmentResult {
original_start: 0,
original_end: 10,
expanded_start: 0,
expanded_end: 10,
},
MappingSegmentResult {
original_start: 10,
original_end: 20,
expanded_start: 12,
expanded_end: 22,
},
],
generated_regions: vec![GeneratedRegionResult {
start: 10,
end: 12,
source_macro: "demo".into(),
}],
};
let mapper = NativePositionMapper::new(mapping);
assert_eq!(mapper.original_to_expanded(5), 5);
assert_eq!(mapper.original_to_expanded(15), 17);
assert_eq!(mapper.expanded_to_original(5), Some(5));
assert_eq!(mapper.expanded_to_original(17), Some(15));
assert_eq!(mapper.expanded_to_original(10), None);
assert!(mapper.is_in_generated(10));
assert_eq!(mapper.generated_by(11).as_deref(), Some("demo"));
assert!(mapper.generated_by(25).is_none());
let span = mapper.map_span_to_original(12, 2).expect("span should map");
assert_eq!(span.start, 10);
assert_eq!(span.length, 2);
let expanded_span = mapper.map_span_to_expanded(8, 4);
assert_eq!(expanded_span.start, 8);
assert_eq!(expanded_span.length, 6);
assert!(!mapper.is_empty());
}
#[test]
fn test_default_below_location_for_unmarked_tokens() {
GLOBALS.set(&Default::default(), || {
let host = MacroExpander::new().unwrap();
let class_ir = base_class("BelowTest");
let ctx = MacroContextIR::new_derive_class(
"Custom".into(),
DERIVE_MODULE_PATH.into(),
SpanIR::new(0, 5),
class_ir.span,
"below.ts".into(),
class_ir.clone(),
"class BelowTest {}".into(),
);
let mut result = MacroResult {
tokens: Some("const helper = () => {};".into()),
..Default::default()
};
let (runtime, type_patches) = host
.process_macro_output(&mut result, &ctx, &ctx.target_source)
.expect("unmarked tokens should be inserted as text below class");
assert_eq!(runtime.len(), 1, "should have 1 runtime patch for below");
assert_eq!(type_patches.len(), 1, "should have 1 type patch for below");
for patch in runtime {
match patch {
Patch::Insert {
code: PatchCode::Text(_),
at,
..
} => {
assert_eq!(at.start, class_ir.span.end, "should insert at class end");
}
other => panic!("expected text insert for below, got {:?}", other),
}
}
});
}
#[test]
fn test_explicit_body_marker_parses_as_class_members() {
GLOBALS.set(&Default::default(), || {
let host = MacroExpander::new().unwrap();
let class_ir = base_class("BodyTest");
let ctx = MacroContextIR::new_derive_class(
"Custom".into(),
DERIVE_MODULE_PATH.into(),
SpanIR::new(0, 5),
class_ir.span,
"body.ts".into(),
class_ir.clone(),
"class BodyTest {}".into(),
);
let mut result = MacroResult {
tokens: Some("/* @macroforge:body */getValue(): string { return \"test\"; }".into()),
..Default::default()
};
let (runtime, type_patches) = host
.process_macro_output(&mut result, &ctx, &ctx.target_source)
.expect("body-marked tokens should parse as class members");
assert_eq!(runtime.len(), 1, "should have 1 runtime patch for body");
assert_eq!(type_patches.len(), 1, "should have 1 type patch for body");
for patch in runtime {
match patch {
Patch::Insert {
code: PatchCode::ClassMember(_),
..
} => {}
other => panic!("expected class member insert for body, got {:?}", other),
}
}
});
}
#[test]
fn test_derive_debug_on_interface_generates_namespace() {
let source = r#"
/** @derive(Debug) */
interface Status {
active: boolean;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("statusToString"),
"Should generate prefix-style statusToString function. Got:\n{}",
result.code
);
});
}
#[test]
fn test_derive_clone_on_interface_generates_functions() {
let source = r#"
/** @derive(Clone) */
interface UserData {
name: string;
age: number;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("userDataClone"),
"Should generate prefix-style userDataClone function"
);
});
}
#[test]
fn test_derive_partial_eq_hash_on_interface_generates_functions() {
let source = r#"
/** @derive(PartialEq, Hash) */
interface Point {
x: number;
y: number;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("pointEquals"),
"Should generate prefix-style pointEquals function"
);
assert!(
result.code.contains("pointHashCode"),
"Should generate prefix-style pointHashCode function"
);
});
}
#[test]
fn test_derive_debug_on_interface_generates_correct_output() {
let source = r#"
/** @derive(Debug) */
interface Status {
active: boolean;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should succeed without errors");
eprintln!("[DEBUG] result.code = {:?}", result.code);
assert!(
result.code.contains("value.active"),
"Should reference value.active"
);
});
}
#[test]
fn test_multiple_derives_on_interface_all_succeed() {
let source = r#"
/** @derive(Debug, Clone, PartialEq, Hash) */
interface Status {
active: boolean;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors for derives on interface, got {} errors",
error_count
);
assert!(
result.code.contains("statusToString"),
"Should have Debug's statusToString"
);
assert!(
result.code.contains("statusClone"),
"Should have Clone's statusClone"
);
assert!(
result.code.contains("statusEquals"),
"Should have PartialEq's statusEquals"
);
assert!(
result.code.contains("statusHashCode"),
"Should have Hash's statusHashCode"
);
});
}
#[test]
fn test_unknown_derive_macro_produces_error() {
let source = r#"
/** @derive(NonExistentMacro) */
class User {
name: string;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let unknown_error = result.diagnostics.iter().find(|d| {
d.message.contains("NonExistentMacro")
|| d.message.contains("unknown")
|| d.message.contains("not found")
});
assert!(
unknown_error.is_some(),
"Should produce an error for unknown derive macro. Diagnostics: {:?}",
result.diagnostics
);
});
}
#[test]
fn test_unknown_derive_macro_on_interface_produces_error() {
let source = r#"
/** @derive(Serializable) */
interface Config {
host: string;
port: number;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let unknown_error = result.diagnostics.iter().find(|d| {
d.message.contains("Serializable")
|| d.message.contains("unknown")
|| d.message.contains("not found")
});
assert!(
unknown_error.is_some(),
"Should produce an error for unknown derive macro on interface. Diagnostics: {:?}",
result.diagnostics
);
});
}
#[test]
fn test_error_span_covers_macro_name_not_entire_decorator() {
let source = r#"
/** @derive(NonExistent) */
interface Data {
value: string;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_diag = result
.diagnostics
.iter()
.find(|d| d.level == DiagnosticLevel::Error)
.expect("Should have an error-level diagnostic");
let span = error_diag.span.as_ref().expect("Error should have a span");
let span_len = span.end - span.start;
assert!(
span_len < 100,
"Error span should be focused, not cover entire source. Span length: {}",
span_len
);
});
}
#[test]
fn test_derive_serialize_dts_output() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Serialize) */
class User {
name: string;
age: number;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed, "expand() should report changes");
let type_output = result.type_output.expect("should have type output");
assert!(
type_output.contains("static serialize(value: User, keepMetadata?: boolean): string"),
"Should have static serialize method"
);
assert!(
type_output
.contains("static serializeWithContext(value: User, ctx: __mf_SerializeContext)"),
"Should have static serializeWithContext method"
);
assert!(
type_output.contains("export function userSerialize"),
"Should have standalone userSerialize function"
);
});
}
#[test]
fn test_derive_serialize_runtime_output() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Serialize) */
class Data {
val: number;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed, "expand() should report changes");
assert!(
result
.code
.contains("static serialize(value: Data, keepMetadata?: boolean): string"),
"Should have static serialize method"
);
assert!(
result.code.contains("serializeWithContext"),
"Should have serializeWithContext method"
);
assert!(
result.code.contains("export function dataSerialize"),
"Should have standalone dataSerialize function"
);
});
}
#[test]
fn test_derive_deserialize_dts_output() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Deserialize) */
class User {
name: string;
age: number;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed, "expand() should report changes");
let type_output = result.type_output.expect("should have type output");
assert!(
type_output.contains("static deserialize(input: unknown"),
"Should have deserialize method"
);
assert!(
type_output.contains(
"static deserializeWithContext(value: any, ctx: __mf_DeserializeContext)"
),
"Should have deserializeWithContext method"
);
});
}
#[test]
fn test_derive_deserialize_runtime_output() {
let source = r#"
import { Derive } from "@macro/derive";
/** @derive(Deserialize) */
class Data {
val: number;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(result.changed, "expand() should report changes");
assert!(
result.code.contains("deserialize"),
"Should have deserialize method"
);
assert!(
result.code.contains("deserializeWithContext"),
"Should have deserializeWithContext method"
);
assert!(result.code.contains("static"), "Methods should be static");
assert!(
result.code.contains("DeserializeContext"),
"Should use DeserializeContext"
);
});
}
#[test]
fn test_derive_serialize_on_interface_generates_functions() {
let source = r#"
/** @derive(Serialize) */
interface Point {
x: number;
y: number;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("pointSerialize"),
"Should generate prefix-style pointSerialize function"
);
assert!(
result.code.contains("pointSerializeWithContext"),
"Should generate prefix-style pointSerializeWithContext function"
);
assert!(
!result.code.contains("#0"),
"Output should not contain SWC syntax context markers like #0. Got:\n{}",
result.code
);
});
}
#[test]
fn test_derive_deserialize_on_interface_generates_functions() {
let source = r#"
/** @derive(Deserialize) */
interface Point {
x: number;
y: number;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("pointDeserialize"),
"Should generate prefix-style pointDeserialize function"
);
assert!(
result.code.contains("pointDeserializeWithContext"),
"Should generate prefix-style pointDeserializeWithContextfunction"
);
});
}
#[test]
fn test_interface_derive_macros_default_to_prefix_functions() {
let source = r#"
/** @derive(Debug, Clone, PartialEq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize) */
export interface Point {
x: number;
y: number;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(
!result.code.contains("export namespace Point"),
"Default naming style should not emit namespaces"
);
for expected in [
"export function pointToString",
"export function pointClone",
"export function pointEquals",
"export function pointHashCode",
"export function pointPartialCompare",
"export function pointCompare",
"export function pointDefaultValue",
"export function pointSerializeWithContext",
"export function pointDeserializeWithContext",
] {
assert!(
result.code.contains(expected),
"Expected prefix-style function: {expected}"
);
}
});
}
#[test]
fn test_external_type_function_imports_for_prefix_style() {
let source = r#"
import { Metadata } from "./metadata.svelte";
/** @derive(Default, Serialize, Deserialize) */
export interface User {
metadata: Metadata;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
assert!(
result.code.contains("metadataSerializeWithContext"),
"Expected User serialization to reference metadataSerializeWithContext"
);
assert!(
result.code.contains("metadataDeserializeWithContext"),
"Expected User deserialization to reference metadataDeserializeWithContext"
);
assert!(
result.code.contains("metadataDefaultValue"),
"Expected User defaultValue to reference metadataDefaultValue"
);
assert!(
result
.code
.contains("import { metadataSerializeWithContext } from \"./metadata.svelte\";"),
"Expected metadataSerializeWithContext import to be added"
);
assert!(
result
.code
.contains("import { metadataDeserializeWithContext } from \"./metadata.svelte\";"),
"Expected metadataDeserializeWithContext import to be added"
);
assert!(
result
.code
.contains("import { metadataDefaultValue } from \"./metadata.svelte\";"),
"Expected metadataDefaultValue import to be added"
);
});
}
#[test]
fn test_multiple_derives_with_serialize_deserialize() {
let source = r#"
/** @derive(Serialize, Deserialize) */
class Config {
host: string;
port: number;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors, got {} errors",
error_count
);
assert!(
result
.code
.contains("static serialize(value: Config, keepMetadata?: boolean): string"),
"Should have Serialize's static serialize"
);
assert!(
result.code.contains("static deserialize"),
"Should have Deserialize's static deserialize"
);
});
}
#[test]
fn test_derive_debug_on_enum_generates_functions() {
let source = r#"
/** @derive(Debug) */
enum Status {
Active,
Inactive,
Pending
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("statusToString"),
"Should generate prefix-style statusToString function"
);
});
}
#[test]
fn test_derive_clone_on_enum_generates_functions() {
let source = r#"
/** @derive(Clone) */
enum Priority {
Low = 1,
Medium = 2,
High = 3
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("priorityClone"),
"Should generate prefix-style clone function for enum"
);
assert!(result.code.contains("Clone"), "Should have Clone function");
});
}
#[test]
fn test_derive_partial_eq_hash_on_enum_generates_functions() {
let source = r#"
/** @derive(PartialEq, Hash) */
enum Color {
Red = "red",
Green = "green",
Blue = "blue"
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("colorEquals"),
"Should generate prefix-style colorEquals function"
);
assert!(
result.code.contains("colorHashCode"),
"Should generate prefix-style colorHashCode function"
);
});
}
#[test]
fn test_derive_serialize_on_enum_generates_functions() {
let source = r#"
/** @derive(Serialize) */
enum Direction {
North,
South,
East,
West
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("directionSerialize"),
"Should generate prefix-style directionSerialize function"
);
});
}
#[test]
fn test_derive_deserialize_on_enum_generates_functions() {
let source = r#"
/** @derive(Deserialize) */
enum Role {
Admin = "admin",
User = "user",
Guest = "guest"
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("roleDeserialize"),
"Should generate prefix-style roleDeserialize function"
);
});
}
#[test]
fn test_multiple_derives_on_enum() {
let source = r#"
/** @derive(Debug, Clone, PartialEq, Hash, Serialize, Deserialize) */
enum Status {
Active = "active",
Inactive = "inactive"
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors, got {} errors",
error_count
);
assert!(
result.code.contains("statusToString"),
"Should have Debug's statusToString"
);
assert!(
result.code.contains("statusClone"),
"Should have Clone's statusClone"
);
assert!(
result.code.contains("statusEquals"),
"Should have PartialEq's statusEquals"
);
assert!(
result.code.contains("statusHashCode"),
"Should have Hash's statusHashCode"
);
assert!(
result.code.contains("statusSerialize"),
"Should have Serialize's statusSerialize"
);
assert!(
result.code.contains("statusDeserialize"),
"Should have Deserialize's statusDeserialize"
);
});
}
#[test]
fn test_derive_debug_on_type_alias_generates_functions() {
let source = r#"
/** @derive(Debug) */
type Point = {
x: number;
y: number;
};
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("pointToString"),
"Should generate pointToString function for type"
);
});
}
#[test]
fn test_derive_clone_on_type_alias_generates_functions() {
let source = r#"
/** @derive(Clone) */
type Config = {
host: string;
port: number;
};
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("configClone"),
"Should generate configClone function for type"
);
});
}
#[test]
fn test_derive_partial_eq_hash_on_type_alias_generates_functions() {
let source = r#"
/** @derive(PartialEq, Hash) */
type Vector = {
x: number;
y: number;
z: number;
};
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("vectorEquals"),
"Should generate vectorEquals function for type"
);
assert!(
result.code.contains("vectorHashCode"),
"Should generate vectorHashCode function for type"
);
});
}
#[test]
fn test_derive_serialize_on_type_alias_generates_functions() {
let source = r#"
/** @derive(Serialize) */
type User = {
name: string;
age: number;
};
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("userSerialize"),
"Should generate userSerialize function for type"
);
assert!(
!result.code.contains("#0"),
"Output should not contain SWC syntax context markers like #0. Got:\n{}",
result.code
);
});
}
#[test]
fn test_derive_deserialize_on_type_alias_generates_functions() {
let source = r#"
/** @derive(Deserialize) */
type Settings = {
theme: string;
language: string;
};
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("settingsDeserialize"),
"Should generate settingsDeserialize function for type"
);
});
}
#[test]
fn test_multiple_derives_on_type_alias() {
let source = r#"
/** @derive(Debug, Clone, PartialEq, Hash, Serialize, Deserialize) */
type Coordinate = {
lat: number;
lng: number;
};
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors, got {} errors",
error_count
);
assert!(
result.code.contains("coordinateToString"),
"Should have Debug's coordinateToString"
);
assert!(
result.code.contains("coordinateClone"),
"Should have Clone's coordinateClone"
);
assert!(
result.code.contains("coordinateEquals"),
"Should have PartialEq's coordinateEquals"
);
assert!(
result.code.contains("coordinateHashCode"),
"Should have Hash's coordinateHashCode"
);
assert!(
result.code.contains("coordinateSerialize"),
"Should have Serialize's coordinateSerialize"
);
assert!(
result.code.contains("coordinateDeserialize"),
"Should have Deserialize's coordinateDeserialize"
);
});
}
#[test]
fn test_deserialize_validation_on_interface() {
let source = r#"
/** @derive(Deserialize) */
interface UserProfile {
/** @serde(email) */
email: string;
/** @serde(minLength(2), maxLength(50)) */
username: string;
/** @serde(positive) */
age?: number;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("test(__raw_email)"),
"Should generate email validation. Got:\n{}",
result.code
);
assert!(
result.code.contains("__raw_username.length < 2")
|| result.code.contains("__raw_username.length > 50"),
"Should generate length validation. Got:\n{}",
result.code
);
assert!(
result.code.contains("__raw_age <= 0"),
"Should generate positive validation. Got:\n{}",
result.code
);
assert!(
result.code.contains("errors.push"),
"Should push validation errors. Got:\n{}",
result.code
);
});
}
#[test]
fn test_deserialize_validation_on_type_alias() {
let source = r#"
/** @derive(Deserialize) */
type ContactInfo = {
/** @serde(email) */
primaryEmail: string;
/** @serde(minLength(1), maxLength(100)) */
address: string;
};
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("test(__raw_primaryEmail)"),
"Should generate email validation for type alias. Got:\n{}",
result.code
);
assert!(
result.code.contains("__raw_address.length < 1")
|| result.code.contains("__raw_address.length > 100"),
"Should generate length validation for type alias. Got:\n{}",
result.code
);
});
}
#[test]
fn test_derive_on_union_type_alias() {
let source = r#"
/** @derive(Debug, PartialEq, Hash) */
type Status = "active" | "inactive" | "pending";
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("statusToString"),
"Should generate statusToString function for union type"
);
assert!(
result.code.contains("statusEquals"),
"Should generate statusEquals function for union type"
);
assert!(
result.code.contains("statusHashCode"),
"Should generate statusHashCode function for union type"
);
});
}
#[test]
fn test_derive_default_on_union_type_alias_with_explicit_default() {
let source = r#"
/** @derive(Default) @default(VariantA.defaultValue()) */
export type UnionType = VariantA | VariantB;
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors with @default specified, got {}",
error_count
);
assert!(
result.code.contains("unionTypeDefaultValue"),
"Should generate unionTypeDefaultValue function. Got:\n{}",
result.code
);
assert!(
result.code.contains("VariantA.defaultValue()"),
"Should call VariantA.defaultValue(). Got:\n{}",
result.code
);
});
}
#[test]
fn test_derive_default_on_union_type_alias_without_default_errors() {
let source = r#"
/** @derive(Default) */
export type UnionType = VariantA | VariantB;
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error = result
.diagnostics
.iter()
.find(|d| d.level == DiagnosticLevel::Error);
assert!(
error.is_some(),
"Should produce an error when @default is missing for union type. Got diagnostics: {:?}",
result.diagnostics
);
let error_msg = &error.unwrap().message;
assert!(
error_msg.contains("@default") || error_msg.contains("union"),
"Error should mention @default or union. Got: {}",
error_msg
);
});
}
#[test]
fn test_derive_default_on_multiline_union_type() {
let source = r#"
/**
* @derive(Default)
* @default(TypeA.defaultValue())
*/
export type MultilineUnion = TypeA | TypeB | TypeC;
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors for multi-line JSDoc with @default. Got: {:?}",
result.diagnostics
);
assert!(
result.code.contains("multilineUnionDefaultValue"),
"Should generate multilineUnionDefaultValue function. Got:\n{}",
result.code
);
});
}
#[test]
fn test_derive_default_with_default_on_union_variant() {
let source = r#"
/** @derive(Default) */
export type UnionWithVariantDefault =
| /** @default */ VariantA
| VariantB
| VariantC;
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors with @default on variant. Got: {:?}",
result.diagnostics
);
assert!(
result.code.contains("unionWithVariantDefaultDefaultValue"),
"Should generate unionWithVariantDefaultDefaultValue function. Got:\n{}",
result.code
);
assert!(
result.code.contains("variantADefaultValue()"),
"Should call variantADefaultValue(). Got:\n{}",
result.code
);
});
}
#[test]
fn test_derive_default_inline_union_without_leading_pipes() {
let source = r#"
/** @derive(Default) */
export type ActivityType = /** @default */ Created | Edited | Sent;
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
eprintln!("[TEST] Expanded code:\n{}", result.code);
eprintln!("[TEST] Diagnostics: {:?}", result.diagnostics);
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors with /** @default */ before first variant. Got: {:?}",
result.diagnostics
);
assert!(
result.code.contains("activityTypeDefaultValue"),
"Should generate activityTypeDefaultValue function. Got:\n{}",
result.code
);
assert!(
result.code.contains("createdDefaultValue()"),
"Should call createdDefaultValue(). Got:\n{}",
result.code
);
});
}
#[test]
fn test_derive_default_on_interface_with_object_literal_field() {
let source = r#"
/** @derive(Default) */
export interface Assignment {
name: string;
scores: { [key: string]: number };
active: boolean;
}
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors for interface with object literal field. Got: {:?}",
result.diagnostics
);
assert!(
result.code.contains("assignmentDefaultValue"),
"Should generate assignmentDefaultValue function. Got:\n{}",
result.code
);
assert!(
result.code.contains("scores: {}"),
"Object literal field should default to {{}}. Got:\n{}",
result.code
);
});
}
#[test]
fn test_inline_jsdoc_with_export_interface() {
let source =
r#"/** @derive(Deserialize) */ export interface User { name: string; age: number; }"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors for inline JSDoc, got {}",
error_count
);
assert!(
result.code.contains("User") && result.code.contains("deserialize"),
"Should generate deserialize for Deserialize on interface. Got:\n{}",
result.code
);
});
}
#[test]
fn test_inline_jsdoc_with_export_class() {
let source = r#"/** @derive(Debug) */ export class User { name: string; }"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors for inline JSDoc on class, got {}",
error_count
);
assert!(
result.code.contains("toString"),
"Should generate toString for Debug on class. Got:\n{}",
result.code
);
});
}
#[test]
fn test_inline_jsdoc_with_export_enum() {
let source = r#"/** @derive(Debug) */ export enum Status { Active, Inactive }"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors for inline JSDoc on enum, got {}",
error_count
);
assert!(
result.code.contains("Status") && result.code.contains("toString"),
"Should generate toString for Debug on enum. Got:\n{}",
result.code
);
});
}
#[test]
fn test_inline_jsdoc_with_export_type() {
let source = r#"/** @derive(Debug) */ export type Point = { x: number; y: number; }"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors for inline JSDoc on type, got {}",
error_count
);
assert!(
result.code.contains("Point") && result.code.contains("toString"),
"Should generate toString for Debug on type alias. Got:\n{}",
result.code
);
});
}
#[test]
fn test_early_bailout_no_derive_returns_unchanged() {
use crate::expand_inner;
let source = r#"
class User {
name: string;
constructor(name: string) {
this.name = name;
}
}
"#;
let result = expand_inner(source, "test.ts", None).unwrap();
assert_eq!(
result.code, source,
"Code without @derive should be returned unchanged"
);
assert!(result.types.is_none(), "No type output expected");
assert!(result.diagnostics.is_empty(), "No diagnostics expected");
assert!(
result.source_mapping.is_none(),
"No source mapping expected"
);
}
#[test]
fn test_early_bailout_svelte_runes_unchanged() {
use crate::expand_inner;
let source = r#"
let count = $state(0);
let double = $derived(count * 2);
function increment() {
count++;
}
"#;
let result = expand_inner(source, "Counter.svelte.ts", None).unwrap();
assert_eq!(
result.code, source,
"Svelte runes without @derive should be unchanged"
);
assert!(
result.diagnostics.is_empty(),
"No diagnostics for Svelte runes"
);
}
#[test]
fn test_early_bailout_svelte_props_unchanged() {
use crate::expand_inner;
let source = r#"
interface Props {
name: string;
count?: number;
}
let { name, count = 0 }: Props = $props();
"#;
let result = expand_inner(source, "Component.svelte.ts", None).unwrap();
assert_eq!(
result.code, source,
"Svelte $props() without @derive should be unchanged"
);
assert!(
result.diagnostics.is_empty(),
"No diagnostics for Svelte $props"
);
}
#[test]
fn test_early_bailout_complex_svelte_component_unchanged() {
use crate::expand_inner;
let source = r#"
interface Props {
items: string[];
selected?: string;
}
let { items, selected = '' }: Props = $props();
let filteredItems = $derived(
items.filter(item => item.includes(selected))
);
let count = $state(0);
let message = $derived.by(() => {
if (count === 0) return 'No items';
return `${count} items`;
});
function handleClick() {
count++;
}
$effect(() => {
console.log('Count changed:', count);
});
"#;
let result = expand_inner(source, "List.svelte.ts", None).unwrap();
assert_eq!(
result.code, source,
"Complex Svelte code without @derive should be unchanged"
);
assert!(result.diagnostics.is_empty(), "No diagnostics expected");
}
#[test]
fn test_with_derive_processes_normally() {
use crate::expand_inner;
let source = r#"
/** @derive(Debug) */
class User {
name: string;
}
"#;
let result = expand_inner(source, "test.ts", None).unwrap();
assert!(
result.code.contains("toString"),
"Debug macro should generate toString"
);
assert_ne!(result.code, source, "Code with @derive should be modified");
}
#[test]
fn test_derive_in_string_literal_still_skipped() {
use crate::expand_inner;
let source = r#"
const msg = "Use @derive to add methods";
class User {
name: string;
}
"#;
let result = expand_inner(source, "test.ts", None).unwrap();
assert!(
result.diagnostics.iter().all(|d| d.level != "error"),
"No errors expected even with @derive in string literal"
);
}
#[test]
fn test_early_bailout_empty_file() {
use crate::expand_inner;
let source = "";
let result = expand_inner(source, "empty.ts", None).unwrap();
assert_eq!(
result.code, source,
"Empty file should be returned unchanged"
);
assert!(
result.diagnostics.is_empty(),
"No diagnostics for empty file"
);
}
#[test]
fn test_early_bailout_only_comments() {
use crate::expand_inner;
let source = r#"
// This is a comment
/* Another comment */
/**
* JSDoc comment without derive
*/
"#;
let result = expand_inner(source, "comments.ts", None).unwrap();
assert_eq!(
result.code, source,
"Comments-only file should be returned unchanged"
);
assert!(
result.diagnostics.is_empty(),
"No diagnostics for comments-only file"
);
}
#[test]
fn test_early_bailout_regular_typescript() {
use crate::expand_inner;
let source = r#"
interface User {
id: string;
name: string;
email: string;
}
type Role = 'admin' | 'user' | 'guest';
enum Status {
Active,
Inactive,
Pending
}
function createUser(name: string): User {
return {
id: crypto.randomUUID(),
name,
email: `${name}@example.com`
};
}
const users: Map<string, User> = new Map();
export { User, Role, Status, createUser, users };
"#;
let result = expand_inner(source, "types.ts", None).unwrap();
assert_eq!(
result.code, source,
"Regular TypeScript should be returned unchanged"
);
assert!(
result.diagnostics.is_empty(),
"No diagnostics for regular TypeScript"
);
}
#[test]
fn test_serialize_generates_correct_field_access() {
use crate::expand_inner;
let source = r#"
/** @derive(Serialize) */
class Point {
x: number;
y: number;
}
"#;
let result = expand_inner(source, "test.ts", None).unwrap();
assert!(
result.code.contains("__type"),
"Should have __type marker. Got:\n{}",
result.code
);
assert!(
result.code.contains("result.x =") || result.code.contains("result.x="),
"Should use direct property access for x. Got:\n{}",
result.code
);
assert!(
!result.code.contains("`${"),
"Should not have template literal syntax. Got:\n{}",
result.code
);
assert!(
!result.code.contains("#0"),
"Should not have SWC syntax context markers. Got:\n{}",
result.code
);
}
fn make_foreign_type(
name: &str,
from: Vec<&str>,
serialize_expr: Option<&str>,
deserialize_expr: Option<&str>,
default_expr: Option<&str>,
has_shape_expr: Option<&str>,
expression_namespaces: Vec<&str>,
) -> ForeignTypeConfig {
ForeignTypeConfig {
name: name.to_string(),
namespace: if name.contains('.') {
Some(name.rsplit_once('.').unwrap().0.to_string())
} else {
None
},
from: from.into_iter().map(|s| s.to_string()).collect(),
serialize_expr: serialize_expr.map(|s| s.to_string()),
serialize_import: None,
deserialize_expr: deserialize_expr.map(|s| s.to_string()),
deserialize_import: None,
default_expr: default_expr.map(|s| s.to_string()),
default_import: None,
has_shape_expr: has_shape_expr.map(|s| s.to_string()),
has_shape_import: None,
aliases: vec![],
expression_namespaces: expression_namespaces
.into_iter()
.map(|s| s.to_string())
.collect(),
}
}
#[test]
fn test_derive_deserialize_union_with_foreign_type_uses_has_shape() {
let source = r#"
import type { DateTime } from 'effect';
/** @derive(Deserialize) */
type FlexibleValue = DateTime.DateTime | RegularType;
"#;
GLOBALS.set(&Default::default(), || {
set_foreign_types(vec![make_foreign_type(
"DateTime.DateTime",
vec!["effect"],
Some("(v) => DateTime.formatIso(v)"),
Some("(raw) => DateTime.unsafeFromDate(new Date(raw))"),
Some("() => DateTime.unsafeNow()"),
Some("(v) => typeof v === \"string\""),
vec!["DateTime"],
)]);
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
clear_foreign_types();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("DateTime.unsafeFromDate")
|| result.code.contains("__mf_DateTime.unsafeFromDate"),
"Should use foreign type deserialize expression. Got:\n{}",
result.code
);
assert!(
!result
.code
.contains("dateTime.dateTimeDeserializeWithContext"),
"Should NOT generate broken dotted deserialize fn. Got:\n{}",
result.code
);
assert!(
result.code.contains("typeof v === \"string\""),
"Should use foreign type hasShape expression. Got:\n{}",
result.code
);
});
}
#[test]
fn test_derive_deserialize_union_foreign_only_types() {
let source = r#"
import type { DateTime, BigDecimal } from 'effect';
/** @derive(Deserialize) */
type FlexValue = DateTime.DateTime | BigDecimal.BigDecimal;
"#;
GLOBALS.set(&Default::default(), || {
set_foreign_types(vec![
make_foreign_type(
"DateTime.DateTime",
vec!["effect"],
Some("(v) => DateTime.formatIso(v)"),
Some("(raw) => DateTime.unsafeFromDate(new Date(raw))"),
Some("() => DateTime.unsafeNow()"),
Some("(v) => typeof v === \"string\""),
vec!["DateTime"],
),
make_foreign_type(
"BigDecimal.BigDecimal",
vec!["effect"],
Some("(v) => BigDecimal.format(v)"),
Some("(raw) => BigDecimal.fromString(String(raw))"),
Some("() => BigDecimal.unsafeFromNumber(0)"),
Some("(v) => typeof v === \"string\" || typeof v === \"number\""),
vec!["BigDecimal"],
),
]);
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
clear_foreign_types();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("DateTime.unsafeFromDate")
|| result.code.contains("__mf_DateTime.unsafeFromDate"),
"Should have DateTime foreign deserialize. Got:\n{}",
result.code
);
assert!(
result.code.contains("BigDecimal.fromString")
|| result.code.contains("__mf_BigDecimal.fromString"),
"Should have BigDecimal foreign deserialize. Got:\n{}",
result.code
);
assert!(
result.code.contains("flexValueHasShape"),
"Should generate hasShape for union. Got:\n{}",
result.code
);
assert!(
!result
.code
.contains("dateTime.dateTimeDeserializeWithContext"),
"Should NOT generate broken DateTime dotted identifier. Got:\n{}",
result.code
);
assert!(
!result
.code
.contains("bigDecimal.bigDecimalDeserializeWithContext"),
"Should NOT generate broken BigDecimal dotted identifier. Got:\n{}",
result.code
);
});
}
#[test]
fn test_derive_deserialize_union_foreign_without_has_shape() {
let source = r#"
import type { DateTime } from 'effect';
/** @derive(Deserialize) */
type Value = DateTime.DateTime | RegularType;
"#;
GLOBALS.set(&Default::default(), || {
set_foreign_types(vec![make_foreign_type(
"DateTime.DateTime",
vec!["effect"],
Some("(v) => DateTime.formatIso(v)"),
Some("(raw) => DateTime.unsafeFromDate(new Date(raw))"),
Some("() => DateTime.unsafeNow()"),
None, vec!["DateTime"],
)]);
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
clear_foreign_types();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("DateTime.unsafeFromDate")
|| result.code.contains("__mf_DateTime.unsafeFromDate"),
"Should use foreign deserialize even without hasShape. Got:\n{}",
result.code
);
assert!(
!result
.code
.contains("dateTime.dateTimeDeserializeWithContext"),
"Should NOT generate broken dotted identifier. Got:\n{}",
result.code
);
});
}
#[test]
fn test_derive_deserialize_union_mixed_foreign_and_primitives() {
let source = r#"
import type { DateTime } from 'effect';
/** @derive(Deserialize) */
type MaybeDate = DateTime.DateTime | string | number;
"#;
GLOBALS.set(&Default::default(), || {
set_foreign_types(vec![make_foreign_type(
"DateTime.DateTime",
vec!["effect"],
Some("(v) => DateTime.formatIso(v)"),
Some("(raw) => DateTime.unsafeFromDate(new Date(raw))"),
Some("() => DateTime.unsafeNow()"),
Some("(v) => typeof v === \"string\""),
vec!["DateTime"],
)]);
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
clear_foreign_types();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(error_count, 0, "Should have no errors, got {}", error_count);
assert!(
result.code.contains("DateTime.unsafeFromDate")
|| result.code.contains("__mf_DateTime.unsafeFromDate"),
"Should use foreign type deserialize in mixed union. Got:\n{}",
result.code
);
assert!(
result.code.contains("typeof value === \"string\""),
"Should have primitive string check. Got:\n{}",
result.code
);
assert!(
result.code.contains("typeof value === \"number\""),
"Should have primitive number check. Got:\n{}",
result.code
);
});
}
#[test]
fn test_derive_default_inline_object_union_with_default() {
let source = r#"
/** @derive(Default, Serialize, Deserialize) */
/** @serde({ tag: "type", content: "value" }) */
export type PropValue = /** @default */ { type: 'String'; value: string } | { type: 'Number'; value: number } | { type: 'Boolean'; value: boolean } | { type: 'Json'; value: string } | { type: 'Asset'; value: string } | { type: 'Page'; value: string } | { type: 'Expression'; value: string };
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors for inline object union with @default. Got: {:?}",
result.diagnostics
);
assert!(
result.code.contains("propValueDefaultValue"),
"Should generate propValueDefaultValue function. Got:\n{}",
result.code
);
assert!(
result.code.contains("'String'") || result.code.contains("\"String\""),
"Should contain 'String' literal value in default. Got:\n{}",
result.code
);
});
}
#[test]
fn test_derive_default_inline_object_union_without_default() {
let source = r#"
/** @derive(Default) */
export type Status = { kind: 'Active'; data: string } | { kind: 'Inactive'; reason: string };
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors for all-object union (fallback to first). Got: {:?}",
result.diagnostics
);
assert!(
result.code.contains("statusDefaultValue"),
"Should generate statusDefaultValue function. Got:\n{}",
result.code
);
assert!(
result.code.contains("'Active'") || result.code.contains("\"Active\""),
"Should contain 'Active' from first variant. Got:\n{}",
result.code
);
});
}
#[test]
fn test_derive_default_inline_object_union_default_on_non_first() {
let source = r#"
/** @derive(Default) */
export type PropValue = { type: 'String'; value: string } | /** @default */ { type: 'Number'; value: number };
"#;
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "test.ts").unwrap();
let error_count = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.count();
assert_eq!(
error_count, 0,
"Should have no errors with @default on non-first object member. Got: {:?}",
result.diagnostics
);
assert!(
result.code.contains("propValueDefaultValue"),
"Should generate propValueDefaultValue function. Got:\n{}",
result.code
);
assert!(
result.code.contains("'Number'") || result.code.contains("\"Number\""),
"Should contain 'Number' from @default variant. Got:\n{}",
result.code
);
});
}
#[test]
fn test_no_false_positive_derive_in_prose_jsdoc() {
let source = r#"
import type { Option } from "effect";
import { Exit } from "effect";
import type { Utc } from "effect/DateTime";
/** Deserialize result format from @derive(Deserialize) */
export type DeserializeResult<T> =
| { success: true; value: T }
| { success: false; errors: Array<{ field: string; message: string }> };
/** Converts a deserialize result to an Effect Exit */
export function toExit<T>(
result: DeserializeResult<T>,
): Exit.Exit<T, Array<{ field: string; message: string }>> {
if (result.success) {
return Exit.succeed(result.value);
} else {
return Exit.fail(result.errors);
}
}
/** Base interface for field controllers */
export interface FieldController<T> {
readonly path: ReadonlyArray<string | number>;
readonly name: string;
readonly constraints: Record<string, unknown>;
readonly label?: string;
readonly description?: string;
readonly placeholder?: string;
readonly disabled?: boolean;
readonly readonly?: boolean;
get(): T;
set(value: T): void;
/** Transform input value before setting (applies configured format like uppercase, trim, etc.) */
transform(value: T): T;
getError(): Option.Option<Array<string>>;
setError(value: Option.Option<Array<string>>): void;
getTainted(): Option.Option<boolean>;
setTainted(value: Option.Option<boolean>): void;
validate(): Array<string>;
}
/** Number field controller with numeric constraints */
export interface NumberFieldController extends FieldController<number | null> {
readonly min?: number;
readonly max?: number;
readonly step?: number;
}
/** Select field controller with options */
export interface SelectFieldController<T = string> extends FieldController<T> {
readonly options: ReadonlyArray<{ label: string; value: T }>;
}
/** Toggle/boolean field controller */
export interface ToggleFieldController extends FieldController<boolean> {
readonly styleClasses?: string;
}
/** Checkbox field controller */
export interface CheckboxFieldController extends FieldController<boolean> {
readonly styleClasses?: string;
}
/** Switch field controller */
export type SwitchFieldController = FieldController<boolean>;
/** Text area field controller */
export type TextAreaFieldController = FieldController<string | null>;
/** Radio group option */
export interface RadioGroupOption {
readonly label: string;
readonly value: string;
readonly icon?: unknown;
}
/** Radio group field controller */
export interface RadioGroupFieldController extends FieldController<string> {
readonly options: ReadonlyArray<RadioGroupOption>;
readonly orientation?: "horizontal" | "vertical";
}
/** Tags field controller (array of strings) */
export type TagsFieldController = FieldController<ReadonlyArray<string>>;
/** Combobox item type */
export interface ComboboxItem<T = unknown> {
readonly label: string;
readonly value: T;
}
/** Configuration for combobox fields that store graph edge objects */
export interface EdgeConfig {
/** Field path within the edge object that references the entity (e.g. "in") */
readonly entityField: string;
}
/** Combobox field controller */
export interface ComboboxFieldController<
T = string,
> extends FieldController<T | null> {
readonly items: ReadonlyArray<ComboboxItem<T>>;
readonly allowCustom?: boolean;
readonly roundedClass?: string;
/** URLs to fetch items from (populated by @comboboxController({ fetchUrls: [...] })) */
readonly fetchUrls?: ReadonlyArray<string>;
/** Key path to extract the display label from fetched items (default: "name") */
readonly itemLabelKeyName?: string;
/** Key path to extract the value from fetched items (default: "id") */
readonly itemValueKeyName?: string;
/** Edge configuration for fields that store graph edges instead of direct entities */
readonly edgeConfig?: EdgeConfig;
}
/** Combobox multiple field controller */
export interface ComboboxMultipleFieldController<
T = string,
> extends FieldController<ReadonlyArray<T>> {
readonly items: ReadonlyArray<ComboboxItem<T>>;
readonly allowCustom?: boolean;
readonly roundedClass?: string;
/** URLs to fetch items from (populated by @comboboxController({ fetchUrls: [...] })) */
readonly fetchUrls?: ReadonlyArray<string>;
/** Key path to extract the display label from fetched items (default: "name") */
readonly itemLabelKeyName?: string;
/** Key path to extract the value from fetched items (default: "id") */
readonly itemValueKeyName?: string;
/** Edge configuration for fields that store graph edges instead of direct entities */
readonly edgeConfig?: EdgeConfig;
}
/** Duration field controller - value is a [seconds, nanos] tuple */
export type DurationFieldController = FieldController<[number, number] | null>;
/** Date-time field controller */
export type DateTimeFieldController = FieldController<Utc | null>;
/** Date-only field controller (no time component) */
export type DateFieldController = FieldController<Utc | null>;
/** Multi-date picker field controller */
export type DatePickerMultipleFieldController = FieldController<Array<Utc> | null>;
/** Email field controller with subcontrollers */
export interface EmailFieldController extends FieldController<string | null> {
/** Controller for the email string input */
readonly emailController: FieldController<string | null>;
/** Controller for the "can email" toggle */
readonly canEmailController: FieldController<boolean>;
}
/** Phone field controller with subcontrollers */
export interface PhoneFieldController extends FieldController<unknown> {
readonly phoneTypeController: ComboboxFieldController<string>;
readonly numberController: FieldController<string | null>;
readonly canCallController: ToggleFieldController;
readonly canTextController: ToggleFieldController;
}
/** Enum/Variant field controller */
export interface EnumFieldController<
TVariant extends string = string,
TVariantControllers = { [K in TVariant]?: Record<string, FieldController<unknown>> },
> extends FieldController<{ type: TVariant; [key: string]: unknown } | null> {
readonly variants: Record<
TVariant,
{ label: string; fields?: Record<string, unknown> }
>;
readonly defaultVariant?: TVariant;
/** Derived variant detected from the current value (tag field or shape matching). */
readonly currentVariant: TVariant;
readonly legend?: string;
readonly selectLabel?: string;
readonly variantControllers?: TVariantControllers;
}
/** Base interface for array field controllers */
export interface ArrayFieldController<T> extends FieldController<ReadonlyArray<T>> {
at(index: number): FieldController<T>;
push(value: T): void;
remove(index: number): void;
swap(a: number, b: number): void;
}
/** Item state for array fieldset items */
export interface ArrayFieldsetItem<T> {
readonly _id: string;
readonly isLeaving: boolean;
readonly variant: "object" | "tuple";
readonly val?: [PropertyKey, T];
}
/** Combobox hydration config for array fieldsets */
export interface ArrayFieldsetComboboxConfig<T = unknown> {
readonly items: Array<{ label: string; value: T }>;
readonly setItems?: (items: Array<{ label: string; value: T }>) => void;
readonly itemLabelKeyName: string;
readonly itemValueKeyName: string;
readonly fetchConfigs: ReadonlyArray<{ url: string; schema?: unknown }>;
readonly skipInitialFetch?: boolean;
}
/** Array fieldset controller with element controllers */
export interface ArrayFieldsetController<
TItem,
TElementControllers extends Record<string, FieldController<unknown>> = Record<
string,
FieldController<unknown>
>,
> extends ArrayFieldController<TItem> {
/** Template structure for new items */
readonly itemStructure: TItem;
/** Legend text for the fieldset */
readonly legendText?: string;
/** Radio group configuration for "main" item selection */
readonly radioGroup?: {
readonly mainFieldKey: string;
};
/** Whether to display items in card style */
readonly card?: boolean;
/** Whether items can be reordered via drag-and-drop */
readonly reorderable?: boolean;
/** Combobox fetch configurations for items */
readonly comboboxFetchConfigs?: ReadonlyArray<ArrayFieldsetComboboxConfig>;
/** Create element controllers for a specific item */
elementControllers(context: {
index: number;
item: ArrayFieldsetItem<TItem>;
}): TElementControllers;
}
/** Base Gigaform interface - generated forms extend this */
export interface BaseGigaform<TData> {
data: TData;
validate(): Exit.Exit<TData, Array<{ field: string; message: string }>>;
asyncValidate(): Promise<
Exit.Exit<TData, ReadonlyArray<{ field: string; message: string }>>
>;
reset(overrides: Partial<TData> | null): void;
}
/** Gigaform with variant support (for unions/enums) */
export interface VariantGigaform<
TData,
TVariant extends string,
> extends BaseGigaform<TData> {
readonly currentVariant: TVariant;
switchVariant(variant: TVariant): void;
}
/** Manual entry controllers for site address fields */
export interface SiteManualEntryControllers {
readonly addressLine1: FieldController<string | null>;
readonly addressLine2: FieldController<string | null>;
readonly locality: FieldController<string | null>;
readonly administrativeAreaLevel1: FieldController<string | null>;
readonly postalCode: FieldController<string | null>;
readonly country: FieldController<string | null>;
}
/** Filter configuration for duplicate site search */
export interface SiteDuplicateSearchFilters {
readonly siteId?: string;
readonly filters: ReadonlyArray<{
field: string;
op: string;
value: unknown;
}>;
}
/** Address lookup field controller for Google Places integration */
export interface AddressLookupFieldController<
TSite = unknown,
> extends FieldController<TSite | null> {
/** Label background class for floating label */
readonly labelBgClass?: string;
}
/** Site fieldset controller with lookup and manual entry modes */
export interface SiteFieldsetController<
TSite = unknown,
> extends FieldController<TSite | null> {
/** Controller for Google Places address lookup */
readonly lookupController: AddressLookupFieldController<TSite>;
/** Controllers for manual address entry fields */
readonly manualEntryControllers: SiteManualEntryControllers;
/** Configuration for duplicate site search */
readonly duplicateSearchFilters?: SiteDuplicateSearchFilters;
/** Optional scrolling container getter for scroll position preservation */
readonly scrollingContainer?: () => HTMLElement | null;
}
/** Wrapper for nullable nested struct field controllers */
export interface NullableControllers<_T> {
isNull(): boolean;
initialize(): void;
clear(): void;
}
"#;
assert!(
!crate::has_macro_annotations(source),
"has_macro_annotations should return false for a file with @derive only in prose JSDoc"
);
GLOBALS.set(&Default::default(), || {
let program = parse_module(source);
let host = MacroExpander::new().unwrap();
let result = host.expand(source, &program, "types.ts").unwrap();
assert!(
!result.changed,
"File with @derive in prose JSDoc should NOT trigger expansion. Got changed=true with code:\n{}",
result.code
);
let errors: Vec<_> = result
.diagnostics
.iter()
.filter(|d| d.level == DiagnosticLevel::Error)
.collect();
assert!(
errors.is_empty(),
"Should have no errors for a file with no macros, got: {:?}",
errors
);
});
}