Packages
expression
2.5.4
2.49.1
2.49.0
2.48.0
2.47.5
2.47.4
2.47.3
2.47.2
2.47.1
2.47.0
2.46.0
2.45.1
2.45.0
2.44.1
2.44.0
2.43.0
2.42.1
2.41.4
2.41.3
2.41.2
2.41.0
2.40.0
2.38.0
2.37.0
2.36.2
2.36.1
2.36.0
2.35.0
2.34.0
2.33.1
2.33.0
2.32.1
2.31.6
2.31.3
2.31.0
2.30.0
2.28.0
2.27.3
2.27.2
2.27.1
2.27.0
2.26.1
2.26.0
2.25.0
2.24.2
2.24.1
2.24.0
2.23.9
2.23.8
2.23.7
2.23.6
2.23.4
2.23.3
2.23.2
2.23.1
2.23.0
2.22.2
2.22.0
2.21.0
2.20.0
2.19.0
2.18.0
2.17.0
2.16.0
2.14.0
2.13.0
2.12.0
2.11.0
2.10.1
2.10.0
2.9.0
2.8.0
2.6.0
2.5.8
2.5.7
2.5.6
2.5.5
2.5.4
2.5.3
2.5.2
2.5.1
2.5.0
2.4.1
2.4.0
2.3.2
2.3.1
2.3.0
2.2.1
2.2.0
2.1.1
2.1.0
2.0.5
2.0.4
2.0.3
2.0.2
2.0.1
2.0.0
1.1.1
1.1.0
1.0.0
0.7.2
0.7.1
0.7.0
0.6.0
0.5.0
0.4.3
0.4.2
0.4.1
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.1
0.1.0
A Excel like expression parser, compatible with FLOIP Expression language.
Current section
Files
Jump to
Current section
Files
lib/expression/callbacks/standard.ex
defmodule Expression.Callbacks.Standard do
@moduledoc """
The function callbacks for the standard function set available
in FLOIP expressions.
This should be relatively swappable with another implementation.
The only requirement is the `handle/3` function.
FLOIP functions are case insensitive. All functions in this callback
module are implemented as lowercase names.
Some functions accept a variable amount of arguments. Elixir doesn't
support variable arguments in functions.
If a function accepts a variable number of arguments the convention
is to call the `<function_name>_vargs/2` callback where the context
is given as the first argument and the argument list as a second
argument.
Reserved names such as `and`, `if`, and `or` are suffixed with an
underscore.
"""
import Expression.Callbacks.EvalHelpers
use Expression.Autodoc
@punctuation_pattern ~r/\s*[,:;!?.-]\s*|\s/
@doc """
Defines a new date value
"""
@expression_doc doc: "Construct a date from year, month, and day integers",
expression: "date(year, month, day)",
context: %{
"year" => 2022,
"month" => 1,
"day" => 31
},
result: ~D[2022-01-31]
def date(ctx, year, month, day) do
[year, month, day] = eval_args!([year, month, day], ctx)
fields = [
calendar: Calendar.ISO,
year: year,
month: month,
day: day,
time_zone: "Etc/UTC",
zone_abbr: "UTC"
]
struct(Date, fields)
end
@doc """
Calculates a new datetime based on the offset and unit provided.
The unit can be any of the following values:
* "Y" for years
* "M" for months
* "W" for weeks
* "D" for days
* "h" for hours
* "m" for minutes
* "s" for seconds
Specifying a negative offset results in date calculations back in time.
# Example
iex> Expression.evaluate!("@datetime_add(date(2022, 11, 1), 1, \\"Y\\")")
~U[2023-11-01 00:00:00.000000Z]
iex> Expression.evaluate!("@datetime_add(date(2022, 11, 1), 1, \\"M\\")")
~U[2022-12-01 00:00:00.000000Z]
iex> Expression.evaluate!("@datetime_add(date(2022, 11, 1), 1, \\"W\\")")
~U[2022-11-08 00:00:00.000000Z]
iex> Expression.evaluate!("@datetime_add(date(2022, 11, 1), 1, \\"D\\")")
~U[2022-11-02 00:00:00.000000Z]
iex> Expression.evaluate!("@datetime_add(date(2022, 11, 1), 1, \\"h\\")")
~U[2022-11-01 01:00:00.000000Z]
iex> Expression.evaluate!("@datetime_add(date(2022, 11, 1), 1, \\"m\\")")
~U[2022-11-01 00:01:00.000000Z]
iex> Expression.evaluate!("@datetime_add(date(2022, 11, 1), 1, \\"s\\")")
~U[2022-11-01 00:00:01.000000Z]
# Examples with leap year handling
iex> Expression.evaluate!("@datetime_add(date(2020, 02, 28), 1, \\"D\\")")
~U[2020-02-29 00:00:00.000000Z]
iex> Expression.evaluate!("@datetime_add(date(2021, 02, 28), 1, \\"D\\")")
~U[2021-03-01 00:00:00.000000Z]
# Examples with negative offsets
iex> Expression.evaluate!("@datetime_add(date(2020, 02, 29), -1, \\"D\\")")
~U[2020-02-28 00:00:00.000000Z]
iex> Expression.evaluate!("@datetime_add(date(2021, 03, 1), -1, \\"D\\")")
~U[2021-02-28 00:00:00.000000Z]
"""
@expression_doc doc: "Calculates a new datetime based on the offset and unit provided.",
expression: "datetime_add(datetime, offset, unit)",
context: %{
"datetime" => ~U[2022-07-31 00:00:00Z],
"offset" => "1",
"unit" => "M"
},
result: ~U[2022-08-31 00:00:00Z]
def datetime_add(ctx, datetime, offset, unit) do
datetime = extract_datetimeish(eval!(datetime, ctx))
[offset, unit] = eval_args!([offset, unit], ctx)
case unit do
"Y" -> Timex.shift(datetime, years: offset)
"M" -> Timex.shift(datetime, months: offset)
"W" -> Timex.shift(datetime, weeks: offset)
"D" -> Timex.shift(datetime, days: offset)
"h" -> Timex.shift(datetime, hours: offset)
"m" -> Timex.shift(datetime, minutes: offset)
"s" -> Timex.shift(datetime, seconds: offset)
end
end
@doc """
Converts date stored in text to an actual date object and
formats it using `strftime` formatting.
It will fallback to "%Y-%m-%d %H:%M:%S" if no formatting is supplied
"""
@expression_doc doc: "Convert a date from a piece of text to a formatted date string",
expression: "datevalue(\"2022-01-01\")",
result: %{"__value__" => "2022-01-01 00:00:00", "date" => ~D[2022-01-01]}
@expression_doc doc: "Convert a date from a piece of text and read the date field",
expression: "datevalue(\"2022-01-01\").date",
result: ~D[2022-01-01]
@expression_doc doc: "Convert a date value and read the date field",
expression: "datevalue(date(2022, 1, 1)).date",
result: ~D[2022-01-01]
def datevalue(ctx, date, format) do
[date, format] = eval!([date, format], ctx)
date = extract_dateish(date)
%{"__value__" => Timex.format!(date, format, :strftime), "date" => date}
end
def datevalue(ctx, date) do
date = extract_dateish(eval!(date, ctx))
%{
"__value__" => Timex.format!(date, "%Y-%m-%d %H:%M:%S", :strftime),
"date" => date
}
end
@doc """
Returns only the day of the month of a date (1 to 31)
"""
@expression_doc doc: "Getting today's day of the month",
expression: "day(date(2022, 9, 10))",
result: 10
@expression_doc doc: "Getting today's day of the month",
expression: "day(now())",
result: DateTime.utc_now().day
def day(ctx, date) do
%{day: day} = eval!(date, ctx)
day
end
@doc """
Moves a date by the given number of months
"""
@expression_doc doc: "Move the date in a date object by 1 month",
expression: "edate(right_now, 1)",
context: %{right_now: DateTime.new!(Date.new!(2022, 1, 1), Time.new!(0, 0, 0))},
result:
Timex.shift(DateTime.new!(Date.new!(2022, 1, 1), Time.new!(0, 0, 0)),
months: 1
)
@expression_doc doc: "Move the date store in a piece of text by 1 month",
expression: "edate(\"2022-10-10\", 1)",
result: ~D[2022-11-10]
def edate(ctx, date, months) do
[date, months] = eval_args!([date, months], ctx)
extract_dateish(date) |> Timex.shift(months: months)
end
@doc """
Returns only the hour of a datetime (0 to 23)
"""
@expression_doc doc: "Get the current hour",
expression: "hour(now())",
result: DateTime.utc_now().hour
def hour(ctx, date) do
%{hour: hour} = eval!(date, ctx)
hour
end
@doc """
Returns only the minute of a datetime (0 to 59)
"""
@expression_doc doc: "Get the current minute",
expression: "minute(now())",
result: DateTime.utc_now().minute
def minute(ctx, date) do
%{minute: minute} = extract_datetimeish(eval!(date, ctx))
minute
end
@doc """
Returns only the month of a date (1 to 12)
"""
@expression_doc doc: "Get the current month",
expression: "month(now())",
result: DateTime.utc_now().month
def month(ctx, date) do
%{month: month} = eval!(date, ctx)
month
end
@doc """
Returns the current date time as UTC
```
It is currently @NOW()
```
"""
@expression_doc doc: "return the current timestamp as a DateTime value",
expression: "now()",
fake_result: DateTime.utc_now()
@expression_doc doc: "return the current datetime and format it using `datevalue`",
expression: "datevalue(now(), \"%Y-%m-%d\")",
fake_result: %{
"__value__" => DateTime.utc_now() |> Timex.format!("%Y-%m-%d", :strftime),
"date" => DateTime.utc_now()
}
def now(_ctx) do
DateTime.utc_now()
end
@doc """
Returns only the second of a datetime (0 to 59)
"""
@expression_doc expression: "second(now)",
context: %{"now" => DateTime.utc_now()},
result: DateTime.utc_now().second
def second(ctx, date) do
%{second: second} = eval!(date, ctx)
second
end
@doc """
Defines a time value which can be used for time arithmetic
"""
@expression_doc expression: "time(12, 13, 14)",
result: %Time{hour: 12, minute: 13, second: 14}
def time(ctx, hours, minutes, seconds) do
[hours, minutes, seconds] = eval_args!([hours, minutes, seconds], ctx)
%Time{hour: hours, minute: minutes, second: seconds}
end
@doc """
Converts time stored in text to an actual time
"""
@expression_doc expression: "timevalue(\"2:30\")",
result: %Time{hour: 2, minute: 30, second: 0}
@expression_doc expression: "timevalue(\"2:30:55\")",
result: %Time{hour: 2, minute: 30, second: 55}
def timevalue(ctx, expression) do
expression = eval!(expression, ctx)
parts =
expression
|> String.split(":")
|> Enum.map(&String.to_integer/1)
defaults = [
hour: 0,
minute: 0,
second: 0
]
fields =
[:hour, :minute, :second]
|> Enum.zip(parts)
struct(Time, Keyword.merge(defaults, fields))
end
@doc """
Returns the current date
"""
@expression_doc expression: "today()",
result: Date.utc_today()
def today(_ctx) do
Date.utc_today()
end
@doc """
Returns the day of the week of a date (1 for Sunday to 7 for Saturday)
"""
@expression_doc expression: "weekday(today)",
context: %{"today" => ~D[2022-11-06]},
result: 1
@expression_doc expression: "weekday(today)",
context: %{"today" => ~D[2022-11-01]},
result: 3
def weekday(ctx, date) do
iso_week_day = Timex.weekday(eval!(date, ctx))
if iso_week_day == 7 do
1
else
iso_week_day + 1
end
end
@doc """
Returns only the year of a date
"""
@expression_doc expression: "year(now)",
context: %{"now" => DateTime.utc_now()},
result: DateTime.utc_now().year
def year(ctx, date) do
%{year: year} = extract_dateish(eval!(date, ctx))
year
end
@doc """
Returns `true` if and only if all its arguments evaluate to `true`
"""
@expression_doc expression: "and(contact.gender = \"F\", contact.age >= 18)",
code_expression: "contact.gender = \"F\" and contact.age >= 18",
context: %{
"contact" => %{
"gender" => "F",
"age" => 32
}
},
result: true
@expression_doc expression: "and(contact.gender = \"F\", contact.age >= 18)",
code_expression: "contact.gender = \"F\" and contact.age >= 18",
context: %{
"contact" => %{
"gender" => "?",
"age" => 32
}
},
result: false
def and_vargs(ctx, arguments) do
arguments = eval_args!(arguments, ctx)
Enum.all?(arguments, & &1)
end
@doc """
Returns `false` if the argument supplied evaluates to truth-y
# Example
iex> Expression.evaluate!("@and(not(false), true)")
true
"""
def not_(ctx, argument) do
!eval!(argument, ctx)
end
@doc """
Returns one value if the condition evaluates to `true`, and another value if it evaluates to `false`
# Example
iex> Expression.evaluate!("@if(true, \\"Yes\\", \\"No\\")")
"Yes"
iex> Expression.evaluate!("@if(false, \\"Yes\\", \\"No\\")")
"No"
"""
@expression_doc expression: "if(true, \"Yes\", \"No\")",
code_expression: """
if true do
"Yes"
else
"No"
end
""",
result: "Yes"
@expression_doc expression: "if(false, \"Yes\", \"No\")",
code_expression: "# Shorthand\nif(false, do: \"Yes\", else: \"No\")",
result: "No"
def if_(ctx, condition, yes, no) do
if(eval!(condition, ctx),
do: eval!(yes, ctx),
else: eval!(no, ctx)
)
end
@doc """
Returns `true` if any argument is `true`.
Returns the first truthy value found or otherwise false.
Accepts any amount of arguments for testing truthiness.
"""
@expression_doc doc: "Return true if any of the values are true",
expression: "or(true, false)",
code_expression: "true or false",
result: true
@expression_doc doc: "Return the first value that is truthy",
expression: "or(false, \"foo\")",
code_expression: "false or \"foo\"",
result: "foo"
@expression_doc expression: "or(true, true)",
code_expression: "true or true",
result: true
@expression_doc expression: "or(false, false)",
code_expression: "false or false",
result: false
def or_vargs(ctx, arguments) do
arguments = eval_args!(arguments, ctx)
Enum.reduce(arguments, fn a, b -> a || b end)
end
@doc """
Returns the absolute value of a number
"""
@expression_doc expression: "abs(-1)",
result: 1
def abs(ctx, number) do
abs(eval!(number, ctx))
end
@doc """
Returns the maximum value of all arguments
"""
@expression_doc expression: "max(1, 2, 3)",
result: 3
def max_vargs(ctx, arguments) do
Enum.max(eval_args!(arguments, ctx))
end
@doc """
Returns the minimum value of all arguments
"""
@expression_doc expression: "min(1, 2, 3)",
result: 1
def min_vargs(ctx, arguments) do
Enum.min(eval_args!(arguments, ctx))
end
@doc """
Returns the result of a number raised to a power - equivalent to the ^ operator
"""
@expression_doc expression: "power(2, 3)",
fake_result: Decimal.from_float(8.0)
def power(ctx, a, b) do
[a, b] = eval_args!([a, b], ctx)
Decimal.from_float(:math.pow(a, b))
end
@doc """
Returns the sum of all arguments, equivalent to the + operator
```
You have @SUM(contact.reports, contact.forms) reports and forms
```
"""
@expression_doc expression: "sum(1, 2, 3)",
result: 6
def sum_vargs(ctx, arguments) do
Enum.sum(eval_args!(arguments, ctx))
end
@doc """
Returns the character specified by a number
```
> "As easy as @char(65), @char(66), @char(67)"
"As easy as A, B, C"
```
"""
@expression_doc expression: "char(65)",
result: "A"
def char(ctx, code) do
code = eval!(code, ctx)
<<code>>
end
@doc """
Removes all non-printable characters from a text string
"""
@expression_doc expression: "clean(value)",
context: %{"value" => <<65, 0, 66, 0, 67>>},
result: "ABC"
def clean(ctx, binary) do
binary
|> eval!(ctx)
|> String.graphemes()
|> Enum.filter(&String.printable?/1)
|> Enum.join("")
end
@doc """
Returns a numeric code for the first character in a text string
```
> "The numeric code of A is @CODE(\\"A\\")"
"The numeric code of A is 65"
```
"""
@expression_doc expression: "code(\"A\")",
result: 65
def code(ctx, code_ast) do
<<code>> = eval!(code_ast, ctx)
code
end
@doc """
Joins text strings into one text string
```
> "Your name is @CONCATENATE(contact.first_name, \\" \\", contact.last_name)"
"Your name is name surname"
```
"""
@expression_doc expression: "concatenate(contact.first_name, \" \", contact.last_name)",
context: %{
"contact" => %{
"first_name" => "name",
"last_name" => "surname"
}
},
result: "name surname"
def concatenate_vargs(ctx, arguments) do
Enum.join(eval_args!(arguments, ctx), "")
end
@doc """
Formats the given number in decimal format using a period and commas
```
> You have @fixed(contact.balance, 2) in your account
"You have 4.21 in your account"
```
"""
@expression_doc expression: "fixed(4.209922, 2, false)",
result: "4.21"
@expression_doc expression: "fixed(4000.424242, 4, true)",
result: "4,000.4242"
@expression_doc expression: "fixed(3.7979, 2, false)",
result: "3.80"
@expression_doc expression: "fixed(3.7979, 2)",
result: "3.80"
def fixed(ctx, number, precision) do
[number, precision] = eval_args!([number, precision], ctx)
Number.Delimit.number_to_delimited(number, precision: precision)
end
def fixed(ctx, number, precision, no_commas) do
case eval_args!([number, precision, no_commas], ctx) do
[number, precision, true] ->
Number.Delimit.number_to_delimited(number,
precision: precision,
delimiter: ",",
separator: "."
)
[number, precision, false] ->
Number.Delimit.number_to_delimited(number, precision: precision)
end
end
@doc """
Returns the first characters in a text string. This is Unicode safe.
"""
@expression_doc expression: "left(\"foobar\", 4)",
result: "foob"
@expression_doc expression:
"left(\"Умерла Мадлен Олбрайт - первая женщина на посту главы Госдепа США\", 20)",
result: "Умерла Мадлен Олбрай"
def left(ctx, binary, size) do
[binary, size] = eval_args!([binary, size], ctx)
String.slice(binary, 0, size)
end
@doc """
Returns the number of characters in a text string
"""
@expression_doc expression: "len(\"foo\")",
result: 3
@expression_doc expression: "len(\"zoë\")",
result: 3
def len(ctx, binary) do
String.length(eval!(binary, ctx))
end
@doc """
Converts a text string to lowercase
"""
@expression_doc expression: "lower(\"Foo Bar\")",
result: "foo bar"
def lower(ctx, binary) do
String.downcase(eval!(binary, ctx))
end
@doc """
Capitalizes the first letter of every word in a text string
"""
@expression_doc expression: "proper(\"foo bar\")",
result: "Foo Bar"
def proper(ctx, binary) do
binary
|> eval!(ctx)
|> String.split(" ")
|> Enum.map_join(" ", &String.capitalize/1)
end
@doc """
Repeats text a given number of times
"""
@expression_doc expression: "rept(\"*\", 10)",
result: "**********"
def rept(ctx, value, amount) do
[value, amount] = eval_args!([value, amount], ctx)
String.duplicate(value, amount)
end
@doc """
Returns the last characters in a text string.
This is Unicode safe.
"""
@expression_doc expression: "right(\"testing\", 3)",
result: "ing"
@expression_doc expression:
"right(\"Умерла Мадлен Олбрайт - первая женщина на посту главы Госдепа США\", 20)",
result: "ту главы Госдепа США"
def right(ctx, binary, size) do
[binary, size] = eval_args!([binary, size], ctx)
String.slice(binary, -size, size)
end
@doc """
Substitutes new_text for old_text in a text string. If instance_num is given, then only that instance will be substituted
"""
@expression_doc expression: "substitute(\"I can't\", \"can't\", \"can do\")",
result: "I can do"
def substitute(ctx, subject, pattern, replacement) do
[subject, pattern, replacement] = eval_args!([subject, pattern, replacement], ctx)
String.replace(subject, pattern, replacement)
end
@doc """
Returns the unicode character specified by a number
"""
@expression_doc expression: "unichar(65)", result: "A"
@expression_doc expression: "unichar(233)", result: "é"
def unichar(ctx, code) do
code = eval!(code, ctx)
<<code::utf8>>
end
@doc """
Returns a numeric code for the first character in a text string
"""
@expression_doc expression: "unicode(\"A\")", result: 65
@expression_doc expression: "unicode(\"é\")", result: 233
def unicode(ctx, letter) do
<<code::utf8>> = eval!(letter, ctx)
code
end
@doc """
Converts a text string to uppercase
"""
@expression_doc expression: "upper(\"foo\")",
result: "FOO"
def upper(ctx, binary) do
String.upcase(eval!(binary, ctx))
end
@doc """
Returns the first word in the given text - equivalent to WORD(text, 1)
"""
@expression_doc expression: "first_word(\"foo bar baz\")",
result: "foo"
def first_word(ctx, binary) do
[word | _] = String.split(eval!(binary, ctx), " ")
word
end
@doc """
Formats a number as a percentage
"""
@expression_doc expression: "percent(2/10)", result: "20%"
@expression_doc expression: "percent(0.2)", result: "20%"
@expression_doc expression: "percent(d)", context: %{"d" => "0.2"}, result: "20%"
def percent(ctx, decimal) do
decimal =
case eval!(decimal, ctx) do
float when is_float(float) -> Decimal.from_float(float)
binary when is_binary(binary) -> Decimal.new(binary)
decimal when is_struct(decimal, Decimal) -> decimal
end
decimal
|> Decimal.mult(100)
|> Decimal.to_float()
|> Number.Percentage.number_to_percentage(precision: 0)
end
@doc """
Formats digits in text for reading in TTS
"""
@expression_doc expression: "read_digits(\"+271\")", result: "plus two seven one"
def read_digits(ctx, binary) do
map = %{
"+" => "plus",
"0" => "zero",
"1" => "one",
"2" => "two",
"3" => "three",
"4" => "four",
"5" => "five",
"6" => "six",
"7" => "seven",
"8" => "eight",
"9" => "nine"
}
binary
|> eval!(ctx)
|> String.graphemes()
|> Enum.map(fn grapheme -> Map.get(map, grapheme, nil) end)
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")
end
@doc """
Removes the first word from the given text. The remaining text will be unchanged
"""
@expression_doc expression: "remove_first_word(\"foo bar\")", result: "bar"
@expression_doc expression: "remove_first_word(\"foo-bar\", \"-\")", result: "bar"
def remove_first_word(ctx, binary) do
binary = eval!(binary, ctx)
separator = " "
tl(String.split(binary, separator)) |> Enum.join(separator)
end
def remove_first_word(ctx, binary, separator) do
[binary, separator] = eval_args!([binary, separator], ctx)
tl(String.split(binary, separator)) |> Enum.join(separator)
end
@doc """
Extracts the nth word from the given text string. If stop is a negative number,
then it is treated as count backwards from the end of the text. If by_spaces is
specified and is `true` then the function splits the text into words only by spaces.
Otherwise the text is split by punctuation characters as well
"""
@expression_doc expression: "word(\"hello cow-boy\", 2)", result: "cow"
@expression_doc expression: "word(\"hello cow-boy\", 2, true)", result: "cow-boy"
@expression_doc expression: "word(\"hello cow-boy\", -1)", result: "boy"
def word(ctx, binary, n) do
[binary, n] = eval_args!([binary, n], ctx)
parts = String.split(binary, @punctuation_pattern)
# This slicing seems off.
[part] =
if n < 0 do
Enum.slice(parts, n, 1)
else
Enum.slice(parts, n - 1, 1)
end
part
end
def word(ctx, binary, n, by_spaces) do
[binary, n, by_spaces] = eval_args!([binary, n, by_spaces], ctx)
splitter = if(by_spaces, do: " ", else: @punctuation_pattern)
parts = String.split(binary, splitter)
# This slicing seems off.
[part] =
if n < 0 do
Enum.slice(parts, n, 1)
else
Enum.slice(parts, n - 1, 1)
end
part
end
@doc """
Returns the number of words in the given text string. If by_spaces is specified and is `true` then the function splits the text into words only by spaces. Otherwise the text is split by punctuation characters as well
```
> You entered @word_count("one two three") words
You entered 3 words
```
"""
@expression_doc expression: "word_count(\"hello cow-boy\")", result: 3
@expression_doc expression: "word_count(\"hello cow-boy\", true)", result: 2
def word_count(ctx, binary) do
binary
|> eval!(ctx)
|> String.split(@punctuation_pattern)
|> Enum.count()
end
def word_count(ctx, binary, by_spaces) do
[binary, by_spaces] = eval_args!([binary, by_spaces], ctx)
splitter = if(by_spaces, do: " ", else: @punctuation_pattern)
binary
|> String.split(splitter)
|> Enum.count()
end
@doc """
Extracts a substring of the words beginning at start, and up to but not-including stop.
If stop is omitted then the substring will be all words from start until the end of the text.
If stop is a negative number, then it is treated as count backwards from the end of the text.
If by_spaces is specified and is `true` then the function splits the text into words only by spaces.
Otherwise the text is split by punctuation characters as well
"""
@expression_doc expression: "word_slice(\"FLOIP expressions are fun\", 2, 4)",
result: "expressions are"
@expression_doc expression: "word_slice(\"FLOIP expressions are fun\", 2)",
result: "expressions are fun"
@expression_doc expression: "word_slice(\"FLOIP expressions are fun\", 1, -2)",
result: "FLOIP expressions"
@expression_doc expression: "word_slice(\"FLOIP expressions are fun\", -1)",
result: "fun"
def word_slice(ctx, binary, start) do
[binary, start] = eval_args!([binary, start], ctx)
parts =
binary
|> String.split(" ")
cond do
start > 0 ->
parts
|> Enum.slice(start - 1, length(parts))
|> Enum.join(" ")
start < 0 ->
parts
|> Enum.slice(start..length(parts))
|> Enum.join(" ")
end
end
def word_slice(ctx, binary, start, stop) do
[binary, start, stop] = eval_args!([binary, start, stop], ctx)
cond do
stop > 0 ->
binary
|> String.split(@punctuation_pattern)
|> Enum.slice((start - 1)..(stop - 2))
|> Enum.join(" ")
stop < 0 ->
binary
|> String.split(@punctuation_pattern)
|> Enum.slice((start - 1)..(stop - 1))
|> Enum.join(" ")
end
end
def word_slice(ctx, binary, start, stop, by_spaces) do
[binary, start, stop, by_spaces] = eval_args!([binary, start, stop, by_spaces], ctx)
splitter = if(by_spaces, do: " ", else: @punctuation_pattern)
case stop do
stop when stop > 0 ->
binary
|> String.split(splitter)
|> Enum.slice((start - 1)..(stop - 2))
|> Enum.join(" ")
stop when stop < 0 ->
binary
|> String.split(splitter)
|> Enum.slice((start - 1)..(stop - 1))
|> Enum.join(" ")
end
end
@doc """
Returns `true` if the argument is a number.
"""
@expression_doc expression: "isnumber(1)", result: true
@expression_doc expression: "isnumber(1.0)", result: true
@expression_doc expression: "isnumber(\"1.0\")", result: true
@expression_doc expression: "isnumber(\"a\")", result: false
def isnumber(ctx, var) do
var = eval!(var, ctx)
case var do
var when is_float(var) or is_integer(var) ->
true
var when is_struct(var, Decimal) ->
true
var when is_binary(var) ->
Decimal.new(var)
true
_var ->
false
end
rescue
Decimal.Error -> false
end
@doc """
Returns `true` if the argument is a boolean.
"""
@expression_doc expression: "isbool(true)", result: true
@expression_doc expression: "isbool(false)", result: true
@expression_doc expression: "isbool(1)", result: false
@expression_doc expression: "isbool(0)", result: false
@expression_doc expression: "isbool(\"true\")", result: false
@expression_doc expression: "isbool(\"false\")", result: false
def isbool(ctx, var) do
eval!(var, ctx) in [true, false]
end
@doc """
Returns `true` if the argument is a string.
"""
@expression_doc expression: "isstring(\"hello\")", result: true
@expression_doc expression: "isstring(false)", result: false
@expression_doc expression: "isstring(1)", result: false
def isstring(ctx, binary), do: is_binary(eval!(binary, ctx))
defp search_words(haystack, words) do
patterns =
words
|> String.split(" ")
|> Enum.map(&Regex.escape/1)
|> Enum.map(&Regex.compile!(&1, "i"))
results =
patterns
|> Enum.map(&Regex.run(&1, haystack))
|> Enum.map(fn
[match] -> match
nil -> nil
end)
|> Enum.reject(&is_nil/1)
{patterns, results}
end
@doc """
Tests whether all the words are contained in text
The words can be in any order and may appear more than once.
"""
@expression_doc expression: "has_all_words(\"the quick brown FOX\", \"the fox\")", result: true
@expression_doc expression: "has_all_words(\"the quick brown FOX\", \"red fox\")", result: false
def has_all_words(ctx, haystack, words) do
[haystack, words] = eval_args!([haystack, words], ctx)
{patterns, results} = search_words(haystack, words)
# future match result: Enum.join(results, " ")
Enum.count(patterns) == Enum.count(results)
end
@doc """
Tests whether any of the words are contained in the text
Only one of the words needs to match and it may appear more than once.
"""
@expression_doc expression: "has_any_word(\"The Quick Brown Fox\", \"fox quick\")",
result: %{"__value__" => true, "match" => "Quick Fox"}
@expression_doc expression: "has_any_word(\"The Quick Brown Fox\", \"yellow\")",
result: %{"__value__" => false, "match" => nil}
def has_any_word(ctx, haystack, words) do
[haystack, words] = eval_args!([haystack, words], ctx)
haystack_words = String.split(haystack)
haystacks_lowercase = Enum.map(haystack_words, &String.downcase/1)
words_lowercase = String.split(words) |> Enum.map(&String.downcase/1)
matched_indices =
haystacks_lowercase
|> Enum.with_index()
|> Enum.filter(fn {haystack_word, _index} ->
Enum.member?(words_lowercase, haystack_word)
end)
|> Enum.map(fn {_haystack_word, index} -> index end)
matched_haystack_words = Enum.map(matched_indices, &Enum.at(haystack_words, &1))
match? = Enum.any?(matched_haystack_words)
%{
"__value__" => match?,
"match" => if(match?, do: Enum.join(matched_haystack_words, " "), else: nil)
}
end
@doc """
Tests whether text starts with beginning
Both text values are trimmed of surrounding whitespace, but otherwise matching is
strict without any tokenization.
"""
@expression_doc expression: "has_beginning(\"The Quick Brown\", \"the quick\")", result: true
@expression_doc expression: "has_beginning(\"The Quick Brown\", \"the quick\")",
result: false
@expression_doc expression: "has_beginning(\"The Quick Brown\", \"quick brown\")", result: false
def has_beginning(ctx, text, beginning) do
[text, beginning] = eval_args!([text, beginning], ctx)
case Regex.run(~r/^#{Regex.escape(beginning)}/i, text) do
# future match result: first
[_first | _remainder] -> true
nil -> false
end
end
defp extract_dateish(date_time) when is_struct(date_time, DateTime), do: date_time
defp extract_dateish(date) when is_struct(date, Date), do: date
defp extract_dateish(expression) do
expression = Regex.replace(~r/[a-z]/u, expression, "")
case DateTimeParser.parse_date(expression) do
{:ok, date} -> date
{:error, _} -> nil
end
end
defp extract_datetimeish(date_time) when is_struct(date_time, DateTime), do: date_time
defp extract_datetimeish(date) when is_struct(date, Date),
do: DateTime.new!(date, Time.new!(0, 0, 0, 0))
defp extract_datetimeish(expression) do
expression = Regex.replace(~r/[a-z]/u, expression, "")
case DateTimeParser.parse_datetime(expression) do
{:ok, date} -> date
{:error, _} -> nil
end
end
@doc """
Tests whether `expression` contains a date formatted according to our environment
This is very naively implemented with a regular expression.
"""
@expression_doc expression: "has_date(\"the date is 15/01/2017\")", result: true
@expression_doc expression: "has_date(\"there is no date here, just a year 2017\")",
result: false
def has_date(ctx, expression) do
!!extract_dateish(eval!(expression, ctx))
end
@doc """
Tests whether `expression` is a date equal to `date_string`
"""
@expression_doc expression: "has_date_eq(\"the date is 15/01/2017\", \"2017-01-15\")",
result: true
@expression_doc expression:
"has_date_eq(\"there is no date here, just a year 2017\", \"2017-01-15\")",
result: false
def has_date_eq(ctx, expression, date_string) do
[expression, date_string] = eval_args!([expression, date_string], ctx)
found_date = extract_dateish(expression)
test_date = extract_dateish(date_string)
# Future match result: found_date
found_date == test_date
end
@doc """
Tests whether `expression` is a date after the date `date_string`
"""
@expression_doc expression: "has_date_gt(\"the date is 15/01/2017\", \"2017-01-01\")",
result: true
@expression_doc expression: "has_date_gt(\"the date is 15/01/2017\", \"2017-03-15\")",
result: false
def has_date_gt(ctx, expression, date_string) do
[expression, date_string] = eval_args!([expression, date_string], ctx)
found_date = extract_dateish(expression)
test_date = extract_dateish(date_string)
# future match result: found_date
Date.compare(found_date, test_date) == :gt
end
@doc """
Tests whether `expression` contains a date before the date `date_string`
"""
@expression_doc expression: "has_date_lt(\"the date is 15/01/2017\", \"2017-06-01\")",
result: true
@expression_doc expression: "has_date_lt(\"the date is 15/01/2021\", \"2017-03-15\")",
result: false
def has_date_lt(ctx, expression, date_string) do
[expression, date_string] = eval_args!([expression, date_string], ctx)
found_date = extract_dateish(expression)
test_date = extract_dateish(date_string)
# future match result: found_date
Date.compare(found_date, test_date) == :lt
end
@doc """
Tests whether an email is contained in text
"""
@expression_doc expression: "has_email(\"my email is foo1@bar.com, please respond\")",
result: true
@expression_doc expression: "has_email(\"i'm not sharing my email\")", result: false
def has_email(ctx, expression) do
expression = eval!(expression, ctx)
case Regex.run(~r/([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)/, expression) do
# future match result: match
[_match | _] -> true
nil -> false
end
end
@doc """
Returns whether the contact is part of group with the passed in UUID
"""
@expression_doc expression:
"has_group(contact.groups, \"b7cf0d83-f1c9-411c-96fd-c511a4cfa86d\")",
context: %{
"contact" => %{
"groups" => [
%{
"uuid" => "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d"
}
]
}
},
result: true
@expression_doc expression:
"has_group(contact.groups, \"00000000-0000-0000-0000-000000000000\")",
context: %{
"contact" => %{
"groups" => [
%{
"uuid" => "b7cf0d83-f1c9-411c-96fd-c511a4cfa86d"
}
]
}
},
result: false
def has_group(ctx, groups, uuid) do
[groups, uuid] = eval_args!([groups, uuid], ctx)
group = Enum.find(groups, nil, &(&1["uuid"] == uuid))
# future match result: group
!!group
end
defp extract_numberish(expression) do
with [match] <-
Regex.run(~r/([0-9]+\.?[0-9]+)/u, replace_arabic_numerals(expression), capture: :first),
{decimal, ""} <- Decimal.parse(match) do
decimal
else
# Regex can return nil
nil -> nil
# Decimal parsing can return :error
:error -> nil
end
end
defp replace_arabic_numerals(expression) do
replace_numerals(expression, %{
"٠" => "0",
"١" => "1",
"٢" => "2",
"٣" => "3",
"٤" => "4",
"٥" => "5",
"٦" => "6",
"٧" => "7",
"٨" => "8",
"٩" => "9"
})
end
defp replace_numerals(expression, mapping) do
mapping
|> Enum.reduce(expression, fn {rune, replacement}, expression ->
String.replace(expression, rune, replacement)
end)
end
defp parse_decimal(decimal) when is_struct(decimal, Decimal), do: decimal
defp parse_decimal(float) when is_float(float), do: Decimal.from_float(float)
defp parse_decimal(number) when is_number(number), do: Decimal.new(number)
defp parse_decimal(binary) when is_binary(binary) do
case Decimal.parse(binary) do
{decimal, ""} -> decimal
:error -> :error
end
end
@doc """
Tests whether `expression` contains a number
"""
@expression_doc expression: "has_number(\"the number is 42 and 5\")", result: true
@expression_doc expression: "has_number(\"العدد ٤٢\")", result: true
@expression_doc expression: "has_number(\"٠.٥\")", result: true
@expression_doc expression: "has_number(\"0.6\")", result: true
def has_number(ctx, expression) do
expression = eval!(expression, ctx)
number = extract_numberish(expression)
# future match result: number
!!number
end
@doc """
Tests whether `expression` contains a number equal to the value
"""
@expression_doc expression: "has_number_eq(\"the number is 42\", 42)", result: true
@expression_doc expression: "has_number_eq(\"the number is 42\", 42.0)", result: true
@expression_doc expression: "has_number_eq(\"the number is 42\", \"42\")", result: true
@expression_doc expression: "has_number_eq(\"the number is 42.0\", \"42\")", result: true
@expression_doc expression: "has_number_eq(\"the number is 40\", \"42\")", result: false
@expression_doc expression: "has_number_eq(\"the number is 40\", \"foo\")", result: false
@expression_doc expression: "has_number_eq(\"four hundred\", \"foo\")", result: false
def has_number_eq(ctx, expression, decimal) do
[expression, decimal] = eval_args!([expression, decimal], ctx)
with %Decimal{} = number <- extract_numberish(expression),
%Decimal{} = decimal <- parse_decimal(decimal) do
# Future match result: number
Decimal.eq?(number, decimal)
else
nil -> false
:error -> false
end
end
@doc """
Tests whether `expression` contains a number greater than min
"""
@expression_doc expression: "has_number_gt(\"the number is 42\", 40)", result: true
@expression_doc expression: "has_number_gt(\"the number is 42\", 40.0)", result: true
@expression_doc expression: "has_number_gt(\"the number is 42\", \"40\")", result: true
@expression_doc expression: "has_number_gt(\"the number is 42.0\", \"40\")", result: true
@expression_doc expression: "has_number_gt(\"the number is 40\", \"40\")", result: false
@expression_doc expression: "has_number_gt(\"the number is 40\", \"foo\")", result: false
@expression_doc expression: "has_number_gt(\"four hundred\", \"foo\")", result: false
def has_number_gt(ctx, expression, decimal) do
[expression, decimal] = eval_args!([expression, decimal], ctx)
with %Decimal{} = number <- extract_numberish(expression),
%Decimal{} = decimal <- parse_decimal(decimal) do
# Future match result: number
Decimal.gt?(number, decimal)
else
nil -> false
:error -> false
end
end
@doc """
Tests whether `expression` contains a number greater than or equal to min
"""
@expression_doc expression: "has_number_gte(\"the number is 42\", 42)", result: true
@expression_doc expression: "has_number_gte(\"the number is 42\", 42.0)", result: true
@expression_doc expression: "has_number_gte(\"the number is 42\", \"42\")", result: true
@expression_doc expression: "has_number_gte(\"the number is 42.0\", \"45\")", result: false
@expression_doc expression: "has_number_gte(\"the number is 40\", \"45\")", result: false
@expression_doc expression: "has_number_gte(\"the number is 40\", \"foo\")", result: false
@expression_doc expression: "has_number_gte(\"four hundred\", \"foo\")", result: false
def has_number_gte(ctx, expression, decimal) do
[expression, decimal] = eval_args!([expression, decimal], ctx)
with %Decimal{} = number <- extract_numberish(expression),
%Decimal{} = decimal <- parse_decimal(decimal) do
# Future match result: number
Decimal.gt?(number, decimal) || Decimal.eq?(number, decimal)
else
nil -> false
:error -> false
end
end
@doc """
Tests whether `expression` contains a number less than max
"""
@expression_doc expression: "has_number_lt(\"the number is 42\", 44)", result: true
@expression_doc expression: "has_number_lt(\"the number is 42\", 44.0)", result: true
@expression_doc expression: "has_number_lt(\"the number is 42\", \"40\")", result: false
@expression_doc expression: "has_number_lt(\"the number is 42.0\", \"40\")", result: false
@expression_doc expression: "has_number_lt(\"the number is 40\", \"40\")", result: false
@expression_doc expression: "has_number_lt(\"the number is 40\", \"foo\")", result: false
@expression_doc expression: "has_number_lt(\"four hundred\", \"foo\")", result: false
def has_number_lt(ctx, expression, decimal) do
[expression, decimal] = eval_args!([expression, decimal], ctx)
with %Decimal{} = number <- extract_numberish(expression),
%Decimal{} = decimal <- parse_decimal(decimal) do
# Future match result: number
Decimal.lt?(number, decimal)
else
nil -> false
:error -> false
end
end
@doc """
Tests whether `expression` contains a number less than or equal to max
"""
@expression_doc expression: "has_number_lte(\"the number is 42\", 42)", result: true
@expression_doc expression: "has_number_lte(\"the number is 42\", 42.0)", result: true
@expression_doc expression: "has_number_lte(\"the number is 42\", \"42\")", result: true
@expression_doc expression: "has_number_lte(\"the number is 42.0\", \"40\")", result: false
@expression_doc expression: "has_number_lte(\"the number is 40\", \"foo\")", result: false
@expression_doc expression: "has_number_lte(\"four hundred\", \"foo\")", result: false
def has_number_lte(ctx, expression, decimal) do
[expression, decimal] = eval_args!([expression, decimal], ctx)
with %Decimal{} = number <- extract_numberish(expression),
%Decimal{} = decimal <- parse_decimal(decimal) do
# Future match result: number
Decimal.lt?(number, decimal) || Decimal.eq?(number, decimal)
else
nil -> false
:error -> false
end
end
@doc """
Tests whether the text contains only phrase
The phrase must be the only text in the text to match
"""
@expression_doc expression: "has_only_phrase(\"Quick Brown\", \"quick brown\")", result: true
@expression_doc expression: "has_only_phrase(\"\", \"\")", result: true
@expression_doc expression: "has_only_phrase(\"The Quick Brown Fox\", \"quick brown\")",
result: false
def has_only_phrase(ctx, expression, phrase) do
[expression, phrase] = eval_args!([expression, phrase], ctx)
case Enum.map([expression, phrase], &String.downcase/1) do
# Future match result: expression
[same, same] -> true
_anything_else -> false
end
end
@doc """
Returns whether two text values are equal (case sensitive). In the case that they are, it will return the text as the match.
"""
@expression_doc expression: "has_only_text(\"foo\", \"foo\")", result: true
@expression_doc expression: "has_only_text(\"\", \"\")", result: true
@expression_doc expression: "has_only_text(\"foo\", \"FOO\")", result: false
def has_only_text(ctx, expression_one, expression_two) do
[expression_one, expression_two] = eval_args!([expression_one, expression_two], ctx)
expression_one == expression_two
end
@doc """
Tests whether `expression` matches the regex pattern
Both text values are trimmed of surrounding whitespace and matching is case-insensitive.
"""
@expression_doc expression: "has_pattern(\"Buy cheese please\", \"buy (\\w+)\")", result: true
@expression_doc expression: "has_pattern(\"Sell cheese please\", \"buy (\\w+)\")", result: false
def has_pattern(ctx, expression, pattern) do
[expression, pattern] = eval_args!([expression, pattern], ctx)
with {:ok, regex} <- Regex.compile(String.trim(pattern), "i"),
[[_first | _remainder]] <- Regex.scan(regex, String.trim(expression), capture: :all) do
# Future match result: first
true
else
_ -> false
end
end
@doc """
Tests whether `expresssion` contains a phone number.
The optional country_code argument specifies the country to use for parsing.
"""
@expression_doc expression: "has_phone(\"my number is +12067799294 thanks\")", result: true
@expression_doc expression: "has_phone(\"my number is 2067799294 thanks\", \"US\")",
result: true
@expression_doc expression: "has_phone(\"my number is 206 779 9294 thanks\", \"US\")",
result: true
@expression_doc expression: "has_phone(\"my number is none of your business\", \"US\")",
result: false
def has_phone(ctx, expression) do
[expression] = eval_args!([expression], ctx)
letters_removed = Regex.replace(~r/[a-z]/i, expression, "")
case ExPhoneNumber.parse(letters_removed, "") do
# Future match result: ExPhoneNumber.format(pn, :es164)
{:ok, _pn} -> true
_ -> false
end
end
def has_phone(ctx, expression, country_code) do
[expression, country_code] = eval_args!([expression, country_code], ctx)
letters_removed = Regex.replace(~r/[a-z]/i, expression, "")
case ExPhoneNumber.parse(letters_removed, country_code) do
# Future match result: ExPhoneNumber.format(pn, :es164)
{:ok, _pn} -> true
_ -> false
end
end
@doc """
Tests whether phrase is contained in `expression`
The words in the test phrase must appear in the same order with no other words in between.
"""
@expression_doc expression: "has_phrase(\"the quick brown fox\", \"brown fox\")", result: true
@expression_doc expression: "has_phrase(\"the quick brown fox\", \"quick fox\")", result: false
@expression_doc expression: "has_phrase(\"the quick brown fox\", \"\")", result: true
def has_phrase(ctx, expression, phrase) do
[expression, phrase] = eval_args!([expression, phrase], ctx)
lower_expression = String.downcase(expression)
lower_phrase = String.downcase(phrase)
found? = String.contains?(lower_expression, lower_phrase)
# Future match result: phrase
found?
end
@doc """
Tests whether there the `expression` has any characters in it
"""
@expression_doc expression: "has_text(\"quick brown\")", result: true
@expression_doc expression: "has_text(\"\")", result: false
@expression_doc expression: "has_text(\" \n\")", result: false
@expression_doc expression: "has_text(123)", result: true
def has_text(ctx, expression) do
expression = eval!(expression, ctx) |> to_string()
String.trim(expression) != ""
end
@doc """
Tests whether `expression` contains a time.
"""
@expression_doc expression: "has_time(\"the time is 10:30\")", result: true
@expression_doc expression: "has_time(\"the time is 10:00 pm\")", result: true
@expression_doc expression: "has_time(\"the time is 10:30:45\")", result: true
@expression_doc expression: "has_time(\"there is no time here, just the number 25\")",
result: false
def has_time(ctx, expression) do
case DateTimeParser.parse_time(eval!(expression, ctx)) do
# Future match result: time
{:ok, _time} -> true
_ -> false
end
end
@doc """
map over a list of items and apply the mapper function to every item, returning
the result.
"""
@expression_doc doc: "Map over the range of numbers, create a date in January for every number",
expression: "map(1..3, &date(2022, 1, &1))",
result: [~D[2022-01-01], ~D[2022-01-02], ~D[2022-01-03]]
@expression_doc doc:
"Map over the range of numbers, multiple each by itself and return the result",
expression: "map(1..3, &(&1 * &1))",
result: [1, 4, 9]
def map(ctx, enumerable, mapper) do
[enumerable, mapper] = eval_args!([enumerable, mapper], ctx)
enumerable
# wrap in a list to be passed as a list of arguments
|> Enum.map(&[&1])
# call the mapper with each list of arguments as a single argument
|> Enum.map(mapper)
end
end