defmodule Credence.Syntax.CloseUnclosedDocHeredoc do @moduledoc """ Detects and fixes unclosed `@doc` heredocs where the LLM jumps straight into function code without ever closing the triple-quote block. LLMs frequently emit `@doc` followed by triple-quotes on their own line and then immediately write a `def` on the next non-blank line without a closing set of triple-quotes in between. This causes a `TokenMissingError` (missing terminator). The fix inserts a closing triple-quote line — indented to match the `@doc` line — immediately before the `def` line. ## Bad (won't parse — TokenMissingError) defmodule Solution do @doc \"\"\" def find_min_max(list) do Enum.min_max(list) end end ## Good defmodule Solution do @doc \"\"\" \"\"\" def find_min_max(list) do Enum.min_max(list) end end """ use Credence.Syntax.Rule alias Credence.Issue @doc_heredoc_open ~r/^(\s*)@doc\s+"""\s*$/ @impl true def analyze(source) do lines = String.split(source, "\n") lines |> Enum.with_index(1) |> Enum.flat_map(fn {line, line_no} -> if unclosed_doc_heredoc?(line, line_no, lines) do [ %Issue{ rule: :close_unclosed_doc_heredoc, message: "Unclosed `@doc \"\"\"` heredoc — the closing `\"\"\"` is missing before the next `def`.", meta: %{line: line_no} } ] else [] end end) end @impl true def fix(source) do lines = String.split(source, "\n") # Collect {insert_before_index, indent} for every unclosed @doc """ insertions = lines |> Enum.with_index() |> Enum.flat_map(fn {line, idx} -> case Regex.run(@doc_heredoc_open, line) do [_, indent] -> remaining = Enum.drop(lines, idx + 1) if next_nonblank_is_def?(remaining) and not closed_before_def?(remaining) do offset = find_next_nonblank_offset(remaining) [{idx + 1 + offset, indent}] else [] end _ -> [] end end) # Apply insertions (sorted descending so indices stay valid) sorted = Enum.sort_by(insertions, &elem(&1, 0), :desc) Enum.reduce(sorted, lines, fn {insert_at, indent}, acc -> {before, after_} = Enum.split(acc, insert_at) before ++ [indent <> ~s(""")] ++ after_ end) |> Enum.join("\n") end defp unclosed_doc_heredoc?(line, line_no, lines) do case Regex.run(@doc_heredoc_open, line) do [_, _indent] -> remaining = Enum.drop(lines, line_no) next_nonblank_is_def?(remaining) and not closed_before_def?(remaining) _ -> false end end defp next_nonblank_is_def?(lines) do Enum.find_value(lines, false, fn next_line -> cond do String.trim(next_line) == "" -> nil Regex.match?(~r/^\s*def/, next_line) -> true true -> false end end) end defp closed_before_def?(lines) do Enum.find_value(lines, false, fn next_line -> cond do String.trim(next_line) == "" -> nil Regex.match?(~r/^\s*"""/, next_line) -> true Regex.match?(~r/^\s*def/, next_line) -> false true -> false end end) end defp find_next_nonblank_offset(lines) do Enum.find_index(lines, fn line -> String.trim(line) != "" end) || length(lines) end end