Current section

Files

Jump to
credence lib syntax prefer_cond_do_keyword.ex
Raw

lib/syntax/prefer_cond_do_keyword.ex

defmodule Credence.Syntax.PreferCondDoKeyword do
@moduledoc """
Repairs `cond ->` written in place of `cond do`.
LLMs occasionally emit `cond ->` — mixing Rust/OCaml match-arm syntax into
Elixir — where Elixir requires `cond do`. The bare `cond ->` never parses, so
this is a REPAIR: replace the offending `cond ->` with `cond do`, opening the
block the trailing `end` was already waiting to close. `cond do … end` is the
only valid form, so the rewrite is behaviour-neutral.
Detection is driven by the parser itself. The rule replaces a **single**
`cond ->` occurrence and commits the result only when `Code.string_to_quoted/1`
then succeeds. A `cond ->` sitting inside a string, heredoc, or comment is
invisible to the parser, so replacing it never resolves a parse error and is
never the committed occurrence — it is left untouched even when the file is
unparseable for an unrelated reason.
## Bad (won't parse)
cond ->
list == [] -> nil
true -> Enum.min(list)
end
## Good
cond do
list == [] -> nil
true -> Enum.min(list)
end
"""
use Credence.Syntax.Rule
alias Credence.Issue
# `cond` (word-boundary) then whitespace then `->` — the broken arm-syntax shape.
@pattern ~r/\bcond\s+->/
@impl true
def analyze(source) do
case repair(source) do
{:fixed, _fixed, line} ->
[
%Issue{
rule: :prefer_cond_do_keyword,
message: "`cond ->` should be `cond do`.",
meta: %{line: line}
}
]
:no_fix ->
[]
end
end
@impl true
def fix(source) do
case repair(source) do
{:fixed, fixed, _line} -> fixed
:no_fix -> source
end
end
# Replace exactly ONE `cond ->` — the first whose lone replacement makes the
# whole source parse — and commit only then. A `cond ->` that is string,
# heredoc, or comment content is invisible to the parser, so replacing it never
# resolves the parse error and is never the committed occurrence. analyze and
# fix share this helper, so they always agree.
defp repair(source) do
@pattern
|> Regex.scan(source, return: :index)
|> Enum.find_value(:no_fix, fn [{start, len}] ->
candidate = splice(source, start, len)
if candidate != source and parses?(candidate) do
{:fixed, candidate, line_at(source, start)}
else
false
end
end)
end
defp parses?(source), do: match?({:ok, _}, Code.string_to_quoted(source))
# `Regex.scan/3` :index offsets are byte-based; splice on bytes so any
# surrounding multibyte content is preserved exactly.
defp splice(source, start, len) do
before = binary_part(source, 0, start)
after_ = binary_part(source, start + len, byte_size(source) - start - len)
before <> "cond do" <> after_
end
defp line_at(source, byte_offset) do
source
|> binary_part(0, byte_offset)
|> String.split("\n")
|> length()
end
end