Current section
Files
Jump to
Current section
Files
lib/pattern/no_trailing_newline_in_doc.ex
defmodule Credence.Pattern.NoTrailingNewlineInDoc do
@moduledoc """
Detects `@doc`, `@moduledoc`, and `@typedoc` strings that contain a
trailing `\\n` escape sequence.
LLMs frequently generate documentation strings with a trailing `\\n`
because Python docstrings use trailing newlines. In Elixir, the closing
`"` or `\"\"\"` handles line termination — the `\\n` produces an
unnecessary blank line in the rendered documentation.
## Bad
@doc "Finds the missing number in a list.\\n"
@moduledoc "A module for palindrome checking.\\n"
## Good
@doc "Finds the missing number in a list."
@moduledoc "A module for palindrome checking."
## Auto-fix
Strips trailing `\\n` from single-line doc strings (strings where the
only newlines are trailing). Heredoc-style docs (`\"\"\"`) are not flagged
or modified — their trailing `\\n` is structural and expected.
"""
use Credence.Pattern.Rule
alias Credence.Issue
@doc_attrs [:doc, :moduledoc, :typedoc]
@impl true
def fixable?, do: true
# ── Check (Code.string_to_quoted AST — escapes resolved) ───────
@impl true
def check(ast, opts) do
source_lines =
case Keyword.get(opts, :source) do
nil -> nil
source -> String.split(source, "\n")
end
{_ast, issues} =
Macro.prewalk(ast, [], fn
{:@, meta, [{attr, _, [value]}]} = node, acc
when attr in @doc_attrs and is_binary(value) ->
if trailing_newline_only?(value) and not already_heredoc?(source_lines, meta) do
{node, [build_issue(meta, attr) | acc]}
else
{node, acc}
end
node, acc ->
{node, acc}
end)
Enum.reverse(issues)
end
# Heredoc trailing \n is structural — not a real issue.
# When source is available, check the actual source line for """.
defp already_heredoc?(nil, _meta), do: false
defp already_heredoc?(source_lines, meta) do
line = Keyword.get(meta, :line)
case Enum.at(source_lines, line - 1) do
nil -> false
source_line -> String.contains?(source_line, ~s("""))
end
end
# ── Fix (Sourceror AST — escapes may be raw OR resolved) ───────
@impl true
def fix(source, _opts) do
ast = Sourceror.parse_string!(source)
if has_fixable_doc?(ast) do
fixed_ast = Macro.postwalk(ast, &fix_node/1)
result = Sourceror.to_string(fixed_ast)
if String.ends_with?(source, "\n") and not String.ends_with?(result, "\n") do
result <> "\n"
else
result
end
else
source
end
end
defp fix_node({:@, meta, [{attr, attr_meta, [{:__block__, str_meta, [value]}]}]} = node)
when attr in @doc_attrs and is_binary(value) do
# Heredoc trailing \n is structural — don't strip it
if Keyword.get(str_meta, :delimiter) == ~s(""") do
node
else
case strip_trailing_doc_newline(value) do
{:ok, cleaned} ->
{:@, meta, [{attr, attr_meta, [{:__block__, str_meta, [cleaned]}]}]}
:skip ->
node
end
end
end
defp fix_node(node), do: node
# ── Pre-check ───────────────────────────────────────────────────
defp has_fixable_doc?(ast) do
{_ast, found} =
Macro.prewalk(ast, false, fn
{:@, _, [{attr, _, [{:__block__, str_meta, [value]}]}]} = node, acc
when attr in @doc_attrs and is_binary(value) ->
already_heredoc = Keyword.get(str_meta, :delimiter) == ~s(""")
{node, acc or (not already_heredoc and fixable_value?(value))}
node, acc ->
{node, acc}
end)
found
end
# ── Value analysis and stripping ────────────────────────────────
#
# Sourceror preserves raw escape sequences in string values when
# parsing fresh source: "text\n" → value is "text\\n" (backslash + n).
#
# But when an earlier rule's Sourceror.to_string() has already run,
# it may unescaped \n into a real newline, so re-parsing produces
# a value with an actual newline character.
#
# We handle both forms.
defp fixable_value?(value) do
raw_trailing_only?(value) or real_trailing_only?(value)
end
defp strip_trailing_doc_newline(value) do
cond do
raw_trailing_only?(value) ->
{:ok, String.trim_trailing(value, "\\n")}
real_trailing_only?(value) ->
{:ok, String.trim_trailing(value, "\n")}
true ->
:skip
end
end
# Raw form: value ends with literal backslash + n (Sourceror on fresh source)
defp raw_trailing_only?(value) do
String.ends_with?(value, "\\n") and
not String.contains?(String.trim_trailing(value, "\\n"), "\\n")
end
# Resolved form: value ends with newline character (Sourceror on reformatted source)
defp real_trailing_only?(value) do
String.ends_with?(value, "\n") and
not String.contains?(String.trim_trailing(value, "\n"), "\n")
end
# For check (Code.string_to_quoted — always resolved)
defp trailing_newline_only?(value), do: real_trailing_only?(value)
defp build_issue(meta, attr) do
%Issue{
rule: :no_trailing_newline_in_doc,
message: """
`@#{attr}` string has a trailing `\\n` that produces an unnecessary \
newline at the end of the documentation. Remove it.
""",
meta: %{line: Keyword.get(meta, :line)}
}
end
end