Current section
Files
Jump to
Current section
Files
lib/semantic/outdented_heredoc.ex
defmodule Credence.Semantic.OutdentedHeredoc do
@moduledoc """
Fixes compiler warnings about outdented heredoc lines by re-indenting
all body content to match the closing delimiter.
LLMs often generate heredoc documentation where the body content is not
indented to match the closing `\"\"\"`, causing a compiler warning that
becomes a hard error under `--warnings-as-errors`.
When any line in a heredoc is flagged, the fixer re-indents every
outdented body line in that heredoc — not just the flagged one.
## Example
# Warning: outdented heredoc line
@doc \"\"\"
Content not indented enough.
More content.
\"\"\"
# Fixed:
@doc \"\"\"
Content not indented enough.
More content.
\"\"\"
"""
use Credence.Semantic.Rule
alias Credence.Issue
@impl true
def match?(%{severity: :warning, message: msg}) do
String.contains?(msg, "outdented heredoc line")
end
def match?(_), do: false
@impl true
def to_issue(%{message: msg, position: position}) do
%Issue{
rule: :outdented_heredoc,
message: msg,
meta: %{line: extract_line(position)}
}
end
@impl true
def fix(source, %{position: position}) do
line_no = extract_line(position)
if line_no do
fix_heredoc(source, line_no - 1)
else
source
end
end
defp extract_line({line, _col}) when is_integer(line), do: line
defp extract_line(line) when is_integer(line), do: line
defp extract_line(_), do: nil
# Given a 0-based index of an outdented line, find the full heredoc
# it belongs to and re-indent every body line.
defp fix_heredoc(source, flagged_idx) do
lines = String.split(source, "\n")
with {:ok, opening_idx} <- find_opening(lines, flagged_idx),
{:ok, closing_idx, closing_indent} <- find_closing(lines, flagged_idx) do
lines
|> Enum.with_index()
|> Enum.map(fn {line, idx} ->
if idx > opening_idx and idx < closing_idx do
reindent_line(line, closing_indent)
else
line
end
end)
|> Enum.join("\n")
else
_ -> source
end
end
defp reindent_line(line, target_indent) do
if String.trim(line) == "" do
line
else
current = leading_spaces(line)
if current < target_indent do
String.duplicate(" ", target_indent - current) <> line
else
line
end
end
end
# Scan backward to find the opening line containing """ or '''
defp find_opening(lines, from_idx) do
result =
(from_idx - 1)..0//-1
|> Enum.find(fn idx ->
line = Enum.at(lines, idx)
String.contains?(line, ~s(""")) or String.contains?(line, ~s('''))
end)
case result do
nil -> :error
idx -> {:ok, idx}
end
end
# Scan forward to find the closing """ or ''' (must be alone on its line)
defp find_closing(lines, from_idx) do
result =
(from_idx + 1)..(length(lines) - 1)//1
|> Enum.find_value(fn idx ->
line = Enum.at(lines, idx)
trimmed = String.trim(line)
if trimmed == ~s(""") or trimmed == ~s(''') do
{idx, leading_spaces(line)}
end
end)
case result do
nil -> :error
{idx, indent} -> {:ok, idx, indent}
end
end
defp leading_spaces(line) do
byte_size(line) - byte_size(String.trim_leading(line))
end
end