lib/credo_binary_patterns/check/consistency/pattern.ex

defmodule CredoBinaryPatterns.Check.Consistency.Pattern do
  @moduledoc """
  Credo check to ensure binary pattern matches follow common formats.

  It will capture the following binary expressions:
    [value]::[left]-[right]
    [value]::[first]-[second]-[third]
    [value]::[first]-[second]-[third]-[fourth]
  """
  use Credo.Check, base_priority: :high

  @signs [:signed, :unsigned]
  @endians [:big, :little, :native]
  @types [:integer, :float, :bits, :bitstring, :binary, :bytes, :utf8, :utf16, :utf32]

  @impl Credo.Check
  def run(source_file, params \\ []) do
    issue_meta = IssueMeta.for(source_file, params)
    Credo.Code.postwalk(source_file, &traverse(&1, &2, issue_meta))
  end

  # 4 elements
  defp traverse(
         {:"::", [line: pattern_line, column: pattern_col],
          [
            value_matched,
            {:-, _,
             [
               {:-, _,
                [
                  {:-, _,
                   [
                     first,
                     second
                   ]},
                  third
                ]},
               fourth
             ]}
          ]} = ast,
         issues,
         issue_meta
       ) do
    first = unwrap(first)
    second = unwrap(second)
    third = unwrap(third)
    fourth = unwrap(fourth)
    value_matched = unwrap_value(value_matched)

    pattern_info = build_info([first, second, third, fourth])

    maybe_issue =
      determine_issue(
        value_matched,
        pattern_info,
        [first, second, third, fourth],
        pattern_line,
        pattern_col,
        issue_meta
      )

    if maybe_issue do
      {ast, [maybe_issue | issues]}
    else
      {ast, issues}
    end
  end

  # 3 elements
  defp traverse(
         {:"::", [line: pattern_line, column: pattern_col],
          [
            value_matched,
            {:-, _,
             [
               {:-, _,
                [
                  first,
                  second
                ]},
               third
             ]}
          ]} = ast,
         issues,
         issue_meta
       ) do
    first = unwrap(first)
    second = unwrap(second)
    third = unwrap(third)
    value_matched = unwrap_value(value_matched)

    pattern_info = build_info([first, second, third])

    maybe_issue =
      determine_issue(
        value_matched,
        pattern_info,
        [first, second, third],
        pattern_line,
        pattern_col,
        issue_meta
      )

    if maybe_issue do
      {ast, [maybe_issue | issues]}
    else
      {ast, issues}
    end
  end

  # 2 elements
  defp traverse(
         {:"::", [line: pattern_line, column: pattern_col],
          [
            {value_matched, [line: _, column: _], nil},
            {:-, _, [left, right]}
          ]} = ast,
         issues,
         issue_meta
       ) do
    left = unwrap(left)
    right = unwrap(right)
    value_matched = unwrap_value(value_matched)

    pattern_info = build_info([left, right])

    maybe_issue =
      determine_issue(
        value_matched,
        pattern_info,
        [left, right],
        pattern_line,
        pattern_col,
        issue_meta
      )

    if maybe_issue != nil do
      {ast, [maybe_issue | issues]}
    else
      {ast, issues}
    end
  end

  defp traverse(ast, issues, _issue_meta) do
    {ast, issues}
  end

  defp unwrap({atom_name, _, _}) do
    atom_name
  end

  defp unwrap(val), do: val

  defp unwrap_value(val), do: Macro.to_string(val) |> String.trim_leading(":")

  defp issue_for(:out_of_order, fixed, line, col, issue_meta) do
    format_issue(
      issue_meta,
      message:
        "[Options out of order] Should follow: [endian]-[sign]-[type]-[size]. Please re-write this binary pattern as #{fixed}",
      line_no: line,
      column: col
    )
  end

  defp issue_for(:default_size, fixed, line, col, issue_meta) do
    format_issue(
      issue_meta,
      message: "[Unneeded size specified] Please re-write this binary pattern as #{fixed}",
      line_no: line,
      column: col
    )
  end

  defp issue_for(:default_sign, fixed, line, col, issue_meta) do
    format_issue(
      issue_meta,
      message:
        "[Unneeded signed/unsigned specified] Please re-write this binary pattern as #{fixed}",
      line_no: line,
      column: col
    )
  end

  defp issue_for(:default_endian, fixed, line, col, issue_meta) do
    format_issue(
      issue_meta,
      message: "[Unneeded endian specified] Please re-write this binary pattern as #{fixed}",
      line_no: line,
      column: col
    )
  end

  defp determine_issue(value, pattern_info, original_members, line, col, meta) do
    cond do
      # Bits must come AFTER the type/other members of the pattern
      not in_correct_order?(pattern_info, original_members) ->
        issue_for(:out_of_order, stringify(value, pattern_info), line, col, meta)

      # Check if the size specified in the pattern is the default size of the type
      default_size(pattern_info.type) == pattern_info.size ->
        issue_for(:default_size, stringify(value, pattern_info), line, col, meta)

      # Check if the sign specified in the pattern is the default sign for the type
      default_sign(pattern_info.type) == pattern_info.sign ->
        issue_for(:default_sign, stringify(value, pattern_info), line, col, meta)

      default_endian(pattern_info.type) == pattern_info.endian ->
        issue_for(:default_endian, stringify(value, pattern_info), line, col, meta)

      true ->
        nil
    end
  end

  # The general order should always be: [endian]-[sign]-[type]-[size]
  # What we do here, is rank each component type with a numeric value corresponding to the proper ordering.
  # If we sort this ranked list, and the result does not match the original list, the pattern is out of order.
  defp in_correct_order?(pattern_info, components) when is_list(components) do
    # special case, if the type is a binary, size comes before type!
    is_binary? = pattern_info.type in [:bytes, :binary]
    type_order = if is_binary?, do: 4, else: 3
    size_order = if is_binary?, do: 3, else: 4

    components_ranked =
      Enum.map(components, fn c ->
        case get_component_type(c) do
          :endian -> 1
          :sign -> 2
          :type -> type_order
          :size -> size_order
        end
      end)

    Enum.sort(components_ranked) == components_ranked
  end

  # Proper format of the pattern as a string, used to suggest corrections
  defp stringify(value, pattern_info) do
    # Ordering depends on the type, if byte/binary, size comes first
    order =
      if pattern_info.type in [:binary, :bytes] do
        [:endian, :sign, :size, :type]
      else
        [:endian, :sign, :type, :size]
      end

    result =
      Enum.reduce(order, "", fn key, acc ->
        if is_nil(pattern_info[key]) or pattern_info[key] == default(key, pattern_info.type) do
          acc
        else
          acc <> "#{pattern_info[key]}-"
        end
      end)

    result = "#{value}::#{result}" |> String.trim_trailing("-")
    "<<#{result}>>"
  end

  defp build_info(members) do
    type = Enum.find(members, &type_option?/1)
    sign = Enum.find(members, &sign_option?/1)
    endian = Enum.find(members, &endian_option?/1)
    size = Enum.find(members, &size_option?/1)
    %{type: type, size: size, sign: sign, endian: endian}
  end

  # Helper that routes to the proper "defaults" function below
  defp default(component_name, component_value) do
    case component_name do
      :size -> default_size(component_value)
      :endian -> default_endian(component_value)
      :sign -> default_sign(component_value)
      _ -> :no_default
    end
  end

  defp default_size(:integer), do: 8
  defp default_size(:float), do: 64
  defp default_size(:bitstring), do: 1
  defp default_size(:bits), do: 1
  defp default_size(:utf8), do: 8
  defp default_size(:utf16), do: 16
  defp default_size(:utf32), do: 32
  defp default_size(_), do: :none

  defp default_endian(:integer), do: :big
  defp default_endian(:float), do: :big
  defp default_endian(:utf8), do: :big
  defp default_endian(:utf16), do: :big
  defp default_endian(:utf32), do: :big
  defp default_endian(:bitstring), do: :none
  defp default_endian(:bits), do: :none
  defp default_endian(_), do: :none

  defp default_sign(:integer), do: :unsigned
  defp default_sign(:float), do: :unsigned
  defp default_sign(:utf8), do: :unsigned
  defp default_sign(:utf16), do: :unsigned
  defp default_sign(:utf32), do: :unsigned
  defp default_sign(:bitstring), do: :none
  defp default_sign(:bits), do: :none
  defp default_sign(_), do: :none

  defp type_option?(c), do: c in @types
  defp sign_option?(c), do: c in @signs
  defp endian_option?(c), do: c in @endians
  defp size_option?(c), do: is_integer(c)

  defp get_component_type(c) do
    cond do
      endian_option?(c) -> :endian
      sign_option?(c) -> :sign
      type_option?(c) -> :type
      size_option?(c) -> :size
      :size -> :size
    end
  end
end