lib/hexdump.ex

defmodule Hexdump do
  @moduledoc """
  Hexdump makes it easier to work with binary data

  By default elixir display binaries as a list of integers in the range from 0..255

  This make it problematic to spot binary patterns

  our example binary:

  ```
  term = <<0,1,2,3,4>> <> "123abcdefxyz" <> <<253,254,255>>
  ```

  ```
  <<0, 1, 2, 3, 4, 49, 50, 51, 97, 98, 99, 100, 101, 102, 120, 121, 122, 253, 254,
  255>>
  ```

  You can pass a param to IO.inspect(term, base: :hex) to print the same term as hex,
  this makes it a bit easier.

  ```
  <<0x0, 0x1, 0x2, 0x3, 0x4, 0x31, 0x32, 0x33, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66,
  0x78, 0x79, 0x7A, 0xFD, 0xFE, 0xFF>>
  ```

  With Hexdump you can see similar output like hex editors have:

  The first column is offset

  second shows a row of 16 bits in binary

  last column shows printable characers

  ```
  0000000  0001 0203 0431 3233 6162 6364 6566 7879   .....123abcdefxy
  0000010  7AFD FEFF                                 z...
  ```

  You can switch between hexdump output by calling:

  ```
  Hexdump.on()
  Hexdump.off()
  Hexdump.on(binaries: :infer)
  Hexdump.on(binaries: :as_strings)
  ```
  """
  @printable_range 0x20..0x7F
  @column_divider "  "
  @newline "\n"
  @header "   offset    0 1  2 3  4 5  6 7  8 9  A B  C D  E F    printable data\n"

  @doc """
  Restores the standard inspect function
  """
  def off do
    Inspect.Opts.default_inspect_fun(&Inspect.inspect/2)
  end

  @doc """
  Enables the custom inspect function
  """
  @default_hexdump_inspect_opts %Inspect.Opts{printable_limit: 5, binaries: :as_binaries}
  def on(opts \\ @default_hexdump_inspect_opts) do
    opts =
      case opts do
        opts when is_struct(opts) -> Map.from_struct(opts)
        opts when is_list(opts) -> Map.new(opts)
        opts -> opts
      end

    Inspect.Opts.default_inspect_fun(&hexdump_inspect_fun(&1, struct(&2, opts)))
  end

  @doc """
  Custom inspect function
  """
  def hexdump_inspect_fun(term, opts) when not is_binary(term) do
    Inspect.inspect(term, %{opts | base: :hex})
  end

  def hexdump_inspect_fun(term, opts) do
    %Inspect.Opts{binaries: bins, printable_limit: printable_limit} = opts

    if bins == :as_strings or
         (bins == :infer and String.printable?(term, printable_limit)) do
      Inspect.inspect(term, opts)
    else
      @newline <> format_hexdump_output(term, opts) <> @newline
    end
  end

  @doc """
  Formatter used in the custom inspect function
  colors meaning:

   - grey: zero byte 0x00
   - green: whitespace
   - yellow: ascii non printable
   - red: non ascii char
   - cyan: printable character
  """
  def format_hexdump_output(term, opts \\ @default_hexdump_inspect_opts) do
    {:ok, string_io} = StringIO.open(term)

    result =
      string_io
      |> IO.binstream(1)
      |> Stream.chunk_every(16)
      |> take_or_infinity(opts.printable_limit)
      |> Stream.map(
        &for char <- &1 do
          <<ascii>> = char

          cond do
            ascii == 0x00 ->
              [IO.ANSI.light_black(), Base.encode16(char), "⋄"]

            ascii == 0x20 ->
              [IO.ANSI.reset(), Base.encode16(char), " "]

            ascii in [0x09, 0x0A, 0x0C, 0x0D] ->
              [IO.ANSI.green(), Base.encode16(char), "_"]

            ascii > 0x7F ->
              [IO.ANSI.light_red(), Base.encode16(char), "×"]

            Enum.member?(
              @printable_range,
              ascii
            ) ->
              [IO.ANSI.cyan(), Base.encode16(char), char]

            true ->
              [IO.ANSI.yellow(), Base.encode16(char), "•"]
          end
        end
      )
      |> Stream.with_index()
      |> Enum.map_join(@newline, fn {chunk, index} ->
        length = length(chunk)

        chunk =
          if length < 16 do
            chunk ++ Enum.map(1..(16 - length), fn _ -> ["", "  ", ""] end)
          else
            chunk
          end

        {binary_representation, original_text} =
          chunk
          |> Enum.with_index()
          |> Enum.map(fn {[ascii_color, binary, printable], index} ->
            space = if rem(index, 2) == 1, do: " ", else: ""
            {[ascii_color, binary, space], [ascii_color, printable]}
          end)
          |> Enum.unzip()

        [
          IO.ANSI.light_black(),
          if index == 0 do
            @header
          else
            []
          end,
          @column_divider,
          # generates the first column 00001
          String.pad_leading("#{index}", 6, "0"),
          # last 0 and divider in the first column
          "0:",
          @column_divider,
          # empty spaces for the last row when it's not full width
          # String.pad_trailing(chunk, 40, " "),
          binary_representation,
          @column_divider,
          original_text,
          IO.ANSI.reset()
        ]
      end)

    StringIO.close(string_io)
    result
  end

  defp take_or_infinity(stream, :infinity), do: stream
  defp take_or_infinity(stream, limit), do: Stream.take(stream, limit)
end