defmodule Credence.Pattern.PreferErlangFloat do @moduledoc """ Replaces bare-variable float coercion tricks with explicit `:erlang.float/1`. LLMs (and developers) use `n * 1.0`, `n / 1.0`, `n + 0.0`, or `n - 0.0` to coerce an integer to a float. These are arithmetic tricks borrowed from Python — `:erlang.float/1` expresses the same intent explicitly and works whether the input is an integer (converts) or already a float (returns it). This rule only handles **bare-variable** operands. Compound expressions and function calls (`(a + b) * 1.0`, `Enum.sum(list) * 1.0`) are handled by `NoIdentityFloatCoercion`, which removes the identity outright — those are overwhelmingly Python-isms, not intentional coercion. ## Detected patterns var * 1.0 1.0 * var var / 1.0 var + 0.0 0.0 + var var - 0.0 Note: `0.0 - var` is NOT flagged — it negates, not coerces. ## Bad defp to_float(n) when is_integer(n), do: n * 1.0 count = count + 0.0 ## Good defp to_float(n) when is_integer(n), do: :erlang.float(n) count = :erlang.float(count) ## Auto-fix Replaces the identity arithmetic with `:erlang.float(var)`. """ use Credence.Pattern.Rule alias Credence.Issue @impl true def fixable?, do: true # ── Check ───────────────────────────────────────────────────────── # Uses AST from Code.string_to_quoted (bare float literals). @impl true def check(ast, _opts) do {_ast, issues} = Macro.prewalk(ast, [], fn # bare_var OP identity (right-hand identity) {op, meta, [expr, val]} = node, acc when is_float(val) and op in [:*, :/, :+, :-] -> if identity_right?(op, val) and bare_var?(expr) do {node, [build_issue(meta) | acc]} else {node, acc} end # identity OP bare_var (left-hand identity, commutative ops only) {op, meta, [val, expr]} = node, acc when is_float(val) and op in [:*, :+] -> if identity_left?(op, val) and bare_var?(expr) do {node, [build_issue(meta) | acc]} else {node, acc} end node, acc -> {node, acc} end) Enum.reverse(issues) end # ── Fix ─────────────────────────────────────────────────────────── # Uses Sourceror for parsing (wraps literals in __block__). @impl true def fix(source, _opts) do case Sourceror.parse_string(source) do {:ok, ast} -> target_lines = find_target_lines(ast) if target_lines == [] do source else line_set = MapSet.new(target_lines) source |> String.split("\n") |> Enum.with_index(1) |> Enum.map(fn {line, idx} -> if idx in line_set, do: replace_with_erlang_float(line), else: line end) |> Enum.join("\n") end {:error, _} -> source end end # ── Target-line collection (Sourceror AST) ──────────────────────── defp find_target_lines(ast) do {_ast, lines} = Macro.prewalk(ast, [], fn {op, meta, [left, right]} = node, acc when op in [:*, :/, :+, :-] -> hit_right = identity_right?(op, unwrap_float(right)) and bare_var?(left) hit_left = op in [:*, :+] and identity_left?(op, unwrap_float(left)) and bare_var?(right) if hit_right or hit_left do {node, [Keyword.get(meta, :line) | acc]} else {node, acc} end node, acc -> {node, acc} end) Enum.uniq(lines) end # ── Line-level rewriting (regex) ────────────────────────────────── @no_ext ~S"(?![0-9eE_])" defp replace_with_erlang_float(line) do line # Trailing: var OP IDENTITY → :erlang.float(var) |> then(&Regex.replace(~r/(\w+)\s*\*\s*1\.0#{@no_ext}/, &1, ":erlang.float(\\1)")) |> then(&Regex.replace(~r/(\w+)\s*\/\s*1\.0#{@no_ext}/, &1, ":erlang.float(\\1)")) |> then(&Regex.replace(~r/(\w+)\s*\+\s*0\.0#{@no_ext}/, &1, ":erlang.float(\\1)")) |> then(&Regex.replace(~r/(\w+)\s*\-\s*0\.0#{@no_ext}/, &1, ":erlang.float(\\1)")) # Leading: IDENTITY OP var → :erlang.float(var) |> then(&Regex.replace(~r/1\.0#{@no_ext}\s*\*\s*(\w+)/, &1, ":erlang.float(\\1)")) |> then(&Regex.replace(~r/0\.0#{@no_ext}\s*\+\s*(\w+)/, &1, ":erlang.float(\\1)")) end # ── Bare-variable detection ────────────────────────────────────── # Sourceror wraps variables in {:__block__, _, [var_node]}. defp bare_var?({:__block__, _, [inner]}), do: bare_var?(inner) defp bare_var?({name, _meta, ctx}) when is_atom(name) and is_atom(ctx), do: true defp bare_var?(_), do: false # ── Identity helpers ────────────────────────────────────────────── defp identity_right?(:*, 1.0), do: true defp identity_right?(:/, 1.0), do: true defp identity_right?(:+, +0.0), do: true defp identity_right?(:-, +0.0), do: true defp identity_right?(_, _), do: false defp identity_left?(:*, 1.0), do: true defp identity_left?(:+, +0.0), do: true defp identity_left?(_, _), do: false # Sourceror wraps float literals in {:__block__, meta, [value]}. defp unwrap_float({:__block__, _, [val]}) when is_float(val), do: val defp unwrap_float(val) when is_float(val), do: val defp unwrap_float(_), do: nil # ── Issue construction ──────────────────────────────────────────── defp build_issue(meta) do %Issue{ rule: :prefer_erlang_float, message: "Use `:erlang.float(var)` for int → float coercion instead of " <> "arithmetic identity tricks (`* 1.0`, `/ 1.0`, `+ 0.0`, `- 0.0`).", meta: %{line: Keyword.get(meta, :line)} } end end