Definition & Overview
An Elixir Macro is a compile-time code transformation mechanism that allows developers to write code that writes code. Macros receive the Abstract Syntax Tree (AST) representation of their arguments as input and return a new AST that gets compiled in place of the original macro call. This enables Domain-Specific Languages (DSLs), automatic code generation, interface abstraction, and compile-time optimizations that would be impossible with regular functions.
Unlike functions which operate on values at runtime, macros operate on code structure at compile time. When the Elixir compiler encounters a macro call, it executes the macro immediately and replaces the call site with the generated AST. This means macros can inspect the structure of their arguments (variable names, operators, nested expressions) and generate entirely different code based on that structure.
In the Prismatic Platform, macros are extensively used for creating self-registering systems where modules automatically register themselves with central registries at compile time. The OSINT tool registration system, AIAD agent discovery, and Academy topic registration all use @after_compile hooks combined with macros to extract configuration from compiled bytecode and populate ETS registries during application startup.
Technical Deep Dive
AST Representation
Elixir represents all code as a three-element tuple AST where every expression is either an atom (literal), a three-element tuple {function, metadata, arguments}, or a two-element tuple {value, metadata} for literals with metadata:
# Code: 1 + 2
# AST: {:+, [context: Elixir, import: Kernel], [1, 2]}
# Code: foo(bar, baz)
# AST: {:foo, [], [{:bar, [], []}, {:baz, [], []}]}
# Code: %{key: value}
# AST: {:%{}, [], [key: {:value, [], []}]}
Macros receive this AST representation and can inspect, transform, and generate new AST structures that become part of the compiled module.
Quote and Unquote
The quote/2 special form converts Elixir code into its AST representation, while unquote/1 injects runtime values into quoted expressions:
defmodule PrismaticOsintCore.Tool do
@moduledoc """
Macro-based tool registration system enabling OSINT adapters
to self-register with comprehensive metadata at compile time.
"""
defmacro register_tool(config) do
# Access the calling module at compile time
caller_module = __CALLER__.module
quote do
@after_compile {PrismaticOsintCore.ToolRegistry, :register_compiled_tool}
@tool_config unquote(config)
def __tool_config__, do: unquote(config)
def __tool_module__, do: unquote(caller_module)
# Generate helper functions based on configuration
unquote(generate_tool_helpers(config))
end
end
defp generate_tool_helpers(config) do
# Extract configuration at compile time to generate appropriate helpers
case config[:api_style] do
:source ->
quote do
def search(query, opts \\ []), do: source_search(query, opts)
def get_details(source_id, opts \\ []), do: source_get_details(source_id, opts)
end
:provider ->
quote do
def run(subject, state), do: provider_run(subject, state)
def init(opts), do: provider_init(opts)
end
end
end
endCompile-Time Hooks
Macros can register compile-time hooks that execute after the module is compiled:
defmodule PrismaticAcademy.Topic do
@moduledoc """
Self-registering topic system using @after_compile hooks to extract
topic metadata from compiled modules and populate the topic registry.
"""
defmacro register_topic(config) do
quote do
@after_compile {PrismaticAcademy.TopicRegistry, :register_topic}
@topic_config unquote(config)
def __topic_config__, do: @topic_config
def topic_slug, do: unquote(config[:slug])
def topic_category, do: unquote(config[:category])
def difficulty_level, do: unquote(config[:difficulty])
end
end
end
defmodule PrismaticAcademy.TopicRegistry do
def register_topic(env, _bytecode) do
module = env.module
# Extract topic configuration from compiled module
if function_exported?(module, :__topic_config__, 0) do
config = module.__topic_config__()
:ets.insert(:topic_registry, {config[:slug], module, config})
end
end
endMacro Hygiene
Elixir macros are partially hygienic, meaning variables defined in macros don't accidentally clash with variables in the calling code:
defmacro debug(expression) do
quote do
# These variables are hygienic - won't clash with caller's variables
result = unquote(expression)
metadata = %{
module: __MODULE__,
function: __FUNCTION__,
line: __LINE__
}
IO.puts("Debug: #{inspect()} at #{inspect()}")
result
end
end
# Usage - the 'result' and 'metadata' variables inside the macro
# don't interfere with any 'result' or 'metadata' in this scope
def test_function do
result = "original value"
debug(1 + 2) # Won't overwrite the 'result' variable above
result # Still "original value"
end
To break hygiene when needed (rare), use var!/2:
defmacro set_magic_variable(value) do
quote do
# This WILL create a variable in the caller's scope
var!(magic_value) = unquote(value)
end
endImplementation Patterns in Prismatic Platform
Self-Registering Systems
The most powerful pattern in the platform is self-registering modules that automatically register with centralized registries:
defmodule MyOSINTAdapter do
use PrismaticOsintCore.Tool
register_tool(%{
slug: "my-adapter",
name: "My OSINT Tool",
category: :global,
api_style: :provider,
input_fields: [
%{name: :query, type: :text, label: "Search Query", required: true}
],
requires_auth: true,
rate_limit: %{requests_per_second: 1, burst: 5}
})
# The register_tool macro automatically:
# - Stores configuration in module attributes
# - Generates helper functions based on api_style
# - Registers @after_compile hook for ETS insertion
# - Creates UI metadata for LiveView form generation
def provider_run(query, state) do
# Implementation...
{:ok, findings}
end
endDSL Creation
Macros enable clean DSLs for complex configuration:
defmodule PrismaticPerimeter.ComplianceRules do
defmacro compliance_framework(name, do: block) do
quote do
@framework_name unquote(name)
@rules []
unquote(block)
def framework_name, do: @framework_name
def all_rules, do: Enum.reverse(@rules)
end
end
defmacro rule(description, opts \\ [], do: check_block) do
quote do
rule_id = :erlang.unique_integer([:positive])
@rules [%{
id: rule_id,
description: unquote(description),
severity: unquote(opts[:severity] || :medium),
check: fn -> unquote(check_block) end
} | @rules]
end
end
end
# Usage creates a clean DSL:
defmodule NIS2Compliance do
import PrismaticPerimeter.ComplianceRules
compliance_framework "NIS2 Directive" do
rule "Multi-factor authentication must be enabled", severity: :critical do
PrismaticPerimeter.check_mfa_enabled()
end
rule "Incident response plan must exist" do
File.exists?("incident_response_plan.pdf")
end
end
endInterface Abstraction
Macros can generate repetitive interface code:
defmodule PrismaticStorage.Adapter do
defmacro __using__(_opts) do
quote do
@behaviour PrismaticStorageCore.Adapter
# Generate default implementations for optional callbacks
def health_check, do: {:ok, %{status: :healthy}}
def stats, do: {:ok, %{}}
defoverridable [health_check: 0, stats: 0]
# Generate telemetry helpers for each callback
unquote(generate_telemetry_wrappers())
end
end
defp generate_telemetry_wrappers do
callbacks = [:get, :put, :delete, :query]
for callback <- callbacks do
quote do
defp unquote(:"telemetry_#{}")(args, fun) do
:telemetry.span(
[:prismatic, :storage, unquote(callback)],
%{adapter: __MODULE__},
fn -> {fun.(), %{}} end
)
end
end
end
end
endCompile-Time Optimization
Macros can perform expensive computations at compile time:
defmodule PrismaticCore.Utils.UUID do
@moduledoc """
Deterministic UUID generation with compile-time optimization
for known string inputs.
"""
defmacro from_string(string) when is_binary(string) do
# Compute UUID at compile time for string literals
uuid = compute_uuid_from_string(string)
quote do: unquote(uuid)
end
defmacro from_string(string) do
# Runtime computation for dynamic strings
quote do: compute_uuid_from_string(unquote(string))
end
defp compute_uuid_from_string(string) do
:crypto.hash(:sha256, string)
|> Base.encode16(case: :lower)
|> String.slice(0, 36)
# Format as proper UUID...
end
endAdvanced Macro Techniques
Context Inspection
Macros can inspect their call context to make intelligent decisions:
defmodule PrismaticCore.Debug do
defmacro debug_context do
caller = __CALLER__
quote do
%{
module: unquote(caller.module),
function: unquote(caller.function),
file: unquote(caller.file),
line: unquote(caller.line),
context: unquote(caller.context),
aliases: unquote(Macro.escape(caller.aliases)),
imports: unquote(Macro.escape(caller.imports))
}
end
end
endCode Generation from External Data
Macros can read external files and generate code:
defmodule PrismaticOsint.SourceRegistry do
@external_resource "priv/osint_sources.json"
@sources "priv/osint_sources.json"
|> File.read!()
|> Jason.decode!()
for source <- @sources do
def source_info(unquote(source["slug"])) do
unquote(Macro.escape(source))
end
end
def source_info(_), do: nil
def all_sources, do: unquote(Macro.escape(@sources))
endBest Practices and Pitfalls
Best Practices
Use macros sparingly. Functions are easier to understand, debug, and test. Only use macros when you need compile-time code generation or inspection that's impossible with functions.
Keep macro logic simple. Complex macro logic is hard to debug because errors show up at the call site, not in the macro definition. Move complex logic to helper functions that the macro calls.
Always use quote do ... end. This provides better error messages and syntax highlighting than quote(do: ...).
Test the generated code, not just the macro. Write tests that verify the AST generated by your macros produces the expected behavior.
Document the generated interface. Users of your macro need to understand what functions/attributes/behaviors the macro creates in their module.
Common Pitfalls
Variable capture. Variables in the calling context can accidentally be captured by the macro if you're not careful with hygiene.
Multiple evaluation. If you unquote a variable multiple times, the expression gets evaluated multiple times:
# BAD - expensive_computation() runs twice
defmacro bad_macro(arg) do
quote do
result1 = unquote(arg)
result2 = unquote(arg) # Evaluates arg again!
{result1, result2}
end
end
# GOOD - evaluate once, use twice
defmacro good_macro(arg) do
quote do
value = unquote(arg)
{value, value}
end
end
Debugging difficulties. Macro-generated code can be hard to debug because stack traces point to the macro call site, not the generated code. Use Macro.expand/2 in IEx to see generated AST.
Compile-time dependencies. Files or resources read during macro expansion become compile-time dependencies. Use @external_resource to tell the compiler to recompile when they change.
Usage in Prismatic Platform
The platform uses macros primarily for three patterns:
| Pattern | Implementation | Purpose |
|---|---|---|
| Self-Registration | register_tool/1, register_topic/1, register_agent/1 | Automatic discovery and registry population |
| DSL Creation | Quality rules, compliance frameworks, workflow definitions | Domain-specific configuration languages |
| Interface Generation | Storage adapters, protocol implementations | Reduce boilerplate while maintaining type safety |
All macros in the platform follow strict conventions: they use @after_compile hooks for registration, generate helper functions based on configuration, and emit telemetry events for observability.
Testing Macros
defmodule PrismaticOsintCore.ToolTest do
use ExUnit.Case, async: true
test "register_tool/1 generates correct module attributes" do
defmodule TestTool do
use PrismaticOsintCore.Tool
register_tool(%{
slug: "test-tool",
name: "Test Tool",
category: :test
})
end
assert TestTool.__tool_config__()[:slug] == "test-tool"
assert TestTool.__tool_config__()[:name] == "Test Tool"
assert TestTool.__tool_config__()[:category] == :test
end
test "register_tool/1 generates appropriate helper functions" do
defmodule SourceTool do
use PrismaticOsintCore.Tool
register_tool(%{
slug: "source-tool",
api_style: :source
})
def source_search(_query, _opts), do: {:ok, []}
def source_get_details(_id, _opts), do: {:ok, %{}}
end
# Macro should have generated these functions:
assert function_exported?(SourceTool, :search, 2)
assert function_exported?(SourceTool, :get_details, 2)
end
endRelated Concepts
- Elixir - Programming language providing the macro system
- Behaviour Pattern - Often combined with macros for interface generation
- AST - Abstract syntax tree that macros manipulate
See Also
- Architecture - Platform architecture using macro patterns
- Apps - Applications using self-registering macro systems
- OSINT Core - Extensive macro-based tool registration
- Academy - Topic registration via macros
- AIAD - Agent standard using macro-based discovery
Connect & Contribute
Created by Tomáš Korcak (korczis) | Open Source under GHL