From bb9f18f3fe2e652422b3ad1d24ec3eeef434cc10 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Tue, 19 Aug 2025 07:02:41 -0600 Subject: [PATCH 1/5] Adds basic comparisons to evaluator --- lib/predicator.ex | 10 + lib/predicator/evaluator.ex | 37 ++ lib/predicator/types.ex | 2 + test/predicator/evaluator_comparison_test.exs | 326 ++++++++++++++++++ 4 files changed, 375 insertions(+) create mode 100644 test/predicator/evaluator_comparison_test.exs diff --git a/lib/predicator.ex b/lib/predicator.ex index 2263fad..c972b9e 100644 --- a/lib/predicator.ex +++ b/lib/predicator.ex @@ -28,6 +28,7 @@ defmodule Predicator do Currently supported instructions: - `["lit", value]` - Push a literal value onto the stack - `["load", variable_name]` - Load a variable from context onto the stack + - `["compare", operator]` - Compare top two stack values (GT, LT, EQ, GTE, LTE, NE) ## Context @@ -85,6 +86,15 @@ defmodule Predicator do iex> instructions = [["lit", 1], ["lit", 2], ["lit", 3]] iex> Predicator.execute(instructions) 3 + + # Comparison operations + iex> instructions = [["load", "score"], ["lit", 85], ["compare", "GT"]] + iex> Predicator.execute(instructions, %{"score" => 90}) + true + + iex> instructions = [["load", "age"], ["lit", 18], ["compare", "GTE"]] + iex> Predicator.execute(instructions, %{"age" => 16}) + false """ @spec execute(Types.instruction_list(), Types.context()) :: Types.result() def execute(instructions, context \\ %{}) when is_list(instructions) and is_map(context) do diff --git a/lib/predicator/evaluator.ex b/lib/predicator/evaluator.ex index 4f940d0..4d52177 100644 --- a/lib/predicator/evaluator.ex +++ b/lib/predicator/evaluator.ex @@ -128,6 +128,9 @@ defmodule Predicator.Evaluator do value = load_from_context(evaluator.context, variable_name) {:ok, push_stack(evaluator, value)} + ["compare", operator] when operator in ["GT", "LT", "EQ", "GTE", "LTE", "NE"] -> + execute_compare(evaluator, operator) + unknown -> {:error, "Unknown instruction: #{inspect(unknown)}"} end @@ -141,6 +144,40 @@ defmodule Predicator.Evaluator do defp advance_instruction_pointer({:error, reason}), do: {:error, reason} + @spec execute_compare(t(), binary()) :: {:ok, t()} | {:error, term()} + defp execute_compare(%__MODULE__{stack: [right | [left | rest]]} = evaluator, operator) do + result = compare_values(left, right, operator) + {:ok, %__MODULE__{evaluator | stack: [result | rest]}} + end + + defp execute_compare(%__MODULE__{stack: stack}, _operator) do + {:error, "Comparison requires two values on stack, got: #{length(stack)}"} + end + + # Custom guard for type matching + defguard types_match(a, b) + when (is_integer(a) and is_integer(b)) or + (is_boolean(a) and is_boolean(b)) or + (is_binary(a) and is_binary(b)) or + (is_list(a) and is_list(b)) + + @spec compare_values(Types.value(), Types.value(), binary()) :: Types.value() + defp compare_values(:undefined, _right, _operator), do: :undefined + defp compare_values(_left, :undefined, _operator), do: :undefined + + defp compare_values(left, right, operator) when types_match(left, right) do + case operator do + "GT" -> left > right + "LT" -> left < right + "EQ" -> left == right + "GTE" -> left >= right + "LTE" -> left <= right + "NE" -> left != right + end + end + + defp compare_values(_left, _right, _operator), do: :undefined + @spec push_stack(t(), Types.value()) :: t() defp push_stack(%__MODULE__{stack: stack} = evaluator, value) do %__MODULE__{evaluator | stack: [value | stack]} diff --git a/lib/predicator/types.ex b/lib/predicator/types.ex index 4c5acb4..1f5f76c 100644 --- a/lib/predicator/types.ex +++ b/lib/predicator/types.ex @@ -40,11 +40,13 @@ defmodule Predicator.Types do Currently supported instructions: - `["lit", value()]` - Push literal value onto stack - `["load", binary()]` - Load variable from context onto stack + - `["compare", binary()]` - Compare top two stack values with operator ## Examples ["lit", 42] # Push literal 42 onto stack ["load", "score"] # Load variable 'score' from context + ["compare", "GT"] # Pop two values, compare with >, push result """ @type instruction :: [binary() | value()] diff --git a/test/predicator/evaluator_comparison_test.exs b/test/predicator/evaluator_comparison_test.exs new file mode 100644 index 0000000..dfee497 --- /dev/null +++ b/test/predicator/evaluator_comparison_test.exs @@ -0,0 +1,326 @@ +defmodule Predicator.EvaluatorComparisonTest do + use ExUnit.Case, async: true + + alias Predicator.Evaluator + + describe "compare instruction - GT (greater than)" do + test "compares integers correctly" do + instructions = [ + ["lit", 10], + ["lit", 5], + ["compare", "GT"] + ] + + assert Evaluator.evaluate(instructions) == true + end + + test "false when left is not greater" do + instructions = [ + ["lit", 5], + ["lit", 10], + ["compare", "GT"] + ] + + assert Evaluator.evaluate(instructions) == false + end + + test "false when values are equal" do + instructions = [ + ["lit", 5], + ["lit", 5], + ["compare", "GT"] + ] + + assert Evaluator.evaluate(instructions) == false + end + + test "compares strings correctly" do + instructions = [ + ["lit", "zebra"], + ["lit", "apple"], + ["compare", "GT"] + ] + + assert Evaluator.evaluate(instructions) == true + end + + test "returns :undefined for mismatched types" do + instructions = [ + ["lit", 10], + ["lit", "hello"], + ["compare", "GT"] + ] + + assert Evaluator.evaluate(instructions) == :undefined + end + + test "returns :undefined when left is :undefined" do + instructions = [ + ["lit", :undefined], + ["lit", 5], + ["compare", "GT"] + ] + + assert Evaluator.evaluate(instructions) == :undefined + end + + test "returns :undefined when right is :undefined" do + instructions = [ + ["lit", 5], + ["lit", :undefined], + ["compare", "GT"] + ] + + assert Evaluator.evaluate(instructions) == :undefined + end + end + + describe "compare instruction - LT (less than)" do + test "compares integers correctly" do + instructions = [ + ["lit", 5], + ["lit", 10], + ["compare", "LT"] + ] + + assert Evaluator.evaluate(instructions) == true + end + + test "false when left is not less" do + instructions = [ + ["lit", 10], + ["lit", 5], + ["compare", "LT"] + ] + + assert Evaluator.evaluate(instructions) == false + end + + test "false when values are equal" do + instructions = [ + ["lit", 5], + ["lit", 5], + ["compare", "LT"] + ] + + assert Evaluator.evaluate(instructions) == false + end + end + + describe "compare instruction - EQ (equal)" do + test "true for equal integers" do + instructions = [ + ["lit", 42], + ["lit", 42], + ["compare", "EQ"] + ] + + assert Evaluator.evaluate(instructions) == true + end + + test "true for equal strings" do + instructions = [ + ["lit", "hello"], + ["lit", "hello"], + ["compare", "EQ"] + ] + + assert Evaluator.evaluate(instructions) == true + end + + test "true for equal booleans" do + instructions = [ + ["lit", true], + ["lit", true], + ["compare", "EQ"] + ] + + assert Evaluator.evaluate(instructions) == true + end + + test "false for different values" do + instructions = [ + ["lit", 42], + ["lit", 43], + ["compare", "EQ"] + ] + + assert Evaluator.evaluate(instructions) == false + end + + test "returns :undefined for mismatched types" do + instructions = [ + ["lit", 42], + ["lit", "42"], + ["compare", "EQ"] + ] + + assert Evaluator.evaluate(instructions) == :undefined + end + end + + describe "compare instruction - GTE (greater than or equal)" do + test "true when greater" do + instructions = [ + ["lit", 10], + ["lit", 5], + ["compare", "GTE"] + ] + + assert Evaluator.evaluate(instructions) == true + end + + test "true when equal" do + instructions = [ + ["lit", 5], + ["lit", 5], + ["compare", "GTE"] + ] + + assert Evaluator.evaluate(instructions) == true + end + + test "false when less" do + instructions = [ + ["lit", 5], + ["lit", 10], + ["compare", "GTE"] + ] + + assert Evaluator.evaluate(instructions) == false + end + end + + describe "compare instruction - LTE (less than or equal)" do + test "true when less" do + instructions = [ + ["lit", 5], + ["lit", 10], + ["compare", "LTE"] + ] + + assert Evaluator.evaluate(instructions) == true + end + + test "true when equal" do + instructions = [ + ["lit", 5], + ["lit", 5], + ["compare", "LTE"] + ] + + assert Evaluator.evaluate(instructions) == true + end + + test "false when greater" do + instructions = [ + ["lit", 10], + ["lit", 5], + ["compare", "LTE"] + ] + + assert Evaluator.evaluate(instructions) == false + end + end + + describe "compare instruction - NE (not equal)" do + test "true for different values" do + instructions = [ + ["lit", 42], + ["lit", 43], + ["compare", "NE"] + ] + + assert Evaluator.evaluate(instructions) == true + end + + test "false for equal values" do + instructions = [ + ["lit", 42], + ["lit", 42], + ["compare", "NE"] + ] + + assert Evaluator.evaluate(instructions) == false + end + + test "returns :undefined for mismatched types" do + instructions = [ + ["lit", 42], + ["lit", "hello"], + ["compare", "NE"] + ] + + assert Evaluator.evaluate(instructions) == :undefined + end + end + + describe "compare instruction with context loading" do + test "compares loaded values" do + instructions = [ + ["load", "score"], + ["lit", 85], + ["compare", "GT"] + ] + + context = %{"score" => 90} + assert Evaluator.evaluate(instructions, context) == true + end + + test "handles missing context values" do + instructions = [ + ["load", "missing"], + ["lit", 85], + ["compare", "GT"] + ] + + assert Evaluator.evaluate(instructions, %{}) == :undefined + end + + test "real-world example: age check" do + instructions = [ + ["load", "age"], + ["lit", 18], + ["compare", "GTE"] + ] + + adult_context = %{"age" => 25} + minor_context = %{"age" => 16} + + assert Evaluator.evaluate(instructions, adult_context) == true + assert Evaluator.evaluate(instructions, minor_context) == false + end + end + + describe "compare instruction error cases" do + test "returns error with insufficient stack values" do + instructions = [ + ["lit", 42], + ["compare", "GT"] + ] + + result = Evaluator.evaluate(instructions) + assert {:error, "Comparison requires two values on stack, got: 1"} = result + end + + test "returns error with empty stack" do + instructions = [ + ["compare", "GT"] + ] + + result = Evaluator.evaluate(instructions) + assert {:error, "Comparison requires two values on stack, got: 0"} = result + end + + test "returns error for invalid operator" do + instructions = [ + ["lit", 5], + ["lit", 10], + ["compare", "INVALID"] + ] + + result = Evaluator.evaluate(instructions) + assert {:error, "Unknown instruction: " <> _error_msg} = result + end + end +end From 34cba7b0f6efbc7a8610fd7fe72389d4d44ceab4 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Tue, 19 Aug 2025 07:47:04 -0600 Subject: [PATCH 2/5] Adds basic Lexer --- lib/predicator/lexer.ex | 270 ++++++++++++++++++++++++++ test/predicator/lexer_test.exs | 336 +++++++++++++++++++++++++++++++++ 2 files changed, 606 insertions(+) create mode 100644 lib/predicator/lexer.ex create mode 100644 test/predicator/lexer_test.exs diff --git a/lib/predicator/lexer.ex b/lib/predicator/lexer.ex new file mode 100644 index 0000000..1c58df5 --- /dev/null +++ b/lib/predicator/lexer.ex @@ -0,0 +1,270 @@ +defmodule Predicator.Lexer do + @moduledoc """ + Lexical analyzer for predicator expressions. + + The lexer converts input strings into a stream of tokens with complete + position tracking for detailed error reporting. Each token includes: + - Token type and value + - Line and column position + - Length for precise error highlighting + + ## Example + + iex> Predicator.Lexer.tokenize("score > 85") + {:ok, [ + {:identifier, 1, 1, 5, "score"}, + {:gt, 1, 7, 1, ">"}, + {:integer, 1, 9, 2, 85}, + {:eof, 1, 11, 0, nil} + ]} + """ + + @typedoc """ + Position information for a token. + + Contains: + - `line` - 1-based line number + - `column` - 1-based column number + - `length` - number of characters in the token + """ + @type position :: {line :: pos_integer(), column :: pos_integer(), length :: pos_integer()} + + @typedoc """ + A lexical token with position information. + + Format: `{type, line, column, length, value}` + """ + @type token :: + {:identifier, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:integer, pos_integer(), pos_integer(), pos_integer(), integer()} + | {:string, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:boolean, pos_integer(), pos_integer(), pos_integer(), boolean()} + | {:gt, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:lt, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:gte, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:lte, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:eq, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:ne, 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} + + @typedoc """ + Lexer result - either success with tokens or error with details. + """ + @type result :: {:ok, [token()]} | {:error, binary(), pos_integer(), pos_integer()} + + @typedoc """ + Internal lexer state for position tracking. + """ + @type lexer_state :: %{ + input: binary(), + position: non_neg_integer(), + line: pos_integer(), + column: pos_integer(), + tokens: [token()] + } + + @doc """ + Tokenizes an input string into a list of tokens. + + ## Parameters + + - `input` - The expression string to tokenize + + ## Returns + + - `{:ok, tokens}` - Successfully tokenized input + - `{:error, message, line, column}` - Lexical error with position + + ## Examples + + iex> Predicator.Lexer.tokenize("score > 85") + {:ok, [ + {:identifier, 1, 1, 5, "score"}, + {:gt, 1, 7, 1, ">"}, + {:integer, 1, 9, 2, 85}, + {:eof, 1, 11, 0, nil} + ]} + + iex> Predicator.Lexer.tokenize("age >= 18") + {:ok, [ + {:identifier, 1, 1, 3, "age"}, + {:gte, 1, 5, 2, ">="}, + {:integer, 1, 8, 2, 18}, + {:eof, 1, 10, 0, nil} + ]} + + iex> Predicator.Lexer.tokenize("name = \\"John\\"") + {:ok, [ + {:identifier, 1, 1, 4, "name"}, + {:eq, 1, 6, 1, "="}, + {:string, 1, 8, 6, "John"}, + {:eof, 1, 14, 0, nil} + ]} + """ + @spec tokenize(binary()) :: result() + def tokenize(input) when is_binary(input) do + input + |> String.to_charlist() + |> tokenize_chars(1, 1, []) + end + + # Main tokenization function + @spec tokenize_chars(charlist(), pos_integer(), pos_integer(), [token()]) :: result() + defp tokenize_chars([], line, col, tokens) do + {:ok, Enum.reverse([{:eof, line, col, 0, nil} | tokens])} + end + + defp tokenize_chars([char | rest], line, col, tokens) do + case char do + # Skip whitespace + ?\s -> + tokenize_chars(rest, line, col + 1, tokens) + + ?\t -> + tokenize_chars(rest, line, col + 1, tokens) + + ?\n -> + tokenize_chars(rest, line + 1, 1, tokens) + + ?\r -> + tokenize_chars(rest, line, col, tokens) + + # Numbers + c when c >= ?0 and c <= ?9 -> + {number, remaining, consumed} = take_number([char | rest]) + token = {:integer, line, col, consumed, number} + tokenize_chars(remaining, line, col + consumed, [token | tokens]) + + # Identifiers + 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]) + + # Operators + ?> -> + case rest do + [?= | rest2] -> + token = {:gte, line, col, 2, ">="} + tokenize_chars(rest2, line, col + 2, [token | tokens]) + + _ -> + token = {:gt, line, col, 1, ">"} + tokenize_chars(rest, line, col + 1, [token | tokens]) + end + + ?< -> + case rest do + [?= | rest2] -> + token = {:lte, line, col, 2, "<="} + tokenize_chars(rest2, line, col + 2, [token | tokens]) + + _ -> + token = {:lt, line, col, 1, "<"} + tokenize_chars(rest, line, col + 1, [token | tokens]) + end + + ?! -> + case rest do + [?= | rest2] -> + token = {:ne, line, col, 2, "!="} + tokenize_chars(rest2, line, col + 2, [token | tokens]) + + _ -> + {:error, "Unexpected character '!'", line, col} + end + + ?= -> + token = {:eq, line, col, 1, "="} + tokenize_chars(rest, line, col + 1, [token | tokens]) + + ?( -> + token = {:lparen, line, col, 1, "("} + tokenize_chars(rest, line, col + 1, [token | tokens]) + + ?) -> + token = {:rparen, line, col, 1, ")"} + tokenize_chars(rest, line, col + 1, [token | tokens]) + + # String literals + ?" -> + case take_string(rest, "", 1) do + {:ok, content, remaining, consumed} -> + # +1 for opening quote + token = {:string, line, col, consumed + 1, content} + tokenize_chars(remaining, line, col + consumed + 1, [token | tokens]) + + {:error, message} -> + {:error, message, line, col} + end + + # Unknown character + _ -> + {:error, "Unexpected character '#{[char]}'", line, col} + end + end + + # Helper functions + @spec take_number(charlist()) :: {integer(), charlist(), pos_integer()} + defp take_number(chars), do: take_number(chars, [], 0) + + @spec take_number(charlist(), charlist(), non_neg_integer()) :: + {integer(), charlist(), pos_integer()} + defp take_number([c | rest], acc, count) when c >= ?0 and c <= ?9 do + take_number(rest, [c | acc], count + 1) + end + + defp take_number(remaining, acc, count) do + number_string = acc |> Enum.reverse() |> List.to_string() + {String.to_integer(number_string), remaining, count} + end + + @spec take_identifier(charlist()) :: {binary(), charlist(), pos_integer()} + defp take_identifier(chars), do: take_identifier(chars, [], 0) + + @spec take_identifier(charlist(), charlist(), non_neg_integer()) :: + {binary(), charlist(), pos_integer()} + defp take_identifier([c | rest], acc, count) + when (c >= ?a and c <= ?z) or (c >= ?A and c <= ?Z) or (c >= ?0 and c <= ?9) or c == ?_ do + take_identifier(rest, [c | acc], count + 1) + end + + defp take_identifier(remaining, acc, count) do + identifier = acc |> Enum.reverse() |> List.to_string() + {identifier, remaining, count} + end + + @spec classify_identifier(binary()) :: {atom(), binary() | boolean()} + defp classify_identifier("true"), do: {:boolean, true} + defp classify_identifier("false"), do: {:boolean, false} + defp classify_identifier(id), do: {:identifier, id} + + @spec take_string(charlist(), binary(), pos_integer()) :: + {:ok, binary(), charlist(), pos_integer()} | {:error, binary()} + defp take_string([], _acc, _count), do: {:error, "Unterminated string literal"} + + defp take_string([?" | rest], acc, count) do + {:ok, acc, rest, count} + end + + defp take_string([?\\ | [escaped | rest]], acc, count) do + char = + case escaped do + ?" -> "\"" + ?\\ -> "\\" + ?n -> "\n" + ?t -> "\t" + ?r -> "\r" + c -> <> + end + + take_string(rest, acc <> char, count + 2) + end + + defp take_string([c | rest], acc, count) do + take_string(rest, acc <> <>, count + 1) + end +end diff --git a/test/predicator/lexer_test.exs b/test/predicator/lexer_test.exs new file mode 100644 index 0000000..ae7ea0a --- /dev/null +++ b/test/predicator/lexer_test.exs @@ -0,0 +1,336 @@ +defmodule Predicator.LexerTest do + use ExUnit.Case, async: true + + alias Predicator.Lexer + + doctest Predicator.Lexer + + describe "tokenize/1 - integers" do + test "tokenizes single integer" do + assert {:ok, tokens} = Lexer.tokenize("42") + + assert tokens == [ + {:integer, 1, 1, 2, 42}, + {:eof, 1, 3, 0, nil} + ] + end + + test "tokenizes multi-digit integer" do + assert {:ok, tokens} = Lexer.tokenize("1234") + + assert tokens == [ + {:integer, 1, 1, 4, 1234}, + {:eof, 1, 5, 0, nil} + ] + end + + test "tokenizes zero" do + assert {:ok, tokens} = Lexer.tokenize("0") + + assert tokens == [ + {:integer, 1, 1, 1, 0}, + {:eof, 1, 2, 0, nil} + ] + end + end + + describe "tokenize/1 - identifiers and keywords" do + test "tokenizes simple identifier" do + assert {:ok, tokens} = Lexer.tokenize("score") + + assert tokens == [ + {:identifier, 1, 1, 5, "score"}, + {:eof, 1, 6, 0, nil} + ] + end + + test "tokenizes identifier with underscores" do + assert {:ok, tokens} = Lexer.tokenize("user_age") + + assert tokens == [ + {:identifier, 1, 1, 8, "user_age"}, + {:eof, 1, 9, 0, nil} + ] + end + + test "tokenizes identifier with numbers" do + assert {:ok, tokens} = Lexer.tokenize("var123") + + assert tokens == [ + {:identifier, 1, 1, 6, "var123"}, + {:eof, 1, 7, 0, nil} + ] + end + + test "tokenizes boolean keywords" do + assert {:ok, tokens} = Lexer.tokenize("true") + + assert tokens == [ + {:boolean, 1, 1, 4, true}, + {:eof, 1, 5, 0, nil} + ] + + assert {:ok, tokens} = Lexer.tokenize("false") + + assert tokens == [ + {:boolean, 1, 1, 5, false}, + {:eof, 1, 6, 0, nil} + ] + end + end + + describe "tokenize/1 - string literals" do + test "tokenizes simple string" do + assert {:ok, tokens} = Lexer.tokenize(~s("hello")) + + assert tokens == [ + {:string, 1, 1, 7, "hello"}, + {:eof, 1, 8, 0, nil} + ] + end + + test "tokenizes empty string" do + assert {:ok, tokens} = Lexer.tokenize(~s("")) + + assert tokens == [ + {:string, 1, 1, 2, ""}, + {:eof, 1, 3, 0, nil} + ] + end + + test "tokenizes string with spaces" do + assert {:ok, tokens} = Lexer.tokenize(~s("hello world")) + + assert tokens == [ + {:string, 1, 1, 13, "hello world"}, + {:eof, 1, 14, 0, nil} + ] + end + + test "tokenizes string with escape sequences" do + # Input: "hello\"world" (with escaped quote) + input = "\"hello\\\"world\"" + assert {:ok, tokens} = Lexer.tokenize(input) + + assert tokens == [ + {:string, 1, 1, 14, "hello\"world"}, + {:eof, 1, 15, 0, nil} + ] + end + + test "tokenizes string with newline escape" do + # Input: "line1\nline2" (with escaped newline) + input = "\"line1\\nline2\"" + assert {:ok, tokens} = Lexer.tokenize(input) + + assert tokens == [ + {:string, 1, 1, 14, "line1\nline2"}, + {:eof, 1, 15, 0, nil} + ] + end + + test "returns error for unterminated string" do + assert {:error, "Unterminated string literal", 1, 1} = Lexer.tokenize(~s("hello)) + end + end + + describe "tokenize/1 - comparison operators" do + test "tokenizes greater than" do + assert {:ok, tokens} = Lexer.tokenize(">") + + assert tokens == [ + {:gt, 1, 1, 1, ">"}, + {:eof, 1, 2, 0, nil} + ] + end + + test "tokenizes greater than or equal" do + assert {:ok, tokens} = Lexer.tokenize(">=") + + assert tokens == [ + {:gte, 1, 1, 2, ">="}, + {:eof, 1, 3, 0, nil} + ] + end + + test "tokenizes less than" do + assert {:ok, tokens} = Lexer.tokenize("<") + + assert tokens == [ + {:lt, 1, 1, 1, "<"}, + {:eof, 1, 2, 0, nil} + ] + end + + test "tokenizes less than or equal" do + assert {:ok, tokens} = Lexer.tokenize("<=") + + assert tokens == [ + {:lte, 1, 1, 2, "<="}, + {:eof, 1, 3, 0, nil} + ] + end + + test "tokenizes equal" do + assert {:ok, tokens} = Lexer.tokenize("=") + + assert tokens == [ + {:eq, 1, 1, 1, "="}, + {:eof, 1, 2, 0, nil} + ] + end + + test "tokenizes not equal" do + assert {:ok, tokens} = Lexer.tokenize("!=") + + assert tokens == [ + {:ne, 1, 1, 2, "!="}, + {:eof, 1, 3, 0, nil} + ] + end + end + + describe "tokenize/1 - parentheses" do + test "tokenizes parentheses" do + assert {:ok, tokens} = Lexer.tokenize("()") + + assert tokens == [ + {:lparen, 1, 1, 1, "("}, + {:rparen, 1, 2, 1, ")"}, + {:eof, 1, 3, 0, nil} + ] + end + end + + describe "tokenize/1 - complex expressions" do + test "tokenizes simple comparison" do + assert {:ok, tokens} = Lexer.tokenize("score > 85") + + assert tokens == [ + {:identifier, 1, 1, 5, "score"}, + {:gt, 1, 7, 1, ">"}, + {:integer, 1, 9, 2, 85}, + {:eof, 1, 11, 0, nil} + ] + end + + test "tokenizes comparison with string" do + assert {:ok, tokens} = Lexer.tokenize(~s(name = "John")) + + assert tokens == [ + {:identifier, 1, 1, 4, "name"}, + {:eq, 1, 6, 1, "="}, + {:string, 1, 8, 6, "John"}, + {:eof, 1, 14, 0, nil} + ] + end + + test "tokenizes comparison with boolean" do + assert {:ok, tokens} = Lexer.tokenize("active = true") + + assert tokens == [ + {:identifier, 1, 1, 6, "active"}, + {:eq, 1, 8, 1, "="}, + {:boolean, 1, 10, 4, true}, + {:eof, 1, 14, 0, nil} + ] + end + + test "tokenizes expression with parentheses" do + assert {:ok, tokens} = Lexer.tokenize("(age >= 18)") + + assert tokens == [ + {:lparen, 1, 1, 1, "("}, + {:identifier, 1, 2, 3, "age"}, + {:gte, 1, 6, 2, ">="}, + {:integer, 1, 9, 2, 18}, + {:rparen, 1, 11, 1, ")"}, + {:eof, 1, 12, 0, nil} + ] + end + + test "handles multiple whitespace" do + assert {:ok, tokens} = Lexer.tokenize(" score > 85 ") + + assert tokens == [ + {:identifier, 1, 3, 5, "score"}, + {:gt, 1, 11, 1, ">"}, + {:integer, 1, 16, 2, 85}, + {:eof, 1, 20, 0, nil} + ] + end + end + + describe "tokenize/1 - position tracking" do + test "tracks line numbers correctly" do + input = """ + score > 85 + age >= 18 + """ + + assert {:ok, tokens} = Lexer.tokenize(input) + + assert tokens == [ + {:identifier, 1, 1, 5, "score"}, + {:gt, 1, 7, 1, ">"}, + {:integer, 1, 9, 2, 85}, + {:identifier, 2, 1, 3, "age"}, + {:gte, 2, 5, 2, ">="}, + {:integer, 2, 8, 2, 18}, + {:eof, 3, 1, 0, nil} + ] + end + + test "tracks columns with tabs" do + assert {:ok, tokens} = Lexer.tokenize("score\t>\t85") + + assert tokens == [ + {:identifier, 1, 1, 5, "score"}, + {:gt, 1, 7, 1, ">"}, + {:integer, 1, 9, 2, 85}, + {:eof, 1, 11, 0, nil} + ] + end + end + + describe "tokenize/1 - error cases" do + test "returns error for unexpected character" do + assert {:error, "Unexpected character '@'", 1, 1} = Lexer.tokenize("@") + end + + test "returns error for standalone exclamation" do + assert {:error, "Unexpected character '!'", 1, 1} = Lexer.tokenize("!") + end + + test "returns error with correct position" do + assert {:error, "Unexpected character '#'", 1, 9} = Lexer.tokenize("score > #") + end + + test "returns error on multiline with correct position" do + input = """ + score > 85 + name @ "John" + """ + + assert {:error, "Unexpected character '@'", 2, 6} = Lexer.tokenize(input) + end + end + + describe "tokenize/1 - edge cases" do + test "tokenizes empty string" do + assert {:ok, tokens} = Lexer.tokenize("") + + assert tokens == [ + {:eof, 1, 1, 0, nil} + ] + end + + test "tokenizes only whitespace" do + assert {:ok, tokens} = Lexer.tokenize(" \n\t ") + + assert tokens == [ + {:eof, 2, 4, 0, nil} + ] + end + end +end From 57d565158d4aa259eb626a7ad08c8676c31dd411 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Tue, 19 Aug 2025 08:56:49 -0600 Subject: [PATCH 3/5] Adds Parser and Compiler --- lib/predicator.ex | 139 +++++++++- lib/predicator/compiler.ex | 53 ++++ lib/predicator/evaluator.ex | 25 ++ lib/predicator/instructions_visitor.ex | 72 +++++ lib/predicator/parser.ex | 238 ++++++++++++++++ lib/predicator/visitor.ex | 63 +++++ test/predicator/compiler_test.exs | 97 +++++++ test/predicator/evaluator_test.exs | 40 +++ test/predicator/instructions_visitor_test.exs | 168 ++++++++++++ test/predicator/integration_test.exs | 135 ++++++++++ test/predicator/parser_test.exs | 248 +++++++++++++++++ test/predicator_test.exs | 254 ++++++++++++++++++ 12 files changed, 1531 insertions(+), 1 deletion(-) create mode 100644 lib/predicator/compiler.ex create mode 100644 lib/predicator/instructions_visitor.ex create mode 100644 lib/predicator/parser.ex create mode 100644 lib/predicator/visitor.ex create mode 100644 test/predicator/compiler_test.exs create mode 100644 test/predicator/instructions_visitor_test.exs create mode 100644 test/predicator/integration_test.exs create mode 100644 test/predicator/parser_test.exs diff --git a/lib/predicator.ex b/lib/predicator.ex index c972b9e..962e72c 100644 --- a/lib/predicator.ex +++ b/lib/predicator.ex @@ -46,7 +46,144 @@ defmodule Predicator do 3. The final result is the top value on the stack when execution completes """ - alias Predicator.{Evaluator, Types} + alias Predicator.{Evaluator, Lexer, Parser, Compiler, Types} + + @doc """ + Evaluates a predicate expression or instruction list with an optional context. + + This is the main entry point for Predicator evaluation. It accepts either: + - A string expression (e.g., "score > 85") which gets compiled automatically + - A pre-compiled instruction list for maximum performance + + ## Parameters + + - `input` - String expression or instruction list + - `context` - Optional context map with variable bindings (default: `%{}`) + + ## Returns + + - The evaluation result (boolean, value, or `:undefined`) + - `{:error, reason}` if parsing or execution fails + + ## Examples + + # String expressions (compiled automatically) + iex> Predicator.evaluate("score > 85", %{"score" => 90}) + true + + iex> Predicator.evaluate("name = \\"John\\"", %{"name" => "John"}) + true + + iex> Predicator.evaluate("age >= 18", %{"age" => 16}) + false + + # Pre-compiled instruction lists (maximum performance) + iex> Predicator.evaluate([["load", "score"], ["lit", 85], ["compare", "GT"]], %{"score" => 90}) + true + + iex> Predicator.evaluate([["lit", 42]]) + 42 + + # Parse errors are returned + iex> Predicator.evaluate("score >", %{}) + {:error, "Expected number, string, boolean, identifier, 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 \\ %{}) + + def evaluate(expression, context) when is_binary(expression) and is_map(context) do + with {:ok, tokens} <- Lexer.tokenize(expression), + {:ok, ast} <- Parser.parse(tokens) do + instructions = Compiler.to_instructions(ast) + Evaluator.evaluate(instructions, context) + else + {:error, message, line, column} -> {:error, "#{message} at line #{line}, column #{column}"} + {:error, message} -> {:error, message} + end + end + + def evaluate(instructions, context) when is_list(instructions) and is_map(context) do + Evaluator.evaluate(instructions, context) + end + + @doc """ + Evaluates a predicate expression or instruction list, raising on errors. + + Similar to `evaluate/2` but raises an exception for error results instead + of returning error tuples. Follows the Elixir convention of bang functions. + + ## Examples + + iex> Predicator.evaluate!("score > 85", %{"score" => 90}) + true + + iex> Predicator.evaluate!([["lit", 42]]) + 42 + + # This would raise an exception: + # Predicator.evaluate!("score >", %{}) + """ + @spec evaluate!(binary() | Types.instruction_list(), Types.context()) :: boolean() | Types.value() + def evaluate!(input, context \\ %{}) do + case evaluate(input, context) do + {:error, reason} -> raise "Evaluation failed: #{reason}" + result -> result + end + end + + @doc """ + Compiles a string expression to instruction list. + + This function allows you to pre-compile expressions for maximum performance + when evaluating the same expression multiple times with different contexts. + + ## Parameters + + - `expression` - String expression to compile + + ## Returns + + - `{:ok, instructions}` - Successfully compiled instructions + - `{:error, message}` - Parse error with details + + ## Examples + + iex> {:ok, instructions} = Predicator.compile("score > 85") + iex> instructions + [["load", "score"], ["lit", 85], ["compare", "GT"]] + + iex> Predicator.compile("score >") + {:error, "Expected number, string, boolean, identifier, 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 + with {:ok, tokens} <- Lexer.tokenize(expression), + {:ok, ast} <- Parser.parse(tokens) do + instructions = Compiler.to_instructions(ast) + {:ok, instructions} + else + {:error, message, line, column} -> {:error, "#{message} at line #{line}, column #{column}"} + {:error, message} -> {:error, message} + end + end + + @doc """ + Compiles a string expression to instruction list, raising on errors. + + Similar to `compile/1` but raises an exception for parse errors. + + ## Examples + + iex> Predicator.compile!("score > 85") + [["load", "score"], ["lit", 85], ["compare", "GT"]] + """ + @spec compile!(binary()) :: Types.instruction_list() + def compile!(expression) when is_binary(expression) do + case compile(expression) do + {:ok, instructions} -> instructions + {:error, reason} -> raise "Compilation failed: #{reason}" + end + end @doc """ Executes a list of instructions with an optional context. diff --git a/lib/predicator/compiler.ex b/lib/predicator/compiler.ex new file mode 100644 index 0000000..ca758f7 --- /dev/null +++ b/lib/predicator/compiler.ex @@ -0,0 +1,53 @@ +defmodule Predicator.Compiler do + @moduledoc """ + Compiler that converts AST to various representations using visitors. + + The compiler orchestrates different visitors to transform Abstract Syntax Trees + into executable instructions, string representations, or other formats. + + ## Examples + + iex> ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + iex> Predicator.Compiler.to_instructions(ast) + [["load", "score"], ["lit", 85], ["compare", "GT"]] + + # Future visitors will enable: + # iex> Predicator.Compiler.to_string(ast) + # "score > 85" + + # iex> Predicator.Compiler.to_dot(ast) + # "digraph {...}" + """ + + alias Predicator.{InstructionsVisitor, Parser, Visitor} + + @doc """ + Converts an AST to stack machine instructions. + + Uses the InstructionsVisitor to generate a list of instructions that can + be executed by the stack-based evaluator. + + ## Parameters + + - `ast` - The Abstract Syntax Tree to compile + - `opts` - Optional compiler options + + ## Returns + + List of instructions in the format `[["operation", ...args]]` + + ## Examples + + iex> ast = {:literal, 42} + iex> Predicator.Compiler.to_instructions(ast) + [["lit", 42]] + + iex> ast = {:comparison, :eq, {:identifier, "name"}, {:literal, "John"}} + iex> Predicator.Compiler.to_instructions(ast) + [["load", "name"], ["lit", "John"], ["compare", "EQ"]] + """ + @spec to_instructions(Parser.ast(), keyword()) :: [[binary() | term()]] + def to_instructions(ast, opts \\ []) do + Visitor.accept(ast, InstructionsVisitor, opts) + end +end \ No newline at end of file diff --git a/lib/predicator/evaluator.ex b/lib/predicator/evaluator.ex index 4d52177..9335c81 100644 --- a/lib/predicator/evaluator.ex +++ b/lib/predicator/evaluator.ex @@ -61,6 +61,31 @@ defmodule Predicator.Evaluator do end end + @doc """ + Evaluates a list of instructions with the given context, raising on errors. + + Similar to `evaluate/2` but raises an exception for error results instead + of returning error tuples. Follows the Elixir convention of bang functions. + + ## Examples + + iex> Predicator.Evaluator.evaluate!([["lit", 42]], %{}) + 42 + + iex> Predicator.Evaluator.evaluate!([["load", "score"]], %{"score" => 85}) + 85 + + # This would raise an exception: + # Predicator.Evaluator.evaluate!([["unknown_op"]], %{}) + """ + @spec evaluate!(Types.instruction_list(), Types.context()) :: boolean() | :undefined + def evaluate!(instructions, context \\ %{}) when is_list(instructions) and is_map(context) do + case evaluate(instructions, context) do + {:error, reason} -> raise "Evaluation failed: #{reason}" + result -> result + end + end + @doc """ Runs the evaluator until it halts or encounters an error. diff --git a/lib/predicator/instructions_visitor.ex b/lib/predicator/instructions_visitor.ex new file mode 100644 index 0000000..9450676 --- /dev/null +++ b/lib/predicator/instructions_visitor.ex @@ -0,0 +1,72 @@ +defmodule Predicator.InstructionsVisitor do + @moduledoc """ + Visitor that converts AST nodes to stack machine instructions. + + This visitor implements post-order traversal to generate instruction lists + that can be executed by the stack-based evaluator. Instructions are generated + in the correct order for stack-based evaluation. + + ## Examples + + iex> ast = {:literal, 42} + iex> Predicator.InstructionsVisitor.visit(ast, []) + [["lit", 42]] + + iex> ast = {:identifier, "score"} + iex> Predicator.InstructionsVisitor.visit(ast, []) + [["load", "score"]] + + iex> ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + iex> Predicator.InstructionsVisitor.visit(ast, []) + [["load", "score"], ["lit", 85], ["compare", "GT"]] + """ + + @behaviour Predicator.Visitor + + alias Predicator.Parser + + @doc """ + Visits an AST node and returns stack machine instructions. + + Uses post-order traversal to ensure operands are pushed onto the stack + before operators are applied. + + ## Parameters + + - `ast_node` - The AST node to convert to instructions + - `opts` - Optional visitor options (currently unused) + + ## Returns + + List of instructions in the format `[["operation", ...args]]` + """ + @impl true + @spec visit(Parser.ast(), keyword()) :: [[binary() | term()]] + def visit(ast_node, _opts \\ []) + + def visit({:literal, value}, _opts) do + [["lit", value]] + end + + def visit({:identifier, name}, _opts) do + [["load", name]] + end + + def visit({:comparison, op, 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 = [["compare", map_comparison_op(op)]] + + left_instructions ++ right_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" + defp map_comparison_op(:lt), do: "LT" + defp map_comparison_op(:gte), do: "GTE" + defp map_comparison_op(:lte), do: "LTE" + defp map_comparison_op(:eq), do: "EQ" + defp map_comparison_op(:ne), do: "NE" +end \ No newline at end of file diff --git a/lib/predicator/parser.ex b/lib/predicator/parser.ex new file mode 100644 index 0000000..d2a6956 --- /dev/null +++ b/lib/predicator/parser.ex @@ -0,0 +1,238 @@ +defmodule Predicator.Parser do + @moduledoc """ + Recursive descent parser for predicator expressions. + + The parser converts a stream of tokens from the lexer into an Abstract Syntax Tree (AST) + with comprehensive error reporting including exact position information. + + ## Grammar + + The parser implements this grammar: + + expression → comparison + comparison → primary ( ( ">" | "<" | ">=" | "<=" | "=" | "!=" ) primary )? + primary → NUMBER | STRING | BOOLEAN | IDENTIFIER | "(" expression ")" + + ## Examples + + iex> {:ok, tokens} = Predicator.Lexer.tokenize("score > 85") + iex> Predicator.Parser.parse(tokens) + {:ok, {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}} + + iex> {:ok, tokens} = Predicator.Lexer.tokenize("(age >= 18)") + iex> Predicator.Parser.parse(tokens) + {:ok, {:comparison, :gte, {:identifier, "age"}, {:literal, 18}}} + """ + + alias Predicator.Lexer + + @typedoc """ + A value that can appear in literals. + """ + @type value :: boolean() | integer() | binary() + + @typedoc """ + Abstract Syntax Tree node types. + + - `{:literal, value}` - A literal value (number, string, boolean) + - `{:identifier, name}` - A variable reference + - `{:comparison, operator, left, right}` - A comparison expression + """ + @type ast :: + {:literal, value()} + | {:identifier, binary()} + | {:comparison, comparison_op(), ast(), ast()} + + @typedoc """ + Comparison operators in the AST. + """ + @type comparison_op :: :gt | :lt | :gte | :lte | :eq | :ne + + @typedoc """ + Parser result - either success with AST or error with details. + """ + @type result :: {:ok, ast()} | {:error, binary(), pos_integer(), pos_integer()} + + @typedoc """ + Internal parser state for tracking position and tokens. + """ + @type parser_state :: %{ + tokens: [Lexer.token()], + position: non_neg_integer() + } + + @doc """ + Parses a list of tokens into an Abstract Syntax Tree. + + ## Parameters + + - `tokens` - List of tokens from the lexer + + ## Returns + + - `{:ok, ast}` - Successfully parsed expression + - `{:error, message, line, column}` - Parse error with position + + ## Examples + + iex> {:ok, tokens} = Predicator.Lexer.tokenize("score > 85") + iex> Predicator.Parser.parse(tokens) + {:ok, {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}} + + iex> {:ok, tokens} = Predicator.Lexer.tokenize("name = \\"John\\"") + iex> Predicator.Parser.parse(tokens) + {:ok, {:comparison, :eq, {:identifier, "name"}, {:literal, "John"}}} + + iex> {:ok, tokens} = Predicator.Lexer.tokenize("active = true") + iex> Predicator.Parser.parse(tokens) + {:ok, {:comparison, :eq, {:identifier, "active"}, {:literal, true}}} + """ + @spec parse([Lexer.token()]) :: result() + def parse(tokens) when is_list(tokens) do + state = %{tokens: tokens, position: 0} + + case parse_expression(state) do + {:ok, ast, final_state} -> + # Ensure we consumed all tokens (except EOF) + case peek_token(final_state) do + {:eof, _, _, _, _} -> + {:ok, ast} + + {type, line, col, _len, value} -> + {:error, "Unexpected token #{format_token(type, value)} after expression", line, col} + + nil -> + {:ok, ast} + end + + {:error, message, line, col} -> + {:error, message, line, col} + end + end + + # Parse expression (top level) + @spec parse_expression(parser_state()) :: + {:ok, ast(), parser_state()} | {:error, binary(), pos_integer(), pos_integer()} + defp parse_expression(state) do + parse_comparison(state) + end + + # Parse comparison expressions + @spec parse_comparison(parser_state()) :: + {:ok, ast(), parser_state()} | {:error, binary(), pos_integer(), pos_integer()} + defp parse_comparison(state) do + case parse_primary(state) do + {:ok, left, new_state} -> + case peek_token(new_state) do + # Comparison operators + {op_type, _line, _col, _len, _value} + when op_type in [:gt, :lt, :gte, :lte, :eq, :ne] -> + operator = map_operator(op_type) + op_state = advance(new_state) + + case parse_primary(op_state) do + {:ok, right, final_state} -> + ast = {:comparison, operator, left, right} + {:ok, ast, final_state} + + {:error, message, line, col} -> + {:error, message, line, col} + end + + # Not a comparison, return the primary expression + _ -> + {:ok, left, new_state} + end + + {:error, message, line, col} -> + {:error, message, line, col} + end + end + + # Parse primary expressions (literals, identifiers, parentheses) + @spec parse_primary(parser_state()) :: + {:ok, ast(), parser_state()} | {:error, binary(), pos_integer(), pos_integer()} + defp parse_primary(state) do + case peek_token(state) do + # Literals + {:integer, _line, _col, _len, value} -> + {:ok, {:literal, value}, advance(state)} + + {:string, _line, _col, _len, value} -> + {:ok, {:literal, value}, advance(state)} + + {:boolean, _line, _col, _len, value} -> + {:ok, {:literal, value}, advance(state)} + + # Identifiers + {:identifier, _line, _col, _len, value} -> + {:ok, {:identifier, value}, advance(state)} + + # Parenthesized expressions + {:lparen, _line, _col, _len, _value} -> + paren_state = advance(state) + + case parse_expression(paren_state) do + {:ok, expr, expr_state} -> + case peek_token(expr_state) do + {:rparen, _line, _col, _len, _value} -> + {:ok, expr, advance(expr_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 + + # Unexpected tokens + {type, line, col, _len, value} -> + expected = "number, string, boolean, identifier, or '('" + {:error, "Expected #{expected} but found #{format_token(type, value)}", line, col} + + # End of input + nil -> + {:error, "Unexpected end of input", 1, 1} + end + end + + # Helper functions + + @spec peek_token(parser_state()) :: Lexer.token() | nil + defp peek_token(%{tokens: tokens, position: pos}) do + Enum.at(tokens, pos) + end + + @spec advance(parser_state()) :: parser_state() + defp advance(%{position: pos} = state) do + %{state | position: pos + 1} + end + + @spec map_operator(atom()) :: comparison_op() + defp map_operator(:gt), do: :gt + defp map_operator(:lt), do: :lt + defp map_operator(:gte), do: :gte + defp map_operator(:lte), do: :lte + defp map_operator(:eq), do: :eq + defp map_operator(:ne), do: :ne + + @spec format_token(atom(), term()) :: binary() + defp format_token(:integer, value), do: "number '#{value}'" + defp format_token(:string, value), do: "string \"#{value}\"" + defp format_token(:boolean, value), do: "boolean '#{value}'" + defp format_token(:identifier, value), do: "identifier '#{value}'" + defp format_token(:gt, _), do: "'>'" + defp format_token(:lt, _), do: "'<'" + defp format_token(:gte, _), do: "'>='" + defp format_token(:lte, _), do: "'<='" + defp format_token(:eq, _), do: "'='" + defp format_token(:ne, _), do: "'!='" + defp format_token(:lparen, _), do: "'('" + defp format_token(:rparen, _), do: "')'" + defp format_token(:eof, _), do: "end of input" + defp format_token(type, value), do: "'#{value}' (#{type})" +end diff --git a/lib/predicator/visitor.ex b/lib/predicator/visitor.ex new file mode 100644 index 0000000..22cebda --- /dev/null +++ b/lib/predicator/visitor.ex @@ -0,0 +1,63 @@ +defmodule Predicator.Visitor do + @moduledoc """ + Behaviour for AST visitors. + + Visitors implement the visitor pattern to traverse and transform + Abstract Syntax Trees into various representations. + + ## Examples + + defmodule MyVisitor do + @behaviour Predicator.Visitor + + @impl true + def visit({:literal, value}, _opts) do + value + end + + @impl true + def visit({:identifier, name}, _opts) do + name + end + + @impl true + def visit({:comparison, op, left, right}, opts) do + left_result = visit(left, opts) + right_result = visit(right, opts) + {op, left_result, right_result} + end + end + """ + + alias Predicator.Parser + + @doc """ + Visits an AST node and returns the transformed result. + + ## Parameters + + - `ast_node` - The AST node to visit + - `opts` - Optional visitor-specific options + + ## Returns + + The transformed representation (type depends on visitor implementation) + """ + @callback visit(ast_node :: Parser.ast(), opts :: keyword()) :: term() + + @doc """ + Utility function to accept a visitor and process an AST. + + This provides a convenient interface for applying visitors to AST nodes. + + ## Examples + + iex> ast = {:literal, 42} + iex> Predicator.Visitor.accept(ast, MyVisitor) + 42 + """ + @spec accept(Parser.ast(), module(), keyword()) :: term() + def accept(ast_node, visitor_module, opts \\ []) do + visitor_module.visit(ast_node, opts) + end +end \ No newline at end of file diff --git a/test/predicator/compiler_test.exs b/test/predicator/compiler_test.exs new file mode 100644 index 0000000..8f13b39 --- /dev/null +++ b/test/predicator/compiler_test.exs @@ -0,0 +1,97 @@ +defmodule Predicator.CompilerTest do + use ExUnit.Case, async: true + + alias Predicator.Compiler + + doctest Predicator.Compiler + + describe "to_instructions/2" do + test "compiles literal to instructions" do + ast = {:literal, 42} + result = Compiler.to_instructions(ast) + + assert result == [["lit", 42]] + end + + test "compiles identifier to instructions" do + ast = {:identifier, "score"} + result = Compiler.to_instructions(ast) + + assert result == [["load", "score"]] + end + + test "compiles comparison to instructions" do + ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + result = Compiler.to_instructions(ast) + + assert result == [ + ["load", "score"], + ["lit", 85], + ["compare", "GT"] + ] + end + + test "works with all comparison operators" do + operators_map = %{ + :gt => "GT", + :lt => "LT", + :gte => "GTE", + :lte => "LTE", + :eq => "EQ", + :ne => "NE" + } + + for {ast_op, instruction_op} <- operators_map do + ast = {:comparison, ast_op, {:identifier, "x"}, {:literal, 1}} + result = Compiler.to_instructions(ast) + + assert result == [ + ["load", "x"], + ["lit", 1], + ["compare", instruction_op] + ] + end + end + + test "compiles with opts parameter" do + ast = {:literal, 42} + result = Compiler.to_instructions(ast, some_option: true) + + assert result == [["lit", 42]] + end + end + + describe "integration with full pipeline" do + test "compiles from string to instructions via lexer and parser" do + alias Predicator.{Lexer, Parser} + + input = "user_age >= 21" + {:ok, tokens} = Lexer.tokenize(input) + {:ok, ast} = Parser.parse(tokens) + + result = Compiler.to_instructions(ast) + + assert result == [ + ["load", "user_age"], + ["lit", 21], + ["compare", "GTE"] + ] + end + + test "compiles complex expressions" do + alias Predicator.{Lexer, Parser} + + input = "(status != \"inactive\")" + {:ok, tokens} = Lexer.tokenize(input) + {:ok, ast} = Parser.parse(tokens) + + result = Compiler.to_instructions(ast) + + assert result == [ + ["load", "status"], + ["lit", "inactive"], + ["compare", "NE"] + ] + end + end +end \ No newline at end of file diff --git a/test/predicator/evaluator_test.exs b/test/predicator/evaluator_test.exs index 6884a22..86a9cda 100644 --- a/test/predicator/evaluator_test.exs +++ b/test/predicator/evaluator_test.exs @@ -181,4 +181,44 @@ defmodule Predicator.EvaluatorTest do assert final_evaluator.halted end end + + describe "evaluate!/2" do + test "returns result directly for successful evaluation" do + instructions = [["lit", 42]] + assert Evaluator.evaluate!(instructions) == 42 + end + + test "returns result for load instruction" do + instructions = [["load", "score"]] + context = %{"score" => 85} + assert Evaluator.evaluate!(instructions, context) == 85 + end + + test "returns result for comparison instruction" do + instructions = [["load", "x"], ["lit", 5], ["compare", "GT"]] + context = %{"x" => 10} + assert Evaluator.evaluate!(instructions, context) == true + end + + test "returns :undefined for missing context" do + instructions = [["load", "missing"]] + assert Evaluator.evaluate!(instructions) == :undefined + end + + test "raises exception for evaluation errors" do + instructions = [["unknown_operation"]] + + assert_raise RuntimeError, ~r/Evaluation failed:/, fn -> + Evaluator.evaluate!(instructions) + end + end + + test "raises exception for empty stack error" do + instructions = [] + + assert_raise RuntimeError, ~r/Evaluation failed:/, fn -> + Evaluator.evaluate!(instructions) + end + end + end end diff --git a/test/predicator/instructions_visitor_test.exs b/test/predicator/instructions_visitor_test.exs new file mode 100644 index 0000000..c8c1fb5 --- /dev/null +++ b/test/predicator/instructions_visitor_test.exs @@ -0,0 +1,168 @@ +defmodule Predicator.InstructionsVisitorTest do + use ExUnit.Case, async: true + + alias Predicator.InstructionsVisitor + + doctest Predicator.InstructionsVisitor + + describe "visit/2 - literal nodes" do + test "generates lit instruction for integer literal" do + ast = {:literal, 42} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["lit", 42]] + end + + test "generates lit instruction for string literal" do + ast = {:literal, "hello"} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["lit", "hello"]] + end + + test "generates lit instruction for boolean literal" do + ast = {:literal, true} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["lit", true]] + end + end + + describe "visit/2 - identifier nodes" do + test "generates load instruction for identifier" do + ast = {:identifier, "score"} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["load", "score"]] + end + + test "generates load instruction for underscore identifier" do + ast = {:identifier, "user_age"} + result = InstructionsVisitor.visit(ast, []) + + assert result == [["load", "user_age"]] + end + end + + describe "visit/2 - comparison nodes" do + test "generates instructions for greater than comparison" do + ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "score"], + ["lit", 85], + ["compare", "GT"] + ] + end + + test "generates instructions for less than comparison" do + ast = {:comparison, :lt, {:identifier, "age"}, {:literal, 18}} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "age"], + ["lit", 18], + ["compare", "LT"] + ] + end + + test "generates instructions for greater than or equal comparison" do + ast = {:comparison, :gte, {:identifier, "score"}, {:literal, 85}} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "score"], + ["lit", 85], + ["compare", "GTE"] + ] + end + + test "generates instructions for less than or equal comparison" do + ast = {:comparison, :lte, {:identifier, "age"}, {:literal, 65}} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "age"], + ["lit", 65], + ["compare", "LTE"] + ] + end + + test "generates instructions for equality comparison" do + ast = {:comparison, :eq, {:identifier, "name"}, {:literal, "John"}} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "name"], + ["lit", "John"], + ["compare", "EQ"] + ] + end + + test "generates instructions for not equal comparison" do + ast = {:comparison, :ne, {:identifier, "status"}, {:literal, "inactive"}} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "status"], + ["lit", "inactive"], + ["compare", "NE"] + ] + end + + test "generates instructions with literal-to-literal comparison" do + ast = {:comparison, :gt, {:literal, 10}, {:literal, 5}} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["lit", 10], + ["lit", 5], + ["compare", "GT"] + ] + end + + test "generates instructions with identifier-to-identifier comparison" do + ast = {:comparison, :eq, {:identifier, "score"}, {:identifier, "threshold"}} + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "score"], + ["load", "threshold"], + ["compare", "EQ"] + ] + end + end + + describe "visit/2 - integration with full pipeline" do + test "works with lexer and parser output" do + alias Predicator.{Lexer, Parser} + + {:ok, tokens} = Lexer.tokenize("score > 85") + {:ok, ast} = Parser.parse(tokens) + + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "score"], + ["lit", 85], + ["compare", "GT"] + ] + end + + test "works with complex parenthesized expression" do + alias Predicator.{Lexer, Parser} + + {:ok, tokens} = Lexer.tokenize("(age >= 18)") + {:ok, ast} = Parser.parse(tokens) + + result = InstructionsVisitor.visit(ast, []) + + assert result == [ + ["load", "age"], + ["lit", 18], + ["compare", "GTE"] + ] + end + end +end \ No newline at end of file diff --git a/test/predicator/integration_test.exs b/test/predicator/integration_test.exs new file mode 100644 index 0000000..5754d0e --- /dev/null +++ b/test/predicator/integration_test.exs @@ -0,0 +1,135 @@ +defmodule Predicator.IntegrationTest do + use ExUnit.Case, async: true + + alias Predicator.{Lexer, Parser, Compiler, Evaluator} + + describe "full pipeline integration" do + test "string -> tokens -> ast -> instructions -> evaluation" do + input = "score > 85" + context = %{"score" => 90} + + # Lex + {:ok, tokens} = Lexer.tokenize(input) + + # Parse + {:ok, ast} = Parser.parse(tokens) + + # Compile + instructions = Compiler.to_instructions(ast) + assert instructions == [ + ["load", "score"], + ["lit", 85], + ["compare", "GT"] + ] + + # Evaluate + result = Evaluator.evaluate!(instructions, context) + assert result == true + end + + test "complex expression with parentheses" do + input = "(age >= 18)" + context = %{"age" => 21} + + {:ok, tokens} = Lexer.tokenize(input) + {:ok, ast} = Parser.parse(tokens) + instructions = Compiler.to_instructions(ast) + + assert instructions == [ + ["load", "age"], + ["lit", 18], + ["compare", "GTE"] + ] + + result = Evaluator.evaluate!(instructions, context) + assert result == true + end + + test "string comparison" do + input = "name = \"John\"" + context = %{"name" => "John"} + + {:ok, tokens} = Lexer.tokenize(input) + {:ok, ast} = Parser.parse(tokens) + instructions = Compiler.to_instructions(ast) + + assert instructions == [ + ["load", "name"], + ["lit", "John"], + ["compare", "EQ"] + ] + + result = Evaluator.evaluate!(instructions, context) + assert result == true + end + + test "boolean comparison" do + input = "active = true" + context = %{"active" => true} + + {:ok, tokens} = Lexer.tokenize(input) + {:ok, ast} = Parser.parse(tokens) + instructions = Compiler.to_instructions(ast) + + assert instructions == [ + ["load", "active"], + ["lit", true], + ["compare", "EQ"] + ] + + result = Evaluator.evaluate!(instructions, context) + assert result == true + end + + test "not equal comparison evaluates to false" do + input = "status != \"active\"" + context = %{"status" => "active"} + + {:ok, tokens} = Lexer.tokenize(input) + {:ok, ast} = Parser.parse(tokens) + instructions = Compiler.to_instructions(ast) + + result = Evaluator.evaluate!(instructions, context) + assert result == false + end + + test "all comparison operators work correctly" do + test_cases = [ + {"x > 5", %{"x" => 10}, true}, + {"x > 5", %{"x" => 3}, false}, + {"x < 5", %{"x" => 3}, true}, + {"x < 5", %{"x" => 10}, false}, + {"x >= 5", %{"x" => 5}, true}, + {"x >= 5", %{"x" => 4}, false}, + {"x <= 5", %{"x" => 5}, true}, + {"x <= 5", %{"x" => 6}, false}, + {"x = 5", %{"x" => 5}, true}, + {"x = 5", %{"x" => 6}, false}, + {"x != 5", %{"x" => 6}, true}, + {"x != 5", %{"x" => 5}, false} + ] + + for {input, context, expected} <- test_cases do + {:ok, tokens} = Lexer.tokenize(input) + {:ok, ast} = Parser.parse(tokens) + instructions = Compiler.to_instructions(ast) + result = Evaluator.evaluate!(instructions, context) + + assert result == expected, "Failed for input: #{input} with context: #{inspect(context)}" + end + end + + test "handles missing context keys" do + input = "missing_key > 5" + context = %{} + + {:ok, tokens} = Lexer.tokenize(input) + {:ok, ast} = Parser.parse(tokens) + instructions = Compiler.to_instructions(ast) + + result = Evaluator.evaluate!(instructions, context) + result = Evaluator.evaluate(instructions, context) + assert result == :undefined + end + end +end \ No newline at end of file diff --git a/test/predicator/parser_test.exs b/test/predicator/parser_test.exs new file mode 100644 index 0000000..343c0da --- /dev/null +++ b/test/predicator/parser_test.exs @@ -0,0 +1,248 @@ +defmodule Predicator.ParserTest do + use ExUnit.Case, async: true + + alias Predicator.{Lexer, Parser} + + doctest Predicator.Parser + + describe "parse/1 - primary expressions" do + test "parses integer literal" do + {:ok, tokens} = Lexer.tokenize("42") + assert Parser.parse(tokens) == {:ok, {:literal, 42}} + end + + test "parses string literal" do + {:ok, tokens} = Lexer.tokenize("\"hello\"") + assert Parser.parse(tokens) == {:ok, {:literal, "hello"}} + end + + test "parses boolean literal true" do + {:ok, tokens} = Lexer.tokenize("true") + assert Parser.parse(tokens) == {:ok, {:literal, true}} + end + + test "parses boolean literal false" do + {:ok, tokens} = Lexer.tokenize("false") + assert Parser.parse(tokens) == {:ok, {:literal, false}} + end + + test "parses identifier" do + {:ok, tokens} = Lexer.tokenize("score") + assert Parser.parse(tokens) == {:ok, {:identifier, "score"}} + end + + test "parses parenthesized expression" do + {:ok, tokens} = Lexer.tokenize("(42)") + assert Parser.parse(tokens) == {:ok, {:literal, 42}} + end + + test "parses nested parentheses" do + {:ok, tokens} = Lexer.tokenize("((score))") + assert Parser.parse(tokens) == {:ok, {:identifier, "score"}} + end + end + + describe "parse/1 - comparison expressions" do + test "parses greater than comparison" do + {:ok, tokens} = Lexer.tokenize("score > 85") + + expected = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + assert Parser.parse(tokens) == {:ok, expected} + end + + test "parses less than comparison" do + {:ok, tokens} = Lexer.tokenize("age < 18") + + expected = {:comparison, :lt, {:identifier, "age"}, {:literal, 18}} + assert Parser.parse(tokens) == {:ok, expected} + end + + test "parses greater than or equal comparison" do + {:ok, tokens} = Lexer.tokenize("score >= 85") + + expected = {:comparison, :gte, {:identifier, "score"}, {:literal, 85}} + assert Parser.parse(tokens) == {:ok, expected} + end + + test "parses less than or equal comparison" do + {:ok, tokens} = Lexer.tokenize("age <= 65") + + expected = {:comparison, :lte, {:identifier, "age"}, {:literal, 65}} + assert Parser.parse(tokens) == {:ok, expected} + end + + test "parses equality comparison" do + {:ok, tokens} = Lexer.tokenize("name = \"John\"") + + expected = {:comparison, :eq, {:identifier, "name"}, {:literal, "John"}} + assert Parser.parse(tokens) == {:ok, expected} + end + + test "parses not equal comparison" do + {:ok, tokens} = Lexer.tokenize("status != \"inactive\"") + + expected = {:comparison, :ne, {:identifier, "status"}, {:literal, "inactive"}} + assert Parser.parse(tokens) == {:ok, expected} + end + + test "parses number to number comparison" do + {:ok, tokens} = Lexer.tokenize("10 > 5") + + expected = {:comparison, :gt, {:literal, 10}, {:literal, 5}} + assert Parser.parse(tokens) == {:ok, expected} + end + + test "parses boolean comparison" do + {:ok, tokens} = Lexer.tokenize("active = true") + + expected = {:comparison, :eq, {:identifier, "active"}, {:literal, true}} + assert Parser.parse(tokens) == {:ok, expected} + end + end + + describe "parse/1 - parenthesized comparisons" do + test "parses comparison in parentheses" do + {:ok, tokens} = Lexer.tokenize("(score > 85)") + + expected = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + assert Parser.parse(tokens) == {:ok, expected} + end + + test "parses parenthesized left operand" do + {:ok, tokens} = Lexer.tokenize("(score) > 85") + + expected = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + assert Parser.parse(tokens) == {:ok, expected} + end + + test "parses parenthesized right operand" do + {:ok, tokens} = Lexer.tokenize("score > (85)") + + expected = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + assert Parser.parse(tokens) == {:ok, expected} + end + + test "parses both operands parenthesized" do + {:ok, tokens} = Lexer.tokenize("(score) > (85)") + + expected = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + assert Parser.parse(tokens) == {:ok, expected} + end + end + + describe "parse/1 - complex expressions" do + test "handles whitespace correctly" do + {:ok, tokens} = Lexer.tokenize(" score > 85 ") + + expected = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + assert Parser.parse(tokens) == {:ok, expected} + end + + test "handles mixed types" do + {:ok, tokens} = Lexer.tokenize("\"apple\" > \"banana\"") + + expected = {:comparison, :gt, {:literal, "apple"}, {:literal, "banana"}} + assert Parser.parse(tokens) == {:ok, expected} + end + end + + describe "parse/1 - error cases" do + test "returns error for empty token list" do + result = Parser.parse([]) + assert {:error, "Unexpected end of input", 1, 1} = result + end + + test "returns error for only EOF token" do + tokens = [{:eof, 1, 1, 0, nil}] + result = Parser.parse(tokens) + + assert {:error, + "Expected number, string, boolean, identifier, or '(' but found end of input", 1, + 1} = result + end + + test "returns error for incomplete comparison" do + {:ok, tokens} = Lexer.tokenize("score >") + + result = Parser.parse(tokens) + + assert {:error, + "Expected number, string, boolean, identifier, or '(' but found end of input", 1, + 8} = result + end + + test "returns error for invalid left operand" do + # This would be caught by the lexer, but let's test with a constructed token + tokens = [{:gt, 1, 1, 1, ">"}, {:integer, 1, 3, 2, 85}, {:eof, 1, 5, 0, nil}] + result = Parser.parse(tokens) + + assert {:error, "Expected number, string, boolean, identifier, or '(' but found '>'", 1, 1} = + result + end + + test "returns error for missing right operand" do + {:ok, tokens} = Lexer.tokenize("score > >") + + result = Parser.parse(tokens) + + assert {:error, "Expected number, string, boolean, identifier, or '(' but found '>'", 1, 9} = + result + end + + test "returns error for unterminated parentheses" do + {:ok, tokens} = Lexer.tokenize("(score") + + result = Parser.parse(tokens) + assert {:error, "Expected ')' but found end of input", 1, 7} = result + end + + test "returns error for mismatched parentheses" do + # The lexer rejects ']' as invalid, so let's test with constructed tokens + tokens = [ + {:lparen, 1, 1, 1, "("}, + {:identifier, 1, 2, 5, "score"}, + # Simulating a different token type + {:identifier, 1, 7, 1, "]"}, + {:eof, 1, 8, 0, nil} + ] + + result = Parser.parse(tokens) + assert {:error, "Expected ')' but found identifier ']'", 1, 7} = result + end + + test "returns error for extra tokens after expression" do + {:ok, tokens} = Lexer.tokenize("score > 85 extra") + + result = Parser.parse(tokens) + assert {:error, "Unexpected token identifier 'extra' after expression", 1, 12} = result + end + + test "returns error for multiple operators" do + {:ok, tokens} = Lexer.tokenize("score > > 85") + + result = Parser.parse(tokens) + + assert {:error, "Expected number, string, boolean, identifier, or '(' but found '>'", 1, 9} = + result + end + end + + describe "parse/1 - integration with lexer errors" do + test "handles lexer tokenization into parser" do + # Test the full pipeline: string -> tokens -> AST + input = "user_age >= 21" + {:ok, tokens} = Lexer.tokenize(input) + + expected = {:comparison, :gte, {:identifier, "user_age"}, {:literal, 21}} + assert Parser.parse(tokens) == {:ok, expected} + end + + test "handles complex parenthesized expressions" do + input = "((score) >= (threshold))" + {:ok, tokens} = Lexer.tokenize(input) + + expected = {:comparison, :gte, {:identifier, "score"}, {:identifier, "threshold"}} + assert Parser.parse(tokens) == {:ok, expected} + end + end +end diff --git a/test/predicator_test.exs b/test/predicator_test.exs index 29f80cf..ee39df1 100644 --- a/test/predicator_test.exs +++ b/test/predicator_test.exs @@ -3,6 +3,260 @@ defmodule PredicatorTest do doctest Predicator + describe "evaluate/2 with string expressions" do + test "evaluates simple comparison" do + result = Predicator.evaluate("score > 85", %{"score" => 90}) + assert result == true + end + + test "evaluates with different operators" do + context = %{"x" => 10} + + assert Predicator.evaluate("x > 5", context) == true + assert Predicator.evaluate("x < 5", context) == false + assert Predicator.evaluate("x >= 10", context) == true + assert Predicator.evaluate("x <= 10", context) == true + assert Predicator.evaluate("x = 10", context) == true + assert Predicator.evaluate("x != 5", context) == true + end + + test "evaluates string comparisons" do + context = %{"name" => "John"} + + assert Predicator.evaluate("name = \"John\"", context) == true + assert Predicator.evaluate("name != \"Jane\"", context) == true + end + + test "evaluates boolean comparisons" do + context = %{"active" => true} + + assert Predicator.evaluate("active = true", context) == true + assert Predicator.evaluate("active != false", context) == true + end + + test "handles parentheses" do + result = Predicator.evaluate("(score > 85)", %{"score" => 90}) + assert result == true + end + + test "handles whitespace" do + result = Predicator.evaluate(" score > 85 ", %{"score" => 90}) + assert result == true + end + + test "returns :undefined for missing variables" do + result = Predicator.evaluate("missing > 5", %{}) + assert result == :undefined + end + + test "returns error for parse failures" do + result = Predicator.evaluate("score >", %{}) + assert {:error, message} = result + assert message =~ "Expected number, string, boolean, identifier, or '(' but found end of input" + assert message =~ "line 1, column 8" + end + + test "returns error for invalid syntax" do + result = Predicator.evaluate("score > >", %{}) + assert {:error, message} = result + assert message =~ "Expected number, string, boolean, identifier, or '(' but found '>'" + end + end + + describe "evaluate/2 with instruction lists" do + test "evaluates literal instructions" do + result = Predicator.evaluate([["lit", 42]], %{}) + assert result == 42 + end + + test "evaluates load instructions" do + result = Predicator.evaluate([["load", "score"]], %{"score" => 85}) + assert result == 85 + end + + test "evaluates comparison instructions" do + instructions = [["load", "score"], ["lit", 85], ["compare", "GT"]] + result = Predicator.evaluate(instructions, %{"score" => 90}) + assert result == true + end + + test "returns error for invalid instructions" do + result = Predicator.evaluate([["unknown_op"]], %{}) + assert {:error, message} = result + assert message =~ "Unknown instruction" + end + end + + describe "evaluate!/2" do + test "returns result directly for string expressions" do + result = Predicator.evaluate!("score > 85", %{"score" => 90}) + assert result == true + end + + test "returns result directly for instruction lists" do + result = Predicator.evaluate!([["lit", 42]], %{}) + assert result == 42 + end + + test "raises exception for parse errors" do + assert_raise RuntimeError, ~r/Evaluation failed:/, fn -> + Predicator.evaluate!("score >", %{}) + end + end + + test "raises exception for execution errors" do + assert_raise RuntimeError, ~r/Evaluation failed:/, fn -> + Predicator.evaluate!([["unknown_op"]], %{}) + end + end + end + + describe "compile/1" do + test "compiles simple expression" do + {:ok, instructions} = Predicator.compile("score > 85") + + expected = [ + ["load", "score"], + ["lit", 85], + ["compare", "GT"] + ] + + assert instructions == expected + end + + test "compiles different operators" do + test_cases = [ + {"x > 5", [["load", "x"], ["lit", 5], ["compare", "GT"]]}, + {"x < 5", [["load", "x"], ["lit", 5], ["compare", "LT"]]}, + {"x >= 5", [["load", "x"], ["lit", 5], ["compare", "GTE"]]}, + {"x <= 5", [["load", "x"], ["lit", 5], ["compare", "LTE"]]}, + {"x = 5", [["load", "x"], ["lit", 5], ["compare", "EQ"]]}, + {"x != 5", [["load", "x"], ["lit", 5], ["compare", "NE"]]} + ] + + for {expression, expected_instructions} <- test_cases do + {:ok, instructions} = Predicator.compile(expression) + assert instructions == expected_instructions + end + end + + test "compiles string expressions" do + {:ok, instructions} = Predicator.compile("name = \"John\"") + + expected = [ + ["load", "name"], + ["lit", "John"], + ["compare", "EQ"] + ] + + assert instructions == expected + end + + test "compiles boolean expressions" do + {:ok, instructions} = Predicator.compile("active = true") + + expected = [ + ["load", "active"], + ["lit", true], + ["compare", "EQ"] + ] + + assert instructions == expected + end + + test "handles parentheses" do + {:ok, instructions} = Predicator.compile("(score > 85)") + + expected = [ + ["load", "score"], + ["lit", 85], + ["compare", "GT"] + ] + + assert instructions == expected + end + + test "returns error for invalid syntax" do + result = Predicator.compile("score >") + assert {:error, message} = result + assert message =~ "Expected number, string, boolean, identifier, or '(' but found end of input" + assert message =~ "line 1, column 8" + end + end + + describe "compile!/1" do + test "compiles successfully" do + instructions = Predicator.compile!("score > 85") + + expected = [ + ["load", "score"], + ["lit", 85], + ["compare", "GT"] + ] + + assert instructions == expected + end + + test "raises exception for parse errors" do + assert_raise RuntimeError, ~r/Compilation failed:/, fn -> + Predicator.compile!("score >") + end + end + end + + describe "performance scenarios" do + test "pre-compiled instructions are faster for repeated evaluation" do + # Compile once + {:ok, instructions} = Predicator.compile("score > 85") + + # Use many times with different contexts + contexts = [ + %{"score" => 90}, + %{"score" => 80}, + %{"score" => 95}, + %{"score" => 70} + ] + + results = Enum.map(contexts, fn context -> + Predicator.evaluate(instructions, context) + end) + + assert results == [true, false, true, false] + end + + test "string expressions work but are slower due to compilation" do + expression = "score > 85" + + contexts = [ + %{"score" => 90}, + %{"score" => 80} + ] + + results = Enum.map(contexts, fn context -> + Predicator.evaluate(expression, context) + end) + + assert results == [true, false] + end + end + + describe "edge cases" do + test "empty context works with literals" do + result = Predicator.evaluate("5 > 3", %{}) + assert result == true + end + + test "nested parentheses work" do + result = Predicator.evaluate("((score > 85))", %{"score" => 90}) + assert result == true + end + + test "type mismatches return :undefined" do + result = Predicator.evaluate("score > \"not_a_number\"", %{"score" => 90}) + assert result == :undefined + end + end + describe "execute/2 API" do test "executes simple literal instruction" do assert Predicator.execute([["lit", 42]]) == 42 From b5cb8d72672296660328872c9cde9dc2011202a9 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Tue, 19 Aug 2025 09:09:19 -0600 Subject: [PATCH 4/5] Fixes credo issues --- lib/predicator.ex | 7 +- lib/predicator/compiler.ex | 2 +- lib/predicator/instructions_visitor.ex | 4 +- lib/predicator/lexer.ex | 8 +- lib/predicator/parser.ex | 23 ++-- lib/predicator/visitor.ex | 2 +- test/predicator/compiler_test.exs | 58 ++++----- test/predicator/evaluator_test.exs | 4 +- test/predicator/instructions_visitor_test.exs | 120 +++++++++--------- test/predicator/integration_test.exs | 75 +++++------ test/predicator/lexer_test.exs | 2 +- test/predicator/parser_test.exs | 109 +++++++++++++++- test/predicator_test.exs | 62 +++++---- 13 files changed, 295 insertions(+), 181 deletions(-) diff --git a/lib/predicator.ex b/lib/predicator.ex index 962e72c..1cc6cd8 100644 --- a/lib/predicator.ex +++ b/lib/predicator.ex @@ -46,7 +46,7 @@ defmodule Predicator do 3. The final result is the top value on the stack when execution completes """ - alias Predicator.{Evaluator, Lexer, Parser, Compiler, Types} + alias Predicator.{Compiler, Evaluator, Lexer, Parser, Types} @doc """ Evaluates a predicate expression or instruction list with an optional context. @@ -98,7 +98,6 @@ defmodule Predicator do Evaluator.evaluate(instructions, context) else {:error, message, line, column} -> {:error, "#{message} at line #{line}, column #{column}"} - {:error, message} -> {:error, message} end end @@ -123,7 +122,8 @@ defmodule Predicator do # This would raise an exception: # Predicator.evaluate!("score >", %{}) """ - @spec evaluate!(binary() | Types.instruction_list(), Types.context()) :: boolean() | Types.value() + @spec evaluate!(binary() | Types.instruction_list(), Types.context()) :: + boolean() | Types.value() def evaluate!(input, context \\ %{}) do case evaluate(input, context) do {:error, reason} -> raise "Evaluation failed: #{reason}" @@ -163,7 +163,6 @@ defmodule Predicator do {:ok, instructions} else {:error, message, line, column} -> {:error, "#{message} at line #{line}, column #{column}"} - {:error, message} -> {:error, message} end end diff --git a/lib/predicator/compiler.ex b/lib/predicator/compiler.ex index ca758f7..07a20ab 100644 --- a/lib/predicator/compiler.ex +++ b/lib/predicator/compiler.ex @@ -50,4 +50,4 @@ defmodule Predicator.Compiler do def to_instructions(ast, opts \\ []) do Visitor.accept(ast, InstructionsVisitor, opts) end -end \ No newline at end of file +end diff --git a/lib/predicator/instructions_visitor.ex b/lib/predicator/instructions_visitor.ex index 9450676..1c0f8b2 100644 --- a/lib/predicator/instructions_visitor.ex +++ b/lib/predicator/instructions_visitor.ex @@ -40,7 +40,7 @@ defmodule Predicator.InstructionsVisitor do List of instructions in the format `[["operation", ...args]]` """ - @impl true + @impl Predicator.Visitor @spec visit(Parser.ast(), keyword()) :: [[binary() | term()]] def visit(ast_node, _opts \\ []) @@ -69,4 +69,4 @@ defmodule Predicator.InstructionsVisitor do defp map_comparison_op(:lte), do: "LTE" defp map_comparison_op(:eq), do: "EQ" defp map_comparison_op(:ne), do: "NE" -end \ No newline at end of file +end diff --git a/lib/predicator/lexer.ex b/lib/predicator/lexer.ex index 1c58df5..da777bf 100644 --- a/lib/predicator/lexer.ex +++ b/lib/predicator/lexer.ex @@ -151,7 +151,7 @@ defmodule Predicator.Lexer do token = {:gte, line, col, 2, ">="} tokenize_chars(rest2, line, col + 2, [token | tokens]) - _ -> + _rest -> token = {:gt, line, col, 1, ">"} tokenize_chars(rest, line, col + 1, [token | tokens]) end @@ -162,7 +162,7 @@ defmodule Predicator.Lexer do token = {:lte, line, col, 2, "<="} tokenize_chars(rest2, line, col + 2, [token | tokens]) - _ -> + _rest -> token = {:lt, line, col, 1, "<"} tokenize_chars(rest, line, col + 1, [token | tokens]) end @@ -173,7 +173,7 @@ defmodule Predicator.Lexer do token = {:ne, line, col, 2, "!="} tokenize_chars(rest2, line, col + 2, [token | tokens]) - _ -> + _rest -> {:error, "Unexpected character '!'", line, col} end @@ -202,7 +202,7 @@ defmodule Predicator.Lexer do end # Unknown character - _ -> + _char -> {:error, "Unexpected character '#{[char]}'", line, col} end end diff --git a/lib/predicator/parser.ex b/lib/predicator/parser.ex index d2a6956..598e897 100644 --- a/lib/predicator/parser.ex +++ b/lib/predicator/parser.ex @@ -95,7 +95,7 @@ defmodule Predicator.Parser do {:ok, ast, final_state} -> # Ensure we consumed all tokens (except EOF) case peek_token(final_state) do - {:eof, _, _, _, _} -> + {:eof, _line, _col, _len, _value} -> {:ok, ast} {type, line, col, _len, value} -> @@ -140,7 +140,7 @@ defmodule Predicator.Parser do end # Not a comparison, return the primary expression - _ -> + _token -> {:ok, left, new_state} end @@ -225,14 +225,13 @@ defmodule Predicator.Parser do defp format_token(:string, value), do: "string \"#{value}\"" defp format_token(:boolean, value), do: "boolean '#{value}'" defp format_token(:identifier, value), do: "identifier '#{value}'" - defp format_token(:gt, _), do: "'>'" - defp format_token(:lt, _), do: "'<'" - defp format_token(:gte, _), do: "'>='" - defp format_token(:lte, _), do: "'<='" - defp format_token(:eq, _), do: "'='" - defp format_token(:ne, _), do: "'!='" - defp format_token(:lparen, _), do: "'('" - defp format_token(:rparen, _), do: "')'" - defp format_token(:eof, _), do: "end of input" - defp format_token(type, value), do: "'#{value}' (#{type})" + defp format_token(:gt, _value), do: "'>'" + defp format_token(:lt, _value), do: "'<'" + defp format_token(:gte, _value), do: "'>='" + defp format_token(:lte, _value), do: "'<='" + defp format_token(:eq, _value), do: "'='" + defp format_token(:ne, _value), do: "'!='" + defp format_token(:lparen, _value), do: "'('" + defp format_token(:rparen, _value), do: "')'" + defp format_token(:eof, _value), do: "end of input" end diff --git a/lib/predicator/visitor.ex b/lib/predicator/visitor.ex index 22cebda..7564db2 100644 --- a/lib/predicator/visitor.ex +++ b/lib/predicator/visitor.ex @@ -60,4 +60,4 @@ defmodule Predicator.Visitor do def accept(ast_node, visitor_module, opts \\ []) do visitor_module.visit(ast_node, opts) end -end \ No newline at end of file +end diff --git a/test/predicator/compiler_test.exs b/test/predicator/compiler_test.exs index 8f13b39..9f9f193 100644 --- a/test/predicator/compiler_test.exs +++ b/test/predicator/compiler_test.exs @@ -9,32 +9,32 @@ defmodule Predicator.CompilerTest do test "compiles literal to instructions" do ast = {:literal, 42} result = Compiler.to_instructions(ast) - + assert result == [["lit", 42]] end test "compiles identifier to instructions" do ast = {:identifier, "score"} result = Compiler.to_instructions(ast) - + assert result == [["load", "score"]] end test "compiles comparison to instructions" do ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} result = Compiler.to_instructions(ast) - + assert result == [ - ["load", "score"], - ["lit", 85], - ["compare", "GT"] - ] + ["load", "score"], + ["lit", 85], + ["compare", "GT"] + ] end test "works with all comparison operators" do operators_map = %{ :gt => "GT", - :lt => "LT", + :lt => "LT", :gte => "GTE", :lte => "LTE", :eq => "EQ", @@ -44,19 +44,19 @@ defmodule Predicator.CompilerTest do for {ast_op, instruction_op} <- operators_map do ast = {:comparison, ast_op, {:identifier, "x"}, {:literal, 1}} result = Compiler.to_instructions(ast) - + assert result == [ - ["load", "x"], - ["lit", 1], - ["compare", instruction_op] - ] + ["load", "x"], + ["lit", 1], + ["compare", instruction_op] + ] end end test "compiles with opts parameter" do ast = {:literal, 42} result = Compiler.to_instructions(ast, some_option: true) - + assert result == [["lit", 42]] end end @@ -64,34 +64,34 @@ defmodule Predicator.CompilerTest do describe "integration with full pipeline" do test "compiles from string to instructions via lexer and parser" do alias Predicator.{Lexer, Parser} - + input = "user_age >= 21" {:ok, tokens} = Lexer.tokenize(input) {:ok, ast} = Parser.parse(tokens) - + result = Compiler.to_instructions(ast) - + assert result == [ - ["load", "user_age"], - ["lit", 21], - ["compare", "GTE"] - ] + ["load", "user_age"], + ["lit", 21], + ["compare", "GTE"] + ] end test "compiles complex expressions" do alias Predicator.{Lexer, Parser} - + input = "(status != \"inactive\")" {:ok, tokens} = Lexer.tokenize(input) {:ok, ast} = Parser.parse(tokens) - + result = Compiler.to_instructions(ast) - + assert result == [ - ["load", "status"], - ["lit", "inactive"], - ["compare", "NE"] - ] + ["load", "status"], + ["lit", "inactive"], + ["compare", "NE"] + ] end end -end \ No newline at end of file +end diff --git a/test/predicator/evaluator_test.exs b/test/predicator/evaluator_test.exs index 86a9cda..20a8400 100644 --- a/test/predicator/evaluator_test.exs +++ b/test/predicator/evaluator_test.exs @@ -207,7 +207,7 @@ defmodule Predicator.EvaluatorTest do test "raises exception for evaluation errors" do instructions = [["unknown_operation"]] - + assert_raise RuntimeError, ~r/Evaluation failed:/, fn -> Evaluator.evaluate!(instructions) end @@ -215,7 +215,7 @@ defmodule Predicator.EvaluatorTest do test "raises exception for empty stack error" do instructions = [] - + assert_raise RuntimeError, ~r/Evaluation failed:/, fn -> Evaluator.evaluate!(instructions) end diff --git a/test/predicator/instructions_visitor_test.exs b/test/predicator/instructions_visitor_test.exs index c8c1fb5..176124d 100644 --- a/test/predicator/instructions_visitor_test.exs +++ b/test/predicator/instructions_visitor_test.exs @@ -9,21 +9,21 @@ defmodule Predicator.InstructionsVisitorTest do test "generates lit instruction for integer literal" do ast = {:literal, 42} result = InstructionsVisitor.visit(ast, []) - + assert result == [["lit", 42]] end test "generates lit instruction for string literal" do ast = {:literal, "hello"} result = InstructionsVisitor.visit(ast, []) - + assert result == [["lit", "hello"]] end test "generates lit instruction for boolean literal" do ast = {:literal, true} result = InstructionsVisitor.visit(ast, []) - + assert result == [["lit", true]] end end @@ -32,14 +32,14 @@ defmodule Predicator.InstructionsVisitorTest do test "generates load instruction for identifier" do ast = {:identifier, "score"} result = InstructionsVisitor.visit(ast, []) - + assert result == [["load", "score"]] end test "generates load instruction for underscore identifier" do ast = {:identifier, "user_age"} result = InstructionsVisitor.visit(ast, []) - + assert result == [["load", "user_age"]] end end @@ -48,121 +48,121 @@ defmodule Predicator.InstructionsVisitorTest do test "generates instructions for greater than comparison" do ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} result = InstructionsVisitor.visit(ast, []) - + assert result == [ - ["load", "score"], - ["lit", 85], - ["compare", "GT"] - ] + ["load", "score"], + ["lit", 85], + ["compare", "GT"] + ] end test "generates instructions for less than comparison" do ast = {:comparison, :lt, {:identifier, "age"}, {:literal, 18}} result = InstructionsVisitor.visit(ast, []) - + assert result == [ - ["load", "age"], - ["lit", 18], - ["compare", "LT"] - ] + ["load", "age"], + ["lit", 18], + ["compare", "LT"] + ] end test "generates instructions for greater than or equal comparison" do ast = {:comparison, :gte, {:identifier, "score"}, {:literal, 85}} result = InstructionsVisitor.visit(ast, []) - + assert result == [ - ["load", "score"], - ["lit", 85], - ["compare", "GTE"] - ] + ["load", "score"], + ["lit", 85], + ["compare", "GTE"] + ] end test "generates instructions for less than or equal comparison" do ast = {:comparison, :lte, {:identifier, "age"}, {:literal, 65}} result = InstructionsVisitor.visit(ast, []) - + assert result == [ - ["load", "age"], - ["lit", 65], - ["compare", "LTE"] - ] + ["load", "age"], + ["lit", 65], + ["compare", "LTE"] + ] end test "generates instructions for equality comparison" do ast = {:comparison, :eq, {:identifier, "name"}, {:literal, "John"}} result = InstructionsVisitor.visit(ast, []) - + assert result == [ - ["load", "name"], - ["lit", "John"], - ["compare", "EQ"] - ] + ["load", "name"], + ["lit", "John"], + ["compare", "EQ"] + ] end test "generates instructions for not equal comparison" do ast = {:comparison, :ne, {:identifier, "status"}, {:literal, "inactive"}} result = InstructionsVisitor.visit(ast, []) - + assert result == [ - ["load", "status"], - ["lit", "inactive"], - ["compare", "NE"] - ] + ["load", "status"], + ["lit", "inactive"], + ["compare", "NE"] + ] end test "generates instructions with literal-to-literal comparison" do ast = {:comparison, :gt, {:literal, 10}, {:literal, 5}} result = InstructionsVisitor.visit(ast, []) - + assert result == [ - ["lit", 10], - ["lit", 5], - ["compare", "GT"] - ] + ["lit", 10], + ["lit", 5], + ["compare", "GT"] + ] end test "generates instructions with identifier-to-identifier comparison" do ast = {:comparison, :eq, {:identifier, "score"}, {:identifier, "threshold"}} result = InstructionsVisitor.visit(ast, []) - + assert result == [ - ["load", "score"], - ["load", "threshold"], - ["compare", "EQ"] - ] + ["load", "score"], + ["load", "threshold"], + ["compare", "EQ"] + ] end end describe "visit/2 - integration with full pipeline" do test "works with lexer and parser output" do alias Predicator.{Lexer, Parser} - + {:ok, tokens} = Lexer.tokenize("score > 85") {:ok, ast} = Parser.parse(tokens) - + result = InstructionsVisitor.visit(ast, []) - + assert result == [ - ["load", "score"], - ["lit", 85], - ["compare", "GT"] - ] + ["load", "score"], + ["lit", 85], + ["compare", "GT"] + ] end test "works with complex parenthesized expression" do alias Predicator.{Lexer, Parser} - + {:ok, tokens} = Lexer.tokenize("(age >= 18)") {:ok, ast} = Parser.parse(tokens) - + result = InstructionsVisitor.visit(ast, []) - + assert result == [ - ["load", "age"], - ["lit", 18], - ["compare", "GTE"] - ] + ["load", "age"], + ["lit", 18], + ["compare", "GTE"] + ] end end -end \ No newline at end of file +end diff --git a/test/predicator/integration_test.exs b/test/predicator/integration_test.exs index 5754d0e..56265e1 100644 --- a/test/predicator/integration_test.exs +++ b/test/predicator/integration_test.exs @@ -1,27 +1,28 @@ defmodule Predicator.IntegrationTest do use ExUnit.Case, async: true - alias Predicator.{Lexer, Parser, Compiler, Evaluator} + alias Predicator.{Compiler, Evaluator, Lexer, Parser} describe "full pipeline integration" do test "string -> tokens -> ast -> instructions -> evaluation" do input = "score > 85" context = %{"score" => 90} - + # Lex {:ok, tokens} = Lexer.tokenize(input) - + # Parse {:ok, ast} = Parser.parse(tokens) - + # Compile instructions = Compiler.to_instructions(ast) + assert instructions == [ - ["load", "score"], - ["lit", 85], - ["compare", "GT"] - ] - + ["load", "score"], + ["lit", 85], + ["compare", "GT"] + ] + # Evaluate result = Evaluator.evaluate!(instructions, context) assert result == true @@ -30,17 +31,17 @@ defmodule Predicator.IntegrationTest do test "complex expression with parentheses" do input = "(age >= 18)" context = %{"age" => 21} - + {:ok, tokens} = Lexer.tokenize(input) {:ok, ast} = Parser.parse(tokens) instructions = Compiler.to_instructions(ast) - + assert instructions == [ - ["load", "age"], - ["lit", 18], - ["compare", "GTE"] - ] - + ["load", "age"], + ["lit", 18], + ["compare", "GTE"] + ] + result = Evaluator.evaluate!(instructions, context) assert result == true end @@ -48,17 +49,17 @@ defmodule Predicator.IntegrationTest do test "string comparison" do input = "name = \"John\"" context = %{"name" => "John"} - + {:ok, tokens} = Lexer.tokenize(input) {:ok, ast} = Parser.parse(tokens) instructions = Compiler.to_instructions(ast) - + assert instructions == [ - ["load", "name"], - ["lit", "John"], - ["compare", "EQ"] - ] - + ["load", "name"], + ["lit", "John"], + ["compare", "EQ"] + ] + result = Evaluator.evaluate!(instructions, context) assert result == true end @@ -66,17 +67,17 @@ defmodule Predicator.IntegrationTest do test "boolean comparison" do input = "active = true" context = %{"active" => true} - + {:ok, tokens} = Lexer.tokenize(input) {:ok, ast} = Parser.parse(tokens) instructions = Compiler.to_instructions(ast) - + assert instructions == [ - ["load", "active"], - ["lit", true], - ["compare", "EQ"] - ] - + ["load", "active"], + ["lit", true], + ["compare", "EQ"] + ] + result = Evaluator.evaluate!(instructions, context) assert result == true end @@ -84,11 +85,11 @@ defmodule Predicator.IntegrationTest do test "not equal comparison evaluates to false" do input = "status != \"active\"" context = %{"status" => "active"} - + {:ok, tokens} = Lexer.tokenize(input) {:ok, ast} = Parser.parse(tokens) instructions = Compiler.to_instructions(ast) - + result = Evaluator.evaluate!(instructions, context) assert result == false end @@ -114,7 +115,7 @@ defmodule Predicator.IntegrationTest do {:ok, ast} = Parser.parse(tokens) instructions = Compiler.to_instructions(ast) result = Evaluator.evaluate!(instructions, context) - + assert result == expected, "Failed for input: #{input} with context: #{inspect(context)}" end end @@ -122,14 +123,14 @@ defmodule Predicator.IntegrationTest do test "handles missing context keys" do input = "missing_key > 5" context = %{} - + {:ok, tokens} = Lexer.tokenize(input) {:ok, ast} = Parser.parse(tokens) instructions = Compiler.to_instructions(ast) - - result = Evaluator.evaluate!(instructions, context) + + _result = Evaluator.evaluate!(instructions, context) result = Evaluator.evaluate(instructions, context) assert result == :undefined end end -end \ No newline at end of file +end diff --git a/test/predicator/lexer_test.exs b/test/predicator/lexer_test.exs index ae7ea0a..fe38162 100644 --- a/test/predicator/lexer_test.exs +++ b/test/predicator/lexer_test.exs @@ -119,7 +119,7 @@ defmodule Predicator.LexerTest do end test "tokenizes string with newline escape" do - # Input: "line1\nline2" (with escaped newline) + # Input: "line1\nline2" (with escaped newline) input = "\"line1\\nline2\"" assert {:ok, tokens} = Lexer.tokenize(input) diff --git a/test/predicator/parser_test.exs b/test/predicator/parser_test.exs index 343c0da..3887cbe 100644 --- a/test/predicator/parser_test.exs +++ b/test/predicator/parser_test.exs @@ -139,7 +139,7 @@ defmodule Predicator.ParserTest do end test "handles mixed types" do - {:ok, tokens} = Lexer.tokenize("\"apple\" > \"banana\"") + {:ok, tokens} = Lexer.tokenize(~s("apple" > "banana")) expected = {:comparison, :gt, {:literal, "apple"}, {:literal, "banana"}} assert Parser.parse(tokens) == {:ok, expected} @@ -245,4 +245,111 @@ defmodule Predicator.ParserTest do assert Parser.parse(tokens) == {:ok, expected} end end + + describe "parse/1 - additional error coverage" do + test "returns error when parentheses reach end of input without closing" do + # This creates tokens that end abruptly inside parentheses + tokens = [ + {:lparen, 1, 1, 1, "("}, + {:identifier, 1, 2, 5, "score"} + # Note: no closing paren and no EOF token to test nil case + ] + + result = Parser.parse(tokens) + assert {:error, "Expected ')' but reached end of input", 1, 1} = result + end + + test "handles nested error propagation from inner expressions" do + # Test error propagation through parentheses + {:ok, tokens} = Lexer.tokenize("(score > )") + + result = Parser.parse(tokens) + assert {:error, message, 1, 10} = result + assert message =~ "Expected number, string, boolean, identifier, or '(' but found ')'" + end + + test "handles comparison operator followed by EOF" do + tokens = [ + {:identifier, 1, 1, 5, "score"}, + {:gt, 1, 7, 1, ">"}, + {: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 unexpected token types in primary position" do + # Test different token types that would fail in primary position + test_cases = [ + {[:gt], "Expected number, string, boolean, identifier, or '(' but found '>'"}, + {[:lt], "Expected number, string, boolean, identifier, or '(' but found '<'"}, + {[:gte], "Expected number, string, boolean, identifier, or '(' but found '>='"}, + {[:lte], "Expected number, string, boolean, identifier, or '(' but found '<='"}, + {[:eq], "Expected number, string, boolean, identifier, or '(' but found '='"}, + {[:ne], "Expected number, string, boolean, identifier, or '(' but found '!='"} + ] + + for {token_types, expected_message} <- test_cases do + [token_type] = token_types + tokens = [{token_type, 1, 1, 1, to_string(token_type)}, {:eof, 1, 2, 0, nil}] + + result = Parser.parse(tokens) + assert {:error, ^expected_message, 1, 1} = result + end + end + + test "format_token function handles all token types correctly" do + # Test various invalid token placements to ensure format_token is exercised + + # Test operators in primary position (should be rejected) + operator_tokens = [ + {:gt, 1, 1, 1, ">"}, + {:lt, 1, 1, 1, "<"}, + {:gte, 1, 1, 2, ">="}, + {:lte, 1, 1, 2, "<="}, + {:eq, 1, 1, 1, "="}, + {:ne, 1, 1, 2, "!="} + ] + + for token <- operator_tokens do + tokens = [token, {:eof, 1, 3, 0, nil}] + result = Parser.parse(tokens) + assert {:error, _message, 1, 1} = result + end + + # Test parentheses and other tokens in wrong positions + other_tokens = [ + {:rparen, 1, 1, 1, ")"}, + {:eof, 1, 1, 0, nil} + ] + + for token <- other_tokens do + tokens = [token, {:eof, 1, 3, 0, nil}] + result = Parser.parse(tokens) + assert {:error, _message, 1, 1} = result + end + end + + test "handles rparen token in unexpected position" do + tokens = [ + {:rparen, 1, 1, 1, ")"}, + {:eof, 1, 2, 0, nil} + ] + + result = Parser.parse(tokens) + assert {:error, "Expected number, string, boolean, identifier, or '(' but found ')'", 1, 1} = result + end + + test "handles empty expression inside parentheses" do + tokens = [ + {:lparen, 1, 1, 1, "("}, + {:rparen, 1, 2, 1, ")"}, + {:eof, 1, 3, 0, nil} + ] + + result = Parser.parse(tokens) + assert {:error, "Expected number, string, boolean, identifier, or '(' but found ')'", 1, 2} = result + end + end end diff --git a/test/predicator_test.exs b/test/predicator_test.exs index ee39df1..6bcef6c 100644 --- a/test/predicator_test.exs +++ b/test/predicator_test.exs @@ -11,7 +11,7 @@ defmodule PredicatorTest do test "evaluates with different operators" do context = %{"x" => 10} - + assert Predicator.evaluate("x > 5", context) == true assert Predicator.evaluate("x < 5", context) == false assert Predicator.evaluate("x >= 10", context) == true @@ -22,14 +22,14 @@ defmodule PredicatorTest do test "evaluates string comparisons" do context = %{"name" => "John"} - + assert Predicator.evaluate("name = \"John\"", context) == true assert Predicator.evaluate("name != \"Jane\"", context) == true end test "evaluates boolean comparisons" do context = %{"active" => true} - + assert Predicator.evaluate("active = true", context) == true assert Predicator.evaluate("active != false", context) == true end @@ -52,7 +52,10 @@ defmodule PredicatorTest do test "returns error for parse failures" do result = Predicator.evaluate("score >", %{}) assert {:error, message} = result - assert message =~ "Expected number, string, boolean, identifier, or '(' but found end of input" + + assert message =~ + "Expected number, string, boolean, identifier, or '(' but found end of input" + assert message =~ "line 1, column 8" end @@ -114,13 +117,13 @@ defmodule PredicatorTest do describe "compile/1" do test "compiles simple expression" do {:ok, instructions} = Predicator.compile("score > 85") - + expected = [ ["load", "score"], ["lit", 85], ["compare", "GT"] ] - + assert instructions == expected end @@ -142,44 +145,47 @@ defmodule PredicatorTest do test "compiles string expressions" do {:ok, instructions} = Predicator.compile("name = \"John\"") - + expected = [ ["load", "name"], ["lit", "John"], ["compare", "EQ"] ] - + assert instructions == expected end test "compiles boolean expressions" do {:ok, instructions} = Predicator.compile("active = true") - + expected = [ ["load", "active"], ["lit", true], ["compare", "EQ"] ] - + assert instructions == expected end test "handles parentheses" do {:ok, instructions} = Predicator.compile("(score > 85)") - + expected = [ ["load", "score"], ["lit", 85], ["compare", "GT"] ] - + assert instructions == expected end test "returns error for invalid syntax" do result = Predicator.compile("score >") assert {:error, message} = result - assert message =~ "Expected number, string, boolean, identifier, or '(' but found end of input" + + assert message =~ + "Expected number, string, boolean, identifier, or '(' but found end of input" + assert message =~ "line 1, column 8" end end @@ -187,13 +193,13 @@ defmodule PredicatorTest do describe "compile!/1" do test "compiles successfully" do instructions = Predicator.compile!("score > 85") - + expected = [ ["load", "score"], ["lit", 85], ["compare", "GT"] ] - + assert instructions == expected end @@ -208,7 +214,7 @@ defmodule PredicatorTest do test "pre-compiled instructions are faster for repeated evaluation" do # Compile once {:ok, instructions} = Predicator.compile("score > 85") - + # Use many times with different contexts contexts = [ %{"score" => 90}, @@ -216,26 +222,28 @@ defmodule PredicatorTest do %{"score" => 95}, %{"score" => 70} ] - - results = Enum.map(contexts, fn context -> - Predicator.evaluate(instructions, context) - end) - + + results = + Enum.map(contexts, fn context -> + Predicator.evaluate(instructions, context) + end) + assert results == [true, false, true, false] end test "string expressions work but are slower due to compilation" do expression = "score > 85" - + contexts = [ %{"score" => 90}, %{"score" => 80} ] - - results = Enum.map(contexts, fn context -> - Predicator.evaluate(expression, context) - end) - + + results = + Enum.map(contexts, fn context -> + Predicator.evaluate(expression, context) + end) + assert results == [true, false] end end From 85646abb3674c8864cc534e1d9a5ded16ce74ab6 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Tue, 19 Aug 2025 09:32:58 -0600 Subject: [PATCH 5/5] Adds StringVisitor --- lib/predicator.ex | 37 +++ lib/predicator/compiler.ex | 39 ++- lib/predicator/lexer.ex | 5 + lib/predicator/parser.ex | 7 + lib/predicator/string_visitor.ex | 123 ++++++++ test/predicator/compiler_test.exs | 118 ++++++++ test/predicator/parser_test.exs | 31 +- test/predicator/string_visitor_test.exs | 379 ++++++++++++++++++++++++ test/predicator_test.exs | 58 ++++ 9 files changed, 784 insertions(+), 13 deletions(-) create mode 100644 lib/predicator/string_visitor.ex create mode 100644 test/predicator/string_visitor_test.exs diff --git a/lib/predicator.ex b/lib/predicator.ex index 1cc6cd8..6ab56e6 100644 --- a/lib/predicator.ex +++ b/lib/predicator.ex @@ -166,6 +166,43 @@ defmodule Predicator do end end + @doc """ + Converts an AST back to a string representation. + + This function takes an Abstract Syntax Tree and generates a readable string + representation. This is useful for debugging, displaying expressions to users, + and documentation purposes. + + ## Parameters + + - `ast` - The Abstract Syntax Tree to convert + - `opts` - Optional formatting options: + - `:parentheses` - `:minimal` (default) | `:explicit` | `:none` + - `:spacing` - `:normal` (default) | `:compact` | `:verbose` + + ## Returns + + String representation of the AST + + ## Examples + + iex> ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + iex> Predicator.decompile(ast) + "score > 85" + + iex> ast = {:literal, 42} + iex> Predicator.decompile(ast) + "42" + + iex> ast = {:comparison, :eq, {:identifier, "active"}, {:literal, true}} + iex> Predicator.decompile(ast, parentheses: :explicit, spacing: :verbose) + "(active = true)" + """ + @spec decompile(Parser.ast(), keyword()) :: binary() + def decompile(ast, opts \\ []) do + Compiler.to_string(ast, opts) + end + @doc """ Compiles a string expression to instruction list, raising on errors. diff --git a/lib/predicator/compiler.ex b/lib/predicator/compiler.ex index 07a20ab..1716fba 100644 --- a/lib/predicator/compiler.ex +++ b/lib/predicator/compiler.ex @@ -19,7 +19,7 @@ defmodule Predicator.Compiler do # "digraph {...}" """ - alias Predicator.{InstructionsVisitor, Parser, Visitor} + alias Predicator.{InstructionsVisitor, Parser, StringVisitor, Visitor} @doc """ Converts an AST to stack machine instructions. @@ -50,4 +50,41 @@ defmodule Predicator.Compiler do def to_instructions(ast, opts \\ []) do Visitor.accept(ast, InstructionsVisitor, opts) end + + @doc """ + Converts an AST to a string representation. + + Uses the StringVisitor to generate a readable string representation + of the Abstract Syntax Tree. This is useful for debugging, documentation, + and displaying expressions to users. + + ## Parameters + + - `ast` - The Abstract Syntax Tree to convert + - `opts` - Optional formatting options: + - `:parentheses` - `:minimal` (default) | `:explicit` | `:none` + - `:spacing` - `:normal` (default) | `:compact` | `:verbose` + + ## Returns + + String representation of the AST + + ## Examples + + iex> ast = {:literal, 42} + iex> Predicator.Compiler.to_string(ast) + "42" + + iex> ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + iex> Predicator.Compiler.to_string(ast) + "score > 85" + + iex> ast = {:comparison, :gt, {:identifier, "age"}, {:literal, 21}} + iex> Predicator.Compiler.to_string(ast, parentheses: :explicit) + "(age > 21)" + """ + @spec to_string(Parser.ast(), keyword()) :: binary() + def to_string(ast, opts \\ []) do + Visitor.accept(ast, StringVisitor, opts) + end end diff --git a/lib/predicator/lexer.ex b/lib/predicator/lexer.ex index da777bf..b32ca47 100644 --- a/lib/predicator/lexer.ex +++ b/lib/predicator/lexer.ex @@ -116,6 +116,11 @@ defmodule Predicator.Lexer do {:ok, Enum.reverse([{:eof, line, col, 0, nil} | tokens])} end + # NOTE: High cyclomatic complexity (28) is expected and appropriate for lexer functions. + # This function must handle all possible input characters and token types in a single + # pattern matching expression, which naturally results in high complexity but is the + # correct approach for lexical analysis. The complexity is well-contained and tested. + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defp tokenize_chars([char | rest], line, col, tokens) do case char do # Skip whitespace diff --git a/lib/predicator/parser.ex b/lib/predicator/parser.ex index 598e897..d00c7b7 100644 --- a/lib/predicator/parser.ex +++ b/lib/predicator/parser.ex @@ -118,8 +118,12 @@ defmodule Predicator.Parser do end # Parse comparison expressions + # NOTE: Nesting depth (3) is expected and appropriate for recursive descent parsing. + # The nested case statements handle: parse left operand -> check for operator -> + # parse right operand -> construct AST, with proper error propagation at each step. @spec parse_comparison(parser_state()) :: {:ok, ast(), parser_state()} | {:error, binary(), pos_integer(), pos_integer()} + # credo:disable-for-lines:27 Credo.Check.Refactor.Nesting defp parse_comparison(state) do case parse_primary(state) do {:ok, left, new_state} -> @@ -150,8 +154,11 @@ defmodule Predicator.Parser do end # Parse primary expressions (literals, identifiers, parentheses) + # This function handles multiple token types and nested error cases - inherent parser complexity @spec parse_primary(parser_state()) :: {:ok, ast(), parser_state()} | {:error, binary(), pos_integer(), pos_integer()} + # credo:disable-for-lines:46 Credo.Check.Refactor.CyclomaticComplexity + # credo:disable-for-lines:46 Credo.Check.Refactor.Nesting defp parse_primary(state) do case peek_token(state) do # Literals diff --git a/lib/predicator/string_visitor.ex b/lib/predicator/string_visitor.ex new file mode 100644 index 0000000..2d92892 --- /dev/null +++ b/lib/predicator/string_visitor.ex @@ -0,0 +1,123 @@ +defmodule Predicator.StringVisitor do + @moduledoc """ + Visitor that converts AST nodes back to string expressions. + + This visitor implements the inverse of parsing - it takes an Abstract Syntax Tree + and generates a readable string representation. This is useful for debugging, + documentation, and round-trip testing. + + ## Examples + + iex> ast = {:literal, 42} + iex> Predicator.StringVisitor.visit(ast, []) + "42" + + iex> ast = {:identifier, "score"} + iex> Predicator.StringVisitor.visit(ast, []) + "score" + + iex> ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + iex> Predicator.StringVisitor.visit(ast, []) + "score > 85" + + iex> ast = {:comparison, :eq, {:identifier, "name"}, {:literal, "John"}} + iex> Predicator.StringVisitor.visit(ast, []) + ~s(name = "John") + """ + + @behaviour Predicator.Visitor + + alias Predicator.Parser + + @doc """ + Visits an AST node and returns its string representation. + + ## Parameters + + - `ast_node` - The AST node to convert to a string + - `opts` - Optional visitor options: + - `:parentheses` - `:minimal` (default) | `:explicit` | `:none` + - `:spacing` - `:normal` (default) | `:compact` | `:verbose` + + ## Returns + + String representation of the AST node + + ## Options + + - `:parentheses` controls parentheses generation: + - `:minimal` - only add parentheses when necessary for precedence + - `:explicit` - add parentheses around all comparisons + - `:none` - never add parentheses (may change meaning!) + + - `:spacing` controls whitespace: + - `:normal` - standard spacing: "score > 85" + - `:compact` - minimal spacing: "score>85" + - `:verbose` - extra spacing: "score > 85" + """ + @impl Predicator.Visitor + @spec visit(Parser.ast(), keyword()) :: binary() + def visit(ast_node, opts \\ []) + + def visit({:literal, value}, _opts) when is_integer(value) do + Integer.to_string(value) + end + + def visit({:literal, value}, _opts) when is_boolean(value) do + Atom.to_string(value) + end + + def visit({:literal, value}, _opts) when is_binary(value) do + # Escape quotes and wrap in quotes + escaped = String.replace(value, "\"", "\\\"") + "\"#{escaped}\"" + end + + def visit({:literal, value}, opts) when is_list(value) do + # Handle list literals (future extension) + items = Enum.map(value, fn item -> visit({:literal, item}, opts) end) + "[#{Enum.join(items, ", ")}]" + end + + def visit({:identifier, name}, _opts) when is_binary(name) do + name + end + + def visit({:comparison, op, left, right}, opts) do + left_str = visit(left, opts) + right_str = visit(right, opts) + op_str = format_operator(op) + + spacing = get_spacing(opts) + + case get_parentheses_mode(opts) do + :explicit -> "(#{left_str}#{spacing}#{op_str}#{spacing}#{right_str})" + :none -> "#{left_str}#{spacing}#{op_str}#{spacing}#{right_str}" + :minimal -> "#{left_str}#{spacing}#{op_str}#{spacing}#{right_str}" + end + end + + # Helper functions + + @spec format_operator(Parser.comparison_op()) :: binary() + defp format_operator(:gt), do: ">" + defp format_operator(:lt), do: "<" + defp format_operator(:gte), do: ">=" + defp format_operator(:lte), do: "<=" + defp format_operator(:eq), do: "=" + defp format_operator(:ne), do: "!=" + + @spec get_spacing(keyword()) :: binary() + defp get_spacing(opts) do + case Keyword.get(opts, :spacing, :normal) do + :compact -> "" + :verbose -> " " + :normal -> " " + end + end + + @spec get_parentheses_mode(keyword()) :: :minimal | :explicit | :none + defp get_parentheses_mode(opts) do + Keyword.get(opts, :parentheses, :minimal) + end +end diff --git a/test/predicator/compiler_test.exs b/test/predicator/compiler_test.exs index 9f9f193..000074f 100644 --- a/test/predicator/compiler_test.exs +++ b/test/predicator/compiler_test.exs @@ -94,4 +94,122 @@ defmodule Predicator.CompilerTest do ] end end + + describe "to_string/2" do + test "converts literal to string" do + ast = {:literal, 42} + result = Compiler.to_string(ast) + + assert result == "42" + end + + test "converts identifier to string" do + ast = {:identifier, "score"} + result = Compiler.to_string(ast) + + assert result == "score" + end + + test "converts comparison to string" do + ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + result = Compiler.to_string(ast) + + assert result == "score > 85" + end + + test "works with all comparison operators" do + operators_map = %{ + :gt => ">", + :lt => "<", + :gte => ">=", + :lte => "<=", + :eq => "=", + :ne => "!=" + } + + for {ast_op, string_op} <- operators_map do + ast = {:comparison, ast_op, {:identifier, "x"}, {:literal, 5}} + result = Compiler.to_string(ast) + + assert result == "x #{string_op} 5" + end + end + + test "converts with formatting options" do + ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + + # Test different spacing + assert Compiler.to_string(ast, spacing: :normal) == "score > 85" + assert Compiler.to_string(ast, spacing: :compact) == "score>85" + assert Compiler.to_string(ast, spacing: :verbose) == "score > 85" + + # Test different parentheses + assert Compiler.to_string(ast, parentheses: :minimal) == "score > 85" + assert Compiler.to_string(ast, parentheses: :explicit) == "(score > 85)" + assert Compiler.to_string(ast, parentheses: :none) == "score > 85" + end + + test "converts string literals correctly" do + ast = {:comparison, :eq, {:identifier, "name"}, {:literal, "John"}} + result = Compiler.to_string(ast) + + assert result == ~s(name = "John") + end + + test "converts boolean literals correctly" do + ast = {:comparison, :ne, {:identifier, "active"}, {:literal, true}} + result = Compiler.to_string(ast) + + assert result == "active != true" + end + + test "converts with opts parameter" do + ast = {:literal, 42} + result = Compiler.to_string(ast, spacing: :compact) + + assert result == "42" + end + end + + describe "round-trip compilation" do + test "string -> AST -> string produces equivalent result" do + alias Predicator.{Lexer, Parser} + + original_expressions = [ + "score > 85", + "age >= 18", + ~s(name = "John"), + "active != true", + "count <= 100", + "status = \"active\"" + ] + + for original <- original_expressions do + {:ok, tokens} = Lexer.tokenize(original) + {:ok, ast} = Parser.parse(tokens) + + # Convert back to string + result = Compiler.to_string(ast) + + # Should be equivalent (may have normalized spacing) + assert result == original, "Round-trip failed for: #{original}" + end + end + + test "AST -> instructions -> evaluation works with string representation" do + alias Predicator.Evaluator + + ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + context = %{"score" => 90} + + # Convert to instructions and evaluate + instructions = Compiler.to_instructions(ast) + result = Evaluator.evaluate!(instructions, context) + assert result == true + + # Convert to string for debugging/display + string_repr = Compiler.to_string(ast) + assert string_repr == "score > 85" + end + end end diff --git a/test/predicator/parser_test.exs b/test/predicator/parser_test.exs index 3887cbe..512d9b7 100644 --- a/test/predicator/parser_test.exs +++ b/test/predicator/parser_test.exs @@ -254,7 +254,7 @@ defmodule Predicator.ParserTest do {:identifier, 1, 2, 5, "score"} # Note: no closing paren and no EOF token to test nil case ] - + result = Parser.parse(tokens) assert {:error, "Expected ')' but reached end of input", 1, 1} = result end @@ -262,7 +262,7 @@ defmodule Predicator.ParserTest do test "handles nested error propagation from inner expressions" do # Test error propagation through parentheses {:ok, tokens} = Lexer.tokenize("(score > )") - + result = Parser.parse(tokens) assert {:error, message, 1, 10} = result assert message =~ "Expected number, string, boolean, identifier, or '(' but found ')'" @@ -274,9 +274,12 @@ defmodule Predicator.ParserTest do {:gt, 1, 7, 1, ">"}, {: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 + + assert {:error, + "Expected number, string, boolean, identifier, or '(' but found end of input", 1, + 8} = result end test "handles unexpected token types in primary position" do @@ -293,7 +296,7 @@ defmodule Predicator.ParserTest do for {token_types, expected_message} <- test_cases do [token_type] = token_types tokens = [{token_type, 1, 1, 1, to_string(token_type)}, {:eof, 1, 2, 0, nil}] - + result = Parser.parse(tokens) assert {:error, ^expected_message, 1, 1} = result end @@ -301,7 +304,7 @@ defmodule Predicator.ParserTest do test "format_token function handles all token types correctly" do # Test various invalid token placements to ensure format_token is exercised - + # Test operators in primary position (should be rejected) operator_tokens = [ {:gt, 1, 1, 1, ">"}, @@ -317,13 +320,13 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) assert {:error, _message, 1, 1} = result end - + # Test parentheses and other tokens in wrong positions other_tokens = [ {:rparen, 1, 1, 1, ")"}, {:eof, 1, 1, 0, nil} ] - + for token <- other_tokens do tokens = [token, {:eof, 1, 3, 0, nil}] result = Parser.parse(tokens) @@ -336,9 +339,11 @@ defmodule Predicator.ParserTest do {:rparen, 1, 1, 1, ")"}, {:eof, 1, 2, 0, nil} ] - + result = Parser.parse(tokens) - assert {:error, "Expected number, string, boolean, identifier, or '(' but found ')'", 1, 1} = result + + assert {:error, "Expected number, string, boolean, identifier, or '(' but found ')'", 1, 1} = + result end test "handles empty expression inside parentheses" do @@ -347,9 +352,11 @@ defmodule Predicator.ParserTest do {:rparen, 1, 2, 1, ")"}, {:eof, 1, 3, 0, nil} ] - + result = Parser.parse(tokens) - assert {:error, "Expected number, string, boolean, identifier, or '(' but found ')'", 1, 2} = result + + assert {:error, "Expected number, string, boolean, identifier, or '(' but found ')'", 1, 2} = + result end end end diff --git a/test/predicator/string_visitor_test.exs b/test/predicator/string_visitor_test.exs new file mode 100644 index 0000000..a43ea13 --- /dev/null +++ b/test/predicator/string_visitor_test.exs @@ -0,0 +1,379 @@ +defmodule Predicator.StringVisitorTest do + use ExUnit.Case, async: true + + alias Predicator.StringVisitor + + doctest Predicator.StringVisitor + + describe "visit/2 - literal nodes" do + test "converts integer literal to string" do + ast = {:literal, 42} + result = StringVisitor.visit(ast, []) + + assert result == "42" + end + + test "converts negative integer literal to string" do + ast = {:literal, -15} + result = StringVisitor.visit(ast, []) + + assert result == "-15" + end + + test "converts zero to string" do + ast = {:literal, 0} + result = StringVisitor.visit(ast, []) + + assert result == "0" + end + + test "converts boolean true literal to string" do + ast = {:literal, true} + result = StringVisitor.visit(ast, []) + + assert result == "true" + end + + test "converts boolean false literal to string" do + ast = {:literal, false} + result = StringVisitor.visit(ast, []) + + assert result == "false" + end + + test "converts string literal with quotes" do + ast = {:literal, "hello"} + result = StringVisitor.visit(ast, []) + + assert result == ~s("hello") + end + + test "converts empty string literal" do + ast = {:literal, ""} + result = StringVisitor.visit(ast, []) + + assert result == ~s("") + end + + test "converts string with escaped quotes" do + ast = {:literal, "hello \"world\""} + result = StringVisitor.visit(ast, []) + + assert result == ~s("hello \\"world\\"") + end + + test "converts string with special characters" do + ast = {:literal, "line1\nline2\ttab"} + result = StringVisitor.visit(ast, []) + + assert result == "\"line1\nline2\ttab\"" + end + + test "converts list literal" do + ast = {:literal, [1, 2, 3]} + result = StringVisitor.visit(ast, []) + + assert result == "[1, 2, 3]" + end + + test "converts mixed type list literal" do + ast = {:literal, [1, "hello", true]} + result = StringVisitor.visit(ast, []) + + assert result == ~s([1, "hello", true]) + end + + test "converts empty list literal" do + ast = {:literal, []} + result = StringVisitor.visit(ast, []) + + assert result == "[]" + end + end + + describe "visit/2 - identifier nodes" do + test "converts simple identifier" do + ast = {:identifier, "score"} + result = StringVisitor.visit(ast, []) + + assert result == "score" + end + + test "converts identifier with underscores" do + ast = {:identifier, "user_age"} + result = StringVisitor.visit(ast, []) + + assert result == "user_age" + end + + test "converts identifier with numbers" do + ast = {:identifier, "var123"} + result = StringVisitor.visit(ast, []) + + assert result == "var123" + end + end + + describe "visit/2 - comparison nodes" do + test "converts greater than comparison" do + ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + result = StringVisitor.visit(ast, []) + + assert result == "score > 85" + end + + test "converts less than comparison" do + ast = {:comparison, :lt, {:identifier, "age"}, {:literal, 18}} + result = StringVisitor.visit(ast, []) + + assert result == "age < 18" + end + + test "converts greater than or equal comparison" do + ast = {:comparison, :gte, {:identifier, "score"}, {:literal, 85}} + result = StringVisitor.visit(ast, []) + + assert result == "score >= 85" + end + + test "converts less than or equal comparison" do + ast = {:comparison, :lte, {:identifier, "age"}, {:literal, 65}} + result = StringVisitor.visit(ast, []) + + assert result == "age <= 65" + end + + test "converts equality comparison" do + ast = {:comparison, :eq, {:identifier, "name"}, {:literal, "John"}} + result = StringVisitor.visit(ast, []) + + assert result == ~s(name = "John") + end + + test "converts not equal comparison" do + ast = {:comparison, :ne, {:identifier, "status"}, {:literal, "inactive"}} + result = StringVisitor.visit(ast, []) + + assert result == ~s(status != "inactive") + end + + test "converts literal-to-literal comparison" do + ast = {:comparison, :gt, {:literal, 10}, {:literal, 5}} + result = StringVisitor.visit(ast, []) + + assert result == "10 > 5" + end + + test "converts identifier-to-identifier comparison" do + ast = {:comparison, :eq, {:identifier, "score"}, {:identifier, "threshold"}} + result = StringVisitor.visit(ast, []) + + assert result == "score = threshold" + end + + test "converts boolean comparisons" do + ast = {:comparison, :eq, {:identifier, "active"}, {:literal, true}} + result = StringVisitor.visit(ast, []) + + assert result == "active = true" + end + end + + describe "visit/2 - spacing options" do + test "normal spacing (default)" do + ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + result = StringVisitor.visit(ast, spacing: :normal) + + assert result == "score > 85" + end + + test "compact spacing" do + ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + result = StringVisitor.visit(ast, spacing: :compact) + + assert result == "score>85" + end + + test "verbose spacing" do + ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + result = StringVisitor.visit(ast, spacing: :verbose) + + assert result == "score > 85" + end + + test "spacing affects all operators" do + operators_and_expected = [ + {:gt, "score > 85"}, + {:lt, "score < 85"}, + {:gte, "score >= 85"}, + {:lte, "score <= 85"}, + {:eq, "score = 85"}, + {:ne, "score != 85"} + ] + + for {op, expected} <- operators_and_expected do + ast = {:comparison, op, {:identifier, "score"}, {:literal, 85}} + result = StringVisitor.visit(ast, spacing: :verbose) + assert result == expected + end + end + end + + describe "visit/2 - parentheses options" do + test "minimal parentheses (default)" do + ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + result = StringVisitor.visit(ast, parentheses: :minimal) + + assert result == "score > 85" + end + + test "explicit parentheses" do + ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + result = StringVisitor.visit(ast, parentheses: :explicit) + + assert result == "(score > 85)" + end + + test "no parentheses" do + ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + result = StringVisitor.visit(ast, parentheses: :none) + + assert result == "score > 85" + end + end + + describe "visit/2 - combined options" do + test "explicit parentheses with compact spacing" do + ast = {:comparison, :gte, {:identifier, "age"}, {:literal, 18}} + result = StringVisitor.visit(ast, parentheses: :explicit, spacing: :compact) + + assert result == "(age>=18)" + end + + test "verbose spacing with explicit parentheses" do + ast = {:comparison, :ne, {:identifier, "name"}, {:literal, "test"}} + result = StringVisitor.visit(ast, parentheses: :explicit, spacing: :verbose) + + assert result == "(name != \"test\")" + end + end + + describe "visit/2 - integration with parser output" do + test "round-trip with simple expression" do + alias Predicator.{Lexer, Parser} + + original = "score > 85" + {:ok, tokens} = Lexer.tokenize(original) + {:ok, ast} = Parser.parse(tokens) + + result = StringVisitor.visit(ast, []) + + assert result == original + end + + test "round-trip with string comparison" do + alias Predicator.{Lexer, Parser} + + original = ~s(name = "John") + {:ok, tokens} = Lexer.tokenize(original) + {:ok, ast} = Parser.parse(tokens) + + result = StringVisitor.visit(ast, []) + + assert result == original + end + + test "round-trip with boolean comparison" do + alias Predicator.{Lexer, Parser} + + original = "active = true" + {:ok, tokens} = Lexer.tokenize(original) + {:ok, ast} = Parser.parse(tokens) + + result = StringVisitor.visit(ast, []) + + assert result == original + end + + test "round-trip with all comparison operators" do + alias Predicator.{Lexer, Parser} + + expressions = [ + "x > 5", + "x < 5", + "x >= 5", + "x <= 5", + "x = 5", + "x != 5" + ] + + for original <- expressions do + {:ok, tokens} = Lexer.tokenize(original) + {:ok, ast} = Parser.parse(tokens) + result = StringVisitor.visit(ast, []) + + assert result == original, "Failed round-trip for: #{original}" + end + end + + test "handles parenthesized expressions" do + alias Predicator.{Lexer, Parser} + + # Note: Parser removes unnecessary parentheses from AST + original = "(score > 85)" + {:ok, tokens} = Lexer.tokenize(original) + {:ok, ast} = Parser.parse(tokens) + + result = StringVisitor.visit(ast, []) + # Parentheses are removed by parser since they're not needed + assert result == "score > 85" + + # But we can add them back with explicit mode + result_explicit = StringVisitor.visit(ast, parentheses: :explicit) + assert result_explicit == "(score > 85)" + end + + test "handles complex expressions with whitespace normalization" do + alias Predicator.{Lexer, Parser} + + original_with_extra_spaces = " score > 85 " + {:ok, tokens} = Lexer.tokenize(original_with_extra_spaces) + {:ok, ast} = Parser.parse(tokens) + + result = StringVisitor.visit(ast, []) + + # StringVisitor normalizes spacing + assert result == "score > 85" + end + end + + describe "visit/2 - edge cases" do + test "handles strings with quotes that need escaping" do + ast = {:comparison, :eq, {:identifier, "message"}, {:literal, ~s(He said "hello")}} + result = StringVisitor.visit(ast, []) + + assert result == ~s(message = "He said \\"hello\\"") + end + + test "handles empty string comparisons" do + ast = {:comparison, :ne, {:identifier, "name"}, {:literal, ""}} + result = StringVisitor.visit(ast, []) + + assert result == ~s(name != "") + end + + test "handles zero comparisons" do + ast = {:comparison, :gt, {:identifier, "count"}, {:literal, 0}} + result = StringVisitor.visit(ast, []) + + assert result == "count > 0" + end + + test "handles negative number comparisons" do + ast = {:comparison, :lt, {:identifier, "temp"}, {:literal, -10}} + result = StringVisitor.visit(ast, []) + + assert result == "temp < -10" + end + end +end diff --git a/test/predicator_test.exs b/test/predicator_test.exs index 6bcef6c..41d8e76 100644 --- a/test/predicator_test.exs +++ b/test/predicator_test.exs @@ -248,6 +248,64 @@ defmodule PredicatorTest do end end + describe "decompile/2" do + test "converts AST back to string" do + ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}} + result = Predicator.decompile(ast) + + assert result == "score > 85" + end + + test "converts literal AST" do + ast = {:literal, 42} + result = Predicator.decompile(ast) + + assert result == "42" + end + + test "converts identifier AST" do + ast = {:identifier, "name"} + result = Predicator.decompile(ast) + + assert result == "name" + end + + test "works with formatting options" do + ast = {:comparison, :eq, {:identifier, "active"}, {:literal, true}} + + # Test spacing options + assert Predicator.decompile(ast, spacing: :normal) == "active = true" + assert Predicator.decompile(ast, spacing: :compact) == "active=true" + assert Predicator.decompile(ast, spacing: :verbose) == "active = true" + + # Test parentheses options + assert Predicator.decompile(ast, parentheses: :minimal) == "active = true" + assert Predicator.decompile(ast, parentheses: :explicit) == "(active = true)" + assert Predicator.decompile(ast, parentheses: :none) == "active = true" + end + + test "handles string literals correctly" do + ast = {:comparison, :ne, {:identifier, "name"}, {:literal, "test"}} + result = Predicator.decompile(ast) + + assert result == ~s(name != "test") + end + + test "round-trip with compile" do + # Test compile -> decompile round trip + original = "score >= 75" + {:ok, _instructions} = Predicator.compile(original) + + # We can't directly get AST from instructions, but we can test with parser + alias Predicator.{Lexer, Parser} + {:ok, tokens} = Lexer.tokenize(original) + {:ok, ast} = Parser.parse(tokens) + + decompiled = Predicator.decompile(ast) + assert decompiled == original + end + end + describe "edge cases" do test "empty context works with literals" do result = Predicator.evaluate("5 > 3", %{})