From 69119d84ceeb984ffc399db177a55d0922b58a3c Mon Sep 17 00:00:00 2001 From: nathanbegbie Date: Tue, 4 Nov 2025 15:45:23 +1030 Subject: [PATCH 1/2] remove experimental v2 from expressions See https://turnio.slack.com/archives/C02EC0PN15G/p1762153270892709 --- .gitignore | 3 + benchmark/blocks.exs | 27 - benchmark/expressions.exs | 21 - lib/expression/v2.ex | 260 ----- lib/expression/v2/autodoc.ex | 277 ----- lib/expression/v2/callbacks.ex | 114 -- lib/expression/v2/callbacks/standard.ex | 1340 ----------------------- lib/expression/v2/compat.ex | 262 ----- lib/expression/v2/compile.ex | 219 ---- lib/expression/v2/context.ex | 20 - lib/expression/v2/parser.ex | 441 -------- mix.exs | 1 - mix.lock | 3 - test/expression/v2/callbacks_test.exs | 5 - test/expression/v2/eval_compat_test.exs | 235 ---- test/expression/v2/eval_test.exs | 174 --- test/expression/v2/parser_test.exs | 224 ---- test/expression/v2_test.exs | 41 - 18 files changed, 3 insertions(+), 3664 deletions(-) delete mode 100644 benchmark/blocks.exs delete mode 100644 benchmark/expressions.exs delete mode 100644 lib/expression/v2.ex delete mode 100644 lib/expression/v2/autodoc.ex delete mode 100644 lib/expression/v2/callbacks.ex delete mode 100644 lib/expression/v2/callbacks/standard.ex delete mode 100644 lib/expression/v2/compat.ex delete mode 100644 lib/expression/v2/compile.ex delete mode 100644 lib/expression/v2/context.ex delete mode 100644 lib/expression/v2/parser.ex delete mode 100644 test/expression/v2/callbacks_test.exs delete mode 100644 test/expression/v2/eval_compat_test.exs delete mode 100644 test/expression/v2/eval_test.exs delete mode 100644 test/expression/v2/parser_test.exs delete mode 100644 test/expression/v2_test.exs diff --git a/.gitignore b/.gitignore index c1c81401..cef84e8a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ expression-*.tar /priv/plts/*.plt /priv/plts/*.plt.hash + +# Expert LS +.expert diff --git a/benchmark/blocks.exs b/benchmark/blocks.exs deleted file mode 100644 index 9e40aa67..00000000 --- a/benchmark/blocks.exs +++ /dev/null @@ -1,27 +0,0 @@ -# Run with `mix run benchmark/blocks.exs` in the console - - -Benchee.run( - %{ - "v1" => fn {expression, context} -> - Expression.evaluate_block(expression, context) - end, - "v2" => fn {expression, context} -> - Expression.V2.eval_block(expression, Expression.V2.Context.new(context)) - end - }, - inputs: %{ - "simple" => - { - "YEAR(contact.birthday)", - %{"contact" => %{"name" => "mary", "birthday" => ~U[1920-02-02T00:00:00+0000]}} - }, - "map" => {~S|map(0..10, &([&1, concatenate("Button ", &1)]))|, %{}}, - "if" => - {"if(something.false, 1, contact.bar)", %{ - "something" => %{"false" => false}, - "contact" => %{} - }}, - "arithmetic" => {"3 * (5 + 2)", %{}} - } -) diff --git a/benchmark/expressions.exs b/benchmark/expressions.exs deleted file mode 100644 index 27a23c49..00000000 --- a/benchmark/expressions.exs +++ /dev/null @@ -1,21 +0,0 @@ -# Run with `mix run benchmark/expressions.exs` in the console - -Benchee.run( - %{ - "v1" => fn {expression, context} -> Expression.evaluate(expression, context) end, - "v2" => fn {expression, context} -> - Expression.V2.eval(expression, Expression.V2.Context.new(context)) - end - }, - inputs: %{ - "simple" => { - "Hello @contact.name, you were born in @(YEAR(contact.birthday))", - %{"contact" => %{"name" => "mary", "birthday" => ~U[1920-02-02T00:00:00+0000]}} - }, - "map" => {~S|hi @map(0..10, &([&1, concatenate("Button ", &1)])) there|, %{}}, - "if" => - {"yebo @if(something.false, 1, contact.bar) yes", - %{"something" => %{"false" => false}, "contact" => %{}}}, - "arithmetic" => {"3 * (5 + 2) = @(3 * (5 + 2))", %{}} - } -) diff --git a/lib/expression/v2.ex b/lib/expression/v2.ex deleted file mode 100644 index 5da1b721..00000000 --- a/lib/expression/v2.ex +++ /dev/null @@ -1,260 +0,0 @@ -defmodule Expression.V2 do - @moduledoc """ - A second attempt at the parser, hopefully a little easier to read & maintain. - - `parse/1` parsed an Expression into AST. - `eval/3` evaluates the given AST using the context supplied. - - For details on how this is done please read `Expression.V2.Parser` and - `Expression.V2.Compile`. - - This parser & evaluator supports the following: - - * [strings](https://hexdocs.pm/elixir/typespecs.html#basic-types) either double or single quoted. - * [integers](https://hexdocs.pm/elixir/typespecs.html#basic-types) such as `1`, `2`, `40`, `55` - * [floats](https://hexdocs.pm/elixir/typespecs.html#basic-types) such as `3.141592653589793` - * [booleans](https://hexdocs.pm/elixir/typespecs.html#basic-types) which can be written in any mixed case such as `tRue` or `TRUE`, `False` etc - * `Range.t` such as `1..10`, also with steps `1..10//2` - * `Date.t` such as `2022-01-01` which is parsed into `~D[2022-01-01]` - * `Time.t` such as `10:30` which is parsed into `~T[10:30:00]` - * ISO formatted `DateTime.t` such as `2022-05-24T00:00:00` which is parsed into `~U[2022-05-24 00:00:00.0Z]` - * US formatted `DateTime.t` such as `01-02-2020 23:23:23` which is parsed into `~U[2020-02-01T23:23:23Z]` - * Lists of any of the above, such as `[1, 2, 3]` or `[1, 1.234, "john"]` - * Reading properties off of nested objects such as maps with a full stop, such as `contact.name` returning `"Doe"` from `%{"contact" => %{"name" => "Doe"}}` - * Reading attributes off of maps, such as `contact[the_key]` which returns `"Doe"` from `%{"contact" => %{"name" => "Doe"}, "the_key" => "name"}` - * Anonymous functions with `&` and `&1` as capture operators, `&(&1 + 1)` is an anonymous function that increments the input by 1. - - The result of a call to `eval/3` is a list of typed evaluated items. It is up to the integrating library to determine how - best to convert these into a final end user representation. - - # Examples - - iex> alias Expression.V2 - iex> V2.eval("the date is @date(2022, 2, 20)") - ["the date is ", ~D[2022-02-20]] - iex> V2.eval("the answer is @true") - ["the answer is ", true] - iex> V2.eval("22 divided by 7 is @(22 / 7)") - ["22 divided by 7 is ", 3.142857142857143] - iex> V2.eval( - ...> "Hello @proper(contact.name)! Looking forward to meet you @date(2023, 2, 20)", - ...> V2.Context.new(%{"contact" => %{"name" => "mary"}}) - ...> ) - ["Hello ", "Mary", "! Looking forward to meet you ", ~D[2023-02-20]] - iex> V2.eval("@map(1..3, &date(2023, 1, &1))") - [[~D[2023-01-01], ~D[2023-01-02], ~D[2023-01-03]]] - iex> V2.eval( - ...> "Here is the multiplication table of @number: @(map(1..10, &(&1 * number)))", - ...> V2.Context.new(%{"number" => 5}) - ...> ) - [ - "Here is the multiplication table of ", - 5, - ": ", - [5, 10, 15, 20, 25, 30, 35, 40, 45, 50] - ] - - """ - - alias Expression.V2.Compile - alias Expression.V2.Context - alias Expression.V2.Parser - - @doc """ - Parse a string with expressions into AST for the compile step - """ - @spec parse(String.t()) :: - {:ok, [term]} - | {:error, reason :: String.t(), bad_parts :: String.t()} - def parse(expression) do - case Parser.parse(expression) do - {:ok, ast, "", _, _, _} -> - {:ok, ast} - - {:ok, _ast, remaining, _, _, _} -> - {:error, "Unable to parse remainder", remaining} - end - end - - @spec escape(String.t()) :: String.t() - def escape(expression) when is_binary(expression) do - String.replace(expression, ~r/@([a-z]+)(\(|\.)/i, "@@\\g{1}\\g{2}") - end - - @doc """ - This function is referenced by `Expression.V2.Compile` to - make access to values in Maps or Lists easier - """ - @spec read_attribute(map | list, binary | integer) :: term - def read_attribute(map, item) when is_map(map), do: Map.get(map, item) - - def read_attribute(list, index) when is_list(list) and is_integer(index), - do: Enum.at(list, index) - - def read_attribute(list, range) when is_list(list) and is_struct(range, Range), - do: Enum.slice(list, range) - - @doc """ - Parse a string with an expression block into AST for the compile step - """ - @spec parse_block(String.t()) :: - {:ok, [term]} | {:error, reason :: String.t(), bad_parts :: String.t()} - def parse_block(expression_block) do - case Parser.expression(expression_block) do - {:ok, ast, "", _, _, _} -> {:ok, ast} - {:ok, _ast, _remainder, _, _, _} -> {:error, "Unable to parse", expression_block} - {:error, _ast, remaining, _, _, _} -> {:error, "Unable to parse remainder", remaining} - end - end - - @doc """ - Evaluate a string with an expression block against a context - """ - @spec eval_block(String.t(), context :: Context.t()) :: - term | {:error, reason :: String.t(), bad_parts :: String.t()} - def eval_block(expression_block, context \\ Context.new()) - - def eval_block(expression_block, map_context) - when is_map(map_context) and not is_struct(map_context, Context) do - eval_block(expression_block, Context.new(map_context)) - end - - def eval_block(expression_block, context) do - with {:ok, ast} <- parse_block(expression_block) do - hd(eval_block_ast(ast, context)) - end - end - - @doc """ - Evaluate a string with expressions against a given context - """ - @spec eval(expression :: String.t(), context :: Context.t()) :: [term] - def eval(expression, context \\ Context.new()) - - def eval(expression, map_context) - when is_map(map_context) and not is_struct(map_context, Context) do - eval(expression, Context.new(map_context)) - end - - def eval(expression, context) when is_binary(expression) do - with {:ok, parsed_parts} <- parse(expression) do - eval_ast(parsed_parts, context) - end - end - - @doc """ - Evaluate a parsed Expression against a given context - """ - @spec eval_ast([term], context :: Context.t()) :: [term()] - def eval_ast(parsed_parts, context \\ Context.new()) do - Enum.flat_map(parsed_parts, fn - binary when is_binary(binary) -> [binary] - ast when is_list(ast) -> eval_block_ast(ast, context) - end) - end - - @doc """ - Evaluate the given AST against a given context - """ - @spec eval_block_ast([term], context :: Context.t()) :: [term] - def eval_block_ast(ast, context) when is_list(ast) do - function = Compile.compile(ast) - resp = function.(context) - - if is_binary(resp) do - # NOTE: if the response was a binary, the user is expecting a - # a string to be returned so make sure we do that. - [eval_as_string(resp, context)] - else - [resp] - end - end - - @doc """ - Evaluate an expression and cast all items to strings before joining - the full result into a single string value to be returned. - - This calls `eval/2` internally, maps the results with `default_value/2` - followed by `stringify/1` and then joins them. - """ - @spec eval_as_string(String.t(), Context.t()) :: String.t() - def eval_as_string(expression, context \\ Context.new()) do - {:ok, ast} = parse(expression) - - ast - |> eval_ast(context) - |> Enum.zip(ast) - |> Enum.map_join("", fn - {nil, [{"__property__", _parts} = property]} -> - "@" <> unwrap_property(property) - - {value, _ast} -> - value - |> default_value(context) - |> stringify() - end) - end - - defp unwrap_property({"__property__", parts}), - do: Enum.map_join(parts, ".", &unwrap_property/1) - - defp unwrap_property(parts) when is_list(parts), - do: Enum.map_join(parts, ".", &unwrap_property/1) - - defp unwrap_property(part), do: part - - @doc """ - Return the default value for a potentially complex value. - - Complex values can be Maps that have a `__value__` key, if that's - returned then we can to use the `__value__` value when eval'ing against - operators or functions. - """ - @spec default_value(term) :: term - def default_value(val, context \\ nil) - def default_value(%{"__value__" => default_value}, _context), do: default_value - def default_value(value, _context), do: value - - @spec stringify(term) :: String.t() - def stringify(items) when is_list(items), do: Enum.map_join(items, "", &stringify/1) - def stringify(binary) when is_binary(binary), do: binary - def stringify(%DateTime{} = date), do: DateTime.to_iso8601(date) - def stringify(%Date{} = date), do: Date.to_iso8601(date) - def stringify(map) when is_map(map), do: "#{inspect(map)}" - def stringify(other), do: to_string(other) - - @spec compile(expression :: String.t()) :: [term] - def compile(expression) when is_binary(expression) do - with {:ok, parts} <- parse(expression), - parts <- Enum.map([parts], &compile_block/1) do - hd(parts) - end - end - - def compile_block({function_name, arguments}) - when is_binary(function_name) and is_list(arguments) do - [{function_name, arguments}] - |> Compile.compile() - |> compile_block() - end - - def compile_block(final), do: final - - @doc """ - Return the code generated for the Abstract Syntax tree or - Expression string provided. - """ - @spec debug(String.t() | [term]) :: String.t() - def debug(expression) when is_binary(expression) do - with {:ok, ast, "", _, _, _} <- Parser.expression(expression) do - debug(ast) - end - end - - def debug(ast) do - ast - |> Compile.to_quoted() - |> Compile.wrap_in_context() - |> Macro.to_string() - end -end diff --git a/lib/expression/v2/autodoc.ex b/lib/expression/v2/autodoc.ex deleted file mode 100644 index 92e05f1b..00000000 --- a/lib/expression/v2/autodoc.ex +++ /dev/null @@ -1,277 +0,0 @@ -defmodule Expression.V2.Autodoc do - @moduledoc """ - - Extract `@expression_doc` attributes from modules defining callbacks - and automatically write doctests for those. - - Also inserts an `expression_docs()` function which returns a list of - all functions and their defined expression docs. - - The format is: - - ```elixir - @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: "2022-01-31T00:00:00Z" - ``` - - Where: - - * `doc` is the explanatory text added to the doctest. - * `expression` is the expression we want to test - * `fake_expression` can optionally be the expression we want to display but not test - * `context` is the context the expression is tested against - * `result` is the result we're expecting to get and are asserting against - * `fake_result` can be optionally supplied when the returning result varies - depending on factors we do not control, like for `now()` for example. - When this is used, the ExDoc tests are faked and won't actually test - anything so use sparingly. - - """ - defmacro __using__(_args) do - quote do - @expression_docs [] - Module.register_attribute(__MODULE__, :expression_doc, accumulate: true) - @on_definition Expression.V2.Autodoc - @before_compile Expression.V2.Autodoc - - import Expression.V2.Autodoc - end - end - - def __on_definition__(env, :def, name, args, _guards, _body), - do: annotate_method(env.module, name, args) - - def __on_definition__(_env, _kind, _name, _args, _guards, _body), do: nil - - def annotate_method(module, function, args) do - if expression_doc = Module.delete_attribute(module, :expression_doc) do - update_annotations(module, function, args, expression_doc) - end - end - - # Ignore the expression_docs/0 function created by this macro - def update_annotations(module, :expression_docs, [], _), - do: Module.get_attribute(module, :expression_docs) - - def update_annotations(module, function, args, []) do - existing_expression_docs = Module.get_attribute(module, :expression_docs) - - {_line_number, doc} = get_existing_docstring(module) - - {function_name, function_type} = format_function_name(function) - - Module.put_attribute(module, :expression_docs, [ - {function_name, function_type, format_function_args(args), doc, []} - | existing_expression_docs - ]) - end - - def update_annotations(module, function, args, expression_docs) do - existing_expression_docs = Module.get_attribute(module, :expression_docs) - - {line_number, doc} = get_existing_docstring(module) - - expression_doc_tests = - expression_docs - |> Enum.reverse() - |> Enum.with_index(1) - |> Enum.map_join("\n", fn {expression_doc, index} -> - doc = expression_doc[:doc] - - {fake_expression?, expression} = get_expression(expression_doc) - - code_expression = expression_doc[:code_expression] || expression_doc[:expression] - context = expression_doc[:context] - - {doctest_prompt, result} = - if is_nil(expression_doc[:fake_result]) do - {"iex", expression_doc[:result]} - else - {"..$", expression_doc[:fake_result]} - end - - """ - ## Example #{index}: - #{if(doc, do: "\n> #{doc}\n", else: "")} - - When used in the following Stack expression it returns a #{format_result(result)}#{format_context(context)} - - ``` - > #{Enum.join(String.split(code_expression, "\n"), "\n> ")} - #{inspect(result)} - ``` - - When used as an expression in text, prepend it with an `@`: - - ```expression - > "... @#{expression} ..." - "#{stringify(result)}" - ``` - - #{unless(fake_expression?, do: generate_ex_doc(doctest_prompt, module, expression, context || %{}, result))} - - --- - - """ - end) - - updated_docs = - case doc do - nil -> expression_doc_tests - doc -> "#{doc}\n\n#{expression_doc_tests}" - end - - Module.put_attribute( - module, - :doc, - {line_number, updated_docs} - ) - - {function_name, function_type} = format_function_name(function) - - Module.put_attribute(module, :expression_docs, [ - {function_name, function_type, format_function_args(args), doc, - format_docs(expression_docs)} - | existing_expression_docs - ]) - end - - def get_expression(expression_doc) do - if is_nil(expression_doc[:fake_expression]) do - {false, expression_doc[:expression]} - else - {true, expression_doc[:fake_expression]} - end - end - - def generate_ex_doc(prompt \\ "iex", module, expression, context, result) do - """ - #{prompt}> # Evaluate a string with expressions - #{prompt}> import ExUnit.Assertions - #{prompt}> result = Expression.V2.eval( - ...> #{inspect("chat for @" <> expression <> " impact")}, - ...> Expression.V2.Context.new(#{inspect(context || %{})}, #{inspect(module)}) - ...> ) - #{generate_assert(prompt, ["chat for ", result, " impact"])} - #{prompt}> - #{prompt}> # Evaluate a standalone expression block - #{prompt}> result = Expression.V2.eval_block( - ...> #{inspect(expression)}, - ...> Expression.V2.Context.new(#{inspect(context || %{})}, #{inspect(module)}) - ...> ) - #{prompt}> - #{generate_assert(prompt, result)} - #{prompt}> - #{prompt}> # Evaluate a string with expressions into a single string - #{prompt}> Expression.V2.eval_as_string( - ...> #{inspect("@" <> expression)}, - ...> Expression.V2.Context.new(#{inspect(context || %{})}, #{inspect(module)}) - ...> ) - #{inspect(stringify(result))} - """ - end - - def generate_assert(prompt, result) when is_nil(result) or result == false do - Enum.join(["#{prompt}> refute result", "#{inspect(result)}"], "\n ") - end - - def generate_assert(prompt, result) do - Enum.join( - [ - "#{prompt}> assert #{inspect(result)} = result", - "#{inspect(result)}" - ], - "\n " - ) - end - - def type_of(%Time{}), do: "Time" - def type_of(%Date{}), do: "Date" - def type_of(%DateTime{}), do: "DateTime" - def type_of(boolean) when is_boolean(boolean), do: "Boolean" - def type_of(nil) when is_nil(nil), do: "Null" - def type_of(integer) when is_integer(integer), do: "Integer" - def type_of(float) when is_float(float), do: "Float" - def type_of(binary) when is_binary(binary), do: "String" - def type_of(map) when is_map(map), do: "Map" - - def type_of(list) when is_list(list), - do: "List with values " <> Enum.map_join(list, ", ", &type_of/1) - - def stringify(%{"__value__" => value}), do: Expression.stringify(value) - def stringify(value), do: Expression.stringify(value) - - def get_existing_docstring(module) do - case Module.get_attribute(module, :doc) do - {line_number, doc} -> {line_number, doc} - nil -> {0, nil} - end - end - - def format_result(%{"__value__" => value} = result) when is_map(result) do - other_fields = - result - |> Map.drop(["__value__"]) - |> Enum.map(fn {key, value} -> - "* *#{key}* of type **#{type_of(value)}**" - end) - - """ - complex **#{type_of(value)}** type of default value: - ```elixir - #{inspect(value)} - ``` - with the following fields:\n\n#{Enum.join(other_fields, "\n")} - """ - end - - def format_result(result), do: " value of type **#{type_of(result)}**: `#{inspect(result)}`" - - def format_context(nil), do: "." - - def format_context(context) do - """ - when used with the following context: - - ```elixir - #{inspect(context)} - ``` - """ - end - - def format_function_name(name) do - name = to_string(name) - - cond do - String.ends_with?(name, "_vargs") -> {String.trim_trailing(name, "_vargs"), :vargs} - String.ends_with?(name, "_") -> {String.trim_trailing(name, "_"), :reserved} - true -> {name, :direct} - end - end - - def format_function_args(args) do - [_ctx_arg | function_args] = args - - Enum.map(function_args, fn - {name, _meta, _ignored} when is_atom(name) -> to_string(name) - literal -> to_string(literal) - end) - end - - def format_docs(docs) do - Enum.map(docs, &Enum.into(&1, %{})) - end - - defmacro __before_compile__(_env) do - quote do - @doc """ - Return a list of all functions annotated with @expression_docs - """ - def expression_docs do - Enum.reverse(@expression_docs) - end - end - end -end diff --git a/lib/expression/v2/callbacks.ex b/lib/expression/v2/callbacks.ex deleted file mode 100644 index d8262311..00000000 --- a/lib/expression/v2/callbacks.ex +++ /dev/null @@ -1,114 +0,0 @@ -defmodule Expression.V2.Callbacks do - @moduledoc """ - Use this module to implement one's own callbacks. - The standard callbacks available are implemented in `Expression.V2.Callbacks.Standard`. - - ```elixir - defmodule MyCallbacks do - use Expression.V2.Callbacks - - @doc \"\"\" - Roll a dice and randomly return a number between 1 and 6. - \"\"\" - def dice_roll(ctx) do - Enum.random(1..6) - end - - end - ``` - """ - - alias Expression.V2.Callbacks.Standard - - @reserved_words ~w[and if or not] - - @doc """ - Convert a string function name into an atom meant to handle - that function - - Reserved words such as `and`, `if`, and `or` are automatically suffixed - with an `_` underscore. - """ - def atom_function_name(function_name) when function_name in @reserved_words, - do: atom_function_name("#{function_name}_") - - def atom_function_name(function_name) do - String.to_atom(function_name) - end - - @doc """ - Callback a function while evaluating the context against an expression. - - Callback functions in this module are either: - - 1. The function name as is - 2. The function name with an underscore suffix if the function name is a reserved word - 3. The function name suffixed with `_vargs` if the takes a variable set of arguments - """ - @spec callback( - module :: module, - context :: map, - function_name :: binary, - arguments :: [any] - ) :: any - def callback(module \\ Standard, context, function_name, arguments) do - case implements(module, function_name, arguments) do - {:exact, module, function_name} -> - apply( - module, - function_name, - [context] ++ Enum.map(arguments, &Expression.V2.default_value(&1, context)) - ) - - {:vargs, module, function_name} -> - apply(module, function_name, [ - context, - Enum.map(arguments, &Expression.V2.default_value(&1, context)) - ]) - - {:error, reason} -> - reason - end - end - - @spec implements(module, function_name :: String.t(), arguments :: [any]) :: - {:exact, module, function_name :: atom} - | {:vargs, module, function_name :: atom} - | {:error, reason :: String.t()} - def implements(module \\ Standard, function_name, arguments) do - exact_function_name = atom_function_name(function_name) - vargs_function_name = atom_function_name("#{function_name}_vargs") - - Code.ensure_loaded!(module) - - cond do - function_exported?(module, exact_function_name, length(arguments) + 1) -> - {:exact, module, exact_function_name} - - # Check if it's been implemented to accept a variable amount of arguments - function_exported?(module, vargs_function_name, 2) -> - {:vargs, module, vargs_function_name} - - # Check if the exact function signature has been implemented - function_exported?(Standard, exact_function_name, length(arguments) + 1) -> - {:exact, Standard, exact_function_name} - - # Check if it's been implemented to accept a variable amount of arguments - function_exported?(Standard, vargs_function_name, 2) -> - {:vargs, Standard, vargs_function_name} - - # Otherwise fail - true -> - {:error, "#{function_name} is not implemented."} - end - end - - defmacro __using__(_opts) do - quote do - def callback(module \\ __MODULE__, context, function_name, args) - - defdelegate callback(module, context, function_name, arguments), - to: Expression.V2.Callbacks - end - end -end diff --git a/lib/expression/v2/callbacks/standard.ex b/lib/expression/v2/callbacks/standard.ex deleted file mode 100644 index 0828d11d..00000000 --- a/lib/expression/v2/callbacks/standard.ex +++ /dev/null @@ -1,1340 +0,0 @@ -defmodule Expression.V2.Callbacks.Standard do - @moduledoc """ - Callback functions to be used in Expressions. - - This is the same idea as `Expression.Callbacks.Standard` but - it's in a rough shape, mostly to just prove that this all works. - """ - - use Expression.V2.Callbacks - use Expression.V2.Autodoc - - alias Expression.DateHelpers - - @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 - 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. - - """ - @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] - @expression_doc doc: "Leap year handling in a leap year.", - expression: "datetime_add(date(2020, 02, 28), 1, \"D\")", - result: ~U[2020-02-29 00:00:00.000000Z] - @expression_doc doc: "Leap year handling outside of a leap year.", - expression: "datetime_add(date(2021, 02, 28), 1, \"D\")", - result: ~U[2021-03-01 00:00:00.000000Z] - @expression_doc doc: "Negative offsets", - expression: "datetime_add(date(2020, 02, 29), -1, \"D\")", - result: ~U[2020-02-28 00:00:00.000000Z] - def datetime_add(_ctx, datetime, offset, unit) do - datetime = DateHelpers.extract_datetimeish(datetime) - - 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-02\").date", - result: ~D[2022-01-02] - @expression_doc doc: "Convert a date value and read the date field", - expression: "datevalue(date(2022, 1, 3)).date", - result: ~D[2022-01-03] - def datevalue(_ctx, date, format \\ "%Y-%m-%d %H:%M:%S") do - case DateHelpers.extract_dateish(date) do - nil -> %{"__value__" => "", "date" => nil} - date -> %{"__value__" => Timex.format!(date, format, :strftime), "date" => date} - end - 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())", - fake_result: DateTime.utc_now().day - def day(_ctx, %{day: day} = _date) do - 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 - DateHelpers.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())", - fake_result: DateTime.utc_now().hour - def hour(_ctx, %{hour: hour} = _date) do - hour - end - - @doc """ - Returns only the minute of a datetime (0 to 59) - """ - @expression_doc doc: "Get the current minute", - expression: "minute(now())", - fake_result: DateTime.utc_now().minute - def minute(_ctx, date) do - %{minute: minute} = DateHelpers.extract_datetimeish(date) - minute - end - - @doc """ - Returns only the month of a date (1 to 12) - """ - @expression_doc doc: "Get the current month", - expression: "month(now())", - fake_result: DateTime.utc_now().month - def month(_ctx, %{month: month} = _date) do - 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()}, - fake_result: DateTime.utc_now().second - def second(_ctx, %{second: second} = _date) do - 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 - %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) when is_binary(expression) do - 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()", - fake_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(date) - - 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()}, - fake_result: DateTime.utc_now().year - def year(_ctx, date) do - %{year: year} = DateHelpers.extract_dateish(date) - 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 - Enum.all?(arguments, & &1) - end - - @doc """ - Returns `false` if the argument supplied evaluates to truth-y - """ - @expression_doc expression: "not(false)", result: true - def not_(_ctx, argument) do - !argument - 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 - @expression_doc expression: "or(a, b)", - context: %{"a" => false, "b" => "bee"}, - code_expression: "a or b", - result: "bee" - @expression_doc expression: "or(a, b)", - context: %{"a" => "a", "b" => false}, - code_expression: "a or b", - result: "a" - @expression_doc expression: "or(b, b)", - context: %{}, - code_expression: "b or b", - result: false - def or_vargs(_ctx, arguments) do - Enum.reduce_while(arguments, false, fn arg, acc -> - if(arg, do: {:halt, arg}, else: {:cont, acc}) - end) - end - - @doc """ - Returns the absolute value of a number - """ - @expression_doc expression: "abs(-1)", - result: 1 - def abs(_ctx, number) do - Kernel.abs(number) - 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(arguments) - 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(arguments) - end - - @doc """ - Returns the result of a number raised to a power - equivalent to the ^ operator - """ - @expression_doc expression: "power(2, 3)", - fake_result: 8.0 - def power(_ctx, a, b) do - :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(arguments) - 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 - <> - 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 - |> 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, <>) do - 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(arguments, "") - 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, no_commas \\ false) - - def fixed(_ctx, number, precision, false), - do: Number.Delimit.number_to_delimited(number, precision: precision) - - def fixed(_ctx, number, precision, true), - do: - Number.Delimit.number_to_delimited(number, - precision: precision, - delimiter: ",", - separator: "." - ) - - @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 - 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(binary) - end - - @doc """ - Converts a text string to lowercase - """ - @expression_doc expression: "lower(\"Foo Bar\")", - result: "foo bar" - def lower(_ctx, binary) do - String.downcase(binary) - 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 - |> 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 - 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 - 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 - 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 - <> - 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, <>) do - code - end - - @doc """ - Converts a text string to uppercase - """ - @expression_doc expression: "upper(\"foo\")", - result: "FOO" - def upper(_ctx, binary) do - String.upcase(binary) - 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(binary, " ") - 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, float) do - with float when is_number(float) <- parse_float(float) do - Number.Percentage.number_to_percentage(float * 100, precision: 0) - end - 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 - |> 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 - separator = " " - tl(String.split(binary, separator)) |> Enum.join(separator) - end - - def remove_first_word(_ctx, binary, separator) do - 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 - 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 - 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 - |> String.split(@punctuation_pattern) - |> Enum.count() - end - - def word_count(_ctx, binary, by_spaces) do - 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 - 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 - cond do - stop > 0 -> - binary - |> String.split(@punctuation_pattern) - |> Enum.slice((start - 1)..(stop - 2)//1) - |> Enum.join(" ") - - stop < 0 -> - binary - |> String.split(@punctuation_pattern) - |> Enum.slice((start - 1)..(stop - 1)//1) - |> Enum.join(" ") - end - end - - def word_slice(_ctx, binary, start, stop, by_spaces) do - 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 - case var do - var when is_float(var) or is_integer(var) -> - true - - var when is_binary(var) -> - String.match?(var, ~r/^\d+?.?\d+$/) - - _var -> - false - end - 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 - var 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(binary) - - 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 - {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 = 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 - case Regex.run(~r/^#{Regex.escape(beginning)}/i, to_string(text)) do - # future match result: first - [_first | _remainder] -> true - nil -> false - 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 - !!DateHelpers.extract_dateish(expression) - 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 - found_date = DateHelpers.extract_dateish(expression) - test_date = DateHelpers.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 - found_date = DateHelpers.extract_dateish(expression) - test_date = DateHelpers.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 - found_date = DateHelpers.extract_dateish(expression) - test_date = DateHelpers.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 - 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 - group = Enum.find(groups, nil, &(&1["uuid"] == uuid)) - # future match result: group - !!group - end - - @spec extract_numberish(nil | number) :: nil | number - defp extract_numberish(nil), do: nil - defp extract_numberish(value) when is_number(value), do: value - - defp extract_numberish(expression) do - with [match] <- - Regex.run(~r/([0-9]+\.?[0-9]*)/u, replace_arabic_numerals(expression), capture: :first) do - parse_float(match) - end - end - - defp replace_arabic_numerals(expression) when is_binary(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 - - @spec parse_float(number | String.t()) :: number | nil - def parse_float(number) when is_number(number), do: number - - def parse_float(binary) when is_binary(binary) do - case Float.parse(binary) do - {float, ""} -> float - _ -> nil - 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 - @expression_doc expression: "has_number(\"\")", result: false - @expression_doc expression: "has_number(value)", context: %{"value" => nil}, result: false - - def has_number(_ctx, expression) do - 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, float) do - with number when is_number(number) <- extract_numberish(expression), - float when is_number(float) <- parse_float(float) do - # Future match result: number - float == number - else - nil -> 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, float) do - with number when is_number(number) <- extract_numberish(expression), - float when is_number(float) <- parse_float(float) do - # Future match result: number - number > float - else - nil -> 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, float) do - with number when is_number(number) <- extract_numberish(expression), - float when is_number(float) <- parse_float(float) do - # Future match result: number - number >= float - else - nil -> 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, float) do - with number when is_number(number) <- extract_numberish(expression), - float when is_number(float) <- parse_float(float) do - # Future match result: number - number < float - else - nil -> 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 - @expression_doc expression: "has_number_lte(response, 5)", - context: %{"response" => 3}, - result: true - def has_number_lte(_ctx, expression, float) do - with number when is_number(number) <- extract_numberish(expression), - float when is_number(float) <- parse_float(float) do - # Future match result: number - number <= float - else - nil -> 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 - result = Enum.map([expression, phrase], &String.downcase(String.trim(to_string(&1)))) - - case result 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 - 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 - 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 `expression` 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 - 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 - 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 - lower_expression = String.downcase(to_string(expression)) - lower_phrase = String.downcase(to_string(phrase)) - - String.contains?(lower_expression, lower_phrase) - 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 |> to_string() |> String.trim() != "" - end - - @doc """ - Tests whether `expression` contains a time. - """ - @expression_doc expression: "has_time(\"the time is 10:30\")", - result: %{"__value__" => true, "match" => ~T[10:30:00]} - @expression_doc expression: "has_time(\"the time is 10:00 pm\")", - result: %{"__value__" => true, "match" => ~T[10:00:00]} - @expression_doc expression: "has_time(\"the time is 10:30:45\")", - result: %{"__value__" => true, "match" => ~T[10:30:45]} - @expression_doc expression: "has_time(\"there is no time here, just the number 25\")", - result: false - def has_time(_ctx, expression) do - if time = DateHelpers.extract_timeish(expression) do - %{ - "__value__" => true, - "match" => time - } - else - 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 - Enum.map(enumerable, mapper) - end - - @doc """ - Return the division remainder of two integers. - """ - @expression_doc expression: "rem(4, 2)", - result: 0 - @expression_doc expression: "rem(85, 3)", - result: 1 - def rem(_ctx, integer1, integer2) do - rem(integer1, integer2) - end - - @doc """ - Appends an item or a list of items to a given list. - """ - @expression_doc expression: "append([\"A\", \"B\"], \"C\")", - result: ["A", "B", "C"] - @expression_doc expression: "append([\"A\", \"B\"], [\"C\", \"B\"])", - result: ["A", "B", "C", "B"] - def append(_ctx, list, payload) do - enumerable = if is_list(payload), do: payload, else: [payload] - - Enum.concat(list, enumerable) - end - - @doc """ - Deletes an element from a map by the given key. - """ - @expression_doc expression: "delete(patient, \"gender\")", - context: %{"patient" => %{"gender" => "?", "age" => 32}}, - result: %{"age" => 32} - def delete(_ctx, map, key) do - Map.delete(map, key) - end -end diff --git a/lib/expression/v2/compat.ex b/lib/expression/v2/compat.ex deleted file mode 100644 index f0ee85d2..00000000 --- a/lib/expression/v2/compat.ex +++ /dev/null @@ -1,262 +0,0 @@ -defmodule Expression.V2.Compat do - @moduledoc """ - Compatibility module to make the transition from V1 to V2 a bit easier, hopefully. - - It does a few things: - - * It swaps out V2 callbacks for V1 callbacks when evaluating expressions with V1. - * It does some patching of the context to match V1's assumptions: - * case insensitive context keys - * casting of integers - * casting of datetimes - * It compares the output of V1 to V2, if those aren't equal it will log an error and return the V1 response. - * If there is no error it will return the value from V2. - - > **NOTE**: This module does *twice* the work because it runs V1 and V2 sequentially - and then compares the result before returning a value. - - > **NOTE**: This was throwing more errors in prod than anticipated, hacking in a revert temporarily - """ - require Logger - - def evaluate_as_string!( - expression, - context, - callback_module \\ Expression.Callbacks.Standard - ) - - def evaluate_as_string!(expression, context, callback_module) do - v1_resp = Expression.evaluate_as_string!(expression, context, callback_module) - - # v2_resp = - # V2.eval_as_string( - # expression, - # V2.Context.new(patch_v1_context(context), callback_module) - # ) - - # return_or_raise(expression, context, v1_resp, v2_resp) - v1_resp - end - - # def v1_module(Turn.Build.Callbacks), do: Turn.Build.CallbacksV1 - # def v1_module(V2.Callbacks.Standard), do: Expression.Callbacks.Standard - - def patch_v1_key(key), - do: - key - |> to_string() - |> String.downcase() - - def patch_v1_context(datetime) when is_struct(datetime, DateTime), do: datetime - - def patch_v1_context(date) when is_struct(date, Date), do: date - - def patch_v1_context(struct) when is_struct(struct) do - Map.from_struct(struct) - |> patch_v1_context() - end - - def patch_v1_context(list) when is_list(list), do: Enum.map(list, &patch_v1_context/1) - - def patch_v1_context(map) when is_map(map) do - map - |> Enum.map(fn {key, value} -> {patch_v1_key(key), patch_v1_context(value)} end) - |> Enum.into(%{}) - end - - def patch_v1_context(binary) when is_binary(binary) do - with :nope <- attempt_integer(binary), - :nope <- attempt_float(binary), - :nope <- attempt_datetime(binary), - :nope <- attempt_boolean(binary) do - binary - end - end - - def patch_v1_context(other), do: other - - def attempt_boolean(binary) do - potential_boolean = - binary - |> String.trim() - |> String.downcase() - - case potential_boolean do - "true" -> true - "false" -> false - _other -> :nope - end - end - - # Leading plus is still parsed as an integer, which we don't want - def attempt_integer("+" <> _), do: :nope - # Leading zero likely means a string code, not an integer - def attempt_integer("0" <> binary) when byte_size(binary) > 0, do: :nope - - def attempt_integer(binary) do - String.to_integer(binary) - rescue - ArgumentError -> :nope - end - - # Leading plus is still parsed as an integer, which we don't want - def attempt_float("+" <> _), do: :nope - - def attempt_float(binary) do - String.to_float(binary) - rescue - ArgumentError -> :nope - end - - def attempt_datetime(binary) do - case DateTime.from_iso8601(binary) do - {:ok, datetime, _} -> datetime - _other -> :nope - end - end - - def evaluate!(expression, context \\ %{}, callback_module \\ Expression.Callbacks.Standard) - - def evaluate!(expression, context, callback_module) do - v1_resp = Expression.evaluate!(expression, context, callback_module) - - # v2_resp = - # V2.eval( - # expression, - # V2.Context.new(patch_v1_context(context), callback_module) - # ) - # |> hd - - # return_or_raise(expression, context, v1_resp, v2_resp) - unpack_returned_value(v1_resp) - end - - def evaluate_block!( - expression, - context \\ %{}, - callback_module \\ Callbacks.Standard, - opts \\ [] - ) - - def evaluate_block!(expression, context, callback_module, opts) do - v1_resp = - Expression.evaluate_block(expression, context, callback_module, opts) - - # v2_resp = - # case V2.eval_block( - # expression, - # V2.Context.new(patch_v1_context(context), callback_module) - # ) do - # {:error, error, reason} -> {:error, error <> " " <> reason} - # value -> {:ok, value} - # end - - # cond do - # # Hack for handling random returns from `rand_between()` callback function - # # these will throw an error because they're designed to be different every time - # String.contains?(expression, "rand_between") -> - # return_or_raise(expression, context, v2_resp, v2_resp) - - # # Hack for handling `@if` expressions, in V2 these aren't evaluated. - # # See the note for this in `eval_compat_test.exs`. - # String.contains?(String.downcase(expression), ["@if", "@left"]) -> - # return_or_raise(expression, context, v2_resp, v2_resp) - - # true -> - # return_or_raise(expression, context, v1_resp, v2_resp) - # end - unpack_returned_value(v1_resp) - end - - def unpack_returned_value({:ok, val}), do: val - def unpack_returned_value({:error, reason}), do: reason - def unpack_returned_value(other), do: other - - def return_or_raise( - _expression, - _context, - {:not_found, _v1_path} = _v1_resp, - nil = _v2_resp - ) do - nil - end - - def return_or_raise(expression, context, {:ok, val1}, {:ok, val2}) do - return_or_raise(expression, context, val1, val2) - end - - def return_or_raise(_expression, _context, {:error, error1}, {:error, _error2}) do - error1 - end - - def return_or_raise(expression, context, "2023" <> _ = v1_resp, "2023" <> _ = v2_resp) - when byte_size(v1_resp) == 10 do - {:ok, v1_resp} = Date.from_iso8601(v1_resp) - {:ok, v2_resp} = Date.from_iso8601(v2_resp) - return_or_raise(expression, context, v1_resp, v2_resp) - end - - def return_or_raise(expression, context, "2023" <> _ = v1_resp, "2023" <> _ = v2_resp) do - {:ok, v1_resp, _} = DateTime.from_iso8601(v1_resp) - {:ok, v2_resp, _} = DateTime.from_iso8601(v2_resp) - return_or_raise(expression, context, v1_resp, v2_resp) - end - - def return_or_raise(expression, context, v1_resp, v2_resp) do - cond do - is_binary(v1_resp) and is_binary(v2_resp) -> - return_or_raise_binaries(expression, context, v1_resp, v2_resp) - - is_struct(v1_resp, DateTime) and is_struct(v2_resp, DateTime) -> - if DateTime.diff(v1_resp, v2_resp) <= :timer.seconds(1) do - v2_resp - else - log_error(expression, context, v1_resp, v2_resp) - end - - normalize_value(v1_resp) == normalize_value(v2_resp) -> - v2_resp - - true -> - log_error(expression, context, v1_resp, v2_resp) - end - end - - # To minimize random errors due to the V1 & V2 expressions being evaluated at different - # times we're truncating DateTime structs to the second to give the CPU some grace - # In the `return_or_raise` we confirm that it's still within a second though and return - # the original (non truncated) value - def normalize_value(%DateTime{} = datetime), do: DateTime.truncate(datetime, :second) - def normalize_value(list) when is_list(list), do: Enum.map(list, &normalize_value/1) - - def normalize_value(map) when is_map(map) and not is_struct(map) do - map - |> Enum.map(fn {key, value} -> {key, normalize_value(value)} end) - |> Enum.into(%{}) - end - - def normalize_value(other), do: other - - def return_or_raise_binaries(expression, context, v1_resp, v2_resp) do - if String.jaro_distance(v1_resp, v2_resp) > 0.9 do - v2_resp - else - log_error(expression, context, v1_resp, v2_resp) - end - end - - def log_error(expression, context, v1_resp, v2_resp) do - Logger.error(""" - - ** Compatibility Error ** - - Expression: #{inspect(expression)} - Context: #{inspect(Map.drop(context, ["flow"]), pretty: true)} - - V1: #{inspect(v1_resp)} - V2: #{inspect(v2_resp)} - """) - - v1_resp - end -end diff --git a/lib/expression/v2/compile.ex b/lib/expression/v2/compile.ex deleted file mode 100644 index f3ffc5c4..00000000 --- a/lib/expression/v2/compile.ex +++ /dev/null @@ -1,219 +0,0 @@ -defmodule Expression.V2.Compile do - @moduledoc """ - An compiler for AST returned by Expression.V2.Parser. - - This reads the AST output returned by `Expression.V2.parse/1` and - compiles it to Elixir code. - - It does this by emitting valid Elixir AST, mimicking what `quote/2` does. - - The Elixir AST is then supplied to `Code.eval_quoted_with_env/3` without any - variable binding. What is returned is an anonymous function that accepts an - `Expression.V2.Context.t` struct and evaluates the code against that context. - - Any function calls are applied to the callback module referenced in the context. - So if an expression uses a function called `foo(1, 2, 3)` then the callback's - `callback/3` function will be called as follows: - - ```elixir - apply(context.callback_module, :callback, ["foo", [1, 2, 3]]) - ``` - - There is some special handling of some functions that have specific Elixir AST - syntax requirements. - - These are documented in the `to_quoted/2` function. - - All variables referenced by the expression are scoped to `context.vars`. - However the full context is supplied to any function calls, giving - functions the privilege of doing more than the `context.vars` scope alone - would allow them to do. - """ - - @built_ins ["*", "+", "-", "<>", ">", ">=", "<", "<=", "/", "^", "=="] - - @doc """ - Accepts AST as emitted by `Expression.V2.parse/1` and returns an anonymous function - that accepts a Context.t as an argument and returns the result of the expression - against the given Context. - - If the callback functions defined in the callback module are pure then this function - is also pure and is suitable for caching. - """ - @spec compile([any]) :: - (Expression.V2.Context.t() -> any) - def compile(ast) do - # convert to valid Elixir AST - quoted = wrap_in_context(to_quoted(ast)) - {term, _binding, _env} = Code.eval_quoted_with_env(quoted, [], Code.env_for_eval([])) - - term - end - - @doc """ - Wrap an AST block into an anonymous function that accepts - a single argument called context. - - This happens _after_ all the code generation completes. The code - generated expects a variable called `context` to exist, wrapping - it in this function ensures that it does. - - This is the anonymous function that is returned to the caller. - The caller is then responsible to call it with the correct context - variables. - """ - @spec wrap_in_context(Macro.t()) :: Macro.t() - def wrap_in_context(quoted) do - # Check to see if the generated AST makes a reference to the context. - # If that is the case then generate an AST that makes it available. - # If there are no references to the context then prefix the variable - # with an underscore to keep the compiler happy and not emit warnings - # at runtime - {quoted, uses_context?} = - Macro.prewalk(quoted, false, fn - {:context, _, _} = node, _acc -> {node, true} - other, acc -> {other, acc} - end) - - context_var = if uses_context?, do: :context, else: :_context - - {:fn, [], - [ - {:->, [], - [ - [{context_var, [], nil}], - {:__block__, [], - [ - quoted - ]} - ]} - ]} - end - - @doc """ - Convert the AST returned from `Expression.V2.parse/1` into valid Elixir AST - that can be used by `Code.eval_quoted_with_env/3`. - - There is some special handling here: - - 1. Lists are recursed to ensure that all list items are properly quoted. - 2. "\"Quoted strings\"" are unquoted and returned as regular strings to the AST. - 3. "Normal strings" are converted into Atoms and treated as such during eval. - 4. Literals such as numbers & booleans are left as is. - 5. Range.t items are converted to valid Elixir AST. - 6. `&` and `&1` captures are generated into valid Elixir AST captures. - 7. Any functions are generated as being function calls for the given callback module. - """ - @spec to_quoted([term] | term) :: Macro.t() - def to_quoted(ast) when is_list(ast) do - quoted_block = - Enum.reduce(ast, [], fn element, acc -> - [quoted(element) | acc] - end) - - {:__block__, [], quoted_block} - end - - defp quoted("\"" <> _ = binary) when is_binary(binary) do - binary - # Chop off the outer quoting - |> String.slice(1..-2//1) - # Remove the double quoting - |> String.replace("\\\"", "\"") - end - - defp quoted(number) when is_number(number), do: number - defp quoted(boolean) when is_boolean(boolean), do: boolean - - defp quoted({"__property__", [a, b]}) when is_binary(b) do - # When the property we're trying to read is a binary then we're doing - # `foo.bar` in an expression and we convert this to a `foo["bar"]` - {{:., [], [Access, :get]}, [], [quoted(a), b]} - end - - defp quoted({"__attribute__", [a, b]}) do - # Since Map keys in Expressions can either be integers or strings - # we use the helper in Expression.V2.read_attribute to read - # the correct value using Elixir function guards in compiled - # code rather than attempting to generate the AST for that here. - {{:., [], [{:__aliases__, [alias: false], [:Expression, :V2]}, :read_attribute]}, [], - [quoted(a), quoted(b)]} - end - - defp quoted({"if", [test, yes, no]}) do - # This is not handled as a callback function in the callback module - # because the arguments need to be evaluated lazily. - {:if, [], - [ - quoted(test), - [ - do: quoted(yes), - else: quoted(no) - ] - ]} - end - - defp quoted({"&", args}) do - {:&, [], Enum.map(args, "ed(&1))} - end - - defp quoted("&" <> index) do - {:&, [], [String.to_integer(index)]} - end - - defp quoted({function_name, arguments}) - when is_binary(function_name) and - function_name in @built_ins and - is_list(arguments) do - default_values = - Enum.map(arguments, fn - argument when is_integer(argument) -> - quoted(argument) - - "\"" <> _string = argument -> - quoted(argument) - - argument -> - {{:., [], [{:__aliases__, [alias: false], [:Expression, :V2]}, :default_value]}, [], - [quoted(argument), {:context, [], nil}]} - end) - - {String.to_existing_atom(function_name), [], default_values} - end - - defp quoted({function_name, arguments}) - when is_binary(function_name) and is_list(arguments) do - {:apply, [], - [ - context_dot_callback_module(), - :callback, - [{:context, [], nil}, function_name, Enum.map(arguments, "ed(&1))] - ]} - end - - defp quoted(list) when is_list(list) do - Enum.map(list, "ed(&1)) - end - - defp quoted(atom) when is_binary(atom) do - {{:., [], [Access, :get]}, [], [context_dot_vars(), atom]} - end - - defp quoted(%Range{first: first, last: last, step: step}) do - {:%, [], - [ - {:__aliases__, [], [:Range]}, - {:%{}, [], [first: first, last: last, step: step]} - ]} - end - - defp context_dot_callback_module do - # Short hand function to generate `context.callback_module` - {{:., [], [{:context, [], nil}, :callback_module]}, [no_parens: true], []} - end - - defp context_dot_vars do - # Short hand function to generate `context.vars` - {{:., [], [{:context, [], nil}, :vars]}, [no_parens: true], []} - end -end diff --git a/lib/expression/v2/context.ex b/lib/expression/v2/context.ex deleted file mode 100644 index bcde3dcb..00000000 --- a/lib/expression/v2/context.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule Expression.V2.Context do - @moduledoc """ - The context supplied to a function generated by `Expression.V2.Compile.compile/1` - - This will be expanded with support for more attributes that a callback function - can access but normal Expression evaluation can not. - """ - defstruct vars: %{}, private: %{}, callback_module: Expression.V2.Callbacks.Standard - - @type t :: %__MODULE__{ - vars: map, - private: map, - callback_module: module - } - - def new(vars \\ %{}, callback_module \\ Expression.V2.Callbacks.Standard), - do: %__MODULE__{vars: vars, callback_module: callback_module} - - def private(ctx, key, value), do: %{ctx | private: Map.put(ctx.private, key, value)} -end diff --git a/lib/expression/v2/parser.ex b/lib/expression/v2/parser.ex deleted file mode 100644 index 98a42d04..00000000 --- a/lib/expression/v2/parser.ex +++ /dev/null @@ -1,441 +0,0 @@ -defmodule Expression.V2.Parser do - @moduledoc """ - A NimbleParsec parser for FLOIP expressions. - - FLOIP Expressions consist of plain text and of blocks. Plain text is returned untouched - but blocks are evaluated. - - Blocks are prefixed with an `@` sign. Blocks can either have expressions between brackets or - be used in a shorthand form when wanting to use a single function or variable substitution. - - As an example, the following are identical: - - * `@(now())` and `@now()` - * `@contact.name` and `@(contact.name)` - - However, a full expression needs to be within brackets: - - `Tomorrow's is @(today().day + 1)` - - This parses it into an Abstract Syntax Tree (AST) which follows a style much like a Lisp would. - It parses expressions in [Infix notation](https://en.wikipedia.org/wiki/Infix_notation) such as - `1 + 1` and parses it into lists where the operator is the first element and the second element - is the list of arguments for the operator. - - ``` - ["+", [1, 1]] - ``` - - Functions are expressed as: - - ``` - {"function name", [arg1, arg2]} - ``` - - Until we have a fixed scope of allowed functions, or if we can dynamically look up whether an - `atom` is a function or a variable reference, we will need to rely on tuples to represent functions - as otherwise the system has no means to distinguish the following AST as being a function call - or a list as a variable: - - ``` - ["echo", [1, 2, 3]] - ``` - - Without being able to say _ahead_ of time whether or not `echo/1` is a known function, the - system cannot reliable determine whether the result of this AST should be `["echo", [1, 2, 3]]` - or the result of `echo(1, 2, 3)`. - - Variable references are single values. - - ``` - "contact" - ``` - - This module provides two functions for parsing. `parse/2` which will parse a full FLOIP expression - including text and blocks, and `expression/2` which will parse expression blocks. - - Internally `parse/2` refers to the same parsers as `expression/2` for things that are expressions. - """ - import NimbleParsec - import Expression.DateHelpers - - # Booleans can be spelled in any mixed case - boolean_true = - choice([string("t"), string("T")]) - |> choice([string("r"), string("R")]) - |> choice([string("u"), string("U")]) - |> choice([string("e"), string("E")]) - |> replace(true) - - boolean_false = - choice([string("f"), string("F")]) - |> choice([string("a"), string("A")]) - |> choice([string("l"), string("L")]) - |> choice([string("s"), string("S")]) - |> choice([string("e"), string("E")]) - |> replace(false) - - boolean = - choice([ - boolean_true, - boolean_false - ]) - - int = - optional(string("-")) - |> concat(ascii_string([?0..?9], min: 1)) - |> reduce({Enum, :join, [""]}) - |> map({String, :to_integer, []}) - - # These are just regular floats, previous iteration used the - # Decimal library but that just made some simple arithmetic - # and comparisons more complicated than needed to be. - float = - optional(string("-")) - |> integer(min: 1) - |> string(".") - # Using ascii string here instead of integer/2 to prevent us chopping - # off leading zeros after the period. - |> concat(ascii_string([?0..?9], min: 1)) - |> reduce({Enum, :join, [""]}) - |> map({String, :to_float, []}) - - # This is inspired by the NimbleParsec docs - # https://hexdocs.pm/nimble_parsec/NimbleParsec.html#repeat_while/4 - defparsecp( - :double_quoted_string, - ascii_char([?"]) - |> repeat_while( - choice([ - string(~S(\")) |> replace("\\\""), - utf8_char([]) - ]), - {:not_double_quote, []} - ) - |> ascii_char([?"]) - |> reduce({List, :to_string, []}) - ) - - @doc false - defp not_double_quote(<>, context, _, _), do: {:halt, context} - defp not_double_quote(_, context, _, _), do: {:cont, context} - - defparsecp( - :single_quoted_string, - ignore(ascii_char([?'])) - |> repeat_while( - choice([ - string(~S(\')) |> replace("'"), - utf8_char([]) - ]), - {:not_single_quote, []} - ) - |> ignore(ascii_char([?'])) - |> reduce(:to_double_quoted_string) - ) - - # Helper to convert a 'hello' parsed by parsec(:single_quoted_string) into - # a "hello" - def to_double_quoted_string(charlist) when is_list(charlist), - do: inspect(to_string(charlist)) - - @doc false - def not_single_quote(<>, context, _, _), do: {:halt, context} - def not_single_quote(_, context, _, _), do: {:cont, context} - - # We support single & double quoted strings. - string_with_quotes = - choice([ - parsec(:single_quoted_string), - parsec(:double_quoted_string) - ]) - - # Atoms are names, these can be variable names or function names etc. - atom = - ascii_string([?a..?z, ?A..?Z, ?0..?9], min: 1) - |> ascii_string([?a..?z, ?A..?Z, ?0..?9, ?_, ?-], min: 0) - |> map({String, :downcase, []}) - |> reduce({Enum, :join, []}) - - whitespace = - choice([ - string(" "), - string("\n"), - string("\r") - ]) - - # Helper function to wrap parsers in, resulting in them ignoring - # surrounding whitespace. - # - # This has to be an anonymous function otherwise the compiler cannot - # find it during compilation steps. - ignore_surrounding_whitespace = fn p -> - ignore(repeat(whitespace)) - |> concat(p) - |> ignore(repeat(whitespace)) - end - - list = - ignore(string("[")) - |> wrap( - repeat( - parsec(:term_operator) - |> optional(ignore(ignore_surrounding_whitespace.(string(",")))) - ) - ) - |> ignore(string("]")) - - function_arguments = - ignore(string("(")) - |> concat( - ignore_surrounding_whitespace.( - wrap( - repeat( - parsec(:term_operator) - |> optional(ignore(ignore_surrounding_whitespace.(string(",")))) - ) - ) - ) - ) - |> ignore(string(")")) - - function = - atom - |> concat(function_arguments) - |> reduce(:as_function_tuple) - - lambda_capture = - string("&") - |> concat(integer(min: 1)) - |> reduce({Enum, :join, []}) - - lambda = - string("&") - |> optional(ignore(string(" "))) - |> choice([ - # either we get a block as a function - function_arguments, - # or we refer to a function directly - wrap(parsec(:term_operator)) - ]) - |> reduce(:as_function_tuple) - - @doc false - def as_function_tuple([binary]) when is_binary(binary), do: {binary, []} - - def as_function_tuple([binary, args]) when is_binary(binary) and is_list(args), - do: {binary, args} - - range = - integer(min: 1) - |> ignore(string("..")) - |> concat(integer(min: 1)) - |> optional( - ignore(string("//")) - |> concat(integer(min: 1)) - ) - |> reduce(:ensure_range) - - @doc false - def ensure_range([first, last, step]), do: Range.new(first, last, step) - def ensure_range([first, last]), do: Range.new(first, last) - - # A block is a expression that can be parsed and is surrounded - # by opening & closing brackets - block = - ignore(string("(")) - |> parsec(:term_operator) - |> ignore(string(")")) - - # A term is the lowest level types in an Expression - term = - times( - choice([ - label(datetime(), "a datetime"), - label(date(), "a date"), - label(time(), "a time"), - label(range, "a range"), - label(float, "a float"), - label(int, "an integer"), - label(string_with_quotes, "a quoted string"), - label(lambda_capture, "a capture"), - label(lambda, "an anonymous function"), - label(function, "a function"), - label(boolean, "a boolean"), - label(atom, "an atom") - ]), - min: 1 - ) - - # Properties are only allowed to be atoms - # So if we have a foo.bar, `bar` is property the atom - # If we allow more complicated types here it causes confusion - # because in `foo.false`, `false` would be parsed as a boolean type - property = - times( - replace(string("."), "__property__") - |> concat(atom), - min: 1 - ) - - # We allow parsec operators here to allow expressions - # to be evaluated and their result to be used as a key - # to lookup an attribute value - attribute = - times( - replace(string("["), "__attribute__") - |> concat(parsec(:term_operator)) - |> ignore(string("]")), - min: 1 - ) - - property_or_attribute = repeat(choice([property, attribute])) - - # A compound term is either a term or a term made up of terms - # using a list or a block which may or may not have a property - # or an attribute - # - # As an example `foo.bar` calls the property `bar` on term `foo`. - compound_term_with_property_or_attribute = - choice([ - label(list, "a list"), - label(block, "a group"), - term - ]) - |> optional(property_or_attribute) - - # The following operators determine the order of operations, as per - # https://en.wikipedia.org/wiki/Order_of_operations - - # Normally this would also have root but we don't have a shorthand for that - # and so rather than a list of options, this is just the exponent operator. - # This has higher precedence. - exponentiation_operator = string("^") - - # Multiplication & division is second - multiplication_division_operator = - choice([ - string("*"), - string("/") - ]) - - # Addition & subtraction are last - addition_subtraction_operator = - choice([ - string("+"), - string("-"), - string("<>"), - string(">="), - string(">"), - string("!="), - string("<="), - string("<"), - string("=="), - string("&"), - replace(string("="), "==") - ]) - - # Below are the precedence parsers, each gives the higher precedence - # a change to parse its things _before_ it itself attempts to do so. - # This is how the precedence is guaranteed. - - # First operator precedence parser - defparsecp( - :exponentiation, - compound_term_with_property_or_attribute - |> label("an expression") - |> repeat( - exponentiation_operator - |> label("an operator") - |> ignore_surrounding_whitespace.() - |> concat(compound_term_with_property_or_attribute) - ) - |> reduce(:fold_infixl) - ) - - # Second operator precedence parser - defparsecp( - :multiplication_division, - parsec(:exponentiation) - |> repeat( - multiplication_division_operator - |> ignore_surrounding_whitespace.() - |> concat(parsec(:exponentiation)) - ) - |> reduce(:fold_infixl) - ) - - # Third operator precedence parser - defparsecp( - :term_operator, - parsec(:multiplication_division) - |> repeat( - addition_subtraction_operator - |> ignore_surrounding_whitespace.() - |> concat(parsec(:multiplication_division)) - ) - |> reduce(:fold_infixl) - ) - - # Parses a block such as `@(1 + 1)` - expression_block = - ignore(string("@")) - |> concat(wrap(block)) - - # Parsed a short hand such as `@now()` - expression_shorthand = - ignore(string("@")) - |> concat(wrap(reduce(compound_term_with_property_or_attribute, :fold_infixl))) - - single_at = string("@") - - # @@ should be treated as an @ - escaped_at = - ignore(string("@")) - |> string("@") - - # Parses any old text as long as it doesn't have - # any @ expression markers - text = - empty() - |> lookahead_not(string("@")) - |> utf8_string([], 1) - |> times(min: 1) - |> reduce({Enum, :join, []}) - - @doc false - def fold_infixl(acc) do - acc - |> Enum.reverse() - |> Enum.chunk_every(2) - |> List.foldr([], fn - [l], [] -> l - [r, op], l -> {op, [l, r]} - end) - end - - @doc """ - Parse a block and return the AST - - ## Example - - iex> Expression.V2.Parser.expression("contact.age + 1") - {:ok, [{"+", [{"__property__", ["contact", "age"]}, 1]}], "", %{}, {1, 0}, 15} - - """ - defparsec(:expression, parsec(:term_operator)) - - @doc """ - Parse an expression and return the AST - - ## Example - - iex> Expression.V2.Parser.parse("hello @world the time is @now()") - {:ok, ["hello ", ["world"], " the time is ", [{"now", []}]], "", %{}, {1, 0}, 31} - - """ - defparsec( - :parse, - repeat(choice([text, escaped_at, expression_block, expression_shorthand, single_at])) - ) -end diff --git a/mix.exs b/mix.exs index 29d6e09b..4225ac5b 100644 --- a/mix.exs +++ b/mix.exs @@ -42,7 +42,6 @@ defmodule Expression.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:benchee, "~> 1.0", only: :dev}, {:credo, "~> 1.5", only: [:dev], runtime: false}, {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, {:ex_doc, ">= 0.28.2", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index b1a94a80..9529383c 100644 --- a/mix.lock +++ b/mix.lock @@ -1,11 +1,9 @@ %{ - "benchee": {:hex, :benchee, "1.5.0", "4d812c31d54b0ec0167e91278e7de3f596324a78a096fd3d0bea68bb0c513b10", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "5b075393aea81b8ae74eadd1c28b1d87e8a63696c649d8293db7c4df3eb67535"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, - "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, @@ -27,7 +25,6 @@ "number": {:hex, :number, "1.0.5", "d92136f9b9382aeb50145782f116112078b3465b7be58df1f85952b8bb399b0f", [:mix], [{:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "c0733a0a90773a66582b9e92a3f01290987f395c972cb7d685f51dd927cd5169"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"}, "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, diff --git a/test/expression/v2/callbacks_test.exs b/test/expression/v2/callbacks_test.exs deleted file mode 100644 index 0f824609..00000000 --- a/test/expression/v2/callbacks_test.exs +++ /dev/null @@ -1,5 +0,0 @@ -defmodule Expression.V2.CallbacksTest do - use ExUnit.Case, async: true - doctest Expression.V2.Callbacks, import: true - doctest Expression.V2.Callbacks.Standard, import: true -end diff --git a/test/expression/v2/eval_compat_test.exs b/test/expression/v2/eval_compat_test.exs deleted file mode 100644 index 4607cd29..00000000 --- a/test/expression/v2/eval_compat_test.exs +++ /dev/null @@ -1,235 +0,0 @@ -defmodule Expression.V2.EvalCompatTest do - @moduledoc """ - This test module is copied from `Expression.EvalTest` and the function calls - have been updated to use the `Expression.V2.Compat` module to test backwards - compatibility. - - The biggest changes are in the ones calling `V2.eval_ast/1` as the return from - that in V2 is a list. The `Compat` function handles that return type difference - for us though. - - Also, V1 did some weird stuff with escaping strings and comparisons between them. - I think I'm coming to the conclusion that that may have been a bad idea and so - I'm not retrofitting that particular use case. - """ - use ExUnit.Case, async: true - alias Expression.V2 - alias Expression.V2.Compat - alias Expression.V2.Parser - - test "substitution" do - assert "bar" == Compat.evaluate_as_string!("@foo", %{"foo" => "bar"}) - end - - @tag :skip - test "substitutions in substitutions" do - assert "string with quotes \" inside" == - Compat.evaluate_block!(~S("string with quotes \" inside")) - - # I'm not sure if this is desirable behaviour to begin with - # - # # credo:disable-for-lines:2 Credo.Check.Readability.StringSigils - # assert true == - # Compat.evaluate_block!( - # "block.response = \"@@IF(cursor + 1 < total_items, \\\"Next article ➡️\\\", \\\"⏮ First article\\\")\"", - # %{ - # "block" => %{ - # "response" => - # "@IF(cursor + 1 < total_items, \"Next article ➡️\", \"⏮ First article\")" - # }, - # "cursor" => "1", - # "total_items" => "10" - # } - # ) - - assert "Your application was successful" == - Compat.evaluate_as_string!( - ~s|Your application @if(conditional, "was @confirm", "was @deny")|, - %{ - "conditional" => true, - "confirm" => "successful", - "deny" => "unsuccessful" - } - ) - - assert "Your application was unsuccessful" == - Compat.evaluate_as_string!( - ~s|Your application @if(conditional, "was @confirm", "was @deny")|, - %{ - "conditional" => false, - "confirm" => "successful", - "deny" => "unsuccessful" - } - ) - end - - test "attributes on substitutions" do - assert "baz" == Compat.evaluate_as_string!("@foo.bar", %{"foo" => %{"bar" => "baz"}}) - end - - test "attributes with literals" do - assert "value" == - Compat.evaluate_as_string!("@foo.bar.123.baz", %{ - "foo" => %{ - "bar" => %{ - "123" => %{ - "baz" => "value" - } - } - } - }) - end - - test "functions" do - {:ok, ast, "", _, _, _} = Parser.parse(~s[@has_any_word("The Quick Brown Fox", "red fox")]) - - assert [%{"__value__" => true, "match" => "Fox"}] == V2.eval_ast(ast) - end - - test "if" do - {:ok, ast, "", _, _, _} = - Parser.parse("@if(image_response.status == 200,\nimage_response.body.id,\nfalse)") - - assert [false] == - V2.eval_ast( - ast, - V2.Context.new(%{ - "image_response" => %{"status" => 500, "body" => "Internal Server Error"} - }) - ) - end - - describe "lambdas" do - test "with map" do - {:ok, ast, "", _, _, _} = Parser.parse("@map(foo, &([&1, 'Button']))") - - assert [[[1, "Button"], [2, "Button"], [3, "Button"]]] == - V2.eval_ast(ast, V2.Context.new(%{"foo" => [1, 2, 3]})) - end - - test "with functions" do - {:ok, ast, "", _, _, _} = Parser.parse("@map(1..3, &date(2022, 5, &1))") - - assert [ - [ - ~D[2022-05-01], - ~D[2022-05-02], - ~D[2022-05-03] - ] - ] == V2.eval_ast(ast, V2.Context.new(%{})) - end - - test "with arithmetic" do - {:ok, ast, "", _, _, _} = Parser.parse("@(map(foo, &([&1, 'Button'])))") - - assert [[[1, "Button"], [2, "Button"], [3, "Button"]]] == - V2.eval_ast(ast, V2.Context.new(%{"foo" => [1, 2, 3]})) - end - - test "lambda with joins" do - assert [["one", "Button one"], ["two", "Button two"], ["three", "Button three"]] == - Compat.evaluate!("@map(choices, &([&1, concatenate('Button ', &1)]))", %{ - "choices" => ["one", "two", "three"] - }) - end - end - - test "email addresses" do - assert "email info@one.two.three.four.five.six for more information" == - Compat.evaluate_as_string!( - "email info@one.two.three.four.five.six for more information", - %{} - ) - end - - test "attributes on functions" do - assert "Fox" == - Compat.evaluate_as_string!( - ~s[@has_any_word("The Quick Brown Fox", "red fox").match], - %{} - ) - end - - describe "lists" do - test "with integer indices" do - {:ok, ast, "", _, _, _} = Parser.parse("@foo[1]") - - assert [1] == V2.eval_ast(ast, V2.Context.new(%{"foo" => [0, 1, 2]})) - end - - test "with binary keys" do - {:ok, ast, "", _, _, _} = Parser.parse("@foo['a']") - - assert [1] == V2.eval_ast(ast, V2.Context.new(%{"foo" => %{"a" => 1}})) - end - - test "with binary keys as variables" do - {:ok, ast, "", _, _, _} = Parser.parse("@foo[bar]") - - assert [1] == V2.eval_ast(ast, V2.Context.new(%{"foo" => %{"a" => 1}, "bar" => "a"})) - end - - test "with binary keys as variables and strings" do - {:ok, ast, "", _, _, _} = Parser.parse("@foo[bar]['baz']") - - assert [1] == - V2.eval_ast(ast, V2.Context.new(%{"foo" => %{"a" => %{"baz" => 1}}, "bar" => "a"})) - end - - test "with function" do - {:ok, ast, "", _, _, _} = Parser.parse("@foo[day(now())]") - today = DateTime.utc_now().day - assert [today] == V2.eval_ast(ast, V2.Context.new(%{"foo" => Enum.to_list(0..31)})) - end - - test "with range slices" do - {:ok, ast, "", _, _, _} = Parser.parse("@foo[1..3]") - assert [[1, 2, 3]] == V2.eval_ast(ast, V2.Context.new(%{"foo" => Enum.to_list(0..31)})) - end - end - - test "arithmetic" do - {:ok, ast, "", _, _, _} = Parser.parse("@(1 + 1)") - assert [2] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(1 + 2 * 3)") - assert [7] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@((1 + 2) * 3)") - assert [9] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@((1 + 2) / 3)") - assert [1.0] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(6 / 2 + 1)") - assert [4.0] == V2.eval_ast(ast, V2.Context.new(%{})) - end - - test "arithmetic with decimals" do - {:ok, ast, "", _, _, _} = Parser.parse("@(1.5 + 1.5)") - assert [3.0] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(1.5 + 2.5 * 3.5)") - assert [10.25] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@((1.5 + 2.5) * 3.5)") - assert [14.00] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@((1.5 + 2.5) / 3.5)") - assert [1.1428571428571428] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(6.8 / 2.0 + 1.5)") - assert [4.9] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(2.002 * 0.05)") - assert [0.10010] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(2 > 0.5)") - assert [true] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(2 >= 2.0)") - assert [true] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(2 < 0.5)") - assert [false] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(2 <= 2.0)") - assert [true] == V2.eval_ast(ast, V2.Context.new(%{})) - {:ok, ast, "", _, _, _} = Parser.parse("@(2 == 2.0)") - assert [true] == V2.eval_ast(ast, V2.Context.new(%{})) - end - - test "text" do - assert "hello Bob" == - Compat.evaluate_as_string!("hello @contact.name", %{ - "contact" => %{"name" => "Bob"} - }) - end -end diff --git a/test/expression/v2/eval_test.exs b/test/expression/v2/eval_test.exs deleted file mode 100644 index 734317ad..00000000 --- a/test/expression/v2/eval_test.exs +++ /dev/null @@ -1,174 +0,0 @@ -defmodule Expression.V2.EvalTest do - use ExUnit.Case, async: true - doctest Expression.V2 - - alias Expression.V2 - alias Expression.V2.Compile - alias Expression.V2.Context - alias Expression.V2.Parser - - def eval(binary, vars \\ %{}, opts \\ []) do - {:ok, ast, "", _, _, _} = Parser.expression(binary) - context = Context.new(vars) - debug = opts[:debug] || false - - if debug do - IO.puts("ast: #{inspect(ast)}") - - ast - |> V2.debug() - |> IO.puts() - end - - case Compile.compile(ast) do - result when is_function(result) -> result.(context) - result -> result - end - end - - describe "map as context" do - test "eval_block accepts a map" do - assert V2.eval_block("contact.name", %{"contact" => %{"name" => "Mary"}}) == "Mary" - end - - test "eval accepts a map" do - assert V2.eval("hello @contact.name", %{"contact" => %{"name" => "Mary"}}) == [ - "hello ", - "Mary" - ] - end - end - - describe "eval_as_string" do - test "with missing vars" do - assert "hello @one.two.three" == Expression.V2.eval_as_string("hello @one.two.three") - end - end - - describe "eval" do - test "with missing vars" do - assert nil == Expression.V2.eval_block("one.two.three") - end - - test "vars" do - assert "bar" == eval("foo", %{"foo" => "bar"}) - end - - test "properties" do - assert "baz" == eval("foo.bar", %{"foo" => %{"bar" => "baz"}}) - end - - test "attributes as vars" do - assert "qux" == eval("foo[bar]", %{"foo" => %{"baz" => "qux"}, "bar" => "baz"}) - end - - test "attributes as literals" do - assert "qux" == eval("foo[\"baz\"]", %{"foo" => %{"baz" => "qux"}}) - end - - test "attribute and property combination" do - assert "hi" == - eval( - "after_hours.rows[random_row].label.bar", - %{ - "after_hours" => %{"rows" => [%{"label" => %{"bar" => "hi"}}]}, - "random_row" => 0 - } - ) - end - - test "indices on lists" do - assert "qux" == eval("foo[2]", %{"foo" => [1, 2, "qux"]}) - end - - test "function calls" do - assert "Hi" == eval("proper(\"hi\")") - end - - test "arithmetic" do - assert 5 == eval("1 * 5") - end - - test "arithmetic against default values" do - assert 50 == - eval( - "5 * foo", - %{ - "foo" => %{ - "__value__" => 10, - "other_attributes" => "ignored" - } - } - ) - end - - test "precedence" do - assert 12 == eval("2 * 5 + 2") - end - - test "groups" do - assert 21 == eval("3 * (5 + 2)") - end - - test "functions with vars" do - assert 50 == eval("day(date(2023, 2, 10)) * 5") - end - - test "functions with default values" do - assert 50 = eval("day(foo) * 5", %{"foo" => %{"__value__" => Date.new!(2023, 2, 10)}}) - end - - test "ints & floats" do - assert true == eval("1.0 <= 1") - assert true == eval("1.0 == 1") - assert true == eval("1.0 = 1") - assert false == eval("1.0 > 1") - assert true == eval("1.1 > 1") - end - - test "if" do - assert 1 == eval("if(something.true, 1, 0)", %{"something" => %{"true" => true}}) - - assert nil == - eval( - "if(something.false, 1, contact.bar)", - %{"something" => %{"false" => false}, "contact" => %{}} - ) - end - - test "complex values" do - assert %{"__value__" => true, "match" => "Fox"} == - eval(~S|has_any_word("The Quick Brown Fox", "red fox")|) - - assert "Fox" == - eval(~S|has_any_word("The Quick Brown Fox", "red fox").match|) - end - - test "lambda & map" do - ctx = %{"foo" => [1, 2, 3]} - assert [1, 2, 3] == eval("map(foo, &(&1))", ctx) - - assert [[1, "Button"], [2, "Button"], [3, "Button"]] = - eval("map(foo, &([&1, \"Button\"]))", ctx) - - assert [[1, "Button 1"], [2, "Button 2"], [3, "Button 3"]] = - eval(~S|map(foo, &([&1, concatenate("Button ", &1)]))|, ctx) - end - - test "lambda & map with single quoted strings" do - assert [ - ["one", "Button One"], - ["two", "Button Two"], - ["three", "Button Three"], - ["four", "Button Four"], - ["five", "Button Five"] - ] == - eval( - ~S|map(options, &([&1, concatenate('Button ', proper(&1))]))|, - %{ - "options" => ["one", "two", "three", "four", "five"] - } - ) - end - end -end diff --git a/test/expression/v2/parser_test.exs b/test/expression/v2/parser_test.exs deleted file mode 100644 index 1692cecc..00000000 --- a/test/expression/v2/parser_test.exs +++ /dev/null @@ -1,224 +0,0 @@ -defmodule Expression.V2.ParserTest do - use ExUnit.Case, async: true - doctest Expression.V2.Parser - - alias Expression.V2.Parser - - describe "mixed" do - test "expression/1 with plain text" do - assert {:ok, ["hi"], "", _, _, _} = Parser.parse("hi") - assert {:ok, ["hi ", [1]], "", _, _, _} = Parser.parse("hi @(1)") - end - - test "escaping @" do - assert {:ok, ["foo", "@", "bar.com"], "", _, _, _} = Parser.parse("foo@@bar.com") - end - - test "lone @" do - assert {:ok, ["foo ", "@", " something is bar"], "", _, _, _} = - Parser.parse("foo @ something is bar") - end - end - - describe "expression/1 primitives" do - test "int" do - assert {:ok, [[1]], "", _, _, _} = Parser.parse("@(1)") - end - - test "string" do - assert {:ok, [["\"hello\""]], "", _, _, _} = Parser.parse("@(\"hello\")") - end - - test "float" do - assert {:ok, [[1.123456789]], "", _, _, _} = Parser.parse("@(1.1234567890)") - end - - test "atom" do - assert {:ok, [["foo"]], "", _, _, _} = Parser.parse("@(foo)") - end - end - - describe "functions" do - test "expression/1" do - assert {:ok, ["hi ", [{"now", []}]], "", _, _, _} = Parser.parse("hi @now()") - assert {:ok, ["hi ", [{"now", [1, 2]}]], "", _, _, _} = Parser.parse("hi @now(1, 2)") - assert {:ok, ["hi ", [{"now", [1, 2]}]], "", _, _, _} = Parser.parse("hi @(now(1, 2))") - end - - test "expression/1 nested" do - assert {:ok, [[{"now", [1, 2, {"foo", [1, 2, 3]}]}]], "", _, _, _} = - Parser.parse("@(now(1, 2, foo(1, 2, 3)))") - end - end - - describe "groups" do - test "expression/1" do - assert {:ok, [[{"+", [1, 1]}]], "", _, _, _} = Parser.parse("@(1 + 1)") - end - - test "expression/1 with functions" do - assert {:ok, [[{"+", [{"+", [{"foo", []}, 1]}, 1]}]], "", _, _, _} = - Parser.parse("@(foo() + 1 + 1)") - end - - test "operator precedence" do - assert {:ok, [[{"+", [1, {"*", [2, 3]}]}]], "", _, _, _} = Parser.parse("@(1 + 2 * 3)") - end - - test "grouping" do - assert {:ok, [[{"*", [1, {"+", [2, 3]}]}]], "", _, _, _} = Parser.parse("@(1 * (2 + 3))") - end - - test "grouping with function calls" do - assert {:ok, [[{"+", [{"*", [1, {"+", [2, 3]}]}, {"foo", []}]}]], "", _, _, _} = - Parser.parse("@(1 * (2 + 3) + foo())") - end - - test "grouping in function arguments" do - assert {:ok, - [ - [ - {"function", [1, {"*", [{"+", [2, 3]}, 4]}, {"other_function", []}]} - ] - ], "", _, _, _} = Parser.parse("@(function(1, (2 + 3) * 4, other_function()))") - end - end - - describe "properties" do - test "direct" do - assert {:ok, [[{"__property__", ["foo", "bar"]}]], "", _, _, _} = Parser.parse("@(foo.bar)") - end - - test "nested" do - assert {:ok, - [ - [ - {"__property__", [{"__property__", ["foo", "bar"]}, "baz"]} - ] - ], "", _, _, _} = Parser.parse("@(foo.bar.baz)") - end - - test "when called on function results" do - assert {:ok, [[{"__property__", [{"function", []}, "bar"]}]], "", _, _, _} = - Parser.parse("@(function().bar)") - end - end - - describe "lists" do - test "plain" do - assert {:ok, [[[1, 2, 3]]], "", _, _, _} = Parser.parse("@([1,2,3])") - end - - test "in lambda" do - assert {:ok, [[{"map", ["foo", {"&", [["&1", "\"Button\""]]}]}]], "", _, _, _} = - Parser.parse("@map(foo, &([&1, \"Button\"]))") - end - end - - describe "ranges" do - test "range" do - assert {:ok, [[1..10]], "", _, _, _} = Parser.parse("@(1..10)") - end - - test "range with step" do - assert {:ok, [[1..10//5]], "", _, _, _} = Parser.parse("@(1..10//5)") - end - end - - describe "lambdas" do - test "parsing lambdas" do - assert {:ok, [[{"map", ["foo", {"&", ["&1"]}]}]], "", _, _, _} = - Parser.parse("@map(foo, &(&1))") - end - end - - describe "attributes" do - test "when nested" do - assert {:ok, - [ - [ - {"__attribute__", [{"__attribute__", ["foo", "bar"]}, "baz"]} - ] - ], "", _, _, _} = Parser.parse("@(foo[bar][baz])") - end - - test "when indexed" do - assert {:ok, [[{"__attribute__", ["foo", 1]}]], "", _, _, _} = Parser.parse("@(foo[1])") - end - - test "when string keys" do - assert {:ok, [[{"__attribute__", ["foo", "\"bar\""]}]], "", _, _, _} = - Parser.parse("@(foo[\"bar\"])") - end - - test "when function values" do - assert {:ok, [[{"__attribute__", ["foo", {"today", []}]}]], "", _, _, _} = - Parser.parse("@(foo[today()])") - end - - test "when called on function results" do - assert {:ok, [[{"__attribute__", [{"function", []}, 0]}]], "", _, _, _} = - Parser.parse("@(function()[0])") - end - - test "when called on properties" do - assert {:ok, - [ - [ - {"__attribute__", - [{"__property__", [{"__property__", ["foo", "bar"]}, "baz"]}, 0]} - ] - ], "", _, _, _} = Parser.parse("@(foo.bar.baz[0])") - end - end - - describe "shorthand" do - test "mixed with function" do - assert {:ok, ["hi ", [{"now", []}]], "", _, _, _} = Parser.parse("hi @now()") - end - - test "function" do - assert {:ok, [[{"now", []}]], "", _, _, _} = Parser.parse("@now()") - end - - test "function with arguments" do - assert {:ok, [[{"now", [1, 2.3]}]], "", _, _, _} = Parser.parse("@now(1, 2.3)") - end - - test "nested functions" do - assert {:ok, [[{"first", [{"second", [1, 2, 3]}, 4]}]], "", _, _, _} = - Parser.parse("@first(second(1, 2, 3), 4)") - end - - test "short hand properties" do - assert {:ok, [[{"__property__", ["bar", "com"]}]], "", _, _, _} = Parser.parse("@bar.com") - end - - test "short hand attributes" do - assert {:ok, [[{"__attribute__", ["bar", "com"]}]], "", _, _, _} = Parser.parse("@bar[com]") - end - end - - describe "error handling" do - test "bad expression" do - assert {:ok, ["foo"], " is bar", _, _, _} = Parser.expression("foo is bar") - end - - test "bad grammar" do - assert {:error, "expected an atom while processing an expression", "?", _, _, _} = - Parser.expression("?") - end - end - - describe "failing examples" do - test "*BOLD*" do - assert {:ok, - [ - "\nHi there ", - [{"__property__", ["contact", "whatsapp_profile_name"]}], - "\n\n*HANGMAN*)\n)\n" - ], "", _, _, _} = - Parser.parse("\nHi there @contact.whatsapp_profile_name\n\n*HANGMAN*)\n)\n") - end - end -end diff --git a/test/expression/v2_test.exs b/test/expression/v2_test.exs deleted file mode 100644 index dad61e69..00000000 --- a/test/expression/v2_test.exs +++ /dev/null @@ -1,41 +0,0 @@ -defmodule Expression.V2Test do - use ExUnit.Case, async: true - alias Expression.V2 - - describe "code gen" do - test "code gen using context" do - assert String.trim(""" - fn context -> - Expression.V2.default_value(context.vars["foo"], context) + - Expression.V2.default_value(context.vars["bar"], context) - end - """) == V2.debug("foo + bar") - end - - test "code gen not using context" do - assert String.trim(""" - fn _context -> ["one", "two"] end - """) == V2.debug(~S|["one", "two"]|) - end - - test "default values not used for integers and builtins" do - assert "fn _context -> 1 + 1 end" == V2.debug("1 + 1") - end - - test "default values not used for strings and builtins" do - assert String.trim(""" - fn _context -> "hello" == "bye" end - """) == - V2.debug(~S|"hello" == "bye"|) - end - - test "default values used for variables and builtins" do - assert String.trim(""" - fn context -> - Expression.V2.default_value(context.vars["a"], context) * - Expression.V2.default_value(context.vars["b"], context) - end - """) == V2.debug("a * b") - end - end -end From a73befc1aa578ead72fb0cca6a9c7e65745766a7 Mon Sep 17 00:00:00 2001 From: nathanbegbie Date: Fri, 23 Jan 2026 09:49:53 +0300 Subject: [PATCH 2/2] Version Bump 3.0.0 Removal of these modules consitutes a breaking change, so we're doing a major version bump --- README.md | 4 ++-- mix.exs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 13bc5320..7f0e54e6 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ by adding `expression` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:expression, "~> 2.48.0"} + {:expression, "~> 3.0.0"} ] end ``` @@ -152,7 +152,7 @@ To publish a new release: 2. Merge the version bump to `develop` via a Pull Request 3. Go to the [GitHub Releases page](https://github.com/turnhub/expression/releases) 4. Click "Draft a new release" -5. Create a new tag matching the version (e.g., `2.48.0`) +5. Create a new tag matching the version (e.g., `3.0.0`) 6. Fill in the release notes and publish The release workflow will verify that `mix.exs` and `README.md` versions match the release tag, then publish to Hex.pm. If versions don't match, the workflow will fail with an error message. diff --git a/mix.exs b/mix.exs index 4225ac5b..de3fb496 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Expression.MixProject do use Mix.Project - @version "2.48.0" + @version "3.0.0" def project do [