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