Mutare.Mutator.MacroHost behaviour (mutare v0.1.2)

Copy Markdown View Source

Capability behaviour for a mutator that hosts mutations inside a compile-time DSL.

Ordinary mutators see runtime Elixir expressions. A fragment inside a macro such as Ecto's where has the library's own semantics and cannot necessarily contain Mutare's ordinary selector. A macro host inserts selectors in the form that DSL accepts.

Macro registration and argument routing belong to the independent Mutare.CallRouting capability. A host subscribes to the macros it can mutate through hosted_macros/0; a separate library extension may own their routing. This lets several independent mutators target the same DSL without replacing one another.

host/2 is itself a mutation-producing callback, so a mutator that delivers all of its mutations through the DSL needs no mutate/1 — just name/0 to identify it in reports:

defmodule MyApp.Mutators.Ecto do
  alias Mutare.Mutator.MacroHost.Target

  @behaviour Mutare.Mutator
  @behaviour Mutare.Mutator.MacroHost

  @impl Mutare.Mutator
  def name, do: :ecto_query

  @impl Mutare.Mutator.MacroHost
  def hosted_macros, do: [{Ecto.Query, :where, :any}]

  @impl Mutare.Mutator.MacroHost
  def host(call, context) do
    [Target.new(fragment, mutations, &splice/2)]
  end
end

(Add a mutate/1 only if the mutator also mutates whole nodes outside the DSL.) See Mutare.CallRouting for the "which behaviours do I implement?" table. The same module may also implement Mutare.CallRouting when it owns the DSL adapter as well as its mutations, but the capabilities remain independently composable.

Summary

Types

A macro selector returned by hosted_macros/0.

Callbacks

Produces mutations for fragments inside a compile-time DSL.

Declare the macros this host can mutate.

Types

macro_selector()

@type macro_selector() ::
  {module :: atom(), name :: atom()}
  | {module :: atom(), name :: atom(), arity :: non_neg_integer() | :any}

A macro selector returned by hosted_macros/0.

Callbacks

host(call, context)

Produces mutations for fragments inside a compile-time DSL.

The transform hands the callback a resolved Mutare.CallRouting.Call and expects a list of Mutare.Mutator.MacroHost.Target values, one per fragment to mutate. A target carries:

  • :original — the fragment before mutation, used for the baseline and the left side of the reported diff;
  • :mutants — mutated fragments, %Mutare.Mutator.Mutation{} values carrying report metadata, with inapplicable entries filtered out before returning the list. A top-level bare nil entry is rejected; use Mutare.AST.literal(nil) for a literal-nil replacement;
  • :splice — a 2-arity (macro_node, case_node -> macro_node) function that inserts the assembled selector into a copy of the macro node;
  • :wrap — optional 1-arity (fragment -> node) function mapping each fragment to its branch value, such as &dynamic([u], &1); defaults to identity;
  • :range — optional Sourceror.Range.t() used for the site; defaults to the original fragment's range.

The callback owns the DSL-specific mutation semantics and selector placement. Core owns ids, sites, coverage, and selector assembly, so survivor diffs contain only the logical fragment change.

Subscribe through hosted_macros/0. The active macro route must contain :hosted, either statically or from Mutare.CallRouting.route_arguments/2. context is the same map Mutare.Mutator.mutate/2 receives, plus :mutators — the run's enabled Mutare.Mutator.Specs (hosts included; Mutare.Analyze.expression_mutations/3 lowers a nested host's targets to whole-call rebuilds instead of weaving them, so a sub-contracted island analyzes with every surface — ordinary and hosted — and hosted delivery never nests).

A :hosted route is permission and a delivery mode, not a target list: the callback receives the whole resolved macro call and owns locating the fragment(s) it will mutate. It need not re-classify the call to do so — Mutare.Calls.routed_treatments/1 on the call's node returns the per-argument treatments the route produced, so the :hosted positions (including values nested under {:keyword, …}) can be read back instead of rediscovered. Core leaves hosted fragments raw and does not route nested macros inside them; the same reader answers for a nested macro the host walks into.

Sub-contracting ordinary Elixir inside a fragment

A hosted fragment may contain islands of ordinary Elixir that are core's business, not the DSL's — everything under an Ecto ^ pin is evaluated at runtime. Rather than mirroring core's value conventions (or applying DSL semantics to non-DSL code), hand the island back to core's generation: Mutare.Analyze.expression_mutations(island, context.mutators, context) returns each single-point mutant as a rebuild of the island, produced by the user's actual configuration. Relay each rebuild as a Mutare.Mutator.Mutation with producer: set to the returned spec — the mutant then rides this host's weave (delivery stays host-owned) while its site and # mutare:ignore vocabulary belong to the producing core family.

hosted_macros()

@callback hosted_macros() :: [macro_selector()]

Declare the macros this host can mutate.

Selectors contain identity only, not argument treatments. The merged Mutare.CallRouting declaration remains the sole source of routing semantics. Wildcards follow call_routes/0: :* may occupy the module or name slot, and omitted arity means :any.