lib/piece_table.ex

defmodule PieceTable do
  @moduledoc """
  The PieceTable module provides a naive implementation of the piece-table data structure
  for efficient text editing operations.

  A piece-table represents an editable buffer of text as a sequence of non-overlapping
  pieces, allowing efficient inserts, deletes, and modifications.

  ## Usage

  ```elixir
  iex> table = PieceTable.new!("Hello, world!")
  iex> table = PieceTable.insert!(table, "you ", 7)
  iex> table = PieceTable.delete!(table, 10, 6)
  iex> table = PieceTable.undo!(table)
  iex> table = PieceTable.redo!(table)
  iex> PieceTable.get_text!(table)
  "Hello, you!"
  ```
  """

  @type t :: %__MODULE__{
          original: String.t(),
          result: String.t(),
          applied: [tuple()],
          to_apply: [tuple()]
        }

  @enforce_keys [:original, :result, :applied]
  defstruct original: "", result: "", applied: [], to_apply: []

  alias PieceTable.Change

  @doc """
  Creates a new PieceTable struct. This is intended the only method to build it.

  ## Parameters 
  - `text` (String.t()): The initial content of the piece table. 

  ## Returns
  - `{:ok, %PieceTable{}}`
  - `{:error, "original text is not a string"}`

  ## Examples

      iex> PieceTable.new("test")
      {:ok, %PieceTable{original: "test", result: "test", applied: []}}

  """
  @spec new(String.t()) :: {:ok, PieceTable.t()} | {:error, :wrong_type_original_text}
  def new(text) when is_binary(text) do
    pt = %__MODULE__{
      original: text,
      # For fast access I'll keep the resulting string after all operations are applied
      # it's important to keep this always up to date otherwise the whole implementation 
      # will break
      result: text,
      # applied will contain the tuples of edits applied to the text 
      applied: [],
      # to_apply will contain the tuples of edits undone onto the text
      to_apply: []
    }

    {:ok, pt}
  end

  def new(_), do: {:error, :wrong_type_original_text}

  @doc """
  Creates a new PieceTable struct. This is intended the only method to build it.

  ## Parameters 
  - `text` (String.t()): The initial content of the piece table. 

  ## Returns `%PieceTable{}`

  ## Examples

      iex> PieceTable.new!("test")
      %PieceTable{original: "test", result: "test", applied: []}

  """
  @spec new!(String.t()) :: PieceTable.t()
  def new!(text), do: text |> new() |> raise_or_return()

  @doc """
  Inserts text into the PieceTable at a specified position.

  ## Parameters

  - `table` (PieceTable.t()): The PieceTable where the text will be inserted.
  - `text` (String.t()): The text to be inserted.
  - `position` (integer()): The position where the text should be inserted. The position is zero-based.

  ## Returns

  - `{:ok, PieceTable.t()}`
  - `{:error, "invalid arguments"}`

  ## Examples

      iex> {:ok, table} = PieceTable.new("Initial content")
      iex> PieceTable.insert(table, ", before", 15)
      {:ok, %PieceTable{original: "Initial content", result: "Initial content, before", applied: [%PieceTable.Change{change: :ins, text: ", before", position: 15}]}}
  """
  defguard is_valid_insert_input(text, position)
           when is_binary(text) and is_integer(position) and position >= 0

  @spec insert(PieceTable.t(), String.t(), integer(), any()) ::
          {:ok, PieceTable.t()} | {:error, :unapplied_changes | :invalid_arguments}
  def insert(table, text, position, blame \\ nil)
  # do nothing on empty string
  def insert(%__MODULE__{} = table, "", _, _), do: {:ok, table}

  # matches if no unapplied changes
  def insert(%__MODULE__{to_apply: []} = table, text, position, blame)
      when is_valid_insert_input(text, position) do
    change = Change.new!(:ins, text, position, blame)
    {:ok, update_piece_table(table, change)}
  end

  # matches if unapplied changes -> error
  def insert(%__MODULE__{}, text, position, _) when is_valid_insert_input(text, position),
    do: {:error, :unapplied_changes}

  def insert(_, _, _, _), do: {:error, :invalid_arguments}

  @doc """
  Inserts text into the PieceTable at a specified position.

  ## Parameters

  - `table` (PieceTable.t()): The PieceTable where the text will be inserted.
  - `text` (String.t()): The text to be inserted.
  - `position` (integer()): The position where the text should be inserted. The position is zero-based.

  ## Returns

  - `PieceTable.t()`

  ## Examples

      iex> table = PieceTable.new!("Initial content")
      iex> PieceTable.insert!(table, ", before", 15)
      %PieceTable{original: "Initial content", result: "Initial content, before", applied: [%PieceTable.Change{change: :ins, text: ", before", position: 15}]}
  """
  @spec insert!(PieceTable.t(), String.t(), integer(), any()) :: PieceTable.t()
  def insert!(table, text, position, blame \\ nil),
    do: table |> insert(text, position, blame) |> raise_or_return()

  @doc """
  Deletes a substring from the PieceTable starting at a specified position and with a specified length.

  ## Parameters

  - `table` (PieceTable.t()): The PieceTable from which the substring will be deleted.
  - `position` (integer()): The starting position of the substring to be deleted. The position is zero-based.
  - `length` (integer()): The length of the substring to be deleted.

  ## Returns

  - `{:ok, PieceTable.t()}`
  - `{:error, "invalid arguments"}`

  ## Examples

      iex> {:ok, table} = PieceTable.new("Initial content")
      iex> PieceTable.delete(table, 4, 3)
      {:ok, %PieceTable{original: "Initial content", result: "Init content", applied: [%PieceTable.Change{change: :del, text: "ial", position: 4}]}}
  """
  defguard valid_delete_input?(position, length)
           when is_integer(position) and position >= 0 and is_integer(length) and length > 0

  @spec delete(PieceTable.t(), integer(), integer(), any()) ::
          {:ok, PieceTable.t()} | {:error, :unapplied_changes | :invalid_arguments}
  def delete(table, position, length, blame \\ nil)
  # do nothing if deleting 0 chars
  def delete(%__MODULE__{} = table, _, 0, _), do: {:ok, table}

  # matches is no unapplied changes
  def delete(%__MODULE__{to_apply: []} = table, position, length, blame)
      when valid_delete_input?(position, length) do
    # To allow reverting a change I'm saving the string instead of the length, so a remove becomes an insert on undo.
    # length will be simply calculated from the length of the string
    text = String.slice(table.result, position, length)
    change = Change.new!(:del, text, position, blame)
    {:ok, update_piece_table(table, change)}
  end

  # matches if unapplied changes -> error
  def delete(%__MODULE__{}, position, length, _) when valid_delete_input?(position, length),
    do: {:error, :unapplied_changes}

  def delete(_, _, _, _), do: {:error, :invalid_arguments}

  @doc """
  Deletes a substring from the PieceTable starting at a specified position and with a specified length.

  ## Parameters

  - `table` (PieceTable.t()): The PieceTable from which the substring will be deleted.
  - `position` (integer()): The starting position of the substring to be deleted. The position is zero-based.
  - `length` (integer()): The length of the substring to be deleted.

  ## Returns

  - `PieceTable.t()`

  ## Examples

      iex> {:ok, table} = PieceTable.new("Initial content")
      iex> PieceTable.delete!(table, 4, 3)
      %PieceTable{original: "Initial content", result: "Init content", applied: [%PieceTable.Change{change: :del, text: "ial", position: 4}]}
  """
  @spec delete!(PieceTable.t(), integer(), integer(), any()) :: PieceTable.t()
  def delete!(table, position, length, blame \\ nil),
    do: table |> delete(position, length, blame) |> raise_or_return()

  @doc """
  Retrieves the entire text content from the PieceTable.

  ## Parameters

  - `table` (PieceTable.t()): The PieceTable from which the text will be retrieved.

  ## Returns

  - `{:ok, String.t()}`
  - `{:error, "not a PieceTable struct"}`

  ## Examples

      iex> {:ok, table} = PieceTable.new("Initial content")
      iex> table = PieceTable.delete!(table, 4, 3)
      iex> PieceTable.get_text(table)
      {:ok, "Init content"}
  """
  @spec get_text(PieceTable.t()) :: {:ok, String.t()} | {:error, :not_a_piece_table}
  def get_text(%__MODULE__{} = table), do: {:ok, table.result}

  def get_text(_), do: {:error, :not_a_piece_table}

  @doc """
  Retrieves the entire text content from the PieceTable.

  ## Parameters

  - `table` (PieceTable.t()): The PieceTable from which the text will be retrieved.

  ## Returns

  - `String.t()`

  ## Examples

      iex> {:ok, table} = PieceTable.new("Initial content")
      iex> table = PieceTable.delete!(table, 4, 3)
      iex> PieceTable.get_text!(table)
      "Init content"
  """
  @spec get_text!(PieceTable.t()) :: String.t()
  def get_text!(table), do: table |> get_text() |> raise_or_return()

  @doc """
  Undo the latest change applied to the string.

  ## Parameters

  - `table` (PieceTable.t()): The PieceTable from which the text will be retrieved.

  ## Returns

  - `{:ok, PieceTable.t()}`
  - `{:first, PieceTable.t()}`
  - `{:error, "not a PieceTable struct"}`


  ## Examples

      iex> {:ok, table} = PieceTable.new("Initial content")
      iex> table = PieceTable.delete!(table, 4, 3)
      iex> PieceTable.undo(table)
      {:ok, %PieceTable{original: "Initial content", result: "Initial content", applied: [], to_apply: [%PieceTable.Change{change: :del, text: "ial", position: 4}]}}
  """
  @spec undo(PieceTable.t()) ::
          {:ok, PieceTable.t()} | {:first, PieceTable.t()} | {:error, :not_a_piece_table}
  # exec only if there is at least 1 change already applied
  # accessing the head of a linked list with `[change | rest]` is an O(1) operation
  def undo(%__MODULE__{to_apply: to_apply, applied: [change | rest]} = table) do
    # transform an edit into its opposite: insert -> remove, remove -> insert
    result = change |> Change.invert!() |> apply_change(table)

    # prepend the reverted change to to_apply list and replace applied with the remaining operations
    updated_table = struct(table, %{result: result, to_apply: [change | to_apply], applied: rest})

    {:ok, updated_table}
  end

  # do nothing if no changes are applied
  def undo(%__MODULE__{applied: []} = table), do: {:first, table}
  def undo(_), do: {:error, :not_a_piece_table}

  @doc """
  Undo the latest change applied to the string.

  ## Parameters

  - `table` (PieceTable.t()): The PieceTable from which the text will be retrieved.

  ## Returns

  - `PieceTable.t()`


  ## Examples

      iex> {:ok, table} = PieceTable.new("Initial content")
      iex> table = PieceTable.delete!(table, 4, 3)
      iex> PieceTable.undo!(table)
      %PieceTable{original: "Initial content", result: "Initial content", applied: [], to_apply: [%PieceTable.Change{change: :del, text: "ial", position: 4}]}
  """
  @spec undo!(PieceTable.t()) :: PieceTable.t()
  def undo!(table), do: table |> undo() |> raise_or_return()

  @doc """
  Redo the next change previously undone to the string.

  ## Parameters

  - `table` (PieceTable.t()): The PieceTable from which the text will be retrieved.

  ## Returns

  - `{:ok, PieceTable.t()}`
  - `{:last, PieceTable.t()}`
  - `{:error, "not a PieceTable struct"}`

  ## Examples

      iex> {:ok, table} = PieceTable.new("Initial content")
      iex> table = PieceTable.delete!(table, 4, 3)
      iex> {:ok, table} = PieceTable.undo(table)
      iex> PieceTable.redo(table)
      {:ok, %PieceTable{original: "Initial content", result: "Init content", applied: [%PieceTable.Change{change: :del, text: "ial", position: 4}]}}
  """
  @spec redo(PieceTable.t()) ::
          {:ok, PieceTable.t()} | {:last, PieceTable.t()} | {:error, :not_a_piece_table}
  # exec only if there is at least 1 change to apply
  # accessing the head of a linked list with `[change | rest]` is an O(1) operation
  def redo(%__MODULE__{to_apply: [change | rest], applied: applied} = table) do
    result = change |> apply_change(table)

    # prepend the reapplied change to applied list and replace to_apply with the remaining operations
    updated_table = struct(table, %{result: result, applied: [change | applied], to_apply: rest})

    {:ok, updated_table}
  end

  # do nothing if all changes are already applied
  def redo(%__MODULE__{to_apply: []} = table), do: {:last, table}
  def redo(_), do: {:error, :not_a_piece_table}

  @doc """
  Redo the next change previously undone to the string.

  ## Parameters

  - `table` (PieceTable.t()): The PieceTable from which the text will be retrieved.

  ## Returns

  - `PieceTable.t()`

  ## Examples

      iex> {:ok, table} = PieceTable.new("Initial content")
      iex> table = PieceTable.delete!(table, 4, 3)
      iex> {:ok, table} = PieceTable.undo(table)
      iex> PieceTable.redo!(table)
      %PieceTable{original: "Initial content", result: "Init content", applied: [%PieceTable.Change{change: :del, text: "ial", position: 4}]}
  """
  @spec redo!(PieceTable.t()) :: PieceTable.t()
  def redo!(table), do: table |> redo() |> raise_or_return()

  # handle responses, raises if :error atom
  defp raise_or_return({status, result}) when status in [:ok, :last, :first], do: result
  defp raise_or_return({:error, msg}), do: raise(ArgumentError, inspect(msg))

  defp update_piece_table(table, %Change{} = change) do
    # get raw attributes
    attrs =
      Map.from_struct(table)
      # Lists in Elixir are linked lists. For efficiency prepend to the list of changes
      |> update_in([:applied], &[change | &1])
      # apply changes
      |> Map.put(:result, apply_change(change, table))

    struct(table, attrs)
  end

  # on insert
  defp apply_change(%Change{change: :ins, text: edit, position: pos}, %{result: str}) do
    [String.slice(str, 0, pos), edit, String.slice(str, pos..-1)]
    |> Enum.reduce("", fn string, acc ->
      # Joining strings in this way is more efficient as it doesn't make copies (unlike `<>`)
      IO.iodata_to_binary([acc, string])
    end)
  end

  # on deletion
  defp apply_change(%Change{change: :del, text: edit, position: pos}, %{result: str}) do
    start = pos + String.length(edit)

    [String.slice(str, 0, pos), String.slice(str, start..-1)]
    |> Enum.reduce("", fn string, acc ->
      # Joining strings in this way is more efficient as it doesn't make copies (unlike `<>`)
      IO.iodata_to_binary([acc, string])
    end)
  end
end