defmodule Bylaw.Credo.Check.Phoenix.ContextFunctionNaming do
@moduledoc """
Context lookup functions must follow a naming convention that signals their
return type, consistent with `Ecto.Repo` (e.g. `Repo.get/2`, `Repo.get!/2`):
## Examples
- `get_*` functions return `record | nil`
- `get_*!` functions return `record` (raise on not found)
- `fetch_*` functions return `{:ok, record} | {:error, reason}`
Avoid:
@spec get_workspace(binary()) :: {:ok, Workspace.t()} | {:error, :not_found}
def get_workspace(id), do: ...
Prefer:
@spec fetch_workspace(binary()) :: {:ok, Workspace.t()} | {:error, :not_found}
def fetch_workspace(id), do: ...
Or this:
@spec get_workspace(binary()) :: Workspace.t() | nil
def get_workspace(id), do: ...
## Notes
Path exclusions are matched against the source filename and are intended for generated files or temporary migration areas.
The check uses static AST analysis, so dynamic code generation and macro-expanded code may fall outside its signal.
## Options
Configure options in `.credo.exs` with the check tuple:
```elixir
%{
configs: [
%{
name: "default",
checks: [
{Bylaw.Credo.Check.Phoenix.ContextFunctionNaming,
[
excluded_paths: ["test/support/"]
]}
]
}
]
}
```
- `:excluded_paths` - List of paths or regex to exclude from this check
## Usage
Add this check to Credo's `checks:` list in `.credo.exs`:
```elixir
%{
configs: [
%{
name: "default",
checks: [
{Bylaw.Credo.Check.Phoenix.ContextFunctionNaming, []}
]
}
]
}
```
"""
use Credo.Check,
base_priority: :high,
category: :design,
param_defaults: [excluded_paths: []],
explanations: [
check: @moduledoc,
params: [
excluded_paths: "List of paths or regex to exclude from this check"
]
]
@doc false
@impl Credo.Check
def run(source_file, params \\ []) do
ctx = Context.build(source_file, params, __MODULE__)
case ignore_path?(source_file.filename, Params.get(params, :excluded_paths, __MODULE__)) do
true ->
[]
false ->
Credo.Code.prewalk(source_file, &walk/2, ctx).issues
end
end
defp walk(
{:@, _meta, [{:spec, _spec_meta, [{:"::", _op_meta, [call, return_type]}]}]} = ast,
ctx
) do
{ast, check_spec(ctx, call, return_type)}
end
defp walk(
{:@, _meta,
[
{:spec, _spec_meta,
[{:when, _when_meta, [{:"::", _op_meta, [call, return_type]} | _constraints]}]}
]} = ast,
ctx
) do
{ast, check_spec(ctx, call, return_type)}
end
defp walk(ast, ctx), do: {ast, ctx}
defp check_spec(ctx, call, return_type) do
case extract_function_name(call) do
nil ->
ctx
{name_str, meta} ->
tagged_tuple_kind = tagged_tuple_kind(return_type)
allows_nil = contains_nil?(return_type)
maybe_add_naming_issue(ctx, name_str, meta, tagged_tuple_kind, allows_nil)
end
end
defp maybe_add_naming_issue(ctx, name_str, meta, tagged_tuple_kind, allows_nil) do
has_any_tagged_tuple = tagged_tuple_kind != :none
cond do
get_prefix_without_bang?(name_str) and has_any_tagged_tuple ->
add_get_returns_tagged_tuple_issue(ctx, name_str, meta)
fetch_prefix?(name_str) and tagged_tuple_kind != :ok_and_error ->
add_fetch_missing_contract_issue(ctx, name_str, meta)
get_bang?(name_str) ->
maybe_add_bang_issue(ctx, name_str, meta, has_any_tagged_tuple, allows_nil)
true ->
ctx
end
end
defp maybe_add_bang_issue(ctx, name_str, meta, has_any_tagged_tuple, allows_nil) do
cond do
has_any_tagged_tuple -> add_bang_returns_tagged_tuple_issue(ctx, name_str, meta)
allows_nil -> add_bang_allows_nil_issue(ctx, name_str, meta)
true -> ctx
end
end
defp get_prefix_without_bang?(name_str), do: get_prefix?(name_str) and not bang?(name_str)
defp get_bang?(name_str), do: bang?(name_str) and get_prefix?(name_str)
defp add_get_returns_tagged_tuple_issue(ctx, name_str, meta) do
put_issue(
ctx,
format_issue(
ctx,
message:
"`#{name_str}` returns tagged tuples but uses `get_` prefix. " <>
"Rename to `#{to_fetch_name(name_str)}` or change return type to `record | nil`.",
trigger: name_str,
line_no: meta[:line]
)
)
end
defp add_fetch_missing_contract_issue(ctx, name_str, meta) do
put_issue(
ctx,
format_issue(
ctx,
message:
"`#{name_str}` uses `fetch_` prefix but does not return " <>
"`{:ok, record} | {:error, reason}`. " <>
"Return tagged tuples or rename to `get_` prefix.",
trigger: name_str,
line_no: meta[:line]
)
)
end
defp add_bang_returns_tagged_tuple_issue(ctx, name_str, meta) do
put_issue(
ctx,
format_issue(
ctx,
message:
"`#{name_str}` returns tagged tuples but uses bang (`!`) suffix. " <>
"Bang functions should return the record directly or raise.",
trigger: name_str,
line_no: meta[:line]
)
)
end
defp add_bang_allows_nil_issue(ctx, name_str, meta) do
put_issue(
ctx,
format_issue(
ctx,
message:
"`#{name_str}` allows `nil` but uses bang (`!`) suffix. " <>
"Bang functions should return the record directly or raise.",
trigger: name_str,
line_no: meta[:line]
)
)
end
defp extract_function_name({name, meta, args}) when is_atom(name) and is_list(args) do
{Atom.to_string(name), meta}
end
defp extract_function_name(_ast), do: nil
defp get_prefix?(name), do: String.starts_with?(name, "get_")
defp fetch_prefix?(name), do: String.starts_with?(name, "fetch_")
defp bang?(name), do: String.ends_with?(name, "!")
defp to_fetch_name("get_" <> rest), do: "fetch_" <> String.trim_trailing(rest, "!")
defp tagged_tuple_kind({:|, _meta, [left, right]}) do
merge_tagged_tuple_kinds(tagged_tuple_kind(left), tagged_tuple_kind(right))
end
defp tagged_tuple_kind({:ok, _type}), do: :ok
defp tagged_tuple_kind({:error, _type}), do: :error
defp tagged_tuple_kind(_other), do: :none
defp merge_tagged_tuple_kinds(:none, kind), do: kind
defp merge_tagged_tuple_kinds(kind, :none), do: kind
defp merge_tagged_tuple_kinds(:ok, :error), do: :ok_and_error
defp merge_tagged_tuple_kinds(:error, :ok), do: :ok_and_error
defp merge_tagged_tuple_kinds(:ok_and_error, _right), do: :ok_and_error
defp merge_tagged_tuple_kinds(_left, :ok_and_error), do: :ok_and_error
defp merge_tagged_tuple_kinds(kind, kind), do: kind
defp contains_nil?({:|, _meta, [left, right]}) do
contains_nil?(left) or contains_nil?(right)
end
defp contains_nil?(nil), do: true
defp contains_nil?(_other), do: false
defp ignore_path?(filename, excluded_paths) do
Enum.any?(excluded_paths, &matches?(filename, &1))
end
defp matches?(filename, %Regex{} = regex), do: Regex.match?(regex, filename)
defp matches?(filename, path) when is_binary(path), do: String.contains?(filename, path)
end