diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3965af..067dc11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,32 +158,47 @@ jobs: dialyzer: name: Static Type Analysis (Dialyzer) runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Elixir - uses: erlef/setup-beam@v1 - with: - elixir-version: '1.17' - otp-version: '27' - - - name: Cache dependencies and PLTs - uses: actions/cache@v4 - with: - path: | - deps - _build - priv/plts - key: deps-plt-${{ runner.os }}-27-1.17-${{ hashFiles('**/mix.lock') }} - restore-keys: | - deps-plt-${{ runner.os }}-27-1.17- + needs: compile - - name: Install dependencies - run: mix deps.get - - - name: Run Dialyzer - run: mix dialyzer + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: '1.18.3' + otp-version: '27.3' + + - name: Cache deps + uses: actions/cache@v4 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-27.3-1.18.3-${{ hashFiles('**/mix.lock') }} + restore-keys: | + deps-${{ runner.os }}-27.3-1.18.3- + + - name: Cache Dialyzer PLTs + uses: actions/cache@v4 + with: + path: priv/plts + key: dialyzer-${{ runner.os }}-27.3-1.18.3-${{ hashFiles('**/mix.lock') }} + restore-keys: | + dialyzer-${{ runner.os }}-27.3-1.18.3- + + - name: Install dependencies + run: mix deps.get + + - name: Compile dependencies + run: mix deps.compile + + - name: Compile project + run: mix compile + + - name: Run Dialyzer + run: mix dialyzer quality: name: Quality Gate diff --git a/CLAUDE.md b/CLAUDE.md index 17eb82e..e2eae1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -416,3 +416,9 @@ test/predicator/ - Elixir ~> 1.11 required - All dependencies in development/test only - No runtime dependencies for core functionality + +- When creating git commit messages: + - be concise but informative, and highlight the functional changes + - no need to mention code quality improvements as they are expected (unless the functional change is about code quality improvements) + - commit titles should be less than 50 characters and be in the simple present tense (active voice) + - commit descriptions should wrap at about 72 characters and also be in the simple present tense (active voice) diff --git a/lib/predicator/evaluator.ex b/lib/predicator/evaluator.ex index aa897cc..d704f29 100644 --- a/lib/predicator/evaluator.ex +++ b/lib/predicator/evaluator.ex @@ -226,7 +226,7 @@ defmodule Predicator.Evaluator do # Comparison instruction defp execute_instruction(%__MODULE__{} = evaluator, ["compare", operator]) - when operator in ["GT", "LT", "EQ", "GTE", "LTE", "NE"] do + when operator in ["GT", "LT", "EQ", "GTE", "LTE", "NE", "STRICT_EQ", "STRICT_NE"] do execute_compare(evaluator, operator) end @@ -362,8 +362,18 @@ defmodule Predicator.Evaluator do (is_map(a) and is_map(b) and not is_struct(a) and not is_struct(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 + # Handle undefined values for non-strict operators + defp compare_values(:undefined, _right, operator) + when operator not in ["STRICT_EQ", "STRICT_NE"], + do: :undefined + + defp compare_values(_left, :undefined, operator) + when operator not in ["STRICT_EQ", "STRICT_NE"], + do: :undefined + + # Strict equality works on all types, including :undefined + defp compare_values(left, right, "STRICT_EQ"), do: left === right + defp compare_values(left, right, "STRICT_NE"), do: left !== right defp compare_values(left, right, operator) when types_match(left, right) do case operator do diff --git a/lib/predicator/lexer.ex b/lib/predicator/lexer.ex index d5878af..0a136f6 100644 --- a/lib/predicator/lexer.ex +++ b/lib/predicator/lexer.ex @@ -52,6 +52,8 @@ defmodule Predicator.Lexer do | {:eq, pos_integer(), pos_integer(), pos_integer(), binary()} | {:ne, pos_integer(), pos_integer(), pos_integer(), binary()} | {:equal_equal, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:strict_equal, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:strict_ne, pos_integer(), pos_integer(), pos_integer(), binary()} | {:plus, pos_integer(), pos_integer(), pos_integer(), binary()} | {:minus, pos_integer(), pos_integer(), pos_integer(), binary()} | {:multiply, pos_integer(), pos_integer(), pos_integer(), binary()} @@ -251,6 +253,10 @@ defmodule Predicator.Lexer do ?! -> case rest do + [?=, ?= | rest3] -> + token = {:strict_ne, line, col, 3, "!=="} + tokenize_chars(rest3, line, col + 3, [token | tokens]) + [?= | rest2] -> token = {:ne, line, col, 2, "!="} tokenize_chars(rest2, line, col + 2, [token | tokens]) @@ -262,6 +268,10 @@ defmodule Predicator.Lexer do ?= -> case rest do + [?=, ?= | rest3] -> + token = {:strict_equal, line, col, 3, "==="} + tokenize_chars(rest3, line, col + 3, [token | tokens]) + [?= | rest2] -> token = {:equal_equal, line, col, 2, "=="} tokenize_chars(rest2, line, col + 2, [token | tokens]) diff --git a/lib/predicator/parser.ex b/lib/predicator/parser.ex index 6224571..be73859 100644 --- a/lib/predicator/parser.ex +++ b/lib/predicator/parser.ex @@ -100,7 +100,8 @@ defmodule Predicator.Parser do @typedoc """ Comparison operators in the AST. """ - @type comparison_op :: :gt | :lt | :gte | :lte | :eq | :ne + @type comparison_op :: + :gt | :lt | :gte | :lte | :eq | :equal_equal | :ne | :strict_eq | :strict_ne @typedoc """ Arithmetic operators in the AST. @@ -330,13 +331,30 @@ defmodule Predicator.Parser do case peek_token(new_state) do # Comparison operators (including equality) {op_type, _line, _col, _len, _value} - when op_type in [:gt, :lt, :gte, :lte, :eq, :equal_equal, :ne] -> + when op_type in [ + :gt, + :lt, + :gte, + :lte, + :eq, + :equal_equal, + :ne, + :strict_equal, + :strict_ne + ] -> op_state = advance(new_state) case parse_addition(op_state) do {:ok, right, final_state} -> - # Map == to :eq for consistency, != stays as :ne - normalized_op = if op_type == :equal_equal, do: :eq, else: op_type + # Map tokens to AST operators + normalized_op = + case op_type do + :equal_equal -> :equal_equal + :strict_equal -> :strict_eq + :strict_ne -> :strict_ne + _other_op_type -> op_type + end + ast = {:comparison, normalized_op, left, right} {:ok, ast, final_state} @@ -724,6 +742,9 @@ defmodule Predicator.Parser do defp format_token(:lte, _value), do: "'<='" defp format_token(:eq, _value), do: "'='" defp format_token(:ne, _value), do: "'!='" + defp format_token(:equal_equal, _value), do: "'=='" + defp format_token(:strict_equal, _value), do: "'==='" + defp format_token(:strict_ne, _value), do: "'!=='" defp format_token(:and_op, _value), do: "'AND'" defp format_token(:or_op, _value), do: "'OR'" defp format_token(:not_op, _value), do: "'NOT'" @@ -742,7 +763,6 @@ defmodule Predicator.Parser do defp format_token(:multiply, _value), do: "'*'" defp format_token(:divide, _value), do: "'/'" defp format_token(:modulo, _value), do: "'%'" - defp format_token(:equal_equal, _value), do: "'=='" defp format_token(:and_and, _value), do: "'&&'" defp format_token(:or_or, _value), do: "'||'" defp format_token(:bang, _value), do: "'!'" diff --git a/lib/predicator/visitors/instructions_visitor.ex b/lib/predicator/visitors/instructions_visitor.ex index 8522b39..c0819f5 100644 --- a/lib/predicator/visitors/instructions_visitor.ex +++ b/lib/predicator/visitors/instructions_visitor.ex @@ -193,7 +193,10 @@ defmodule Predicator.Visitors.InstructionsVisitor do 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(:equal_equal), do: "EQ" defp map_comparison_op(:ne), do: "NE" + defp map_comparison_op(:strict_eq), do: "STRICT_EQ" + defp map_comparison_op(:strict_ne), do: "STRICT_NE" # Helper function to map AST arithmetic operators to instruction format @spec map_arithmetic_op(Parser.arithmetic_op()) :: binary() diff --git a/lib/predicator/visitors/string_visitor.ex b/lib/predicator/visitors/string_visitor.ex index 2e17238..3b4eecb 100644 --- a/lib/predicator/visitors/string_visitor.ex +++ b/lib/predicator/visitors/string_visitor.ex @@ -241,7 +241,10 @@ defmodule Predicator.Visitors.StringVisitor do defp format_operator(:gte), do: ">=" defp format_operator(:lte), do: "<=" defp format_operator(:eq), do: "=" + defp format_operator(:equal_equal), do: "==" defp format_operator(:ne), do: "!=" + defp format_operator(:strict_eq), do: "===" + defp format_operator(:strict_ne), do: "!==" defp format_operator(:add), do: "+" defp format_operator(:subtract), do: "-" defp format_operator(:multiply), do: "*" diff --git a/test/predicator/parser_test.exs b/test/predicator/parser_test.exs index 9acf99c..4e43ae8 100644 --- a/test/predicator/parser_test.exs +++ b/test/predicator/parser_test.exs @@ -1065,7 +1065,7 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) expected_ast = - {:comparison, :eq, {:arithmetic, :add, {:identifier, "a"}, {:identifier, "b"}}, + {:comparison, :equal_equal, {:arithmetic, :add, {:identifier, "a"}, {:identifier, "b"}}, {:arithmetic, :multiply, {:identifier, "c"}, {:identifier, "d"}}} assert {:ok, ^expected_ast} = result diff --git a/test/predicator/strict_equality_test.exs b/test/predicator/strict_equality_test.exs new file mode 100644 index 0000000..01b8903 --- /dev/null +++ b/test/predicator/strict_equality_test.exs @@ -0,0 +1,246 @@ +defmodule Predicator.StrictEqualityTest do + @moduledoc """ + Comprehensive tests for strict equality operators (=== and !==). + """ + + use ExUnit.Case + + describe "lexer tokenization" do + test "tokenizes === as strict_equal" do + {:ok, tokens} = Predicator.Lexer.tokenize("a === b") + + assert tokens == [ + {:identifier, 1, 1, 1, "a"}, + {:strict_equal, 1, 3, 3, "==="}, + {:identifier, 1, 7, 1, "b"}, + {:eof, 1, 8, 0, nil} + ] + end + + test "tokenizes !== as strict_ne" do + {:ok, tokens} = Predicator.Lexer.tokenize("x !== y") + + assert tokens == [ + {:identifier, 1, 1, 1, "x"}, + {:strict_ne, 1, 3, 3, "!=="}, + {:identifier, 1, 7, 1, "y"}, + {:eof, 1, 8, 0, nil} + ] + end + + test "distinguishes === from == and =" do + {:ok, tokens} = Predicator.Lexer.tokenize("a = b == c === d") + + assert tokens == [ + {:identifier, 1, 1, 1, "a"}, + {:eq, 1, 3, 1, "="}, + {:identifier, 1, 5, 1, "b"}, + {:equal_equal, 1, 7, 2, "=="}, + {:identifier, 1, 10, 1, "c"}, + {:strict_equal, 1, 12, 3, "==="}, + {:identifier, 1, 16, 1, "d"}, + {:eof, 1, 17, 0, nil} + ] + end + + test "distinguishes !== from !=" do + {:ok, tokens} = Predicator.Lexer.tokenize("a != b !== c") + + assert tokens == [ + {:identifier, 1, 1, 1, "a"}, + {:ne, 1, 3, 2, "!="}, + {:identifier, 1, 6, 1, "b"}, + {:strict_ne, 1, 8, 3, "!=="}, + {:identifier, 1, 12, 1, "c"}, + {:eof, 1, 13, 0, nil} + ] + end + + test "handles strict operators in complex expressions" do + {:ok, tokens} = Predicator.Lexer.tokenize("(x === 1) AND (y !== 'text')") + + assert Enum.any?(tokens, fn token -> match?({:strict_equal, _, _, _, _}, token) end) + assert Enum.any?(tokens, fn token -> match?({:strict_ne, _, _, _, _}, token) end) + end + end + + describe "parser AST generation" do + test "parses === as strict_eq comparison" do + {:ok, ast} = Predicator.parse("value === 42") + + assert ast == {:comparison, :strict_eq, {:identifier, "value"}, {:literal, 42}} + end + + test "parses !== as strict_ne comparison" do + {:ok, ast} = Predicator.parse("name !== 'John'") + + assert ast == + {:comparison, :strict_ne, {:identifier, "name"}, + {:string_literal, "John", :single}} + end + + test "handles mixed equality operators with correct precedence" do + # Test that different operators can be used in separate comparisons with logical operators + {:ok, ast} = Predicator.parse("(a === b) AND (c != d)") + + assert match?( + {:logical_and, {:comparison, :strict_eq, _, _}, {:comparison, :ne, _, _}}, + ast + ) + end + + test "parses complex expressions with strict operators" do + {:ok, ast} = Predicator.parse("(x === 1) AND (y !== 'test')") + + assert match?( + {:logical_and, {:comparison, :strict_eq, _, _}, {:comparison, :strict_ne, _, _}}, + ast + ) + end + end + + describe "instruction compilation" do + test "compiles === to STRICT_EQ instruction" do + {:ok, instructions} = Predicator.compile("value === 42") + + assert instructions == [ + ["load", "value"], + ["lit", 42], + ["compare", "STRICT_EQ"] + ] + end + + test "compiles !== to STRICT_NE instruction" do + {:ok, instructions} = Predicator.compile("name !== 'test'") + + assert instructions == [ + ["load", "name"], + ["lit", "test"], + ["compare", "STRICT_NE"] + ] + end + end + + describe "evaluation behavior" do + test "=== performs strict equality (same type and value)" do + # Same type and value - true + assert {:ok, true} = Predicator.evaluate("5 === 5", %{}) + assert {:ok, true} = Predicator.evaluate("'hello' === 'hello'", %{}) + assert {:ok, true} = Predicator.evaluate("true === true", %{}) + + # Different types - false + assert {:ok, false} = Predicator.evaluate("5 === '5'", %{}) + assert {:ok, false} = Predicator.evaluate("1 === true", %{}) + assert {:ok, false} = Predicator.evaluate("0 === false", %{}) + end + + test "!== performs strict inequality" do + # Different types - true + assert {:ok, true} = Predicator.evaluate("5 !== '5'", %{}) + assert {:ok, true} = Predicator.evaluate("1 !== true", %{}) + assert {:ok, true} = Predicator.evaluate("0 !== false", %{}) + + # Same type and value - false + assert {:ok, false} = Predicator.evaluate("5 !== 5", %{}) + assert {:ok, false} = Predicator.evaluate("'hello' !== 'hello'", %{}) + assert {:ok, false} = Predicator.evaluate("true !== true", %{}) + end + + test "=== vs == behavior differences" do + # Test with different value types + # Both should be true for same values + # Same value, same type + assert {:ok, true} = Predicator.evaluate("1 == 1", %{}) + # Same value, same type + assert {:ok, true} = Predicator.evaluate("1 === 1", %{}) + + # Same type comparisons + assert {:ok, true} = Predicator.evaluate("1 == 1", %{}) + assert {:ok, true} = Predicator.evaluate("1 === 1", %{}) + end + + test "!== vs != behavior differences" do + # Different types should behave differently + # Same value, same type + assert {:ok, false} = Predicator.evaluate("1 != 1", %{}) + # Same value, same type + assert {:ok, false} = Predicator.evaluate("1 !== 1", %{}) + + # Different values + assert {:ok, true} = Predicator.evaluate("1 != 2", %{}) + # Different values + assert {:ok, true} = Predicator.evaluate("1 !== 2", %{}) + end + + test "evaluates with context variables" do + context = %{"num" => 42, "str" => "42", "flag" => true} + + assert {:ok, true} = Predicator.evaluate("num === 42", context) + assert {:ok, false} = Predicator.evaluate("num === str", context) + assert {:ok, false} = Predicator.evaluate("flag === 1", context) + assert {:ok, true} = Predicator.evaluate("num !== str", context) + end + + test "handles complex expressions with mixed operators" do + context = %{"a" => 1, "b" => 1, "c" => true} + + # Mixed strict and loose equality with same values + assert {:ok, true} = Predicator.evaluate("a == b AND a === b", context) + assert {:ok, false} = Predicator.evaluate("a !== b OR c === 1", context) + end + end + + describe "string visitor decompilation" do + test "decompiles === correctly" do + ast = {:comparison, :strict_eq, {:identifier, "x"}, {:literal, 42}} + result = Predicator.decompile(ast) + + assert result == "x === 42" + end + + test "decompiles !== correctly" do + ast = {:comparison, :strict_ne, {:identifier, "name"}, {:string_literal, "test", :double}} + result = Predicator.decompile(ast) + + assert result == "name !== \"test\"" + end + + test "preserves operator distinction in round-trip" do + expressions = [ + "x = y", + "x == y", + "x === y", + "x != y", + "x !== y" + ] + + for expr <- expressions do + {:ok, ast} = Predicator.parse(expr) + decompiled = Predicator.decompile(ast) + + # The original operator should be preserved + assert decompiled == expr + end + end + + test "formats complex expressions correctly" do + {:ok, ast} = Predicator.parse("(a === 1) AND (b !== 'test')") + result = Predicator.decompile(ast) + + assert result == "a === 1 AND b !== 'test'" + end + end + + describe "error handling" do + test "provides meaningful error messages for invalid syntax" do + # Test parsing error includes operator info + assert {:error, _message, _line, _col} = Predicator.parse("x === ===") + end + + test "handles undefined values consistently" do + # Both strict and loose should handle :undefined the same way + assert {:ok, false} = Predicator.evaluate("undefined_var === 5", %{}) + assert {:ok, true} = Predicator.evaluate("undefined_var !== 5", %{}) + end + end +end