lib/namor.ex

defmodule Namor do
  @moduledoc """
  A subdomain-safe name generator. Check out the [README](readme.html) to get started.
  """

  alias Namor.Helpers

  @type dictionary_type :: :default | :rugged
  @type salt_type :: :numbers | :letters | :mixed
  @type_generate_options quote(
                           do: [
                             words: integer,
                             salt: integer,
                             salt_type: salt_type,
                             separator: binary
                           ]
                         )

  @strip_nonalphanumeric_pattern ~r/[^a-zA-Z0-9]/
  @valid_subdomain_pattern ~r/^[\w](?:[\w-]{0,61}[\w])?$/

  @reserved Namor.Helpers.get_dict!("reserved.txt")
  @dictionaries %{
    default: Namor.Helpers.get_dict!(:default),
    rugged: Namor.Helpers.get_dict!(:rugged)
  }

  @doc false
  defmacro __using__(_opts) do
    quote do
      # TODO: Allow custom dictionaries to be passed through `opts`
    end
  end

  @doc """
  Get Namor's dictionaries.
  """
  @spec get_dictionaries :: %{binary => Namor.Dictionary.t()}
  def get_dictionaries, do: @dictionaries

  @doc """
  Get Namor's reserved word list.
  """
  @spec get_reserved :: [binary]
  def get_reserved, do: @reserved

  @doc """
  Generates a random name from a Namor dictionary.

  Returns `{:ok, name}` for a successfully generated name, or
  `{:error, reason}` if something goes wrong. See [Custom Dictionaries](README.md#custom-dictionaries)
  for instructions on how to use a custom dictionary.

  ## Options

    * `:words` - Number of words to generate. Must be <=4. Defaults to `2`.
    * `:salt` - Length of the salt to append. Must be >=0. Defaults to `0`.
    * `:salt_type` - Whether the salt should contain numbers, letters, or both. Defaults to `:mixed`.
    * `:separator` - String to use as a separator between words. Defaults to `-`.
    * `:dictionary` - Namor dictionary to use. Defaults to `:default`.

  ## Error Reasons

    * `:dict_not_found` - Could not find the specified dictionary.

  ## Example

      iex> require Namor
      Namor

      iex> Namor.generate(words: 3, dictionary: :rugged)
      {:ok, "savage-whiskey-stain"}

  """
  @spec generate([unquote_splicing(@type_generate_options), dictionary: dictionary_type]) ::
          {:ok, binary} | {:error, atom}

  defmacro generate(opts \\ []) when is_list(opts) do
    {dictionary, opts} = Keyword.pop(opts, :dictionary, :default)

    quote do
      case Namor.get_dictionaries() |> Map.get(unquote(dictionary)) do
        nil -> {:error, :dict_not_found}
        dict -> Namor.generate(unquote(opts), dict)
      end
    end
  end

  @doc """
  Generates a random name from a custom dictionary.

  Returns `{:ok, name}` for a successfully generated name. Takes the
  same options as `generate/1` with the exception of `:dictionary`.
  """
  @spec generate(unquote(@type_generate_options), Namor.Dictionary.t()) :: {:ok, binary}

  def generate(opts, %Namor.Dictionary{} = dict) when is_list(opts) do
    words = Keyword.get(opts, :words, 2)
    salt = Keyword.get(opts, :salt, 0)
    salt_type = Keyword.get(opts, :salt_type, :mixed)
    separator = Keyword.get(opts, :separator, "-") |> to_string()

    name =
      words
      |> Helpers.get_pattern()
      |> Enum.map_join(separator, &(Map.get(dict, &1) |> Enum.random()))

    {:ok, if(salt > 0, do: with_salt(name, salt, separator, salt_type), else: name)}
  end

  @doc """
  Appends a salt to a value.

  If you want to use a custom charlist for the salt, use
  `Namor.Helpers.get_salt/2` instead.
  """
  @spec with_salt(binary, integer, binary, salt_type) :: binary

  def with_salt(value, length, separator \\ "-", type \\ :mixed) do
    chars =
      case type do
        :numbers -> '0123456789'
        :letters -> 'abcdefghijklmnopqrstuvwxyz'
        :mixed -> 'abcdefghijklmnopqrstuvwxyz0123456789'
      end

    value <> separator <> Helpers.get_salt(length, chars)
  end

  @doc """
  Checks whether a value exists in Namor's list of reserved words.

  See `reserved?/2` for more info. See [Custom Dictionaries](README.md#custom-dictionaries)
  for instructions on how to use a custom reserved word list.
  """
  @spec reserved?(binary) :: boolean

  defmacro reserved?(value) when is_binary(value),
    do: quote(do: Namor.reserved?(unquote(value), Namor.get_reserved()))

  @doc """
  Checks whether a value exists in a provided list of reserved words.

  Before checking, the value is stripped of all special characters.
  So for example, `log-in` will still return true if `["login"]` is
  passed to `reserved`.
  """
  @spec reserved?(binary, [binary]) :: boolean

  def reserved?(value, reserved) when is_binary(value) and is_list(reserved),
    do: Enum.member?(reserved, Regex.replace(@strip_nonalphanumeric_pattern, value, ""))

  @doc """
  Checks whether a given value is a valid subdomain.

  Valid subdomains contain no special characters other than `-`, and
  are 63 characters or less.
  """
  @spec subdomain?(binary) :: boolean

  def subdomain?(value) when is_binary(value),
    do: Regex.match?(@valid_subdomain_pattern, value)
end