lib/mix/gear_static_analysis.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

# `Module.concat` is safe because mix task runs in a separate process.
# credo:disable-for-this-file Credo.Check.Warning.UnsafeToAtom

defmodule Mix.Tasks.Compile.GearStaticAnalysis do
  @moduledoc """
  Statically checks issues in gear's source code.

  Since antikythera is designed to run multiple gears within an ErlangVM, it assumes that each gear

  - is implemented so that it can run side-by-side with other gears, and
  - does not disturb resource allocation controlled by antikythera.

  This task is integrated into `mix compile` command so that these issues are detected each time gear's source code is compiled.
  """

  use Mix.Task.Compiler
  alias Antikythera.MacroUtil

  def run(_) do
    Mix.Project.config()[:elixirc_paths]
    |> Enum.flat_map(fn dir ->
      Path.wildcard(Path.join([dir, "**", "*.ex"]))
    end)
    |> Enum.flat_map(&find_issues_in_file/1)
    |> report()
  end

  defp find_issues_in_file(ex_file_path) do
    File.read!(ex_file_path)
    |> Code.string_to_quoted!()
    |> Macro.prewalk([], fn
      {:defmodule, meta, [{:__aliases__, _, atoms}, [do: body]]}, acc ->
        {issue_or_nil, tool?} =
          check_toplevel_module_name(Module.concat(atoms), meta, ex_file_path)

        check_module_body(body, List.wrap(issue_or_nil) ++ acc, ex_file_path, tool?)

      {:defimpl, meta,
       [{:__aliases__, _, protocol_atoms}, [for: {:__aliases__, _, mod_atoms}], [do: body]]},
      acc ->
        issue_or_nil =
          check_defimpl(
            Module.concat(protocol_atoms),
            Module.concat(mod_atoms),
            meta,
            ex_file_path
          )

        check_module_body(body, List.wrap(issue_or_nil) ++ acc, ex_file_path, false)

      n, acc ->
        {n, acc}
    end)
    |> elem(1)
    |> Enum.reverse()
  end

  defp check_toplevel_module_name(mod, meta, file) do
    # Compare module name prefix as String.t, in order not to be confused by the difference between e.g. `Mix` and `:Mix`.
    gear_name_camel = Mix.Project.config()[:app] |> Atom.to_string() |> Macro.camelize()

    case Module.split(mod) do
      [^gear_name_camel | _] ->
        {nil, false}

      ["Mix", ^gear_name_camel | _] ->
        {nil, true}

      ["Mix", "Tasks", ^gear_name_camel | _] ->
        {nil, true}

      _ ->
        {{:error, file, meta, "module name `#{inspect(mod)}` is not prefixed with the gear name"},
         false}
    end
  end

  defp check_defimpl(protocol, mod, meta, file) do
    # `defimpl` can be at the toplevel or within `defmodule`; we have to take the both cases into account.
    gear_name_camel = Mix.Project.config()[:app] |> Atom.to_string() |> Macro.camelize()

    case Module.split(protocol) do
      [^gear_name_camel | _] ->
        nil

      _ ->
        case Module.split(mod) do
          [^gear_name_camel | _] ->
            nil

          _ ->
            {:error, file, meta,
             "implementing non-gear-specific protocol for non-gear-specific type can affect other projects and is thus prohibited"}
        end
    end
  end

  defp check_module_body(body, acc, file, tool?) do
    # Since we want to employ different rules for (1) production code and (2) mix task (tool),
    # we need to traverse the module body with different accumulator function.
    issues =
      MacroUtil.prewalk_accumulate(body, [], fn n, acc2 ->
        List.wrap(check_ast_node(n, file, tool?)) ++ acc2
      end)

    # don't walk into the module body further by returning `nil`
    {nil, issues ++ acc}
  end

  defp check_ast_node(n, file, tool?) do
    case n do
      {:defimpl, meta,
       [{:__aliases__, _, protocol_atoms}, [for: {:__aliases__, _, mod_atoms}], [do: _block]]} ->
        # We don't have to check `defimpl` without `for:`, as the enclosing module's name is enforced to be properly prefixed by gear name.
        check_defimpl(Module.concat(protocol_atoms), Module.concat(mod_atoms), meta, file)

      {:use, meta, [{:__aliases__, _, atoms} | kw]} ->
        with_concatenated_module_atom(atoms, fn mod ->
          check_use_within_module(mod, kw, meta, file, tool?)
        end)

      {{:., _, [{:__aliases__, _, atoms}, fun]}, meta, args} ->
        with_concatenated_module_atom(atoms, fn mod ->
          check_remote_call(mod, fun, args, meta, file, tool?)
        end)

      {{:., _, [erlang_mod, fun]}, meta, args} ->
        check_remote_call(erlang_mod, fun, args, meta, file, tool?)

      {:__aliases__, meta, atoms} ->
        with_concatenated_module_atom(atoms, fn mod ->
          check_module(mod, meta, file, tool?)
        end)

      {fun, meta, args} when is_atom(fun) and is_list(args) ->
        check_local_call(fun, args, meta, file, tool?)

      atom when is_atom(atom) ->
        check_atom(atom, file)

      _ ->
        nil
    end
  end

  defp with_concatenated_module_atom(atoms, f) do
    # exclude module aliases such as `__MODULE__.Foo` by returning `nil`
    if Enum.all?(atoms, &is_atom/1) do
      f.(Module.concat(atoms))
    end
  end

  defp check_use_within_module(Gettext, _kw, _meta, file, _tool?) do
    {:error, file, [],
     "directly invoking `use Gettext` is not allowed (`use Antikythera.Gettext` instead)"}
  end

  defp check_use_within_module(_mod, _kw, _meta, _file, _tool?), do: nil

  defguardp writing_to_stdout?(args)
            when is_list(args) and (length(args) == 1 or hd(args) == :stdio)

  defp check_remote_call(mod, fun, args, meta, file, false) do
    with :ok <- check_disturbing_execution_of_vm(mod, fun),
         :ok <- check_writing_to_stdout(mod, fun, args),
         :ok <- check_spawning_process(mod, fun),
         :ok <- check_executing_shell_command(mod, fun) do
      nil
    else
      {severity, message} -> {severity, file, meta, message}
    end
  end

  defp check_remote_call(_mod, _fun, _args, _meta, _file, _tool?), do: nil

  defp check_disturbing_execution_of_vm(mod, fun) do
    if disturbing_execution_of_vm?(mod, fun) do
      {:error, "disturbing execution of ErlangVM is strictly prohibited"}
    else
      :ok
    end
  end

  defp disturbing_execution_of_vm?(System, fun) when fun in [:halt, :stop], do: true
  defp disturbing_execution_of_vm?(:erlang, :halt), do: true
  defp disturbing_execution_of_vm?(:init, _fun), do: true
  defp disturbing_execution_of_vm?(_mod, _fun), do: false

  defp check_writing_to_stdout(IO, fun, args)
       when fun in [:inspect, :puts, :write] and writing_to_stdout?(args) do
    severity = if Mix.env() == :prod, do: :error, else: :warning

    {severity,
     "writing to STDOUT/STDERR is not allowed in prod environment (use each gear's logger instead)"}
  end

  defp check_writing_to_stdout(_mod, _fun, _args), do: :ok

  defp check_spawning_process(mod, fun) when mod in [Process, Task, Agent] do
    if spawning_a_new_process?(Atom.to_string(fun)) do
      {:error, "spawning processes in gear's code is prohibited"}
    else
      :ok
    end
  end

  defp check_spawning_process(_mod, _fun), do: :ok

  defp check_executing_shell_command(:os, :cmd) do
    {:error, "calling :os.cmd/1 in gear's code is prohibited"}
  end

  defp check_executing_shell_command(System, :cmd) do
    if use_antikythera_internal_modules?() do
      :ok
    else
      {:error, "calling System.cmd/3 in gear's code is prohibited"}
    end
  end

  defp check_executing_shell_command(_mod, _fun), do: :ok

  defp spawning_a_new_process?("start" <> _), do: true
  defp spawning_a_new_process?("spawn" <> _), do: true
  defp spawning_a_new_process?("async" <> _), do: true
  defp spawning_a_new_process?(_), do: false

  defp check_local_call(fun, _args, meta, file, _tool?) do
    if Atom.to_string(fun) |> String.starts_with?("spawn") do
      {:error, file, meta, "spawning processes in gear's code is prohibited"}
    end
  end

  defp check_module(mod, meta, file, tool?) do
    [
      check_module_prefix(mod, meta, file, tool?),
      check_deprecated_libraries(mod, meta, file)
    ]
    |> Enum.reject(&is_nil/1)
  end

  defp check_module_prefix(mod, meta, file, tool?) do
    mod_str = inspect(mod)

    if use_antikythera_internal_modules?() do
      check_task_only_modules(mod_str, meta, file, tool?) ||
        check_test_only_modules(mod_str, meta, file, tool?)
    else
      check_task_only_modules(mod_str, meta, file, tool?) ||
        check_test_only_modules(mod_str, meta, file, tool?) ||
        check_antikythera_internal_modules(mod_str, meta, file)
    end
  end

  defp use_antikythera_internal_modules?() do
    Mix.Project.config()
    |> Keyword.fetch!(:antikythera_gear)
    |> Keyword.fetch!(:use_antikythera_internal_modules?)
  end

  defp check_antikythera_internal_modules(mod_str, meta, file) do
    case String.split(mod_str, ".") do
      ["AntikytheraCore" | _] ->
        {:error, file, meta, "direct use of `AntikytheraCore.*` is prohibited"}

      ["AntikytheraEal" | _] ->
        {:error, file, meta, "direct use of `AntikytheraEal.*` is prohibited"}

      ["AntikytheraLocal" | _] ->
        {:error, file, meta, "direct use of `AntikytheraLocal.*` is prohibited"}

      _ ->
        nil
    end
  end

  defp check_test_only_modules(mod_str, meta, file, tool?) do
    if !tool? do
      case String.split(mod_str, ".") do
        ["Antikythera", "Test" | _] ->
          {:error, file, meta, "using `Antikythera.Test.*` in production code is prohibited"}

        _ ->
          nil
      end
    end
  end

  defp check_task_only_modules(mod_str, meta, file, tool?) do
    if !tool? do
      case String.split(mod_str, ".") do
        ["Antikythera", "Mix", "Task" | _] ->
          {:error, file, meta, "`Antikythera.Mix.Task.*` can only be used in mix tasks"}

        _ ->
          nil
      end
    end
  end

  defp check_deprecated_libraries(_mod, _meta, _file) do
    # currently there's nothing to check
    nil
  end

  defp check_atom(atom, file) do
    case atom do
      :hackney ->
        {:warning, file, [],
         "directly depending on `:hackney` is not allowed (for `Antikythera.Httpc` use other options; for initialization of HTTP client library in your mix tasks use `Antikythera.Mix.Task.prepare_antikythera_instance/0`)"}

      _ ->
        nil
    end
  end

  defp report(issues) do
    mod_name = Module.split(__MODULE__) |> List.last()
    prefix = "[#{mod_name}]"

    Enum.each(issues, fn
      {:warning, file, meta, msg} ->
        IO.puts("#{prefix} #{file}:#{meta[:line]} WARNING #{msg}")

      {:error, file, meta, msg} ->
        IO.puts(
          IO.ANSI.red() <> "#{prefix} #{file}:#{meta[:line]} ERROR #{msg}" <> IO.ANSI.reset()
        )
    end)

    {warnings, errors} = Enum.split_with(issues, &match?({:warning, _, _, _}, &1))
    n_warnings = length(warnings)
    n_errors = length(errors)

    cond do
      n_errors > 0 ->
        Mix.raise(
          "#{prefix} Found #{n_errors} errors and #{n_warnings} warnings. Please fix them and try again."
        )

      n_warnings > 0 ->
        {IO.puts("#{prefix} Found #{n_warnings} warnings."), []}

      true ->
        {:ok, []}
    end
  end
end