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

defmodule Bylaw.Credo.Check.HEEx.RequireAccessibleButtonText do
  @moduledoc """
  Requires static HEEx/HTML button tags to have an accessible name.

  ## 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.
  Avoid:

        ~H\"\"\"
        <button type="button"><.icon name="hero-x-mark" /></button>
        \"\"\"
  Prefer:

        ~H\"\"\"
        <button type="button">Close</button>
        <button type="button" aria-label="Close"><.icon name="hero-x-mark" /></button>
        <button type="button">{@label}</button>
        \"\"\"

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

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

  alias Bylaw.Credo.Heex

  @message "Buttons must have text content, aria-label, or aria-labelledby."
  @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(&Heex.tokens/1)
    |> inaccessible_buttons()
    |> Enum.map(&issue_for(issue_meta, &1))
  end

  defp inaccessible_buttons(tokens) do
    {issues, _stack} =
      Enum.reduce(tokens, {[], []}, fn token, {issues, stack} ->
        case token do
          %Heex.Tag{type: :tag, name: "button", closing: :self} = tag ->
            issues =
              if attrs_have_name?(tag) do
                issues
              else
                [%{line: tag.line, column: tag.column} | issues]
              end

            {issues, stack}

          %Heex.Tag{type: :tag, name: "button"} = tag ->
            button = %{
              line: tag.line,
              column: tag.column,
              has_name?: attrs_have_name?(tag)
            }

            {issues, [button | stack]}

          %Heex.Tag{closing: :self} ->
            {issues, stack}

          %Heex.CloseTag{name: "button"} ->
            close_button(issues, stack)

          %Heex.Text{content: content} ->
            {issues, mark_named(stack, text_name?(content))}

          %Heex.Expression{} ->
            {issues, mark_named(stack, true)}

          _token ->
            {issues, stack}
        end
      end)

    Enum.reverse(issues)
  end

  defp close_button(issues, [button | rest]) do
    if button.has_name? do
      {issues, rest}
    else
      {[button | issues], rest}
    end
  end

  defp close_button(issues, []), do: {issues, []}

  defp mark_named(stack, true), do: Enum.map(stack, &%{&1 | has_name?: true})
  defp mark_named(stack, false), do: stack

  defp text_name?(text) do
    text =
      text
      |> String.replace(~r/\s+/, " ")
      |> String.trim()

    text != ""
  end

  defp attrs_have_name?(%Heex.Tag{attrs: attrs}) do
    Enum.any?(attrs, fn attr ->
      attr.name == :root or
        (attr.name in ["aria-label", "aria-labelledby"] and non_empty_attr_value?(attr.value))
    end)
  end

  defp non_empty_attr_value?({:string, value, _meta}), do: String.trim(value) != ""
  defp non_empty_attr_value?(nil), do: false
  defp non_empty_attr_value?(_value), do: true

  defp issue_for(issue_meta, %{line: line, column: column}) do
    format_issue(
      issue_meta,
      message: @message,
      trigger: "<button",
      line_no: line,
      column: column
    )
  end
end