diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da3cd57..5b58174 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -199,39 +199,3 @@ jobs: steps: - name: All checks passed run: echo "All quality checks have passed successfully!" - - docs: - name: Generate Documentation - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Elixir - uses: erlef/setup-beam@v1 - with: - elixir-version: '1.17' - otp-version: '27' - - - name: Cache dependencies - uses: actions/cache@v4 - with: - path: | - deps - _build - key: deps-${{ runner.os }}-27-1.17-${{ hashFiles('**/mix.lock') }} - restore-keys: | - deps-${{ runner.os }}-27-1.17- - - - name: Install dependencies - run: mix deps.get - - - name: Generate docs - run: mix docs - - - name: Upload documentation artifacts - uses: actions/upload-artifact@v3 - with: - name: documentation - path: doc/ diff --git a/CLAUDE.md b/CLAUDE.md index 88dc269..3c8a1b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This document provides context for Claude Code when working on the Predicator pr ## Project Overview -Predicator is a secure, non-evaluative condition engine for processing end-user boolean predicates in Elixir. It provides a complete compilation pipeline from string expressions to executable instructions without the security risks of dynamic code execution. Supports comparison operators (>, <, >=, <=, =, !=), logical operators (AND, OR, NOT) with proper precedence, date/datetime literals, list literals, and membership operators (in, contains). +Predicator is a secure, non-evaluative condition engine for processing end-user boolean predicates in Elixir. It provides a complete compilation pipeline from string expressions to executable instructions without the security risks of dynamic code execution. Supports comparison operators (>, <, >=, <=, =, !=), logical operators (AND, OR, NOT) with proper precedence, date/datetime literals, list literals, membership operators (in, contains), and function calls with built-in system functions. ## Architecture @@ -22,7 +22,8 @@ logical_or → logical_and ( ("OR" | "or") logical_and )* logical_and → logical_not ( ("AND" | "and") logical_not )* logical_not → ("NOT" | "not") logical_not | comparison comparison → primary ( ( ">" | "<" | ">=" | "<=" | "=" | "!=" | "in" | "contains" ) primary )? -primary → NUMBER | STRING | BOOLEAN | DATE | DATETIME | IDENTIFIER | list | "(" expression ")" +primary → NUMBER | STRING | BOOLEAN | DATE | DATETIME | IDENTIFIER | list | function_call | "(" expression ")" +function_call → IDENTIFIER "(" ( expression ( "," expression )* )? ")" list → "[" ( expression ( "," expression )* )? "]" ``` @@ -32,7 +33,12 @@ list → "[" ( expression ( "," expression )* )? "]" - **Parser** (`lib/predicator/parser.ex`): Recursive descent parser building AST - **Compiler** (`lib/predicator/compiler.ex`): Converts AST to executable instructions - **Evaluator** (`lib/predicator/evaluator.ex`): Executes instructions against data -- **StringVisitor** (`lib/predicator/string_visitor.ex`): Converts AST back to strings +- **Visitors** (`lib/predicator/visitors/`): AST transformation modules + - **StringVisitor**: Converts AST back to strings + - **InstructionsVisitor**: Converts AST to executable instructions +- **Functions** (`lib/predicator/functions/`): Function system components + - **SystemFunctions**: Built-in system functions (len, upper, abs, max, etc.) + - **Registry**: Custom function registration and dispatch - **Main API** (`lib/predicator.ex`): Public interface with convenience functions ## Development Commands @@ -93,22 +99,45 @@ lib/predicator/ ├── parser.ex # Recursive descent parser ├── compiler.ex # AST to instructions conversion ├── evaluator.ex # Instruction execution engine -├── string_visitor.ex # AST to string decompilation ├── visitor.ex # Visitor behavior definition -├── types.ex # Type specifications -└── application.ex # OTP application +├── types.ex # Type specifications +├── application.ex # OTP application +├── functions/ # Function system components +│ ├── system_functions.ex # Built-in functions (len, upper, abs, etc.) +│ └── registry.ex # Function registration and dispatch +└── visitors/ # AST transformation modules + ├── string_visitor.ex # AST to string decompilation + └── instructions_visitor.ex # AST to instructions conversion test/predicator/ ├── lexer_test.exs ├── parser_test.exs ├── compiler_test.exs ├── evaluator_test.exs -├── string_visitor_test.exs -└── predicator_test.exs # Integration tests +├── predicator_test.exs # Integration tests +├── functions/ # Function system tests +│ ├── system_functions_test.exs +│ └── registry_test.exs +└── visitors/ # Visitor tests + ├── string_visitor_test.exs + └── instructions_visitor_test.exs ``` ## Recent Additions (2025) +### Function Call System +- **Built-in Functions**: System functions automatically available + - **String functions**: `len(string)`, `upper(string)`, `lower(string)`, `trim(string)` + - **Numeric functions**: `abs(number)`, `max(a, b)`, `min(a, b)` + - **Date functions**: `year(date)`, `month(date)`, `day(date)` +- **Custom Functions**: Register anonymous functions with `Predicator.register_function/3` +- **Function Registry**: ETS-based registry with arity validation and error handling +- **Examples**: + - `len(name) > 5` + - `upper(status) = "ACTIVE"` + - `year(created_date) = 2024` + - `max(score1, score2) > 85` + ### Date and DateTime Support - **Syntax**: `#2024-01-15#` (date), `#2024-01-15T10:30:00Z#` (datetime) - **Lexer**: Added date tokenization with ISO 8601 parsing diff --git a/README.md b/README.md index 70553bf..1f81056 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A secure, non-evaluative condition engine for processing end-user boolean predic - 📅 **Date Support**: Native date and datetime literals with ISO 8601 format - 📋 **Lists**: List literals with membership operations (`in`, `contains`) - 🧠 **Smart Logic**: Logical operators with proper precedence (`AND`, `OR`, `NOT`) +- 🔧 **Functions**: Built-in functions for string, numeric, and date operations ## Quick Start @@ -59,6 +60,16 @@ true iex> Predicator.evaluate!("(score > 85 OR admin) AND active", %{"score" => 80, "admin" => true, "active" => true}) true +# Built-in functions +iex> Predicator.evaluate!("len(name) > 3", %{"name" => "Alice"}) +true + +iex> Predicator.evaluate!("upper(role) = \"ADMIN\"", %{"role" => "admin"}) +true + +iex> Predicator.evaluate!("year(created_at) = 2024", %{"created_at" => ~D[2024-03-15]}) +true + # Compile once, evaluate many times for performance iex> {:ok, instructions} = Predicator.compile("score > threshold AND active") iex> Predicator.evaluate!(instructions, %{"score" => 95, "threshold" => 80, "active" => true}) @@ -69,7 +80,7 @@ iex> Predicator.evaluate("score > 85", %{"score" => 92}) {:ok, true} iex> Predicator.evaluate("invalid >> syntax", %{}) -{:error, "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found '>' at line 1, column 10"} +{:error, "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found '>' at line 1, column 10"} # Using evaluate/1 for expressions without context (strings or instruction lists) iex> Predicator.evaluate("#2024-01-15# > #2024-01-10#") @@ -109,6 +120,30 @@ iex> Predicator.decompile(ast) | `in` | Element in collection | `role in ["admin", "manager"]` | | `contains` | Collection contains element | `[1, 2, 3] contains 2` | +### Built-in Functions + +#### String Functions +| Function | Description | Example | +|----------|-------------|---------| +| `len(string)` | String length | `len(name) > 3` | +| `upper(string)` | Convert to uppercase | `upper(role) = "ADMIN"` | +| `lower(string)` | Convert to lowercase | `lower(name) = "alice"` | +| `trim(string)` | Remove whitespace | `len(trim(input)) > 0` | + +#### Numeric Functions +| Function | Description | Example | +|----------|-------------|---------| +| `abs(number)` | Absolute value | `abs(balance) < 100` | +| `max(a, b)` | Maximum of two numbers | `max(score1, score2) > 85` | +| `min(a, b)` | Minimum of two numbers | `min(age, 65) >= 18` | + +#### Date Functions +| Function | Description | Example | +|----------|-------------|---------| +| `year(date)` | Extract year | `year(created_at) = 2024` | +| `month(date)` | Extract month | `month(birthday) = 12` | +| `day(date)` | Extract day | `day(deadline) <= 15` | + ## Data Types - **Numbers**: `42`, `-17` (integers) @@ -137,7 +172,8 @@ logical_or → logical_and ( ("OR" | "or") logical_and )* logical_and → logical_not ( ("AND" | "and") logical_not )* logical_not → ("NOT" | "not") logical_not | comparison comparison → primary ( ( ">" | "<" | ">=" | "<=" | "=" | "!=" | "in" | "contains" ) primary )? -primary → NUMBER | STRING | BOOLEAN | DATE | DATETIME | IDENTIFIER | list | "(" expression ")" +primary → NUMBER | STRING | BOOLEAN | DATE | DATETIME | IDENTIFIER | function_call | list | "(" expression ")" +function_call → FUNCTION_NAME "(" ( expression ( "," expression )* )? ")" list → "[" ( expression ( "," expression )* )? "]" ``` @@ -147,7 +183,12 @@ list → "[" ( expression ( "," expression )* )? "]" - **Parser** (`Predicator.Parser`): Builds Abstract Syntax Tree with error reporting - **Compiler** (`Predicator.Compiler`): Converts AST to executable instructions - **Evaluator** (`Predicator.Evaluator`): Executes instructions against data -- **StringVisitor** (`Predicator.StringVisitor`): Converts AST back to expressions +- **Visitors**: AST transformation modules + - **StringVisitor** (`Predicator.Visitors.StringVisitor`): Converts AST back to expressions + - **InstructionsVisitor** (`Predicator.Visitors.InstructionsVisitor`): Converts AST to instructions +- **Functions**: Function system components + - **SystemFunctions** (`Predicator.Functions.SystemFunctions`): Built-in system functions + - **Registry** (`Predicator.Functions.Registry`): Function registration and dispatch ## Error Handling @@ -158,11 +199,43 @@ iex> Predicator.evaluate("score >> 85", %{}) {:error, "Unexpected character '>' at line 1, column 8"} iex> Predicator.evaluate("score AND", %{}) -{:error, "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found end of input at line 1, column 1"} +{:error, "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found end of input at line 1, column 1"} ``` ## Advanced Usage +### Custom Function Registration + +You can register your own custom functions for use in expressions: + +```elixir +# Register a simple function +Predicator.register_function("double", 1, fn [n], _context -> + {:ok, n * 2} +end) + +# Use in expressions +iex> Predicator.evaluate("double(score) > 100", %{"score" => 60}) +{:ok, true} + +# Context-aware function +Predicator.register_function("user_role", 0, fn [], context -> + {:ok, Map.get(context, "current_user_role", "guest")} +end) + +iex> Predicator.evaluate("user_role() = \"admin\"", %{"current_user_role" => "admin"}) +{:ok, true} + +# Function with error handling +Predicator.register_function("divide", 2, fn [a, b], _context -> + if b == 0 do + {:error, "Division by zero"} + else + {:ok, a / b} + end +end) +``` + ### String Formatting Options The StringVisitor supports multiple formatting modes: diff --git a/lib/predicator.ex b/lib/predicator.ex index 5c1d854..c958fec 100644 --- a/lib/predicator.ex +++ b/lib/predicator.ex @@ -47,6 +47,7 @@ defmodule Predicator do """ alias Predicator.{Compiler, Evaluator, Lexer, Parser, Types} + alias Predicator.Functions.{Registry, SystemFunctions} @doc """ Evaluates a predicate expression or instruction list with an empty context. @@ -82,7 +83,7 @@ defmodule Predicator do # Parse errors are returned iex> Predicator.evaluate("invalid >> syntax") - {:error, "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found '>' at line 1, column 10"} + {:error, "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found '>' at line 1, column 10"} """ @spec evaluate(binary() | Types.instruction_list()) :: Types.result() def evaluate(input) do @@ -127,7 +128,7 @@ defmodule Predicator do # Parse errors are returned iex> Predicator.evaluate("score >", %{}) - {:error, "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found end of input at line 1, column 8"} + {:error, "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found end of input at line 1, column 8"} """ @spec evaluate(binary() | Types.instruction_list(), Types.context()) :: Types.result() def evaluate(input, context) @@ -201,7 +202,7 @@ defmodule Predicator do [["load", "score"], ["lit", 85], ["compare", "GT"]] iex> Predicator.compile("score >") - {:error, "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found end of input at line 1, column 8"} + {:error, "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found end of input at line 1, column 8"} """ @spec compile(binary()) :: {:ok, Types.instruction_list()} | {:error, binary()} def compile(expression) when is_binary(expression) do @@ -344,4 +345,78 @@ defmodule Predicator do def run_evaluator(%Evaluator{} = evaluator) do Evaluator.run(evaluator) end + + # Custom Function Registration API + + @doc """ + Registers a custom function with the given name, arity, and implementation. + + This is a convenience wrapper around `Predicator.FunctionRegistry.register_function/3`. + + ## Parameters + + - `name` - Function name as it will appear in expressions + - `arity` - Number of arguments the function expects + - `impl` - Function implementation that takes `(args, context)` and returns `{:ok, result}` or `{:error, message}` + + ## Examples + + # Simple function without context + Predicator.register_function("double", 1, fn [n], _context -> + {:ok, n * 2} + end) + + # Function that uses context + Predicator.register_function("user_role", 0, fn [], context -> + {:ok, Map.get(context, "user_role", "guest")} + end) + + # Use the function in expressions + Predicator.evaluate("double(5)") # => {:ok, 10} + Predicator.evaluate("user_role() = \"admin\"", %{"user_role" => "admin"}) # => {:ok, true} + """ + @spec register_function(binary(), non_neg_integer(), function()) :: :ok + def register_function(name, arity, impl) do + Registry.register_function(name, arity, impl) + end + + @doc """ + Lists all registered functions (both built-in and custom). + + Returns information about each registered function including name and arity. + Built-in functions like len, max, etc. are included in the results. + + ## Examples + + iex> # Register system functions first to ensure they're available + iex> Predicator.Functions.SystemFunctions.register_all() + :ok + iex> functions = Predicator.list_custom_functions() + iex> Enum.any?(functions, fn f -> f.name == "len" end) + true + iex> Enum.any?(functions, fn f -> f.name == "max" end) + true + """ + @spec list_custom_functions() :: [map()] + def list_custom_functions do + Registry.list_functions() + |> Enum.map(fn %{name: name, arity: arity} -> %{name: name, arity: arity} end) + end + + @doc """ + Clears all registered custom functions but preserves built-in functions. + + This is primarily useful for testing scenarios where you want to reset + custom functions without losing the built-in ones (len, max, etc.). + + ## Examples + + iex> Predicator.clear_custom_functions() + :ok + """ + @spec clear_custom_functions() :: :ok + def clear_custom_functions do + Registry.clear_registry() + SystemFunctions.register_all() + end end diff --git a/lib/predicator/application.ex b/lib/predicator/application.ex index 24f0dfa..49a83c2 100644 --- a/lib/predicator/application.ex +++ b/lib/predicator/application.ex @@ -5,12 +5,16 @@ defmodule Predicator.Application do use Application + alias Predicator.Functions.{Registry, SystemFunctions} + @impl Application def start(_type, _args) do - children = [ - # Starts a worker by calling: Predicator.Worker.start_link(arg) - # {Predicator.Worker, arg} - ] + # Initialize the function registry and register system functions immediately + Registry.start_registry() + SystemFunctions.register_all() + + # Start supervisor with empty children list since initialization is done + children = [] # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options diff --git a/lib/predicator/compiler.ex b/lib/predicator/compiler.ex index 1716fba..30371b0 100644 --- a/lib/predicator/compiler.ex +++ b/lib/predicator/compiler.ex @@ -19,7 +19,8 @@ defmodule Predicator.Compiler do # "digraph {...}" """ - alias Predicator.{InstructionsVisitor, Parser, StringVisitor, Visitor} + alias Predicator.{Parser, Visitor} + alias Predicator.Visitors.{InstructionsVisitor, StringVisitor} @doc """ Converts an AST to stack machine instructions. diff --git a/lib/predicator/evaluator.ex b/lib/predicator/evaluator.ex index 1b55f50..831684e 100644 --- a/lib/predicator/evaluator.ex +++ b/lib/predicator/evaluator.ex @@ -4,8 +4,20 @@ defmodule Predicator.Evaluator do The evaluator executes a list of instructions using a stack machine approach. Instructions operate on a stack, with the most recent values at the top (head of list). + + Supported instruction types: + - `["lit", value]` - Push literal value onto stack + - `["load", variable_name]` - Load variable from context onto stack + - `["compare", operator]` - Compare top two stack values with operator + - `["and"]` - Logical AND of top two boolean values + - `["or"]` - Logical OR of top two boolean values + - `["not"]` - Logical NOT of top boolean value + - `["in"]` - Membership test (element in collection) + - `["contains"]` - Membership test (collection contains element) + - `["call", function_name, arg_count]` - Call function with arguments from stack """ + alias Predicator.Functions.Registry alias Predicator.Types @typedoc "Internal evaluator state" @@ -187,6 +199,12 @@ defmodule Predicator.Evaluator do execute_membership(evaluator, :contains) end + # Function call instruction + defp execute_instruction(%__MODULE__{} = evaluator, ["call", function_name, arg_count]) + when is_binary(function_name) and is_integer(arg_count) and arg_count >= 0 do + execute_function_call(evaluator, function_name, arg_count) + end + # Unknown instruction - catch-all clause defp execute_instruction(%__MODULE__{}, unknown) do {:error, "Unknown instruction: #{inspect(unknown)}"} @@ -336,6 +354,27 @@ defmodule Predicator.Evaluator do "#{String.upcase(to_string(operation))} requires two values on stack, got: #{length(stack)}"} end + @spec execute_function_call(t(), binary(), non_neg_integer()) :: {:ok, t()} | {:error, term()} + defp execute_function_call(%__MODULE__{stack: stack} = evaluator, function_name, arg_count) do + if length(stack) >= arg_count do + # Extract arguments from stack (they're in reverse order) + {args, remaining_stack} = Enum.split(stack, arg_count) + # Reverse args to get correct order (stack is LIFO) + function_args = Enum.reverse(args) + + case Registry.call(function_name, function_args, evaluator.context) do + {:ok, result} -> + {:ok, %__MODULE__{evaluator | stack: [result | remaining_stack]}} + + {:error, message} -> + {:error, message} + end + else + {:error, + "Function #{function_name}() expects #{arg_count} arguments, but only #{length(stack)} values on stack"} + end + end + @spec load_from_context(Types.context(), binary()) :: Types.value() defp load_from_context(context, variable_name) when is_map(context) and is_binary(variable_name) do diff --git a/lib/predicator/functions/registry.ex b/lib/predicator/functions/registry.ex new file mode 100644 index 0000000..e8df897 --- /dev/null +++ b/lib/predicator/functions/registry.ex @@ -0,0 +1,183 @@ +defmodule Predicator.Functions.Registry do + @moduledoc """ + Function registry for custom predicator functions. + + This module provides a simple way to register custom functions using anonymous functions. + + ## Function Registration + + # Register anonymous functions + Predicator.FunctionRegistry.register_function("double", 1, fn [n], _context -> + {:ok, n * 2} + end) + + # Register with arity validation + Predicator.FunctionRegistry.register_function("add", 2, fn [a, b], _context -> + {:ok, a + b} + end) + + ## Function Contract + + All custom functions must: + - Accept two parameters: `[arg1, arg2, ...]` and `context` + - Return `{:ok, result}` on success or `{:error, message}` on failure + - The context is always available but can be ignored if not needed + + ## Usage in Expressions + + # Using registered functions in predicator expressions + Predicator.evaluate("double(value)", %{"value" => 5}) + # => {:ok, 10} + + Predicator.evaluate("add(x, y) > 10", %{"x" => 5, "y" => 7}) + # => {:ok, true} + """ + + alias Predicator.Types + + @type function_impl :: ([Types.value()], Types.context() -> + {:ok, Types.value()} | {:error, binary()}) + @type function_info :: %{ + name: binary(), + arity: non_neg_integer(), + impl: function_impl() + } + + # Global registry state + @registry_name :predicator_function_registry + + @doc """ + Starts the function registry. + + This is typically called automatically when the application starts. + """ + @spec start_registry :: :ok + def start_registry do + :ets.new(@registry_name, [:set, :public, :named_table]) + :ok + end + + @doc """ + Registers a single function with name, arity, and implementation. + + ## Parameters + + - `name` - Function name as it appears in expressions + - `arity` - Number of arguments the function expects + - `impl` - Function implementation that takes `(args, context)` and returns `{:ok, result}` or `{:error, message}` + + ## Examples + + # Simple function + FunctionRegistry.register_function("double", 1, fn [n], _context -> + {:ok, n * 2} + end) + + # Function that uses context + FunctionRegistry.register_function("current_user", 0, fn [], context -> + {:ok, Map.get(context, "current_user", "anonymous")} + end) + """ + @spec register_function(binary(), non_neg_integer(), function_impl()) :: :ok + def register_function(name, arity, impl) + when is_binary(name) and is_integer(arity) and arity >= 0 do + ensure_registry_exists() + + function_info = %{ + name: name, + arity: arity, + impl: impl + } + + :ets.insert(@registry_name, {name, function_info}) + :ok + end + + @doc """ + Calls a registered custom function. + + ## Parameters + + - `name` - Function name + - `args` - List of argument values + - `context` - Evaluation context + + ## Returns + + - `{:ok, result}` - Function executed successfully + - `{:error, message}` - Function call error + """ + @spec call(binary(), [Types.value()], Types.context()) :: + {:ok, Types.value()} | {:error, binary()} + def call(name, args, context) when is_binary(name) and is_list(args) and is_map(context) do + ensure_registry_exists() + + case :ets.lookup(@registry_name, name) do + [{_name, %{arity: arity, impl: impl}}] -> + if length(args) == arity do + try do + impl.(args, context) + rescue + error -> + {:error, "Function #{name}() failed: #{Exception.message(error)}"} + end + else + {:error, "Function #{name}() expects #{arity} arguments, got #{length(args)}"} + end + + [] -> + {:error, "Unknown function: #{name}"} + end + end + + @doc """ + Lists all registered custom functions. + + Returns a list of function information maps containing name, arity, and implementation. + """ + @spec list_functions() :: [function_info()] + def list_functions do + ensure_registry_exists() + + :ets.tab2list(@registry_name) + |> Enum.map(fn {_key, function_info} -> function_info end) + |> Enum.sort_by(& &1.name) + end + + @doc """ + Checks if a function is registered. + + ## Examples + + iex> FunctionRegistry.function_registered?("my_func") + true + + iex> FunctionRegistry.function_registered?("unknown") + false + """ + @spec function_registered?(binary()) :: boolean() + def function_registered?(name) when is_binary(name) do + ensure_registry_exists() + :ets.member(@registry_name, name) + end + + @doc """ + Clears all registered functions. + + This is primarily useful for testing. + """ + @spec clear_registry() :: :ok + def clear_registry do + ensure_registry_exists() + :ets.delete_all_objects(@registry_name) + :ok + end + + # Ensure the ETS table exists + defp ensure_registry_exists do + case :ets.whereis(@registry_name) do + :undefined -> start_registry() + _table_exists -> :ok + end + end +end diff --git a/lib/predicator/functions/system_functions.ex b/lib/predicator/functions/system_functions.ex new file mode 100644 index 0000000..4eafa0a --- /dev/null +++ b/lib/predicator/functions/system_functions.ex @@ -0,0 +1,226 @@ +defmodule Predicator.Functions.SystemFunctions do + @moduledoc """ + Built-in helper functions for use in predicator expressions. + + This module provides a collection of utility functions that can be called + within predicator expressions using function call syntax. These functions + operate on values from the evaluation context and return computed results. + + ## Available Functions + + ### String Functions + - `len(string)` - Returns the length of a string + - `upper(string)` - Converts string to uppercase + - `lower(string)` - Converts string to lowercase + - `trim(string)` - Removes leading and trailing whitespace + + ### Numeric Functions + - `abs(number)` - Returns the absolute value of a number + - `max(a, b)` - Returns the larger of two numbers + - `min(a, b)` - Returns the smaller of two numbers + + ### Date Functions + - `year(date)` - Extracts the year from a date or datetime + - `month(date)` - Extracts the month from a date or datetime + - `day(date)` - Extracts the day from a date or datetime + + ## Examples + + iex> Predicator.BuiltInFunctions.call("len", ["hello"]) + {:ok, 5} + + iex> Predicator.BuiltInFunctions.call("upper", ["world"]) + {:ok, "WORLD"} + + iex> Predicator.BuiltInFunctions.call("max", [10, 5]) + {:ok, 10} + + iex> Predicator.BuiltInFunctions.call("unknown", []) + {:error, "Unknown function: unknown"} + """ + + alias Predicator.Functions.Registry + alias Predicator.Types + + @type function_result :: {:ok, Types.value()} | {:error, binary()} + + @doc """ + Registers all built-in functions with the function registry. + + This should be called during application startup to make built-in functions + available through the unified registry interface. + + ## Examples + + iex> Predicator.BuiltInFunctions.register_all() + :ok + """ + @spec register_all :: :ok + def register_all do + # String functions + Registry.register_function("len", 1, &call_len/2) + Registry.register_function("upper", 1, &call_upper/2) + Registry.register_function("lower", 1, &call_lower/2) + Registry.register_function("trim", 1, &call_trim/2) + + # Numeric functions + Registry.register_function("abs", 1, &call_abs/2) + Registry.register_function("max", 2, &call_max/2) + Registry.register_function("min", 2, &call_min/2) + + # Date functions + Registry.register_function("year", 1, &call_year/2) + Registry.register_function("month", 1, &call_month/2) + Registry.register_function("day", 1, &call_day/2) + + :ok + end + + # String function implementations + + @spec call_len([Types.value()], Types.context()) :: function_result() + defp call_len([value], _context) when is_binary(value) do + {:ok, String.length(value)} + end + + defp call_len([_value], _context) do + {:error, "len() expects a string argument"} + end + + defp call_len(_args, _context) do + {:error, "len() expects exactly 1 argument"} + end + + @spec call_upper([Types.value()], Types.context()) :: function_result() + defp call_upper([value], _context) when is_binary(value) do + {:ok, String.upcase(value)} + end + + defp call_upper([_value], _context) do + {:error, "upper() expects a string argument"} + end + + defp call_upper(_args, _context) do + {:error, "upper() expects exactly 1 argument"} + end + + @spec call_lower([Types.value()], Types.context()) :: function_result() + defp call_lower([value], _context) when is_binary(value) do + {:ok, String.downcase(value)} + end + + defp call_lower([_value], _context) do + {:error, "lower() expects a string argument"} + end + + defp call_lower(_args, _context) do + {:error, "lower() expects exactly 1 argument"} + end + + @spec call_trim([Types.value()], Types.context()) :: function_result() + defp call_trim([value], _context) when is_binary(value) do + {:ok, String.trim(value)} + end + + defp call_trim([_value], _context) do + {:error, "trim() expects a string argument"} + end + + defp call_trim(_args, _context) do + {:error, "trim() expects exactly 1 argument"} + end + + # Numeric function implementations + + @spec call_abs([Types.value()], Types.context()) :: function_result() + defp call_abs([value], _context) when is_integer(value) do + {:ok, abs(value)} + end + + defp call_abs([_value], _context) do + {:error, "abs() expects a numeric argument"} + end + + defp call_abs(_args, _context) do + {:error, "abs() expects exactly 1 argument"} + end + + @spec call_max([Types.value()], Types.context()) :: function_result() + defp call_max([a, b], _context) when is_integer(a) and is_integer(b) do + {:ok, max(a, b)} + end + + defp call_max([_a, _b], _context) do + {:error, "max() expects two numeric arguments"} + end + + defp call_max(_args, _context) do + {:error, "max() expects exactly 2 arguments"} + end + + @spec call_min([Types.value()], Types.context()) :: function_result() + defp call_min([a, b], _context) when is_integer(a) and is_integer(b) do + {:ok, min(a, b)} + end + + defp call_min([_a, _b], _context) do + {:error, "min() expects two numeric arguments"} + end + + defp call_min(_args, _context) do + {:error, "min() expects exactly 2 arguments"} + end + + # Date function implementations + + @spec call_year([Types.value()], Types.context()) :: function_result() + defp call_year([%Date{year: year}], _context) do + {:ok, year} + end + + defp call_year([%DateTime{year: year}], _context) do + {:ok, year} + end + + defp call_year([_value], _context) do + {:error, "year() expects a date or datetime argument"} + end + + defp call_year(_args, _context) do + {:error, "year() expects exactly 1 argument"} + end + + @spec call_month([Types.value()], Types.context()) :: function_result() + defp call_month([%Date{month: month}], _context) do + {:ok, month} + end + + defp call_month([%DateTime{month: month}], _context) do + {:ok, month} + end + + defp call_month([_value], _context) do + {:error, "month() expects a date or datetime argument"} + end + + defp call_month(_args, _context) do + {:error, "month() expects exactly 1 argument"} + end + + @spec call_day([Types.value()], Types.context()) :: function_result() + defp call_day([%Date{day: day}], _context) do + {:ok, day} + end + + defp call_day([%DateTime{day: day}], _context) do + {:ok, day} + end + + defp call_day([_value], _context) do + {:error, "day() expects a date or datetime argument"} + end + + defp call_day(_args, _context) do + {:error, "day() expects exactly 1 argument"} + end +end diff --git a/lib/predicator/lexer.ex b/lib/predicator/lexer.ex index 6c19428..9077898 100644 --- a/lib/predicator/lexer.ex +++ b/lib/predicator/lexer.ex @@ -1,4 +1,7 @@ defmodule Predicator.Lexer do + # Disable credo checks that are inherent to recursive descent parsing + # credo:disable-for-this-file Credo.Check.Refactor.Nesting + @moduledoc """ Lexical analyzer for predicator expressions. @@ -57,6 +60,7 @@ defmodule Predicator.Lexer do | {:comma, pos_integer(), pos_integer(), pos_integer(), binary()} | {:in_op, pos_integer(), pos_integer(), pos_integer(), binary()} | {:contains_op, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:function_name, pos_integer(), pos_integer(), pos_integer(), binary()} | {:eof, pos_integer(), pos_integer(), pos_integer(), nil} @typedoc """ @@ -164,12 +168,32 @@ defmodule Predicator.Lexer do token = {:integer, line, col, consumed, number} tokenize_chars(remaining, line, col + consumed, [token | tokens]) - # Identifiers + # Identifiers (including potential function calls) c when (c >= ?a and c <= ?z) or (c >= ?A and c <= ?Z) or c == ?_ -> {identifier, remaining, consumed} = take_identifier([char | rest]) - {token_type, value} = classify_identifier(identifier) - token = {token_type, line, col, consumed, value} - tokenize_chars(remaining, line, col + consumed, [token | tokens]) + + # Check if this is a function call by looking ahead for '(' + case skip_whitespace(remaining) do + [?( | _rest] -> + # Check if this identifier is a keyword that should not become a function + case classify_identifier(identifier) do + {:identifier, _value} -> + # This is a regular identifier followed by '(', so it's a function call + token = {:function_name, line, col, consumed, identifier} + tokenize_chars(remaining, line, col + consumed, [token | tokens]) + + {token_type, value} -> + # This is a keyword, keep it as the keyword (don't make it a function) + token = {token_type, line, col, consumed, value} + tokenize_chars(remaining, line, col + consumed, [token | tokens]) + end + + _no_function_call -> + # Regular identifier or keyword + {token_type, value} = classify_identifier(identifier) + token = {token_type, line, col, consumed, value} + tokenize_chars(remaining, line, col + consumed, [token | tokens]) + end # Operators ?> -> @@ -303,6 +327,14 @@ defmodule Predicator.Lexer do defp classify_identifier("contains"), do: {:contains_op, "contains"} defp classify_identifier(id), do: {:identifier, id} + # Helper function to skip whitespace characters for lookahead + @spec skip_whitespace(charlist()) :: charlist() + defp skip_whitespace([?\s | rest]), do: skip_whitespace(rest) + defp skip_whitespace([?\t | rest]), do: skip_whitespace(rest) + defp skip_whitespace([?\n | rest]), do: skip_whitespace(rest) + defp skip_whitespace([?\r | rest]), do: skip_whitespace(rest) + defp skip_whitespace(chars), do: chars + @spec take_string(charlist(), binary(), pos_integer()) :: {:ok, binary(), charlist(), pos_integer()} | {:error, binary()} defp take_string([], _acc, _count), do: {:error, "Unterminated string literal"} diff --git a/lib/predicator/parser.ex b/lib/predicator/parser.ex index 173dfcb..3249111 100644 --- a/lib/predicator/parser.ex +++ b/lib/predicator/parser.ex @@ -18,7 +18,8 @@ defmodule Predicator.Parser do logical_and → logical_not ( "AND" logical_not )* logical_not → "NOT" logical_not | comparison comparison → primary ( ( ">" | "<" | ">=" | "<=" | "=" | "!=" | "in" | "contains" ) primary )? - primary → NUMBER | STRING | BOOLEAN | DATE | DATETIME | IDENTIFIER | list | "(" expression ")" + primary → NUMBER | STRING | BOOLEAN | DATE | DATETIME | IDENTIFIER | function_call | list | "(" expression ")" + function_call → FUNCTION_NAME "(" ( expression ( "," expression )* )? ")" list → "[" ( expression ( "," expression )* )? "]" ## Examples @@ -54,6 +55,7 @@ defmodule Predicator.Parser do - `{:logical_not, operand}` - A logical NOT expression - `{:list, elements}` - A list literal - `{:membership, operator, left, right}` - A membership operation (in/contains) + - `{:function_call, name, arguments}` - A function call with arguments """ @type ast :: {:literal, value()} @@ -64,6 +66,7 @@ defmodule Predicator.Parser do | {:logical_or, ast(), ast()} | {:logical_not, ast()} | {:list, [ast()]} + | {:function_call, binary(), [ast()]} @typedoc """ Comparison operators in the AST. @@ -338,6 +341,11 @@ defmodule Predicator.Parser do {:ok, {:identifier, value}, advance(state)} end + # Parse function call + defp parse_primary_token(state, {:function_name, _line, _col, _len, name}) do + parse_function_call(state, name) + end + # Parse parenthesized expression defp parse_primary_token(state, {:lparen, _line, _col, _len, _value}) do paren_state = advance(state) @@ -367,7 +375,7 @@ defmodule Predicator.Parser do # Handle unexpected tokens defp parse_primary_token(_state, {type, line, col, _len, value}) do - expected = "number, string, boolean, date, datetime, identifier, list, or '('" + expected = "number, string, boolean, date, datetime, identifier, function call, list, or '('" {:error, "Expected #{expected} but found #{format_token(type, value)}", line, col} end @@ -423,6 +431,7 @@ defmodule Predicator.Parser do defp format_token(:lbracket, _value), do: "'['" defp format_token(:rbracket, _value), do: "']'" defp format_token(:comma, _value), do: "','" + defp format_token(:function_name, value), do: "function '#{value}'" defp format_token(:eof, _value), do: "end of input" # Parse list literals: [element1, element2, ...] @@ -481,4 +490,75 @@ defmodule Predicator.Parser do {:error, message, line, col} end end + + # Parse function call: function_name(arg1, arg2, ...) + @spec parse_function_call(parser_state(), binary()) :: + {:ok, ast(), parser_state()} | {:error, binary(), pos_integer(), pos_integer()} + defp parse_function_call(state, function_name) do + # Consume function name token + func_state = advance(state) + + case peek_token(func_state) do + {:lparen, _line, _col, _len, _value} -> + # Consume opening parenthesis + paren_state = advance(func_state) + + case peek_token(paren_state) do + # Empty argument list + {:rparen, _line, _col, _len, _value} -> + {:ok, {:function_call, function_name, []}, advance(paren_state)} + + # Non-empty argument list + _token -> + case parse_function_arguments(paren_state, []) do + {:ok, arguments, final_state} -> + case peek_token(final_state) do + {:rparen, _line, _col, _len, _value} -> + {:ok, {:function_call, function_name, Enum.reverse(arguments)}, + advance(final_state)} + + {type, line, col, _len, value} -> + {:error, "Expected ')' but found #{format_token(type, value)}", line, col} + + nil -> + {:error, "Expected ')' but reached end of input", 1, 1} + end + + {:error, message, line, col} -> + {:error, message, line, col} + end + end + + {type, line, col, _len, value} -> + {:error, "Expected '(' after function name but found #{format_token(type, value)}", line, + col} + + nil -> + {:error, "Expected '(' after function name but reached end of input", 1, 1} + end + end + + # Parse function arguments recursively + @spec parse_function_arguments(parser_state(), [ast()]) :: + {:ok, [ast()], parser_state()} | {:error, binary(), pos_integer(), pos_integer()} + defp parse_function_arguments(state, acc) do + case parse_expression(state) do + {:ok, argument, new_state} -> + new_acc = [argument | acc] + + case peek_token(new_state) do + {:comma, _line, _col, _len, _value} -> + # More arguments, consume comma and continue + comma_state = advance(new_state) + parse_function_arguments(comma_state, new_acc) + + _token -> + # No more arguments + {:ok, new_acc, new_state} + end + + {:error, message, line, col} -> + {:error, message, line, col} + end + end end diff --git a/lib/predicator/types.ex b/lib/predicator/types.ex index 4b48e88..0ac4617 100644 --- a/lib/predicator/types.ex +++ b/lib/predicator/types.ex @@ -46,6 +46,9 @@ defmodule Predicator.Types do - `["and"]` - Logical AND of top two boolean values - `["or"]` - Logical OR of top two boolean values - `["not"]` - Logical NOT of top boolean value + - `["in"]` - Membership test (element in collection) + - `["contains"]` - Membership test (collection contains element) + - `["call", binary(), integer()]` - Call built-in function with arguments from stack ## Examples @@ -55,6 +58,7 @@ defmodule Predicator.Types do ["and"] # Pop two boolean values, push AND result ["or"] # Pop two boolean values, push OR result ["not"] # Pop one boolean value, push NOT result + ["call", "len", 1] # Pop 1 argument, call len function, push result """ @type instruction :: [binary() | value()] diff --git a/lib/predicator/instructions_visitor.ex b/lib/predicator/visitors/instructions_visitor.ex similarity index 83% rename from lib/predicator/instructions_visitor.ex rename to lib/predicator/visitors/instructions_visitor.ex index 40c102b..97377e4 100644 --- a/lib/predicator/instructions_visitor.ex +++ b/lib/predicator/visitors/instructions_visitor.ex @@ -1,4 +1,4 @@ -defmodule Predicator.InstructionsVisitor do +defmodule Predicator.Visitors.InstructionsVisitor do @moduledoc """ Visitor that converts AST nodes to stack machine instructions. @@ -9,20 +9,24 @@ defmodule Predicator.InstructionsVisitor do ## Examples iex> ast = {:literal, 42} - iex> Predicator.InstructionsVisitor.visit(ast, []) + iex> Predicator.Visitors.InstructionsVisitor.visit(ast, []) [["lit", 42]] iex> ast = {:identifier, "score"} - iex> Predicator.InstructionsVisitor.visit(ast, []) + iex> Predicator.Visitors.InstructionsVisitor.visit(ast, []) [["load", "score"]] iex> ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} - iex> Predicator.InstructionsVisitor.visit(ast, []) + iex> Predicator.Visitors.InstructionsVisitor.visit(ast, []) [["load", "score"], ["lit", 85], ["compare", "GT"]] iex> ast = {:logical_and, {:literal, true}, {:literal, false}} - iex> Predicator.InstructionsVisitor.visit(ast, []) + iex> Predicator.Visitors.InstructionsVisitor.visit(ast, []) [["lit", true], ["lit", false], ["and"]] + + iex> ast = {:function_call, "len", [{:identifier, "name"}]} + iex> Predicator.Visitors.InstructionsVisitor.visit(ast, []) + [["load", "name"], ["call", "len", 1]] """ @behaviour Predicator.Visitor @@ -115,6 +119,17 @@ defmodule Predicator.InstructionsVisitor do left_instructions ++ right_instructions ++ op_instruction end + def visit({:function_call, function_name, arguments}, opts) do + # Post-order traversal: arguments first (in order), then function call + arg_instructions = + arguments + |> Enum.flat_map(fn arg -> visit(arg, opts) end) + + call_instruction = [["call", function_name, length(arguments)]] + + arg_instructions ++ call_instruction + end + # Helper function to map AST comparison operators to instruction format @spec map_comparison_op(Parser.comparison_op()) :: binary() defp map_comparison_op(:gt), do: "GT" diff --git a/lib/predicator/string_visitor.ex b/lib/predicator/visitors/string_visitor.ex similarity index 87% rename from lib/predicator/string_visitor.ex rename to lib/predicator/visitors/string_visitor.ex index e6f7d91..56bbe62 100644 --- a/lib/predicator/string_visitor.ex +++ b/lib/predicator/visitors/string_visitor.ex @@ -1,4 +1,4 @@ -defmodule Predicator.StringVisitor do +defmodule Predicator.Visitors.StringVisitor do @moduledoc """ Visitor that converts AST nodes back to string expressions. @@ -9,28 +9,32 @@ defmodule Predicator.StringVisitor do ## Examples iex> ast = {:literal, 42} - iex> Predicator.StringVisitor.visit(ast, []) + iex> Predicator.Visitors.StringVisitor.visit(ast, []) "42" iex> ast = {:identifier, "score"} - iex> Predicator.StringVisitor.visit(ast, []) + iex> Predicator.Visitors.StringVisitor.visit(ast, []) "score" iex> ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} - iex> Predicator.StringVisitor.visit(ast, []) + iex> Predicator.Visitors.StringVisitor.visit(ast, []) "score > 85" iex> ast = {:comparison, :eq, {:identifier, "name"}, {:literal, "John"}} - iex> Predicator.StringVisitor.visit(ast, []) + iex> Predicator.Visitors.StringVisitor.visit(ast, []) ~s(name = "John") iex> ast = {:logical_and, {:literal, true}, {:literal, false}} - iex> Predicator.StringVisitor.visit(ast, []) + iex> Predicator.Visitors.StringVisitor.visit(ast, []) "true AND false" iex> ast = {:logical_not, {:literal, true}} - iex> Predicator.StringVisitor.visit(ast, []) + iex> Predicator.Visitors.StringVisitor.visit(ast, []) "NOT true" + + iex> ast = {:function_call, "len", [{:identifier, "name"}]} + iex> Predicator.Visitors.StringVisitor.visit(ast, []) + "len(name)" """ @behaviour Predicator.Visitor @@ -163,6 +167,12 @@ defmodule Predicator.StringVisitor do end end + def visit({:function_call, function_name, arguments}, opts) do + arg_strings = Enum.map(arguments, fn arg -> visit(arg, opts) end) + args_str = Enum.join(arg_strings, ", ") + "#{function_name}(#{args_str})" + end + # Helper functions @spec format_operator(Parser.comparison_op()) :: binary() diff --git a/mix.exs b/mix.exs index ccc15d5..bf154df 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule Predicator.MixProject do use Mix.Project @version "1.0.0" - @source_url "https://github.com/predicator/predicator_elixir" + @source_url "https://github.com/riddler/predicator-ex" def project do [ diff --git a/test/custom_functions_integration_test.exs b/test/custom_functions_integration_test.exs new file mode 100644 index 0000000..f2a3f8a --- /dev/null +++ b/test/custom_functions_integration_test.exs @@ -0,0 +1,241 @@ +defmodule CustomFunctionsIntegrationTest do + use ExUnit.Case, async: true + + import Predicator + + # CompanyFunctions module removed - using manual registration instead + + setup do + # Clear custom functions before each test + clear_custom_functions() + :ok + end + + describe "simple function registration" do + test "registers and uses simple anonymous functions" do + # Register simple functions + register_function("double", 1, fn [n], _context -> + {:ok, n * 2} + end) + + register_function("concat", 2, fn [a, b], _context -> + {:ok, a <> b} + end) + + # Test in expressions + assert {:ok, 10} = evaluate("double(5)") + assert {:ok, "helloworld"} = evaluate(~s|concat("hello", "world")|) + assert {:ok, true} = evaluate("double(5) > 8") + assert {:ok, false} = evaluate(~s|len(concat("ab", "cd")) > 5|) + end + + test "context-aware functions" do + register_function("user_name", 0, fn [], context -> + {:ok, Map.get(context, "user_name", "anonymous")} + end) + + register_function("is_admin", 0, fn [], context -> + role = Map.get(context, "role") + {:ok, role == "admin"} + end) + + # Test with context + context = %{"user_name" => "Alice", "role" => "admin"} + assert {:ok, true} = evaluate("user_name() = \"Alice\"", context) + assert {:ok, true} = evaluate("is_admin() AND user_name() = \"Alice\"", context) + + # Test without context + assert {:ok, true} = evaluate("user_name() = \"anonymous\"") + assert {:ok, false} = evaluate("is_admin()") + end + end + + describe "module-based registration" do + test "registers and uses module-based functions" do + # Register company functions manually + register_function("employee_count", 1, fn [dept_id], context -> + company_id = Map.get(context, "company_id") + + counts = %{ + {"eng", 123} => 45, + {"sales", 123} => 12, + {"hr", 123} => 5 + } + + count = Map.get(counts, {dept_id, company_id}, 0) + {:ok, count} + end) + + register_function("is_business_hours", 0, fn [], context -> + current_hour = Map.get(context, "current_hour", 12) + timezone = Map.get(context, "timezone", "UTC") + + business_hours = + case timezone do + "PST" -> 9..17 + "EST" -> 12..20 + _other_timezone -> 8..16 + end + + {:ok, current_hour in business_hours} + end) + + register_function("department_budget", 1, fn [dept_id], context -> + budgets = Map.get(context, "budgets", %{}) + budget = Map.get(budgets, dept_id, 0) + {:ok, budget} + end) + + context = %{ + "company_id" => 123, + "current_hour" => 14, + "timezone" => "PST", + "budgets" => %{"eng" => 500_000, "sales" => 200_000} + } + + # Test individual functions + assert {:ok, 45} = evaluate("employee_count(\"eng\")", context) + assert {:ok, true} = evaluate("is_business_hours()", context) + assert {:ok, 500_000} = evaluate("department_budget(\"eng\")", context) + + # Test in complex expressions + assert {:ok, true} = evaluate("employee_count(\"eng\") > 40", context) + + assert {:ok, true} = + evaluate(~s|is_business_hours() AND employee_count("eng") > 30|, context) + + assert {:ok, false} = + evaluate(~s|department_budget("sales") > department_budget("eng")|, context) + end + + test "functions work with different contexts" do + # Register business hours function manually + register_function("is_business_hours", 0, fn [], context -> + current_hour = Map.get(context, "current_hour", 12) + timezone = Map.get(context, "timezone", "UTC") + + business_hours = + case timezone do + "PST" -> 9..17 + "EST" -> 12..20 + _other_timezone -> 8..16 + end + + {:ok, current_hour in business_hours} + end) + + # Morning context (not business hours for PST) + morning_context = %{"company_id" => 123, "current_hour" => 7, "timezone" => "PST"} + assert {:ok, false} = evaluate("is_business_hours()", morning_context) + + # Business hours context + work_context = %{"company_id" => 123, "current_hour" => 14, "timezone" => "PST"} + assert {:ok, true} = evaluate("is_business_hours()", work_context) + + # Different timezone + est_context = %{"company_id" => 123, "current_hour" => 14, "timezone" => "EST"} + assert {:ok, true} = evaluate("is_business_hours()", est_context) + end + + test "handles function errors gracefully" do + register_function("divide_safe", 2, fn [a, b], _context -> + if b == 0 do + {:error, "Cannot divide by zero"} + else + {:ok, a / b} + end + end) + + assert {:ok, 2.5} = evaluate("divide_safe(5, 2)") + assert {:error, "Cannot divide by zero"} = evaluate("divide_safe(10, 0)") + end + + test "mixed built-in and custom functions" do + register_function("cube", 1, fn [n], _context -> {:ok, n * n * n} end) + + # Mix custom and built-in functions + # built-in + assert {:ok, true} = evaluate("len(\"hello\") = 5") + # custom + assert {:ok, true} = evaluate("cube(3) = 27") + # mixed + assert {:ok, true} = evaluate("cube(len(\"ab\")) = 8") + end + end + + describe "function listing and management" do + test "lists custom functions" do + register_function("func1", 1, fn [_arg], _context -> {:ok, 1} end) + register_function("func2", 2, fn [_arg1, _arg2], _context -> {:ok, 2} end) + + functions = list_custom_functions() + # Should include built-in functions (10) plus the 2 custom functions + assert length(functions) >= 12 + + names = Enum.map(functions, & &1.name) + assert "func1" in names + assert "func2" in names + # Also check that built-in functions are included + assert "len" in names + assert "max" in names + end + + test "clears custom functions" do + register_function("temp", 0, fn [], _context -> {:ok, :temp} end) + assert {:ok, :temp} = evaluate("temp()") + + clear_custom_functions() + assert {:error, _msg} = evaluate("temp()") + end + end + + describe "nested and complex expressions" do + test "nested function calls with custom functions" do + register_function("add_one", 1, fn [n], _context -> {:ok, n + 1} end) + register_function("multiply_by", 2, fn [n, factor], _context -> {:ok, n * factor} end) + + # Nested custom functions + assert {:ok, 15} = evaluate("multiply_by(add_one(4), 3)") + + # Mix with built-in functions + assert {:ok, true} = evaluate("len(\"test\") = add_one(3)") + assert {:ok, 20} = evaluate("multiply_by(len(\"hello\"), add_one(3))") + end + + test "functions in logical expressions" do + register_function("in_range", 3, fn [value, min, max], _context -> + {:ok, value >= min and value <= max} + end) + + register_function("user_age", 0, fn [], context -> + {:ok, Map.get(context, "age", 0)} + end) + + context = %{"age" => 25} + + assert {:ok, true} = evaluate("in_range(user_age(), 18, 65)", context) + assert {:ok, true} = evaluate("user_age() > 18 AND in_range(user_age(), 20, 30)", context) + end + end + + describe "error handling" do + test "unknown custom function" do + assert {:error, "Unknown function: unknown_func"} = evaluate("unknown_func()") + end + + test "wrong arity for custom function" do + register_function("test_func", 2, fn [a, b], _context -> {:ok, a + b} end) + + assert {:error, "Function test_func() expects 2 arguments, got 1"} = + evaluate("test_func(5)") + end + + test "custom function runtime error" do + register_function("error_func", 0, fn [], _context -> + {:error, "Something went wrong"} + end) + + assert {:error, "Something went wrong"} = evaluate("error_func()") + end + end +end diff --git a/test/function_calls_integration_test.exs b/test/function_calls_integration_test.exs new file mode 100644 index 0000000..b9344bf --- /dev/null +++ b/test/function_calls_integration_test.exs @@ -0,0 +1,102 @@ +defmodule FunctionCallsIntegrationTest do + use ExUnit.Case, async: true + + import Predicator + alias Predicator.Functions.{Registry, SystemFunctions} + + setup do + # Ensure system functions are available + Registry.clear_registry() + SystemFunctions.register_all() + :ok + end + + describe "function calls end-to-end" do + test "evaluates simple string function" do + assert {:ok, 5} = evaluate("len(\"hello\")") + end + + test "evaluates function with variable" do + context = %{"name" => "world"} + assert {:ok, 5} = evaluate("len(name)", context) + end + + test "evaluates function in comparison" do + context = %{"name" => "alice"} + assert {:ok, true} = evaluate("len(name) > 3", context) + end + + test "evaluates nested functions" do + context = %{"name" => " hello "} + assert {:ok, 5} = evaluate("len(trim(name))", context) + end + + test "evaluates numeric functions" do + assert {:ok, 10} = evaluate("max(5, 10)") + assert {:ok, 5} = evaluate("min(5, 10)") + + context = %{"negative_val" => -10} + assert {:ok, 10} = evaluate("abs(negative_val)", context) + end + + test "evaluates date functions" do + context = %{"created_at" => ~D[2024-03-15]} + assert {:ok, 2024} = evaluate("year(created_at)", context) + assert {:ok, 3} = evaluate("month(created_at)", context) + assert {:ok, 15} = evaluate("day(created_at)", context) + end + + test "evaluates string functions" do + context = %{"title" => "hello world"} + assert {:ok, "HELLO WORLD"} = evaluate("upper(title)", context) + assert {:ok, "hello world"} = evaluate("lower(upper(title))", context) + end + + test "evaluates function with multiple arguments" do + assert {:ok, 15} = evaluate("max(10, 15)") + + context = %{"a" => 8, "b" => 12} + assert {:ok, 12} = evaluate("max(a, b)", context) + end + + test "function in logical expression" do + context = %{"password" => "secret123"} + assert {:ok, true} = evaluate("len(password) >= 8 AND len(password) <= 20", context) + end + + test "returns error for unknown function" do + assert {:error, _msg} = evaluate("unknown(123)") + end + + test "returns error for wrong argument count" do + assert {:error, _msg} = evaluate("len()") + assert {:error, _msg} = evaluate(~s|len("a", "b")|) + end + + test "returns error for wrong argument type" do + assert {:error, _msg} = evaluate("len(123)") + end + end + + describe "function calls decompilation" do + test "decompiles simple function call" do + {:ok, ast} = parse("len(name)") + assert decompile(ast) == "len(name)" + end + + test "decompiles function with multiple arguments" do + {:ok, ast} = parse("max(a, b)") + assert decompile(ast) == "max(a, b)" + end + + test "decompiles nested function calls" do + {:ok, ast} = parse("upper(trim(name))") + assert decompile(ast) == "upper(trim(name))" + end + + test "decompiles function in expression" do + {:ok, ast} = parse("len(name) > 5") + assert decompile(ast) == "len(name) > 5" + end + end +end diff --git a/test/predicator/evaluator_edge_cases_test.exs b/test/predicator/evaluator_edge_cases_test.exs new file mode 100644 index 0000000..7011787 --- /dev/null +++ b/test/predicator/evaluator_edge_cases_test.exs @@ -0,0 +1,341 @@ +defmodule Predicator.EvaluatorEdgeCasesTest do + use ExUnit.Case, async: true + + alias Predicator.Evaluator + alias Predicator.Functions.{Registry, SystemFunctions} + + setup do + # Ensure built-in functions are available + Registry.clear_registry() + SystemFunctions.register_all() + :ok + end + + describe "error handling" do + test "handles evaluation with invalid comparison types" do + # Create instructions that try to compare incompatible types + instructions = [ + ["lit", 1], + ["lit", 2], + # This leaves one boolean on stack (false) + ["compare", "EQ"], + ["lit", 3], + # This tries to compare boolean with 3 + ["compare", "EQ"] + ] + + # Should return :undefined since boolean false can't be compared to 3 + result = Evaluator.evaluate(instructions, %{}) + assert :undefined = result + end + + test "handles instruction bounds checking" do + # Test that evaluator handles instruction bounds properly + instructions = [["lit", 42]] + + # This is testing internal behavior - the evaluator should handle bounds checking + result = Evaluator.evaluate(instructions, %{}) + assert 42 = result + end + + test "handles comparison with mismatched types" do + # Test type coercion edge cases + instructions = [ + ["lit", "5"], + ["lit", 5], + ["compare", "EQ"] + ] + + result = Evaluator.evaluate(instructions, %{}) + # Should handle string vs number comparison + assert :undefined = result + end + + test "handles comparison with null/nil values" do + instructions = [ + ["lit", nil], + ["lit", nil], + ["compare", "EQ"] + ] + + assert :undefined = Evaluator.evaluate(instructions, %{}) + + instructions = [ + ["lit", nil], + ["lit", 42], + ["compare", "EQ"] + ] + + assert :undefined = Evaluator.evaluate(instructions, %{}) + end + + test "handles function call with insufficient stack items" do + # Try to call function but not enough arguments on stack + instructions = [ + # max expects 2 args but stack is empty + ["call", "max", 2] + ] + + result = Evaluator.evaluate(instructions, %{}) + assert {:error, message} = result + assert message =~ "expects 2 arguments" + assert message =~ "stack" + end + + test "handles unknown function call" do + instructions = [ + ["lit", 1], + ["call", "unknown_function", 1] + ] + + assert {:error, "Unknown function: unknown_function"} = + Evaluator.evaluate(instructions, %{}) + end + + test "handles function call that returns error" do + # Register a function that always returns an error + Registry.register_function("error_func", 1, fn [_arg], _context -> + {:error, "Function intentionally failed"} + end) + + instructions = [ + ["lit", "test"], + ["call", "error_func", 1] + ] + + assert {:error, "Function intentionally failed"} = + Evaluator.evaluate(instructions, %{}) + end + + test "handles variable loading with missing context" do + instructions = [ + ["load", "nonexistent_var"] + ] + + # Should load :undefined for missing variable + assert :undefined = Evaluator.evaluate(instructions, %{}) + end + + test "handles logical operations with non-boolean values" do + # Test AND with non-boolean + instructions = [ + # string, not boolean + ["lit", "true"], + ["lit", true], + ["and"] + ] + + result = Evaluator.evaluate(instructions, %{}) + # Implementation should handle type coercion or error appropriately + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + + test "handles OR with edge cases" do + # Test OR with boolean values only (the evaluator enforces boolean types) + instructions = [ + ["lit", false], + ["lit", true], + ["or"] + ] + + result = Evaluator.evaluate(instructions, %{}) + assert true = result + end + + test "handles NOT with non-boolean values" do + # The evaluator enforces boolean types, so this should error + instructions = [ + # non-boolean value + ["lit", 0], + ["not"] + ] + + result = Evaluator.evaluate(instructions, %{}) + assert {:error, _message} = result + + instructions = [ + # empty string + ["lit", ""], + ["not"] + ] + + result = Evaluator.evaluate(instructions, %{}) + assert {:error, _message} = result + end + end + + describe "stack boundary conditions" do + test "handles deep stack operations" do + # Create instructions that build a deep stack + instructions = Enum.flat_map(1..100, fn i -> [["lit", i]] end) |> List.flatten() + # Then pop them all with additions + add_instructions = Enum.flat_map(1..99, fn _i -> [["add"]] end) |> List.flatten() + + all_instructions = instructions ++ add_instructions + result = Evaluator.evaluate(all_instructions, %{}) + + # Should compute sum of 1 to 100 + # Arithmetic operations not supported, should error + assert {:error, _message} = result + end + + test "handles alternating stack operations" do + # Push and pop operations + instructions = [ + ["lit", 10], + ["lit", 5], + # leaves boolean + ["compare", "GT"], + ["lit", 20], + ["lit", 15], + # leaves boolean + ["compare", "LT"], + # combines booleans + ["and"] + ] + + # The instructions perform: 10 > 5 (true) AND 20 < 15 (false) = false + result = Evaluator.evaluate(instructions, %{}) + refute result + end + + test "handles stack underflow scenarios" do + # Try operations that need stack items when stack is empty/insufficient + instructions = [ + ["lit", 5], + # add needs 2 items, only 1 on stack + ["add"] + ] + + result = Evaluator.evaluate(instructions, %{}) + assert {:error, _message} = result + end + end + + describe "context edge cases" do + test "handles deeply nested context access" do + context = %{ + # flat key, nested access not supported + "user.profile.settings.theme" => "dark" + } + + instructions = [ + ["load", "user.profile.settings.theme"] + ] + + # Should load the flat key (nested access not implemented) + result = Evaluator.evaluate(instructions, context) + assert "dark" = result + end + + test "handles context with special characters in keys" do + context = %{ + "user-name" => "test", + "user.email" => "test@example.com", + "user space" => "value" + } + + instructions = [ + ["load", "user-name"], + ["load", "user.email"], + ["load", "user space"] + ] + + # Should be able to load all these values + results = Enum.map(instructions, &Evaluator.evaluate([&1], context)) + + assert [ + "test", + "test@example.com", + "value" + ] = results + end + + test "handles context with numeric and boolean values" do + context = %{ + "count" => 42, + "enabled" => true, + "score" => 95.5 + } + + instructions = [ + ["load", "count"], + ["lit", 50], + ["compare", "LT"] + ] + + assert true = Evaluator.evaluate(instructions, context) + end + end + + describe "evaluate!/2 function" do + test "returns result directly on success" do + instructions = [["lit", 42]] + assert 42 = Evaluator.evaluate!(instructions, %{}) + end + + test "raises exception on error" do + instructions = [["unknown_instruction"]] + + assert_raise RuntimeError, ~r/Evaluation failed/, fn -> + Evaluator.evaluate!(instructions, %{}) + end + end + + test "preserves error message in exception" do + # Register function that returns error + Registry.register_function("fail_func", 0, fn [], _context -> + {:error, "Custom failure message"} + end) + + instructions = [["call", "fail_func", 0]] + + assert_raise RuntimeError, ~r/Custom failure message/, fn -> + Evaluator.evaluate!(instructions, %{}) + end + end + end + + describe "complex instruction sequences" do + test "handles mixed operations with functions and comparisons" do + instructions = [ + ["lit", "hello"], + # len("hello") = 5 + ["call", "len", 1], + ["lit", 10], + ["lit", 3], + # max(10, 3) = 10 + ["call", "max", 2], + # 5 < 10 = true + ["compare", "LT"], + ["lit", true], + # true AND true = true + ["and"] + ] + + assert true = Evaluator.evaluate(instructions, %{}) + end + + test "handles conditional-like logic with functions" do + context = %{"role" => "admin"} + + # Simulate: role = "admin" AND len(role) > 3 + instructions = [ + ["load", "role"], + ["lit", "admin"], + # true + ["compare", "EQ"], + ["load", "role"], + # 5 + ["call", "len", 1], + ["lit", 3], + # 5 > 3 = true + ["compare", "GT"], + # true AND true = true + ["and"] + ] + + assert true = Evaluator.evaluate(instructions, context) + end + end +end diff --git a/test/predicator/functions/registry_test.exs b/test/predicator/functions/registry_test.exs new file mode 100644 index 0000000..a47f0b0 --- /dev/null +++ b/test/predicator/functions/registry_test.exs @@ -0,0 +1,368 @@ +defmodule Predicator.Functions.RegistryTest do + use ExUnit.Case, async: true + + alias Predicator.Functions.Registry + + setup do + # Clear registry before each test but preserve built-in functions + Predicator.clear_custom_functions() + :ok + end + + describe "simple function registration" do + test "registers and calls a simple function" do + Registry.register_function("double", 1, fn [n], _context -> + {:ok, n * 2} + end) + + assert {:ok, 10} = Registry.call("double", [5], %{}) + end + + test "validates function arity" do + Registry.register_function("add", 2, fn [a, b], _context -> + {:ok, a + b} + end) + + # Correct arity + assert {:ok, 7} = Registry.call("add", [3, 4], %{}) + + # Wrong arity + assert {:error, "Function add() expects 2 arguments, got 1"} = + Registry.call("add", [3], %{}) + end + + test "handles function errors gracefully" do + Registry.register_function("divide", 2, fn [a, b], _context -> + if b == 0 do + {:error, "Division by zero"} + else + {:ok, a / b} + end + end) + + assert {:ok, 2.5} = Registry.call("divide", [5, 2], %{}) + assert {:error, "Division by zero"} = Registry.call("divide", [5, 0], %{}) + end + + test "handles function exceptions" do + Registry.register_function("crash", 0, fn [], _context -> + raise "Something went wrong" + end) + + assert {:error, "Function crash() failed: Something went wrong"} = + Registry.call("crash", [], %{}) + end + + test "returns error for unknown function" do + assert {:error, "Unknown function: unknown"} = + Registry.call("unknown", [], %{}) + end + end + + describe "context-aware functions" do + test "functions can access context" do + Registry.register_function("user_role", 0, fn [], context -> + {:ok, Map.get(context, "role", "guest")} + end) + + assert {:ok, "admin"} = Registry.call("user_role", [], %{"role" => "admin"}) + assert {:ok, "guest"} = Registry.call("user_role", [], %{}) + end + + test "functions can use context for validation" do + Registry.register_function("can_delete", 1, fn [resource_id], context -> + user_role = Map.get(context, "role") + user_id = Map.get(context, "user_id") + resource_owner = Map.get(context, "resources", %{}) |> Map.get(resource_id) + + cond do + user_role == "admin" -> {:ok, true} + user_id == resource_owner -> {:ok, true} + true -> {:ok, false} + end + end) + + context = %{ + "role" => "user", + "user_id" => 123, + "resources" => %{"doc1" => 123, "doc2" => 456} + } + + assert {:ok, true} = Registry.call("can_delete", ["doc1"], context) + assert {:ok, false} = Registry.call("can_delete", ["doc2"], context) + + admin_context = Map.put(context, "role", "admin") + assert {:ok, true} = Registry.call("can_delete", ["doc2"], admin_context) + end + end + + describe "registry management" do + test "lists registered functions" do + Registry.register_function("func1", 1, fn [_arg], _context -> {:ok, 1} end) + Registry.register_function("func2", 2, fn [_arg1, _arg2], _context -> {:ok, 2} end) + + functions = Registry.list_functions() + # Should include built-in functions + 2 custom functions + assert length(functions) >= 12 + + names = Enum.map(functions, & &1.name) + assert "func1" in names + assert "func2" in names + # Also check built-in functions are present + assert "len" in names + + func1 = Enum.find(functions, &(&1.name == "func1")) + assert func1.arity == 1 + end + + test "checks if function is registered" do + refute Registry.function_registered?("missing") + + Registry.register_function("exists", 0, fn [], _context -> {:ok, true} end) + assert Registry.function_registered?("exists") + end + + test "clears registry" do + Registry.register_function("temp", 0, fn [], _context -> {:ok, :temp} end) + assert Registry.function_registered?("temp") + + Predicator.clear_custom_functions() + refute Registry.function_registered?("temp") + end + end + + describe "error handling" do + test "handles function that raises exception" do + Registry.register_function("crash_func", 1, fn [_arg], _context -> + raise "Intentional crash for testing" + end) + + assert {:error, "Function crash_func() failed: Intentional crash for testing"} = + Registry.call("crash_func", ["test"], %{}) + end + + test "handles function that raises different exception types" do + Registry.register_function("runtime_error", 0, fn [], _context -> + raise RuntimeError, "Runtime error test" + end) + + Registry.register_function("argument_error", 0, fn [], _context -> + raise ArgumentError, "Argument error test" + end) + + assert {:error, "Function runtime_error() failed: Runtime error test"} = + Registry.call("runtime_error", [], %{}) + + assert {:error, "Function argument_error() failed: Argument error test"} = + Registry.call("argument_error", [], %{}) + end + + test "handles function registration with invalid inputs" do + # Test with invalid arity + assert_raise FunctionClauseError, fn -> + Registry.register_function("invalid", -1, fn [], _context -> {:ok, :test} end) + end + + # Test with non-binary name + assert_raise FunctionClauseError, fn -> + Registry.register_function(:invalid_atom, 1, fn [_arg], _context -> + {:ok, :test} + end) + end + + # Test with non-integer arity + assert_raise FunctionClauseError, fn -> + Registry.register_function("invalid", "one", fn [_arg], _context -> + {:ok, :test} + end) + end + end + + test "registry auto-starts when needed" do + # Clear registry table completely (simulate it not existing) + :ets.delete_all_objects(:predicator_function_registry) + :ets.delete(:predicator_function_registry) + + # Registry should auto-start when we try to call a function + Registry.register_function("test_auto_start", 0, fn [], _context -> + {:ok, :started} + end) + + assert {:ok, :started} = Registry.call("test_auto_start", [], %{}) + end + end + + describe "registry state management" do + test "start_registry creates table" do + # Delete existing table + :ets.delete_all_objects(:predicator_function_registry) + :ets.delete(:predicator_function_registry) + + # Start registry + assert :ok = Registry.start_registry() + + # Verify table exists (whereis returns table reference, not name) + table_ref = :ets.whereis(:predicator_function_registry) + assert table_ref != :undefined + end + + test "function_registered? works correctly" do + refute Registry.function_registered?("non_existent") + + Registry.register_function("exists", 0, fn [], _context -> {:ok, :yes} end) + assert Registry.function_registered?("exists") + + Registry.clear_registry() + refute Registry.function_registered?("exists") + end + + test "list_functions returns sorted results" do + Registry.register_function("zebra", 0, fn [], _context -> {:ok, :z} end) + Registry.register_function("alpha", 1, fn [_arg], _context -> {:ok, :a} end) + Registry.register_function("beta", 2, fn [_arg1, _arg2], _context -> {:ok, :b} end) + + functions = Registry.list_functions() + names = Enum.map(functions, & &1.name) + + # Should be sorted alphabetically + assert names == Enum.sort(names) + + # Should contain our functions plus built-ins + assert "alpha" in names + assert "beta" in names + assert "zebra" in names + end + end + + describe "function registration edge cases" do + test "handles function registration multiple times" do + # Register the same function multiple times (should overwrite) + Registry.register_function("multi_test", 1, fn [_arg], _context -> + {:ok, "first"} + end) + + assert {:ok, "first"} = Registry.call("multi_test", ["arg"], %{}) + + # Register again with different implementation + Registry.register_function("multi_test", 1, fn [_arg], _context -> + {:ok, "second"} + end) + + # Should use the latest registration + assert {:ok, "second"} = Registry.call("multi_test", ["arg"], %{}) + end + end + + describe "registry table management" do + test "handles registry deletion and recreation" do + # Register a function first + Registry.register_function("before_delete", 0, fn [], _context -> {:ok, :before} end) + + assert {:ok, :before} = Registry.call("before_delete", [], %{}) + + # Delete the entire table + :ets.delete(:predicator_function_registry) + + # Should auto-recreate when needed + Registry.register_function("after_delete", 0, fn [], _context -> {:ok, :after} end) + assert {:ok, :after} = Registry.call("after_delete", [], %{}) + + # Old function should be gone since table was deleted + assert {:error, "Unknown function: before_delete"} = + Registry.call("before_delete", [], %{}) + end + + test "function_registered? works with non-existent functions" do + refute Registry.function_registered?("definitely_not_registered") + refute Registry.function_registered?("") + refute Registry.function_registered?("spaces in name") + end + + test "list_functions returns empty list when no custom functions registered" do + # Clear everything including built-ins + Registry.clear_registry() + + functions = Registry.list_functions() + assert [] = functions + end + + test "clear_registry works when registry doesn't exist" do + # Delete the table completely + :ets.delete(:predicator_function_registry) + + # Should not crash + assert :ok = Registry.clear_registry() + end + end + + describe "complex function implementations" do + test "functions can access and modify context data" do + Registry.register_function("context_reader", 1, fn [key], context -> + case Map.get(context, key) do + nil -> {:error, "Key not found in context"} + value -> {:ok, "Found: #{value}"} + end + end) + + context = %{"test_key" => "test_value"} + + assert {:ok, "Found: test_value"} = + Registry.call("context_reader", ["test_key"], context) + + assert {:error, "Key not found in context"} = + Registry.call("context_reader", ["missing_key"], context) + end + + test "functions can handle complex data structures" do + Registry.register_function("process_map", 1, fn [data], _context -> + case data do + map when is_map(map) -> {:ok, Map.keys(map)} + list when is_list(list) -> {:ok, Enum.count(list)} + _other -> {:error, "Expected map or list"} + end + end) + + assert {:ok, ["key1", "key2"]} = + Registry.call("process_map", [%{"key1" => 1, "key2" => 2}], %{}) + + assert {:ok, 3} = + Registry.call("process_map", [[1, 2, 3]], %{}) + + assert {:error, "Expected map or list"} = + Registry.call("process_map", ["string"], %{}) + end + + test "functions can raise exceptions that get caught" do + Registry.register_function("exception_thrower", 0, fn [], _context -> + raise ArgumentError, "This function always fails" + end) + + assert {:error, message} = Registry.call("exception_thrower", [], %{}) + assert message =~ "This function always fails" + assert message =~ "exception_thrower() failed:" + end + + test "functions with zero arity work correctly" do + Registry.register_function("zero_arity", 0, fn [], _context -> + {:ok, "no arguments needed"} + end) + + assert {:ok, "no arguments needed"} = Registry.call("zero_arity", [], %{}) + + # Wrong arity should fail + assert {:error, "Function zero_arity() expects 0 arguments, got 1"} = + Registry.call("zero_arity", ["arg"], %{}) + end + + test "functions with high arity work correctly" do + Registry.register_function("five_args", 5, fn [a, b, c, d, e], _context -> + {:ok, a + b + c + d + e} + end) + + assert {:ok, 15} = Registry.call("five_args", [1, 2, 3, 4, 5], %{}) + + assert {:error, "Function five_args() expects 5 arguments, got 3"} = + Registry.call("five_args", [1, 2, 3], %{}) + end + end +end diff --git a/test/predicator/functions/system_functions_test.exs b/test/predicator/functions/system_functions_test.exs new file mode 100644 index 0000000..d9d87eb --- /dev/null +++ b/test/predicator/functions/system_functions_test.exs @@ -0,0 +1,416 @@ +defmodule Predicator.Functions.SystemFunctionsTest do + use ExUnit.Case, async: true + + alias Predicator.Functions.{Registry, SystemFunctions} + + setup do + # Clear and re-register system functions for each test + Registry.clear_registry() + SystemFunctions.register_all() + :ok + end + + describe "string functions" do + test "len/1 with valid arguments" do + assert {:ok, 5} = Registry.call("len", ["hello"], %{}) + assert {:ok, 0} = Registry.call("len", [""], %{}) + assert {:ok, 3} = Registry.call("len", ["🚀🚀🚀"], %{}) + end + + test "len/1 with invalid argument types" do + assert {:error, "len() expects a string argument"} = + Registry.call("len", [123], %{}) + + assert {:error, "len() expects a string argument"} = + Registry.call("len", [true], %{}) + + assert {:error, "len() expects a string argument"} = + Registry.call("len", [nil], %{}) + + assert {:error, "len() expects a string argument"} = + Registry.call("len", [[]], %{}) + end + + test "len/1 with wrong arity" do + assert {:error, "Function len() expects 1 arguments, got 0"} = + Registry.call("len", [], %{}) + + assert {:error, "Function len() expects 1 arguments, got 2"} = + Registry.call("len", ["a", "b"], %{}) + end + + test "upper/1 with valid arguments" do + assert {:ok, "HELLO"} = Registry.call("upper", ["hello"], %{}) + assert {:ok, ""} = Registry.call("upper", [""], %{}) + assert {:ok, "123"} = Registry.call("upper", ["123"], %{}) + end + + test "upper/1 with invalid argument types" do + assert {:error, "upper() expects a string argument"} = + Registry.call("upper", [123], %{}) + + assert {:error, "upper() expects a string argument"} = + Registry.call("upper", [true], %{}) + end + + test "upper/1 with wrong arity" do + assert {:error, "Function upper() expects 1 arguments, got 0"} = + Registry.call("upper", [], %{}) + + assert {:error, "Function upper() expects 1 arguments, got 2"} = + Registry.call("upper", ["a", "b"], %{}) + end + + test "lower/1 with valid arguments" do + assert {:ok, "hello"} = Registry.call("lower", ["HELLO"], %{}) + assert {:ok, ""} = Registry.call("lower", [""], %{}) + assert {:ok, "123"} = Registry.call("lower", ["123"], %{}) + end + + test "lower/1 with invalid argument types" do + assert {:error, "lower() expects a string argument"} = + Registry.call("lower", [123], %{}) + end + + test "lower/1 with wrong arity" do + assert {:error, "Function lower() expects 1 arguments, got 0"} = + Registry.call("lower", [], %{}) + + assert {:error, "Function lower() expects 1 arguments, got 2"} = + Registry.call("lower", ["a", "b"], %{}) + end + + test "trim/1 with valid arguments" do + assert {:ok, "hello"} = Registry.call("trim", [" hello "], %{}) + assert {:ok, "hello"} = Registry.call("trim", ["hello"], %{}) + assert {:ok, ""} = Registry.call("trim", [" "], %{}) + assert {:ok, "a b"} = Registry.call("trim", [" a b "], %{}) + end + + test "trim/1 with invalid argument types" do + assert {:error, "trim() expects a string argument"} = + Registry.call("trim", [123], %{}) + end + + test "trim/1 with wrong arity" do + assert {:error, "Function trim() expects 1 arguments, got 0"} = + Registry.call("trim", [], %{}) + + assert {:error, "Function trim() expects 1 arguments, got 2"} = + Registry.call("trim", ["a", "b"], %{}) + end + end + + describe "numeric functions" do + test "abs/1 with valid arguments" do + assert {:ok, 5} = Registry.call("abs", [5], %{}) + assert {:ok, 5} = Registry.call("abs", [-5], %{}) + assert {:ok, 0} = Registry.call("abs", [0], %{}) + assert {:ok, 42} = Registry.call("abs", [-42], %{}) + end + + test "abs/1 with invalid argument types" do + assert {:error, "abs() expects a numeric argument"} = + Registry.call("abs", ["5"], %{}) + + assert {:error, "abs() expects a numeric argument"} = + Registry.call("abs", [true], %{}) + end + + test "abs/1 with wrong arity" do + assert {:error, "Function abs() expects 1 arguments, got 0"} = + Registry.call("abs", [], %{}) + + assert {:error, "Function abs() expects 1 arguments, got 2"} = + Registry.call("abs", [1, 2], %{}) + end + + test "max/2 with valid arguments" do + assert {:ok, 7} = Registry.call("max", [3, 7], %{}) + assert {:ok, 7} = Registry.call("max", [7, 3], %{}) + assert {:ok, 5} = Registry.call("max", [5, 5], %{}) + assert {:ok, 0} = Registry.call("max", [-5, 0], %{}) + assert {:ok, -1} = Registry.call("max", [-5, -1], %{}) + end + + test "max/2 with invalid argument types" do + assert {:error, "max() expects two numeric arguments"} = + Registry.call("max", ["3", 7], %{}) + + assert {:error, "max() expects two numeric arguments"} = + Registry.call("max", [3, "7"], %{}) + + assert {:error, "max() expects two numeric arguments"} = + Registry.call("max", [true, false], %{}) + end + + test "max/2 with wrong arity" do + assert {:error, "Function max() expects 2 arguments, got 1"} = + Registry.call("max", [5], %{}) + + assert {:error, "Function max() expects 2 arguments, got 0"} = + Registry.call("max", [], %{}) + + assert {:error, "Function max() expects 2 arguments, got 3"} = + Registry.call("max", [1, 2, 3], %{}) + end + + test "min/2 with valid arguments" do + assert {:ok, 3} = Registry.call("min", [3, 7], %{}) + assert {:ok, 3} = Registry.call("min", [7, 3], %{}) + assert {:ok, 5} = Registry.call("min", [5, 5], %{}) + assert {:ok, -5} = Registry.call("min", [-5, 0], %{}) + assert {:ok, -5} = Registry.call("min", [-5, -1], %{}) + end + + test "min/2 with invalid argument types" do + assert {:error, "min() expects two numeric arguments"} = + Registry.call("min", ["3", 7], %{}) + + assert {:error, "min() expects two numeric arguments"} = + Registry.call("min", [3, "7"], %{}) + end + + test "min/2 with wrong arity" do + assert {:error, "Function min() expects 2 arguments, got 1"} = + Registry.call("min", [5], %{}) + + assert {:error, "Function min() expects 2 arguments, got 0"} = + Registry.call("min", [], %{}) + end + end + + describe "date functions" do + test "year/1 with Date" do + date = ~D[2024-03-15] + assert {:ok, 2024} = Registry.call("year", [date], %{}) + end + + test "year/1 with DateTime" do + datetime = ~U[2024-03-15 10:30:00Z] + assert {:ok, 2024} = Registry.call("year", [datetime], %{}) + end + + test "year/1 with invalid argument types" do + assert {:error, "year() expects a date or datetime argument"} = + Registry.call("year", ["2024"], %{}) + + assert {:error, "year() expects a date or datetime argument"} = + Registry.call("year", [2024], %{}) + end + + test "year/1 with wrong arity" do + assert {:error, "Function year() expects 1 arguments, got 0"} = + Registry.call("year", [], %{}) + + assert {:error, "Function year() expects 1 arguments, got 2"} = + Registry.call("year", [~D[2024-01-01], ~D[2024-12-31]], %{}) + end + + test "month/1 with Date" do + date = ~D[2024-03-15] + assert {:ok, 3} = Registry.call("month", [date], %{}) + end + + test "month/1 with DateTime" do + datetime = ~U[2024-12-15 10:30:00Z] + assert {:ok, 12} = Registry.call("month", [datetime], %{}) + end + + test "month/1 with invalid argument types" do + assert {:error, "month() expects a date or datetime argument"} = + Registry.call("month", ["March"], %{}) + end + + test "month/1 with wrong arity" do + assert {:error, "Function month() expects 1 arguments, got 0"} = + Registry.call("month", [], %{}) + end + + test "day/1 with Date" do + date = ~D[2024-03-15] + assert {:ok, 15} = Registry.call("day", [date], %{}) + end + + test "day/1 with DateTime" do + datetime = ~U[2024-03-25 10:30:00Z] + assert {:ok, 25} = Registry.call("day", [datetime], %{}) + end + + test "day/1 with invalid argument types" do + assert {:error, "day() expects a date or datetime argument"} = + Registry.call("day", ["15"], %{}) + end + + test "day/1 with wrong arity" do + assert {:error, "Function day() expects 1 arguments, got 0"} = + Registry.call("day", [], %{}) + end + end + + describe "numeric edge cases" do + test "abs/1 with very large numbers" do + # max 64-bit signed int + large_positive = 9_223_372_036_854_775_807 + # min 64-bit signed int + _large_negative = -9_223_372_036_854_775_808 + + assert {:ok, ^large_positive} = Registry.call("abs", [large_positive], %{}) + # Note: -large_negative would overflow, so we test with a smaller negative + assert {:ok, 9_223_372_036_854_775_807} = + Registry.call("abs", [-9_223_372_036_854_775_807], %{}) + end + + test "abs/1 with zero" do + assert {:ok, 0} = Registry.call("abs", [0], %{}) + end + + test "max/2 with identical values" do + assert {:ok, 5} = Registry.call("max", [5, 5], %{}) + assert {:ok, -10} = Registry.call("max", [-10, -10], %{}) + end + + test "min/2 with identical values" do + assert {:ok, 5} = Registry.call("min", [5, 5], %{}) + assert {:ok, -10} = Registry.call("min", [-10, -10], %{}) + end + + test "max/2 and min/2 with extreme values" do + assert {:ok, 100} = Registry.call("max", [-1000, 100], %{}) + assert {:ok, 100} = Registry.call("max", [100, -1000], %{}) + + assert {:ok, -1000} = Registry.call("min", [-1000, 100], %{}) + assert {:ok, -1000} = Registry.call("min", [100, -1000], %{}) + end + end + + describe "string edge cases" do + test "len/1 with unicode strings" do + # Various unicode characters + # é = 1 char + assert {:ok, 5} = Registry.call("len", ["héllo"], %{}) + # emoji = 1 char + assert {:ok, 1} = Registry.call("len", ["🚀"], %{}) + # Japanese = 5 chars + assert {:ok, 5} = Registry.call("len", ["こんにちは"], %{}) + end + + test "len/1 with empty string" do + assert {:ok, 0} = Registry.call("len", [""], %{}) + end + + test "upper/1 with mixed case and numbers" do + assert {:ok, "HELLO123"} = Registry.call("upper", ["Hello123"], %{}) + assert {:ok, "123!@#"} = Registry.call("upper", ["123!@#"], %{}) + end + + test "lower/1 with mixed case and numbers" do + assert {:ok, "hello123"} = Registry.call("lower", ["HELLO123"], %{}) + assert {:ok, "123!@#"} = Registry.call("lower", ["123!@#"], %{}) + end + + test "upper/1 and lower/1 with unicode" do + assert {:ok, "JOSÉ"} = Registry.call("upper", ["josé"], %{}) + assert {:ok, "josé"} = Registry.call("lower", ["JOSÉ"], %{}) + end + + test "trim/1 with various whitespace" do + assert {:ok, "hello"} = Registry.call("trim", ["\t\n hello \r\n"], %{}) + assert {:ok, "a b c"} = Registry.call("trim", [" a b c "], %{}) + assert {:ok, ""} = Registry.call("trim", [" "], %{}) + assert {:ok, ""} = Registry.call("trim", ["\t\n\r"], %{}) + end + + test "trim/1 with no whitespace to trim" do + assert {:ok, "hello"} = Registry.call("trim", ["hello"], %{}) + assert {:ok, "a"} = Registry.call("trim", ["a"], %{}) + end + end + + describe "date edge cases" do + test "date functions with leap year dates" do + # Feb 29 in leap year + leap_date = ~D[2024-02-29] + + assert {:ok, 2024} = Registry.call("year", [leap_date], %{}) + assert {:ok, 2} = Registry.call("month", [leap_date], %{}) + assert {:ok, 29} = Registry.call("day", [leap_date], %{}) + end + + test "date functions with end of year" do + end_of_year = ~D[2024-12-31] + + assert {:ok, 2024} = Registry.call("year", [end_of_year], %{}) + assert {:ok, 12} = Registry.call("month", [end_of_year], %{}) + assert {:ok, 31} = Registry.call("day", [end_of_year], %{}) + end + + test "date functions with beginning of year" do + start_of_year = ~D[2024-01-01] + + assert {:ok, 2024} = Registry.call("year", [start_of_year], %{}) + assert {:ok, 1} = Registry.call("month", [start_of_year], %{}) + assert {:ok, 1} = Registry.call("day", [start_of_year], %{}) + end + + test "datetime functions with different timezones" do + utc_datetime = ~U[2024-03-15 10:30:00Z] + + assert {:ok, 2024} = Registry.call("year", [utc_datetime], %{}) + assert {:ok, 3} = Registry.call("month", [utc_datetime], %{}) + assert {:ok, 15} = Registry.call("day", [utc_datetime], %{}) + end + + test "date functions with invalid date-like structures" do + # looks like date but isn't Date struct + fake_date = %{year: 2024, month: 3, day: 15} + + assert {:error, "year() expects a date or datetime argument"} = + Registry.call("year", [fake_date], %{}) + + assert {:error, "month() expects a date or datetime argument"} = + Registry.call("month", [fake_date], %{}) + + assert {:error, "day() expects a date or datetime argument"} = + Registry.call("day", [fake_date], %{}) + end + end + + describe "function registration behavior" do + test "register_all can be called multiple times safely" do + # Should not crash or cause issues when called multiple times + assert :ok = SystemFunctions.register_all() + assert :ok = SystemFunctions.register_all() + assert :ok = SystemFunctions.register_all() + + # Functions should still work normally + assert {:ok, 5} = Registry.call("len", ["hello"], %{}) + assert {:ok, 10} = Registry.call("max", [5, 10], %{}) + end + + test "all built-in functions are registered" do + SystemFunctions.register_all() + + functions = Registry.list_functions() + names = Enum.map(functions, & &1.name) + + # Check that all expected built-in functions are registered + expected_functions = [ + "len", + "upper", + "lower", + "trim", + "abs", + "max", + "min", + "year", + "month", + "day" + ] + + for expected <- expected_functions do + assert expected in names, "Expected function #{expected} to be registered" + end + end + end +end diff --git a/test/predicator/lexer_test.exs b/test/predicator/lexer_test.exs index 23c9ec6..519c705 100644 --- a/test/predicator/lexer_test.exs +++ b/test/predicator/lexer_test.exs @@ -557,4 +557,109 @@ defmodule Predicator.LexerTest do ]} = Lexer.tokenize(input) end end + + describe "function calls" do + test "tokenizes simple function call" do + input = "len(name)" + + assert {:ok, + [ + {:function_name, 1, 1, 3, "len"}, + {:lparen, 1, 4, 1, "("}, + {:identifier, 1, 5, 4, "name"}, + {:rparen, 1, 9, 1, ")"}, + {:eof, 1, 10, 0, nil} + ]} = Lexer.tokenize(input) + end + + test "tokenizes function call with whitespace" do + input = "upper ( name )" + + assert {:ok, + [ + {:function_name, 1, 1, 5, "upper"}, + {:lparen, 1, 7, 1, "("}, + {:identifier, 1, 9, 4, "name"}, + {:rparen, 1, 14, 1, ")"}, + {:eof, 1, 15, 0, nil} + ]} = Lexer.tokenize(input) + end + + test "tokenizes function call with multiple arguments" do + input = "max(score, 100)" + + assert {:ok, + [ + {:function_name, 1, 1, 3, "max"}, + {:lparen, 1, 4, 1, "("}, + {:identifier, 1, 5, 5, "score"}, + {:comma, 1, 10, 1, ","}, + {:integer, 1, 12, 3, 100}, + {:rparen, 1, 15, 1, ")"}, + {:eof, 1, 16, 0, nil} + ]} = Lexer.tokenize(input) + end + + test "tokenizes function call in expression" do + input = "len(name) > 5" + + assert {:ok, + [ + {:function_name, 1, 1, 3, "len"}, + {:lparen, 1, 4, 1, "("}, + {:identifier, 1, 5, 4, "name"}, + {:rparen, 1, 9, 1, ")"}, + {:gt, 1, 11, 1, ">"}, + {:integer, 1, 13, 1, 5}, + {:eof, 1, 14, 0, nil} + ]} = Lexer.tokenize(input) + end + + test "tokenizes nested function calls" do + input = "upper(trim(name))" + + assert {:ok, + [ + {:function_name, 1, 1, 5, "upper"}, + {:lparen, 1, 6, 1, "("}, + {:function_name, 1, 7, 4, "trim"}, + {:lparen, 1, 11, 1, "("}, + {:identifier, 1, 12, 4, "name"}, + {:rparen, 1, 16, 1, ")"}, + {:rparen, 1, 17, 1, ")"}, + {:eof, 1, 18, 0, nil} + ]} = Lexer.tokenize(input) + end + + test "distinguishes function calls from parenthesized expressions" do + # This should be a regular identifier with parentheses (not a function call) + input = "name AND (score > 85)" + + assert {:ok, + [ + {:identifier, 1, 1, 4, "name"}, + {:and_op, 1, 6, 3, "AND"}, + {:lparen, 1, 10, 1, "("}, + {:identifier, 1, 11, 5, "score"}, + {:gt, 1, 17, 1, ">"}, + {:integer, 1, 19, 2, 85}, + {:rparen, 1, 21, 1, ")"}, + {:eof, 1, 22, 0, nil} + ]} = Lexer.tokenize(input) + end + + test "handles keywords that could be function names" do + # "not" is a keyword, so "not(" should NOT be a function call - it stays as NOT keyword + input = "not(active)" + + assert {:ok, + [ + {:not_op, 1, 1, 3, "not"}, + {:lparen, 1, 4, 1, "("}, + {:identifier, 1, 5, 6, "active"}, + {:rparen, 1, 11, 1, ")"}, + {:eof, 1, 12, 0, nil} + ]} = Lexer.tokenize(input) + end + end end diff --git a/test/predicator/parser_edge_cases_test.exs b/test/predicator/parser_edge_cases_test.exs new file mode 100644 index 0000000..9e55ffa --- /dev/null +++ b/test/predicator/parser_edge_cases_test.exs @@ -0,0 +1,160 @@ +defmodule Predicator.ParserEdgeCasesTest do + use ExUnit.Case, async: true + + alias Predicator.{Lexer, Parser} + + describe "error handling" do + test "handles unexpected tokens after expression" do + # Test case: valid expression followed by unexpected token + tokens = [ + {:integer, 1, 1, 1, 42}, + {:identifier, 1, 3, 4, "unexpected"} + ] + + assert {:error, message, 1, 3} = Parser.parse(tokens) + assert message =~ "Unexpected token" + assert message =~ "unexpected" + end + + test "handles function call with missing closing parenthesis" do + # This should be caught by lexer, but test parser robustness + {:ok, tokens} = Lexer.tokenize("func(") + assert {:error, _message, _line, _col} = Parser.parse(tokens) + end + + test "handles function call with missing arguments" do + {:ok, tokens} = Lexer.tokenize("func(,)") + assert {:error, _message, _line, _col} = Parser.parse(tokens) + end + + test "handles deeply nested expressions" do + # Create a deeply nested expression + nested = String.duplicate("(", 100) <> "1" <> String.duplicate(")", 100) + {:ok, tokens} = Lexer.tokenize(nested) + + # Should still parse successfully (testing stack depth handling) + assert {:ok, _ast} = Parser.parse(tokens) + end + + test "handles complex function call combinations" do + {:ok, tokens} = Lexer.tokenize("func1(func2(arg1), func3(arg2, arg3))") + assert {:ok, _ast} = Parser.parse(tokens) + end + + test "handles function calls with different argument types" do + {:ok, tokens} = Lexer.tokenize("func(\"string\", 42, true, [1, 2, 3])") + assert {:ok, ast} = Parser.parse(tokens) + + # Verify AST structure + assert {:function_call, "func", args} = ast + assert length(args) == 4 + end + + test "handles empty function call" do + {:ok, tokens} = Lexer.tokenize("func()") + assert {:ok, {:function_call, "func", []}} = Parser.parse(tokens) + end + + test "handles function calls in logical expressions" do + {:ok, tokens} = Lexer.tokenize("func1() AND func2() OR NOT func3()") + assert {:ok, _ast} = Parser.parse(tokens) + end + + test "handles function calls in comparison expressions" do + {:ok, tokens} = Lexer.tokenize("func1() > func2() AND func3() = \"test\"") + assert {:ok, _ast} = Parser.parse(tokens) + end + + test "handles function calls with list arguments" do + {:ok, tokens} = Lexer.tokenize("func([1, 2, \"test\", true])") + assert {:ok, {:function_call, "func", [args]}} = Parser.parse(tokens) + assert {:list, _elements} = args + end + + test "handles function calls with nested lists" do + {:ok, tokens} = Lexer.tokenize("func([[1, 2], [3, 4]])") + assert {:ok, {:function_call, "func", [args]}} = Parser.parse(tokens) + assert {:list, _elements} = args + end + + test "handles invalid list syntax" do + # trailing comma + {:ok, tokens} = Lexer.tokenize("[1, 2,]") + # This might be valid or invalid depending on implementation + result = Parser.parse(tokens) + # Just verify it returns some result (either ok or error) + assert match?({:ok, _}, result) or match?({:error, _, _, _}, result) + end + end + + describe "boundary conditions" do + test "handles maximum integer values" do + # max 64-bit signed integer + max_int = 9_223_372_036_854_775_807 + {:ok, tokens} = Lexer.tokenize("#{max_int}") + assert {:ok, {:literal, ^max_int}} = Parser.parse(tokens) + end + + test "handles very long strings" do + long_string = String.duplicate("a", 1000) + {:ok, tokens} = Lexer.tokenize("\"#{long_string}\"") + assert {:ok, {:literal, ^long_string}} = Parser.parse(tokens) + end + + test "handles very long identifiers" do + long_id = String.duplicate("a", 100) + {:ok, tokens} = Lexer.tokenize(long_id) + assert {:ok, {:identifier, ^long_id}} = Parser.parse(tokens) + end + + test "handles empty input" do + {:ok, tokens} = Lexer.tokenize("") + # Empty input produces EOF token + assert [_eof_token] = tokens + assert {:error, _message, _line, _col} = Parser.parse(tokens) + end + + test "handles whitespace-only input" do + {:ok, tokens} = Lexer.tokenize(" \n \t ") + # Whitespace-only input produces EOF token + assert [_eof_token] = tokens + assert {:error, _message, _line, _col} = Parser.parse(tokens) + end + end + + describe "complex expressions" do + test "handles mixed operators with function calls" do + expression = "len(name) > 5 AND upper(title) = \"ADMIN\" OR day(created_at) > 15" + {:ok, tokens} = Lexer.tokenize(expression) + assert {:ok, _ast} = Parser.parse(tokens) + end + + test "handles nested function calls with all types" do + expression = "func(len(\"test\"), max(1, 2), year(created_at), [1, 2, 3])" + {:ok, tokens} = Lexer.tokenize(expression) + assert {:ok, ast} = Parser.parse(tokens) + + assert {:function_call, "func", args} = ast + assert length(args) == 4 + end + + test "handles complex logical expressions with functions" do + expression = "(func1() AND func2()) OR (NOT func3() AND func4())" + {:ok, tokens} = Lexer.tokenize(expression) + assert {:ok, _ast} = Parser.parse(tokens) + end + + test "handles comparison chains with functions" do + expression = "min(a, b) < max(c, d) AND len(str) >= 5" + {:ok, tokens} = Lexer.tokenize(expression) + assert {:ok, _ast} = Parser.parse(tokens) + end + + test "handles function calls in list elements" do + expression = "[func1(), func2(\"arg\"), 42]" + {:ok, tokens} = Lexer.tokenize(expression) + assert {:ok, {:list, elements}} = Parser.parse(tokens) + assert length(elements) == 3 + end + end +end diff --git a/test/predicator/parser_test.exs b/test/predicator/parser_test.exs index 935a6d5..9117f96 100644 --- a/test/predicator/parser_test.exs +++ b/test/predicator/parser_test.exs @@ -157,7 +157,7 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) assert {:error, - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found end of input", + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found end of input", 1, 1} = result end @@ -167,7 +167,7 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) assert {:error, - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found end of input", + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found end of input", 1, 8} = result end @@ -177,7 +177,7 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) assert {:error, - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found '>'", + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found '>'", 1, 1} = result @@ -189,7 +189,7 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) assert {:error, - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found '>'", + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found '>'", 1, 9} = result @@ -229,7 +229,7 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) assert {:error, - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found '>'", + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found '>'", 1, 9} = result @@ -276,7 +276,7 @@ defmodule Predicator.ParserTest do assert {:error, message, 1, 10} = result assert message =~ - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found ')'" + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found ')'" end test "handles comparison operator followed by EOF" do @@ -289,7 +289,7 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) assert {:error, - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found end of input", + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found end of input", 1, 8} = result end @@ -297,17 +297,17 @@ defmodule Predicator.ParserTest do # Test different token types that would fail in primary position test_cases = [ {[:gt], - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found '>'"}, + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found '>'"}, {[:lt], - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found '<'"}, + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found '<'"}, {[:gte], - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found '>='"}, + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found '>='"}, {[:lte], - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found '<='"}, + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found '<='"}, {[:eq], - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found '='"}, + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found '='"}, {[:ne], - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found '!='"} + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found '!='"} ] for {token_types, expected_message} <- test_cases do @@ -360,7 +360,7 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) assert {:error, - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found ')'", + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found ')'", 1, 1} = result @@ -376,7 +376,7 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) assert {:error, - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found ')'", + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found ')'", 1, 2} = result @@ -568,7 +568,7 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) assert {:error, - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found end of input", + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found end of input", 1, 9} = result end @@ -582,7 +582,7 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) assert {:error, - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found end of input", + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found end of input", 1, 8} = result end @@ -595,7 +595,7 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) assert {:error, - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found end of input", + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found end of input", 1, 4} = result end @@ -729,7 +729,7 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) assert {:error, - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found 'AND'", + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found 'AND'", 1, 1} = result end diff --git a/test/predicator/instructions_visitor_test.exs b/test/predicator/visitors/instructions_visitor_test.exs similarity index 98% rename from test/predicator/instructions_visitor_test.exs rename to test/predicator/visitors/instructions_visitor_test.exs index ae015f7..0e2b9f1 100644 --- a/test/predicator/instructions_visitor_test.exs +++ b/test/predicator/visitors/instructions_visitor_test.exs @@ -1,9 +1,9 @@ -defmodule Predicator.InstructionsVisitorTest do +defmodule Predicator.Visitors.InstructionsVisitorTest do use ExUnit.Case, async: true - alias Predicator.InstructionsVisitor + alias Predicator.Visitors.InstructionsVisitor - doctest Predicator.InstructionsVisitor + doctest Predicator.Visitors.InstructionsVisitor describe "visit/2 - literal nodes" do test "generates lit instruction for integer literal" do diff --git a/test/predicator/visitors/string_visitor_edge_cases_test.exs b/test/predicator/visitors/string_visitor_edge_cases_test.exs new file mode 100644 index 0000000..eeb54a1 --- /dev/null +++ b/test/predicator/visitors/string_visitor_edge_cases_test.exs @@ -0,0 +1,148 @@ +defmodule Predicator.Visitors.StringVisitorEdgeCasesTest do + use ExUnit.Case, async: true + + alias Predicator.Visitors.StringVisitor + + describe "edge cases" do + test "handles function calls with empty arguments" do + expression = "func()" + {:ok, ast} = Predicator.parse(expression) + assert "func()" = StringVisitor.visit(ast) + end + + test "handles function calls with single argument" do + expression = "len(\"test\")" + {:ok, ast} = Predicator.parse(expression) + assert "len(\"test\")" = StringVisitor.visit(ast) + end + + test "handles function calls with multiple arguments" do + expression = "max(1, 2)" + {:ok, ast} = Predicator.parse(expression) + assert "max(1, 2)" = StringVisitor.visit(ast) + end + + test "handles nested function calls" do + expression = "len(upper(\"hello\"))" + {:ok, ast} = Predicator.parse(expression) + assert "len(upper(\"hello\"))" = StringVisitor.visit(ast) + end + + test "handles function calls in logical expressions" do + expression = "len(name) > 5 AND upper(role) = \"ADMIN\"" + {:ok, ast} = Predicator.parse(expression) + result = StringVisitor.visit(ast) + + # Should reconstruct the expression (may have different formatting) + assert result =~ "len(name)" + assert result =~ "upper(role)" + assert result =~ "\"ADMIN\"" + end + + test "handles function calls with complex arguments" do + expression = "func([1, 2, 3], \"test\", true)" + {:ok, ast} = Predicator.parse(expression) + result = StringVisitor.visit(ast) + assert result =~ "func(" + assert result =~ "[1, 2, 3]" + assert result =~ "\"test\"" + assert result =~ "true" + end + + test "handles function calls with list arguments" do + expression = "func([\"a\", \"b\", \"c\"])" + {:ok, ast} = Predicator.parse(expression) + result = StringVisitor.visit(ast) + assert result =~ "func(" + assert result =~ "[\"a\", \"b\", \"c\"]" + end + + test "handles very long expressions" do + # Create a long expression with many function calls + long_expr = Enum.map_join(1..10, " AND ", fn i -> "func#{i}(\"arg#{i}\")" end) + + {:ok, ast} = Predicator.parse(long_expr) + result = StringVisitor.visit(ast) + + # Should contain all function calls + for i <- 1..10 do + assert result =~ "func#{i}(" + assert result =~ "\"arg#{i}\"" + end + end + + test "handles special characters in strings" do + expression = "len(\"hello\\nworld\\t!\")" + {:ok, ast} = Predicator.parse(expression) + result = StringVisitor.visit(ast) + assert result =~ "len(" + # The string should be properly escaped + assert result =~ "hello" + assert result =~ "world" + end + + test "handles numbers with different formats" do + expressions = [ + "func(42)", + "func(0)" + ] + + for expr <- expressions do + {:ok, ast} = Predicator.parse(expr) + result = StringVisitor.visit(ast) + assert result =~ "func(" + end + end + + test "handles deeply nested parentheses with functions" do + expression = "((len(\"test\") > 3) AND (max(1, 2) = 2))" + {:ok, ast} = Predicator.parse(expression) + result = StringVisitor.visit(ast) + + assert result =~ "len(\"test\")" + assert result =~ "max(1, 2)" + end + + test "handles mixed operators with functions" do + expression = "len(name) > 2 AND max(age, 18) >= 18 OR min(score, 100) <= 50" + {:ok, ast} = Predicator.parse(expression) + result = StringVisitor.visit(ast) + + assert result =~ "len(name)" + assert result =~ "max(age, 18)" + assert result =~ "min(score, 100)" + end + end + + describe "format preservation" do + test "preserves function call structure in complex expressions" do + expression = "NOT (len(upper(name)) > 5 AND role IN [\"admin\", \"user\"])" + {:ok, ast} = Predicator.parse(expression) + result = StringVisitor.visit(ast) + + # Should preserve the logical structure + assert result =~ "len(" + assert result =~ "upper(" + assert result =~ "name" + end + + test "handles function calls in membership operations" do + expression = "upper(role) IN [\"ADMIN\", \"USER\"]" + {:ok, ast} = Predicator.parse(expression) + result = StringVisitor.visit(ast) + + assert result =~ "upper(role)" + assert result =~ "IN" + assert result =~ "[\"ADMIN\", \"USER\"]" + end + + test "handles function calls in contains operations" do + expression = "[\"admin\", \"user\"] CONTAINS lower(role)" + {:ok, ast} = Predicator.parse(expression) + result = StringVisitor.visit(ast) + + assert result =~ "lower(role)" + assert result =~ "CONTAINS" + end + end +end diff --git a/test/predicator/string_visitor_test.exs b/test/predicator/visitors/string_visitor_test.exs similarity index 99% rename from test/predicator/string_visitor_test.exs rename to test/predicator/visitors/string_visitor_test.exs index f8d04d3..69f77e4 100644 --- a/test/predicator/string_visitor_test.exs +++ b/test/predicator/visitors/string_visitor_test.exs @@ -1,9 +1,9 @@ -defmodule Predicator.StringVisitorTest do +defmodule Predicator.Visitors.StringVisitorTest do use ExUnit.Case, async: true - alias Predicator.StringVisitor + alias Predicator.Visitors.StringVisitor - doctest Predicator.StringVisitor + doctest Predicator.Visitors.StringVisitor describe "visit/2 - literal nodes" do test "converts integer literal to string" do diff --git a/test/predicator_edge_cases_test.exs b/test/predicator_edge_cases_test.exs new file mode 100644 index 0000000..e33e655 --- /dev/null +++ b/test/predicator_edge_cases_test.exs @@ -0,0 +1,232 @@ +defmodule PredicatorEdgeCasesTest do + use ExUnit.Case, async: true + + import Predicator + alias Predicator.Functions.{Registry, SystemFunctions} + + setup do + # Ensure built-in functions are available + Registry.clear_registry() + SystemFunctions.register_all() + :ok + end + + describe "main API edge cases" do + test "handles empty expressions" do + assert {:error, _message} = evaluate("") + assert {:error, _message} = evaluate(" ") + assert {:error, _message} = evaluate("\n\t") + end + + test "handles very complex nested expressions" do + complex_expr = """ + ((len(upper(name)) > 5 AND age >= 18) OR + (role IN ["admin", "super_admin"] AND + max(score1, score2) > 80)) AND + NOT (status = "banned") + """ + + context = %{ + "name" => "john_doe", + "age" => 25, + "role" => "admin", + "score1" => 85, + "score2" => 90, + "status" => "active" + } + + assert {:ok, true} = evaluate(complex_expr, context) + end + + test "handles expressions with all supported data types" do + context = %{ + "str" => "hello", + "num" => 42, + "bool" => true, + "list" => [1, 2, 3], + "date" => ~D[2024-01-15] + } + + # Test each type + assert {:ok, "hello"} = evaluate("str", context) + assert {:ok, 42} = evaluate("num", context) + assert {:ok, true} = evaluate("bool", context) + assert {:ok, [1, 2, 3]} = evaluate("list", context) + assert {:ok, ~D[2024-01-15]} = evaluate("date", context) + end + + test "handles function calls with all data types" do + context = %{ + "date" => ~D[2024-03-15], + "datetime" => ~U[2024-03-15 14:30:00Z] + } + + assert {:ok, 5} = evaluate("len(\"hello\")", context) + assert {:ok, 10} = evaluate("max(5, 10)", context) + assert {:ok, 2024} = evaluate("year(date)", context) + assert {:ok, 2024} = evaluate("year(datetime)", context) + end + + test "handles mixed string and evaluate calls" do + # Test that both string expressions and pre-compiled instructions work + instructions = [["lit", 42]] + + assert {:ok, 42} = evaluate(instructions) + assert {:ok, 42} = evaluate("42") + end + + test "handles custom functions with errors" do + # Register a function that can return errors + register_function("validate_email", 1, fn [email], _context -> + if is_binary(email) and String.contains?(email, "@") do + {:ok, true} + else + {:error, "Invalid email format"} + end + end) + + assert {:ok, true} = evaluate("validate_email(\"user@example.com\")") + assert {:error, "Invalid email format"} = evaluate("validate_email(\"invalid\")") + assert {:error, "Invalid email format"} = evaluate("validate_email(123)") + end + + test "handles context with nil values" do + context = %{ + "nullable_field" => nil, + "empty_string" => "", + "zero_number" => 0 + } + + # When loading a variable that exists but has nil value, it should return :undefined + # This matches the evaluator's behavior for nil context values + assert {:ok, :undefined} = evaluate("nullable_field", context) + assert {:ok, ""} = evaluate("empty_string", context) + assert {:ok, 0} = evaluate("zero_number", context) + + # Test comparisons with nil - comparing nil values returns :undefined in the evaluator + assert {:ok, :undefined} = evaluate("nullable_field = nil", context) + assert {:ok, :undefined} = evaluate("empty_string = nil", context) + end + + test "handles very long variable names" do + long_var_name = String.duplicate("very_long_variable_name_", 10) + context = %{long_var_name => "test_value"} + + assert {:ok, "test_value"} = evaluate(long_var_name, context) + end + + test "handles expressions with unicode characters" do + context = %{ + "name" => "José María", + "emoji" => "🚀", + "japanese" => "こんにちは" + } + + assert {:ok, "José María"} = evaluate("name", context) + assert {:ok, "🚀"} = evaluate("emoji", context) + assert {:ok, "こんにちは"} = evaluate("japanese", context) + + # Test string functions with unicode + # José María = 10 chars + assert {:ok, 10} = evaluate("len(name)", context) + # emoji = 1 char + assert {:ok, 1} = evaluate("len(emoji)", context) + end + end + + describe "function registry integration" do + test "handles function overrides" do + # Register a custom version of a built-in function name + register_function("len", 1, fn [_value], _context -> + {:ok, "custom_len_result"} + end) + + # Should use the custom version (last registered wins) + assert {:ok, "custom_len_result"} = evaluate("len(\"anything\")") + + # Clear and re-register built-ins to restore normal behavior + clear_custom_functions() + assert {:ok, 8} = evaluate("len(\"restored\")") + end + + test "handles function registry state across tests" do + # Register a test function + register_function("test_func", 0, fn [], _context -> {:ok, :test} end) + assert {:ok, :test} = evaluate("test_func()") + + # Clear functions + clear_custom_functions() + + # Function should be gone but built-ins should remain + assert {:error, _message} = evaluate("test_func()") + # built-in should work + assert {:ok, 4} = evaluate("len(\"test\")") + end + + test "handles function listing with mixed types" do + register_function("custom1", 1, fn [_arg], _context -> {:ok, 1} end) + register_function("custom2", 2, fn [_arg1, _arg2], _context -> {:ok, 2} end) + + functions = list_custom_functions() + + # Should include built-ins + custom functions + names = Enum.map(functions, & &1.name) + assert "custom1" in names + assert "custom2" in names + # built-in + assert "len" in names + # built-in + assert "max" in names + + # Should have correct arities + custom1 = Enum.find(functions, &(&1.name == "custom1")) + custom2 = Enum.find(functions, &(&1.name == "custom2")) + len_func = Enum.find(functions, &(&1.name == "len")) + + assert custom1.arity == 1 + assert custom2.arity == 2 + assert len_func.arity == 1 + end + end + + describe "error message quality" do + test "provides helpful error messages for parsing errors" do + result = evaluate("1 +") + assert {:error, message} = result + assert message =~ "Unexpected character" or message =~ "Expected" + end + + test "provides helpful error messages for function errors" do + result = evaluate("unknown_function()") + assert {:error, message} = result + assert message =~ "Unknown function" + assert message =~ "unknown_function" + end + + test "provides helpful error messages for type errors" do + result = evaluate("len(123)") + assert {:error, message} = result + assert message =~ "expects a string" + end + + test "provides helpful error messages for arity errors" do + result = evaluate("len()") + assert {:error, message} = result + assert message =~ "expects 1 arguments, got 0" + end + + test "provides helpful error messages for invalid syntax" do + invalid_expressions = [ + "1 + + 2", + "func(,)", + "AND OR", + "1 = = 2" + ] + + for expr <- invalid_expressions do + result = evaluate(expr) + assert {:error, _message} = result + end + end + end +end diff --git a/test/predicator_test.exs b/test/predicator_test.exs index a7f6517..045e32a 100644 --- a/test/predicator_test.exs +++ b/test/predicator_test.exs @@ -50,7 +50,7 @@ defmodule PredicatorTest do assert {:error, message} = result assert message =~ - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found end of input" + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found end of input" assert message =~ "line 1, column 8" end @@ -60,7 +60,7 @@ defmodule PredicatorTest do assert {:error, message} = result assert message =~ - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found '>'" + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found '>'" end end @@ -179,7 +179,7 @@ defmodule PredicatorTest do assert {:error, message} = result assert message =~ - "Expected number, string, boolean, date, datetime, identifier, list, or '(' but found end of input" + "Expected number, string, boolean, date, datetime, identifier, function call, list, or '(' but found end of input" assert message =~ "line 1, column 8" end