Credence.RuleHelpers (credence v0.6.0)

Copy Markdown

Shared utilities used by all three Credence phases (Syntax, Semantic, Pattern).

Provides rule discovery, safe compilation with diagnostics capture, diff computation, and change logging so the phase modules don't duplicate this plumbing.

Summary

Functions

Invokes a Pattern rule's fix on source, dispatching to either the new patch-based fix_patches/2 callback (if the rule has migrated) or the legacy fix/2 callback.

Compiles source with Code.with_diagnostics/1 and returns {:ok, diagnostics} or {:error, diagnostics}.

Returns true if source compiles without errors.

Computes a line-by-line diff between two strings.

Returns all modules implementing behaviour, sorted by priority (lower first) with module name as tiebreaker for determinism.

Returns the value bound to :do in a Sourceror-shaped keyword list ([{{:__block__, _, [:do]}, body} | _]).

Returns true if module declares behaviour in its @behaviour attribute.

Logs a before/after diff under a [credence_fix] prefix.

Strips Sourceror.parse_string!/1's {:__block__, meta, [value]} wrappers around literals, atoms, 2-tuples, and lists — producing the bare-literal shape that Macro.to_string/1 expects for rendering.

Applies an AST-to-AST transform_fn, then emits patches via AST-diff between the original AST and a re-parsed version of the rendered output.

Emits patches by AST-diffing original against a pre-computed transformed AST. Use when the transformation can't be expressed as a single Macro.postwalk/2 matcher — e.g. it prunes nodes, reorders siblings, or needs cross-clause information.

Mechanical migration helper for rules that today do source |> Sourceror.parse_string!() |> Macro.postwalk(matcher) |> Sourceror.to_string().

Renders a replacement subtree as source text suitable for patching back into the original source at original_range's position.

Replaces the value bound to :do in a Sourceror-shaped keyword list ([{{:__block__, _, [:do]}, body} | _]) with new_body. Returns the list unchanged if no :do entry is found.

Re-wraps a list of elements in a Sourceror :__block__ wrapper, reusing the original wrapper's meta so bracket-position metadata survives the rewrite. Paired with unwrap_list/1 for list-literal AST surgery.

Returns the short name of a rule module for logging.

Unwraps a Sourceror list-literal node ({:__block__, meta, [list]}).

Functions

apply_rule_fix(rule, source, opts \\ [])

@spec apply_rule_fix(module(), String.t(), keyword()) :: String.t()

Invokes a Pattern rule's fix on source, dispatching to either the new patch-based fix_patches/2 callback (if the rule has migrated) or the legacy fix/2 callback.

Used by both the orchestrator (Credence.Pattern.run_fixable_rules/3) and rule tests, so test assertions on the post-fix source string continue working unchanged regardless of which side of the migration a rule is on.

compile_and_capture(source)

@spec compile_and_capture(String.t()) :: {:ok, [map()]} | {:error, [map()]}

Compiles source with Code.with_diagnostics/1 and returns {:ok, diagnostics} or {:error, diagnostics}.

Uses :code.soft_purge/1 for cleanup so that compiling source which redefines a currently-executing module does not kill the BEAM (see :code.purge/1 — it sends an unconditional kill signal to any process still running the old version of the module).

compiles?(source)

@spec compiles?(String.t()) :: boolean()

Returns true if source compiles without errors.

Convenience wrapper around compile_and_capture/1 for callers that only need a boolean (e.g., the Pattern phase compile gate).

diff_lines(before, after_fix)

@spec diff_lines(String.t(), String.t()) :: [
  {:removed, pos_integer(), String.t()} | {:added, pos_integer(), String.t()}
]

Computes a line-by-line diff between two strings.

Returns a list of {:removed, line_no, text} and {:added, line_no, text} tuples for every line that changed.

discover_rules(behaviour)

@spec discover_rules(module()) :: [module()]

Returns all modules implementing behaviour, sorted by priority (lower first) with module name as tiebreaker for determinism.

iex> Credence.RuleHelpers.discover_rules(Credence.Pattern.Rule)
[Credence.Pattern.SomeRule, ...]

extract_do_body(kw)

@spec extract_do_body(list()) :: {:ok, term()} | :error

Returns the value bound to :do in a Sourceror-shaped keyword list ([{{:__block__, _, [:do]}, body} | _]).

Returns {:ok, body} when present, :error otherwise.

implements?(module, behaviour)

@spec implements?(module(), module()) :: boolean()

Returns true if module declares behaviour in its @behaviour attribute.

log_diff(label, before, after_fix)

@spec log_diff(String.t(), String.t(), String.t()) :: :ok

Logs a before/after diff under a [credence_fix] prefix.

Shows every changed line — the diff is never truncated so that the full extent of each fix is visible in the log output.

normalize_sourceror_ast(ast)

@spec normalize_sourceror_ast(Macro.t()) :: Macro.t()

Strips Sourceror.parse_string!/1's {:__block__, meta, [value]} wrappers around literals, atoms, 2-tuples, and lists — producing the bare-literal shape that Macro.to_string/1 expects for rendering.

Used by test helpers to canonicalize ASTs for structural comparison (parse → normalize → Macro.to_string → string compare). Not used by production rules — all rules pattern-match Sourceror's wrapped form directly.

Unwraps:

  • Literals: {:__block__, _, [1]}1
  • Atoms: {:__block__, _, [:do]}:do
  • 2-tuples: {:__block__, _, [{a, b}]}{a, b}
  • Lists: {:__block__, _, [[a, b]]}[a, b]

patches_from_ast_transform(ast, source, transform_fn)

@spec patches_from_ast_transform(Macro.t(), String.t(), (Macro.t() -> Macro.t())) :: [
  map()
]

Applies an AST-to-AST transform_fn, then emits patches via AST-diff between the original AST and a re-parsed version of the rendered output.

The re-parse step gives the transformed AST clean Sourceror metadata (literal wrappers, ranges) so the diff lines up structurally with the original. This is needed when a rule's transformation produces fresh subtrees with bare-literal shape that wouldn't otherwise match the original Sourceror-wrapped shape.

Returns [] if the transformation produced an identical AST. Falls back to a whole-source patch when re-parsing fails (rare — typically signals the transform produced invalid code).

patches_from_diff(original, transformed)

@spec patches_from_diff(Macro.t(), Macro.t()) :: [map()]

Emits patches by AST-diffing original against a pre-computed transformed AST. Use when the transformation can't be expressed as a single Macro.postwalk/2 matcher — e.g. it prunes nodes, reorders siblings, or needs cross-clause information.

The transformed AST should be built by walking original (so that unchanged subtrees retain their Sourceror metadata for range lookup); replacement subtrees can be freshly synthesized without metadata — the diff uses the original node's range.

patches_from_postwalk(ast, matcher)

@spec patches_from_postwalk(Macro.t(), (Macro.t() -> Macro.t())) :: [map()]

Mechanical migration helper for rules that today do source |> Sourceror.parse_string!() |> Macro.postwalk(matcher) |> Sourceror.to_string().

Applies the matcher via Macro.postwalk/2 to build a transformed AST, then diffs the original against the transformed and emits one patch per outermost changed subtree. Non-overlapping by construction — nested matches are subsumed by the outer patch.

The rule's fix_patches/2 becomes a one-line delegation:

def fix_patches(ast, _opts) do
  Credence.RuleHelpers.patches_from_postwalk(ast, fn
    {target_pattern, _, _} = node -> build_replacement(node)
    node -> node
  end)
end

render_replacement(new_ast, original_range, opts \\ [])

@spec render_replacement(Macro.t(), map(), keyword()) :: String.t()

Renders a replacement subtree as source text suitable for patching back into the original source at original_range's position.

Uses Sourceror's default line_length: 98. Pass :line_length to override — e.g. when a rule wants to force a multi-line replacement to mirror a multi-line original (the original Issue 4 trick).

original_range is currently unused but reserved as a hint for future rule-specific budget heuristics.

replace_do_body(kw_list, new_body)

@spec replace_do_body(list(), term()) :: list()

Replaces the value bound to :do in a Sourceror-shaped keyword list ([{{:__block__, _, [:do]}, body} | _]) with new_body. Returns the list unchanged if no :do entry is found.

rewrap_list(arg, new_elements)

@spec rewrap_list(term(), list()) :: term()

Re-wraps a list of elements in a Sourceror :__block__ wrapper, reusing the original wrapper's meta so bracket-position metadata survives the rewrite. Paired with unwrap_list/1 for list-literal AST surgery.

rule_name(module)

@spec rule_name(module()) :: String.t()

Returns the short name of a rule module for logging.

iex> Credence.RuleHelpers.rule_name(Credence.Pattern.NoSortThenAt)
"NoSortThenAt"

unwrap_list(node)

@spec unwrap_list(term()) :: {:ok, list(), term()} | :error

Unwraps a Sourceror list-literal node ({:__block__, meta, [list]}).

Returns {:ok, elements, original_node} — the second value is the wrapper itself so the caller can pass it to rewrap_list/2 later to preserve bracket-position metadata on a rewrite.