Current section
Files
Jump to
Current section
Files
lib/syntax/no_else_if.ex
defmodule Credence.Syntax.NoElseIf do
@moduledoc """
Detects and rewrites Python-style `else if` (two words) chains to idiomatic `cond`.
LLMs translating from Python emit `else if` (two words) which in Elixir parses
as `else` + nested `if` needing its own `end`, causing persistent
`TokenMissingError`. Converting `else if` chains to `cond` is
behaviour-preserving and idiomatic.
## Bad (won't parse as intended)
if n == 0 do
list
else if n >= length(list) do
[]
else
List.take(list, length(list) - n)
end
## Good
cond do
n == 0 -> list
n >= length(list) -> []
true -> List.take(list, length(list) - n)
end
"""
use Credence.Syntax.Rule
alias Credence.Issue
@else_if_re ~r/^\s*else\s+if\b/
@impl true
def analyze(source) do
source
|> String.split("\n")
|> Enum.with_index(1)
|> Enum.flat_map(fn {line, line_no} ->
if Regex.match?(@else_if_re, line) do
[
%Issue{
rule: :no_else_if,
message: "Use `cond` instead of `else if`",
meta: %{line: line_no}
}
]
else
[]
end
end)
end
@impl true
def fix(source) do
lines = String.split(source, "\n")
case find_else_if(lines) do
{:ok, idx} ->
rewrite_block(lines, idx) |> Enum.join("\n")
:not_found ->
source
end
end
defp find_else_if(lines) do
Enum.find_value(Enum.with_index(lines), :not_found, fn {line, idx} ->
if Regex.match?(@else_if_re, line), do: {:ok, idx}, else: nil
end)
end
defp rewrite_block(lines, else_if_idx) do
else_if_indent = get_indent(Enum.at(lines, else_if_idx))
with {:ok, if_idx} <- scan_back_for_if(lines, else_if_idx - 1, else_if_indent),
{:ok, branches, end_idx, else_branches} <-
collect_branches(lines, if_idx, else_if_idx, else_if_indent) do
if_start_indent = get_indent(Enum.at(lines, if_idx))
cond_lines = build_cond(branches, else_branches, if_start_indent)
Enum.take(lines, if_idx) ++ cond_lines ++ Enum.drop(lines, end_idx + 1)
else
# `:not_found` (no matching `if`) or `:bail` (a branch condition could not
# be extracted cleanly — e.g. a multi-line condition or a trailing comment
# after `do`). Emitting a guessed `true` clause there would silently change
# behaviour, so we leave the source untouched and let it stay flagged.
_ -> lines
end
end
defp scan_back_for_if(lines, idx, target_indent) when idx >= 0 do
line = Enum.at(lines, idx)
cond do
if_line?(line) and get_indent(line) == target_indent ->
{:ok, idx}
get_indent(line) < target_indent ->
:not_found
true ->
scan_back_for_if(lines, idx - 1, target_indent)
end
end
defp scan_back_for_if(_, _, _), do: :not_found
defp collect_branches(lines, if_idx, else_if_idx, else_if_indent) do
if_line = Enum.at(lines, if_idx)
if_body = Enum.slice(lines, (if_idx + 1)..(else_if_idx - 1))
with {:ok, if_cond} <- extract_condition(if_line),
{:ok, branches, rest_idx} <-
collect_else_if_branches(lines, else_if_idx, else_if_indent, [{if_cond, if_body}]) do
case find_else_at_indent(lines, rest_idx, else_if_indent) do
{:ok, else_idx, end_idx} ->
else_body = Enum.slice(lines, (else_idx + 1)..(end_idx - 1))
{:ok, branches, end_idx, [{"true", else_body}]}
{:not_found, end_idx} ->
# No trailing `else`: a nested `if` without an `else` evaluates to `nil`
# when no branch matches, but `cond` raises `CondClauseError`. Add an
# explicit `true -> nil` clause so the rewrite keeps the `nil` answer.
{:ok, branches, end_idx, [{"true", ["nil"]}]}
end
end
end
defp collect_else_if_branches(lines, idx, target_indent, acc) do
line = Enum.at(lines, idx)
if Regex.match?(@else_if_re, line) and get_indent(line) == target_indent do
case extract_else_if_condition(line) do
{:ok, cond} ->
next_idx = find_next_else(lines, idx + 1, target_indent)
body = Enum.slice(lines, (idx + 1)..(next_idx - 1))
collect_else_if_branches(lines, next_idx, target_indent, acc ++ [{cond, body}])
:error ->
:bail
end
else
{:ok, acc, idx}
end
end
defp find_next_else(lines, idx, target_indent) do
Enum.find_value(idx..(length(lines) - 1), length(lines) - 1, fn i ->
line = Enum.at(lines, i)
if line != nil and get_indent(line) == target_indent and
(Regex.match?(~r/^\s*else\b/, line) or if_end?(line)) do
i
end
end)
end
defp find_else_at_indent(lines, idx, target_indent) do
line = Enum.at(lines, idx)
if line != nil and get_indent(line) == target_indent do
if Regex.match?(~r/^\s*else\s*$/, String.trim_trailing(line)) do
end_idx = find_end(lines, idx + 1, target_indent)
{:ok, idx, end_idx}
else
{:not_found, idx}
end
else
{:not_found, idx}
end
end
defp find_end(lines, idx, target_indent) do
Enum.find_value(idx..(length(lines) - 1), length(lines) - 1, fn i ->
line = Enum.at(lines, i)
if line != nil and get_indent(line) == target_indent and if_end?(line), do: i
end)
end
defp build_cond(branches, else_branches, base_indent) do
all_branches = branches ++ else_branches
body_indent = base_indent <> " "
branch_lines =
Enum.flat_map(all_branches, fn {cond_str, body_lines} ->
clean_body = Enum.reject(body_lines, &blank?/1)
{expr_lines, trailing_comments} = Enum.split_with(clean_body, &(not comment?(&1)))
case expr_lines do
[] ->
# No expression lines — only comments (or nothing). A `cond` clause
# still needs a value after `->`, so insert `nil` and keep comments
# below it; otherwise the rewrite produces a bodyless `true ->` that
# the validator rejects and reverts.
case trailing_comments do
[] ->
["#{body_indent}#{cond_str} ->"]
_ ->
nil_line = "#{body_indent}#{cond_str} -> nil"
comment_tail = Enum.map(trailing_comments, &"#{body_indent} #{String.trim(&1)}")
[nil_line | comment_tail]
end
_ ->
first = "#{body_indent}#{cond_str} -> #{String.trim(hd(expr_lines))}"
rest = Enum.map(tl(expr_lines), &"#{body_indent} #{String.trim(&1)}")
comment_tail = Enum.map(trailing_comments, &"#{body_indent} #{String.trim(&1)}")
[first | rest] ++ comment_tail
end
end)
["#{base_indent}cond do"] ++ branch_lines ++ ["#{base_indent}end"]
end
defp get_indent(line) do
case Regex.run(~r/^(\s*)/, line) do
[_, indent] -> indent
_ -> ""
end
end
defp if_line?(line), do: Regex.match?(~r/^\s*if\b/, line)
defp if_end?(line), do: Regex.match?(~r/^\s*end\s*$/, line)
defp extract_condition(line) do
case Regex.run(~r/^\s*if\s+(.+?)\s+do\s*$/, line) do
[_, cond] -> {:ok, cond}
_ -> :error
end
end
defp extract_else_if_condition(line) do
case Regex.run(~r/^\s*else\s+if\s+(.+?)\s+do\s*$/, line) do
[_, cond] -> {:ok, cond}
_ -> :error
end
end
defp blank?(line), do: String.trim(line) == ""
defp comment?(line), do: Regex.match?(~r/^\s*#/, line)
end