defmodule Bylaw.Credo.Check.Elixir.NoResultTupleArgument do
@moduledoc """
Prevents functions from accepting `{:ok, _}` or `{:error, _}` as their first
argument.
## Examples
Avoid:
def handle({:ok, value}), do: process(value)
def handle({:error, reason}), do: reason
Prefer:
case fetch_value() do
{:ok, value} -> process(value)
{:error, reason} -> reason
end
## Notes
A helper that accepts tagged result tuples mixes two responsibilities:
branching on the result shape and doing the work for the successful or error
value. That makes the helper harder to reuse with plain values.
Branch where the tagged result is produced, then call helpers with the value
or reason they actually operate on.
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.Elixir.NoResultTupleArgument,
[
excluded_paths: ["test/support/"]
]}
]
}
]
}
```
- `:excluded_paths` - List of paths or regexes 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.Elixir.NoResultTupleArgument, []}
]
}
]
}
```
"""
use Credo.Check,
base_priority: :high,
category: :warning,
param_defaults: [excluded_paths: []],
explanations: [
check: @moduledoc,
params: [
excluded_paths: "List of paths or regexes to exclude from this check"
]
]
@result_tags [:ok, :error]
@doc false
@impl Credo.Check
def run(%Credo.SourceFile{} = source_file, params \\ []) do
excluded_paths = Params.get(params, :excluded_paths, __MODULE__)
if ignored_path?(source_file.filename, excluded_paths) do
[]
else
issue_meta = IssueMeta.for(source_file, params)
Credo.Code.prewalk(source_file, &traverse(&1, &2, issue_meta))
end
end
defp traverse(
{fun, meta, [{:when, _when_meta, [{_name, _name_meta, params} | _guards]} | _body]} = ast,
issues,
issue_meta
)
when fun in [:def, :defp] and is_list(params) do
{ast, maybe_add_issue(List.first(params), issues, issue_meta, meta)}
end
defp traverse(
{fun, meta, [{_name, _name_meta, params} | _body]} = ast,
issues,
issue_meta
)
when fun in [:def, :defp] and is_list(params) do
{ast, maybe_add_issue(List.first(params), issues, issue_meta, meta)}
end
defp traverse(ast, issues, _issue_meta), do: {ast, issues}
defp maybe_add_issue(nil, issues, _issue_meta, _meta), do: issues
defp maybe_add_issue(param, issues, issue_meta, meta) do
case find_result_tuple(param) do
nil ->
issues
tuple_pattern ->
[issue_for(issue_meta, meta[:line] || 0, tuple_pattern) | issues]
end
end
defp find_result_tuple({:=, _meta, [left, right]}) do
find_result_tuple(left) || find_result_tuple(right)
end
defp find_result_tuple({tag, _value} = tuple) when tag in @result_tags, do: tuple
defp find_result_tuple({:{}, _meta, [tag, _value | _rest]} = tuple) when tag in @result_tags,
do: tuple
defp find_result_tuple(_other), do: nil
defp issue_for(issue_meta, line_no, tuple_pattern) do
trigger = Macro.to_string(tuple_pattern)
format_issue(
issue_meta,
message:
"Do not accept `#{trigger}` as the first function argument. Branch on the result " <>
"earlier with `case` or `with`, then pass the unwrapped value or reason to a " <>
"dedicated function.",
trigger: trigger,
line_no: line_no
)
end
defp ignored_path?(filename, excluded_paths) do
Enum.any?(excluded_paths, &matches_path?(filename, &1))
end
defp matches_path?(filename, %Regex{} = regex), do: Regex.match?(regex, filename)
defp matches_path?(filename, path) when is_binary(path), do: String.contains?(filename, path)
end