lib/matcha/trace.ex

defmodule Matcha.Trace do
  alias Matcha.Trace

  @moduledoc """
  About tracing.
  """

  require Matcha

  alias Matcha.Context
  alias Matcha.Helpers
  alias Matcha.Source

  alias Matcha.Spec

  @default_trace_limit 1
  @recon_any_function :_
  @matcha_any_function @recon_any_function
  @recon_any_arity :_
  @matcha_any_arity :any

  defstruct [:module, :function, :arguments, limit: @default_trace_limit, opts: []]

  @type t :: %__MODULE__{
          module: atom(),
          function: atom(),
          arguments: unquote(@matcha_any_arity) | 0..255 | Spec.t(),
          limit: pos_integer(),
          opts: Keyword.t()
        }

  # Ensure only valid traces are built
  defp build_trace!(module, function, arguments, limit, opts) do
    problems =
      []
      |> trace_problems_module_exists(module)
      |> trace_problems_function_exists(module, function)
      |> trace_problems_numeric_arities_valid(arguments)
      |> trace_problems_function_with_arity_exists(module, function, arguments)
      |> trace_problems_warn_match_spec_tracing_context(arguments)
      |> trace_problems_match_spec_valid(arguments)

    trace = %__MODULE__{
      module: module,
      function: function,
      arguments: arguments,
      limit: limit,
      opts: opts
    }

    if length(problems) > 0 do
      raise Trace.Error, source: trace, details: "when building trace", problems: problems
    else
      trace
    end
  end

  defp trace_problems_module_exists(problems, module) do
    if Helpers.module_exists?(module) do
      problems
    else
      [
        {:error, "cannot trace a module that doesn't exist: `#{module}`"}
        | problems
      ]
    end
  end

  defp trace_problems_function_exists(problems, module, function) do
    if Helpers.function_exists?(module, function) do
      problems
    else
      [
        {:error, "cannot trace a function that doesn't exist: `#{module}.#{function}`"}
        | problems
      ]
    end
  end

  defp trace_problems_numeric_arities_valid(problems, arguments) do
    if (is_integer(arguments) and (arguments < 0 or arguments > 255)) or
         (is_atom(arguments) and arguments != @matcha_any_arity) do
      [
        {:error,
         "invalid arguments provided to trace: `#{inspect(arguments)}`, must be an integer within `0..255`, a `Matcha.Spec`, or `#{@matcha_any_arity}`"}
        | problems
      ]
    else
      problems
    end
  end

  defp trace_problems_function_with_arity_exists(problems, module, function, arguments) do
    if is_integer(arguments) and arguments in 0..255 do
      if Helpers.function_with_arity_exists?(module, function, arguments) do
        problems
      else
        [
          {:error,
           "cannot trace a function that doesn't exist: `#{module}.#{function}/#{arguments}`"}
          | problems
        ]
      end
    else
      problems
    end
  end

  # TODO: use is_struct(arguments, Spec) once we drop support for elixir v1.10.0
  defp trace_problems_warn_match_spec_tracing_context(problems, arguments) do
    if is_map(arguments) and Map.get(arguments, :__struct__) == Spec and
         arguments.context != Context.Trace do
      IO.warn(
        "#{inspect(arguments)} was not defined in a `#{Matcha.Context.Trace.__context_name__()}` context," <>
          " doing so may provide better compile-time guarantees it is valid," <>
          " via `Matcha.spec(#{Matcha.Context.Trace.__context_name__()}) do...`"
      )
    else
      problems
    end
  end

  # TODO: use is_struct(arguments, Spec) once we drop support for elixir v1.10.0
  defp trace_problems_match_spec_valid(problems, arguments) do
    if is_map(arguments) and Map.get(arguments, :__struct__) == Spec and
         arguments.context == Context.Trace do
      case Spec.validate(arguments) do
        {:ok, _spec} -> problems
        {:error, spec_problems} -> spec_problems ++ problems
      end
    else
      problems
    end
  end

  @spec calls(atom, atom, non_neg_integer | Spec.t(), keyword) :: non_neg_integer
  @doc """
  Trace `function` calls to `module` with specified `arguments`.

  `arguments` may be:

  - an integer arity, only tracing function calls with that number of parameters
  - a `Matcha.Spec`, only tracing function calls whose arguments match the provided patterns

  If calling with just an arity, all matching calls will print a corresponding trace message.
  If calling with a spec, additional operations can be performed, as documented in `Matcha.Context.Trace`.

  By default, only #{@default_trace_limit} calls will be traced.
  More calls can be traced by providing an integer `:limit` in the `opts`.

  All other `opts` are forwarded to
  [`:recon_trace.calls/3`](https://ferd.github.io/recon/recon_trace.html#calls-3)
  as the third argument.
  """
  # TODO: use or is_struct(arguments, Spec) when we drop support for v1.10.x
  def calls(module, function, arguments, opts \\ [])
      when is_atom(module) and is_atom(function) and
             ((is_integer(arguments) and arguments >= 0) or is_struct(arguments)) and
             is_list(opts) do
    do_trace(module, function, arguments, opts)
  end

  @doc """
  Trace all `function` calls to `module`.

  By default, only #{@default_trace_limit} calls will be traced.
  More calls can be traced by providing an integer `:limit` in the `opts`.

  All other `opts` are forwarded to
  [`:recon_trace.calls/3`](https://ferd.github.io/recon/recon_trace.html#calls-3)
  as the third argument.
  """
  def function(module, function, opts \\ [])
      when is_atom(module) and is_atom(function) and is_list(opts) do
    do_trace(module, function, @matcha_any_arity, opts)
  end

  @doc """
  Trace all calls to a `module`.

  By default, only #{@default_trace_limit} calls will be traced.
  More calls can be traced by providing an integer `:limit` in the `opts`.

  All other `opts` are forwarded to
  [`:recon_trace.calls/3`](https://ferd.github.io/recon/recon_trace.html#calls-3)
  as the third argument.
  """
  def module(module, opts \\ [])
      when is_atom(module) and is_list(opts) do
    do_trace(module, @matcha_any_function, @matcha_any_arity, opts)
  end

  # Build trace from args/opts
  defp do_trace(module, function, arguments, opts) do
    {limit, opts} = Keyword.pop(opts, :limit, @default_trace_limit)
    trace = build_trace!(module, function, arguments, limit, opts)

    do_recon_trace_calls(trace)
  end

  # Translate a trace to :recon_trace.calls arguments and invoke it
  defp do_recon_trace_calls(%Trace{} = trace) do
    recon_module = trace.module

    recon_function =
      case trace.function do
        @matcha_any_function -> @recon_any_function
        function -> function
      end

    recon_arguments =
      case trace.arguments do
        @matcha_any_arity -> @recon_any_arity
        arity when is_integer(arity) -> arity
        %Spec{source: source} -> source
      end

    recon_limit = trace.limit

    recon_opts = trace.opts

    :recon_trace.calls({recon_module, recon_function, recon_arguments}, recon_limit, recon_opts)
  end

  @spec awaiting_messages?(:all | pid, timeout :: non_neg_integer()) :: boolean
  @doc """
  Checks if `pid` is awaiting trace messages.

  Waits `timeout` milliseconds for the `pid` to report that all trace messages
  intended for it when `awaiting_messages?/2` was called have been delivered.

  Returns `true` if no response is received within `timeout`, and you may assume
  that `pid` is still working through trace messages it has received.
  If it receives confirmation before the `timeout`, returns `false`.

  The `pid` must refer to an alive (or previously alive) process
  ***from the same node this function is called from***,
  or it will raise an `ArgumentError`.

  If the atom `:all` is provided instead of a `pid`, this function returns `true`
  if ***any*** process on the current node is awaiting trace messages.

  This function is best used when shutting down processes (or the current node),
  to give them a chance to finish any tracing they are handling.

  """
  def awaiting_messages?(pid \\ :all, timeout \\ 5000) do
    ref = request_confirmation_all_messages_delivered(pid)

    receive do
      {:trace_delivered, ^pid, ^ref} -> false
    after
      timeout -> true
    end
  end

  defp request_confirmation_all_messages_delivered(pid) do
    :erlang.trace_delivered(pid)
  end

  @type info_subject ::
          pid
          | port
          | :new
          | :new_processes
          | :new_ports
          | {module, function :: atom, arity :: non_neg_integer}
          | :on_load
          | :send
          | :receive

  @type info_item ::
          :flags
          | :tracer
          | :traced
          | :match_spec
          | :meta
          | :meta_match_spec
          | :call_count
          | :call_time
          | :all

  @type info_result ::
          :undefined
          | {:flags, [info_flag]}
          | {:tracer, pid | port | []}
          | {:tracer, module, any}
          | info_item_result
          | {:all, [info_item_result] | false | :undefined}

  @type info_flag ::
          :send
          | :receive
          | :set_on_spawn
          | :call
          | :return_to
          | :procs
          | :set_on_first_spawn
          | :set_on_link
          | :running
          | :garbage_collection
          | :timestamp
          | :monotonic_timestamp
          | :strict_monotonic_timestamp
          | :arity

  @type info_item_result ::
          {:traced, :global | :local | false | :undefined}
          | {:match_spec, Source.spec() | false | :undefined}
          | {:meta, pid | port | false | :undefined | []}
          | {:meta, module, any}
          | {:meta_match_spec, Source.spec() | false | :undefined}
          | {:call_count, non_neg_integer | boolean | :undefined}
          | {:call_time,
             [{pid, non_neg_integer, non_neg_integer, non_neg_integer}]
             | boolean
             | :undefined}

  @spec info(info_subject, info_item) :: info_result
  def info(pid_port_func_event, item) do
    :erlang.trace_info(pid_port_func_event, item)
  end

  @doc """
  Stops all tracing at once.
  """
  def stop do
    :recon_trace.clear()
  end
end