Current section

Files

Jump to
useful lib useful.ex
Raw

lib/useful.ex

defmodule Useful do
@moduledoc """
A Library of `Useful` functions for building `Elixir` Apps.
"""
@doc """
`atomize_map_keys/1` converts a `Map` with different keys
to a map with just atom keys. Works recursively for nested maps.
Inspired by https://stackoverflow.com/questions/31990134
## Examples
iex> Useful.atomize_map_keys(%{"name" => "alex", id: 1})
%{id: 1, name: "alex"}
iex> Useful.atomize_map_keys(%{"name" => "alex", data: %{ "age" => 17}})
%{name: "alex", data: %{age: 17}}
"""
def atomize_map_keys(%Date{} = value), do: value
def atomize_map_keys(%Time{} = value), do: value
def atomize_map_keys(%DateTime{} = value), do: value
def atomize_map_keys(%NaiveDateTime{} = value), do: value
# Avoid Plug.Upload.__struct__/0 is undefined compilation error
# [useful#52](https://github.com/dwyl/useful/issues/52)
# alias Plug.Upload
def atomize_map_keys(%Plug.Upload{} = value), do: value
# handle lists in maps:
# [useful#46](https://github.com/dwyl/useful/issues/46)
def atomize_map_keys(items) when is_list(items) do
for i <- items do
atomize_map_keys(i)
end
end
def atomize_map_keys(map) when is_map(map) do
for {key, val} <- map, into: %{} do
cond do
is_atom(key) -> {key, atomize_map_keys(val)}
true -> {String.to_atom(key), atomize_map_keys(val)}
end
end
end
def atomize_map_keys(value), do: value
@doc """
`empty_dir_contents/1` delete all files including any directories
recursively in a dir but not the dir itself.
Very happy for anyone to refactor this function to something pretty.
"""
def empty_dir_contents(dir) do
cond do
is_nil(dir) ->
{:error, "dir supplied to Useful.empty_dir_contents/1 is nil"}
not File.dir?(dir) ->
{:error, "#{dir} arg to Useful.empty_dir_contents/1 not a directory"}
true ->
# read contents of directory:
{:ok, list} = File.ls(dir)
Enum.each(list, fn f ->
path = Path.join([dir, f]) |> Path.expand()
cond do
# delete all dirs recursively:
File.dir?(path) ->
File.rm_rf(path)
# delete any files in the dir:
File.exists?(path) ->
File.rm(path)
end
end)
{:ok, dir}
end
end
@doc """
`flatten_map/1` flattens a `Map` of any depth/nesting for easier processing.
Deeply nested maps are denoted by "__" (double underscore) e.g:
`%{name: Alex, detail: %{age: 17}}` becomes `%{name: Alex, detail__age: 17}`
this makes it easy to see what the data structure was before flattening.
Map keys are converted to Atom for simpler access and consistency.
Inspired by: https://stackoverflow.com/questions/39401947/flatten-nested-map
## Examples
iex> map = %{name: "alex", data: %{age: 17, height: 185}}
iex> Useful.flatten_map(map)
%{data__age: 17, data__height: 185, name: "alex"}
"""
def flatten_map(map) when is_map(map) do
map
|> to_list_of_tuples
|> Enum.map(&key_to_atom/1)
|> Enum.into(%{})
end
@doc """
`get_in_default/3` Proxies `Kernel.get_in/2`
but allows setting a `default` value as the 3rd argument.
## Examples
iex> map = %{name: "alex", data: %{age: 17, height: 185}}
iex> Useful.get_in_default(map, [:data, :age])
17
iex> Useful.get_in_default(map, [:data, :iq], 180)
180
iex> Useful.get_in_default(nil, [:unhappy, :path], "Happy!")
"Happy!"
"""
def get_in_default(map, keys, default \\ nil) do
# https://hexdocs.pm/elixir/1.14/Kernel.html#get_in/2
# Enum.each(keys, )
try do
case get_in(map, keys) do
nil -> default
result -> result
end
rescue
_ ->
default
end
end
@doc """
`list_tuples_to_unique_keys/1` turns a list of tuples
with the same key into a list of tuples with unique keys.
Useful when dealing with "multipart" forms that upload multiple files.
## Example
iex> parts = [{"file", "header", "pic1.png"}, {"file", "header", "pic2.png"}]
iex> Useful.list_tuples_to_unique_keys(parts)
[{"file-1", "header", "pic1.png"}, {"file-2", "header", "pic2.png"}]
"""
def list_tuples_to_unique_keys(parts) do
key = parts |> hd() |> elem(0)
new_keys = Enum.map(1..length(parts), &(key <> "-#{&1}"))
Enum.zip_reduce([parts, new_keys], [], fn [elt, new_key], acc ->
[
elt |> Tuple.delete_at(0) |> Tuple.insert_at(0, new_key)
| acc
]
end)
|> Enum.sort()
end
@doc """
`remove_item_from_list/2` removes a given `item` from a `list` in any position.
## Examples
iex> list = ["They'll", "never", "take", "our", "freedom!"]
iex> Useful.remove_item_from_list(list, "never")
["They'll", "take", "our", "freedom!"]
"""
def remove_item_from_list(list, item) do
if Enum.member?(list, item) do
i = Enum.find_index(list, fn it -> it == item end)
List.delete_at(list, i)
else
list
end
end
@doc """
`stringy_map/1` converts a `Map` of any depth/nesting into a string.
Deeply nested maps are denoted by "__" (double underscore). See flatten_map/1
for more details.
Alphabetizes the keys for consistency.
See: [useful#56](https://github.com/dwyl/useful/issues/56)
## Examples
iex> map = %{name: "alex", data: %{age: 17, height: 185}}
iex> Useful.stringify_map(map)
"data__age: 17, data__height: 185, name: alex"
"""
def stringify_map(map) when is_nil(map), do: "nil"
def stringify_map(map) when is_map(map) do
map
|> flatten_map()
|> Enum.sort()
|> Enum.map(&stringify_tuple/1)
|> Enum.join(", ")
end
@doc """
`stringify_tuple/1` stringifies a `Tuple` with arbitrary values.
Handy when you want to print out a tuple during debugging.
## Examples
iex> tuple = {:name, "alex"}
iex> Useful.stringify_tuple(tuple)
"name: alex"
"""
def stringify_tuple({key, values}) when is_list(values) do
text = Enum.join(values, ", ")
stringify_tuple({key, "\"#{text}\""})
end
def stringify_tuple({key, value}) do
"#{key}: #{value}"
end
# Recap: https://elixir-lang.org/getting-started/basic-types.html#tuples
defp to_list_of_tuples(map) do
map
|> Enum.map(&process/1)
|> List.flatten()
end
# avoids the error "** (Protocol.UndefinedError) protocol Enumerable
# not implemented for ~U[2017-08-05 16:34:08.003Z] of type DateTime"
defp process({key, %Date{} = date}), do: {key, date}
defp process({key, %DateTime{} = datetime}), do: {key, datetime}
# process nested maps
defp process({key, sub_map}) when is_map(sub_map) do
for {sub_key, value} <- flatten_map(sub_map) do
{"#{key}__#{sub_key}", value}
end
end
# catch-all for any type of key/value
defp process({key, value}), do: {key, value}
# Converts the {key: value} with Atom key for simpler access
defp key_to_atom({key, value}) do
{String.to_atom("#{key}"), value}
end
@doc """
`truncate/3` truncates an `input` (`String`) to desired `length` (`Number`).
_Optional_ third param `terminator` defines what comes after truncated text.
The default is "..." but any alternative can be defined; see examples below.
Don't cut a string mid-word e.g: "I like to eat shiitaki mushrooms"
should not be truncated to "I like to eat shiit..."
Rather, it should truncate to: "I like to eat ..."
I'm sure you can think of more examples, but you get the idea.
## Examples
iex> input = "A room without books is like a body without a soul."
iex> Useful.truncate(input, 29)
"A room without books is like..."
iex> input = "do or do not there is no try"
iex> Useful.truncate(input, 12, "") # no ellipsis
"do or do not"
"""
# Header with default value for terminator
def truncate(input, length, terminator \\ "...")
def truncate(input, _length, _terminator) when not is_binary(input) do
# return the input unmodified
input
end
def truncate(input, length, _terminator) when not is_number(length) do
# return the input unmodified if length is NOT a number
input
end
def truncate(input, _length, terminator) when not is_binary(terminator) do
# return the input unmodified
input
end
def truncate(input, length, terminator) do
cond do
# avoid processing invalid binaries, return input early:
# hexdocs.pm/elixir/1.12/String.html#valid?/1
!String.valid?(input) ->
input
# input is less than length, return full input early:
String.length(input) <= length ->
input
# input is valid and longer than `length`, attempt to truncate it:
true ->
# Slice the input string at the end of `length`:
sliced = String.slice(input, 0..(length - 1))
# dbg(sliced)
# Get character at the position of `length` in the input string:
char_at = String.at(input, length)
# Check if character at end of the truncated string is whitespace:
sliced =
if Regex.match?(~r/\p{Zs}/u, char_at) do
sliced
else
# Character at the end of the truncated string is NOT whitespace
# since we don't want to cut a word in half, we instead find a space.
# Find the last whitespace character nearest (before) `length`:
# Regex: https://elixirforum.com/t/detect-char-whitespace/26735/5
# Try it in iex:
# > Regex.scan(~r/\p{Zs}/u, "foo bar baz", return: :index)
# > [[{3, 1}], [{7, 1}]]
[{index, _}] =
Regex.scan(~r/\p{Zs}/u, sliced, return: :index)
|> List.last()
String.slice(input, 0..(index - 1))
end
"#{sliced}#{terminator}"
end
end
@doc """
`typeof/1` returns the type of a variable.
Inspired by https://stackoverflow.com/questions/28377135/typeof-var-elixir
## Examples
iex> Useful.typeof(:atom)
"atom"
iex> bin = "hello"
iex> Useful.typeof(bin)
"binary"
iex> bitstr = <<1::3>>
iex> Useful.typeof(bitstr)
"bitstring"
iex> Useful.typeof(:true)
"boolean"
iex> pi = 3.14159
iex> Useful.typeof(pi)
"float"
iex> fun = fn (a, b) -> a + b end
iex> Useful.typeof(fun)
"function"
iex> Useful.typeof(&Useful.typeof/1)
"function"
iex> int = 42
iex> Useful.typeof(int)
"integer"
iex> list = [1,2,3,4]
iex> Useful.typeof(list)
"list"
iex> map = %{:foo => "bar", "hello" => :world}
iex> Useful.typeof(map)
"map"
iex> Useful.typeof(nil)
"nil"
iex> pid = spawn(fn -> 1 + 2 end)
iex> Useful.typeof(pid)
"pid"
iex> port = Port.open({:spawn, "cat"}, [:binary])
iex> Useful.typeof(port)
"port"
iex> ref = :erlang.make_ref
iex> Useful.typeof(ref)
"reference"
iex> tuple = {:name, "alex"}
iex> Useful.typeof(tuple)
"tuple"
"""
types =
~w[boolean binary bitstring float function integer list map nil pid port reference tuple atom]
for type <- types do
def typeof(x) when unquote(:"is_#{type}")(x), do: unquote(type)
end
# No idea how to test this. Do you? ¯\_(ツ)_/¯
# coveralls-ignore-start
def typeof(_) do
"unknown"
end
# coveralls-ignore-stop
end