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
13 changes: 12 additions & 1 deletion 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.
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 (>, <, >=, <=, =, !=) and logical operators (AND, OR, NOT) with proper precedence.

## Architecture

Expand All @@ -14,6 +14,17 @@ Expression String → Lexer → Parser → Compiler → Instructions → Evaluat
StringVisitor (decompile)
```

### Grammar with Operator Precedence

```
expression → logical_or
logical_or → logical_and ( "OR" logical_and )*
logical_and → logical_not ( "AND" logical_not )*
logical_not → "NOT" logical_not | comparison
comparison → primary ( ( ">" | "<" | ">=" | "<=" | "=" | "!=" ) primary )?
primary → NUMBER | STRING | BOOLEAN | IDENTIFIER | "(" expression ")"
```

### Core Components

- **Lexer** (`lib/predicator/lexer.ex`): Tokenizes expressions with position tracking
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ iex> Predicator.evaluate("name = \"Alice\"", %{"name" => "Alice"})
iex> Predicator.evaluate("(age >= 18) = true", %{"age" => 25})
{:ok, true}

# Logical operators
iex> Predicator.evaluate("score > 85 AND age >= 18", %{"score" => 92, "age" => 25})
{:ok, true}

iex> Predicator.evaluate("role = \"admin\" OR role = \"manager\"", %{"role" => "admin"})
{:ok, true}

iex> Predicator.evaluate("NOT expired = true", %{"expired" => false})
{:ok, true}

# Compile once, evaluate many times
iex> {:ok, instructions} = Predicator.compile("score > threshold")
iex> Predicator.evaluate(instructions, %{"score" => 95, "threshold" => 80})
Expand All @@ -48,6 +58,9 @@ iex> Predicator.decompile(ast)
| `<=` | Less than or equal | `count <= 5` |
| `=` | Equal | `status = "active"` |
| `!=` | Not equal | `role != "guest"` |
| `AND` | Logical AND | `score > 85 AND age >= 18` |
| `OR` | Logical OR | `role = "admin" OR role = "manager"` |
| `NOT` | Logical NOT | `NOT expired = true` |

## Data Types

Expand All @@ -63,7 +76,7 @@ Predicator uses a multi-stage compilation pipeline:
```
Expression String → Lexer → Parser → Compiler → Instructions
↓ ↓ ↓ ↓ ↓
"score > 85" → Tokens → AST → Instructions → Evaluation
"score > 85 AND age >= 18" → Tokens → AST → Instructions → Evaluation
```

### Core Components
Expand All @@ -82,7 +95,7 @@ Predicator provides detailed error information with exact positioning:
iex> Predicator.evaluate("score >> 85", %{})
{:error, "Unexpected character '>' at line 1, column 8"}

iex> Predicator.evaluate("score >", %{})
iex> Predicator.evaluate("score AND", %{})
{:error, "Expected number, string, boolean, identifier, or '(' but found end of input at line 1, column 1"}
```

Expand Down
16 changes: 16 additions & 0 deletions lib/predicator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,22 @@ defmodule Predicator do
end
end

@doc """
Parses an expression string into an Abstract Syntax Tree.

## Examples

iex> Predicator.parse("score > 85")
{:ok, {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}}
"""
@spec parse(binary()) :: {:ok, Parser.ast()} | {:error, binary(), pos_integer(), pos_integer()}
def parse(expression) when is_binary(expression) do
case Lexer.tokenize(expression) do
{:ok, tokens} -> Parser.parse(tokens)
{:error, message, line, column} -> {:error, message, line, column}
end
end

@doc """
Converts an AST back to a string representation.

Expand Down
56 changes: 56 additions & 0 deletions lib/predicator/evaluator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,15 @@ defmodule Predicator.Evaluator do
["compare", operator] when operator in ["GT", "LT", "EQ", "GTE", "LTE", "NE"] ->
execute_compare(evaluator, operator)

["and"] ->
execute_logical_and(evaluator)

["or"] ->
execute_logical_or(evaluator)

["not"] ->
execute_logical_not(evaluator)

unknown ->
{:error, "Unknown instruction: #{inspect(unknown)}"}
end
Expand Down Expand Up @@ -208,6 +217,53 @@ defmodule Predicator.Evaluator do
%__MODULE__{evaluator | stack: [value | stack]}
end

@spec execute_logical_and(t()) :: {:ok, t()} | {:error, term()}
defp execute_logical_and(%__MODULE__{stack: [right | [left | rest]]} = evaluator)
when is_boolean(left) and is_boolean(right) do
result = left and right
{:ok, %__MODULE__{evaluator | stack: [result | rest]}}
end

defp execute_logical_and(%__MODULE__{stack: [right | [left | _rest]]}) do
{:error,
"Logical AND requires two boolean values, got: #{inspect(left)} and #{inspect(right)}"}
end

defp execute_logical_and(%__MODULE__{stack: stack}) do
{:error, "Logical AND requires two values on stack, got: #{length(stack)}"}
end

@spec execute_logical_or(t()) :: {:ok, t()} | {:error, term()}
defp execute_logical_or(%__MODULE__{stack: [right | [left | rest]]} = evaluator)
when is_boolean(left) and is_boolean(right) do
result = left or right
{:ok, %__MODULE__{evaluator | stack: [result | rest]}}
end

defp execute_logical_or(%__MODULE__{stack: [right | [left | _rest]]}) do
{:error,
"Logical OR requires two boolean values, got: #{inspect(left)} and #{inspect(right)}"}
end

defp execute_logical_or(%__MODULE__{stack: stack}) do
{:error, "Logical OR requires two values on stack, got: #{length(stack)}"}
end

@spec execute_logical_not(t()) :: {:ok, t()} | {:error, term()}
defp execute_logical_not(%__MODULE__{stack: [value | rest]} = evaluator)
when is_boolean(value) do
result = not value
{:ok, %__MODULE__{evaluator | stack: [result | rest]}}
end

defp execute_logical_not(%__MODULE__{stack: [value | _rest]}) do
{:error, "Logical NOT requires a boolean value, got: #{inspect(value)}"}
end

defp execute_logical_not(%__MODULE__{stack: []}) do
{:error, "Logical NOT requires one value on stack, got: 0"}
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
Expand Down
30 changes: 30 additions & 0 deletions lib/predicator/instructions_visitor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ defmodule Predicator.InstructionsVisitor do
iex> ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}
iex> Predicator.InstructionsVisitor.visit(ast, [])
[["load", "score"], ["lit", 85], ["compare", "GT"]]

iex> ast = {:logical_and, {:literal, true}, {:literal, false}}
iex> Predicator.InstructionsVisitor.visit(ast, [])
[["lit", true], ["lit", false], ["and"]]
"""

@behaviour Predicator.Visitor
Expand Down Expand Up @@ -61,6 +65,32 @@ defmodule Predicator.InstructionsVisitor do
left_instructions ++ right_instructions ++ op_instruction
end

def visit({:logical_and, left, right}, opts) do
# Post-order traversal: left operand, right operand, then operator
left_instructions = visit(left, opts)
right_instructions = visit(right, opts)
op_instruction = [["and"]]

left_instructions ++ right_instructions ++ op_instruction
end

def visit({:logical_or, left, right}, opts) do
# Post-order traversal: left operand, right operand, then operator
left_instructions = visit(left, opts)
right_instructions = visit(right, opts)
op_instruction = [["or"]]

left_instructions ++ right_instructions ++ op_instruction
end

def visit({:logical_not, operand}, opts) do
# Post-order traversal: operand first, then operator
operand_instructions = visit(operand, opts)
op_instruction = [["not"]]

operand_instructions ++ op_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"
Expand Down
21 changes: 21 additions & 0 deletions lib/predicator/lexer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ defmodule Predicator.Lexer do
| {:lte, pos_integer(), pos_integer(), pos_integer(), binary()}
| {:eq, pos_integer(), pos_integer(), pos_integer(), binary()}
| {:ne, pos_integer(), pos_integer(), pos_integer(), binary()}
| {:and_op, pos_integer(), pos_integer(), pos_integer(), binary()}
| {:or_op, pos_integer(), pos_integer(), pos_integer(), binary()}
| {:not_op, pos_integer(), pos_integer(), pos_integer(), binary()}
| {:lparen, pos_integer(), pos_integer(), pos_integer(), binary()}
| {:rparen, pos_integer(), pos_integer(), pos_integer(), binary()}
| {:eof, pos_integer(), pos_integer(), pos_integer(), nil}
Expand Down Expand Up @@ -102,6 +105,18 @@ defmodule Predicator.Lexer do
{:string, 1, 8, 6, "John"},
{:eof, 1, 14, 0, nil}
]}

iex> Predicator.Lexer.tokenize("score > 85 AND age >= 18")
{:ok, [
{:identifier, 1, 1, 5, "score"},
{:gt, 1, 7, 1, ">"},
{:integer, 1, 9, 2, 85},
{:and_op, 1, 12, 3, "AND"},
{:identifier, 1, 16, 3, "age"},
{:gte, 1, 20, 2, ">="},
{:integer, 1, 23, 2, 18},
{:eof, 1, 25, 0, nil}
]}
"""
@spec tokenize(binary()) :: result()
def tokenize(input) when is_binary(input) do
Expand Down Expand Up @@ -245,6 +260,12 @@ defmodule Predicator.Lexer do
@spec classify_identifier(binary()) :: {atom(), binary() | boolean()}
defp classify_identifier("true"), do: {:boolean, true}
defp classify_identifier("false"), do: {:boolean, false}
defp classify_identifier("AND"), do: {:and_op, "AND"}
defp classify_identifier("OR"), do: {:or_op, "OR"}
defp classify_identifier("NOT"), do: {:not_op, "NOT"}
defp classify_identifier("and"), do: {:and_op, "and"}
defp classify_identifier("or"), do: {:or_op, "or"}
defp classify_identifier("not"), do: {:not_op, "not"}
defp classify_identifier(id), do: {:identifier, id}

@spec take_string(charlist(), binary(), pos_integer()) ::
Expand Down
Loading