defmodule Credence.Semantic do @moduledoc """ Semantic phase — fixes compiler warnings and errors. Uses `Code.with_diagnostics/1` to compile the source and capture diagnostics without permanently loading modules. Delegates to rules implementing `Credence.Semantic.Rule` behaviour. When compilation succeeds, warning-level diagnostics are matched against rules and fixed. When compilation fails, error-level diagnostics are matched first; if any fix is applied, the phase retries (up to `max_passes`) to catch warnings that only appear once the error is resolved. """ require Logger alias Credence.RuleHelpers @default_max_passes 3 @spec analyze(String.t(), keyword()) :: [Credence.Issue.t()] def analyze(source, _opts \\ []) do case RuleHelpers.compile_and_capture(source) do {:ok, diagnostics} -> diagnostics |> Enum.filter(&(&1.severity == :warning)) |> Enum.flat_map(&match_rules/1) {:error, diagnostics} -> diagnostics |> Enum.filter(&(&1.severity == :error)) |> Enum.flat_map(&match_rules/1) end end @spec fix(String.t(), keyword()) :: String.t() def fix(source, opts \\ []) do {code, _applied} = fix_with_trace(source, opts) code end @doc """ Like `fix/2`, but also returns a list of `{rule_module, issue_count}` tuples for every rule that actually fired and was applied. Every step is logged via `Logger.debug` with `[credence_fix]` prefix: pass number, severity being targeted, rule name, whether the source changed, and a before/after diff of the lines that were modified. """ @spec fix_with_trace(String.t(), keyword()) :: {String.t(), [{module(), non_neg_integer()}]} def fix_with_trace(source, opts \\ []) do max_passes = Keyword.get(opts, :max_passes, @default_max_passes) Logger.debug( "[credence_fix] starting semantic fix pipeline (max #{max_passes} passes, #{length(rules())} rules)" ) {code, applied} = do_fix_traced(source, max_passes, 1, []) summary = Enum.map_join(applied, ", ", fn {mod, count} -> "#{RuleHelpers.rule_name(mod)}(#{count})" end) Logger.debug("[credence_fix] semantic done. Applied: [#{summary}]") {code, applied} end defp do_fix_traced(source, max_passes, pass, applied) when pass > max_passes do Logger.debug("[credence_fix] semantic pass limit reached (#{max_passes}), stopping") {source, Enum.reverse(applied)} end defp do_fix_traced(source, max_passes, pass, applied) do case RuleHelpers.compile_and_capture(source) do {:ok, diagnostics} -> # Compilation succeeded — fix warnings (terminal pass, no retry needed) warnings = Enum.filter(diagnostics, &(&1.severity == :warning)) Logger.debug( "[credence_fix] semantic pass #{pass}: compilation OK, #{length(warnings)} warning(s)" ) {fixed, new_applied} = apply_fixes_traced(source, warnings) {fixed, Enum.reverse(new_applied ++ applied)} {:error, diagnostics} -> # Compilation failed — fix errors, then retry errors = Enum.filter(diagnostics, &(&1.severity == :error)) if errors == [] do Logger.debug( "[credence_fix] semantic pass #{pass}: compilation raised an exception " <> "(0 diagnostics captured — see Code.compile_string raised log above)" ) else Logger.debug( "[credence_fix] semantic pass #{pass}: compilation FAILED, #{length(errors)} error(s)" ) end {fixed, new_applied} = apply_fixes_traced(source, errors) if fixed != source do Logger.debug("[credence_fix] semantic pass #{pass}: source changed, retrying...") do_fix_traced(fixed, max_passes, pass + 1, new_applied ++ applied) else Logger.debug( "[credence_fix] semantic pass #{pass}: no rule could fix the error(s), stopping" ) {fixed, Enum.reverse(new_applied ++ applied)} end end end defp apply_fixes_traced(source, diagnostics) do # Apply rightmost (highest-column) diagnostics first so column-aware # rules don't see stale columns after an earlier fix mutates the # line. Insertions / underscoring before a binding shift everything # to the right of it; processing right-to-left keeps untouched # columns valid for the rest of the pass. diagnostics |> Enum.sort_by(&position_sort_key/1, :desc) |> Enum.reduce({source, []}, fn diagnostic, {src, applied} -> case find_matching_rule(diagnostic) do nil -> Logger.debug( "[credence_fix] no rule matched diagnostic: #{inspect(diagnostic.message)}" ) {src, applied} rule -> name = RuleHelpers.rule_name(rule) Logger.debug("[credence_fix] #{name}: matched diagnostic, running fix...") fixed = rule.fix(src, diagnostic) if fixed == src do Logger.debug("[credence_fix] #{name}: fix returned IDENTICAL source (no change)") else RuleHelpers.log_diff(name, src, fixed) end {fixed, [{rule, 1} | applied]} end end) end # Sort key for ordering diagnostics within a pass: `{line, col}` if # both are present, `{line, 0}` if only the line is known, `{0, 0}` # otherwise. Used with `:desc` so rightmost-on-line is applied first. defp position_sort_key(%{position: {line, col}}) when is_integer(line) and is_integer(col), do: {line, col} defp position_sort_key(%{position: {line, _}}) when is_integer(line), do: {line, 0} defp position_sort_key(%{position: line}) when is_integer(line), do: {line, 0} defp position_sort_key(_), do: {0, 0} defp match_rules(diagnostic) do case find_matching_rule(diagnostic) do nil -> [] rule -> [rule.to_issue(diagnostic)] end end defp find_matching_rule(diagnostic) do Enum.find(rules(), fn rule -> rule.match?(diagnostic) end) end @doc false def default_rules do RuleHelpers.discover_rules(Credence.Semantic.Rule) end defp rules, do: default_rules() end