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(<>, <>, 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