This document establishes core principles for developing Haxe macros in the Reflaxe.Elixir project, based on lessons learned from implementing features like async/await, build macros, and AST transformations.
- Core Principles
- AST Manipulation Best Practices
- Build Macro Architecture
- Type System Integration
- Error Handling
- Testing Strategy
- Performance Considerations
Principle: Keep AST nodes (TypedExpr/Expr) as long as possible before converting to strings.
Why: AST provides structural information for proper transformations.
Good:
// Work with AST structures
var transformedBody = processAwaitInExpr(func.expr);
var newExpr = transformAnonymousFunctionBody(transformedBody, pos);Bad:
// Convert to string too early
var stringBody = func.expr.toString();
var processed = stringBody.replace("await", "js.Syntax.code(\"await\")");Principle: Design transformation functions to be stateless and independent.
Why: Build macros process each class individually and cannot share state.
Implementation:
// Good: Stateless function
static function transformAsyncFunction(field: Field, func: Function): Field {
// All data comes from parameters
// No static variables or shared state
}
// Bad: Stateful transformation
static var processedFunctions: Array<String> = [];
static function transformAsyncFunction(field: Field, func: Function): Field {
processedFunctions.push(field.name); // State that doesn't work across classes
}Principle: Use different transformation strategies based on context.
Example from async/await:
// Class methods: Wrap in async IIFE
static function transformFunctionBody(expr: Expr, pos: Position): Expr {
return macro @:pos(pos) {
return js.Syntax.code("(async function() {0})()", ${wrapInAsyncFunction(transformedBody, pos)});
};
}
// Anonymous functions: Direct Promise wrapping
static function transformAnonymousFunctionBody(expr: Expr, pos: Position): Expr {
return macro @:pos(pos) return js.lib.Promise.resolve($processedExpr);
}Principle: Use flexible pattern matching that handles edge cases and different AST structures.
Example: Promise type detection that handles both imported and qualified forms:
switch (returnType) {
case TPath(p) if (p.name == "Promise" && (p.pack.length == 0 || (p.pack.length == 2 && p.pack[0] == "js" && p.pack[1] == "lib"))):
// Handles both Promise<T> (imported) and js.lib.Promise<T> (qualified)
return returnType;
case _:
// Wrap in Promise<T>
return TPath({name: "Promise", pack: ["js", "lib"], params: [TPType(returnType)]});
}Principle: Use expr.map() for recursive traversal of expression trees.
Implementation:
static function processExpression(expr: Expr): Expr {
return switch (expr.expr) {
case EMeta(meta, funcExpr) if (isAsyncMeta(meta.name)):
// Handle specific case
transformAnonymousAsync(funcExpr, func, meta, expr.pos);
case _:
// Recursively process all child expressions
expr.map(processExpression);
}
}Pattern: Remove original metadata and add new metadata for downstream processing.
// Remove @:async metadata
var newMeta = removeAsyncMeta(field.meta);
// Add :jsAsync metadata for JavaScript generator
newMeta.push({
name: ":jsAsync",
params: [],
pos: field.pos
});Principle: Always preserve source positions for debugging and error reporting.
// Good: Preserve positions
return {
expr: EReturn(macro @:pos(pos) js.lib.Promise.resolve($returnExpr)),
pos: pos
};
// Bad: Lose position information
return {
expr: EReturn(macro js.lib.Promise.resolve($returnExpr)),
pos: Context.currentPos() // Wrong position
};Principle: Maintain type safety throughout transformation pipeline.
// Good: Explicit type annotation
static function transformReturnType(returnType: Null<ComplexType>, pos: Position): ComplexType {
// Clear return type contract
}
// Bad: Lose type information
static function transformReturnType(returnType: Dynamic, pos: Dynamic): Dynamic {
// Unclear contracts, harder to debug
}Principle: Use Compiler.addGlobalMetadata for comprehensive processing.
public static function init(): Void {
Compiler.addGlobalMetadata("", "@:build(reflaxe.js.Async.build())", true, true, false);
}Benefits:
- Processes ALL classes automatically
- Finds metadata anywhere in the codebase
- No manual application required
Pattern: Handle different contexts in separate phases.
public static function build(): Array<Field> {
var fields = Context.getBuildFields();
var transformedFields: Array<Field> = [];
for (field in fields) {
switch (field.kind) {
case FFun(func):
if (hasAsyncMeta(field.meta)) {
// Phase 1: Transform class methods
var transformedField = transformAsyncFunction(field, func);
// Phase 2: Process nested expressions
transformedField = processExpression(transformedField);
} else {
// Phase 2: Process expressions even in non-async methods
field = processExpression(field);
}
// Handle other field types...
}
}
return transformedFields;
}Pattern: Use guards and helper functions for clear metadata detection.
static function hasAsyncMeta(meta: Metadata): Bool {
if (meta == null) return false;
for (entry in meta) {
if (entry.name == ":async" || entry.name == "async") {
return true;
}
}
return false;
}
// Use in pattern matching
switch (expr.expr) {
case EMeta(meta, funcExpr) if (isAsyncMeta(meta.name)):
// Clear intent
}Principle: Account for how Haxe's import system affects AST structure.
Key Insight: When js.lib.Promise is imported, references appear with empty pack arrays:
// User writes: Promise<String>
// AST shows: TPath({name: Promise, pack: []}) // Empty pack!
// Not: TPath({name: Promise, pack: ["js", "lib"]})Solution: Flexible matching for both forms:
case TPath(p) if (p.name == "Promise" && (p.pack.length == 0 || p.pack.join(".") == "js.lib")):Pattern: Transform types consistently while preserving semantics.
static function transformReturnType(returnType: Null<ComplexType>, pos: Position): ComplexType {
if (returnType == null) {
// Default case
return TPath({name: "Promise", pack: ["js", "lib"], params: [TPType(macro: Dynamic)]});
}
// Check if already transformed
switch (returnType) {
case TPath(p) if (isPromiseType(p)):
return returnType; // Don't double-wrap
case _:
return wrapInPromise(returnType); // Transform
}
}Pattern: Build ComplexType structures properly for reliable compilation.
// Good: Proper structure
return TPath({
name: "Promise",
pack: ["js", "lib"],
params: [TPType(innerType)]
});
// Bad: Incomplete structure
return TPath({
name: "Promise"
// Missing pack and params
});Principle: Provide fallbacks when transformation cannot proceed.
static function transformAnonymousFunctionBody(expr: Expr, pos: Position): Expr {
if (expr == null) {
// Graceful fallback
return macro @:pos(pos) return js.lib.Promise.resolve(null);
}
// Normal processing
var processedExpr = processAwaitInExpr(expr);
// ...
}Principle: Provide context and location information in errors.
// Good: Contextual error
Context.error("@:async functions must return Promise<T>, got: " + returnType, pos);
// Bad: Generic error
Context.error("Invalid type", pos);Principle: Validate input before attempting transformation.
static function validateAsyncFunction(func: Function, pos: Position): Void {
if (func.ret == null) {
Context.warning("@:async function without return type will default to Promise<Dynamic>", pos);
}
// Additional validations...
}Principle: Test transformations at the Haxe compilation level, not runtime.
Approach: Snapshot testing with expected output comparison:
# Test structure
test/tests/AsyncAnonymousFunctions/
├── compile.hxml # Compilation configuration
├── MainMinimal.hx # Test cases
└── out/main.js # Generated output to verifyTests should verify:
- Compilation Success: No compilation errors
- Type Safety: No type mismatch errors
- Output Correctness: Generated code matches expectations
- Edge Cases: Handle null/empty inputs gracefully
Pattern: Strategic trace statements for understanding AST structure.
// Development traces (remove in production)
trace("transformReturnType received: " + returnType);
trace("AST structure: " + expr.expr);
// Use during development, remove before commitPrinciple: Avoid unnecessary recursive processing.
// Good: Early return for irrelevant cases
static function processExpression(expr: Expr): Expr {
if (expr == null) return null;
switch (expr.expr) {
case EMeta(meta, _) if (!isRelevantMeta(meta.name)):
return expr; // Skip processing
case _:
return expr.map(processExpression); // Only process when needed
}
}Principle: Order pattern matching by frequency and early exits.
// Good: Most common cases first
switch (expr.expr) {
case EBlock(_): // Most common
// Handle efficiently
case EMeta(meta, _) if (isAsyncMeta(meta.name)): // Specific case
// Handle async metadata
case _: // Fallback
expr.map(processExpression);
}Principle: Check if transformation is needed before applying.
// Good: Check before transforming
if (hasAsyncMeta(field.meta)) {
return transformAsyncFunction(field, func);
} else {
return field; // No transformation needed
}
// Bad: Always transform
return transformAsyncFunction(field, func); // Even when not needed- Start Simple: Basic transformation first
- Add Edge Cases: Handle null, empty, malformed inputs
- Optimize: Improve performance and error handling
- Document: Capture learnings and patterns
- Create Test Case: Minimal example of desired behavior
- Implement Transformation: Make test pass
- Add Edge Cases: Handle variations and errors
- Refactor: Clean up and optimize
- Document Decisions: Why certain approaches were chosen
- Capture Insights: Unexpected behaviors and solutions
- Share Patterns: Reusable solutions for future work
- Maintain Examples: Working code that demonstrates principles
These principles form the foundation for reliable, maintainable macro development in Reflaxe.Elixir. They are based on real-world experience implementing complex features like async/await anonymous function support.
Key takeaways:
- AST preservation over string manipulation
- Context-aware transformation strategies
- Robust pattern matching for edge cases
- Stateless design for scalability
- Comprehensive testing at the compiler level
Future macro development should build on these principles while capturing new learnings in this documentation framework.