Current section

Files

Jump to
credence lib semantic prefer_explicit_range_step.ex
Raw

lib/semantic/prefer_explicit_range_step.ex

defmodule Credence.Semantic.PreferExplicitRangeStep do
@moduledoc """
Makes the implicit step of a descending literal range explicit.
Since Elixir 1.19 a fully-literal range whose default step is `-1`
(`first > last`, e.g. `1..-2`, `10..-5`, `5..1`) emits a deprecation warning:
1..-2 has a default step of -1, please write 1..-2//-1 instead
Under `--warnings-as-errors` this blocks compilation. The fix appends `//-1`,
which is a **no-op on behaviour**: the warning states that `-1` is already the
current default step, so `1..-2` and `1..-2//-1` are the identical `Range`
value.
## Why the fix is AST-based, not text-based
The diagnostic carries **no usable position**`Code.with_diagnostics`
reports the range-step deprecation at position `0`, with no line or column —
and the message **normalizes whitespace** (a source range `1 .. -2` is
reported as `1..-2`). A text-replacement fix keyed on the message string would
therefore (a) miss spaced ranges entirely and (b) blindly rewrite *every*
occurrence of that text — including the same digits sitting inside a string
literal, atom, or comment — silently changing a value. That is a behaviour
change, not a no-op.
Instead the fix parses the source and rewrites only genuine range *operator*
nodes (`{:.., _, [lo, hi]}`) whose two endpoints are integer literals (or the
unary minus of one) with `lo > hi` — exactly the inputs the compiler warns on,
and the only inputs for which the default step is `-1`. Strings, charlists,
atoms and comments carry no `..` operator node, so they are never touched. A
range with a non-literal endpoint (`a..-1`) produces no diagnostic and matches
no node, so it is left alone.
"""
use Credence.Semantic.Rule
alias Credence.Issue
@impl true
def match?(%{severity: :warning, message: msg}) when is_binary(msg) do
String.contains?(msg, "has a default step of") and
String.contains?(msg, "please write")
end
def match?(_), do: false
@impl true
def to_issue(diagnostic) do
%Issue{
rule: :prefer_explicit_range_step,
message: diagnostic.message,
meta: %{line: line(diagnostic)}
}
end
@impl true
def fix(source, %{message: msg}) when is_binary(msg) do
if match?(%{severity: :warning, message: msg}) do
patch_descending_ranges(source)
else
source
end
end
def fix(source, _diagnostic), do: source
# Parse the source and append an explicit `//-1` step to every descending
# literal range operator node. Patching is range-targeted (Sourceror replaces
# only the bytes of each `..` node), so the rest of the source — including any
# string/atom/comment that merely contains the same digits — is byte-for-byte
# preserved. Unparseable source is left untouched.
defp patch_descending_ranges(source) do
case Sourceror.parse_string(source) do
{:ok, ast} ->
case collect_ranges(ast) do
[] -> source
ranges -> Sourceror.patch_string(source, Enum.map(ranges, &patch/1))
end
_ ->
source
end
end
defp collect_ranges(ast) do
{_ast, ranges} =
Macro.prewalk(ast, [], fn
{:.., _meta, [lo, hi]} = node, acc ->
case {literal_int(lo), literal_int(hi)} do
{{:ok, a}, {:ok, b}} when a > b -> {node, [node | acc]}
_ -> {node, acc}
end
node, acc ->
{node, acc}
end)
ranges
end
defp patch(node) do
%{range: Sourceror.get_range(node), change: &(&1 <> "//-1")}
end
# The endpoints the compiler can fold to a default step of -1: an integer
# literal, or the unary minus of one. Anything else (a variable, a call, an
# attribute) yields no compile-time step and so no deprecation — skip it.
defp literal_int({:__block__, _, [n]}) when is_integer(n), do: {:ok, n}
defp literal_int(n) when is_integer(n), do: {:ok, n}
defp literal_int({:-, _, [inner]}) do
case literal_int(inner) do
{:ok, n} -> {:ok, -n}
:error -> :error
end
end
defp literal_int(_), do: :error
defp line(%{position: {line, _col}}), do: line
defp line(%{position: line}) when is_integer(line), do: line
end