diff --git a/CLAUDE.md b/CLAUDE.md index f25af3f..2111e6e 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. +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 @@ -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 diff --git a/README.md b/README.md index 9b682ca..630743f 100644 --- a/README.md +++ b/README.md @@ -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}) @@ -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 @@ -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 @@ -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"} ``` diff --git a/lib/predicator.ex b/lib/predicator.ex index 6ab56e6..b8ad094 100644 --- a/lib/predicator.ex +++ b/lib/predicator.ex @@ -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. diff --git a/lib/predicator/evaluator.ex b/lib/predicator/evaluator.ex index 9335c81..df78acf 100644 --- a/lib/predicator/evaluator.ex +++ b/lib/predicator/evaluator.ex @@ -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 @@ -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 diff --git a/lib/predicator/instructions_visitor.ex b/lib/predicator/instructions_visitor.ex index 1c0f8b2..120e980 100644 --- a/lib/predicator/instructions_visitor.ex +++ b/lib/predicator/instructions_visitor.ex @@ -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 @@ -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" diff --git a/lib/predicator/lexer.ex b/lib/predicator/lexer.ex index b32ca47..e963f49 100644 --- a/lib/predicator/lexer.ex +++ b/lib/predicator/lexer.ex @@ -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} @@ -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 @@ -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()) :: diff --git a/lib/predicator/parser.ex b/lib/predicator/parser.ex index d00c7b7..75cf2df 100644 --- a/lib/predicator/parser.ex +++ b/lib/predicator/parser.ex @@ -7,9 +7,12 @@ defmodule Predicator.Parser do ## Grammar - The parser implements this grammar: + The parser implements this grammar with proper operator precedence: - expression → comparison + 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 ")" @@ -22,6 +25,10 @@ defmodule Predicator.Parser do iex> {:ok, tokens} = Predicator.Lexer.tokenize("(age >= 18)") iex> Predicator.Parser.parse(tokens) {:ok, {:comparison, :gte, {:identifier, "age"}, {:literal, 18}}} + + iex> {:ok, tokens} = Predicator.Lexer.tokenize("score > 85 AND age >= 18") + iex> Predicator.Parser.parse(tokens) + {:ok, {:logical_and, {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}, {:comparison, :gte, {:identifier, "age"}, {:literal, 18}}}} """ alias Predicator.Lexer @@ -37,11 +44,17 @@ defmodule Predicator.Parser do - `{:literal, value}` - A literal value (number, string, boolean) - `{:identifier, name}` - A variable reference - `{:comparison, operator, left, right}` - A comparison expression + - `{:logical_and, left, right}` - A logical AND expression + - `{:logical_or, left, right}` - A logical OR expression + - `{:logical_not, operand}` - A logical NOT expression """ @type ast :: {:literal, value()} | {:identifier, binary()} | {:comparison, comparison_op(), ast(), ast()} + | {:logical_and, ast(), ast()} + | {:logical_or, ast(), ast()} + | {:logical_not, ast()} @typedoc """ Comparison operators in the AST. @@ -114,7 +127,97 @@ defmodule Predicator.Parser do @spec parse_expression(parser_state()) :: {:ok, ast(), parser_state()} | {:error, binary(), pos_integer(), pos_integer()} defp parse_expression(state) do - parse_comparison(state) + parse_logical_or(state) + end + + # Parse logical OR expressions (lowest precedence) + @spec parse_logical_or(parser_state()) :: + {:ok, ast(), parser_state()} | {:error, binary(), pos_integer(), pos_integer()} + defp parse_logical_or(state) do + case parse_logical_and(state) do + {:ok, left, new_state} -> + parse_logical_or_rest(left, new_state) + + {:error, message, line, col} -> + {:error, message, line, col} + end + end + + @spec parse_logical_or_rest(ast(), parser_state()) :: + {:ok, ast(), parser_state()} | {:error, binary(), pos_integer(), pos_integer()} + defp parse_logical_or_rest(left, state) do + case peek_token(state) do + {:or_op, _line, _col, _len, _value} -> + or_state = advance(state) + + case parse_logical_and(or_state) do + {:ok, right, final_state} -> + ast = {:logical_or, left, right} + parse_logical_or_rest(ast, final_state) + + {:error, message, line, col} -> + {:error, message, line, col} + end + + _token -> + {:ok, left, state} + end + end + + # Parse logical AND expressions (middle precedence) + @spec parse_logical_and(parser_state()) :: + {:ok, ast(), parser_state()} | {:error, binary(), pos_integer(), pos_integer()} + defp parse_logical_and(state) do + case parse_logical_not(state) do + {:ok, left, new_state} -> + parse_logical_and_rest(left, new_state) + + {:error, message, line, col} -> + {:error, message, line, col} + end + end + + @spec parse_logical_and_rest(ast(), parser_state()) :: + {:ok, ast(), parser_state()} | {:error, binary(), pos_integer(), pos_integer()} + defp parse_logical_and_rest(left, state) do + case peek_token(state) do + {:and_op, _line, _col, _len, _value} -> + and_state = advance(state) + + case parse_logical_not(and_state) do + {:ok, right, final_state} -> + ast = {:logical_and, left, right} + parse_logical_and_rest(ast, final_state) + + {:error, message, line, col} -> + {:error, message, line, col} + end + + _token -> + {:ok, left, state} + end + end + + # Parse logical NOT expressions (highest precedence) + @spec parse_logical_not(parser_state()) :: + {:ok, ast(), parser_state()} | {:error, binary(), pos_integer(), pos_integer()} + defp parse_logical_not(state) do + case peek_token(state) do + {:not_op, _line, _col, _len, _value} -> + not_state = advance(state) + + case parse_logical_not(not_state) do + {:ok, operand, final_state} -> + ast = {:logical_not, operand} + {:ok, ast, final_state} + + {:error, message, line, col} -> + {:error, message, line, col} + end + + _token -> + parse_comparison(state) + end end # Parse comparison expressions @@ -238,6 +341,9 @@ defmodule Predicator.Parser do defp format_token(:lte, _value), do: "'<='" defp format_token(:eq, _value), do: "'='" defp format_token(:ne, _value), do: "'!='" + defp format_token(:and_op, _value), do: "'AND'" + defp format_token(:or_op, _value), do: "'OR'" + defp format_token(:not_op, _value), do: "'NOT'" defp format_token(:lparen, _value), do: "'('" defp format_token(:rparen, _value), do: "')'" defp format_token(:eof, _value), do: "end of input" diff --git a/lib/predicator/string_visitor.ex b/lib/predicator/string_visitor.ex index 2d92892..0e29f96 100644 --- a/lib/predicator/string_visitor.ex +++ b/lib/predicator/string_visitor.ex @@ -23,6 +23,14 @@ defmodule Predicator.StringVisitor do iex> ast = {:comparison, :eq, {:identifier, "name"}, {:literal, "John"}} iex> Predicator.StringVisitor.visit(ast, []) ~s(name = "John") + + iex> ast = {:logical_and, {:literal, true}, {:literal, false}} + iex> Predicator.StringVisitor.visit(ast, []) + "true AND false" + + iex> ast = {:logical_not, {:literal, true}} + iex> Predicator.StringVisitor.visit(ast, []) + "NOT true" """ @behaviour Predicator.Visitor @@ -97,6 +105,39 @@ defmodule Predicator.StringVisitor do end end + def visit({:logical_and, left, right}, opts) do + left_str = visit(left, opts) + right_str = visit(right, opts) + spacing = get_spacing(opts) + + case get_parentheses_mode(opts) do + :explicit -> "(#{left_str}#{spacing}AND#{spacing}#{right_str})" + _other -> "#{left_str}#{spacing}AND#{spacing}#{right_str}" + end + end + + def visit({:logical_or, left, right}, opts) do + left_str = visit(left, opts) + right_str = visit(right, opts) + spacing = get_spacing(opts) + + case get_parentheses_mode(opts) do + :explicit -> "(#{left_str}#{spacing}OR#{spacing}#{right_str})" + _other -> "#{left_str}#{spacing}OR#{spacing}#{right_str}" + end + end + + def visit({:logical_not, operand}, opts) do + operand_str = visit(operand, opts) + spacing = get_spacing(opts) + + case get_parentheses_mode(opts) do + :explicit -> "(NOT#{spacing}#{operand_str})" + :none -> "NOT#{spacing}#{operand_str}" + :minimal -> "NOT#{spacing}#{operand_str}" + end + end + # Helper functions @spec format_operator(Parser.comparison_op()) :: binary() diff --git a/lib/predicator/types.ex b/lib/predicator/types.ex index 1f5f76c..269edf6 100644 --- a/lib/predicator/types.ex +++ b/lib/predicator/types.ex @@ -41,12 +41,18 @@ defmodule Predicator.Types do - `["lit", value()]` - Push literal value onto stack - `["load", binary()]` - Load variable from context onto stack - `["compare", binary()]` - 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 ## Examples ["lit", 42] # Push literal 42 onto stack ["load", "score"] # Load variable 'score' from context ["compare", "GT"] # Pop two values, compare with >, push result + ["and"] # Pop two boolean values, push AND result + ["or"] # Pop two boolean values, push OR result + ["not"] # Pop one boolean value, push NOT result """ @type instruction :: [binary() | value()] diff --git a/test/predicator/evaluator_test.exs b/test/predicator/evaluator_test.exs index 20a8400..199f66b 100644 --- a/test/predicator/evaluator_test.exs +++ b/test/predicator/evaluator_test.exs @@ -221,4 +221,159 @@ defmodule Predicator.EvaluatorTest do end end end + + describe "logical operators" do + test "evaluates logical AND with true values" do + instructions = [["lit", true], ["lit", true], ["and"]] + assert Evaluator.evaluate(instructions) == true + end + + test "evaluates logical AND with false values" do + instructions = [["lit", false], ["lit", false], ["and"]] + assert Evaluator.evaluate(instructions) == false + end + + test "evaluates logical AND with mixed values" do + instructions = [["lit", true], ["lit", false], ["and"]] + assert Evaluator.evaluate(instructions) == false + + instructions = [["lit", false], ["lit", true], ["and"]] + assert Evaluator.evaluate(instructions) == false + end + + test "evaluates logical OR with true values" do + instructions = [["lit", true], ["lit", true], ["or"]] + assert Evaluator.evaluate(instructions) == true + end + + test "evaluates logical OR with false values" do + instructions = [["lit", false], ["lit", false], ["or"]] + assert Evaluator.evaluate(instructions) == false + end + + test "evaluates logical OR with mixed values" do + instructions = [["lit", true], ["lit", false], ["or"]] + assert Evaluator.evaluate(instructions) == true + + instructions = [["lit", false], ["lit", true], ["or"]] + assert Evaluator.evaluate(instructions) == true + end + + test "evaluates logical NOT with true value" do + instructions = [["lit", true], ["not"]] + assert Evaluator.evaluate(instructions) == false + end + + test "evaluates logical NOT with false value" do + instructions = [["lit", false], ["not"]] + assert Evaluator.evaluate(instructions) == true + end + + test "returns error for logical AND with non-boolean values" do + instructions = [["lit", 42], ["lit", true], ["and"]] + result = Evaluator.evaluate(instructions) + assert {:error, _message} = result + assert match?({:error, "Logical AND requires two boolean values" <> _}, result) + end + + test "returns error for logical AND with insufficient stack" do + instructions = [["lit", true], ["and"]] + result = Evaluator.evaluate(instructions) + assert {:error, "Logical AND requires two values on stack, got: 1"} = result + end + + test "returns error for logical AND with empty stack" do + instructions = [["and"]] + result = Evaluator.evaluate(instructions) + assert {:error, "Logical AND requires two values on stack, got: 0"} = result + end + + test "returns error for logical OR with non-boolean values" do + instructions = [["lit", "hello"], ["lit", false], ["or"]] + result = Evaluator.evaluate(instructions) + assert {:error, _message} = result + assert match?({:error, "Logical OR requires two boolean values" <> _}, result) + end + + test "returns error for logical OR with insufficient stack" do + instructions = [["lit", false], ["or"]] + result = Evaluator.evaluate(instructions) + assert {:error, "Logical OR requires two values on stack, got: 1"} = result + end + + test "returns error for logical OR with empty stack" do + instructions = [["or"]] + result = Evaluator.evaluate(instructions) + assert {:error, "Logical OR requires two values on stack, got: 0"} = result + end + + test "returns error for logical NOT with non-boolean value" do + instructions = [["lit", 123], ["not"]] + result = Evaluator.evaluate(instructions) + assert {:error, "Logical NOT requires a boolean value, got: 123"} = result + end + + test "returns error for logical NOT with empty stack" do + instructions = [["not"]] + result = Evaluator.evaluate(instructions) + assert {:error, "Logical NOT requires one value on stack, got: 0"} = result + end + + test "complex logical expression with variables" do + # (score > 85 AND age >= 18) OR admin = true + instructions = [ + ["load", "score"], + ["lit", 85], + ["compare", "GT"], + ["load", "age"], + ["lit", 18], + ["compare", "GTE"], + ["and"], + ["load", "admin"], + ["lit", true], + ["compare", "EQ"], + ["or"] + ] + + context = %{"score" => 90, "age" => 20, "admin" => false} + assert Evaluator.evaluate(instructions, context) == true + + context = %{"score" => 80, "age" => 16, "admin" => false} + assert Evaluator.evaluate(instructions, context) == false + + context = %{"score" => 80, "age" => 16, "admin" => true} + assert Evaluator.evaluate(instructions, context) == true + end + + test "nested NOT expressions" do + # NOT (NOT true) + instructions = [["lit", true], ["not"], ["not"]] + assert Evaluator.evaluate(instructions) == true + + # NOT (NOT (NOT false)) + instructions = [["lit", false], ["not"], ["not"], ["not"]] + assert Evaluator.evaluate(instructions) == true + end + + test "mixed comparison and logical operations" do + # score > 85 AND NOT expired + instructions = [ + ["load", "score"], + ["lit", 85], + ["compare", "GT"], + ["load", "expired"], + ["not"], + ["and"] + ] + + context = %{"score" => 90, "expired" => false} + assert Evaluator.evaluate(instructions, context) == true + + context = %{"score" => 80, "expired" => false} + assert Evaluator.evaluate(instructions, context) == false + + context = %{"score" => 90, "expired" => true} + assert Evaluator.evaluate(instructions, context) == false + end + end end diff --git a/test/predicator/instructions_visitor_test.exs b/test/predicator/instructions_visitor_test.exs index 176124d..ae015f7 100644 --- a/test/predicator/instructions_visitor_test.exs +++ b/test/predicator/instructions_visitor_test.exs @@ -134,6 +134,139 @@ defmodule Predicator.InstructionsVisitorTest do end end + describe "visit/2 - logical nodes" do + test "generates instructions for logical AND" do + ast = {:logical_and, {:literal, true}, {:literal, false}} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["lit", true], + ["lit", false], + ["and"] + ] + end + + test "generates instructions for logical OR" do + ast = {:logical_or, {:identifier, "admin"}, {:literal, false}} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "admin"], + ["lit", false], + ["or"] + ] + end + + test "generates instructions for logical NOT" do + ast = {:logical_not, {:identifier, "expired"}} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "expired"], + ["not"] + ] + end + + test "generates instructions for nested logical NOT" do + ast = {:logical_not, {:logical_not, {:literal, true}}} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["lit", true], + ["not"], + ["not"] + ] + end + + test "generates instructions for complex logical expression" do + # (score > 85 AND age >= 18) OR admin = true + ast = { + :logical_or, + {:logical_and, {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}, + {:comparison, :gte, {:identifier, "age"}, {:literal, 18}}}, + {:comparison, :eq, {:identifier, "admin"}, {:literal, true}} + } + + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + # Left side of OR: (score > 85 AND age >= 18) + ["load", "score"], + ["lit", 85], + ["compare", "GT"], + ["load", "age"], + ["lit", 18], + ["compare", "GTE"], + ["and"], + # Right side of OR: admin = true + ["load", "admin"], + ["lit", true], + ["compare", "EQ"], + # Final OR + ["or"] + ] + end + + test "generates instructions for logical AND with comparisons" do + # score > 85 AND name = "John" + ast = { + :logical_and, + {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}, + {:comparison, :eq, {:identifier, "name"}, {:literal, "John"}} + } + + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "score"], + ["lit", 85], + ["compare", "GT"], + ["load", "name"], + ["lit", "John"], + ["compare", "EQ"], + ["and"] + ] + end + + test "generates instructions for logical OR with comparisons" do + # role = "admin" OR role = "manager" + ast = { + :logical_or, + {:comparison, :eq, {:identifier, "role"}, {:literal, "admin"}}, + {:comparison, :eq, {:identifier, "role"}, {:literal, "manager"}} + } + + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "role"], + ["lit", "admin"], + ["compare", "EQ"], + ["load", "role"], + ["lit", "manager"], + ["compare", "EQ"], + ["or"] + ] + end + + test "generates instructions for NOT with comparison" do + # NOT expired = true + ast = { + :logical_not, + {:comparison, :eq, {:identifier, "expired"}, {:literal, true}} + } + + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "expired"], + ["lit", true], + ["compare", "EQ"], + ["not"] + ] + end + end + describe "visit/2 - integration with full pipeline" do test "works with lexer and parser output" do alias Predicator.{Lexer, Parser} @@ -164,5 +297,59 @@ defmodule Predicator.InstructionsVisitorTest do ["compare", "GTE"] ] end + + test "works with logical AND expression" do + alias Predicator.{Lexer, Parser} + + {:ok, tokens} = Lexer.tokenize("score > 85 AND age >= 18") + {:ok, ast} = Parser.parse(tokens) + + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "score"], + ["lit", 85], + ["compare", "GT"], + ["load", "age"], + ["lit", 18], + ["compare", "GTE"], + ["and"] + ] + end + + test "works with logical OR expression" do + alias Predicator.{Lexer, Parser} + + {:ok, tokens} = Lexer.tokenize(~s(role = "admin" OR role = "manager")) + {:ok, ast} = Parser.parse(tokens) + + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "role"], + ["lit", "admin"], + ["compare", "EQ"], + ["load", "role"], + ["lit", "manager"], + ["compare", "EQ"], + ["or"] + ] + end + + test "works with logical NOT expression" do + alias Predicator.{Lexer, Parser} + + {:ok, tokens} = Lexer.tokenize("NOT expired = true") + {:ok, ast} = Parser.parse(tokens) + + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "expired"], + ["lit", true], + ["compare", "EQ"], + ["not"] + ] + end end end diff --git a/test/predicator/lexer_test.exs b/test/predicator/lexer_test.exs index fe38162..5960277 100644 --- a/test/predicator/lexer_test.exs +++ b/test/predicator/lexer_test.exs @@ -77,6 +77,52 @@ defmodule Predicator.LexerTest do {:eof, 1, 6, 0, nil} ] end + + test "tokenizes uppercase logical operators" do + assert {:ok, tokens} = Lexer.tokenize("AND") + + assert tokens == [ + {:and_op, 1, 1, 3, "AND"}, + {:eof, 1, 4, 0, nil} + ] + + assert {:ok, tokens} = Lexer.tokenize("OR") + + assert tokens == [ + {:or_op, 1, 1, 2, "OR"}, + {:eof, 1, 3, 0, nil} + ] + + assert {:ok, tokens} = Lexer.tokenize("NOT") + + assert tokens == [ + {:not_op, 1, 1, 3, "NOT"}, + {:eof, 1, 4, 0, nil} + ] + end + + test "tokenizes lowercase logical operators" do + assert {:ok, tokens} = Lexer.tokenize("and") + + assert tokens == [ + {:and_op, 1, 1, 3, "and"}, + {:eof, 1, 4, 0, nil} + ] + + assert {:ok, tokens} = Lexer.tokenize("or") + + assert tokens == [ + {:or_op, 1, 1, 2, "or"}, + {:eof, 1, 3, 0, nil} + ] + + assert {:ok, tokens} = Lexer.tokenize("not") + + assert tokens == [ + {:not_op, 1, 1, 3, "not"}, + {:eof, 1, 4, 0, nil} + ] + end end describe "tokenize/1 - string literals" do diff --git a/test/predicator/parser_test.exs b/test/predicator/parser_test.exs index 512d9b7..5110795 100644 --- a/test/predicator/parser_test.exs +++ b/test/predicator/parser_test.exs @@ -359,4 +359,247 @@ defmodule Predicator.ParserTest do result end end + + describe "logical operators" do + test "parses simple AND expression" do + tokens = [ + {: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} + ] + + result = Parser.parse(tokens) + + assert {:ok, + {:logical_and, {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}, + {:comparison, :gte, {:identifier, "age"}, {:literal, 18}}}} = result + end + + test "parses simple OR expression" do + tokens = [ + {:identifier, 1, 1, 4, "role"}, + {:eq, 1, 6, 1, "="}, + {:string, 1, 8, 7, "admin"}, + {:or_op, 1, 16, 2, "OR"}, + {:identifier, 1, 19, 4, "role"}, + {:eq, 1, 24, 1, "="}, + {:string, 1, 26, 9, "manager"}, + {:eof, 1, 36, 0, nil} + ] + + result = Parser.parse(tokens) + + assert {:ok, + {:logical_or, {:comparison, :eq, {:identifier, "role"}, {:literal, "admin"}}, + {:comparison, :eq, {:identifier, "role"}, {:literal, "manager"}}}} = result + end + + test "parses simple NOT expression" do + tokens = [ + {:not_op, 1, 1, 3, "NOT"}, + {:identifier, 1, 5, 7, "expired"}, + {:eq, 1, 13, 1, "="}, + {:boolean, 1, 15, 4, true}, + {:eof, 1, 19, 0, nil} + ] + + result = Parser.parse(tokens) + + assert {:ok, {:logical_not, {:comparison, :eq, {:identifier, "expired"}, {:literal, true}}}} = + result + end + + test "parses nested NOT expression" do + tokens = [ + {:not_op, 1, 1, 3, "NOT"}, + {:not_op, 1, 5, 3, "NOT"}, + {:boolean, 1, 9, 4, true}, + {:eof, 1, 13, 0, nil} + ] + + result = Parser.parse(tokens) + + assert {:ok, {:logical_not, {:logical_not, {:literal, true}}}} = result + end + + test "parses operator precedence correctly - AND has higher precedence than OR" do + # true OR false AND true should parse as: true OR (false AND true) + tokens = [ + {:boolean, 1, 1, 4, true}, + {:or_op, 1, 6, 2, "OR"}, + {:boolean, 1, 9, 5, false}, + {:and_op, 1, 15, 3, "AND"}, + {:boolean, 1, 19, 4, true}, + {:eof, 1, 23, 0, nil} + ] + + result = Parser.parse(tokens) + + assert {:ok, + {:logical_or, {:literal, true}, {:logical_and, {:literal, false}, {:literal, true}}}} = + result + end + + test "parses operator precedence correctly - NOT has highest precedence" do + # NOT false AND true should parse as: (NOT false) AND true + tokens = [ + {:not_op, 1, 1, 3, "NOT"}, + {:boolean, 1, 5, 5, false}, + {:and_op, 1, 11, 3, "AND"}, + {:boolean, 1, 15, 4, true}, + {:eof, 1, 19, 0, nil} + ] + + result = Parser.parse(tokens) + + assert {:ok, {:logical_and, {:logical_not, {:literal, false}}, {:literal, true}}} = result + end + + test "parses complex precedence expression" do + # NOT false OR true AND false should parse as: (NOT false) OR (true AND false) + tokens = [ + {:not_op, 1, 1, 3, "NOT"}, + {:boolean, 1, 5, 5, false}, + {:or_op, 1, 11, 2, "OR"}, + {:boolean, 1, 14, 4, true}, + {:and_op, 1, 19, 3, "AND"}, + {:boolean, 1, 23, 5, false}, + {:eof, 1, 28, 0, nil} + ] + + result = Parser.parse(tokens) + + assert {:ok, + {:logical_or, {:logical_not, {:literal, false}}, + {:logical_and, {:literal, true}, {:literal, false}}}} = result + end + + test "parses left-associative AND operations" do + # true AND false AND true should parse as: (true AND false) AND true + tokens = [ + {:boolean, 1, 1, 4, true}, + {:and_op, 1, 6, 3, "AND"}, + {:boolean, 1, 10, 5, false}, + {:and_op, 1, 16, 3, "AND"}, + {:boolean, 1, 20, 4, true}, + {:eof, 1, 24, 0, nil} + ] + + result = Parser.parse(tokens) + + assert {:ok, + {:logical_and, {:logical_and, {:literal, true}, {:literal, false}}, + {:literal, true}}} = result + end + + test "parses left-associative OR operations" do + # true OR false OR true should parse as: (true OR false) OR true + tokens = [ + {:boolean, 1, 1, 4, true}, + {:or_op, 1, 6, 2, "OR"}, + {:boolean, 1, 9, 5, false}, + {:or_op, 1, 15, 2, "OR"}, + {:boolean, 1, 18, 4, true}, + {:eof, 1, 22, 0, nil} + ] + + result = Parser.parse(tokens) + + assert {:ok, + {:logical_or, {:logical_or, {:literal, true}, {:literal, false}}, {:literal, true}}} = + result + end + + test "parses parenthesized logical expressions" do + # (true OR false) AND true + tokens = [ + {:lparen, 1, 1, 1, "("}, + {:boolean, 1, 2, 4, true}, + {:or_op, 1, 7, 2, "OR"}, + {:boolean, 1, 10, 5, false}, + {:rparen, 1, 15, 1, ")"}, + {:and_op, 1, 17, 3, "AND"}, + {:boolean, 1, 21, 4, true}, + {:eof, 1, 25, 0, nil} + ] + + result = Parser.parse(tokens) + + assert {:ok, + {:logical_and, {:logical_or, {:literal, true}, {:literal, false}}, {:literal, true}}} = + result + end + + test "handles error when AND missing right operand" do + tokens = [ + {:boolean, 1, 1, 4, true}, + {:and_op, 1, 6, 3, "AND"}, + {:eof, 1, 9, 0, nil} + ] + + result = Parser.parse(tokens) + + assert {:error, + "Expected number, string, boolean, identifier, or '(' but found end of input", 1, + 9} = result + end + + test "handles error when OR missing right operand" do + tokens = [ + {:boolean, 1, 1, 4, true}, + {:or_op, 1, 6, 2, "OR"}, + {:eof, 1, 8, 0, nil} + ] + + result = Parser.parse(tokens) + + assert {:error, + "Expected number, string, boolean, identifier, or '(' but found end of input", 1, + 8} = result + end + + test "handles error when NOT missing operand" do + tokens = [ + {:not_op, 1, 1, 3, "NOT"}, + {:eof, 1, 4, 0, nil} + ] + + result = Parser.parse(tokens) + + assert {:error, + "Expected number, string, boolean, identifier, or '(' but found end of input", 1, + 4} = result + end + + test "complex mixed expression with comparisons and logical operators" do + # score > 85 AND age >= 18 OR admin = true + tokens = [ + {: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}, + {:or_op, 1, 26, 2, "OR"}, + {:identifier, 1, 29, 5, "admin"}, + {:eq, 1, 35, 1, "="}, + {:boolean, 1, 37, 4, true}, + {:eof, 1, 41, 0, nil} + ] + + result = Parser.parse(tokens) + + assert {:ok, + {:logical_or, + {:logical_and, {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}, + {:comparison, :gte, {:identifier, "age"}, {:literal, 18}}}, + {:comparison, :eq, {:identifier, "admin"}, {:literal, true}}}} = result + end + end end diff --git a/test/predicator/string_visitor_test.exs b/test/predicator/string_visitor_test.exs index a43ea13..f8d04d3 100644 --- a/test/predicator/string_visitor_test.exs +++ b/test/predicator/string_visitor_test.exs @@ -376,4 +376,189 @@ defmodule Predicator.StringVisitorTest do assert result == "temp < -10" end end + + describe "visit/2 - logical operators" do + test "formats simple logical AND" do + ast = {:logical_and, {:literal, true}, {:literal, false}} + result = StringVisitor.visit(ast, []) + + assert result == "true AND false" + end + + test "formats simple logical OR" do + ast = {:logical_or, {:literal, true}, {:literal, false}} + result = StringVisitor.visit(ast, []) + + assert result == "true OR false" + end + + test "formats simple logical NOT" do + ast = {:logical_not, {:literal, true}} + result = StringVisitor.visit(ast, []) + + assert result == "NOT true" + end + + test "formats logical AND with comparisons" do + ast = + {:logical_and, {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}, + {:comparison, :gte, {:identifier, "age"}, {:literal, 18}}} + + result = StringVisitor.visit(ast, []) + + assert result == "score > 85 AND age >= 18" + end + + test "formats logical OR with comparisons" do + ast = + {:logical_or, {:comparison, :eq, {:identifier, "role"}, {:literal, "admin"}}, + {:comparison, :eq, {:identifier, "role"}, {:literal, "manager"}}} + + result = StringVisitor.visit(ast, []) + + assert result == ~s(role = "admin" OR role = "manager") + end + + test "formats logical NOT with comparison" do + ast = {:logical_not, {:comparison, :eq, {:identifier, "expired"}, {:literal, true}}} + result = StringVisitor.visit(ast, []) + + assert result == "NOT expired = true" + end + + test "formats nested logical NOT" do + ast = {:logical_not, {:logical_not, {:literal, false}}} + result = StringVisitor.visit(ast, []) + + assert result == "NOT NOT false" + end + + test "formats complex nested logical expression" do + # (score > 85 AND age >= 18) OR admin = true + ast = + {:logical_or, + {:logical_and, {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}, + {:comparison, :gte, {:identifier, "age"}, {:literal, 18}}}, + {:comparison, :eq, {:identifier, "admin"}, {:literal, true}}} + + result = StringVisitor.visit(ast, []) + + assert result == "score > 85 AND age >= 18 OR admin = true" + end + + test "formats logical operators with compact spacing" do + ast = {:logical_and, {:literal, true}, {:literal, false}} + result = StringVisitor.visit(ast, spacing: :compact) + + assert result == "trueANDfalse" + end + + test "formats logical operators with verbose spacing" do + ast = {:logical_or, {:literal, true}, {:literal, false}} + result = StringVisitor.visit(ast, spacing: :verbose) + + assert result == "true OR false" + end + + test "formats logical operators with explicit parentheses" do + ast = {:logical_and, {:literal, true}, {:literal, false}} + result = StringVisitor.visit(ast, parentheses: :explicit) + + assert result == "(true AND false)" + end + + test "formats logical NOT with explicit parentheses" do + ast = {:logical_not, {:literal, true}} + result = StringVisitor.visit(ast, parentheses: :explicit) + + assert result == "(NOT true)" + end + + test "formats logical NOT with no parentheses mode" do + ast = {:logical_not, {:literal, false}} + result = StringVisitor.visit(ast, parentheses: :none) + + assert result == "NOT false" + end + + test "formats complex logical expression with all formatting options" do + ast = {:logical_not, {:logical_and, {:literal, true}, {:literal, false}}} + result = StringVisitor.visit(ast, spacing: :verbose, parentheses: :explicit) + + assert result == "(NOT (true AND false))" + end + + test "formats left-associative AND operations" do + # ((true AND false) AND true) + ast = {:logical_and, {:logical_and, {:literal, true}, {:literal, false}}, {:literal, true}} + result = StringVisitor.visit(ast, []) + + assert result == "true AND false AND true" + end + + test "formats left-associative OR operations" do + # ((true OR false) OR true) + ast = {:logical_or, {:logical_or, {:literal, true}, {:literal, false}}, {:literal, true}} + result = StringVisitor.visit(ast, []) + + assert result == "true OR false OR true" + end + + test "formats mixed comparison and logical operations" do + # score > 85 AND NOT expired + ast = + {:logical_and, {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}, + {:logical_not, {:identifier, "expired"}}} + + result = StringVisitor.visit(ast, []) + + assert result == "score > 85 AND NOT expired" + end + end + + describe "visit/2 - integration with parser" do + test "round-trip with logical AND expression" do + alias Predicator.{Lexer, Parser} + + expression = "score > 85 AND age >= 18" + {:ok, tokens} = Lexer.tokenize(expression) + {:ok, ast} = Parser.parse(tokens) + result = StringVisitor.visit(ast, []) + + assert result == expression + end + + test "round-trip with logical OR expression" do + alias Predicator.{Lexer, Parser} + + expression = ~s(role = "admin" OR role = "manager") + {:ok, tokens} = Lexer.tokenize(expression) + {:ok, ast} = Parser.parse(tokens) + result = StringVisitor.visit(ast, []) + + assert result == expression + end + + test "round-trip with logical NOT expression" do + alias Predicator.{Lexer, Parser} + + expression = "NOT expired = true" + {:ok, tokens} = Lexer.tokenize(expression) + {:ok, ast} = Parser.parse(tokens) + result = StringVisitor.visit(ast, []) + + assert result == expression + end + + test "round-trip with complex logical expression" do + alias Predicator.{Lexer, Parser} + + expression = "score > 85 AND age >= 18 OR admin = true" + {:ok, tokens} = Lexer.tokenize(expression) + {:ok, ast} = Parser.parse(tokens) + result = StringVisitor.visit(ast, []) + + assert result == expression + end + end end diff --git a/test/predicator_test.exs b/test/predicator_test.exs index 41d8e76..3061523 100644 --- a/test/predicator_test.exs +++ b/test/predicator_test.exs @@ -354,4 +354,404 @@ defmodule PredicatorTest do assert evaluator.context == context end end + + describe "logical operators - integration tests" do + test "evaluates logical AND with true results" do + result = Predicator.evaluate("score > 85 AND age >= 18", %{"score" => 90, "age" => 25}) + assert result == true + end + + test "evaluates logical AND with false results" do + result = Predicator.evaluate("score > 85 AND age >= 18", %{"score" => 80, "age" => 25}) + assert result == false + + result = Predicator.evaluate("score > 85 AND age >= 18", %{"score" => 90, "age" => 16}) + assert result == false + + result = Predicator.evaluate("score > 85 AND age >= 18", %{"score" => 80, "age" => 16}) + assert result == false + end + + test "evaluates logical OR with true results" do + result = Predicator.evaluate(~s(role = "admin" OR role = "manager"), %{"role" => "admin"}) + assert result == true + + result = + Predicator.evaluate(~s(role = "admin" OR role = "manager"), %{"role" => "manager"}) + + assert result == true + end + + test "evaluates logical OR with false results" do + result = Predicator.evaluate(~s(role = "admin" OR role = "manager"), %{"role" => "user"}) + assert result == false + end + + test "evaluates logical NOT with boolean variables" do + result = Predicator.evaluate("NOT expired = true", %{"expired" => false}) + assert result == true + + result = Predicator.evaluate("NOT expired = true", %{"expired" => true}) + assert result == false + end + + test "evaluates complex logical expressions" do + # (score > 85 AND age >= 18) OR admin = true + context1 = %{"score" => 90, "age" => 20, "admin" => false} + result1 = Predicator.evaluate("score > 85 AND age >= 18 OR admin = true", context1) + assert result1 == true + + context2 = %{"score" => 80, "age" => 16, "admin" => false} + result2 = Predicator.evaluate("score > 85 AND age >= 18 OR admin = true", context2) + assert result2 == false + + context3 = %{"score" => 80, "age" => 16, "admin" => true} + result3 = Predicator.evaluate("score > 85 AND age >= 18 OR admin = true", context3) + assert result3 == true + end + + test "evaluates nested NOT expressions" do + result = Predicator.evaluate("NOT NOT active = true", %{"active" => true}) + assert result == true + + result = Predicator.evaluate("NOT NOT active = true", %{"active" => false}) + assert result == false + end + + test "evaluates operator precedence correctly" do + # NOT false OR false AND true should be: (NOT false) OR (false AND true) = true OR false = true + result = + Predicator.evaluate( + "NOT expired = false OR role = \"user\" AND score > 85", + %{"expired" => true, "role" => "user", "score" => 90} + ) + + assert result == true + + # Same expression with different values - should be: false OR true = true + result = + Predicator.evaluate( + "NOT expired = false OR role = \"user\" AND score > 85", + %{"expired" => false, "role" => "user", "score" => 90} + ) + + assert result == true + + # Same expression with different values - should be: false OR false = false + result = + Predicator.evaluate( + "NOT expired = false OR role = \"user\" AND score > 85", + %{"expired" => false, "role" => "user", "score" => 80} + ) + + assert result == false + end + + test "evaluates parenthesized logical expressions" do + # (active = true OR role = \"admin\") AND score > 85 + context1 = %{"active" => true, "role" => "user", "score" => 90} + + result1 = + Predicator.evaluate("(active = true OR role = \"admin\") AND score > 85", context1) + + assert result1 == true + + context2 = %{"active" => false, "role" => "admin", "score" => 90} + + result2 = + Predicator.evaluate("(active = true OR role = \"admin\") AND score > 85", context2) + + assert result2 == true + + context3 = %{"active" => false, "role" => "user", "score" => 90} + + result3 = + Predicator.evaluate("(active = true OR role = \"admin\") AND score > 85", context3) + + assert result3 == false + + context4 = %{"active" => true, "role" => "admin", "score" => 80} + + result4 = + Predicator.evaluate("(active = true OR role = \"admin\") AND score > 85", context4) + + assert result4 == false + end + + test "compiles and decompiles logical expressions correctly" do + original_expressions = [ + "score > 85 AND age >= 18", + "role = \"admin\" OR role = \"manager\"", + "NOT expired = true", + "score > 85 AND age >= 18 OR admin = true", + "NOT false OR true AND false" + ] + + for expression <- original_expressions do + {:ok, ast} = Predicator.parse(expression) + decompiled = Predicator.decompile(ast) + assert decompiled == expression + + # Also test compilation to instructions + {:ok, instructions} = Predicator.compile(expression) + assert is_list(instructions) + assert length(instructions) > 0 + end + end + + test "parse function returns correct AST for logical operators" do + {:ok, ast} = Predicator.parse("score > 85 AND age >= 18") + assert match?({:logical_and, _, _}, ast) + + {:ok, ast} = Predicator.parse(~s(role = "admin" OR role = "manager")) + assert match?({:logical_or, _, _}, ast) + + {:ok, ast} = Predicator.parse("NOT expired = true") + assert match?({:logical_not, _}, ast) + end + + test "compile function generates correct instructions for logical operators" do + {:ok, instructions} = Predicator.compile("true AND false") + assert instructions == [["lit", true], ["lit", false], ["and"]] + + {:ok, instructions} = Predicator.compile("true OR false") + assert instructions == [["lit", true], ["lit", false], ["or"]] + + {:ok, instructions} = Predicator.compile("NOT true") + assert instructions == [["lit", true], ["not"]] + end + + test "evaluate! function works with logical operators" do + result = Predicator.evaluate!("score > 85 AND age >= 18", %{"score" => 90, "age" => 25}) + assert result == true + + result = Predicator.evaluate!("NOT expired = true", %{"expired" => false}) + assert result == true + end + + test "handles error cases in logical expressions" do + # Syntax errors + result = Predicator.evaluate("score AND", %{"score" => 90}) + assert {:error, _message} = result + + result = Predicator.evaluate("OR score > 85", %{"score" => 90}) + assert {:error, _message} = result + + result = Predicator.evaluate("NOT", %{}) + assert {:error, _message} = result + end + + test "works with atom keys in context" do + result = Predicator.evaluate("score > 85 AND age >= 18", %{score: 90, age: 25}) + assert result == true + + result = Predicator.evaluate("NOT expired = true", %{expired: false}) + assert result == true + end + + test "works with mixed string and atom keys in context" do + result = Predicator.evaluate("score > 85 AND age >= 18", %{"score" => 90, age: 25}) + assert result == true + + result = + Predicator.evaluate("role = \"admin\" OR active = true", %{ + "active" => false, + role: "admin" + }) + + assert result == true + end + end + + describe "plain boolean expressions" do + test "evaluates boolean literals without operators" do + assert Predicator.evaluate("true", %{}) == true + assert Predicator.evaluate("false", %{}) == false + end + + test "evaluates boolean identifiers from context" do + assert Predicator.evaluate("active", %{"active" => true}) == true + assert Predicator.evaluate("active", %{"active" => false}) == false + assert Predicator.evaluate("expired", %{"expired" => true}) == true + assert Predicator.evaluate("expired", %{"expired" => false}) == false + end + + test "evaluates boolean identifiers with atom keys" do + assert Predicator.evaluate("active", %{active: true}) == true + assert Predicator.evaluate("expired", %{expired: false}) == false + end + + test "returns :undefined for missing boolean variables" do + assert Predicator.evaluate("missing", %{}) == :undefined + end + + test "works with logical operators on plain boolean expressions" do + context = %{"active" => true, "expired" => false, "verified" => true} + + assert Predicator.evaluate("active AND verified", context) == true + assert Predicator.evaluate("active AND expired", context) == false + assert Predicator.evaluate("active OR expired", context) == true + assert Predicator.evaluate("expired OR verified", context) == true + assert Predicator.evaluate("NOT expired", context) == true + assert Predicator.evaluate("NOT active", context) == false + end + + test "combines plain boolean expressions with comparisons" do + context = %{"active" => true, "score" => 90, "admin" => false} + + assert Predicator.evaluate("active AND score > 85", context) == true + assert Predicator.evaluate("active AND score < 85", context) == false + assert Predicator.evaluate("admin OR score > 85", context) == true + assert Predicator.evaluate("NOT admin AND score > 85", context) == true + end + + test "compiles plain boolean expressions correctly" do + {:ok, instructions} = Predicator.compile("true") + assert instructions == [["lit", true]] + + {:ok, instructions} = Predicator.compile("active") + assert instructions == [["load", "active"]] + + {:ok, instructions} = Predicator.compile("active AND expired") + assert instructions == [["load", "active"], ["load", "expired"], ["and"]] + end + + test "parses and decompiles plain boolean expressions" do + {:ok, ast} = Predicator.parse("true") + assert ast == {:literal, true} + assert Predicator.decompile(ast) == "true" + + {:ok, ast} = Predicator.parse("active") + assert ast == {:identifier, "active"} + assert Predicator.decompile(ast) == "active" + + {:ok, ast} = Predicator.parse("active AND expired") + assert match?({:logical_and, {:identifier, "active"}, {:identifier, "expired"}}, ast) + assert Predicator.decompile(ast) == "active AND expired" + end + + test "evaluate! works with plain boolean expressions" do + assert Predicator.evaluate!("true", %{}) == true + assert Predicator.evaluate!("active", %{"active" => true}) == true + + assert Predicator.evaluate!("active AND expired", %{"active" => true, "expired" => false}) == + false + end + + test "handles complex expressions with plain booleans and literals" do + context = %{"active" => true, "admin" => false, "score" => 95} + + # Mix of plain booleans, comparisons, and literals + result = Predicator.evaluate("active AND score > 90 OR admin", context) + assert result == true + + result = Predicator.evaluate("NOT admin AND (active OR score < 80)", context) + assert result == true + + result = Predicator.evaluate("false OR active AND true", context) + assert result == true + end + end + + describe "lowercase logical operators" do + test "evaluates lowercase 'and' operator" do + assert Predicator.evaluate("true and false", %{}) == false + assert Predicator.evaluate("true and true", %{}) == true + assert Predicator.evaluate("false and false", %{}) == false + end + + test "evaluates lowercase 'or' operator" do + assert Predicator.evaluate("true or false", %{}) == true + assert Predicator.evaluate("false or false", %{}) == false + assert Predicator.evaluate("false or true", %{}) == true + end + + test "evaluates lowercase 'not' operator" do + assert Predicator.evaluate("not true", %{}) == false + assert Predicator.evaluate("not false", %{}) == true + end + + test "works with boolean variables from context" do + context = %{"active" => true, "expired" => false, "verified" => true} + + assert Predicator.evaluate("active and verified", context) == true + assert Predicator.evaluate("active and expired", context) == false + assert Predicator.evaluate("active or expired", context) == true + assert Predicator.evaluate("expired or verified", context) == true + assert Predicator.evaluate("not expired", context) == true + assert Predicator.evaluate("not active", context) == false + end + + test "combines with comparisons" do + context = %{"score" => 85, "age" => 20, "admin" => false} + + assert Predicator.evaluate("score >= 80 and age >= 18", context) == true + assert Predicator.evaluate("score >= 90 and age >= 18", context) == false + assert Predicator.evaluate("score >= 90 or admin", context) == false + assert Predicator.evaluate("not admin and score >= 80", context) == true + end + + test "respects operator precedence with lowercase operators" do + # not false or false and true should be: (not false) or (false and true) = true or false = true + context = %{"expired" => true, "role" => "user", "score" => 90} + + result = + Predicator.evaluate("not expired = false or role = \"user\" and score > 85", context) + + assert result == true + end + + test "works with mixed case operators" do + context = %{"active" => true, "admin" => false, "score" => 90} + + # Mix uppercase and lowercase + assert Predicator.evaluate("active AND not admin", context) == true + assert Predicator.evaluate("active and NOT admin", context) == true + assert Predicator.evaluate("active or admin", context) == true + end + + test "compiles lowercase operators correctly" do + {:ok, instructions} = Predicator.compile("true and false") + assert instructions == [["lit", true], ["lit", false], ["and"]] + + {:ok, instructions} = Predicator.compile("true or false") + assert instructions == [["lit", true], ["lit", false], ["or"]] + + {:ok, instructions} = Predicator.compile("not true") + assert instructions == [["lit", true], ["not"]] + end + + test "parses lowercase operators correctly" do + {:ok, ast} = Predicator.parse("true and false") + assert match?({:logical_and, {:literal, true}, {:literal, false}}, ast) + + {:ok, ast} = Predicator.parse("true or false") + assert match?({:logical_or, {:literal, true}, {:literal, false}}, ast) + + {:ok, ast} = Predicator.parse("not true") + assert match?({:logical_not, {:literal, true}}, ast) + end + + test "decompiles to preserve original case" do + # Note: Decompilation uses StringVisitor which formats based on AST + # The original case is preserved in the token value + {:ok, ast} = Predicator.parse("active and expired") + decompiled = Predicator.decompile(ast) + # StringVisitor uses uppercase in output + assert decompiled == "active AND expired" + end + + test "works with complex expressions" do + context = %{"user" => "admin", "active" => true, "score" => 95, "verified" => false} + + result = Predicator.evaluate("user = \"admin\" and active and score > 90", context) + assert result == true + + result = Predicator.evaluate("not verified or (active and score > 85)", context) + assert result == true + + result = Predicator.evaluate("verified and active or user = \"admin\"", context) + assert result == true + end + end end