lib/string.ex

defmodule Moar.String do
  # @related [test](/test/string_test.exs)

  @moduledoc "String-related functions."

  import Bitwise

  @type string_case() :: :camel_case | :kebab_case | :lower_camel_case | :snake_case

  @doc """
  Appends `suffix` to `string` unless `string` is blank according to `Moar.Term.blank?/1`.

  ```elixir
  iex> Moar.String.append_unless_blank("foo", "-bar")
  "foo-bar"

  iex> Moar.String.append_unless_blank("", "-bar")
  ""

  iex> Moar.String.append_unless_blank(nil, "-bar")
  nil
  ```
  """
  @spec append_unless_blank(binary() | nil, binary() | nil) :: binary()
  def append_unless_blank(string, suffix) do
    if Moar.Term.present?(string) && Moar.Term.present?(suffix),
      do: string <> suffix,
      else: string
  end

  @doc """
  Compares two strings, returning `:lt`, `:eq`, or `:gt` depending on whether the first arg is less than, equal to, or
  greater than the second arg. Accepts one or more functions that transform the inputs before comparison.

  See `Moar.String.compare?/2` for a version that returns `true` or `false`.

  ```
  iex> Moar.String.compare("foo", "FOO")
  :gt

  iex> Moar.String.compare("foo", "FOO", &String.downcase/1)
  :eq

  iex> Moar.String.compare("foo bar", " FOO    bar ", [&String.downcase/1, &Moar.String.squish/1])
  :eq
  ```
  """
  @spec compare(binary(), binary(), (binary() -> binary()) | [(binary() -> binary())]) :: :lt | :eq | :gt
  def compare(left, right, transformer_fns \\ []) do
    List.wrap(transformer_fns)
    |> Enum.reduce({left, right}, fn
      transformer_fn, {left, right} -> {transformer_fn.(left), transformer_fn.(right)}
    end)
    |> case do
      {left, right} when left < right -> :lt
      {left, right} when left > right -> :gt
      _ -> :eq
    end
  end

  @doc """
  Compares two strings, returning `true` if the first arg is less than or equal to the second arg, or `false` if the
  first arg is greater than the second arg. Accepts one or more functions that transform the inputs before comparison.
  Useful for sorter functions like what is passed into `Enum.sort/2`.

  See `Moar.String.compare/2` for a version that returns `:lt`, `:eq`, or `:gt`.

  ```
  iex> Moar.String.compare?("foo", "FOO")
  false

  iex> Moar.String.compare?("foo", "FOO", &String.downcase/1)
  true

  iex> Moar.String.compare?("foo bar", " FOO    bar ", [&String.downcase/1, &Moar.String.squish/1])
  true
  ```
  """
  @spec compare?(binary(), binary(), (binary() -> binary()) | [(binary() -> binary())]) :: boolean()
  def compare?(left, right, transformer_fns \\ []),
    do: compare(left, right, transformer_fns) in [:lt, :eq]

  @doc """
  Returns the number of leading spaces. Only considers "plain" spaces, not all unicode whitespace.

  ```elixir
  iex> Moar.String.count_leading_spaces("  foo")
  2
  ```
  """
  @spec count_leading_spaces(binary()) :: non_neg_integer()
  def count_leading_spaces(<<" ", rest::binary>>), do: 1 + count_leading_spaces(rest)
  def count_leading_spaces(_), do: 0

  @doc """
  Dasherizes `term`. A shortcut to `slug(term, "-")`.

  See docs for `slug/2`.

  ```elixir
  iex> Moar.String.dasherize("foo bar")
  "foo-bar"
  ```
  """
  @spec dasherize(String.Chars.t() | [String.Chars.t()]) :: binary()
  def dasherize(term),
    do: slug(term, "-")

  @doc """
  Truncate `s` to `max_length` by replacing the middle of the string with `replacement`, which defaults to
  the single unicode character `…`.

  Note that the final length of the string will be `max_length` plus the length of `replacement`.

  ```elixir
  iex> Moar.String.inner_truncate("abcdefghijklmnopqrstuvwxyz", 10)
  "abcde…vwxyz"

  iex> Moar.String.inner_truncate("abcdefghijklmnopqrstuvwxyz", 10, "<==>")
  "abcde<==>vwxyz"
  ```
  """
  @spec inner_truncate(binary(), integer(), binary()) :: binary()
  def inner_truncate(s, max_length, replacement \\ "…")

  def inner_truncate(nil, _, _),
    do: nil

  def inner_truncate(s, max_length, replacement) do
    case String.length(s) <= max_length do
      true ->
        s

      false ->
        left_length = (max_length / 2) |> Float.ceil() |> round()
        right_length = (max_length / 2) |> Float.floor() |> round()
        [String.slice(s, 0, left_length), replacement, String.slice(s, -right_length, right_length)] |> to_string()
    end
  end

  @doc """
  Join multiple items with `joiner`. `join/2` accepts a list of items; `join/3` through `join/6` accept
  individual items for convenience. All items must implement `String.Chars` protocol (they must be
  `to_string`-able).

  ```elixir
  iex> Moar.String.join("-", ["a", "b", "c"])
  "a-b-c"

  iex> Moar.String.join("-", "a", "b", "c")
  "a-b-c"
  ```
  """
  @spec join(String.t(), list() | String.Chars.t()) :: String.t()
  def join(joiner, list) when is_list(list), do: Enum.map_join(list, joiner, &to_string/1)
  def join(_joiner, a), do: a

  @spec join(String.t(), String.Chars.t(), String.Chars.t()) :: String.t()
  def join(joiner, a, b), do: join(joiner, [a, b])

  @spec join(String.t(), String.Chars.t(), String.Chars.t(), String.Chars.t()) :: String.t()
  def join(joiner, a, b, c), do: join(joiner, [a, b, c])

  @spec join(String.t(), String.Chars.t(), String.Chars.t(), String.Chars.t(), String.Chars.t()) :: String.t()
  def join(joiner, a, b, c, d), do: join(joiner, [a, b, c, d])

  @spec join(String.t(), String.Chars.t(), String.Chars.t(), String.Chars.t(), String.Chars.t(), String.Chars.t()) ::
          String.t()
  def join(joiner, a, b, c, d, e), do: join(joiner, [a, b, c, d, e])

  @doc "Creates a lorem ipsum string of length `character_count`."
  @spec lorem(non_neg_integer()) :: String.t()
  def lorem(character_count) do
    ipsum = ~w[
      laborum est id anim mollit deserunt officia qui culpa in sunt proident non cupidatat occaecat sint
      excepteur pariatur nulla fugiat eu dolore cillum esse velit voluptate in reprehenderit in dolor irure
      aute duis consequat commodo ea ex aliquip ut nisi laboris ullamco exercitation nostrud quis veniam
      minim ad enim ut aliqua magna dolore et labore ut incididunt tempor eiusmod do sed elit adipiscing
      consectetur amet sit dolor ipsum lorem
      ]

    Enum.reduce_while(Stream.cycle(ipsum), [], fn word, acc ->
      if IO.iodata_length(acc) > character_count,
        do: {:halt, acc |> Enum.join(" ") |> String.slice(0..(character_count - 1))},
        else: {:cont, [word | acc]}
    end)
  end

  @doc """
  Pluralizes a string.

  When `count` is -1 or 1, returns the second argument (the singular string).

  Otherwise, returns the third argument (the pluralized string), or if the third argument is a function,
  calls the function with the singular string as an argument.

  Options:
  * `:include_number` will include the number in the result (e.g., "4 fishies")

  ```elixir
  iex> Moar.String.pluralize(1, "fish", "fishies")
  "fish"

  iex> Moar.String.pluralize(1, "fish", "fishies", :include_number)
  "1 fish"

  iex> Moar.String.pluralize(2, "fish", "fishies")
  "fishies"

  iex> Moar.String.pluralize(2, "fish", fn singular -> singular <> "ies" end)
  "fishies"

  iex> Moar.String.pluralize(2, "fish", &(&1 <> "ies"), :include_number)
  "2 fishies"
  ```
  """
  @spec pluralize(number(), binary(), binary() | function(), atom() | [atom()]) :: binary()
  def pluralize(count, singular, plural_or_pluralizer, opts \\ []) do
    pluralized =
      cond do
        count in [-1, 1] -> singular
        is_binary(plural_or_pluralizer) -> plural_or_pluralizer
        is_function(plural_or_pluralizer) -> plural_or_pluralizer.(singular)
      end

    if :include_number in List.wrap(opts),
      do: "#{count} #{pluralized}",
      else: pluralized
  end

  @doc """
  Removes all whitespace following a backspace+v escape code.

  Especially useful in test assertions where only some of the whitespace matters.
  ```
  iex> Moar.String.remove_marked_whitespace("one two three\v\t   four five")
  "one two threefour five"
  ```
  """
  @spec remove_marked_whitespace(binary()) :: binary()
  def remove_marked_whitespace(s),
    do: s |> String.replace(~r|\v\s*|, "")

  @doc """
  Compares the two binaries in constant-time to avoid timing attacks.
  See: <http://codahale.com/a-lesson-in-timing-attacks/>.

  ```elixir
  iex> Moar.String.secure_compare("foo", "bar")
  false
  ```
  """
  @spec secure_compare(binary(), binary()) :: boolean()
  def secure_compare(left, right) when is_nil(left) or is_nil(right),
    do: false

  def secure_compare(left, right) when is_binary(left) and is_binary(right),
    do: byte_size(left) == byte_size(right) and secure_compare(left, right, 0)

  defp secure_compare(<<x, left::binary>>, <<y, right::binary>>, acc) do
    xorred = Bitwise.bxor(x, y)
    secure_compare(left, right, acc ||| xorred)
  end

  defp secure_compare(<<>>, <<>>, acc),
    do: acc === 0

  @doc """
  Creates slugs like `foo-bar-123` or `foo_bar` from various input types.

  Converts strings, atoms, and anything else that implements `String.Chars`, plus lists of those things,
  to a single string after removing non-alphanumeric characters, and then joins them with `joiner`.
  Existing occurrences of `joiner` are kept, including leading and trailing ones.

  `dasherize/1` and `underscore/1` are shortcuts that specify a joiner.

  ```elixir
  iex> Moar.String.slug("foo bar", "_")
  "foo_bar"

  iex> Moar.String.slug("foo bar", "+")
  "foo+bar"

  iex> Moar.String.slug(["foo", "bar"], "+")
  "foo+bar"

  iex> Moar.String.slug("_foo bar", "_")
  "_foo_bar"

  iex> ["foo", "FOO", :foo] |> Enum.map(&Moar.String.slug(&1, "-"))
  ["foo", "foo", "foo"]

  iex> ["foo-bar", "foo_bar", :foo_bar, " fooBar ", "  ?foo ! bar  "] |> Enum.map(&Moar.String.slug(&1, "-"))
  ["foo-bar", "foo-bar", "foo-bar", "foo-bar", "foo-bar"]
  ```
  """
  @spec slug(String.Chars.t() | [String.Chars.t()], binary()) :: binary()
  def slug(term, joiner) when is_list(term) do
    Enum.map_join(term, joiner, fn t ->
      t
      |> to_string()
      |> String.replace(~r/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
      |> String.replace(~r/([a-z\d])([A-Z])/, "\\1_\\2")
      |> String.replace(~r{^[^a-z0-9#{joiner}]+}i, "", global: false)
      |> String.replace(~r{[^a-z0-9#{joiner}]+$}i, "", global: false)
      |> String.replace(~r{[^a-z0-9#{joiner}]+}i, joiner)
      |> String.downcase()
    end)
  end

  def slug(term, joiner),
    do: slug([term], joiner)

  @doc """
  Trims spaces from the beginning and end of a string, and replaces consecutive whitespace characters with a single
  space.

  ```elixir
  iex> Moar.String.squish("  foo   bar  \tbaz ")
  "foo bar baz"
  ```
  """
  @spec squish(binary()) :: binary()
  def squish(nil),
    do: nil

  def squish(s),
    do: s |> trim() |> Elixir.String.replace(~r/\s+/, " ")

  @doc """
  Adds `surrounder` to the beginning and end of `s`.

  ```elixir
  iex> Moar.String.surround("Hello", "**")
  "**Hello**"
  ```
  """
  @spec surround(binary(), binary()) :: binary()
  def surround(s, surrounder),
    do: surrounder <> s <> surrounder

  @doc """
  Adds `prefix` to the beginning of `s` and `suffix` to the end.

  ```elixir
  iex> Moar.String.surround("Hello", "“", "”")
  "“Hello”"
  ```
  """
  @spec surround(binary(), binary(), binary()) :: binary()
  def surround(s, prefix, suffix),
    do: prefix <> s <> suffix

  @doc """
  Change the case of a string.

  ```elixir
  iex> Moar.String.to_case("text_with_case", :camel_case)
  "TextWithCase"
  iex> Moar.String.to_case("textWithCase", :camel_case)
  "TextWithCase"
  iex> Moar.String.to_case("some random text", :camel_case)
  "SomeRandomText"

  iex> Moar.String.to_case("text_with_case", :lower_camel_case)
  "textWithCase"
  iex> Moar.String.to_case("textWithCase", :lower_camel_case)
  "textWithCase"
  iex> Moar.String.to_case("some random text", :lower_camel_case)
  "someRandomText"

  iex> Moar.String.to_case("text_with_case", :kebab_case)
  "text-with-case"
  iex> Moar.String.to_case("textWithCase", :kebab_case)
  "text-with-case"
  iex> Moar.String.to_case("some random text", :kebab_case)
  "some-random-text"

  iex> Moar.String.to_case("text_with_case", :snake_case)
  "text_with_case"
  iex> Moar.String.to_case("textWithCase", :snake_case)
  "text_with_case"
  iex> Moar.String.to_case("some random text", :snake_case)
  "some_random_text"
  ```
  """
  @spec to_case(binary(), string_case()) :: binary()
  def to_case(s, :camel_case),
    do: s |> to_case(:lower_camel_case) |> String.replace(~r[^(\w)], fn char -> String.upcase(char) end)

  def to_case(s, :kebab_case),
    do: s |> slug("-")

  def to_case(s, :lower_camel_case),
    do: s |> to_case(:snake_case) |> String.replace(~r[_(\w)], fn "_" <> char -> String.upcase(char) end)

  def to_case(s, :snake_case),
    do: s |> slug("_")

  @doc """
  Converts a string to an integer. Returns `nil` if the argument is `nil` or empty string.
  Returns the argument without complaint if it is already an integer.

  ```elixir
  iex> Moar.String.to_integer("12,345")
  12_345

  iex> Moar.String.to_integer("")
  nil

  iex> Moar.String.to_integer(12_345)
  12_345
  ```
  """
  @spec to_integer(nil | binary()) :: integer()
  def to_integer(nil),
    do: nil

  def to_integer(""),
    do: nil

  def to_integer(integer) when is_integer(integer),
    do: integer

  def to_integer(s) when is_binary(s),
    do: s |> trim() |> Elixir.String.replace(",", "") |> Elixir.String.to_integer()

  @doc """
  Like `to_integer/1` but with options:
  * `:lenient` option removes non-digit characters first
  * `default:` option specifies a default in case `s` is nil

  ```elixir
  iex> Moar.String.to_integer("USD$25", :lenient)
  25

  iex> Moar.String.to_integer(nil, default: 0)
  0
  ```
  """
  @spec to_integer(binary(), :lenient | [default: binary()]) :: integer()
  def to_integer(s, :lenient) when is_binary(s),
    do: s |> String.replace(~r|\D|, "") |> Elixir.String.to_integer()

  def to_integer(s, default: default),
    do: s |> to_integer() |> Moar.Term.presence(default)

  @doc "Like `String.trim/1` but returns `nil` if the argument is nil."
  @spec trim(nil | binary()) :: nil | binary()
  def trim(nil), do: nil
  def trim(s) when is_binary(s), do: Elixir.String.trim(s)

  @doc """
  Truncates `s` at the last instance of `at`, causing the string to be at most `limit` characters.

  ```elixir
  iex> Moar.String.truncate_at("I like apples. I like bananas. I like cherries.", ".", 35)
  "I like apples. I like bananas."
  ```
  """
  def truncate_at(s, at, limit) do
    s
    |> String.graphemes()
    |> Enum.take(limit)
    |> Enum.reverse()
    |> Enum.split_while(fn c -> c != at end)
    |> case do
      {a, []} -> a
      {[], b} -> b
      {_a, b} -> b
    end
    |> Enum.reverse()
    |> Enum.join("")
  end

  @doc """
  Underscores `term`. Will be deprecated soon; use `to_case(:snake_case)` instead..

  ```elixir
  iex> Moar.String.underscore("foo bar")
  "foo_bar"
  ```
  """
  @spec underscore(String.Chars.t() | [String.Chars.t()]) :: binary()
  def underscore(term),
    do: slug(term, "_")

  @doc """
  Unindents a string by finding the smallest indentation of the string, and removing that many spaces from each line.
  Only considers "plain" spaces, not all unicode whitespace.

  ```elixir
  iex> \"""
  ...>      ant
  ...>    bat
  ...>      cat
  ...>        dog
  ...> \""" |> Moar.String.unindent()
  "  ant\nbat\n  cat\n    dog\n"
  ```
  """
  @spec unindent(binary()) :: binary()
  def unindent(s) do
    lines = String.split(s, "\n")
    min_spaces = lines |> Enum.filter(&Moar.Term.present?/1) |> Enum.map(&count_leading_spaces(&1)) |> Enum.min()
    unindent(s, min_spaces)
  end

  @doc """
  Unindents a string by `count` spaces. Only considers "plain" spaces, not all unicode whitespace.

  ```elixir
  iex> \"""
  ...> foo
  ...>   bar
  ...>     baz
  ...> \""" |> Moar.String.unindent(2)
  "foo\nbar\n  baz\n"
  ```
  """
  @spec unindent(binary(), non_neg_integer()) :: binary()
  def unindent(s, count) do
    s
    |> String.split("\n")
    |> Enum.map_join("\n", &String.slice(&1, min(count, count_leading_spaces(&1))..-1//1))
  end
end