Mutare.Analyze (mutare v0.4.2)

Copy Markdown View Source

Expression-level mutant generation for selector hosts and integrations.

Core analysis skips regions routed :hosted or :raw. These regions can still contain ordinary Elixir expressions: everything under an Ecto ^ pin is evaluated at runtime and interpolated as a query parameter. Expressions such as ^18 or ^(min + 1) can therefore be mutated with the user's configured Elixir families.

collect_expression/3 generates the logical single-point mutants of an expression subtree using the same analysis as for code outside a DSL. It returns them as data for the caller to embed through either delivery path:

  • a selector host (Mutare.Mutator.MacroHost.host/2) wraps each rebuilt subtree back under its pin, appends it to its Mutare.Mutator.MacroHost.Target mutants with its original attribution and producing spec intact, and the ordinary hosted pipeline assigns ids, records sites under the producing family, and weaves the host's selector;
  • a mutator offered the whole call of a registered macro (Mutare.Mutator.mutate/2 — the free-standing dynamic/1,2 shape, where the macro sits in ordinary expression position and its :raw argument stays as written) rebuilds the call around each mutant and relays it the same way; delivery is the ordinary in-place selector.

On both paths context.mutators contains the full set of enabled specs, including selector hosts, :as renames, and per-instance options. The interior is analyzed under that configuration, with no mutants from disabled families. For a mutator implementing hosting, generation uses both its ordinary node-level mutate/1,2 and its hosted callbacks. A registered macro inside the expression is passed whole to its registered mutator, as with a nested dynamic call. Hosted targets are lowered to whole-call rebuilds (splice(wrap(mutant))) using just the selected branch. This preserves their mutations without nesting hosted selectors:

defp pin_mutants({:^, meta, [inner]}, context) do
  for mutation <- Mutare.Analyze.collect_expression(inner, context.mutators, context) do
    Mutare.Mutator.Mutation.map_node(mutation, &{:^, meta, [&1]})
  end
end

Summary

Functions

Collects expression mutations as producer-attributed Mutare.Mutator.Mutation values.

Returns the logical single-point mutants of the expression subtree, one rebuild per mutant.

Resolves a region of the host's call that core left as written, in the lexical environment the callback context carries — the first step of collect_expression/3, on its own.

Functions

collect_expression(subtree, mutators, context \\ %{})

@spec collect_expression(Macro.t(), [Mutare.Mutator.Spec.t() | module()], map()) :: [
  Mutare.Mutator.Mutation.t()
]

Collects expression mutations as producer-attributed Mutare.Mutator.Mutation values.

Each node is the whole expression rebuilt around one mutation. Its attribution preserves the logical source change, including explicit replacement/deletion attribution and the source range and selection position. Notes, producer configuration and resolved variant labels survive nested collection and hosting. The producing family's finalize/2 has already run; relaying the value does not finalize it again.

Use Mutare.Mutator.Mutation.map_node/2 to embed the replacement in surrounding syntax without replacing its origin:

for mutation <- Mutare.Analyze.collect_expression(inner, context.mutators, context) do
  Mutare.Mutator.Mutation.map_node(mutation, &{:^, meta, [&1]})
end

A source patch at the preserved attribution must have the same behavior as the delivered mutant; adapters remain responsible for that contract when rebuilding foreign syntax.

This function uses the core analyzer's traversal of runtime expressions, with the same mutation rules as for code outside the DSL: call-routing stamps apply (a :raw or :pattern argument stays raw, an :expression argument is traversed), pattern positions are never mutated in place, and cross-family suppressions apply. A nested {:hosted, …} stamp inside the subtree keeps its argument core-raw, but the subscribed host's Mutare.Mutator.MacroHost.host/2 runs and each target mutant is lowered to a rebuild of the hosting call — splice(wrap(mutant)), the woven selector degenerated to its selected branch. By construction, this has the same value as the assembled selector when that mutant is active. Hosted mutations are returned as data; only the calling host inserts a selector for the region.

Node-level producers run through the ordinary dispatch (Mutare.Mutator.mutate/1 / Mutare.Mutator.mutate/2 — so notes, variants, per-spec options, behaviours, and each producer's Mutare.Mutator.finalize/2 funnel follow the established contract), and selector hosts through the lowering above (their finalize/2 runs at target normalization, same contract). Structural families in mutators are ignored — def-level, clause-level, and return-value shapes don't apply to a bare expression subtree.

It assigns no ids and records no sites. The transform does both when embedding the returned mutations. Pass the callback's context unchanged: it carries the enclosing call's lexical environment and the configured routes and argument marks. Core resolves the declared Elixir island there before analysis, including its pipes, aliases and imports. Nested hosted regions remain syntax until their own hosts declare an island. During a scan, resolution also reports the island's route/mark matches, even when it produces no mutations.

Without a callback context, the subtree must already carry any resolution its mutations need.

expression_mutations(subtree, mutators, context \\ %{})

@spec expression_mutations(Macro.t(), [Mutare.Mutator.Spec.t() | module()], map()) ::
  [
    {Mutare.Mutator.Spec.t(), Macro.t(), String.t() | nil,
     Mutare.Mutator.Mutation.variant()}
  ]

Returns the logical single-point mutants of the expression subtree, one rebuild per mutant.

This tuple-returning compatibility API discards source attribution. Prefer collect_expression/3 when relaying mutations into a host or another call.

Each element is {producing_spec, mutated_subtree, note, variant}: the whole subtree with exactly one position swapped, plus the producing Mutare.Mutator.Spec and the mutation's optional advisory note and resolved # mutare:ignore variant label(s) — ready to wrap under a %Mutare.Mutator.Mutation{} with producer: spec.

Examples

iex> subtree = Sourceror.parse_string!("min + 1")
iex> [{spec, mutated, _note, _variant}] =
...>   Mutare.Analyze.expression_mutations(subtree, [Mutare.Mutators.Arithmetic])
iex> {spec.name, Sourceror.to_string(mutated)}
{:arithmetic, "min - 1"}

resolve(subtree, context)

@spec resolve(Macro.t(), map()) :: Macro.t()

Resolves a region of the host's call that core left as written, in the lexical environment the callback context carries — the first step of collect_expression/3, on its own.

Core does not interpret a :raw or :hosted argument (or a keyword value under either): no call inside it is stamped, no Kernel pipe desugared, no registered macro routed. A DSL may still embed calls the host must identify — a nested query, a macro the user registered a route for. Handing such a region here reads it as Elixir: every call is stamped with what it resolves to through the aliases and imports in force at the enclosing call, a registered macro is routed (its :routing classifier invoked), and a Kernel pipe becomes the direct call it is sugar for, still spelled as the pipe in reports. On the result, Mutare.Calls.resolved_call/1, Mutare.Calls.resolved_routed_call/1 and Mutare.Calls.routed_treatments/1 answer as they do for any resolved node. Nothing is mutated, and no id is assigned.

This is the host's claim about its own syntax, so the host makes it: for the regions its routes declared, at its Mutare.Mutator.MacroHost.host/2 or Mutare.Mutator.mutate/2 boundary, once. Resolution stops at the :raw/:hosted positions of the calls it routes inside the region, as it does at the top; a host whose DSL nests that way reads those through the same call. A configured :skip inside the region is honoured: the skipped call is stamped, and its arguments are left as written. During a scan, the route and argument-mark matches inside the region are reported like an island's.

Pass the callback's context unchanged, and the region itself — not the routed call around it, which resolves again to the same boundaries and leaves the region as written. A context that carries no environment (a producer driven directly, in a test) returns subtree as it is.

def host(%Mutare.CallRouting.Call{arguments: [_query, fragment]}, context) do
  fragment = Mutare.Analyze.resolve(fragment, context)
  # nested `from(…)` calls are now `Mutare.Calls.resolved_routed_call/1` matches
  …
end