Skip to main content

lib/fsst.ex

defmodule FSST do
  @moduledoc """
  Fast Static Symbol Tables compression for Elixir.

  `FSST` is a pure Elixir port with an optional Rustler backend planned around
  [`fsst-rs`](https://docs.rs/fsst-rs/latest/fsst/). The API follows typical
  Elixir conventions: `train/2` returns `{:ok, table}` or `{:error, reason}`,
  while `train!/2`, `compress!/3`, and `decompress!/3` raise on failure.
  """

  alias FSST.Table

  @type table :: Table.t()
  @type reason ::
          :backend_unavailable | :invalid_input | :invalid_sample | :truncated_escape | term()

  @doc """
  Trains an FSST table from representative binary samples.
  """
  @spec train([binary()], keyword()) :: {:ok, table()} | {:error, reason()}
  def train(samples, opts \\ []) do
    opts
    |> backend()
    |> then(& &1.train(samples, opts))
  end

  @doc """
  Trains an FSST table or raises when training fails.
  """
  @spec train!([binary()], keyword()) :: table()
  def train!(samples, opts \\ []) do
    case train(samples, opts) do
      {:ok, table} -> table
      {:error, reason} -> raise ArgumentError, "could not train FSST table: #{inspect(reason)}"
    end
  end

  @doc """
  Compresses a binary with a table returned by `train/2`.
  """
  @spec compress(table(), binary(), keyword()) :: {:ok, binary()} | {:error, reason()}
  def compress(table, input, opts \\ [])

  def compress(%Table{} = table, input, opts) do
    table
    |> table_backend(opts)
    |> then(& &1.compress(table, input))
  end

  def compress(_table, _input, _opts), do: {:error, :invalid_input}

  @doc """
  Compresses a binary or raises on failure.
  """
  @spec compress!(table(), binary(), keyword()) :: binary()
  def compress!(table, input, opts \\ []) do
    case compress(table, input, opts) do
      {:ok, compressed} -> compressed
      {:error, reason} -> raise ArgumentError, "could not compress input: #{inspect(reason)}"
    end
  end

  @doc """
  Decompresses a binary with the same table used for compression.
  """
  @spec decompress(table(), binary(), keyword()) :: {:ok, binary()} | {:error, reason()}
  def decompress(table, input, opts \\ [])

  def decompress(%Table{} = table, input, opts) do
    table
    |> table_backend(opts)
    |> then(& &1.decompress(table, input))
  end

  def decompress(_table, _input, _opts), do: {:error, :invalid_input}

  @doc """
  Decompresses a binary or raises on failure.
  """
  @spec decompress!(table(), binary(), keyword()) :: binary()
  def decompress!(table, input, opts \\ []) do
    case decompress(table, input, opts) do
      {:ok, decompressed} -> decompressed
      {:error, reason} -> raise ArgumentError, "could not decompress input: #{inspect(reason)}"
    end
  end

  @doc """
  Returns the backend module selected for the current options and runtime.
  """
  @spec backend(keyword()) :: module()
  def backend(opts \\ []) do
    case Keyword.get(opts, :backend, :auto) do
      :auto -> auto_backend()
      :pure -> FSST.Pure
      :rust -> FSST.Rust
      module when is_atom(module) -> module
    end
  end

  @doc false
  @spec compress_sample([binary()], keyword()) :: {:ok, table()} | {:error, reason()}
  def compress_sample(samples, opts \\ []), do: train(samples, opts)

  defp auto_backend do
    if FSST.Rust.available?(), do: FSST.Rust, else: FSST.Pure
  end

  defp table_backend(_table, opts) when opts != [], do: backend(opts)
  defp table_backend(%Table{backend: backend}, _opts), do: backend
end