lib/conv_case.ex

defmodule ConvCase do
  @moduledoc """
  Functions to convert strings, atoms and map keys between `camelCase`,
  `snake_case` and `kebab-case`.

  Currently this functions do not support UTF-8.

  If this package fits not your requirements then take a look here:

    * [Macro.camelize/1](https://hexdocs.pm/elixir/Macro.html#camelize/1) and
      [Macro.underscore/1](https://hexdocs.pm/elixir/Macro.html#underscore/1)
    * [ReCase](https://github.com/sobolevn/recase) helps you to convert a string
      from any case to any case.
    * [ProperCase](https://github.com/johnnyji/proper_case) an Elixir library that
      converts keys in maps between snake_case and camel_case.
  """

  @underscore ?_
  @hyphen ?-
  @separator [@underscore, @hyphen]

  defguardp is_upper(char) when char >= ?A and char <= ?Z
  defguardp is_lower(char) when not (char >= ?A and char <= ?Z)

  defguardp is_separator(char) when char in @separator

  @doc """
  Converts `camelCase` and `kebab-case` into `snake_case`.

  For strings, the function returns the converted string.

  ## Examples

      iex> ConvCase.to_snake_case("fooBar")
      "foo_bar"

      iex> ConvCase.to_snake_case("foo-bar")
      "foo_bar"

  For atoms, the function returns the converted atom.
  `String.to_existing_atom/1` is used to create "new" atoms.

      iex> ConvCase.to_snake_case(:fooBar)
      :foo_bar

  For lists, the function returns a list with converted values.

      iex> ConvCase.to_snake_case(["fooBar", "foo-bar"])
      ["foo_bar", "foo_bar"]

  For tuples, the function returns a tuple with converted values.

      iex> ConvCase.to_snake_case({"fooBar", "foo-bar"})
      {"foo_bar", "foo_bar"}

  For maps, the function returns a map with converted keys. The type of the key
  will not be changed. New atoms are generated by `String.to_existing_atom/1`.
  The keys of nested maps are also converted.

      iex> ConvCase.to_snake_case(%{fooBar: %{"foo-bar" => "foo-bar"}})
      %{foo_bar: %{"foo_bar" => "foo-bar"}}

  For other types, the function returns the given value.

      iex> ConvCase.to_snake_case(42)
      42

  """
  @spec to_snake_case(any) :: any
  def to_snake_case(string)

  def to_snake_case(""), do: ""

  def to_snake_case(atom)
      when is_atom(atom),
      do:
        atom
        |> Atom.to_string()
        |> to_snake_case()
        |> String.to_existing_atom()

  def to_snake_case(strings)
      when is_list(strings),
      do: Enum.map(strings, &to_snake_case/1)

  def to_snake_case(tuple)
      when is_tuple(tuple),
      do:
        tuple
        |> Tuple.to_list()
        |> Enum.map(&to_snake_case/1)
        |> List.to_tuple()

  def to_snake_case(map)
      when is_map(map),
      do: convert_map(map, &to_snake_case/1)

  def to_snake_case(<<a, b, t::binary>>)
      when is_upper(a) and is_lower(b),
      do: <<to_lower(a), b>> <> do_to_separator_case(t, @underscore)

  def to_snake_case(string)
      when is_binary(string),
      do: do_to_separator_case(string, @underscore)

  def to_snake_case(any), do: any

  @doc """
  Converts `snake_case` and `kebab-case` into `camelCase`.

  For strings, the function returns the converted string.

  ## Examples

      iex> ConvCase.to_camel_case("foo_bar")
      "fooBar"

      iex> ConvCase.to_camel_case("foo-bar")
      "fooBar"

  For atoms, the function returns the converted atom. This function used
  `String.to_existing_atom/1`.

  ## Examples

      iex> ConvCase.to_camel_case(:foo_bar)
      :fooBar

  For lists, the function returns a list with converted values.

  ## Examples

      iex> ConvCase.to_camel_case(["foo_bar", "foo-bar"])
      ["fooBar", "fooBar"]

  For tuples, the function returns a tuple with converted values.

  ## Examples

      iex> ConvCase.to_camel_case({"foo_bar", "foo-bar"})
      {"fooBar", "fooBar"}

  For maps, the function returns a map with converted keys. The type of the key
  will not be changed. New atoms are generated by `String.to_existing_atom/1`.
  Keys of nested maps are converted too.

  ## Examples

      iex> ConvCase.to_camel_case(%{foo_bar: %{"foo-bar" => "foo-bar"}})
      %{fooBar: %{"fooBar" => "foo-bar"}}

  For other types, the function returns the given value.

  ## Examples

      iex> ConvCase.to_camel_case(42)
      42

  """
  @spec to_camel_case(any) :: any
  def to_camel_case(value)

  def to_camel_case(""), do: ""

  def to_camel_case(atom)
      when is_atom(atom),
      do:
        atom
        |> Atom.to_string()
        |> to_camel_case()
        |> String.to_existing_atom()

  def to_camel_case(strings)
      when is_list(strings),
      do: Enum.map(strings, &to_camel_case/1)

  def to_camel_case(tuple)
      when is_tuple(tuple),
      do:
        tuple
        |> Tuple.to_list()
        |> Enum.map(&to_camel_case/1)
        |> List.to_tuple()

  def to_camel_case(map)
      when is_map(map),
      do: convert_map(map, &to_camel_case/1)

  def to_camel_case(<<a, b, t::binary>>)
      when is_separator(a),
      do: <<to_upper(b)>> <> to_camel_case(t)

  def to_camel_case(<<h, t::binary>>), do: <<h>> <> to_camel_case(t)

  def to_camel_case(any), do: any

  @doc """
  Converts `snake_case` and `camelCase` into `kebab-case`.

  For strings, the function returns the converted string.

  ## Examples

      iex> ConvCase.to_kebab_case("foo_bar")
      "foo-bar"

      iex> ConvCase.to_kebab_case("fooBar")
      "foo-bar"

  For atoms, the function returns the converted atom. This function used
  `String.to_existing_atom/1`.

  ## Examples

      iex> ConvCase.to_kebab_case(:foo_bar)
      :"foo-bar"

  For lists, the function returns a list with converted values.

  ## Examples

      iex> ConvCase.to_kebab_case(["foo_bar", "fooBar"])
      ["foo-bar", "foo-bar"]

  For tuples, the function returns a tuple with converted values.

  ## Examples

      iex> ConvCase.to_kebab_case({"foo_bar", "fooBar"})
      {"foo-bar", "foo-bar"}

  For maps, the function returns a map with converted keys. The type of the key
  will not be changed. New atoms are generated by `String.to_existing_atom/1`.
  Keys of nested maps are converted too.

  ## Examples

      iex> ConvCase.to_kebab_case(%{foo_bar: %{"fooBar" => "fooBar"}})
      %{"foo-bar": %{"foo-bar" => "fooBar"}}

  For other types, the function returns the given value.

  ## Examples

      iex> ConvCase.to_kebab_case(42)
      42
  """
  @spec to_kebab_case(any) :: any
  def to_kebab_case(value)

  def to_kebab_case(""), do: ""

  def to_kebab_case(atom)
      when is_atom(atom),
      do:
        atom
        |> Atom.to_string()
        |> to_kebab_case()
        |> String.to_existing_atom()

  def to_kebab_case(strings)
      when is_list(strings),
      do: Enum.map(strings, &to_kebab_case/1)

  def to_kebab_case(tuple)
      when is_tuple(tuple),
      do:
        tuple
        |> Tuple.to_list()
        |> Enum.map(&to_kebab_case/1)
        |> List.to_tuple()

  def to_kebab_case(map)
      when is_map(map),
      do: convert_map(map, &to_kebab_case/1)

  def to_kebab_case(<<a, b, t::binary>>)
      when is_upper(a) and is_lower(b),
      do: <<to_lower(a), b>> <> do_to_separator_case(t, @hyphen)

  def to_kebab_case(string)
      when is_binary(string),
      do: do_to_separator_case(string, @hyphen)

  def to_kebab_case(any), do: any

  # Convert string with given separator.
  defp do_to_separator_case("", _separator), do: ""

  defp do_to_separator_case(<<h, t::binary>>, separator)
       when is_separator(h),
       do: <<separator>> <> do_to_separator_case(t, separator)

  defp do_to_separator_case(<<a, b, t::binary>>, separator)
       when is_lower(a) and is_upper(b),
       do: <<a, separator, to_lower(b)>> <> do_to_separator_case(t, separator)

  defp do_to_separator_case(<<h, t::binary>>, separator),
    do: <<h>> <> do_to_separator_case(t, separator)

  # Convert map keys with the given converter.
  defp convert_map(%{__struct__: _} = struct, _), do: struct

  defp convert_map(map, converter) when is_map(map) do
    for {key, value} <- map,
        into: %{},
        do: {convert_key(key, converter), convert_map(value, converter)}
  end

  defp convert_map(list, converter)
       when is_list(list),
       do: Enum.map(list, &convert_map(&1, converter))

  defp convert_map(map, _converter), do: map

  # Convert key with the given converter.
  defp convert_key(key, converter)
       when is_atom(key),
       do: key |> converter.()

  defp convert_key(key, converter), do: converter.(key)

  # Convert a lowercase character into an uppercase character.
  defp to_upper(char), do: char - 32

  # Convert an uppercase character into a lowercase character.
  defp to_lower(char), do: char + 32
end