lib/bylaw/credo/check/elixir/float_usage.ex

defmodule Bylaw.Credo.Check.Elixir.FloatUsage do
  @moduledoc """
  Prefer `Decimal` over floats in Ecto migrations, Ecto schemas, and Elixir code.

  Floats are approximate binary numbers. Many decimal values cannot be represented
  exactly as floats, which can make calculations, rounding, equality checks, and
  persisted values surprising for money, tax, rates, balances, quantities, and
  other business data.

  For example, a float calculation may keep a tiny representation error:

        0.1 + 0.2

  Use the `Decimal` library and Ecto's `:decimal` type when a value needs decimal
  precision or predictable rounding:

        Decimal.add(Decimal.new("0.1"), Decimal.new("0.2"))

  Floats may still be appropriate for approximate measurements, statistics,
  scientific calculations, graphics, telemetry, or other domains where small
  precision differences are expected and acceptable.

  ## Examples

  Avoid:

        add :amount, :float
        field :amount, :float
        Float.round(value, 2)
        amount = 1.25
        @spec amount() :: float()
  Prefer:

        add :amount, :decimal
        field :amount, :decimal
        Decimal.round(value, 2)
        amount = Decimal.new("1.25")
        @spec amount() :: Decimal.t()

  If a float is genuinely required, document the exception and disable the check locally.

  ## Notes

  This check uses static AST analysis, so it favors clear source-level patterns over runtime behavior.

  ## Options

  This check has no check-specific options. Configure it with an empty option list.

  ## Usage

  Add this check to Credo's `checks:` list in `.credo.exs`:

  ```elixir
  %{
    configs: [
      %{
        name: "default",
        checks: [
          {Bylaw.Credo.Check.Elixir.FloatUsage, []}
        ]
      }
    ]
  }
  ```
  """

  use Credo.Check,
    base_priority: :high,
    category: :warning,
    explanations: [
      check: @moduledoc
    ]

  @migration_functions [:add, :add_if_not_exists, :modify]
  @typespec_attributes [:spec, :type, :typep, :opaque, :callback, :macrocallback]
  @doc false
  @impl Credo.Check
  def run(source_file, params \\ []) do
    ctx = Context.build(source_file, params, __MODULE__)
    ctx = Credo.Code.prewalk(source_file, &walk/2, ctx)

    ctx.issues ++ float_literal_issues(source_file, ctx)
  end

  defp walk({op, meta, [_name, :float | _rest]} = ast, ctx) when op in @migration_functions do
    {ast, put_issue(ctx, issue_for(ctx, meta, Atom.to_string(op), migration_message()))}
  end

  defp walk({:field, meta, [_name, :float | _rest]} = ast, ctx) do
    {ast, put_issue(ctx, issue_for(ctx, meta, "field", schema_message()))}
  end

  defp walk(
         {{:., meta, [{:__aliases__, _mod_meta, [:Float]}, function]}, _call_meta, _args} = ast,
         ctx
       ) do
    trigger = "Float.#{function}"
    {ast, put_issue(ctx, issue_for(ctx, meta, trigger, module_message()))}
  end

  defp walk({:@, _attr_meta, [{attribute, _spec_meta, arguments}]} = ast, ctx)
       when attribute in @typespec_attributes do
    {ast, find_typespec_float_issues(arguments, ctx)}
  end

  defp walk(ast, ctx), do: {ast, ctx}

  defp find_typespec_float_issues(arguments, ctx) do
    case Macro.prewalk(arguments, ctx, fn
           {:float, meta, []} = ast, inner_ctx ->
             {ast,
              put_issue(inner_ctx, issue_for(inner_ctx, meta, "float()", typespec_message()))}

           ast, inner_ctx ->
             {ast, inner_ctx}
         end) do
      {_ast, updated_ctx} -> updated_ctx
    end
  end

  defp float_literal_issues(source_file, ctx) do
    ignored_lines =
      ctx.issues
      |> Enum.map(& &1.line_no)
      |> MapSet.new()

    source_file
    |> Credo.Code.to_tokens()
    |> Enum.reduce([], fn
      {:flt, {line_no, _column, _value}, literal}, issues ->
        case MapSet.member?(ignored_lines, line_no) do
          true ->
            issues

          false ->
            [
              issue_for(
                ctx,
                [line: line_no],
                List.to_string(literal),
                float_literal_message()
              )
              | issues
            ]
        end

      _token, issues ->
        issues
    end)
    |> Enum.reverse()
  end

  defp issue_for(ctx, meta, trigger, message) do
    format_issue(
      ctx,
      message: message,
      trigger: trigger,
      line_no: meta[:line]
    )
  end

  defp migration_message, do: "Prefer `:decimal` over `:float` in Ecto migrations."
  defp schema_message, do: "Prefer `:decimal` over `:float` in Ecto schemas."
  defp module_message, do: "Prefer `Decimal` APIs over the `Float` module."
  defp typespec_message, do: "Prefer `Decimal.t()` over `float()` in typespecs."
  defp float_literal_message, do: "Prefer `Decimal.new/1` over float literals."
end