lib/base45.ex

defmodule Utils do
  @moduledoc false
  def invert(m) do
    {_array, result} = Enum.map_reduce(m, %{},
      fn {k, v}, acc -> {nil, Map.put(acc, v, k)} end)
    result
  end
end  

defmodule NilValuesError do
  @moduledoc false
  defexception message: "nil value(s)"
end
  
defmodule InvalidBase45Error do
  @moduledoc false
  defexception message: "Invalid Base45 string"
end
  
defmodule InputOutputError do
  @moduledoc false
  defexception message: "Input/output error"
end
  
defmodule Base45 do
  @moduledoc """
  A library to encode and decode binaries using the base 45 encoding scheme (specified in RFC TODO).

  """

  @value_to_code %{0 => "0",
      1 => "1",
      2 => "2",
      3 => "3",
      4 => "4",
      5 => "5",
      6 => "6",
      7 => "7",
      8 => "8",
      9 => "9",
      10 => "A",
      11 => "B",
      12 => "C",
      13 => "D",
      14 => "E",
      15 => "F",
      16 => "G",
      17 => "H",
      18 => "I",
      19 => "J",
      20 => "K",
      21 => "L",
      22 => "M",
      23 => "N",
      24 => "O",
      25 => "P",
      26 => "Q",
      27 => "R",
      28 => "S",
      29 => "T",
      30 => "U",
      31 => "V",
      32 => "W",
      33 => "X",
      34 => "Y",
      35 => "Z",
      36 => " ",
      37 => "$",
      38 => "%",
      39 => "*",
      40 => "+",
      41 => "-",
      42 => ".",
      43 => "/",
      44 => ":"}

  @doc false
  @spec value_to_code() :: map
  def value_to_code() do
    @value_to_code
  end
  
  @code_to_value Utils.invert(@value_to_code)

  @doc """
    Encode binary data as Base45

## Examples

      iex> Base45.encode("Hello")
      "%69 VDL2"
       
      iex(1)> Base45.encode(<<0, 1, 127, 129, 255>>)
      "100G5GU5"
"""
  @spec encode(binary) :: String.t
  # Erlang has a limit for its function byte_size, you get an
  # ArgumentError if you exceed it. pow(2, 24)?
  def encode(data) do
    couples(data)
    |> Enum.map(&to16/1)
    |> Enum.map_reduce("", fn x, s -> {nil, s <> to_b45(x)} end)
    |> elem(1)
  end

  @doc """
  Encodes a file (which must be open, the argument has to be a file handle or an atom
  such as :stdio)
  """
  @spec encode_file(atom | pid) :: String.t
  def encode_file(file) do
     #  Dialyzer claims "The function call will not succeed.
     #
     #    IO.binread(_file :: any(), :all)
     #
     #    breaks the contract
     #    (device(), :eof | :line | non_neg_integer()) :: iodata() | nodata()
     # which seems quite wrong, the documentation of binread clearly authorizes :all
    data = IO.binread(file, :all)
    case data do
      {:error, _reason} -> raise InputOutputError
      :eof -> ""
      other -> Base45.encode(other) 
    end
  end

  @doc """
    Decode Base45 strings to binary data. Returns nil if the string is not valid Base45.

 ## Examples

      iex> Base45.decode("%69 VDL2")
      "Hello"

      iex(1)> Base45.decode("100G5GU5")
      <<0, 1, 127, 129, 255>>

      iex(1)> Base45.decode("===")
      nil

 """
  # Unlike encode, decode can fail.
  @spec decode(String.t) :: binary
  def decode(str) do
    decoding(str)
  rescue
    _e in NilValuesError -> nil
    _e in InvalidBase45Error -> nil
  end
  
  @doc """
    Decode Base45 strings to binary data. Raises an exception NilValuesError if the string is not valid Base45.

 ## Examples

      iex> Base45.decode!("%69 VDL2")
      "Hello"

      iex(1)> Base45.decode!("===")
      ** (NilValuesError) nil value(s)

 """
  @spec decode!(String.t) :: binary
  def decode!(str) do
    decoding(str)
    # Let the exceptions pass
  end

  @doc false
  @spec from_b45([integer]) :: {integer, integer}
  def from_b45([one, two, three]) do
    {2, three*45*45 + two*45 + one}
  end
  def from_b45([one, two]) do
    {1, two*45 + one}
  end
  def from_b45(_other) do
    raise InvalidBase45Error, "No proper chunking"
  end
  
  @doc false
  @spec two_bytes({integer, integer}) :: [byte]
  def two_bytes(v) do
    {size, n} = v
    if size != 1 and size != 2 do
      raise "Internal error, #{size} is not an accepted value for size"
    end
    high = trunc(n/256)
    if high >= 256 do
      raise InvalidBase45Error, "#{n} is too large"
    end
    low = trunc(n-256*high)
    if size == 2 do
      [high, low]
    else
      [low]
    end
  end

  @doc false
  @spec validate_values([any]) :: [integer]
  def validate_values(a) do
    Enum.map(a,
      fn item ->
	if is_nil(item) do
	  raise NilValuesError
	else
	  item
	end
      end)
    a
  end

  @doc false
  @spec decoding(String.t) :: binary
  def decoding(str) do
        String.graphemes(str)
    |> Enum.map(fn c -> @code_to_value[c] end)
    |> validate_values
    |> Enum.chunk_every(3)
    |> Enum.map(&from_b45/1)
    |> Enum.map(&two_bytes/1)
    |> Enum.map_reduce(<<>>, fn c, acc -> {nil, acc <> :binary.list_to_bin(c)} end)
    |> elem(1)
  end

  @doc false
  def encoding(c) do
    @value_to_code[c]
  end

  @doc false
  def table([c, d, e]) do
    encoding(c) <> encoding(d) <> encoding(e)
  end
  def table([c, d]) do
    encoding(c) <> encoding(d)
  end

  @doc false
  def couples(b) do
    case byte_size(b) do
      0 -> []
      1 -> [[:binary.first(b)]]
      _ -> [[:binary.at(b, 0), :binary.at(b, 1)] |
	   couples(:binary.part(b, {2, byte_size(b) - 2}))]
    end
  end

  @doc false
  @spec to_b45({integer, integer}) :: String.t
  def to_b45({size, n}) do
    e = trunc(n/(45*45))
    d = trunc((n - (e*45*45))/45)
    c = n - (d*45) - (e*45*45)
    case size do
      2 -> table([c, d, e])
      1 -> table([c, d])
    end
  end

  @doc false
  @spec to16([integer]) :: {byte, integer}
  def to16([h, l]) do
     {2, h*256 + l}
  end
  def to16([l]) do
     {1, l}
  end
  
end