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 "  "
  @bytes_count 16
  @newline "\n"
  @header "   offset    0 1  2 3  4 5  6 7  8 9  A B  C D  E F    printable data"

  @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: 32, binaries: :as_binaries}
  def on(opts \\ @default_hexdump_inspect_opts) do
    opts = get_opts(opts)

    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
      hexdump_output(term, opts)
    end
  end

  @doc """
  When printable limit is smaller than the size of binary we only display
  the amount of bytes plus last line of the binary
  """
  def hexdump_output(term, opts \\ @default_hexdump_inspect_opts) do
    opts = get_opts(opts)
    size = byte_size(term)
    last_line = rem(size, 16)
    last_line = if last_line == 0, do: 16, else: last_line

    if size > opts.printable_limit do
      IO.ANSI.light_black() <>
        @header <>
        @newline <>
        format_hexdump_output(:binary.part(term, 0, opts.printable_limit)) <>
        generate_last_line(term, size, last_line)
    else
      IO.ANSI.light_black() <>
        @header <>
        @newline <>
        format_hexdump_output(term, opts) <> @newline
    end
  end

  defp generate_last_line(term, size, last_line) do
    @newline <>
      @column_divider <>
      "**" <>
      @newline <>
      (term
       |> :binary.part(size - last_line, last_line)
       |> format_hexdump_output()
       |> String.replace(
         "000000",
         String.pad_leading("#{trunc((size - last_line) / 16)}", 6, "0")
       ))
  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(@bytes_count)
      |> take_or_infinity(opts.printable_limit)
      |> Stream.map(
        &for char <- &1 do
          <<ascii>> = char
          encoded = Base.encode16(char)

          case ascii do
            # zero byte
            0x00 -> [IO.ANSI.light_black(), encoded, "⋄"]
            # space
            0x20 -> [IO.ANSI.reset(), encoded, " "]
            # other whitespace
            ascii when ascii in [0x09, 0x0A, 0x0C, 0x0D] -> [IO.ANSI.green(), encoded, "_"]
            # non ascii
            ascii when ascii >= 0x80 -> [IO.ANSI.light_red(), encoded, "×"]
            # ascii printable
            ascii when ascii in @printable_range -> [IO.ANSI.cyan(), encoded, char]
            # ascii non printable
            _ -> [IO.ANSI.yellow(), encoded, "•"]
          end
        end
      )
      |> Stream.with_index()
      |> Stream.map(fn {chunk, index} ->
        {binary_representation, original_text} = build_line_text(chunk)

        [
          IO.ANSI.light_black(),
          @column_divider,
          # generates the first column 00001
          String.pad_leading("#{index}", 6, "0"),
          # last 0 in the offset column
          "0:",
          @column_divider,
          binary_representation,
          @column_divider,
          original_text,
          IO.ANSI.reset()
        ]
      end)
      |> Enum.join(@newline)

    StringIO.close(string_io)
    result
  end

  @doc """
  Replace terminal escape sequences with empty string.
  Used for removing coloring from the generated string.
  """
  def remove_escapes(string) do
    Regex.replace(~r<\x1B([@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]>, string, "")
  end

  defp get_opts(opts) do
    case opts do
      opts when is_struct(opts) -> Map.from_struct(opts)
      opts when is_list(opts) -> Map.new(opts)
      opts -> opts
    end
  end

  defp maybe_pad_chunk(chunk) when length(chunk) == @bytes_count, do: chunk

  defp maybe_pad_chunk(chunk) do
    # add padding to last line when it has less that 16 bytes

    chunk ++ Enum.map(1..(@bytes_count - length(chunk)), fn _ -> ["", "  ", ""] end)
  end

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

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