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 alias Credence.RuleHelpers @doc_attrs [:doc, :moduledoc, :typedoc] @impl true def priority, do: 501 # Sourceror records the string delimiter (`"""` vs `"`) in the # `:__block__` metadata's `:delimiter` key. The check uses that to # tell heredocs apart from escape-string docs purely from the AST. @impl true def check(ast, _opts) do {_ast, issues} = Macro.prewalk(ast, [], fn # Sourceror shape: string wrapped in :__block__ with :delimiter meta. {:@, meta, [{attr, _, [{:__block__, str_meta, [value]}]}]} = node, acc when attr in @doc_attrs and is_binary(value) -> already_heredoc = Keyword.get(str_meta, :delimiter) == ~s(""") if not already_heredoc and (raw_multi_line?(value) or real_multi_line?(value)) do {node, [build_issue(meta, attr) | acc]} else {node, acc} end node, acc -> {node, acc} end) Enum.reverse(issues) end @impl true def fix_patches(ast, _opts) do # Sourceror's AST preserves both shapes of multi-line doc strings: # `@doc "a\\nb"` keeps the literal `\\n` in the string value, while # `@doc """\na\nb\n"""` carries a `:delimiter` of `~s(""")` in the # block metadata. `fix_doc_node/1` handles both via `raw_multi_line?` # and `real_multi_line?`. The `:delimiter` check skips already-heredoc # strings — re-processing one through `Sourceror.to_string` corrupts # indentation. RuleHelpers.patches_from_postwalk(ast, &fix_doc_node/1) 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) -> # Sourceror needs the value to end with `\n` so the closing # `"""` renders on its own line — without it the closing # delimiter ends up glued to the last content line. content = unescape_value(value) |> ensure_trailing_newline() new_str_meta = Keyword.put(str_meta, :delimiter, ~s(""")) {:@, meta, [{attr, attr_meta, [{:__block__, new_str_meta, [content]}]}]} real_multi_line?(value) -> content = ensure_trailing_newline(value) 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 ensure_trailing_newline(s) do if String.ends_with?(s, "\n"), do: s, else: s <> "\n" 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 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