Current section

Files

Jump to
boundary lib mix tasks compile boundary.ex
Raw

lib/mix/tasks/compile/boundary.ex

defmodule Mix.Tasks.Compile.Boundary do
# credo:disable-for-this-file Credo.Check.Readability.Specs
use Boundary, deps: [Boundary]
use Mix.Task.Compiler
alias Boundary.Xref
@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: [:boundaries]
end
```
## Warnings
Every invalid cross-boundary call 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 call to MySystemWeb.Endpoint.url/0
(calls from MySystem to MySystemWeb are not allowed)
(call originated from MySystem.User)
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
Xref.start_link(path())
Mix.Task.Compiler.after_compiler(:app, &after_compiler(&1, argv))
tracers = Code.get_compiler_option(:tracers)
Code.put_compiler_option(:tracers, [__MODULE__ | tracers])
{:ok, []}
end
@doc false
def trace({remote, meta, callee_module, name, arity}, env) when remote in ~w/remote_function remote_macro/a do
if env.module != nil do
Xref.add_call(
env.module,
%{callee: {callee_module, name, arity}, file: Path.relative_to_cwd(env.file), line: meta[:line]}
)
end
:ok
end
def trace(_event, _env), do: :ok
defp after_compiler({:error, _} = status, _argv), do: status
defp after_compiler({status, diagnostics}, argv) when status in [:ok, :noop] do
tracers = Enum.reject(Code.get_compiler_option(:tracers), &(&1 == __MODULE__))
Code.put_compiler_option(:tracers, tracers)
calls = Xref.calls(path(), app_modules())
errors = Boundary.MixCompiler.check(calls: calls)
print_diagnostic_errors(errors)
{status(errors, argv), diagnostics ++ errors}
end
defp app_modules do
app = Keyword.fetch!(Mix.Project.config(), :app)
Application.load(app)
Application.spec(app, :modules)
end
defp status([], _), do: :ok
defp status([_ | _], argv), do: if(warnings_as_errors?(argv), do: :error, else: :ok)
defp warnings_as_errors?(argv) do
{parsed, _argv, _errors} = OptionParser.parse(argv, strict: [warnings_as_errors: :boolean])
Keyword.get(parsed, :warnings_as_errors, false)
end
defp print_diagnostic_errors(errors) do
if errors != [], do: IO.puts("")
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
pos = if error.position != nil, do: ":#{error.position}", 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 path, do: Path.join(Mix.Project.compile_path(), "boundary_calls.ets")
end