lib/boundary/mix/tasks/compile/boundary.ex

defmodule Mix.Tasks.Compile.Boundary do
  # credo:disable-for-this-file Credo.Check.Readability.Specs

  use Boundary, classify_to: Boundary.Mix
  use Mix.Task.Compiler
  alias Boundary.Mix.CompilerState

  @moduledoc """
  Verifies cross-module function calls according to defined boundaries.

  This compiler reports all cross-boundary function calls which are not permitted, according to
  the current definition of boundaries. For details on defining boundaries, see the docs for the
  `Boundary` module.

  ## Usage

  Once you have configured the boundaries, you need to include the compiler in `mix.exs`:

  ```
  defmodule MySystem.MixProject do
    # ...

    def project do
      [
        compilers: [:boundary] ++ Mix.compilers(),
        # ...
      ]
    end

    # ...
  end
  ```

  When developing a library, it's advised to use this compiler only in `:dev` and `:test`
  environments:

  ```
  defmodule Boundary.MixProject do
    # ...

    def project do
      [
        compilers: extra_compilers(Mix.env()) ++ Mix.compilers(),
        # ...
      ]
    end

    # ...

    defp extra_compilers(:prod), do: []
    defp extra_compilers(_env), do: [:boundary]
  end
  ```

  ## Warnings

  Every invalid cross-boundary usage is reported as a compiler warning. Consider the following example:

  ```
  defmodule MySystem.User do
    def auth() do
      MySystemWeb.Endpoint.url()
    end
  end
  ```

  Assuming that calls from `MySystem` to `MySystemWeb` are not allowed, you'll get the following warning:

  ```
  $ mix compile

  warning: forbidden reference to MySystemWeb
    (references from MySystem to MySystemWeb are not allowed)
    lib/my_system/user.ex:3
  ```

  Since the compiler emits warnings, `mix compile` will still succeed, and you can normally start
  your system, even if some boundary rules are violated. The compiler doesn't force you to immediately
  fix these violations, which is a deliberate decision made to avoid disrupting the development flow.

  At the same time, it's worth enforcing boundaries on the CI. This can easily be done by providing
  the `--warnings-as-errors` option to `mix compile`.
  """

  @recursive true

  @impl Mix.Task.Compiler
  def run(argv) do
    {opts, _rest, _errors} = OptionParser.parse(argv, strict: [force: :boolean, warnings_as_errors: :boolean])

    CompilerState.start_link(Keyword.take(opts, [:force]))
    Mix.Task.Compiler.after_compiler(:elixir, &after_elixir_compiler/1)
    Mix.Task.Compiler.after_compiler(:app, &after_app_compiler(&1, opts))

    tracers = Code.get_compiler_option(:tracers)
    Code.put_compiler_option(:tracers, [__MODULE__ | tracers])

    {:ok, []}
  end

  @doc false
  def trace({remote, meta, to_module, _name, _arity}, env)
      when remote in ~w/remote_function imported_function remote_macro imported_macro/a do
    mode = if is_nil(env.function) or remote in ~w/remote_macro imported_macro/a, do: :compile, else: :runtime
    record(to_module, meta, env, mode, :call)
  end

  def trace({local, _meta, _to_module, _name, _arity}, env)
      when local in ~w/local_function local_macro/a,
      # We need to initialize module although we're not going to record the call, to correctly remove previously
      # recorded entries when the module is recompiled.
      do: initialize_module(env.module)

  def trace({:struct_expansion, meta, to_module, _keys}, env),
    do: record(to_module, meta, env, :compile, :struct_expansion)

  def trace({:alias_reference, meta, to_module}, env) do
    unless env.function == {:boundary, 1} do
      mode = if is_nil(env.function), do: :compile, else: :runtime
      record(to_module, meta, env, mode, :alias_reference)
    end

    :ok
  end

  def trace({:on_module, _bytecode, _ignore}, env) do
    CompilerState.add_module_meta(env.module, :protocol?, Module.defines?(env.module, {:__impl__, 1}, :def))
    :ok
  end

  def trace(_event, _env), do: :ok

  defp record(to_module, meta, env, mode, type) do
    # We need to initialize module even if we're not going to record the call, to correctly remove previously
    # recorded entries when the module is recompiled.
    initialize_module(env.module)

    unless env.module in [nil, to_module] or system_module?(to_module) or
             not String.starts_with?(Atom.to_string(to_module), "Elixir.") do
      CompilerState.record_references(
        env.module,
        %{
          from_function: env.function,
          to: to_module,
          mode: mode,
          type: type,
          file: Path.relative_to_cwd(env.file),
          line: Keyword.get(meta, :line, env.line)
        }
      )
    end

    :ok
  end

  defp initialize_module(module),
    do: unless(is_nil(module), do: CompilerState.initialize_module(module))

  # Building the list of "system modules", which we'll exclude from the traced data, to reduce the collected data and
  # processing time.
  system_apps = ~w/elixir stdlib kernel/a

  system_apps
  |> Stream.each(&Application.load/1)
  |> Stream.flat_map(&Application.spec(&1, :modules))
  # We'll also include so called preloaded modules (e.g. `:erlang`, `:init`), which are not a part of any app.
  |> Stream.concat(:erlang.pre_loaded())
  |> Enum.each(fn module -> defp system_module?(unquote(module)), do: true end)

  defp system_module?(_module), do: false

  defp after_elixir_compiler(outcome) do
    # Unloading the tracer after Elixir compiler, irrespective of the outcome. This ensures that the tracer is correctly
    # unloaded even if the compilation fails.
    tracers = Enum.reject(Code.get_compiler_option(:tracers), &(&1 == __MODULE__))
    Code.put_compiler_option(:tracers, tracers)
    outcome
  end

  defp after_app_compiler(outcome, opts) do
    # Perform the boundary checks only on a successfully compiled app, to avoid false positives.
    with {status, diagnostics} when status in [:ok, :noop] <- outcome do
      # We're reloading the app to make sure we have the latest version. This fixes potential stale state in ElixirLS.
      Application.unload(Boundary.Mix.app_name())
      Application.load(Boundary.Mix.app_name())

      CompilerState.flush(Application.spec(Boundary.Mix.app_name(), :modules) || [])

      # Caching of the built view for non-user apps. A user app is the main app of the project, and all local deps
      # (in-umbrella and path deps). All other apps are library dependencies, and we're caching the boundary view of such
      # apps, because that view isn't changing, and we want to avoid loading modules of those apps on every compilation,
      # since that's very slow.
      user_apps =
        for {app, [_ | _] = opts} <- Keyword.get(Mix.Project.config(), :deps, []),
            Enum.any?(opts, &(&1 == {:in_umbrella, true} or match?({:path, _}, &1))),
            into: MapSet.new([Boundary.Mix.app_name()]),
            do: app

      view = Boundary.Mix.View.refresh(user_apps, Keyword.take(opts, ~w/force/a))

      errors = check(view, CompilerState.references())
      print_diagnostic_errors(errors)
      {status(errors, opts), diagnostics ++ errors}
    end
  end

  defp status([], _), do: :ok
  defp status([_ | _], opts), do: if(Keyword.get(opts, :warnings_as_errors, false), do: :error, else: :ok)

  defp print_diagnostic_errors(errors) do
    if errors != [], do: Mix.shell().info("")
    Enum.each(errors, &print_diagnostic_error/1)
  end

  defp print_diagnostic_error(error) do
    Mix.shell().info([severity(error.severity), error.message, location(error)])
  end

  defp location(error) do
    if error.file != nil and error.file != "" do
      line = with tuple when is_tuple(tuple) <- error.position, do: elem(tuple, 0)
      pos = if line != nil, do: ":#{line}", else: ""
      "\n  #{error.file}#{pos}\n"
    else
      "\n"
    end
  end

  defp severity(severity), do: [:bright, color(severity), "#{severity}: ", :reset]
  defp color(:error), do: :red
  defp color(:warning), do: :yellow

  defp check(application, entries) do
    Boundary.errors(application, entries)
    |> Stream.map(&to_diagnostic_error/1)
    |> Enum.sort_by(&{&1.file, &1.position})
  rescue
    e in Boundary.Error ->
      [diagnostic(e.message, file: e.file, position: e.line)]
  end

  defp to_diagnostic_error({:unclassified_module, module}),
    do: diagnostic("#{inspect(module)} is not included in any boundary", file: module_source(module))

  defp to_diagnostic_error({:unknown_dep, dep}) do
    diagnostic("unknown boundary #{inspect(dep.name)} is listed as a dependency",
      file: Path.relative_to_cwd(dep.file),
      position: dep.line
    )
  end

  defp to_diagnostic_error({:check_in_false_dep, dep}) do
    diagnostic("boundary #{inspect(dep.name)} can't be a dependency because it has check.in set to false",
      file: Path.relative_to_cwd(dep.file),
      position: dep.line
    )
  end

  defp to_diagnostic_error({:forbidden_dep, dep}) do
    diagnostic(
      "#{inspect(dep.name)} can't be listed as a dependency because it's not a sibling, a parent, or a dep of some ancestor",
      file: Path.relative_to_cwd(dep.file),
      position: dep.line
    )
  end

  defp to_diagnostic_error({:unknown_export, export}) do
    diagnostic("unknown module #{inspect(export.name)} is listed as an export",
      file: Path.relative_to_cwd(export.file),
      position: export.line
    )
  end

  defp to_diagnostic_error({:export_not_in_boundary, export}) do
    diagnostic("module #{inspect(export.name)} can't be exported because it's not a part of this boundary",
      file: Path.relative_to_cwd(export.file),
      position: export.line
    )
  end

  defp to_diagnostic_error({:cycle, cycle}) do
    cycle = cycle |> Stream.map(&inspect/1) |> Enum.join(" -> ")
    diagnostic("dependency cycle found:\n#{cycle}\n")
  end

  defp to_diagnostic_error({:unknown_boundary, info}) do
    diagnostic("unknown boundary #{inspect(info.name)}",
      file: Path.relative_to_cwd(info.file),
      position: info.line
    )
  end

  defp to_diagnostic_error({:cant_reclassify, info}) do
    diagnostic("only mix task and protocol implementation can be reclassified",
      file: Path.relative_to_cwd(info.file),
      position: info.line
    )
  end

  defp to_diagnostic_error({:invalid_reference, error}) do
    reason =
      case error.type do
        :normal ->
          "(references from #{inspect(error.from_boundary)} to #{inspect(error.to_boundary)} are not allowed)"

        :runtime ->
          "(runtime references from #{inspect(error.from_boundary)} to #{inspect(error.to_boundary)} are not allowed)"

        :not_exported ->
          module = inspect(error.reference.to)
          "(module #{module} is not exported by its owner boundary #{inspect(error.to_boundary)})"

        :invalid_external_dep_call ->
          "(references from #{inspect(error.from_boundary)} to #{inspect(error.to_boundary)} are not allowed)"
      end

    message = "forbidden reference to #{inspect(error.reference.to)}\n  #{reason}"

    diagnostic(message, file: Path.relative_to_cwd(error.reference.file), position: error.reference.line)
  end

  defp to_diagnostic_error({:unknown_option, %{name: :ignore?, value: value} = data}) do
    diagnostic(
      "ignore?: #{value} is deprecated, use check: [in: #{not value}, out: #{not value}] instead",
      file: Path.relative_to_cwd(data.file),
      position: data.line
    )
  end

  defp to_diagnostic_error({:unknown_option, data}) do
    diagnostic("unknown option #{inspect(data.name)}",
      file: Path.relative_to_cwd(data.file),
      position: data.line
    )
  end

  defp to_diagnostic_error({:deps_in_check_out_false, data}) do
    diagnostic("deps can't be listed if check.out is set to false",
      file: Path.relative_to_cwd(data.file),
      position: data.line
    )
  end

  defp to_diagnostic_error({:apps_in_check_out_false, data}) do
    diagnostic("check apps can't be listed if check.out is set to false",
      file: Path.relative_to_cwd(data.file),
      position: data.line
    )
  end

  defp to_diagnostic_error({:exports_in_check_in_false, data}) do
    diagnostic("can't export modules if check.in is set to false",
      file: Path.relative_to_cwd(data.file),
      position: data.line
    )
  end

  defp to_diagnostic_error({:invalid_type, data}) do
    diagnostic("invalid type",
      file: Path.relative_to_cwd(data.file),
      position: data.line
    )
  end

  defp to_diagnostic_error({:invalid_ignores, boundary}) do
    diagnostic("can't disable checks in a sub-boundary",
      file: Path.relative_to_cwd(boundary.file),
      position: boundary.line
    )
  end

  defp to_diagnostic_error({:ancestor_with_ignored_checks, boundary, ancestor}) do
    diagnostic("sub-boundary inside a boundary with disabled checks (#{inspect(ancestor.name)})",
      file: Path.relative_to_cwd(boundary.file),
      position: boundary.line
    )
  end

  defp to_diagnostic_error({:unused_dirty_xref, boundary, xref}) do
    diagnostic(
      "module #{inspect(xref)} doesn't need to be included in the `dirty_xrefs` list for the boundary #{inspect(boundary.name)}",
      file: Path.relative_to_cwd(boundary.file),
      position: boundary.line
    )
  end

  defp module_source(module) do
    module.module_info(:compile)
    |> Keyword.fetch!(:source)
    |> to_string()
    |> Path.relative_to_cwd()
  catch
    _, _ -> ""
  end

  def diagnostic(message, opts \\ []) do
    %Mix.Task.Compiler.Diagnostic{
      compiler_name: "boundary",
      details: nil,
      file: nil,
      message: message,
      position: 0,
      severity: :warning
    }
    |> struct(opts)
  end
end