Current section
Files
Jump to
Current section
Files
lib/pattern/prefer_erlang_float.ex
defmodule Credence.Pattern.PreferErlangFloat do
@moduledoc """
Replaces float-coercion arithmetic tricks with explicit `:erlang.float/1`.
LLMs (and developers) use `x * 1.0`, `x / 1.0`, `x + 0.0`, or `x - 0.0` to
coerce a number to a float. These are arithmetic idioms 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 unchanged).
The rewrite **preserves the float result** — it does not delete the coercion.
Deleting `* 1.0` (an earlier mistake of a now-merged sibling rule) would turn
`6.0` back into `6`, a value-kind change; wrapping in `:erlang.float/1` keeps
the value identical for every number.
Applies to **any operand shape** — bare variables (`n * 1.0`) and compound
expressions alike (`Enum.sum(list) * 1.0`, `(a + b) * 1.0`).
## Detected patterns
x * 1.0 1.0 * x
x / 1.0
x + 0.0 0.0 + x
x - 0.0
Note: `0.0 - x` is NOT flagged — it negates, not coerces.
## Behaviour note
For every numeric operand the rewrite is exact (same float value). A
*non-number* operand raises in both forms — `x * 1.0` raises `ArithmeticError`
while `:erlang.float(x)` raises `ArgumentError` — so the code crashes either
way and only the exception module differs. (A non-number operand at a
float-coercion site is already-broken code.)
## Bad
defp to_float(n) when is_integer(n), do: n * 1.0
avg = total / count * 1.0
## Good
defp to_float(n) when is_integer(n), do: :erlang.float(n)
avg = :erlang.float(total / count)
"""
use Credence.Pattern.Rule
# DSL-unsafe in Ash.Expr / Ecto.Query: replaces float-coercion arithmetic
# (`x * 1.0`, `x / 1.0`) with `:erlang.float/1`. A bare Erlang call is not a
# query operator — Ash will not translate it and Ecto forbids it — whereas the
# original arithmetic maps to SQL. Nx.Defn is NOT listed because this rule
# handles it more precisely with its own `defn_operator_positions/1` guard:
# inside a `defn`/`defnp` body `x * 1.0` is valid *element-wise* tensor math, so
# the guard skips those operator positions outright (an `unsafe_in_dsl :nx_defn`
# gate keys on the same `defn` blocks and would be redundant). Outside a defn
# body operators are plain `Kernel.*` — exact on numbers, already-crashing on a
# tensor — so no working defn code is silently changed.
@impl true
def unsafe_in_dsl, do: [:ash_expr, :ecto_query]
alias Credence.Issue
@impl true
def check(ast, _opts) do
if overrides_arith_operators?(ast) do
[]
else
defn_ops = defn_operator_positions(ast)
{_ast, issues} =
Macro.prewalk(ast, [], fn
{op, meta, [left, right]} = node, acc
when op in [:*, :/, :+, :-] ->
cond do
MapSet.member?(defn_ops, position(meta)) ->
{node, acc}
# operand OP identity (right-hand identity)
identity_right?(op, unwrap_float(right)) ->
{node, [build_issue(meta) | acc]}
# identity OP operand (left-hand identity, commutative ops only)
op in [:*, :+] and identity_left?(op, unwrap_float(left)) ->
{node, [build_issue(meta) | acc]}
true ->
{node, acc}
end
node, acc ->
{node, acc}
end)
Enum.reverse(issues)
end
end
@impl true
def fix_patches(ast, _opts) do
if overrides_arith_operators?(ast) do
[]
else
defn_ops = defn_operator_positions(ast)
Credence.RuleHelpers.patches_from_postwalk(ast, &maybe_to_erlang_float(&1, defn_ops))
end
end
# `* 1.0` / `/ 1.0` are float coercion ONLY when `*`//` are the Kernel
# operators on numbers. They are not when:
#
# - the module overrides arithmetic operators (`use Image.Math`,
# `import Kernel, except: [*: 2]`) — every `*` is then a struct/image
# operation, and `:erlang.float/1` on that struct crashes; OR
# - the operand is an Nx tensor inside a `defn`/`defnp` body — `:erlang.float/1`
# is a raw BIF outside the defn-allowed function set.
#
# The first is module-wide (skip the whole file); the second is per operator
# node (regular `def`s in the same module are still fair game).
defp overrides_arith_operators?(ast) do
{_ast, found} =
Macro.prewalk(ast, false, fn
_node, true ->
{nil, true}
{:use, _, [{:__aliases__, _, parts} | _]} = node, _
when is_list(parts) and parts != [] ->
{node, List.last(parts) == :Math}
{:import, _, [{:__aliases__, _, [:Kernel]} | rest]} = node, _ ->
{node, mentions_arith_op?(rest)}
node, acc ->
{node, acc}
end)
found
end
defp mentions_arith_op?(ast) do
{_ast, found} =
Macro.prewalk(ast, false, fn
_node, true -> {nil, true}
op, _ when op in [:*, :/, :+, :-] -> {op, true}
node, acc -> {node, acc}
end)
found
end
defp defn_operator_positions(ast) do
{_ast, set} =
Macro.prewalk(ast, MapSet.new(), fn
{defn, _, _} = node, acc when defn in [:defn, :defnp] ->
{_n, inner} =
Macro.prewalk(node, acc, fn
{op, meta, [_l, _r]} = n, a when op in [:*, :/, :+, :-] ->
{n, MapSet.put(a, position(meta))}
n, a ->
{n, a}
end)
{node, inner}
node, acc ->
{node, acc}
end)
set
end
defp position(meta), do: {Keyword.get(meta, :line), Keyword.get(meta, :column)}
# Replace identity-coercion arithmetic with `:erlang.float(operand)`, where the
# operand is whichever side is not the `1.0` / `0.0` identity literal.
defp maybe_to_erlang_float({op, meta, [left, right]} = node, defn_ops)
when op in [:*, :/, :+, :-] do
cond do
MapSet.member?(defn_ops, position(meta)) ->
node
identity_right?(op, unwrap_float(right)) ->
erlang_float(unwrap_block(left))
op in [:*, :+] and identity_left?(op, unwrap_float(left)) ->
erlang_float(unwrap_block(right))
true ->
node
end
end
defp maybe_to_erlang_float(node, _defn_ops), do: node
# Strip a single Sourceror `{:__block__, _, [inner]}` wrapper (around a bare
# variable or literal); leave compound expression nodes untouched.
defp unwrap_block({:__block__, _, [inner]}), do: inner
defp unwrap_block(node), do: node
defp erlang_float(operand_node) do
{{:., [], [:erlang, :float]}, [], [operand_node]}
end
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(_), do: nil
defp build_issue(meta) do
%Issue{
rule: :prefer_erlang_float,
message:
"Use `:erlang.float(x)` for number → float coercion instead of " <>
"arithmetic identity tricks (`* 1.0`, `/ 1.0`, `+ 0.0`, `- 0.0`).",
meta: %{line: Keyword.get(meta, :line)}
}
end
end