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 teach Mutare a library's compile-time vocabulary, 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 points to the module docs that carry the full contract. Every kind listed here has a small working example under test/support/ in the Mutare repository.

Which piece do you want?

You want to…ImplementRead next
Swap one AST node for another (andor)Mutare.Mutator with mutate/1Mutare.Mutator
Make a mutator configurable, pipe-aware, or gated on the module's @behavioursmutate/2Mutare.Mutator
Mutate a position: a clause's return value, an if condition, a head patternMutare.Mutator.StructuralMutare.Mutator.Structural
Match calls to a specific library or stdlib functionmutate/1,2 + Mutare.Calls.resolved_call_to/3Mutare.Calls
Skip a call, or keep a macro's arguments from being mutatedno code — call_routes: (and argument_marks:) in .mutare.exsthe README's "Routing calls" section
Describe how a DSL's macro arguments should be treated (a reusable adapter)Mutare.CallRoutingMutare.CallRouting
Emit mutations inside a DSL fragment (an Ecto where, say)Mutare.Mutator.MacroHostMutare.Mutator.MacroHost
Handle a use whose injected imports/behaviours Mutare can't recoverMutare.UseExpansionMutare.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
end

Enable 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:

  1. 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.
  2. Never decide placement. You return replacement nodes; Mutare decides whether they're delivered in place or by lifting the enclosing function.
  3. Build literals with Mutare.AST.literal/1, not by hand — a hand-built literal can silently render as the original source.

Emitting AST

Sourceror nodes carry rendering metadata with sharp edges, and every one of them is core's problem, not yours. Each invariant has a Mutare.AST helper that discharges it — reach for the helper instead of re-deriving the rule:

InvariantHelper
Fresh literals need clean, derived meta, or the renderer re-emits the old source textMutare.AST.literal/1
Numeric literals woven into parsed source need a :token, or rendering raisesMutare.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 contextMutare.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, pipes, 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 :as option renames the family, so one module can run twice under two names.
  • :pipe_mode — whether the call sits on the right of a |> (its first argument is then implicit; helpers Mutare.Mutator.effective_arity/2 and visible_index/2 do the arithmetic).
  • :behaviours — the enclosing module's @behaviour set, for mutators that should only fire in, say, a GenServer.

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 (with Mutare.Mutator.Mutation.tagged/2 or variant/2) gives your mutations labels, so # mutare:ignore[and_or:or] can suppress one kind without silencing the family. See Mutare.Mutator and Mutare.Ignore.
  • mutate_call_option_keys?/1 lets a key-mutating family opt out of rewriting trailing call options like the timeout: in foo(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.

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}
  ]
end

A route names a resolved call — macro or function, the registry never asks which. 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), :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/2 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. 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, :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 saw the concrete argument, so the mismatch is its 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 says what core may touch; a macro host goes further and emits mutations inside fragments core must leave raw, in whatever form that 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 learn what they inject — the imports and aliases that make call resolution work, and the behaviours that behaviour-gated mutators check. Some __using__ macros can't be expanded from the outside; Mutare.UseExpansion lets an extension supply the answer 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: :decline

Handlers 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.