Current section
Files
Jump to
Current section
Files
lib/pattern/no_missing_require_logger.ex
defmodule Credence.Pattern.NoMissingRequireLogger do
@moduledoc """
Detects Logger macro calls without a `require Logger` in the enclosing module.
Logger's logging functions (`info`, `debug`, `warning`, `error`, etc.) are
macros, not regular functions. Calling them without `require Logger` compiles
fine but crashes at runtime with `UndefinedFunctionError`:
** (UndefinedFunctionError) function Logger.info/1 is undefined or private.
However, there is a macro with the same name and arity.
Be sure to require Logger if you intend to invoke this macro
LLMs frequently forget the `require` because Python's `logging.info()` needs
no equivalent setup.
## Bad
defmodule MyApp do
def run do
Logger.info("starting")
end
end
## Good
defmodule MyApp do
require Logger
def run do
Logger.info("starting")
end
end
## What is flagged
Any `defmodule` whose body calls a Logger macro (`debug`, `info`, `notice`,
`warning`, `warn`, `error`, `critical`, `alert`, `emergency`, `log`) without
a corresponding `require Logger`, `import Logger`, or `use Logger`.
Logger function calls like `Logger.configure/1` or `Logger.metadata/1` are
not flagged — they are regular functions that don't need `require`.
## Auto-fix
Inserts `require Logger` at the top of the module body, after any existing
`@moduledoc`, `use`, `import`, `require`, or `alias` directives.
## Known limitations
Aliased Logger (`alias Logger, as: L` then `L.info(...)`) is not detected.
"""
use Credence.Pattern.Rule
alias Credence.Issue
@logger_macros ~w(debug info notice warning warn error critical alert emergency log)a
@impl true
def check(ast, _opts) do
for {{:defmodule, meta, _}, _kw} <- unsatisfied_modules(ast), do: build_issue(meta)
end
@impl true
def fix_patches(ast, _opts) do
Enum.flat_map(unsatisfied_modules(ast), fn {_node, kw} -> require_patch(kw) end)
end
# Modules (innermost-first within each scope) that call a Logger macro and have
# NOTHING in scope that would provide `require Logger`. "In scope" includes the
# module's own directives AND those inherited from enclosing modules / file
# scope, because `require`/`alias` propagate lexically downward. A module is
# treated as satisfied (not flagged, not auto-required) when, anywhere in scope:
# * `require`/`import`/`use Logger`,
# * an `alias` that makes `Logger` refer to a *different* module
# (`alias Conform.Logger` / `alias X, as: Logger`) — then `Logger.*` is not
# the stdlib macro at all, or
# * any `use SomeModule` — whose `__using__` may inject `require Logger`
# (common for app/web base modules and logging mix-ins); we can't expand it.
defp unsatisfied_modules(ast) do
collect_unsatisfied(top_statements(ast), false, [])
|> Enum.reverse()
end
defp collect_unsatisfied(statements, inherited?, acc) do
scope_provides? = inherited? or Enum.any?(statements, &provides_logger?/1)
Enum.reduce(statements, acc, fn
{:defmodule, _meta, [_name, kw]} = node, acc when is_list(kw) ->
case extract_do_body(kw) do
nil ->
acc
body ->
stmts = block_to_list(body)
self_provides? =
scope_provides? or Enum.any?(stmts, &provides_logger?/1) or
has_logger_require?(body)
acc =
if has_logger_macro_call?(body) and not self_provides?,
do: [{node, kw} | acc],
else: acc
collect_unsatisfied(stmts, self_provides?, acc)
end
_other, acc ->
acc
end)
end
defp top_statements({:__block__, _, stmts}), do: stmts
defp top_statements(single), do: [single]
# OTP/stdlib behaviour modules whose `use` does NOT inject `require Logger`.
# Other `use X` are custom mix-ins (app/web bases, logging helpers) whose
# `__using__` commonly injects `require Logger` / `alias …Logger`, which we
# cannot expand — so we conservatively treat them as providing it.
@stdlib_use_targets [
[:GenServer],
[:Agent],
[:Task],
[:Supervisor],
[:DynamicSupervisor],
[:Application],
[:GenEvent],
[:GenStage],
[:Registry],
[:Bitwise]
]
# Does a single statement bring `require Logger` into scope, alias `Logger` to a
# non-stdlib module, or possibly inject a require via a custom `use`?
defp provides_logger?({:use, _, [{:__aliases__, _, parts} | _]}) when is_list(parts),
do: parts not in @stdlib_use_targets
defp provides_logger?({:use, _, [_ | _]}), do: true
defp provides_logger?({d, _, [{:__aliases__, _, parts} | _]})
when d in [:require, :import] and is_list(parts),
do: List.last(parts) == :Logger
defp provides_logger?({:alias, _, args}), do: alias_targets_logger?(args)
defp provides_logger?(_), do: false
# `alias Conform.Logger` (multi-segment ending in Logger) or
# `alias X, as: Logger` (X ≠ Logger) make `Logger` refer to a non-stdlib module.
defp alias_targets_logger?([{:__aliases__, _, parts}])
when is_list(parts),
do: length(parts) > 1 and List.last(parts) == :Logger
defp alias_targets_logger?([{:__aliases__, _, target}, opts]) when is_list(opts) do
match?({:__aliases__, _, [:Logger]}, Keyword.get(opts, :as)) and target != [:Logger]
end
defp alias_targets_logger?(_), do: false
# A single zero-width insertion patch placing `require Logger` (as its own
# paragraph) just before the first non-directive statement. Inserting
# surgically — rather than rebuilding the module body with the extra statement
# — avoids re-rendering, and thereby reformatting, the rest of the module.
defp require_patch(kw) do
with body when not is_nil(body) <- extract_do_body(kw),
true <- has_logger_macro_call?(body) and not has_logger_require?(body),
statements = block_to_list(body),
anchor when not is_nil(anchor) <- Enum.at(statements, find_directive_end(statements)),
%Sourceror.Range{start: start} <- Sourceror.get_range(anchor) do
# Anchor the insertion at column 1 of the statement's line (not its own
# column): `Sourceror.patch_string` re-indents a multi-line change to the
# patch's start column, which would double the indentation. At column 1 the
# change is inserted verbatim, so we bake the indent in ourselves.
indent = String.duplicate(" ", start[:column] - 1)
[
%{
range: %{start: [line: start[:line], column: 1], end: [line: start[:line], column: 1]},
change: indent <> "require Logger\n\n"
}
]
else
_ -> []
end
end
# Walks the body looking for Logger.macro_name(...) calls.
# Stops at nested defmodule nodes so inner modules don't get
# attributed to the outer module.
defp has_logger_macro_call?(body) do
{_, found} =
Macro.prewalk(body, false, fn
_node, true ->
{nil, true}
# Replace nested defmodule / quote with an atom to prevent descent.
# A Logger call inside a `quote` belongs to the *generated* code (which
# carries its own `require Logger`), not the module defining the macro.
{:defmodule, _, _}, acc ->
{:__skip__, acc}
{:quote, _, _}, acc ->
{:__skip__, acc}
{{:., _, [{:__aliases__, _, [:Logger]}, func]}, _, _}, _acc
when func in @logger_macros ->
{nil, true}
node, acc ->
{node, acc}
end)
found
end
# A `require Logger` (or import/use) anywhere in the module body satisfies the
# requirement — including inside a function body or a `quote`. Checking only
# top-level statements falsely flags a module whose require is co-located with
# the call inside a function, or lives in the `quote` alongside the call.
defp has_logger_require?(body) do
{_, found} =
Macro.prewalk(body, false, fn
_node, true ->
{nil, true}
{directive, _, [{:__aliases__, _, [:Logger]} | _]}, _acc
when directive in [:require, :import, :use] ->
{nil, true}
node, acc ->
{node, acc}
end)
found
end
defp block_to_list({:__block__, _, stmts}), do: stmts
defp block_to_list(single), do: [single]
# Extracts the body from a defmodule's keyword argument list.
defp extract_do_body([{{:__block__, _, [:do]}, body}]), do: body
defp extract_do_body(_), do: nil
@directives [:use, :import, :require, :alias]
defp find_directive_end(statements) do
statements
|> Enum.with_index()
|> Enum.reduce(0, fn {stmt, idx}, last ->
if directive_like?(stmt), do: idx + 1, else: last
end)
end
defp directive_like?({tag, _, _}) when tag in @directives, do: true
defp directive_like?({:@, _, [{:moduledoc, _, _}]}), do: true
defp directive_like?(_), do: false
defp build_issue(meta) do
%Issue{
rule: :no_missing_require_logger,
message:
"Logger macros (info, debug, warning, error, etc.) need " <>
"`require Logger` in the enclosing module. Without it, the code " <>
"compiles but crashes at runtime with UndefinedFunctionError.",
meta: %{line: Keyword.get(meta, :line)}
}
end
end