Current section

Files

Jump to
credence lib pattern rule.ex
Raw

lib/pattern/rule.ex

defmodule Credence.Pattern.Rule do
@moduledoc """
Behaviour for pattern-level rules that detect and auto-fix anti-patterns.
Every Pattern rule fixes the issue it detects — there is no "warn-only"
mode. Rules that could only detect but not fix are deleted, not shipped.
## Interface
Three callbacks, all mandatory (only `priority/0` has a default):
- **`priority() :: integer()`** — fire order, lower runs first. Default 500.
- **`check(ast, opts) :: [Issue.t()]`** — detect issues in the AST.
- **`fix_patches(ast, opts) :: [patch]`** — emit byte-range patches that,
when applied, resolve the issues `check/2` reported. Empty list = no
change.
Both callbacks receive Sourceror AST (`Sourceror.parse_string!/1`) and
`opts` containing `:source` for rules that need raw source bytes.
## Implementation choices for `fix_patches/2`
All rules return `[patch]`, but the way they compute those patches
varies with what the transformation needs:
- **AST-walking**`Credence.RuleHelpers.patches_from_postwalk/2`
handles rules whose fix is a single `Macro.postwalk/2` matcher.
- **AST-with-restructuring**
`Credence.RuleHelpers.patches_from_ast_transform/3` for fixes that
prune, reorder, or insert siblings (the rendered result is re-parsed
for clean range diffing).
- **Direct patch emission** — a rule walks the AST itself and builds
`[%{range: Sourceror.Range, change: String.t()}]` manually. Useful
when the kept subtree's source bytes must be preserved verbatim
(e.g. parens metadata Sourceror's renderer would drop) — slice the
original source bytes for the kept range instead of re-rendering.
See each helper's docstring for the rule-side calling convention.
"""
@typedoc """
A byte-range patch against the source string.
- `range` carries Sourceror-style start/end positions (`[line: L, column: C]`).
- `change` is the replacement text. Apply via `Sourceror.patch_string/2`.
"""
@type patch :: %{
required(:range) => map(),
required(:change) => String.t()
}
@callback priority() :: integer()
@doc "Detect issues in the AST. Returns list of issues."
@callback check(ast :: Macro.t(), opts :: keyword()) :: [Credence.Issue.t()]
@doc "Auto-fix via byte-range patches. Returns a list of patches; `[]` means no change."
@callback fix_patches(ast :: Macro.t(), opts :: keyword()) :: [patch()]
@doc """
The assumptions (safety switches) this rule's fix relies on to be
behaviour-preserving. Returns a list of switch names from
`Credence.Assumptions`; `[]` (the default) means the fix is correct for
*every* input and is never filtered out.
A rule only runs when **all** of its named assumptions are currently on
(see `Credence.RuleHelpers.filter_by_assumptions/3`). Defaults to `[]`.
"""
@callback assumptions() :: [atom()]
@doc """
The reinterpreting-DSL families this rule's fix is **not** behaviour-preserving
inside. Returns a list of family atoms from `Credence.DslGuard.families/0`
(`:ash_expr`, `:ecto_query`, `:nx_defn`) or `:all`; `[]` (the default) means the
fix is plain-Elixir-structural and safe inside every DSL.
A macro DSL re-reads ordinary Elixir AST with different meaning (e.g. inside
`Ash.Expr.expr/1`, `!x` is not `not x`). A rule whose rewrite introduces, flips
or removes a construct that the DSL reinterprets gives a different runtime answer
there — while still compiling. Such a rule lists the families it diverges in, and
the engine then skips its findings/fixes inside those blocks (see
`Credence.DslGuard`). The classification is empirical: a rule is listed only when
its fix is shown to change a reinterpreted construct (comparison/boolean/control-
flow/nil form). Defaults to `[]`.
"""
@callback unsafe_in_dsl() :: [atom()] | :all
defmacro __using__(_opts) do
quote do
@behaviour Credence.Pattern.Rule
alias Credence.Issue
@impl true
def priority, do: 500
@impl true
def assumptions, do: []
@impl true
def unsafe_in_dsl, do: []
defoverridable priority: 0, assumptions: 0, unsafe_in_dsl: 0
end
end
end