Mutare.Calls (mutare v0.1.0)

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, visible arguments, pipe information, and a source-preserving rebuild function.

routed_treatments/1 reads how a node's macro is registered — the resolved per-argument routing the merged registry (built-ins + every mutator's/extension's call_routes/0 + the declarative :call_routes option) assigned it. A macro host uses it in two places: on its own call's node, to locate the positions the route marked :hosted (including values nested under {:keyword, …}); and on a nested macro inside a fragment it walks, to ask whether an argument routes :raw (left as written), whether the whole call is skipped (:skip), or otherwise specially. In both cases it replaces re-deriving the classification.

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 whichever shape the function lives in.

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; the reader remains useful to a host walking nested macro nodes.

Returns the resolved treatment for each visible 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 whichever shape the function lives in.

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; the reader remains useful to a host walking nested macro nodes.

routed_treatments(node)

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

Returns the resolved treatment for each visible 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]). For a piped call, the left side of the pipe is not included. A call routed :skip returns the bare :skip; a call that has no registered route returns nil.

Routing describes arguments; registration identifies ownership

A route says 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 is the ownership test — decline 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, and it is just as much somebody else's. 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.