lib/metacredo/check/warning/lazy_logging.ex

defmodule MetaCredo.Check.Warning.LazyLogging do
  use MetaCredo.Check,
    category: :warning,
    base_priority: :normal,
    explanations: [
      check: """
      Detects Logger calls that use string interpolation directly instead of
      wrapping the message in an anonymous function. Eager interpolation
      performs the string building even when the log level is disabled.

      Use `Logger.info(fn -> "msg: \#{val}" end)` instead.
      """,
      examples: [
        elixir: [
          wrong: """
          # String is always built, even if :debug level is disabled in production
          Logger.debug("Processing order \#{order.id} for user \#{user.email}")
          """,
          correct: """
          # Lazy evaluation: string is only built when the level is active
          Logger.debug(fn -> "Processing order \#{order.id} for user \#{user.email}" end)
          """
        ]
      ]
    ]

  @logger_names ~W(Logger.info Logger.warn Logger.error Logger.debug Logger.warning Logger.notice Logger.critical Logger.alert Logger.emergency)

  @impl true
  def run(%SourceFile{} = source_file, _params) do
    {_, issues} =
      source_file
      |> SourceFile.ast()
      |> AST.prewalk([], fn node, acc -> traverse(node, acc, source_file) end)

    issues
  end

  defp traverse(
         {:function_call, meta, args} = node,
         issues,
         source_file
       )
       when is_list(meta) and is_list(args) do
    fn_name = to_string(Keyword.get(meta, :name, ""))

    if logger_call?(fn_name) and has_interpolation_arg?(args) do
      line = Keyword.get(meta, :line)

      issue =
        format_issue(source_file,
          message:
            "Use lazy logging with an anonymous function to avoid eager interpolation in '#{fn_name}'",
          trigger: fn_name,
          line_no: line
        )

      {node, [issue | issues]}
    else
      {node, issues}
    end
  end

  defp traverse(node, issues, _source_file), do: {node, issues}

  defp logger_call?(fn_name), do: fn_name in @logger_names

  defp has_interpolation_arg?(args) do
    Enum.any?(args, &contains_interpolation?/1)
  end

  defp contains_interpolation?({:string_interpolation, _meta, _children}), do: true

  defp contains_interpolation?({_type, _meta, children}) when is_list(children) do
    Enum.any?(children, &contains_interpolation?/1)
  end

  defp contains_interpolation?(_), do: false
end