defmodule Credence.Pattern.PreferPatternMatchEmptyString do @moduledoc """ Detects `byte_size(var) == 0` in function guards that can be replaced with pattern matching `""` directly in the function head. LLMs reach for `byte_size/1` guards to check for empty strings because many languages check `.length == 0` or `.size() == 0`. In Elixir, pattern matching `""` in the function head is shorter, clearer, and more idiomatic. ## Bad def reverse_left_words(str, _count) when byte_size(str) == 0, do: str def process(str) when byte_size(str) == 0, do: :empty ## Good def reverse_left_words("" = str, _count), do: str def process(""), do: :empty ## What is flagged Any `def`/`defp` clause whose guard contains `byte_size(param) == 0` where `param` is a top-level simple parameter. The `byte_size` check may be the sole guard or a direct conjunct in an `and` chain. Not flagged: - `byte_size(var) == 0` inside `or` - `byte_size(var) != 0` (negated check) - `byte_size(expr) == 0` where expr is not a simple variable - `byte_size(var) == N` where N != 0 ## Auto-fix Replaces the parameter with `""` in the function head and removes `byte_size(param) == 0` from the guard (or drops the guard entirely if it was the only condition). When the parameter is used in the function body, the fix uses `"" = param` to preserve the binding. """ use Credence.Pattern.Rule alias Credence.Issue @impl true def check(ast, _opts) do {_ast, issues} = Macro.prewalk(ast, [], fn node, acc -> case detect(node) do {:ok, meta} -> {node, [build_issue(meta) | acc]} :skip -> {node, acc} end end) Enum.reverse(issues) end # def/defp with a when clause defp detect({def_kind, meta, [{:when, _wm, [fn_head, guard]} | _]}) when def_kind in [:def, :defp] do params = top_level_param_names(fn_head) if params != [] and has_fixable_byte_size_zero?(guard, params) do {:ok, meta} else :skip end end defp detect(_), do: :skip # Extract simple top-level param names: {name, _, ctx} where both atoms defp top_level_param_names({_name, _meta, params}) when is_list(params) do for {n, _, ctx} <- params, is_atom(n), is_atom(ctx), do: n end defp top_level_param_names(_), do: [] # Walk the guard looking for byte_size(param) == 0 — only recurse # into `and` nodes, stop at `or` / `not` / `!` defp has_fixable_byte_size_zero?({:==, _, [{:byte_size, _, [{name, _, ctx}]}, zero]}, params) when is_atom(name) and is_atom(ctx) do case unwrap_int(zero) do 0 -> name in params _ -> false end end defp has_fixable_byte_size_zero?({:==, _, [zero, {:byte_size, _, [{name, _, ctx}]}]}, params) when is_atom(name) and is_atom(ctx) do case unwrap_int(zero) do 0 -> name in params _ -> false end end defp has_fixable_byte_size_zero?({:and, _, [left, right]}, params), do: has_fixable_byte_size_zero?(left, params) or has_fixable_byte_size_zero?(right, params) defp has_fixable_byte_size_zero?({:or, _, _}, _params), do: false defp has_fixable_byte_size_zero?({:not, _, _}, _params), do: false defp has_fixable_byte_size_zero?({:!, _, _}, _params), do: false defp has_fixable_byte_size_zero?(_, _params), do: false defp unwrap_int({:__block__, _, [n]}) when is_integer(n), do: n defp unwrap_int(n) when is_integer(n), do: n defp unwrap_int(_), do: nil @impl true def fix_patches(ast, _opts) do {_ast, patches} = Macro.prewalk(ast, [], fn {def_kind, _meta, [{:when, _wm, [fn_head, guard]} = when_node | rest]} = node, acc when def_kind in [:def, :defp] -> case build_when_patch(when_node, fn_head, guard, rest) do nil -> {node, acc} patch -> {node, [patch | acc]} end node, acc -> {node, acc} end) Enum.reverse(patches) end defp build_when_patch(when_node, fn_head, guard, body_rest) do params = top_level_param_names(fn_head) if params != [] and has_fixable_byte_size_zero?(guard, params) do bs_params = collect_bs_params(guard, params) if bs_params == [] do nil else {_fn_name, _fm, fn_args} = fn_head remaining_guard = remove_byte_size_zero_from_guard(guard, bs_params) # A param replaced with bare `""` is no longer bound, so it must keep # `"" = var` whenever it is still referenced anywhere the binding is in # scope — the body OR the guard that survives the byte_size removal. body_used = used_in_body?(bs_params, [remaining_guard | body_rest]) new_fn_args = Enum.map(fn_args, &maybe_replace_param(&1, bs_params, body_used)) new_fn_head = put_elem(fn_head, 2, new_fn_args) head_source = case remaining_guard do nil -> Macro.to_string(new_fn_head) g -> "#{Macro.to_string(new_fn_head)} when #{Macro.to_string(g)}" end %{range: Sourceror.get_range(when_node), change: head_source} end end end defp collect_bs_params({:==, _, [{:byte_size, _, [{name, _, ctx}]}, zero]}, params) when is_atom(name) and is_atom(ctx) do if name in params and unwrap_int(zero) == 0, do: [name], else: [] end defp collect_bs_params({:==, _, [zero, {:byte_size, _, [{name, _, ctx}]}]}, params) when is_atom(name) and is_atom(ctx) do if name in params and unwrap_int(zero) == 0, do: [name], else: [] end defp collect_bs_params({:and, _, [l, r]}, params), do: Enum.uniq(collect_bs_params(l, params) ++ collect_bs_params(r, params)) defp collect_bs_params(_, _params), do: [] defp maybe_replace_param({name, _meta, ctx} = node, bs_params, body_used) when is_atom(name) and is_atom(ctx) do if name in bs_params do if MapSet.member?(body_used, name) do # Keep binding: "" = var {:=, [], [{:__block__, [delimiter: "\""], [""]}, {name, [line: 0, column: 0], nil}]} else # Drop binding: just "" {:__block__, [delimiter: "\""], [""]} end else node end end defp maybe_replace_param(other, _bs_params, _body_used), do: other defp used_in_body?(bs_params, body_rest) do bs_params |> Enum.filter(fn name -> {_, found} = Macro.prewalk(body_rest, false, fn {^name, _, ctx} = n, _acc when is_atom(ctx) -> {n, true} n, acc -> {n, acc} end) found end) |> MapSet.new() end defp remove_byte_size_zero_from_guard( {:==, _, [{:byte_size, _, [{name, _, ctx}]}, zero]} = node, bs_params ) when is_atom(name) and is_atom(ctx) do if name in bs_params and unwrap_int(zero) == 0, do: nil, else: node end defp remove_byte_size_zero_from_guard( {:==, _, [zero, {:byte_size, _, [{name, _, ctx}]}]} = node, bs_params ) when is_atom(name) and is_atom(ctx) do if name in bs_params and unwrap_int(zero) == 0, do: nil, else: node end defp remove_byte_size_zero_from_guard({:and, meta, [l, r]}, bs_params) do case {remove_byte_size_zero_from_guard(l, bs_params), remove_byte_size_zero_from_guard(r, bs_params)} do {nil, nil} -> nil {nil, remaining} -> remaining {remaining, nil} -> remaining {l2, r2} -> {:and, meta, [l2, r2]} end end defp remove_byte_size_zero_from_guard(other, _bs_params), do: other defp build_issue(meta) do %Issue{ rule: :prefer_pattern_match_empty_string, message: """ `byte_size(var) == 0` in a guard can be replaced with pattern \ matching `""` directly in the function head. def f(str) when byte_size(str) == 0 → def f("") """, meta: %{line: Keyword.get(meta, :line)} } end end