Mutare.Calls (mutare v0.4.1)

Copy Markdown View Source

Call-resolution readers for custom mutators and macro integrations.

resolved_call/1 normalizes qualified, aliased, imported, and Erlang-module calls to {module, function, arguments, rebuild}. Use rebuild to preserve the source's written call form when that is compile-safe; bare imported calls may be requalified when the replacement changes name or arity. It operates on nodes passed to a mutator by Mutare's transform.

This reads the alias/import stamps the transform places on the AST before mutators run, so it is only meaningful on a node handed to a mutator by the transform (a mutate/1 argument) — exactly where a call-matching mutator needs it.

module_key/1 encodes a real module atom into the key shape resolved_call/1 returns, and resolved_call_to/3 bundles the common "is this a call to module M (function F)?" match — together they save a caller from ever constructing or pattern-building the key representation itself.

resolved_routed_call/1 is the routed-call twin — a call matched by a call_routes entry, macro or function. It returns a stable Mutare.CallRouting.Call with a natural module atom, the call's arguments (a piped call's operand included, as argument 0), and a source-preserving rebuild function.

routed_treatments/1 reads the per-argument routing already assigned by the merged registry (built-ins, mutators, extensions and configured routes). A host uses it on its own call's node to locate the positions marked :hosted, including nested keyword values, without re-classifying the call. It also reads nested calls in resolved Elixir arguments. Foreign :raw/:hosted fragments have not been resolved or routed: their syntax, including pipes, belongs to the DSL. To sub-contract an Elixir island, pass it and the callback context to Mutare.Analyze.expression_mutations/3; to read the calls a DSL fragment embeds (a nested query, a registered macro), resolve the fragment first with Mutare.Analyze.resolve/2 — these readers then answer for it.

rebuild reuses the offered call's meta, its routing stamp included, and a mutator need not strip or restamp what it rebuilds: Mutare routes a rebuilt call as the call it now is before anything reads it — the registry's answer for the rebuilt head and arity, a :routing classifier invoked on the rebuilt arguments, no route where nothing matches. A classifier therefore classifies a rebuilt call to its macro as it classifies any call to it — over the arguments as written, a nested pipe as a pipe — and a call the mutator left as it was keeps its classification. What the rebuilt call binds and how it is delivered follow that route: a mutant that stops a macro from evaluating an expression is not credited with the expression's bindings, is withheld where a delivery would depend on them, and, as a pipe stage, is not handed the piped value ahead of a callee that would not evaluate it.

Example

defmodule MyApp.Mutators.Upcase do
  @behaviour Mutare.Mutator
  def name, do: :upcase_swap

  def mutate(node) do
    case Mutare.Calls.resolved_call(node) do
      {[:String], :upcase, [arg], rebuild} -> [rebuild.(:downcase, [arg])]
      _ -> :skip
    end
  end
end

Summary

Types

A resolved module: an Elixir-module path ([:Enum], [:String]) or an Erlang-module atom (:binary, :string). A mutator keys its table on the function's module-key representation.

Functions

The resolved-call key for a module atom — the shape resolved_call/1 returns in its first element. An Elixir module becomes its segment path, an Erlang module stays an atom. Use it to compare a configured module against resolved calls instead of re-deriving the encoding.

Returns {module, function, arguments, rebuild} for a resolved standard-library call, or nil.

Matches a resolved call against a target module and function name(s).

Return the stable call value for a node the call-route resolver matched, or nil for any other node. Extension callbacks receive this value directly. Nested calls have routing stamps only in regions core has resolved as Elixir, not inside raw or hosted syntax.

Returns the resolved treatment for each argument of a registered macro call, or nil.

Types

module_key()

@type module_key() :: Mutare.Transform.Calls.module_key()

A resolved module: an Elixir-module path ([:Enum], [:String]) or an Erlang-module atom (:binary, :string). A mutator keys its table on the function's module-key representation.

Functions

module_key(module)

@spec module_key(module()) :: module_key()

The resolved-call key for a module atom — the shape resolved_call/1 returns in its first element. An Elixir module becomes its segment path, an Erlang module stays an atom. Use it to compare a configured module against resolved calls instead of re-deriving the encoding.

iex> Mutare.Calls.module_key(Ecto.Query)
[:Ecto, :Query]
iex> Mutare.Calls.module_key(:binary)
:binary

resolved_call(node)

@spec resolved_call(Macro.t()) ::
  {module_key(), atom(), [Macro.t()], (atom(), [Macro.t()] -> Macro.t())} | nil

Returns {module, function, arguments, rebuild} for a resolved standard-library call, or nil.

module is an Elixir alias path such as [:String] or an Erlang module atom. rebuild.(new_function, new_arguments) preserves the call's written qualifier when safe. Remote calls keep their written qualifier or alias. Bare imported calls stay bare for value-only replacements, but may be requalified when the replacement changes name or arity.

iex> node = Sourceror.parse_string!("String.upcase(s)")
iex> {module, function, arguments, rebuild} =
...>   Mutare.Calls.resolved_call(node)
iex> {module, function}
{[:String], :upcase}
iex> Sourceror.to_string(rebuild.(:downcase, arguments))
"String.downcase(s)"

iex> erlang = Sourceror.parse_string!(":binary.first(b)")
iex> {module, function, _arguments, _rebuild} =
...>   Mutare.Calls.resolved_call(erlang)
iex> {module, function}
{:binary, :first}

iex> Mutare.Calls.resolved_call(Sourceror.parse_string!("foo(x)"))
nil

resolved_call_to(node, module, functions \\ :any)

@spec resolved_call_to(Macro.t(), module() | module_key(), atom() | [atom()] | :any) ::
  {:ok, atom(), [Macro.t()], (atom(), [Macro.t()] -> Macro.t())} | :error

Matches a resolved call against a target module and function name(s).

module may be a real module atom or an already-encoded module_key/0; functions is one name, a list of names, or :any (the default). Returns {:ok, function, arguments, rebuild} on a match, :error otherwise — including for a node that is not a resolved call at all. The common call-matching preamble without hand-building the key:

iex> node = Sourceror.parse_string!("String.upcase(s)")
iex> {:ok, fun, args, rebuild} = Mutare.Calls.resolved_call_to(node, String)
iex> fun
:upcase
iex> Sourceror.to_string(rebuild.(:downcase, args))
"String.downcase(s)"

iex> node = Sourceror.parse_string!("String.upcase(s)")
iex> Mutare.Calls.resolved_call_to(node, String, [:downcase, :capitalize])
:error
iex> Mutare.Calls.resolved_call_to(node, Enum)
:error

resolved_routed_call(node)

@spec resolved_routed_call(Macro.t()) :: Mutare.CallRouting.Call.t() | nil

Return the stable call value for a node the call-route resolver matched, or nil for any other node. Extension callbacks receive this value directly. Nested calls have routing stamps only in regions core has resolved as Elixir, not inside raw or hosted syntax.

routed_treatments(node)

@spec routed_treatments(Macro.t()) ::
  [Mutare.CallRouting.routing_treatment()] | :skip | nil

Returns the resolved treatment for each argument of a registered macro call, or nil.

Treatments come from the fully merged macro-routing registry and include any shape-aware classification already performed for the call. The result may contain static treatments, :hosted, or nested keyword routing.

Inside Mutare.Mutator.MacroHost.host/2, calling this on the received call's node returns the treatments that granted hosting, so a host locates its :hosted positions without re-classifying the call.

A keyed refinement reads back in the author form it was written in ([:expression, timeout: :raw]). A piped call's operand is position 0, as in the direct call it is. A call routed :skip returns the bare :skip; a call with no assigned route returns nil, including one inside unresolved foreign syntax.

Routing describes arguments; registration identifies ownership

A route specifies how to treat a registered macro's arguments. It does not stop a mutator's own catalog from matching the call, which the expression walk still offers — so a macro registered :raw is opaque in its interior and exposed in its name. (The call-level :skip is the exception: an inert leaf is offered to nobody, and this reader returns :skip for it.)

That matters for a catalog keyed on a bare function-name atom — all there is to match on inside a DSL whose API functions are never imported. A name is not an identity: rewriting somebody else's macro to a sibling name emits a call nobody defines, which the library rejects while expanding. Having a treatment is exactly what being registered means, so a non-nil result identifies a registered macro — skip the node:

def mutate(node) do
  if Mutare.Calls.routed_treatments(node), do: :skip, else: swap(node)
end

Test for nil, not for a non-empty list: [] is a registered macro with no visible arguments, whose treatment is also defined by the registered route. The test recognises a registered macro — including one registered by name only, the route for calls whose module cannot be resolved — and nothing beyond that. An unregistered macro is indistinguishable from an ordinary call, so a catalog still needs whatever arity and import gating it already applies.

A catalog that matches through resolved_call/1 needs no such test: it keys on {module, function} and gets nil for a call it cannot resolve.