Mutare has two extension points, matching two keys in .mutare.exs:
:mutators— modules that produce mutations. A custom mutator adds new kinds of mutants alongside (or instead of) the built-in families.:extensions— non-mutating modules that define how to handle a library's compile-time syntax, so the built-in mutators can work in and around its macros. Extensions never appear in reports.
This guide helps you pick the piece you need and links to the full contracts in
the module docs. Every kind listed here has a small working example
under test/support/ in the Mutare repository.
Which piece do you want?
| You want to… | Implement | Read next |
|---|---|---|
Swap one AST node for another (and → or) | Mutare.Mutator with mutate/1 | Mutare.Mutator |
Make a mutator configurable, or gated on the module's @behaviours | mutate/2 | Mutare.Mutator |
Mutate a position: a clause's return value, an if condition, a head pattern | Mutare.Mutator.Structural | Mutare.Mutator.Structural |
| Match calls to a specific library or stdlib function | mutate/1,2 + Mutare.Calls.resolved_call_to/3 | Mutare.Calls |
| Skip a call, or keep a macro's arguments from being mutated | no code — call_routes: (and argument_marks:) in .mutare.exs | the README's "Routing calls" section |
| Describe how a DSL's macro arguments should be treated (a reusable adapter) | Mutare.CallRouting | Mutare.CallRouting |
Emit mutations inside a DSL fragment (an Ecto where, say) | Mutare.Mutator.MacroHost | Mutare.Mutator.MacroHost |
Handle a use whose injected imports/behaviours Mutare can't recover | Mutare.UseExpansion | Mutare.UseExpansion |
The first four are mutators and go under :mutators. The last three are
extension capabilities: implemented by a non-mutating module, they go under
:extensions (a mutator may also implement Mutare.CallRouting and
Mutare.Mutator.MacroHost itself — then the one :mutators entry enables
everything).
Writing a mutator
A mutator is a module implementing Mutare.Mutator: name/0 (the family name
shown in reports) plus at least one mutation-producing callback. The simplest
producer is mutate/1 — match the nodes you care about, return replacements,
:skip everything else:
defmodule MyApp.Mutators.AndOr do
@behaviour Mutare.Mutator
@impl true
def name, do: :and_or
@impl true
def mutate({:and, meta, [left, right]}), do: [{:or, meta, [left, right]}]
def mutate({:or, meta, [left, right]}), do: [{:and, meta, [left, right]}]
def mutate(_node), do: :skip
endEnable it in .mutare.exs — :builtins keeps the default families alongside
yours:
[mutators: [:builtins, MyApp.Mutators.AndOr]]Three rules keep you out of trouble; the why is in the Mutare.Mutator docs:
- Every replacement must compile. All mutants are compiled together into one program, so one bad replacement sinks the whole run. Reuse the original operands wherever you can.
- Never decide placement. You return replacement nodes; Mutare decides whether they're delivered in place or by lifting the enclosing function.
- Build literals with
Mutare.AST.literal/1, not by hand — a hand-built literal can silently render as the original source.
Emitting AST
Sourceror requires specific rendering metadata on AST nodes. Use the
Mutare.AST helpers to construct nodes with the required metadata:
| Invariant | Helper |
|---|---|
| Fresh literals need clean, derived meta, or the renderer re-emits the old source text | Mutare.AST.literal/1 |
Numeric literals woven into parsed source need a :token, or rendering raises | Mutare.AST.literal/1 |
Keyword keys need format: :keyword to render as key: rather than {:key, …} | Mutare.AST.keyword_key/1 |
| Re-declared variables must drop source meta but keep their hygiene context | Mutare.AST.clean_var/1 |
Emitted module references must be alias-proof (Elixir.-prefixed) | Mutare.AST.absolute_alias/1, absolute_call/3, remote_call/3 |
If you find yourself building a raw {:__block__, meta, [value]} tuple in a
mutator, one of these is missing from your toolkit — or from Mutare.AST, in
which case that's a bug report.
Context: configuration and behaviours
Implement mutate/2 instead of mutate/1 when the mutation depends on
context. (If both are exported, Mutare calls only mutate/2 — compose them
yourself by calling mutate/1 from it.) The second argument carries:
:opts— per-instance options, when the mutator is registered as{MyApp.Mutators.MagicNumber, swaps: %{200 => 500}}. The reserved:asoption renames the family, so one module can run twice under two names.:behaviours— the enclosing module's@behaviourset, for mutators that should only fire in, say, aGenServer.
Pipes need nothing from you. xs |> Enum.sort(:desc) is offered as the call it
is sugar for, Enum.sort(xs, :desc), so a clause matching the call's arguments
matches both spellings; the report shows the mutant in the spelling the user
wrote.
Structural positions
Some targets are positions, not nodes: a function clause's return value, an
if/unless/cond condition, a head or destructuring pattern. For those,
declare Mutare.Mutator.Structural alongside Mutare.Mutator and implement
the matching callback — return_replacements/1, condition_replacements/1,
or pattern_mutations/2 (each also has a context-taking arity). No mutate/1
needed.
Matching library calls
Don't pattern-match qualified call AST directly — users write Enum.sort/1 as
Enum.sort, E.sort under an alias, or bare sort under an import. Use
Mutare.Calls.resolved_call_to/3 to match a specific module (and optionally
function names): it takes the real module atom — you never build the resolved
key yourself — and returns {:ok, function, arguments, rebuild}, where
rebuild re-emits your replacement in whatever form the source used. The
lower-level Mutare.Calls.resolved_call/1 returns the raw resolved tuple for
table-driven matching across many modules, and Mutare.Calls.module_key/1
encodes a module atom into its key shape when you need to build such a table.
Polish: ignore variants and option keys
Two optional callbacks refine how users interact with your mutator:
variants/0(withMutare.Mutator.Mutation.tagged/2orvariant/2) gives your mutations labels, so# mutare:ignore[and_or:or]can suppress one kind without silencing the family. SeeMutare.MutatorandMutare.Ignore.mutate_call_option_keys?/1lets a key-mutating family opt out of rewriting trailing call options like thetimeout:infoo(x, timeout: 5).
Reporting a whole-node rewrite at its clause
A macro-aware mutator that rebuilds and returns a whole registered call from
mutate/1,2 — say it rewrites an entire multi-line from(...) query but only
changed one where: clause — would otherwise report every mutant at the call's
line, so # mutare:ignore (line-keyed) could only suppress the whole call at
once. Wrap the return in a Mutare.Mutator.Mutation with an :attribution to
point the report at the clause you actually changed:
# a value flip, reported (and ignorable) at the order_by: line:
Mutation.new(rebuilt_call, attribution: Mutation.at(order_by_value, flipped_value))
# a clause drop, reported at the dropped clause's line:
Mutation.new(rebuilt_call, attribution: Mutation.at_drop(dropped_clause))The metamutant is still built from the returned node; attribution only moves the
site's location and diff onto the named clause. Point it at a node inside the
rewrite — core drops (with a warning) an attribution whose clause isn't rangeable
or escapes the returned node's span. See Mutare.Mutator.Mutation.
Testing your mutator
import Mutare.Test in an ExUnit case. It tests at three levels: the
replacements for one node (node_mutations/3), what the full transform
produces (diffs/3, metamutant_source/3), and — the real proof — compiling
a metamutant and checking that activating your mutant changes runtime
behaviour (compile_metamutant/3 + observe_mutant/3). Each source-driven
helper forwards a trailing keyword list to Mutare.transform_string/2, so a
suite can thread :extensions or :call_routes through them. To test that
your mutator composes with macro routing an independent library ships, use
the bundled Mutare.Test.RoutingExtension rather than authoring a
no-op routing provider.
The source-driven helpers also check every transform they make
(verify_invariants: true, unless you pass false) and raise
Mutare.InvariantError when your mutator leaves the metamutant unsound: a
recorded mutant that the metamutant cannot select, or that no coverage record
lists, a replacement that renders identically to the original, or a mutator
that returns something different each time it is called. A host whose splice
overwrites selectors core already placed shows up here as core's mutants losing
their branches. To run the same checks over a real project, use
mix mutare --check --verify-invariants.
Writing an extension
An extension is a module implementing Mutare.CallRouting,
Mutare.UseExpansion, or both, listed under :extensions:
[extensions: [Mutare.Gettext]]It produces no mutations, has no name/0, and never appears in a report. Its
job is to describe a library so Mutare's ordinary mutators behave correctly
around that library's macros. (Mutators that implement these capabilities
belong under :mutators, not :extensions.)
Call routing
A macro can put an argument somewhere Mutare's normal treatment of runtime
code would be wrong — a query DSL body, a pattern, a schema field. If all you
need is "leave this macro's arguments alone" (or "skip this call"), the
call_routes: key in .mutare.exs does that declaratively with no code (see
the README's "Routing calls"). Implement Mutare.CallRouting when you're
building a reusable adapter for a library:
@impl Mutare.CallRouting
def call_routes do
[
{Ecto.Query, :from, 2, [:expression, :raw]},
{Ecto.Query, :where, :any, :routing}
]
endA route names a resolved call, with the same routing rules for macros and
functions. Its treatment is either :skip (the whole call is an inert leaf: never
offered to a mutator, nothing inside it descended) or one position per
argument: :expression (mutate normally), :lazy_expression (the same, for a
callee that may not evaluate the argument eagerly — Mutare never evaluates it
ahead of the call), :raw (leave as written),
:interior (mutate the argument's contents but never its own node),
:pattern/:binding_pattern (treat as a pattern), a keyed refinement
[leading, key: treatment, …] over a literal keyword argument,
:interpolated (the value is interpolable data — reuse core's mutations on
it, delivered through ^ interpolation), {:keyword, ...} (route
keyword values positionally, keep keys raw), or :hosted (hand the
position to a host mutator — below). The :routing sentinel defers to
route_arguments/1 when the right treatment depends on the call's shape —
where(q, category: "Foo") is data, where(q, [u], u.x == u.y) is a DSL
fragment. Return the treatments as ArgumentRoutes.new(call, treatments), one
per entry of call.arguments. A piped call needs no special handling here
either: (p in Post) |> from(order_by: …) is shown — to a classifier, a host,
and a mutator alike — as from(p in Post, order_by: …). The piped operand is
argument 0: routed by its shape, rewritable through call.rebuild, and
hostable like any other position. Reports keep the pipe the user wrote.
Two limits on what a route may name: the structural forms (if,
case, the boolean operators — anything the analyzer walks with a clause of its
own) take :skip only; definitions (def, defmodule, …) and literal syntax ({}, %{}, …)
take no route —
an explicit key is rejected, a wildcard's positions are not applied to them.
:skip, :raw, :interior, :expression, :lazy_expression, :pattern, :binding_pattern,
and keyed refinements built from them are also the end-user vocabulary of the
declarative call_routes: config key — they can only remove or re-route
mutants. The other three are adapter-grade: routing a position
:interpolated, {:keyword, ...}, or :hosted asserts facts about the DSL
that Mutare cannot check, and a wrong assertion has real consequences.
:interpolated delivers mutations through ^: a bare scalar gets a ^-pinned
selector, and a value the source already pins is descended as plain Elixir. It
is sound only where the DSL genuinely accepts ^ interpolation, and only for
bare values that are scalar (a bare compound is rejected at transform time with
an error rather than left to poison the build — but a DSL that rejects ^
outright still breaks the single compile, costing a poison-recovery rebuild
that drops those mutants).
{:keyword, ...} asserts the argument is a keyword list whose keys are DSL
vocabulary, never data — the treatment list must name exactly one treatment
per pair (a mismatch is an error at transform time), and a non-keyword
argument is left raw with no mutants: silently for a static route (another
call shape may be a legal form of the macro), with a printed warning when a
:routing classifier misrouted it (the classifier was called with the concrete argument,
so the mismatch is a classifier bug). :hosted is a delivery contract, not a hint: it leaves the position
raw and requires an enabled mutator subscribed via Mutare.Mutator.MacroHost,
and the run aborts at scan time if none is. That is why these treatments can
only come from here — an adapter written and tested against the library it
describes. A declarative call_routes: entry in .mutare.exs that uses one
is rejected with an error; put the route in a module implementing
Mutare.CallRouting instead (a one-module extension is enough). The full
treatment semantics, precedence rules, and the committed compatibility surface
for adapters are in the Mutare.CallRouting docs.
Hosting mutations inside a DSL
Routing determines which positions core may mutate. A macro host emits
mutations inside fragments core must leave raw, in whatever form the DSL
accepts. A host is a mutator (it produces mutations, so it's a :mutators
entry) implementing Mutare.Mutator.MacroHost: hosted_macros/0 names the
macros it can mutate, and host/2 receives each resolved call and returns
targets — original fragment, mutants, and a splice function. Routing and
hosting are deliberately separate: one library adapter can describe the DSL
under :extensions while several independent host mutators contribute
mutants inside it.
use expansion
Mutare expands use calls to recover their injected directives: the imports and
aliases used in call resolution, and the behaviours that behaviour-gated
mutators check. Some __using__ macros can't be expanded from the outside;
Mutare.UseExpansion lets an extension supply those directives directly:
@impl Mutare.UseExpansion
def expand_use(Gettext, _args, _context) do
Mutare.UseExpansion.expand([quote(do: import(Gettext.Macros))])
end
def expand_use(_used, _args, _context), do: :declineHandlers are tried in :extensions order; the first non-:decline result
wins. A library integration commonly pairs this with Mutare.CallRouting in
one module — recover the injected import so the macro calls resolve, then
route them.