defmodule Credence.Semantic.RequireDefmoduleWrapper do @moduledoc """ The canonical "module attributes / code outside a `defmodule`" rule. Matches the Elixir compiler diagnostic emitted when `@moduledoc`/`@doc`/ `@spec` (or a top-level `def`/`defp`) appear at file scope with no enclosing module (`cannot invoke @/1 outside module`, `... outside module`) and repairs it in one of two ways: * **No module at all** → wrap the whole source in `defmodule Solution do … end`. * **Doc/spec attributes orphaned ABOVE an existing `defmodule`** → MOVE that contiguous run of attributes inside the module (their original order, prepended to the body). Wrapping would be wrong here — it nests the existing module and mis-attaches the attributes. This is a SEMANTIC-round rule by design: the offending code PARSES but does not COMPILE, so it is diagnostic-driven (the syntax round is for code that won't parse; the pattern round is skipped for code that won't compile). It supersedes the redundant rules the tunex evolution runs generated for this one concern — `Syntax.NoOrphanedModuleAttributes`, `Syntax.WrapBareModuleAttrsInDefmodule`, `Syntax.PreferDefmoduleWrapper`, `Semantic.NoDocSpecOutsideModule`, `Semantic.PreferDefmoduleWrapper` — and the dead Pattern-round `NoAttrBeforeDefmodule` (whose fix never fired, since the input never compiles). Its move-into-module logic now lives here, where the round can actually run it. """ use Credence.Semantic.Rule alias Credence.{Issue, RuleHelpers} # Doc/spec attributes that are safe to relocate. Never `@impl`/`@behaviour`/ # `@derive` etc. — their placement carries ordering semantics. @movable [:moduledoc, :doc, :spec, :type, :typep] @impl true def match?(%{message: message}) when is_binary(message) do String.contains?(message, "outside module") or String.contains?(message, "redefining @") end def match?(_), do: false @impl true def to_issue(diagnostic) do %Issue{ rule: :require_defmodule_wrapper, message: "Top-level code must be wrapped in a defmodule", meta: %{line: line(diagnostic)} } end @impl true def fix(source, _diagnostic) do case parse(source) do {:ok, ast} -> case relocation(ast) do # Attrs orphaned above an existing module → move them inside it. {:ok, _attrs, _kept, _dm, _rest} -> patches = RuleHelpers.patches_from_ast_transform(ast, source, &move_attrs/1) Sourceror.patch_string(source, patches) :no -> wrap_or_decline(source) end :error -> wrap_or_decline(source) end end # No movable attrs to relocate: wrap bare code, but DECLINE if a `defmodule` # already exists (wrapping would nest it — leave the diagnostic for that case). defp wrap_or_decline(source) do if has_defmodule?(source) do source else "defmodule Solution do\n" <> String.trim_trailing(source) <> "\nend\n" end end defp parse(source) do {:ok, Sourceror.parse_string!(source)} rescue _ -> :error end defp has_defmodule?(source), do: Regex.match?(~r/^\s*defmodule\b/m, source) # ── Move orphaned attrs into the following module (ported from the retired # Pattern.NoAttrBeforeDefmodule; operates on the Sourceror tree) ────────── defp move_attrs(ast) do case relocation(ast) do {:ok, attrs, kept, dm, rest} -> {:__block__, block_meta(ast), kept ++ [prepend_body(dm, attrs) | rest]} :no -> ast end end defp block_meta({:__block__, meta, _}), do: meta defp block_meta(_), do: [] # Finds the contiguous run of movable attrs immediately preceding the first # top-level `defmodule`. Returns {:ok, attrs, kept_before, defmodule, rest}. defp relocation({:__block__, _, children}) when is_list(children) do case Enum.find_index(children, &defmodule?/1) do nil -> :no idx -> {before_dm, [dm | rest]} = Enum.split(children, idx) {kept, attrs} = split_trailing_attrs(before_dm) if attrs == [], do: :no, else: {:ok, attrs, kept, dm, rest} end end defp relocation(_), do: :no defp split_trailing_attrs(list) do {rev_attrs, rev_kept} = Enum.split_while(Enum.reverse(list), &movable_attr?/1) {Enum.reverse(rev_kept), Enum.reverse(rev_attrs)} end defp prepend_body({:defmodule, dm_meta, [alias_node, [{do_key, body}]]}, attrs) do existing_stmts = body_statements(body) # Drop incoming attrs that would duplicate an attribute already inside the # module (the inner one takes precedence). Exception: a real @moduledoc # being moved in should REPLACE a `@moduledoc false` — keep the incoming one # and strip the false one below. existing_attr_names = existing_attr_names(existing_stmts) real_moduledoc_incoming = has_real_moduledoc?(attrs) filtered_attrs = Enum.filter(attrs, fn attr -> name = attr_name(attr) cond do is_nil(name) -> true not MapSet.member?(existing_attr_names, name) -> true # @moduledoc false in body + real @moduledoc incoming → replace false with real name == :moduledoc and real_moduledoc_incoming and moduledoc_false_in?(existing_stmts) -> true true -> false end end) # If we kept a real @moduledoc from the incoming attrs, strip any inner # @moduledoc false to avoid a duplicate-attribute warning. cleaned = if real_moduledoc_incoming do Enum.reject(existing_stmts, &moduledoc_false?/1) else existing_stmts end new_body = {:__block__, [], filtered_attrs ++ cleaned} {:defmodule, dm_meta, [alias_node, [{do_key, new_body}]]} end # Unexpected defmodule shape — leave it untouched (no fix rather than a wrong one). defp prepend_body(other, _attrs), do: other defp body_statements({:__block__, _, stmts}) when is_list(stmts) and length(stmts) > 1, do: stmts defp body_statements(single), do: [single] defp movable_attr?({:@, _, [{name, _, _}]}) when name in @movable, do: true defp movable_attr?(_), do: false defp defmodule?({:defmodule, _, _}), do: true defp defmodule?(_), do: false # True when attrs list contains a @moduledoc with real content (not false). defp has_real_moduledoc?(attrs) do Enum.any?(attrs, fn {:@, _, [{:moduledoc, _, [{:__block__, _, [val]}]}]} when is_binary(val) -> true {:@, _, [{:moduledoc, _, [val]}]} when is_binary(val) -> true _ -> false end) end # True for `@moduledoc false`. defp moduledoc_false?({:@, _, [{:moduledoc, _, [{:__block__, _, [false]}]}]}), do: true defp moduledoc_false?(_), do: false # Returns a MapSet of movable attribute names already present in the body. defp existing_attr_names(stmts) do stmts |> Enum.map(&attr_name/1) |> Enum.reject(&is_nil/1) |> MapSet.new() end # Extracts the attribute name from a `@name ...` AST node, or nil. defp attr_name({:@, _, [{name, _, _}]}) when name in @movable, do: name defp attr_name(_), do: nil # True when any statement in `stmts` is `@moduledoc false`. defp moduledoc_false_in?(stmts), do: Enum.any?(stmts, &moduledoc_false?/1) defp line(%{position: {line, _col}}), do: line defp line(%{position: line}) when is_integer(line), do: line end