lib/bylaw/credo/check/heex/no_duplicate_static_ids.ex

defmodule Bylaw.Credo.Check.HEEx.NoDuplicateStaticIds do
  @moduledoc """
  Forbids duplicate static `id` attributes in HEEx/HTML templates.

  ## Examples

  Embedded `~H` templates are checked during normal Credo runs over Elixir
  files. Standalone `.html.heex` templates require enabling
  `Bylaw.Credo.Plugin.HEExSources` in Credo's `plugins` configuration.

  Dynamic `id` values and root attributes are ignored because their final DOM
  IDs cannot be proven statically.
  Avoid:

        ~H\"\"\"
        <section id="profile">
          <div id="profile"></div>
        </section>
        \"\"\"
  Prefer:

        ~H\"\"\"
        <section id="profile">
          <div id="profile-details"></div>
          <div id={@dynamic_id}></div>
        </section>
        \"\"\"

  ## Notes

  Embedded `~H` templates in `.ex` and `.exs` files are checked by Credo's normal source traversal. Standalone `.html.heex` templates are checked when `Bylaw.Credo.Plugin.HEExSources` is enabled in `.credo.exs`.

  This check uses static HEEx token analysis, so it reports only patterns visible in the template source.

  ## 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.HEEx.NoDuplicateStaticIds, []}
        ]
      }
    ]
  }
  ```
  """

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

  alias Bylaw.Credo.Heex

  @message "Static DOM id values must be unique within a HEEx source."
  @doc false
  @impl Credo.Check
  def run(%Credo.SourceFile{} = source_file, params \\ []) do
    issue_meta = IssueMeta.for(source_file, params)

    source_file
    |> Heex.templates()
    |> Enum.flat_map(&duplicate_ids_in_template/1)
    |> Enum.map(&issue_for(issue_meta, &1))
  end

  defp duplicate_ids_in_template(template) do
    template
    |> Heex.tags()
    |> static_ids()
    |> duplicate_ids()
  end

  defp static_ids(tags) do
    tags
    |> Enum.filter(&html_tag?/1)
    |> Enum.flat_map(fn tag ->
      tag.attrs
      |> Enum.filter(&static_id?/1)
      |> Enum.map(&Map.put(&1, :tag, tag))
    end)
  end

  defp html_tag?(%Heex.Tag{type: :tag}), do: true
  defp html_tag?(_tag), do: false

  defp static_id?(%{name: "id", value: {:string, value, _meta}}) when is_binary(value), do: true
  defp static_id?(_attr), do: false

  defp duplicate_ids(static_ids) do
    {_seen, duplicates} =
      Enum.reduce(static_ids, {%{}, []}, fn %{value: {:string, value, _meta}} = attr,
                                            {seen, duplicates} ->
        if Map.has_key?(seen, value) do
          {seen, [attr | duplicates]}
        else
          {Map.put(seen, value, attr), duplicates}
        end
      end)

    Enum.reverse(duplicates)
  end

  defp issue_for(issue_meta, %{tag: %Heex.Tag{} = tag, value: {:string, value, _meta}} = attr) do
    format_issue(
      issue_meta,
      message: "#{@message} Duplicate id: #{inspect(value)}.",
      trigger: ~s(id="#{value}"),
      line_no: attr.line || tag.line,
      column: attr.column || tag.column
    )
  end
end