Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 0 additions & 36 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
45 changes: 37 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 )* )? "]"
```

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
81 changes: 77 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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})
Expand All @@ -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#")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 )* )? "]"
```

Expand All @@ -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

Expand All @@ -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:
Expand Down
81 changes: 78 additions & 3 deletions lib/predicator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
12 changes: 8 additions & 4 deletions lib/predicator/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading