lib/string.ex

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

  @moduledoc "String-related functions"

  use Bitwise

  @doc """
  Convert strings and atoms to dash-case (kebab-case) and trims leading and trailing non-alphanumeric characters.

  The following all get converted to `"foo"`: `"foo"`, `"FOO"`, `:foo`.

  The following all get converted to `"foo-bar"`: `"foo-bar"`, `:foo_bar`, `" fooBar "`, `"  ?foo ! bar "`
  """
  @spec dasherize(atom() | binary()) :: binary()
  def dasherize(term) do
    term
    |> Moar.Atom.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]+}i, "-")
    |> String.trim_leading("-")
    |> String.trim_trailing("-")
    |> String.downcase()
  end

  @doc "Truncate `s` to `max_length` by removing the middle of the string"
  @spec inner_truncate(binary(), integer()) :: binary()
  def inner_truncate(nil, _),
    do: nil

  def inner_truncate(s, max_length) 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)}#{String.slice(s, -right_length, right_length)}"
    end
  end

  @doc """
  Compares the two binaries in constant-time to avoid timing attacks.
  See: http://codahale.com/a-lesson-in-timing-attacks/
  """
  @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 "Replace consecutive whitespace characters with a single space"
  @spec squish(binary()) :: binary()
  def squish(nil),
    do: nil

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

  @doc "Add `surrounder` to the beginning and end of `s`"
  @spec surround(binary(), binary()) :: binary()
  def surround(s, surrounder),
    do: surrounder <> s <> surrounder

  @doc "Add `prefix` to the beginning of `s` and `suffix` to the end"
  @spec surround(binary(), binary(), binary()) :: binary()
  def surround(s, prefix, suffix),
    do: prefix <> s <> suffix

  @doc "Convert a string to an integer. Returns `nil` if the argument is `nil` or empty string"
  @spec to_integer(nil | binary()) :: integer()
  def to_integer(nil),
    do: nil

  def to_integer(""),
    do: nil

  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
  """
  @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.or_default(default)

  @doc "Like `String.trim/1` but returns `nil` if the argument is nil"
  @spec trim(nil | binary()) :: 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
end