Current section

Files

Jump to
credence lib pattern prefer_heredoc_for_multi_line_doc.ex
Raw

lib/pattern/prefer_heredoc_for_multi_line_doc.ex

defmodule Credence.Pattern.PreferHeredocForMultiLineDoc do
@moduledoc """
Detects `@doc`, `@moduledoc`, and `@typedoc` strings that contain
escaped newlines (`\\n`) and should use heredoc syntax instead.
LLMs generate documentation as single-line strings with `\\n` escapes
because that's how Python docstrings work. In Elixir, multi-line
documentation should use the heredoc (`\"\"\"`) syntax for readability.
## Bad
@doc "Finds the second largest number in a list.\\nThe list must have at least two distinct values.\\n"
## Good
@doc \"\"\"
Finds the second largest number in a list.
The list must have at least two distinct values.
\"\"\"
## Auto-fix
Converts single-line `@doc`/`@moduledoc`/`@typedoc` strings containing
`\\n` escapes into heredoc format. The fixer preserves indentation and
strips unnecessary trailing `\\n` (since heredocs naturally end with a
newline). Strings containing `\\\"\\\"\\\"` are left unchanged.
"""
use Credence.Pattern.Rule
alias Credence.Issue
@doc_attrs [:doc, :moduledoc, :typedoc]
@impl true
def fixable?, do: true
@impl true
def priority, do: 501
# ── Check ───────────────────────────────────────────────────────
#
# Code.string_to_quoted doesn't preserve delimiter info, so a heredoc
# and a single-line string with \n produce the same AST value.
# When :source is available in opts (e.g. during Credence.fix re-check),
# we look at the actual source line to skip already-converted heredocs.
@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 multi_line_in_single_string?(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
# Check if the source line at this position already uses """
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
@impl true
def fix(source, _opts) do
# Try line-level fix first (works when \n is still escaped in source)
line_fixed = fix_by_lines(source)
if line_fixed != source do
line_fixed
else
# AST-based fallback (works after Sourceror has unescaped \n)
fix_by_ast(source)
end
end
# The string value (in AST) has at least one internal \n, meaning it
# contains multi-line content.
defp multi_line_in_single_string?(value) do
trimmed = String.trim_trailing(value, "\n")
String.contains?(trimmed, "\n")
end
# ══════════════════════════════════════════════════════════════════
# Path A: Line-level fix (works on fresh source with \n escapes)
# ══════════════════════════════════════════════════════════════════
defp fix_by_lines(source) do
source
|> String.split("\n")
|> fix_lines([])
|> Enum.reverse()
|> Enum.join("\n")
end
defp fix_lines([], acc), do: acc
defp fix_lines([line | rest], acc) do
case try_extract_doc_string(line) do
{:ok, attr, indent, content_raw} ->
if safe_to_convert?(content_raw) do
heredoc_lines = build_heredoc(attr, indent, content_raw)
fix_lines(rest, Enum.reverse(heredoc_lines) ++ acc)
else
fix_lines(rest, [line | acc])
end
:skip ->
fix_lines(rest, [line | acc])
end
end
defp try_extract_doc_string(line) do
case Regex.run(
~r/^(\s*)@(doc|moduledoc|typedoc)\s+"(.*)"(\s*)$/,
line
) do
[_full, indent, attr, content, _trailing] ->
if has_internal_escaped_newlines?(content) do
{:ok, attr, indent, content}
else
:skip
end
_ ->
:skip
end
end
defp has_internal_escaped_newlines?(content) do
stripped = Regex.replace(~r/(\\n)+$/, content, "")
String.contains?(stripped, "\\n")
end
defp safe_to_convert?(content_raw) do
not String.contains?(content_raw, ~S("""))
end
defp build_heredoc(attr, indent, content_raw) do
content =
content_raw
|> String.replace("\\\\", "\x00BACKSLASH\x00")
|> String.replace("\\n", "\n")
|> String.replace("\\\"", "\"")
|> String.replace("\\t", "\t")
|> String.replace("\x00BACKSLASH\x00", "\\")
content = String.trim_trailing(content, "\n")
doc_lines =
content
|> String.split("\n")
|> Enum.map(fn doc_line ->
if doc_line == "", do: "", else: "#{indent}#{doc_line}"
end)
opening = "#{indent}@#{attr} \"\"\""
closing = "#{indent}\"\"\""
[opening | doc_lines] ++ [closing]
end
# ══════════════════════════════════════════════════════════════════
# Path B: AST-based fix (works after Sourceror has unescaped \n)
# ══════════════════════════════════════════════════════════════════
defp fix_by_ast(source) do
ast = Sourceror.parse_string!(source)
if has_fixable_multi_line_doc?(ast) do
fixed_ast = Macro.postwalk(ast, &fix_doc_node/1)
result = Sourceror.to_string(fixed_ast)
result = fix_heredoc_closings(result)
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_doc_node({:@, meta, [{attr, attr_meta, [{:__block__, str_meta, [value]}]}]} = node)
when attr in @doc_attrs and is_binary(value) do
# Already a heredoc — leave it alone. Sourceror records the delimiter
# in the string block's metadata; re-processing a heredoc through
# Sourceror.to_string corrupts indentation and destroys the file.
if Keyword.get(str_meta, :delimiter) == ~s(""") do
node
else
cond do
raw_multi_line?(value) ->
content = unescape_value(value)
content = String.trim_trailing(content, "\n")
new_str_meta = Keyword.put(str_meta, :delimiter, ~s("""))
{:@, meta, [{attr, attr_meta, [{:__block__, new_str_meta, [content]}]}]}
real_multi_line?(value) ->
content = String.trim_trailing(value, "\n")
new_str_meta = Keyword.put(str_meta, :delimiter, ~s("""))
{:@, meta, [{attr, attr_meta, [{:__block__, new_str_meta, [content]}]}]}
true ->
node
end
end
end
defp fix_doc_node(node), do: node
defp has_fixable_multi_line_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(""")
needs_fix = not already_heredoc and (raw_multi_line?(value) or real_multi_line?(value))
{node, acc or needs_fix}
node, acc ->
{node, acc}
end)
found
end
defp raw_multi_line?(value) do
trimmed = String.trim_trailing(value, "\\n")
String.contains?(trimmed, "\\n")
end
defp real_multi_line?(value) do
trimmed = String.trim_trailing(value, "\n")
String.contains?(trimmed, "\n")
end
defp unescape_value(value) do
value
|> String.replace("\\\\", "\x00BACKSLASH\x00")
|> String.replace("\\n", "\n")
|> String.replace("\\t", "\t")
|> String.replace("\\\"", "\"")
|> String.replace("\x00BACKSLASH\x00", "\\")
end
defp fix_heredoc_closings(source) do
source
|> String.split("\n")
|> Enum.flat_map(fn line ->
trimmed = String.trim_trailing(line)
if needs_heredoc_split?(trimmed) do
before = String.slice(trimmed, 0, String.length(trimmed) - 3)
indent = leading_whitespace(line)
[before, indent <> ~s(""")]
else
[line]
end
end)
|> Enum.join("\n")
end
defp needs_heredoc_split?(trimmed) do
String.ends_with?(trimmed, ~s(""")) and
String.length(trimmed) > 3 and
not Regex.match?(~r/^\s*@(doc|moduledoc|typedoc)\s+"""$/, trimmed) and
not Regex.match?(~r/^\s*"""$/, trimmed)
end
defp leading_whitespace(line) do
case Regex.run(~r/^(\s*)/, line) do
[_, ws] -> ws
_ -> ""
end
end
defp build_issue(meta, attr) do
%Issue{
rule: :prefer_heredoc_for_multi_line_doc,
message: """
`@#{attr}` contains multi-line content using `\\n` escape sequences. \
Use heredoc (`\"\"\"`) syntax instead for readability.
""",
meta: %{line: Keyword.get(meta, :line)}
}
end
end