defmodule Bylaw.Credo.Check.Elixir.NoPassthroughWrapper do
@moduledoc """
Avoid private functions that only forward their arguments to a single call.
Inline the call instead.
## Examples
Avoid:
defp format_datetime(datetime), do: DateTime.to_iso8601(datetime)
Prefer:
DateTime.to_iso8601(datetime)
If the wrapper name materially improves readability, keep it and disable
this check locally:
# credo:disable-for-next-line Bylaw.Credo.Check.Elixir.NoPassthroughWrapper
defp format_datetime(datetime), do: DateTime.to_iso8601(datetime)
## Notes
This check uses static AST analysis, so it favors clear source-level patterns over runtime behavior.
## Options
Configure options in `.credo.exs` with the check tuple:
```elixir
%{
configs: [
%{
name: "default",
checks: [
{Bylaw.Credo.Check.Elixir.NoPassthroughWrapper,
[
include_public: true
]}
]
}
]
}
```
- `:include_public` - When true, also report public `def` passthrough wrappers
## Usage
Add this check to Credo's `checks:` list in `.credo.exs`:
```elixir
%{
configs: [
%{
name: "default",
checks: [
{Bylaw.Credo.Check.Elixir.NoPassthroughWrapper, []}
]
}
]
}
```
"""
use Credo.Check,
base_priority: :high,
category: :design,
param_defaults: [include_public: false],
explanations: [
check: @moduledoc,
params: [
include_public: "When true, also report public `def` passthrough wrappers"
]
]
@definitions ~w(def defp)a
@doc false
@impl Credo.Check
def run(source_file, params \\ []) do
ctx = Context.build(source_file, params, __MODULE__)
definition_counts = collect_definition_counts(source_file)
state = %{
ctx: ctx,
include_public?: Params.get(params, :include_public, __MODULE__),
definition_counts: definition_counts
}
Credo.Code.prewalk(source_file, &walk/2, state).ctx.issues
end
defp walk({definition, _meta, [head, [do: body]]} = ast, state)
when definition in @definitions do
case issue_for_definition(state, definition, head, body) do
nil -> {ast, state}
issue -> {ast, %{state | ctx: put_issue(state.ctx, issue)}}
end
end
defp walk(ast, state), do: {ast, state}
defp issue_for_definition(%{include_public?: false}, :def, _head, _body), do: nil
defp issue_for_definition(state, definition, head, body) when definition in @definitions do
case {extract_definition(head), extract_forwarded_call(body)} do
{{:ok, {name, meta, param_names}}, {:ok, {callee, forwarded_args}}} ->
arity = Enum.count(param_names)
if param_names == forwarded_args and
single_clause_definition?(state, definition, name, arity) do
format_issue(
state.ctx,
message:
"Avoid tiny indirection in `#{name}/#{arity}`; it only forwards arguments to `#{callee}`. " <>
"Inline the call unless the wrapper name materially improves readability.",
trigger: Atom.to_string(name),
line_no: meta[:line]
)
end
_other ->
nil
end
end
defp extract_definition({:when, _meta, [_call, _guard]}), do: :error
defp extract_definition({name, meta, params}) when is_atom(name) and is_list(params) do
case extract_param_names(params) do
{:ok, param_names} ->
if Enum.empty?(param_names) do
:error
else
{:ok, {name, meta, param_names}}
end
_other ->
:error
end
end
defp extract_definition(_head), do: :error
defp collect_definition_counts(source_file) do
Credo.Code.prewalk(source_file, &collect_definition/2, %{})
end
defp collect_definition({definition, _meta, [head, _body]} = ast, counts)
when definition in @definitions do
case definition_signature(head) do
{:ok, signature} -> {ast, Map.update(counts, {definition, signature}, 1, &(&1 + 1))}
:error -> {ast, counts}
end
end
defp collect_definition(ast, counts), do: {ast, counts}
defp definition_signature({:when, _meta, [call, _guard]}), do: definition_signature(call)
defp definition_signature({name, _meta, params}) when is_atom(name) and is_list(params) do
{:ok, {name, Enum.count(params)}}
end
defp definition_signature(_head), do: :error
defp single_clause_definition?(state, definition, name, arity) do
Map.get(state.definition_counts, {definition, {name, arity}}) == 1
end
defp extract_param_names(params), do: extract_names(params, &extract_param_name/1)
defp extract_param_name({:\\, _meta, [param, _default]}), do: extract_param_name(param)
defp extract_param_name({:=, _meta, [_pattern, param]}), do: extract_param_name(param)
defp extract_param_name({name, _meta, context})
when is_atom(name) and (is_atom(context) or is_nil(context)) do
{:ok, name}
end
defp extract_param_name(_param), do: :error
defp extract_forwarded_call({:|>, _meta, [input, call]}) do
case {extract_arg_name(input), extract_forwarded_pipe_call(call)} do
{{:ok, input_name}, {:ok, {callee, arg_names}}} ->
{:ok, {callee, [input_name | arg_names]}}
_other ->
:error
end
end
defp extract_forwarded_call({name, _meta, args}) when is_atom(name) and is_list(args) do
case extract_arg_names(args) do
{:ok, arg_names} -> {:ok, {"#{name}/#{Enum.count(args)}", arg_names}}
:error -> :error
end
end
defp extract_forwarded_call(
{{:., _dot_meta, [{:__aliases__, _alias_meta, modules}, name]}, _meta, args}
)
when is_atom(name) and is_list(args) do
case extract_arg_names(args) do
{:ok, arg_names} ->
{:ok, {"#{module_name(modules)}.#{name}/#{Enum.count(args)}", arg_names}}
:error ->
:error
end
end
defp extract_forwarded_call(_body), do: :error
defp extract_forwarded_pipe_call({name, _meta, args}) when is_atom(name) and is_list(args) do
case extract_arg_names(args) do
{:ok, arg_names} -> {:ok, {"#{name}/#{Enum.count(args) + 1}", arg_names}}
:error -> :error
end
end
defp extract_forwarded_pipe_call(
{{:., _dot_meta, [{:__aliases__, _alias_meta, modules}, name]}, _meta, args}
)
when is_atom(name) and is_list(args) do
case extract_arg_names(args) do
{:ok, arg_names} ->
{:ok, {"#{module_name(modules)}.#{name}/#{Enum.count(args) + 1}", arg_names}}
:error ->
:error
end
end
defp extract_forwarded_pipe_call(_call), do: :error
defp extract_arg_names(args), do: extract_names(args, &extract_arg_name/1)
defp extract_arg_name({name, _meta, context})
when is_atom(name) and (is_atom(context) or is_nil(context)) do
{:ok, name}
end
defp extract_arg_name(_arg), do: :error
defp extract_names(items, extractor) do
case Enum.reduce_while(items, [], &extract_name_step(&1, &2, extractor)) do
:error -> :error
names -> {:ok, Enum.reverse(names)}
end
end
defp extract_name_step(item, names, extractor) do
case extractor.(item) do
{:ok, name} -> {:cont, [name | names]}
:error -> {:halt, :error}
end
end
defp module_name(modules), do: Enum.map_join(modules, ".", &Atom.to_string/1)
end