diff --git a/README.md b/README.md index ea9b30f..83c4d74 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,38 @@ iex> Useful.stringify_tuple(tuple) "ok: example" ``` +### `truncate/3` + +> **truncate**; To shorten (something) by, or as if by, cutting part of it off. +> [wiktionary.org/wiki/truncate](https://en.wiktionary.org/wiki/truncate) + +Returns a truncated version of the `String` according to the desired `length`. +_Useful_ if your displaying an uncertain amount of text in an interface. +E.g. the "bio" field on GitHub can be up **`160 characters`**. +_Most_ `people` don't have a `bio` but some use every character. +If you're displaying profiles in an interface, you want a _predictable_ length. +Usage: + +```elixir +iex> input = "You cannot lose what you never had." +iex> Useful.truncate(input, 18) +"You cannot lose ..." +``` + +The **_optional_ third argument** `terminator` +allows specify any `String` or an _empty_ `String` if you prefer +as the terminator for your truncated text: + +```elixir +iex> input = "do or do not there is no try" +iex> Useful.truncate(input, 12, "") # no ellipsis +"do or do not" + +iex> input = "It was the best of times, it was the worst of times" +iex> Useful.trucate(input, 25, "") +"It was the best of times" +``` + ### `typeof/1` Returns the type of a variable, e.g: "function" or "integer" diff --git a/lib/useful.ex b/lib/useful.ex index 258a467..f9afc07 100644 --- a/lib/useful.ex +++ b/lib/useful.ex @@ -6,7 +6,7 @@ defmodule Useful do @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 stackoverflow.com/questions/31990134 + Inspired by https://stackoverflow.com/questions/31990134 ## Examples @@ -21,11 +21,13 @@ defmodule Useful do 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/issues#52 + # 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: github.com/dwyl/useful/issues/46 + # 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) @@ -176,7 +178,8 @@ defmodule Useful do `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: github.com/dwyl/useful/issues/56 + Alphabetizes the keys for consistency. + See: [useful#56](https://github.com/dwyl/useful/issues/56) ## Examples @@ -242,8 +245,88 @@ defmodule Useful do end @doc """ - `typeof/1` returns the type of a vairable. - Inspired by stackoverflow.com/questions/28377135/check-typeof-variable-in-elixir + `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 diff --git a/mix.exs b/mix.exs index 62c451a..8773399 100644 --- a/mix.exs +++ b/mix.exs @@ -4,8 +4,8 @@ defmodule Useful.MixProject do def project do [ app: :useful, - description: "A collection of useful functions", - version: "1.14.0", + description: "A collection of useful functions for building Elixir apps.", + version: "1.15.0", elixir: "~> 1.10", start_permanent: Mix.env() == :prod, deps: deps(), @@ -17,7 +17,8 @@ defmodule Useful.MixProject do coveralls: :test, "coveralls.detail": :test, "coveralls.json": :test, - "coveralls.html": :test + "coveralls.html": :test, + t: :test ] ] end @@ -46,7 +47,7 @@ defmodule Useful.MixProject do # Plug helper functions: github.com/elixir-plug/plug # Used for %Plug.Upload{} Struct see: #49 & #52 - {:plug, "~> 1.14"} + {:plug, "~> 1.16"} ] end @@ -63,7 +64,8 @@ defmodule Useful.MixProject do defp aliases do [ - c: ["coveralls.html"] + c: ["coveralls.html"], + t: ["test"] ] end end diff --git a/test/useful_test.exs b/test/useful_test.exs index 48f0865..7067827 100644 --- a/test/useful_test.exs +++ b/test/useful_test.exs @@ -263,6 +263,52 @@ defmodule UsefulTest do end end + describe "truncate/3" do + test "truncates the string to the desired length and adds '...' " do + end + + test "Returns the first argument unmodified if NOT a String" do + # don't attempt to truncate an atom: + assert Useful.truncate(:notstring, 42) == :notstring + end + + test "Returns the first argument unmodified if length NOT number" do + # don't attempt to truncate if length is not numeric: + assert Useful.truncate("hello", :not_number) == "hello" + end + + test "Returns the first argument unmodified if char NOT binary" do + # don't attempt to truncate if length is not numeric: + assert Useful.truncate("Hello Alex!", 42, :cat) == "Hello Alex!" + end + + test "Returns early if input is not a valid string e.g: <<0xFFFF::16>>" do + assert Useful.truncate(<<0xFFFF::16>>, 42, "") == <<0xFFFF::16>> + end + + test "Don't truncate if input is less than length" do + assert Useful.truncate("Hello World!", 42) == "Hello World!" + end + + test "Don't truncate mid-word, find the previous whitespace" do + input = "It's supercalifragilisticexpialidocious" + truncated = Useful.truncate(input, 24) + assert truncated == "It's..." + end + + test "Returns the truncated string WITH trailing ellipsis" do + input = "It was a bright cold day in April, and the clocks were striking" + truncated = Useful.truncate(input, 24) + assert truncated == "It was a bright cold day..." + end + + test "Returns the truncated string WITHOUT trailing ellipsis" do + input = "Three things were happening inside the Park on that Saturday" + truncated = Useful.truncate(input, 27, "") + assert truncated == "Three things were happening" + end + end + describe "typeof/1" do test "returns \"atom\" for an :atom" do assert Useful.typeof(:atom) == "atom"