lib/exiffer/io/buffer.ex

defmodule Exiffer.IO.Buffer do
  @moduledoc """
  Documentation for `Exiffer.IO.Buffer`.

  A buffer with read access to the file source.

  When matching on the buffer's raw data, it is up to the user to use ensure/2
  so that enough data has been read.
  """

  require Logger

  @enforce_keys ~w(io_device)a
  defstruct [
    :io_device,
    data: <<>>,
    position: 0,
    remaining: 0,
    read_ahead: 1000,
    status: :ok
  ]

  def offset_buffer(buffer, offset) do
    Exiffer.OffsetBuffer.new(buffer, offset)
  end

  def new_from_binary(binary, opts \\ []) do
    direction = Keyword.get(opts, :direction, :read)
    {:ok, fd} = :file.open(binary, [:ram, :binary, direction])

    initialize(fd, opts)
  end

  def new(filename, opts \\ []) do
    direction = Keyword.get(opts, :direction, :read)
    open_opts = [:binary, direction]
    {:ok, fd} = File.open(filename, open_opts)

    initialize(fd, opts)
  end

  defp initialize(fd, opts) do
    direction = Keyword.get(opts, :direction, :read)
    read_ahead = Keyword.get(opts, :read_ahead, 1000)
    buffer = %__MODULE__{io_device: fd, read_ahead: read_ahead}
    if direction == :read do
      ensure(buffer, read_ahead)
    else
      buffer
    end
  end

  def seek(%__MODULE__{io_device: io_device} = buffer, position) do
    finish = buffer.position + buffer.remaining

    {data, remaining} =
      if position >= buffer.position && position < finish do
        count = position - buffer.position
        <<_skip::binary-size(count), rest::binary>> = buffer.data
        remaining = buffer.remaining - count
        correct_position = position + remaining
        {:ok, _position} = :file.position(io_device, correct_position)
        {rest, remaining}
      else
        {:ok, _position} = :file.position(io_device, position)
        {<<>>, 0}
      end

    struct!(buffer, data: data, position: position, remaining: remaining)
    |> ensure(buffer.read_ahead)
  end

  def consume(%__MODULE__{} = buffer, count) do
    %__MODULE__{data: data, position: position, remaining: remaining} =
      buffer = ensure(buffer, count)

    available = if remaining >= count, do: count, else: remaining
    <<consumed::binary-size(available), rest::binary>> = data
    new_position = position + available

    buffer =
      struct!(buffer, data: rest, position: new_position, remaining: remaining - available)
      |> ensure(buffer.read_ahead)

    {consumed, buffer}
  end

  def skip(%__MODULE__{} = buffer, count) do
    seek(buffer, buffer.position + count)
  end

  @doc """
  Read some bytes without changing the current buffer position.

  If the bytes are in the current read buffer, simply return them.
  Otherwise, position the buffer, read the bytes, then reposition the buffer
  to the previous position.
  """
  def random(buffer, read_position, count)

  def random(
        %__MODULE__{data: data, position: position, remaining: remaining},
        read_position,
        count
      )
      when read_position > position and read_position + count < position + remaining do
    start = read_position - position
    <<_before::binary-size(start), result::binary-size(count), _rest::binary>> = data
    result
  end

  def random(%__MODULE__{} = buffer, read_position, count) do
    %__MODULE__{io_device: io_device, position: position, remaining: remaining} = buffer
    {:ok, _position} = :file.position(io_device, read_position)

    result =
      case IO.binread(io_device, count) do
        :eof ->
          nil

        chunk ->
          chunk
      end

    end_of_current_buffer = position + remaining
    {:ok, _position} = :file.position(io_device, end_of_current_buffer)
    result
  end

  def tell(buffer), do: buffer.position

  @copy_chunk_size 1_000_000

  def copy(%__MODULE__{} = input, %__MODULE__{} = output) do
    case consume(input, @copy_chunk_size) do
      {<<chunk::binary-size(@copy_chunk_size)>>, input} ->
        length = byte_size(chunk)
        Logger.debug("#{__MODULE__}.copy/2 - chunk, #{length} bytes")
        :ok = write(output, chunk)
        copy(input, output)

      {chunk, _input} ->
        length = byte_size(chunk)
        Logger.debug("#{__MODULE__}.copy/2 - final chunk, #{length} bytes")
        :ok = write(output, chunk)
        nil
    end
  end

  def write(%__MODULE__{io_device: io_device}, binary) do
    :ok = IO.binwrite(io_device, binary)
  end

  def close(%__MODULE__{io_device: io_device}) do
    :ok = File.close(io_device)
  end

  def ensure(%__MODULE__{remaining: remaining} = buffer, amount)
  when remaining > amount do
    buffer
  end

  def ensure(%__MODULE__{remaining: remaining, read_ahead: read_ahead} = buffer, amount) do
    new_length = max(amount, read_ahead)
    needed = new_length - remaining
    read(buffer, needed)
  end

  defp read(%__MODULE__{io_device: io_device, data: data, remaining: remaining} = buffer, amount) do
    case IO.binread(io_device, amount) do
      :eof ->
        Logger.debug("Buffer.read EOF")
        struct!(buffer, status: :eof)

      chunk ->
        bytes_read = byte_size(chunk)
        data = <<data::binary, chunk::binary>>
        struct!(buffer, data: data, remaining: remaining + bytes_read)
    end
  end

  defimpl Exiffer.Buffer do
    alias Exiffer.IO.Buffer

    def offset_buffer(buffer, offset) do
      Buffer.offset_buffer(buffer, offset)
    end

    def consume(buffer, count) do
      Buffer.consume(buffer, count)
    end

    def seek(buffer, position) do
      Buffer.seek(buffer, position)
    end

    def skip(buffer, count) do
      Buffer.skip(buffer, count)
    end

    def random(buffer, read_position, count) do
      Buffer.random(buffer, read_position, count)
    end

    def tell(buffer) do
      Buffer.tell(buffer)
    end
  end
end