Skip to content

Commit

Permalink
Merge pull request #78 from dwyl/truncate-issue-#77
Browse files Browse the repository at this point in the history
PR: `truncate/3` a string to shorten long sentences e.g. `Bio` issue #77
  • Loading branch information
asntc authored Jan 13, 2025
2 parents 906d596 + e71a610 commit 1932b43
Show file tree
Hide file tree
Showing 4 changed files with 174 additions and 11 deletions.
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
95 changes: 89 additions & 6 deletions lib/useful.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -63,7 +64,8 @@ defmodule Useful.MixProject do

defp aliases do
[
c: ["coveralls.html"]
c: ["coveralls.html"],
t: ["test"]
]
end
end
46 changes: 46 additions & 0 deletions test/useful_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 1932b43

Please sign in to comment.