diff --git a/.claude/agents/code-quality-enforcer.md b/.claude/agents/code-quality-enforcer.md new file mode 100644 index 0000000..b2ac7bb --- /dev/null +++ b/.claude/agents/code-quality-enforcer.md @@ -0,0 +1,36 @@ +--- +name: code-quality-enforcer +description: Use this agent when you need to format, lint, and ensure code passes verification tools. Examples: Context: User has written a Python function with inconsistent formatting and wants to clean it up. user: 'I just wrote this function but the formatting is messy and I want to make sure it passes all our quality checks' assistant: 'I'll use the code-quality-enforcer agent to format your code and ensure it meets all verification standards' The user needs code formatting and quality verification, so use the code-quality-enforcer agent. Context: User is preparing code for a pull request and wants to ensure it meets project standards. user: 'Can you clean up this code before I submit my PR?' assistant: 'I'll use the code-quality-enforcer agent to format and lint your code to ensure it meets project standards' The user needs comprehensive code quality enforcement before submission. +model: sonnet +color: cyan +--- + +You are a meticulous Code Quality Enforcer, an expert in code formatting, linting, and automated verification standards. Your mission is to transform code into its cleanest, most compliant form while maintaining functionality and readability. + +Your core responsibilities: +- Apply consistent formatting according to language-specific standards (PEP 8 for Python, ESLint for JavaScript, gofmt for Go, etc.) +- Identify and fix linting violations including unused imports, variables, and functions +- Ensure code passes static analysis tools and type checkers +- Optimize import statements and organize them properly +- Fix spacing, indentation, and line length issues +- Remove trailing whitespace and ensure proper line endings +- Validate naming conventions and suggest improvements +- Check for potential security vulnerabilities in code patterns + +Your methodology: +1. First, identify the programming language and applicable standards +2. Run through formatting checks systematically (indentation, spacing, line length) +3. Review and clean up imports, removing unused ones and organizing remaining imports +4. Check for linting violations and fix them while preserving functionality +5. Validate naming conventions and code structure +6. Perform a final review to ensure all changes maintain code correctness +7. Provide a summary of changes made and any remaining recommendations + +When making changes: +- Preserve all functionality - never alter program behavior +- Explain significant changes that might not be obvious +- If multiple valid formatting approaches exist, choose the most widely adopted standard +- Flag any issues that require human judgment rather than making assumptions +- Suggest additional tooling or configuration if patterns indicate systematic issues + +Always provide the cleaned code along with a clear summary of improvements made. If you encounter ambiguous situations or potential breaking changes, ask for clarification before proceeding. diff --git a/.coveralls.exs b/.coveralls.exs new file mode 100644 index 0000000..afda5b2 --- /dev/null +++ b/.coveralls.exs @@ -0,0 +1,24 @@ +# Coveralls configuration +%{ + # Minimum coverage threshold (fails if coverage is below this) + minimum_coverage: 90.0, + + # Skip files that shouldn't be included in coverage + skip_files: [ + # Skip generated files + ~r/priv\//, + # Skip build artifacts + ~r/_build\//, + # Skip deps + ~r/deps\//, + # Skip old code + ~r/old\//, + ], + + # Coverage output options + output_dir: "cover/", + template_path: "cover/coverage.html", + + # Only count lines that can actually be executed + treat_no_relevant_lines_as_covered: true, +} \ No newline at end of file diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..d4c91e5 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,227 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: true, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + {Credo.Check.Design.TagFIXME, []}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FilterCount, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.Dbg, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.UnsafeExec, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.WrongTestFileExtension, []}, + + # Enabled controversial and experimental checks + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Warning.MixEnv, []} + ], + disabled: [ + # + # Checks scheduled for next check update (opt-in for now) + {Credo.Check.Refactor.UtcNowTruncate, []}, + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + # {Credo.Check.Consistency.UnusedVariableNames, []}, + # {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + # {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.OneArityFunctionInPipe, []}, + {Credo.Check.Readability.OnePipePerLine, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + # {Credo.Check.Readability.Specs, []}, + # {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + # {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PassAsyncInTestCases, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + # {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + + + # {Credo.Check.Refactor.MapInto, []}, + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + } + ] +} diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..da3cd57 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,237 @@ +name: CI + +on: + push: + branches: [ main, master ] + paths-ignore: + - 'README.md' + - 'CHANGELOG.md' + - 'LICENSE' + - 'docs/**' + - '*.md' + pull_request: + branches: [ main, master ] + paths-ignore: + - 'README.md' + - 'CHANGELOG.md' + - 'LICENSE' + - 'docs/**' + - '*.md' + +env: + MIX_ENV: test + +jobs: + compile: + name: Compile + runs-on: ubuntu-latest + strategy: + matrix: + elixir: ['1.14', '1.15', '1.16', '1.17'] + otp: ['25', '26', '27'] + exclude: + # OTP 27 requires Elixir 1.17+ + - elixir: '1.14' + otp: '27' + - elixir: '1.15' + otp: '27' + - elixir: '1.16' + otp: '27' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}- + + - name: Install dependencies + run: mix deps.get + + - name: Compile with warnings as errors + run: mix compile --warnings-as-errors + + format: + name: Code Formatting + 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 + uses: actions/cache@v4 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-27-1.17-${{ hashFiles('**/mix.lock') }} + restore-keys: | + deps-${{ runner.os }}-27-1.17- + + - name: Install dependencies + run: mix deps.get + + - name: Check formatting + run: mix format --check-formatted + + test: + name: Test & Coverage + runs-on: ubuntu-latest + strategy: + matrix: + elixir: ['1.17', '1.18'] + otp: ['26', '27'] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}- + + - name: Install dependencies + run: mix deps.get + + - name: Run tests with coverage + run: mix coveralls.json + + - name: Upload coverage to Codecov + if: matrix.elixir == '1.17' && matrix.otp == '27' + uses: codecov/codecov-action@v3 + with: + files: ./cover/excoveralls.json + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + + credo: + name: Static Analysis (Credo) + 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 + uses: actions/cache@v4 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-27-1.17-${{ hashFiles('**/mix.lock') }} + restore-keys: | + deps-${{ runner.os }}-27-1.17- + + - name: Install dependencies + run: mix deps.get + + - name: Run Credo + run: mix credo --strict + + 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- + + - name: Install dependencies + run: mix deps.get + + - name: Run Dialyzer + run: mix dialyzer + + quality: + name: Quality Gate + runs-on: ubuntu-latest + needs: [compile, format, test, credo, dialyzer] + steps: + - name: All checks passed + run: echo "All quality checks have passed successfully!" + + docs: + name: Generate Documentation + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: '1.17' + otp-version: '27' + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-27-1.17-${{ hashFiles('**/mix.lock') }} + restore-keys: | + deps-${{ runner.os }}-27-1.17- + + - name: Install dependencies + run: mix deps.get + + - name: Generate docs + run: mix docs + + - name: Upload documentation artifacts + uses: actions/upload-artifact@v3 + with: + name: documentation + path: doc/ diff --git a/.gitignore b/.gitignore index fc28c3d..5c019fc 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ erl_crash.dump /config/ src/*.erl + +.claude/*.local.json +old/ diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index dc05950..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,29 +0,0 @@ -# CHANGELOG - -## v0.7.2 - * Enhancements - * Adds option to leex and parse instruction tokens into atoms or strings with the new `leex_and_parse/2` - -## v0.7.1 - * New - * Adds `between` instruction for eval on dates - -## v0.7.0 - * New - * Adds 2 new comparison predicates for `starts_with` & `ends_with` - -## v0.6.0 - * Updates & New - * Adds 3 new evaluatable predicates for `to_date`, `date_ago`, and `date_from_now` - -## v0.5.0 - * Updates - * Evaluator now reads new coercion instructions `to_int`, `to_str`, & `to_bool` - -## v0.4.0 - * New - * Adds 4 new functions to the `Predicator` module `eval/3`, `leex_string/1`, `parsed_lexed/1`, & `leex_and_parse/1` - -## v0.3.0 - * Enhancements - * Adds options to `Predicator.Evaluator.execute/3` as a keyword list to define if the context map is a string keyed list `[map_type: :string]` or atom keyed for the default `[map_type: :atom]` diff --git a/README.md b/README.md index 74759d7..e212b09 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,21 @@ # Predicator -[predicator_elixir](https://hexdocs.pm/predicator) is a predicate evaluator for compiled rules from the [predicator](https://github.com/predicator/predicator) ruby gem +**TODO: Add description** -### Installation +## Installation -The package can be installed by: +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `predicator` to your list of dependencies in `mix.exs`: -1. Adding to your list of dependencies in `mix.exs`: +```elixir +def deps do + [ + {:predicator, "~> 0.1.0"} + ] +end +``` - ```elixir - def deps do - [{:predicator, "~> 0.7"}] - end - ``` - - or if you want to use the ecto types for predicator you can add the predicator_ecto lib. - - ```elixir - def deps do - [ - {:predicator, "~> 0.7"}, - {:predicator_ecto, ">= 0.0.0"}, - ] - end - ``` - -### Using - -_Currently has working Evaluator for Predicator instructions & limited lexing and parsing_ +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . diff --git a/dialyzer.ignore-warnings b/dialyzer.ignore-warnings deleted file mode 100644 index e69de29..0000000 diff --git a/lib/predicator.ex b/lib/predicator.ex index 06775c2..2263fad 100644 --- a/lib/predicator.ex +++ b/lib/predicator.ex @@ -1,100 +1,153 @@ defmodule Predicator do @moduledoc """ - Documentation for Predicator. + A secure, non-evaluative condition engine for processing end-user boolean predicates. - Lexer and Parser currently only compatible with 0.4.0 predicate syntax - """ - alias Predicator.Evaluator + Predicator transforms string conditions into executable instructions that can be + safely evaluated without direct code execution. It uses a stack-based virtual machine + to process instructions and supports flexible context-based condition checking. - @lexer :predicate_lexer - @atom_parser :atom_instruction_parser - @string_parser :string_instruction_parser + ## Basic Usage - @type token_key_t :: - :atom_key_inst - | :string_key_inst + The simplest way to use Predicator is with the `execute/2` function: - @type predicate :: String.t | charlist + iex> instructions = [["lit", 42]] + iex> Predicator.execute(instructions) + 42 - @doc """ - Currently only compatible with 0.4.0 predicate syntax - leex_string/1 takes string or charlist and returns a lexed tuple for parsing. + iex> instructions = [["load", "score"]] + iex> context = %{"score" => 85} + iex> Predicator.execute(instructions, context) + 85 + + ## Instruction Format + + Instructions are lists where: + - First element is the operation name (string) + - Remaining elements are operation arguments + + Currently supported instructions: + - `["lit", value]` - Push a literal value onto the stack + - `["load", variable_name]` - Load a variable from context onto the stack + + ## Context + + The context is a map containing variable bindings. Both string and atom keys + are supported for flexibility: + + %{"score" => 85, "name" => "Alice"} + %{score: 85, name: "Alice"} - iex> leex_string('10 > 5') - {:ok, [{:lit, 1, 10}, {:comparator, 1, :GT}, {:lit, 1, 5}], 1} + ## Architecture - iex> leex_string("apple > 5532") - {:ok, [{:load, 1, :apple}, {:comparator, 1, :GT}, {:lit, 1, 5532}], 1} + Predicator uses a stack-based evaluation model: + 1. Instructions are processed sequentially + 2. Each instruction manipulates a stack + 3. The final result is the top value on the stack when execution completes """ - @spec leex_string(predicate) :: {:ok|:error, list|tuple, non_neg_integer()} - def leex_string(str) when is_binary(str), do: str |> to_charlist |> leex_string - def leex_string(str) when is_list(str), do: @lexer.string(str) + alias Predicator.{Evaluator, Types} @doc """ - Currently only compatible with 0.4.0 predicate syntax - parse_lexed/1 takes a leexed token(list or tup) and returns a predicate. It also - can take optional atom for type of token keys to return. options are `:string_ey_inst` & `:atom_key_inst` + Executes a list of instructions with an optional context. - iex> parse_lexed({:ok, [{:load, 1, :apple}, {:comparator, 1, :GT}, {:lit, 1, 5532}], 1}) - {:ok, [["load", :apple], ["lit", 5532], ["comparator", "GT"]]} + This is the main entry point for evaluating predicator instructions. + Instructions are executed in order using a stack machine, and the + final result is returned. - iex> parse_lexed({:ok, [{:load, 1, :apple}, {:comparator, 1, :GT}, {:lit, 1, 5532}], 1}, :string_key_inst) - {:ok, [["load", :apple], ["lit", 5532], ["comparator", "GT"]]} + ## Parameters - iex> parse_lexed([{:load, 1, :apple}, {:comparator, 1, :GT}, {:lit, 1, 5532}], :atom_key_inst) - {:ok, [[:load, :apple], [:lit, 5532], [:comparator, :GT]]} - """ - @spec parse_lexed(list, token_key_t) :: {:ok|:error, list|tuple} - def parse_lexed(token, opt \\ :string_key_inst) - def parse_lexed(token, :string_key_inst) when is_list(token), do: @string_parser.parse(token) - def parse_lexed({_, token, _}, :string_key_inst), do: @string_parser.parse(token) + - `instructions` - List of instructions to execute + - `context` - Optional context map with variable bindings (default: `%{}`) - def parse_lexed(token, :atom_key_inst) when is_list(token), do: @atom_parser.parse(token) - def parse_lexed({_, token, _}, :atom_key_inst), do: @atom_parser.parse(token) + ## Returns + - The final value from the top of the stack + - `{:error, reason}` if execution fails - @doc """ - Currently only compatible with 0.4.0 predicate syntax - leex_and_parse/1 takes a string or charlist and does all lexing and parsing then - returns the predicate. + ## Examples + + # Literal values + iex> Predicator.execute([["lit", 42]]) + 42 + + iex> Predicator.execute([["lit", true]]) + true - iex> leex_and_parse("13 > 12") - [["lit", 13], ["lit", 12], ["comparator", "GT"]] + # Loading from context + iex> Predicator.execute([["load", "score"]], %{"score" => 85}) + 85 - iex> leex_and_parse('532 == 532', :atom_key_inst) - [[:lit, 532], [:lit, 532], [:comparator, :EQ]] + # Missing variables return :undefined + iex> Predicator.execute([["load", "missing"]], %{}) + :undefined + + # Multiple instructions (last value wins) + iex> instructions = [["lit", 1], ["lit", 2], ["lit", 3]] + iex> Predicator.execute(instructions) + 3 """ - @spec leex_and_parse(String.t) :: list|{:error, any(), non_neg_integer} - def leex_and_parse(str, token_type \\ :string_key_inst) do - with {:ok, tokens, _} <- leex_string(str), - {:ok, predicate} <- parse_lexed(tokens, token_type) do - predicate - end + @spec execute(Types.instruction_list(), Types.context()) :: Types.result() + def execute(instructions, context \\ %{}) when is_list(instructions) and is_map(context) do + Evaluator.evaluate(instructions, context) end @doc """ - eval/3 takes a predicate set, a context struct and options + Creates a new evaluator state for low-level instruction processing. + + This function is useful when you need fine-grained control over the + evaluation process or want to inspect the evaluator state. + + ## Parameters + + - `instructions` - List of instructions to prepare for execution + - `context` - Optional context map with variable bindings (default: `%{}`) + + ## Returns + + An `%Predicator.Evaluator{}` struct ready for execution. + + ## Examples + + iex> evaluator = Predicator.evaluator([["lit", 42]]) + iex> evaluator.instructions + [["lit", 42]] + + iex> evaluator = Predicator.evaluator([["load", "x"]], %{"x" => 10}) + iex> evaluator.context + %{"x" => 10} """ - def eval(inst, context \\ %{}, opts \\ [map_type: :string]) - def eval(inst, context, opts), do: Evaluator.execute(inst, context, opts) - - def compile(predicate) do - with {:ok, tokens, _} <- leex_string(predicate), - {:ok, predicate} <- parse_lexed(tokens, :string_key_inst) do - {:ok, predicate} - else - {:error, _} = err -> err - {:error, left, right} -> {:error, {left, right}} - end + @spec evaluator(Types.instruction_list(), Types.context()) :: Evaluator.t() + def evaluator(instructions, context \\ %{}) when is_list(instructions) and is_map(context) do + %Evaluator{ + instructions: instructions, + context: context + } end - def matches?(predicate, context) when is_list(context) do - matches?(predicate, Map.new(context)) - end - def matches?(predicate, context) when is_binary(predicate) or is_list(predicate) do - with {:ok, predicate} <- compile(predicate) do - eval(predicate, context) - end + @doc """ + Runs an evaluator until completion. + + This provides direct access to the low-level evaluator API for cases + where you need more control than the `execute/2` function provides. + + ## Parameters + + - `evaluator` - An `%Predicator.Evaluator{}` struct + + ## Returns + + - `{:ok, final_evaluator_state}` on success + - `{:error, reason}` on failure + + ## Examples + + iex> evaluator = Predicator.evaluator([["lit", 42]]) + iex> {:ok, final_state} = Predicator.run_evaluator(evaluator) + iex> final_state.stack + [42] + """ + @spec run_evaluator(Evaluator.t()) :: {:ok, Evaluator.t()} | {:error, term()} + def run_evaluator(%Evaluator{} = evaluator) do + Evaluator.run(evaluator) end end diff --git a/lib/predicator/application.ex b/lib/predicator/application.ex index bdb211a..24f0dfa 100644 --- a/lib/predicator/application.ex +++ b/lib/predicator/application.ex @@ -1,9 +1,20 @@ defmodule Predicator.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications @moduledoc false + use Application + @impl Application def start(_type, _args) do - import Supervisor.Spec, warn: false - Supervisor.start_link([], [strategy: :one_for_one, name: Predicator.Supervisor]) + children = [ + # Starts a worker by calling: Predicator.Worker.start_link(arg) + # {Predicator.Worker, arg} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Predicator.Supervisor] + Supervisor.start_link(children, opts) end end diff --git a/lib/predicator/errors.ex b/lib/predicator/errors.ex deleted file mode 100644 index 72c6add..0000000 --- a/lib/predicator/errors.ex +++ /dev/null @@ -1,4 +0,0 @@ -defmodule Predicator.Evaluator.Errors do - @moduledoc false - -end diff --git a/lib/predicator/errors/date_conversion_error.ex b/lib/predicator/errors/date_conversion_error.ex deleted file mode 100644 index 5766506..0000000 --- a/lib/predicator/errors/date_conversion_error.ex +++ /dev/null @@ -1,17 +0,0 @@ -defmodule Predicator.DateConversionError do - @moduledoc """ - Error struct returned by Date Conversion Error. - - iex> %Predicator.DateConversionError{} - %Predicator.DateConversionError{error: "Date was not parsable", date: nil} - """ - @type t :: %__MODULE__{ - error: String.t(), - date: NaiveDateTime.t - } - - defstruct [ - error: "Date was not parsable", - date: nil - ] -end diff --git a/lib/predicator/errors/instruction_error.ex b/lib/predicator/errors/instruction_error.ex deleted file mode 100644 index 844e100..0000000 --- a/lib/predicator/errors/instruction_error.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule Predicator.InstructionError do - @moduledoc """ - Error struct returned by Value Error Types. - - iex> %Predicator.InstructionError{} - %Predicator.InstructionError{error: "Non valid predicate instruction", instructions: nil, predicate: nil, instruction_pointer: nil, stack: nil} - """ - @type t :: %__MODULE__{ - error: String.t(), - instructions: list(), - predicate: String.t(), - stack: list(), - instruction_pointer: non_neg_integer(), - opts: list() - } - - defstruct [ - error: "Non valid predicate instruction", - instructions: nil, - predicate: nil, - stack: nil, - instruction_pointer: nil, - opts: nil - ] - - @spec instruction_error(Predicator.Machine.t, term) :: {:error, t} - def instruction_error(machine=%Predicator.Machine{}, predicate) do - {:error, %__MODULE__{ - instructions: machine.instructions, - predicate: predicate, - stack: machine.stack, - instruction_pointer: machine.instruction_pointer, - opts: machine.opts - } - } - end -end diff --git a/lib/predicator/errors/instruction_not_complete_error.ex b/lib/predicator/errors/instruction_not_complete_error.ex deleted file mode 100644 index b3ff474..0000000 --- a/lib/predicator/errors/instruction_not_complete_error.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule Predicator.InstructionNotCompleteError do - @moduledoc """ - Error struct returned by Instruction Not Complete Error. - - iex> %Predicator.InstructionNotCompleteError{} - %Predicator.InstructionNotCompleteError{error: "Instruction must have evaluation rule after a type conversion", instructions: nil, stack: nil, instruction_pointer: nil} - """ - @type t :: %__MODULE__{ - error: String.t(), - instructions: list(), - stack: term(), - instruction_pointer: non_neg_integer(), - opts: list() - } - - defstruct [ - error: "Instruction must have evaluation rule after a type conversion", - instructions: nil, - stack: nil, - instruction_pointer: nil, - opts: nil - ] - - @spec inst_not_complete_error(Predicator.Machine.t) :: {:error, t} - def inst_not_complete_error(machine=%Predicator.Machine{}) do - {:error, %__MODULE__{ - stack: machine.stack, - instructions: machine.instructions, - instruction_pointer: machine.instruction_pointer, - opts: machine.opts - } - } - end -end diff --git a/lib/predicator/errors/value_error.ex b/lib/predicator/errors/value_error.ex deleted file mode 100644 index a41f224..0000000 --- a/lib/predicator/errors/value_error.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule Predicator.ValueError do - @moduledoc """ - Error struct returned by Value Error Types. - - iex> %Predicator.ValueError{} - %Predicator.ValueError{error: "Non valid load value to evaluate", instructions: nil, stack: nil, instruction_pointer: nil} - """ - @type t :: %__MODULE__{ - error: String.t(), - instructions: list(), - stack: list(), - instruction_pointer: non_neg_integer(), - opts: list() - } - - defstruct [ - error: "Non valid load value to evaluate", - instructions: nil, - stack: nil, - instruction_pointer: nil, - opts: nil - ] - - @spec value_error(Predicator.Machine.t) :: {:error, t} - def value_error(machine=%Predicator.Machine{}) do - {:error, %__MODULE__{ - stack: machine.stack, - instructions: machine.instructions, - instruction_pointer: machine.instruction_pointer, - opts: machine.opts - } - } - end -end diff --git a/lib/predicator/evaluator.ex b/lib/predicator/evaluator.ex index 5873163..4f940d0 100644 --- a/lib/predicator/evaluator.ex +++ b/lib/predicator/evaluator.ex @@ -1,66 +1,167 @@ defmodule Predicator.Evaluator do - @moduledoc "Evaluator Module" - alias Predicator.{ - InstructionNotCompleteError, - Machine, - } - - @typedoc "Error types returned from Predicator.Evaluator" - @type error_t :: {:error, - InstructionError.t() - | ValueError.t() - | InstructionNotCompleteError.t() } - - def execute(%Machine{} = machine) do - case Machine.step(machine) do - %Machine{} = machine -> - cond do - Machine.complete?(machine) and is_boolean(Machine.peek(machine)) -> - Machine.peek(machine) - - Machine.complete?(machine) -> - InstructionNotCompleteError.inst_not_complete_error(machine) - - true -> - execute(machine) - end - - {:error, _reason} = err -> err + @moduledoc """ + Stack-based evaluator for predicator instructions. + + The evaluator executes a list of instructions using a stack machine approach. + Instructions operate on a stack, with the most recent values at the top (head of list). + """ + + alias Predicator.Types + + @typedoc "Internal evaluator state" + @type t :: %__MODULE__{ + instructions: Types.instruction_list(), + instruction_pointer: non_neg_integer(), + stack: [Types.value()], + context: Types.context(), + halted: boolean() + } + + defstruct [ + :instructions, + instruction_pointer: 0, + stack: [], + context: %{}, + halted: false + ] + + @doc """ + Evaluates a list of instructions with the given context. + + Returns the top value on the stack when evaluation completes, + or an error if something goes wrong. + + ## Examples + + iex> Predicator.Evaluator.evaluate([["lit", 42]], %{}) + 42 + + iex> Predicator.Evaluator.evaluate([["load", "score"]], %{"score" => 85}) + 85 + + iex> Predicator.Evaluator.evaluate([["load", "missing"]], %{}) + :undefined + """ + @spec evaluate(Types.instruction_list(), Types.context()) :: Types.result() + def evaluate(instructions, context \\ %{}) when is_list(instructions) and is_map(context) do + evaluator = %__MODULE__{ + instructions: instructions, + context: context + } + + case run(evaluator) do + {:ok, %__MODULE__{stack: [result | _rest]}} -> + result + + {:ok, %__MODULE__{stack: []}} -> + {:error, "Evaluation completed with empty stack"} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Runs the evaluator until it halts or encounters an error. + + Returns `{:ok, final_state}` on success or `{:error, reason}` on failure. + """ + @spec run(t()) :: {:ok, t()} | {:error, term()} + def run(%__MODULE__{halted: true} = evaluator), do: {:ok, evaluator} + + def run(%__MODULE__{} = evaluator) do + case step(evaluator) do + {:ok, new_evaluator} -> run(new_evaluator) + {:error, reason} -> {:error, reason} end end - @doc ~S""" - Execute will evaluate a predicator instruction set. + @doc """ + Executes a single instruction step. - If your context struct is using string_keyed map then you will need to pass in the - `[map_type: :string]` options to the execute function to evaluate. + Returns the updated evaluator state or an error. + """ + @spec step(t()) :: {:ok, t()} | {:error, term()} + def step(%__MODULE__{} = evaluator) do + if finished?(evaluator) do + {:ok, halt(evaluator)} + else + case fetch_current_instruction(evaluator) do + {:ok, instruction} -> + evaluator + |> execute_instruction(instruction) + |> advance_instruction_pointer() - ### Examples: + {:error, reason} -> + {:error, reason} + end + end + end - iex> Predicator.Evaluator.execute([["lit", true]]) - true + # Private functions - iex> Predicator.Evaluator.execute([["lit", 2], ["lit", 3], ["comparator", "LT"]]) - true + @spec finished?(t()) :: boolean() + defp finished?(%__MODULE__{instruction_pointer: ip, instructions: instructions}) do + ip >= length(instructions) + end - iex> Predicator.Evaluator.execute([["load", "age"], ["lit", 18], ["comparator", "GT"]], %{age: 19}) - true + @spec halt(t()) :: t() + defp halt(%__MODULE__{} = evaluator) do + %__MODULE__{evaluator | halted: true} + end - iex> Predicator.Evaluator.execute([["load", "name"], ["lit", "jrichocean"], ["comparator", "EQ"]], %{age: 19}) - {:error, %Predicator.ValueError{error: "Non valid load value to evaluate", instruction_pointer: 0, instructions: [["load", "name"], ["lit", "jrichocean"], ["comparator", "EQ"]], stack: [], opts: [map_type: :string, nil_values: ["", nil]]}} + @spec fetch_current_instruction(t()) :: {:ok, Types.instruction()} | {:error, term()} + defp fetch_current_instruction(%__MODULE__{instruction_pointer: ip, instructions: instructions}) do + case Enum.at(instructions, ip) do + nil -> {:error, "Invalid instruction pointer: #{ip}"} + instruction -> {:ok, instruction} + end + end - iex> Predicator.Evaluator.execute([["load", "age"], ["lit", 18], ["comparator", "GT"]], %{"age" => 19}, [map_type: :string]) - true + @spec execute_instruction(t(), Types.instruction()) :: {:ok, t()} | {:error, term()} + defp execute_instruction(%__MODULE__{} = evaluator, instruction) do + case instruction do + ["lit", value] -> + {:ok, push_stack(evaluator, value)} - """ - @spec execute(list(), struct()|map()) :: boolean() | error_t - def execute(inst, context \\ %{}, opts \\ [map_type: :string, nil_values: ["", nil]]) when is_list(inst) do - inst - |> to_machine(context, opts) - |> execute + ["load", variable_name] when is_binary(variable_name) -> + value = load_from_context(evaluator.context, variable_name) + {:ok, push_stack(evaluator, value)} + + unknown -> + {:error, "Unknown instruction: #{inspect(unknown)}"} + end end - def to_machine(instructions, context, opts) do - Machine.new(instructions, context, opts) + @spec advance_instruction_pointer({:ok, t()} | {:error, term()}) :: + {:ok, t()} | {:error, term()} + defp advance_instruction_pointer({:ok, %__MODULE__{} = evaluator}) do + {:ok, %__MODULE__{evaluator | instruction_pointer: evaluator.instruction_pointer + 1}} + end + + defp advance_instruction_pointer({:error, reason}), do: {:error, reason} + + @spec push_stack(t(), Types.value()) :: t() + defp push_stack(%__MODULE__{stack: stack} = evaluator, value) do + %__MODULE__{evaluator | stack: [value | stack]} + end + + @spec load_from_context(Types.context(), binary()) :: Types.value() + defp load_from_context(context, variable_name) + when is_map(context) and is_binary(variable_name) do + # Try string key first, then atom key + case Map.get(context, variable_name) do + nil -> + # Try as atom key if string key doesn't exist + atom_key = String.to_existing_atom(variable_name) + Map.get(context, atom_key, :undefined) + + value -> + value + end + rescue + ArgumentError -> + # String.to_existing_atom failed, variable doesn't exist + :undefined end end diff --git a/lib/predicator/evaluator/date.ex b/lib/predicator/evaluator/date.ex deleted file mode 100644 index 7dcf134..0000000 --- a/lib/predicator/evaluator/date.ex +++ /dev/null @@ -1,50 +0,0 @@ -defmodule Predicator.Evaluator.Date do - @moduledoc false - alias Predicator.{ - Machine, - DateConversionError - } - - def _execute(["to_date"|_], machine=%Machine{stack: [date|_rest_of_stack]}) do - Machine.replace_stack(machine, _convert_date(date)) - end - - def _execute(["date_ago"|_], machine=%Machine{stack: [date_in_seconds|_rest_of_stack]}) do - with {:ok, dt_from_stack} <- DateTime.from_unix(date_in_seconds), - diff_in_seconds <- DateTime.diff(DateTime.utc_now, dt_from_stack), - {:ok, datetime} <- DateTime.from_unix(diff_in_seconds) - do - Machine.replace_stack(machine, datetime) - end - end - - def _execute(["date_from_now"|_], machine=%Machine{stack: [seconds_from_now|_rest_of_stack]}) do - date_from_now = - DateTime.utc_now - |> DateTime.to_unix - |> add(seconds_from_now) - - Machine.replace_stack(machine, date_from_now) - end - - - @compile {:inline, _convert_date: 1} - def _convert_date(arg) when is_number(arg), do: DateTime.from_unix!(arg) - def _convert_date(arg) do - case NaiveDateTime.from_iso8601(arg) do - {:ok, naivedate} -> - with {:ok, datetime} <- DateTime.from_naive(naivedate, "Etc/UTC"), - do: datetime - {:error, _} -> - case Date.from_iso8601(arg) do - {:error, _} -> {:error, %DateConversionError{date: arg}} - {:ok, date} -> - with {:ok, naivedate} <- NaiveDateTime.new(date, ~T[00:00:00.000]), - {:ok, datetime} <- DateTime.from_naive(naivedate, "Etc/UTC"), - do: datetime - end - end - end - - defp add(now, seconds_from_now), do: now + seconds_from_now -end diff --git a/lib/predicator/machine.ex b/lib/predicator/machine.ex deleted file mode 100644 index 9ba9b25..0000000 --- a/lib/predicator/machine.ex +++ /dev/null @@ -1,277 +0,0 @@ -defmodule Predicator.Machine do - @moduledoc """ - A Machine Struct is comprised of the instructions set, the current stack, the instruction pointer and the context struct. - - iex>%Predicator.Machine{} - %Predicator.Machine{instructions: [], stack: [], instruction_pointer: 0, context: nil, opts: []} - """ - alias Predicator.{ - ValueError, - InstructionError, - InstructionNotCompleteError - } - - defstruct [ - instructions: [], - stack: [], - instruction_pointer: 0, - context: %{}, - opts: [] - ] - - @type t :: %__MODULE__{ - instructions: [] | [...], - stack: [] | [...], - instruction_pointer: non_neg_integer(), - context: struct() | map(), - opts: [{atom, atom}, ...] | [{atom, [...]}, ...] - } - - def new(instructions, context \\ %{}, opts \\ []) do - do_new(instructions, context, opts) - end - - defp do_new(instructions, %{__struct__: _} = context, opts) do - do_new(instructions, Map.from_struct(context), opts) - end - - defp do_new(instructions, context, opts) do - context = - context - |> Enum.map(fn - ({k, v}) when is_atom(k) -> - {Atom.to_string(k), v} - (other) -> other - end) - |> Map.new - - %__MODULE__{instructions: instructions, context: context, opts: opts} - end - - def complete?(%__MODULE__{} = machine) do - case next_instruction(machine) do - nil -> true - _ -> false - end - end - - def peek(%__MODULE__{stack: []}), do: nil - def peek(%__MODULE__{stack: [head | _tail]}) do - head - end - - def step(%__MODULE__{} = machine) do - next_instruction = next_instruction(machine) - accept_instruction(machine, next_instruction) - end - - def put_instruction(%__MODULE__{} = machine, instruction, opts \\ []) do - pointer = - if Keyword.get(opts, :increment, true) do - machine.instruction_pointer + 1 - else - machine.instruction_pointer - end - - %__MODULE__{ machine | stack: [instruction | machine.stack], instruction_pointer: pointer } - end - - def next_instruction(%__MODULE__{} = machine) do - if machine.instruction_pointer < Enum.count(machine.instructions) do - Enum.at(machine.instructions, machine.instruction_pointer) - end - end - - def increment_pointer(%__MODULE__{} = machine, amount) do - %__MODULE__{machine | instruction_pointer: machine.instruction_pointer + amount} - end - - def replace_stack(%__MODULE__{stack: [_head | tail]} = machine, value) do - %__MODULE__{machine | stack: [value | tail], instruction_pointer: machine.instruction_pointer + 1} - end - - def pop_instruction(%__MODULE__{} = machine) do - %__MODULE__{ machine | stack: tl(machine.stack), instruction_pointer: machine.instruction_pointer + 1 } - end - - def load!(%__MODULE__{} = machine, key) when is_atom(key) do - load!(machine, Atom.to_string(key)) - end - - def load!(%__MODULE__{context: context} = machine, key) when is_binary(key) do - if has_variable?(machine, key) do - Map.get(context, key) - else - ValueError.value_error(machine) - end - end - - def has_variable?(%__MODULE__{context: context}, key) when is_binary(key) do - Map.has_key?(context, key) - end - - def has_variable?(%__MODULE__{context: context}, key) when is_atom(key) do - Map.has_key?(context, Atom.to_string(key)) - end - - def accept_instruction(m = %__MODULE__{stack: [first|_]}, nil) - when not is_boolean(first), do: InstructionNotCompleteError.inst_not_complete_error(m) - def accept_instruction(machine, nil), do: hd(machine.stack) - - def accept_instruction(machine = %__MODULE__{}, ["array"|[val|_]]) do - put_instruction(machine, val) - end - - def accept_instruction(machine = %__MODULE__{}, ["lit"|[val|_]]) do - put_instruction(machine, val) - end - - def accept_instruction(machine = %__MODULE__{stack: [val|_rest_of_stack]}, ["not"|_]) do - put_instruction(machine, !val) - end - - # Conversion Predicates - def accept_instruction(machine = %__MODULE__{stack: ["false"|_rest_of_stack]}, ["to_bool"|_]) do - replace_stack(machine, false) - end - def accept_instruction(machine = %__MODULE__{stack: ["true"|_rest_of_stack]}, ["to_bool"|_]) do - replace_stack(machine, true) - end - def accept_instruction(machine = %__MODULE__{stack: [val|_rest_of_stack]} = machine, ["to_bool"|_]) when is_boolean(val) do - replace_stack(machine, val) - end - def accept_instruction(machine = %__MODULE__{}, ["to_bool"|_]), do: ValueError.value_error(machine) - - def accept_instruction(machine = %__MODULE__{stack: [val|_rest_of_stack]}, ["to_str"|_]) when is_nil(val) do - replace_stack(machine, "nil") - end - def accept_instruction(machine = %__MODULE__{stack: [val|_rest_of_stack]}, ["to_str"|_]) do - replace_stack(machine, to_string(val)) - end - - def accept_instruction(machine = %__MODULE__{stack: [val|_rest_of_stack]}, ["to_int"|_]) when is_binary(val) do - case Integer.parse(val) do - {integer, _} -> - put_instruction(machine, integer) - - :error -> - ValueError.value_error(machine) - end - end - def accept_instruction(machine = %__MODULE__{stack: [val|_rest_of_stack]}, ["to_int"|_]) when is_integer(val) do - put_instruction(machine, val) - end - - def accept_instruction(machine = %__MODULE__{}, inst=["to_date"|_]), - do: Predicator.Evaluator.Date._execute(inst, machine) - - def accept_instruction(machine = %__MODULE__{}, inst=["date_ago"|_]), - do: Predicator.Evaluator.Date._execute(inst, machine) - - def accept_instruction(machine = %__MODULE__{}, inst=["date_from_now"|_]), - do: Predicator.Evaluator.Date._execute(inst, machine) - - - def accept_instruction(machine = %__MODULE__{stack: [val|_rest_of_stack], opts: opts}, ["blank"]) do - val = Enum.member?(opts[:nil_values], val) - put_instruction(machine, val) - end - - def accept_instruction(machine = %__MODULE__{stack: [val|_rest_of_stack], opts: opts}, ["present"]) do - val = !(Enum.member?(opts[:nil_values], val)) - put_instruction(machine, val) - end - - def accept_instruction(machine = %__MODULE__{stack: [left|[right|_rest_of_stack]]}, ["comparator"|["EQ"|_]]) do - put_instruction(machine, left == right) - end - def accept_instruction(machine, ["comparator"|["EQ"|_]]) do - put_instruction(machine, false, increment: false) - end - - def accept_instruction(machine = %__MODULE__{stack: [left|[right|_rest_of_stack]]}, ["comparator"|["IN"|_]]) do - val = Enum.member?(left, right) - put_instruction(machine, val) - end - def accept_instruction(machine, ["comparator"|["IN"|_]]) do - put_instruction(machine, false, increment: false) - end - - def accept_instruction(machine = %__MODULE__{stack: [left|[right|_rest_of_stack]]}, ["comparator"|["NOTIN"|_]]) do - val = !Enum.member?(left, right) - put_instruction(machine, val) - end - def accept_instruction(machine, ["comparator"|["NOTIN"|_]]) do - put_instruction(machine, false, increment: false) - end - - def accept_instruction(machine = %__MODULE__{stack: [second|[first|_rest_of_stack]]}, ["comparator"|["GT"|_]]) do - put_instruction(machine, first > second) - end - def accept_instruction(machine, ["comparator"|["GT"|_]]) do - put_instruction(machine, false, increment: false) - end - - def accept_instruction(machine = %__MODULE__{stack: [second|[first|_rest_of_stack]]}, ["comparator"|["LT"|_]]) do - put_instruction(machine, first < second) - end - def accept_instruction(machine, ["comparator"|["LT"|_]]) do - put_instruction(machine, false, increment: false) - end - - def accept_instruction( - machine = %__MODULE__{stack: [max=%DateTime{}|[min=%DateTime{}|[val=%DateTime{}|_rest_of_stack]]]}, ["comparator"|["BETWEEN"|_]] - ) do - is_between = - with :gt <- DateTime.compare(max, val), - :lt <- DateTime.compare(min, val) - do - true - else - _ -> false - end - - put_instruction(machine, is_between) - end - def accept_instruction(machine = %__MODULE__{stack: [max|[min|[val|_rest_of_stack]]]}, ["comparator"|["BETWEEN"|_]]) do - put_instruction(machine, val in min..max) - end - - def accept_instruction(machine = %__MODULE__{stack: [match|[stack_val|_rest_of_stack]]}, ["comparator"|["STARTSWITH"|_]]) do - put_instruction(machine, String.starts_with?(stack_val, match)) - end - - def accept_instruction(machine = %__MODULE__{stack: [end_match|[stack_val|_rest_of_stack]]}, ["comparator"|["ENDSWITH"|_]]) do - put_instruction(machine, String.ends_with?(stack_val, end_match)) - end - - def accept_instruction(machine = %__MODULE__{}, ["load"|[val|_]]) do - if has_variable?(machine, val) do - user_key = load!(machine, val) - put_instruction(machine, user_key) - else - ValueError.value_error(machine) - end - end - - def accept_instruction(machine = %__MODULE__{}, ["jfalse"|[offset|_]]) do - case hd(machine.stack) do - false -> - increment_pointer(machine, offset) - _ -> - pop_instruction(machine) - end - end - - def accept_instruction(machine = %__MODULE__{}, ["jtrue"|[offset|_]]) do - case hd(machine.stack) do - true -> - increment_pointer(machine, offset) - _ -> - pop_instruction(machine) - end - end - - def accept_instruction(machine = %__MODULE__{}, [non_recognized_predicate|_]), - do: InstructionError.instruction_error(machine, non_recognized_predicate) -end diff --git a/lib/predicator/types.ex b/lib/predicator/types.ex new file mode 100644 index 0000000..4c5acb4 --- /dev/null +++ b/lib/predicator/types.ex @@ -0,0 +1,125 @@ +defmodule Predicator.Types do + @moduledoc """ + Core type definitions for the Predicator library. + + This module defines all the fundamental types used throughout the + Predicator system for instructions, evaluation contexts, and results. + """ + + @typedoc """ + A single value that can be used in predicates. + + Values can be: + - `boolean()` - true/false values + - `integer()` - numeric values + - `binary()` - string values + - `list()` - lists of values + - `:undefined` - represents undefined/null values + """ + @type value :: boolean() | integer() | binary() | list() | :undefined + + @typedoc """ + The evaluation context containing variable bindings. + + Context maps variable names (strings or atoms) to their values. + Both string and atom keys are supported for flexibility. + + ## Examples + + %{"score" => 85, "name" => "Alice"} + %{score: 85, name: "Alice"} + """ + @type context :: %{required(binary() | atom()) => value()} + + @typedoc """ + A single instruction in the stack machine. + + Instructions are lists where the first element is the operation name + and remaining elements are arguments. + + Currently supported instructions: + - `["lit", value()]` - Push literal value onto stack + - `["load", binary()]` - Load variable from context onto stack + + ## Examples + + ["lit", 42] # Push literal 42 onto stack + ["load", "score"] # Load variable 'score' from context + """ + @type instruction :: [binary() | value()] + + @typedoc """ + A list of instructions that form a complete program. + + Instructions are executed in order by the stack machine. + """ + @type instruction_list :: [instruction()] + + @typedoc """ + The result of evaluating a predicate. + + Returns: + - `boolean()` - the final evaluation result + - `{:error, term()}` - evaluation error with details + """ + @type result :: boolean() | {:error, term()} + + @typedoc """ + The internal state of the stack machine evaluator. + + Contains: + - `instructions` - list of instructions to execute + - `instruction_pointer` - current position in instruction list + - `stack` - evaluation stack (top element is head of list) + - `context` - variable bindings + - `halted` - whether execution has stopped + """ + @type evaluator_state :: %{ + instructions: instruction_list(), + instruction_pointer: non_neg_integer(), + stack: [value()], + context: context(), + halted: boolean() + } + + @doc """ + Checks if a value is undefined. + + ## Examples + + iex> Predicator.Types.undefined?(:undefined) + true + + iex> Predicator.Types.undefined?(42) + false + """ + @spec undefined?(value()) :: boolean() + def undefined?(value), do: value == :undefined + + @doc """ + Checks if two values have matching types for operations. + + Two values have matching types if they are both: + - integers + - booleans + - binaries (strings) + - lists + + ## Examples + + iex> Predicator.Types.types_match?(1, 2) + true + + iex> Predicator.Types.types_match?("hello", "world") + true + + iex> Predicator.Types.types_match?(1, "hello") + false + """ + @spec types_match?(value(), value()) :: boolean() + def types_match?(a, b) when is_integer(a) and is_integer(b), do: true + def types_match?(a, b) when is_boolean(a) and is_boolean(b), do: true + def types_match?(a, b) when is_binary(a) and is_binary(b), do: true + def types_match?(a, b) when is_list(a) and is_list(b), do: true + def types_match?(_a, _b), do: false +end diff --git a/mix.exs b/mix.exs index 7b245ae..ccc15d5 100644 --- a/mix.exs +++ b/mix.exs @@ -1,49 +1,98 @@ -defmodule Predicator.Mixfile do +defmodule Predicator.MixProject do use Mix.Project - def project() do + @version "1.0.0" + @source_url "https://github.com/predicator/predicator_elixir" + + def project do [ app: :predicator, - version: "0.7.2", - elixir: "~> 1.6", - build_embedded: Mix.env == :prod, - start_permanent: Mix.env == :prod, + version: @version, + elixir: "~> 1.11", + start_permanent: Mix.env() == :prod, + deps: deps(), + docs: docs(), + description: description(), package: package(), aliases: aliases(), - description: description(), - deps: deps() + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + "test.watch": :test, + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test, + "coveralls.json": :test, + quality: :test, + "quality.check": :test, + "test.coverage": :test, + "test.coverage.html": :test, + "test.coverage.detail": :test + ] ] end - def description(), do: "Predicate Evaluator" + def application do + [ + extra_applications: [:logger], + mod: {Predicator.Application, []} + ] + end - def package() do + defp deps do [ - name: :predicator, - maintainers: ["Joshua Richardson"], - licenses: ["MIT"], - docs: [extras: ["README.md"]], - links: %{"GitHub" => "https://github.com/predicator/predicator_elixir"} + # Development and testing + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:ex_doc, "~> 0.31", only: :dev, runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:mix_test_watch, "~> 1.2", only: [:dev, :test], runtime: false}, + {:excoveralls, "~> 0.18", only: :test} ] end - defp aliases() do - [compile: "compile --warnings-as-errors"] + defp description do + "A secure, non-evaluative condition engine for processing end-user boolean predicates in Elixir" + end + + defp package do + [ + name: :predicator, + files: ~w(lib mix.exs README.md LICENSE CHANGELOG.md), + licenses: ["MIT"], + links: %{"GitHub" => @source_url}, + maintainers: ["Predicator Team"] + ] end - def application() do + defp docs do [ - mod: {Predicator.Application, []}, - extra_applications: [ - :logger + name: "Predicator", + source_ref: "v#{@version}", + canonical: "https://hexdocs.pm/predicator", + source_url: @source_url, + extras: ["README.md", "CHANGELOG.md"], + groups_for_modules: [ + Core: [Predicator, Predicator.Types], + Evaluation: [Predicator.Evaluator], + Compilation: [], + Errors: [] ] ] end - defp deps() do + defp aliases do [ - {:dialyxir, "~> 1.0.0-rc.4", only: [:dev], runtime: false}, - {:ex_doc, "~> 0.19.0", only: :dev}, + "test.watch": ["test.watch --stale"], + quality: ["format", "credo --strict", "coveralls", "dialyzer"], + "quality.check": [ + "format --check-formatted", + "credo --strict", + "coveralls", + "dialyzer" + ], + "test.coverage": ["coveralls"], + "test.coverage.html": ["coveralls.html"], + "test.coverage.detail": ["coveralls.detail"] ] end end diff --git a/mix.lock b/mix.lock index 83cc3d8..d230e6f 100644 --- a/mix.lock +++ b/mix.lock @@ -1,21 +1,16 @@ %{ - "certifi": {:hex, :certifi, "2.3.1", "d0f424232390bf47d82da8478022301c561cf6445b5b5fb6a84d49a9e76d2639", [:rebar3], [{:parse_trans, "3.2.0", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, - "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, - "dialyxir": {:hex, :dialyxir, "1.0.0-rc.4", "71b42f5ee1b7628f3e3a6565f4617dfb02d127a0499ab3e72750455e986df001", [:mix], [{:erlex, "~> 0.1", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"}, - "earmark": {:hex, :earmark, "1.2.6", "b6da42b3831458d3ecc57314dff3051b080b9b2be88c2e5aa41cd642a5b044ed", [:mix], [], "hexpm"}, - "erlex": {:hex, :erlex, "0.1.6", "c01c889363168d3fdd23f4211647d8a34c0f9a21ec726762312e08e083f3d47e", [:mix], [], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.19.1", "519bb9c19526ca51d326c060cb1778d4a9056b190086a8c6c115828eaccea6cf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.7", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, - "gettext": {:hex, :gettext, "0.16.0", "4a7e90408cef5f1bf57c5a39e2db8c372a906031cc9b1466e963101cb927dafc", [:mix], [], "hexpm"}, - "hackney": {:hex, :hackney, "1.13.0", "24edc8cd2b28e1c652593833862435c80661834f6c9344e84b6a2255e7aeef03", [:rebar3], [{:certifi, "2.3.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.2", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, - "idna": {:hex, :idna, "5.1.2", "e21cb58a09f0228a9e0b95eaa1217f1bcfc31a1aaa6e1fdf2f53a33f7dbd9494", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup": {:hex, :makeup, "0.5.1", "966c5c2296da272d42f1de178c1d135e432662eca795d6dc12e5e8787514edf7", [:mix], [{:nimble_parsec, "~> 0.2.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.8.0", "1204a2f5b4f181775a0e456154830524cf2207cf4f9112215c05e0b76e4eca8b", [:mix], [{:makeup, "~> 0.5.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 0.2.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, - "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.2.2", "d526b23bdceb04c7ad15b33c57c4526bf5f50aaa70c7c141b4b4624555c68259", [:mix], [], "hexpm"}, - "parse_trans": {:hex, :parse_trans, "3.2.0", "2adfa4daf80c14dc36f522cf190eb5c4ee3e28008fc6394397c16f62a26258c2", [:rebar3], [], "hexpm"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, - "timex": {:hex, :timex, "3.3.0", "e0695aa0ddb37d460d93a2db34d332c2c95a40c27edf22fbfea22eb8910a9c8d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, - "tzdata": {:hex, :tzdata, "0.5.19", "7962a3997bf06303b7d1772988ede22260f3dae1bf897408ebdac2b4435f4e6a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, + "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "ex_doc": {:hex, :ex_doc, "0.38.3", "ddafe36b8e9fe101c093620879f6604f6254861a95133022101c08e75e6c759a", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "ecaa785456a67f63b4e7d7f200e8832fa108279e7eb73fd9928e7e66215a01f9"}, + "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, + "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, + "mix_test_watch": {:hex, :mix_test_watch, "1.3.0", "2ffc9f72b0d1f4ecf0ce97b044e0e3c607c3b4dc21d6228365e8bc7c2856dc77", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f9e5edca976857ffac78632e635750d158df14ee2d6185a15013844af7570ffe"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, } diff --git a/src/atom_instruction_parser.yrl b/src/atom_instruction_parser.yrl deleted file mode 100644 index 022da20..0000000 --- a/src/atom_instruction_parser.yrl +++ /dev/null @@ -1,23 +0,0 @@ -Header -"%% Predicator Elixir". - -Nonterminals predicates predicate. - -Terminals lit load comparator jfalse jtrue. - -Rootsymbol predicates. - -predicates -> predicate : '$1'. -predicates -> predicate jfalse predicate : ['$1', jfalse, '$3']. %% jfalse -predicates -> predicates jfalse predicate : {'$1', jfalse, '$3'}. -predicates -> predicate jtrue predicate : ['$1', jtrue, '$3']. %% jtrue -predicates -> predicates jtrue predicate : {'$1', jtrue, '$3'}. - -predicate -> lit comparator load : [unwrap('$1'), unwrap('$3'), unwrap('$2')]. -predicate -> load comparator lit : [unwrap('$1'), unwrap('$3'), unwrap('$2')]. -predicate -> lit comparator lit : [unwrap('$1'), unwrap('$3'), unwrap('$2')]. -predicate -> load comparator load : [unwrap('$1'), unwrap('$3'), unwrap('$2')]. - -Erlang code. - -unwrap({INST,_,V}) -> [INST, V]. diff --git a/src/predicate_lexer.xrl b/src/predicate_lexer.xrl deleted file mode 100644 index cd06a3f..0000000 --- a/src/predicate_lexer.xrl +++ /dev/null @@ -1,35 +0,0 @@ -Definitions. - -WHITESPACE = [\s\t\n\r] - -EQUAL = (==|=|===|equal) -LESS_THAN = (lt|LT|<) -GREATER_THAN = (gt|GT|>) -% BETWEEN = (bt|BTWEEN|BT) - -ATOM = :[a-z_]+ -IDENTIFIER = [a-z][A-Za-z0-9_]* -INTEGER = [0-9]+ -BOOLEAN = (true|false) - - -Rules. - -{WHITESPACE} : skip_token. - -{GREATER_THAN} : {token, {comparator, TokenLine, 'GT'}}. -{LESS_THAN} : {token, {comparator, TokenLine, 'LT'}}. -{EQUAL} : {token, {comparator, TokenLine, 'EQ'}}. -% {BETWEEN} : {token, {comparator, TokenLine, 'BETWEEN'}}. - -and : {token, {jfalse, TokenLine, list_to_existing_atom(TokenChars)}}. -or : {token, {jtrue, TokenLine, list_to_existing_atom(TokenChars)}}. - -{BOOLEAN} : {token, {lit, TokenLine, list_to_existing_atom(TokenChars)}}. -{ATOM} : {token, {load, TokenLine, list_to_atom(TokenChars)}}. -{IDENTIFIER} : {token, {load, TokenLine, list_to_atom(TokenChars)}}. -{INTEGER} : {token, {lit, TokenLine, list_to_integer(TokenChars)}}. - - -Erlang code. - diff --git a/src/string_instruction_parser.yrl b/src/string_instruction_parser.yrl deleted file mode 100644 index b634f13..0000000 --- a/src/string_instruction_parser.yrl +++ /dev/null @@ -1,32 +0,0 @@ -Header -"%% Predicator Elixir". - -Nonterminals predicates predicate. - -Terminals lit load comparator jfalse jtrue. - -Rootsymbol predicates. - -predicates -> predicate : '$1'. -predicates -> predicate jfalse predicate : ['$1', jfalse, '$3']. %% jfalse -predicates -> predicates jfalse predicate : {'$1', jfalse, '$3'}. -predicates -> predicate jtrue predicate : ['$1', jtrue, '$3']. %% jtrue -predicates -> predicates jtrue predicate : {'$1', jtrue, '$3'}. - -predicate -> lit comparator load : [unwrap('$1'), unwrap('$3'), unwrap('$2')]. -predicate -> load comparator lit : [unwrap('$1'), unwrap('$3'), unwrap('$2')]. -predicate -> lit comparator lit : [unwrap('$1'), unwrap('$3'), unwrap('$2')]. -predicate -> load comparator load : [unwrap('$1'), unwrap('$3'), unwrap('$2')]. - -Erlang code. - -unwrap({INST,_,V='GT'}) -> - [tobin(INST), tobin(V)]; -unwrap({INST,_,V='LT'}) -> - [tobin(INST), tobin(V)]; -unwrap({INST,_,V='EQ'}) -> - [tobin(INST), tobin(V)]; -unwrap({INST,_,V}) -> - [tobin(INST), V]. - -tobin(ATOM) -> erlang:atom_to_binary(ATOM, utf8). diff --git a/test/predicator/errors/insctruction_error_test.exs b/test/predicator/errors/insctruction_error_test.exs deleted file mode 100644 index eb6fc56..0000000 --- a/test/predicator/errors/insctruction_error_test.exs +++ /dev/null @@ -1,4 +0,0 @@ -defmodule Predicator.InstructionErrorTest do - use ExUnit.Case - doctest Predicator.InstructionError -end diff --git a/test/predicator/errors/instruction_not_complete_error_test.exs b/test/predicator/errors/instruction_not_complete_error_test.exs deleted file mode 100644 index 6e9c3bc..0000000 --- a/test/predicator/errors/instruction_not_complete_error_test.exs +++ /dev/null @@ -1,3 +0,0 @@ -defmodule Predicator.InstructionNotCompleteErrorTest do - use ExUnit.Case; doctest Predicator.InstructionNotCompleteError -end diff --git a/test/predicator/errors/value_error_test.exs b/test/predicator/errors/value_error_test.exs deleted file mode 100644 index 23c148e..0000000 --- a/test/predicator/errors/value_error_test.exs +++ /dev/null @@ -1,4 +0,0 @@ -defmodule Predicator.ValueErrorTest do - use ExUnit.Case - doctest Predicator.ValueError -end diff --git a/test/predicator/evaluator_operations/between_test.exs b/test/predicator/evaluator_operations/between_test.exs deleted file mode 100644 index 533e793..0000000 --- a/test/predicator/evaluator_operations/between_test.exs +++ /dev/null @@ -1,47 +0,0 @@ -defmodule Predicator.EvaluatorOperation.BetweenTest do - use ExUnit.Case - import Predicator.Evaluator - - defmodule TstStruct, do: defstruct [created_at: "2012-12-12"] - - @moduletag :now - - describe "[\"BETWEEN\"] operation" do - @tag :current - test "inclusive evaluation of integer BETWEEN integers" do - inst = [["lit", 3], ["lit", 1], ["lit", 5], ["comparator", "BETWEEN"]] - inst2 = [["lit", 1], ["lit", 1], ["lit", 5], ["comparator", "BETWEEN"]] - inst3 = [["lit", 5], ["lit", 1], ["lit", 5], ["comparator", "BETWEEN"]] - - assert execute(inst) == true - assert execute(inst2) == true - assert execute(inst3) == true - end - - test "date eval true between dates" do - inst = [ - ["lit", "2012-12-12"], - ["to_date"], - ["lit", 1], - ["to_date"], - ["lit", 5000000000], - ["to_date"], - ["comparator", "BETWEEN"] - ] - assert execute(inst) == true - end - - test "date eval with load is true between dates" do - inst = [ - ["load", "created_at"], - ["to_date"], - ["lit", 259200], - ["to_date"], - ["lit", 5000000000], - ["to_date"], - ["comparator", "BETWEEN"] - ] - assert execute(inst, %TstStruct{}) == true - end - end -end diff --git a/test/predicator/evaluator_operations/blank_test.exs b/test/predicator/evaluator_operations/blank_test.exs deleted file mode 100644 index 75f0df6..0000000 --- a/test/predicator/evaluator_operations/blank_test.exs +++ /dev/null @@ -1,70 +0,0 @@ -defmodule Predicator.EvaluatorOperation.BlankTest do - use ExUnit.Case - import Predicator.Evaluator - - @moduletag :parsed - - defmodule TestUser do - defstruct [ - name: "Joshua", - string_age: "29", - age: 29, - metalhead: "true", - is_superhero: "falsse", - nil_val: nil, - blank_with_nil_options: "dog", - true_val: true, - false_val: false - ] - end - - describe "[\"BLANK\"] operation" do - test "false when existing lit val" do - inst1 = [["lit", 12], ["blank"]] - inst2 = [["lit", " "], ["blank"]] - inst3 = [["lit", "hello"], ["blank"]] - inst4 = [["lit", %{key: "some_val"}], ["blank"]] - assert execute(inst1) == false - assert execute(inst2) == false - assert execute(inst3) == false - assert execute(inst4) == false - end - - test "true when blank val" do - inst1 = [["lit", ""], ["blank"]] - inst2 = [["lit", nil], ["blank"]] - assert execute(inst1) == true - assert execute(inst2) == true - end - - test "false when loaded existing val" do - inst1 = [["load", "age"], ["blank"]] - inst2 = [["load", "metalhead"], ["blank"]] - - assert execute(inst1, %TestUser{}) == false - assert execute(inst2, %TestUser{}) == false - end - - test "true when loaded blank val" do - inst = [["load", "nil_val"], ["blank"]] - assert execute(inst, %TestUser{}) == true - end - - test "BLANK with load & extra nil opts" do - custom_opts = [map_type: :atom, nil_values: ["", nil, "dog"]] - blank1 = [["load", "blank_with_nil_options"], ["blank"]] - blank2 = [["load", "blank_with_nil_options"], ["blank"]] - assert execute(blank1, %TestUser{}, custom_opts) == true - assert execute(blank2, %TestUser{}, custom_opts) == true - end - - test "not BLANK with changed default opts" do - custom_opts = [map_type: :atom, nil_values: ["IAmTheNewNil"]] - blank1 = [["load", "nil_val"], ["blank"]] - blank2 = [["load", "nil_val"], ["blank"]] - assert execute(blank1, %TestUser{}, custom_opts) == false - assert execute(blank2, %TestUser{}, custom_opts) == false - end - end - -end diff --git a/test/predicator/evaluator_operations/ends_with_test.exs b/test/predicator/evaluator_operations/ends_with_test.exs deleted file mode 100644 index 2dc7de9..0000000 --- a/test/predicator/evaluator_operations/ends_with_test.exs +++ /dev/null @@ -1,27 +0,0 @@ -defmodule Predicator.EvaluatorOperation.EndsWithTest do - use ExUnit.Case - import Predicator.Evaluator - - @moduletag :parsed - - defmodule TestUser, do: defstruct [website: "sanfransiscoisoldnews.com"] - - describe "[\"ENDSWITH\"] Comparison operator" do - test "execute/1 lit val ends with charset" do - inst = [["lit", "13 dollars"], ["lit", "dollars"], ["comparator", "ENDSWITH"]] - assert execute(inst) == true - end - - test "execute/2 load val ends with charset" do - inst = [["load", "website"], ["lit", ".com"], ["comparator", "ENDSWITH"]] - assert execute(inst, %TestUser{}) == true - end - - test "execute/3 load val ends with charset" do - map = %{"website" => "sanfransiscoisoldnews.com"} - inst = [["load", "website"], ["lit", ".com"], ["comparator", "ENDSWITH"]] - assert execute(inst, map, [map_type: :string]) == true - end - end - -end diff --git a/test/predicator/evaluator_operations/equal_test.exs b/test/predicator/evaluator_operations/equal_test.exs deleted file mode 100644 index 1afc3b1..0000000 --- a/test/predicator/evaluator_operations/equal_test.exs +++ /dev/null @@ -1,33 +0,0 @@ -defmodule Predicator.EvaluatorOperation.EqualTest do - use ExUnit.Case - import Predicator.Evaluator - - @moduletag :parsed - - defmodule TestUser, do: defstruct [age: 29] - - describe "[\"EQ\"] operation" do - - test "lit integer EQ integer" do - inst = [["lit", 1], ["lit", 1], ["comparator", "EQ"]] - assert execute(inst) == true - end - - test "lit integer not EQ to false" do - inst = [["lit", 1], ["lit", nil], ["comparator", "EQ"]] - assert execute(inst) == false - end - - test "load val EQ integer" do - inst = [["load", "age"], ["lit", 29], ["comparator", "EQ"]] - assert execute(inst, %TestUser{}) == true - end - - test "execute/3 variable EQ to integer in string_keyed_map" do - map = %{"name" => "Joshua", "age" => 29, "metalhead" => "true", "is_superhero" => "falsse"} - inst = [["load", "age"], ["lit", 29], ["comparator", "EQ"]] - - assert execute(inst, map, [map_type: :string]) == true - end - end -end diff --git a/test/predicator/evaluator_operations/greater_than_test.exs b/test/predicator/evaluator_operations/greater_than_test.exs deleted file mode 100644 index 40e0205..0000000 --- a/test/predicator/evaluator_operations/greater_than_test.exs +++ /dev/null @@ -1,27 +0,0 @@ -defmodule Predicator.EvaluatorOperation.GreaterThanTest do - use ExUnit.Case - import Predicator.Evaluator - - @moduletag :parsed - - defmodule TestUser, do: defstruct [string_age: "29", age: 29] - - describe "[\"GT\"] operation" do - test "lit GT integer" do - inst = [["lit", 2], ["lit", 1], ["comparator", "GT"]] - assert execute(inst) == true - end - - test "load val GT integer" do - inst = [["load", "age"], ["lit", 20], ["comparator", "GT"]] - assert execute(inst, %TestUser{}) == true - end - - test "load val from string-keyed map GT integer" do - map = %{"name" => "Joshua", "age" => 29, "metalhead" => "true", "is_superhero" => "falsse"} - inst = [["load", "age"], ["lit", 20], ["comparator", "GT"]] - - assert execute(inst, map, [map_type: :string]) == true - end - end -end diff --git a/test/predicator/evaluator_operations/in_test.exs b/test/predicator/evaluator_operations/in_test.exs deleted file mode 100644 index 59064e0..0000000 --- a/test/predicator/evaluator_operations/in_test.exs +++ /dev/null @@ -1,19 +0,0 @@ -defmodule Predicator.EvaluatorOperation.InTest do - use ExUnit.Case - import Predicator.Evaluator - - @moduletag :parsed - - describe "[\"IN\"] operation" do - test "integer IN list" do - inst = [["lit", 1], ["array", [1, 2]], ["comparator", "IN"]] - - assert execute(inst) == true - end - - test "string IN list" do - inst = [["lit", "UT"], ["array", ["UT", "NM"]], ["comparator", "IN"]] - assert execute(inst) == true - end - end -end diff --git a/test/predicator/evaluator_operations/jump_test.exs b/test/predicator/evaluator_operations/jump_test.exs deleted file mode 100644 index 17b8e29..0000000 --- a/test/predicator/evaluator_operations/jump_test.exs +++ /dev/null @@ -1,24 +0,0 @@ -defmodule Predicator.EvaluatorOperation.JumpTest do - use ExUnit.Case - import Predicator.Evaluator - - @moduletag :parsed - - describe "[\"JUMP\"] operation" do - test "true and true" do - inst = [["lit", true], ["jfalse", 2], ["lit", true]] - assert execute(inst) == true - end - - test "true or false" do - inst = [["lit", true], ["jtrue", 2], ["lit", false]] - assert execute(inst) == true - end - - @tag :jump - test "false or integer equal integer" do - inst = [["lit", false], ["jtrue", 4], ["lit", 1], ["lit", 1], ["comparator", "EQ"]] - assert execute(inst) == true - end - end -end diff --git a/test/predicator/evaluator_operations/less_than_test.exs b/test/predicator/evaluator_operations/less_than_test.exs deleted file mode 100644 index fc5a576..0000000 --- a/test/predicator/evaluator_operations/less_than_test.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Predicator.EvaluatorOperation.LessThanTest do - use ExUnit.Case - import Predicator.Evaluator - - @moduletag :parsed - - setup do - user = %{"string_age" => "29", "age" => 29} - {:ok, %{user: user}} - end - - describe "[\"LT\"] operation" do - test "load val LT integer", context do - inst = [["load", "age"], ["lit", 30], ["comparator", "LT"]] - assert execute(inst, context.user) == true - end - - test "load val from string-keyed map LT integer" do - str_map = %{"name" => "Joshua", "age" => 29, "metalhead" => "true", "is_superhero" => "falsse"} - inst = [["load", "age"], ["lit", 30], ["comparator", "LT"]] - - assert execute(inst, str_map, [map_type: :string]) == true - end - end - -end diff --git a/test/predicator/evaluator_operations/not_in_test.exs b/test/predicator/evaluator_operations/not_in_test.exs deleted file mode 100644 index e7727ed..0000000 --- a/test/predicator/evaluator_operations/not_in_test.exs +++ /dev/null @@ -1,30 +0,0 @@ -defmodule Predicator.EvaluatorOperation.NotInTest do - use ExUnit.Case - import Predicator.Evaluator - - @moduletag :parsed - - defmodule TestUser, do: defstruct [age: 30, state: "WA"] - - describe "[\"NOTIN\"] operation" do - test "lit integer NOTIN list" do - inst = [["lit", 3], ["array", [1, 2]], ["comparator", "NOTIN"]] - assert execute(inst) == true - end - - test "lit string NOTIN list" do - inst = [["lit", "NY"], ["array", ["UT", "NM"]], ["comparator", "NOTIN"]] - assert execute(inst) == true - end - - test "load integer NOTIN list" do - inst = [["load", "age"], ["array", [1, 2]], ["comparator", "NOTIN"]] - assert execute(inst, %TestUser{}) == true - end - - test "load string NOTIN list" do - inst = [["load", "state"], ["array", ["UT", "NM"]], ["comparator", "NOTIN"]] - assert execute(inst, %TestUser{}) == true - end - end -end diff --git a/test/predicator/evaluator_operations/present_test.exs b/test/predicator/evaluator_operations/present_test.exs deleted file mode 100644 index e66473e..0000000 --- a/test/predicator/evaluator_operations/present_test.exs +++ /dev/null @@ -1,57 +0,0 @@ -defmodule Predicator.EvaluatorOperation.PresentTest do - use ExUnit.Case - import Predicator.Evaluator - - @moduletag :parsed - - defmodule TestUser do - defstruct [ - age: 29, - metalhead: "true", - nil_val: nil, - blank_with_nil_options: "dog" - ] - end - - describe "[\"PRESENT\"] operation" do - test "true when existing val" do - inst1 = [["lit", 12], ["present"]] - inst2 = [["lit", " "], ["present"]] - inst3 = [["lit", "hello"], ["present"]] - inst4 = [["lit", %{key: "some_val"}], ["present"]] - assert execute(inst1) == true - assert execute(inst2) == true - assert execute(inst3) == true - assert execute(inst4) == true - end - - test "false when blank val" do - inst1 = [["lit", ""], ["present"]] - inst2 = [["lit", nil], ["present"]] - assert execute(inst1) == false - assert execute(inst2) == false - end - - test "true when loaded existing val" do - inst1 = [["load", "age"], ["present"]] - inst2 = [["load", "metalhead"], ["present"]] - - assert execute(inst1, %TestUser{}) == true - assert execute(inst2, %TestUser{}) == true - end - - test "false when loaded blank val" do - inst1 = [["load", "nil_val"], ["present"]] - assert execute(inst1, %TestUser{}) == false - end - - test "tests PRESENT with load & extra nil opts" do - custom_opts = [map_type: :atom, nil_values: ["", nil, "dog"]] - - present1 = [["load", "blank_with_nil_options"], ["present"]] - present2 = [["load", "blank_with_nil_options"], ["present"]] - assert execute(present1, %TestUser{}, custom_opts) == false - assert execute(present2, %TestUser{}, custom_opts) == false - end - end -end diff --git a/test/predicator/evaluator_operations/starts_with_test.exs b/test/predicator/evaluator_operations/starts_with_test.exs deleted file mode 100644 index 04aa926..0000000 --- a/test/predicator/evaluator_operations/starts_with_test.exs +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Predicator.EvaluatorOperation.StartsWithTest do - use ExUnit.Case - import Predicator.Evaluator - - @moduletag :parsed - - defmodule TestUser, do: defstruct [website: "http://sanfransiscoisoldnews.com"] - - describe "[\"STARTSWITH\"] Comparison operator" do - test "execute/1 lit val ends with charset" do - inst = [["lit", "Ms. Tulip"], ["lit", "Ms."], ["comparator", "STARTSWITH"]] - assert execute(inst) == true - end - - test "execute/2 load val ends with charset" do - inst = [["load", "website"], ["lit", "http://"], ["comparator", "STARTSWITH"]] - assert execute(inst, %TestUser{}) == true - end - - test "execute/3 load val ends with charset" do - map = %{"date" => "monday the 31st"} - inst = [["load", "date"], ["lit", "monday"], ["comparator", "STARTSWITH"]] - assert execute(inst, map, [map_type: :string]) == true - end - end -end diff --git a/test/predicator/evaluator_operations/to_bool_test.exs b/test/predicator/evaluator_operations/to_bool_test.exs deleted file mode 100644 index f45851b..0000000 --- a/test/predicator/evaluator_operations/to_bool_test.exs +++ /dev/null @@ -1,49 +0,0 @@ -defmodule Predicator.EvaluatorOperation.ToBoolTest do - use ExUnit.Case - import Predicator.Evaluator - - @moduletag :parsed - - defmodule TestUser do - defstruct [ - name: "Joshua", - metalhead: "true", - is_superhero: "falsse", - true_val: true, - false_val: false - ] - end - - describe "[\"TOBOOL\"] operation" do - test "true load val" do - inst = [["load", "true_val"], ["to_bool"]] - assert execute(inst, %TestUser{}) == true - end - - test "false load val" do - inst = [["load", "metalhead"], ["to_bool"]] - assert execute(inst, %TestUser{}) == true - end - - test "random load val" do - inst = [["load", "metalhead"], ["to_bool"]] - assert execute(inst, %TestUser{}) == true - end - - test "refutes binary being coerced into TOBOOL value" do - inst = [["load", "name"], ["to_bool"]] - refute execute(inst, %TestUser{}) == true - end - - test "returns TOBOOL error tuple" do - inst = [["load", "is_superhero"], ["to_bool"]] - assert {:error, %Predicator.ValueError{ - error: "Non valid load value to evaluate", - instruction_pointer: 1, - instructions: [["load", "is_superhero"], ["to_bool"]], - stack: ["falsse"] - } - } = execute(inst, %TestUser{}) - end - end -end diff --git a/test/predicator/evaluator_operations/to_date_test.exs b/test/predicator/evaluator_operations/to_date_test.exs deleted file mode 100644 index 7e93b1a..0000000 --- a/test/predicator/evaluator_operations/to_date_test.exs +++ /dev/null @@ -1,37 +0,0 @@ -defmodule Predicator.EvaluatorOperation.ToDateTest do - use ExUnit.Case - import Predicator.Evaluator - - @moduletag :parsed - - defmodule TestUser, do: defstruct [created_at: "2012-01-31 18:14:13.0", string_age: "29", age: 29] - - describe "Predicator.Evaluator.Date module import functions" do - - end - - # "2017-09-10" - describe "[\"TO_DATE\"] operation" do - test "lit val convert" do - inst = [["lit", "2017-09-10"], ["to_date"], ["lit", "2017-09-10"], ["to_date"], ["comparator", "EQ"]] - assert execute(inst) == true - end - end - - - describe "[\"DATE_AGO\"] operation" do - test "less then eval false" do - inst = [["load", "created_at"], ["to_date"], ["lit", 259200], ["date_ago"], ["lit", 432000], ["date_ago"], ["comparator", "BETWEEN"]] - assert execute(inst, %TestUser{}) == false - end - end - - - describe "[\"DATE_FROM_NOW\"] operation" do - test "less then eval true" do - inst = [["load", "created_at"], ["to_date"], ["lit", 259200], ["date_from_now"], ["lit", 432000], ["date_from_now"], ["comparator", "BETWEEN"]] - assert execute(inst, %TestUser{}) == false - end - end - -end diff --git a/test/predicator/evaluator_operations/to_int_test.exs b/test/predicator/evaluator_operations/to_int_test.exs deleted file mode 100644 index 2769cfb..0000000 --- a/test/predicator/evaluator_operations/to_int_test.exs +++ /dev/null @@ -1,31 +0,0 @@ -defmodule Predicator.EvaluatorOperation.ToIntTest do - use ExUnit.Case - import Predicator.Evaluator - - @moduletag :parsed - - defmodule TestUser, do: defstruct [string_age: "29", age: 29] - - describe "[\"TOINT\"] operation" do - test "string_int coercion from lit val" do - inst = [ - ["lit", "29"], - ["to_int"], - ["load", "age"], - ["comparator", "EQ"] - ] - assert execute(inst, %TestUser{}) == true - end - - test "string_int coercion from load" do - inst = [ - ["load", "string_age"], - ["to_int"], - ["lit", 29], - ["comparator", "EQ"] - ] - assert execute(inst, %TestUser{}) == true - end - - end -end diff --git a/test/predicator/evaluator_operations/to_string_test.exs b/test/predicator/evaluator_operations/to_string_test.exs deleted file mode 100644 index 3a57694..0000000 --- a/test/predicator/evaluator_operations/to_string_test.exs +++ /dev/null @@ -1,73 +0,0 @@ -defmodule Predicator.EvaluatorOperation.ToStringTest do - use ExUnit.Case - import Predicator.Evaluator - - @moduletag :parsed - - defmodule TestUser do - defstruct [ - age: 29, - string_age: "29", - nil_val: nil, - true_val: true, - false_val: false - ] - end - - describe "[\"TOSTRING\"] operation" do - test "integer coercion from lit & load val" do - inst = [ - ["lit", 29], ["to_str"], - ["lit", "29"], ["comparator", "EQ"] - ] - - inst2 = [ - ["load", "age"], ["to_str"], - ["lit", "29"], ["comparator", "EQ"] - ] - assert execute(inst) == true - assert execute(inst2, %TestUser{}) == true - end - - test "nil coercion from lit & load val" do - inst = [ - ["lit", nil], ["to_str"], - ["lit", "nil"], ["comparator", "EQ"] - ] - - inst2 = [ - ["load", "nil_val"], ["to_str"], - ["lit", "nil"], ["comparator", "EQ"] - ] - - assert execute(inst) == true - assert execute(inst2, %TestUser{}) == true - end - - test "true coercion from lit & load val" do - true_inst = [ - ["lit", true], ["to_str"], - ["lit", "true"], ["comparator", "EQ"] - ] - true_inst2 = [ - ["load", "true_val"], ["to_str"], - ["lit", "true"], ["comparator", "EQ"] - ] - assert execute(true_inst) == true - assert execute(true_inst2, %TestUser{}) == true - end - - test "false coercion from lit & load val" do - true_inst = [ - ["lit", false], ["to_str"], - ["lit", "false"], ["comparator", "EQ"] - ] - true_inst2 = [ - ["load", "false_val"], ["to_str"], - ["lit", "false"], ["comparator", "EQ"] - ] - assert execute(true_inst) == true - assert execute(true_inst2, %TestUser{}) == true - end - end -end diff --git a/test/predicator/evaluator_test.exs b/test/predicator/evaluator_test.exs index b0ab7e9..6884a22 100644 --- a/test/predicator/evaluator_test.exs +++ b/test/predicator/evaluator_test.exs @@ -1,97 +1,184 @@ defmodule Predicator.EvaluatorTest do - use ExUnit.Case, async: false - import Predicator.Evaluator + use ExUnit.Case, async: true + + alias Predicator.Evaluator + doctest Predicator.Evaluator - defmodule TestUser do - defstruct [ - name: "Joshua", - string_age: "29", - age: 29, - metalhead: "true", - is_superhero: "falsse", - nil_val: nil, - blank_with_nil_options: "dog", - true_val: true, - false_val: false - ] - end + describe "evaluate/2 with lit instructions" do + test "evaluates single literal integer" do + instructions = [["lit", 42]] + assert Evaluator.evaluate(instructions) == 42 + end + + test "evaluates single literal boolean" do + instructions = [["lit", true]] + assert Evaluator.evaluate(instructions) == true - describe "[\"lit\"] instruction" do - test "true" do - inst = [["lit", true]] - assert execute(inst) == true + instructions = [["lit", false]] + assert Evaluator.evaluate(instructions) == false end - test "false" do - inst = [["lit", false]] - assert execute(inst) == false + test "evaluates single literal string" do + instructions = [["lit", "hello"]] + assert Evaluator.evaluate(instructions) == "hello" end - test "lit NOT true" do - inst = [["lit", true], ["not"]] - assert execute(inst) == false + test "evaluates single literal list" do + instructions = [["lit", [1, 2, 3]]] + assert Evaluator.evaluate(instructions) == [1, 2, 3] end - test "lit NOT false" do - inst = [["lit", false], ["not"]] - assert execute(inst) == true + test "evaluates literal :undefined" do + instructions = [["lit", :undefined]] + assert Evaluator.evaluate(instructions) == :undefined + end + + test "multiple literals - returns last one pushed (top of stack)" do + instructions = [ + ["lit", 1], + ["lit", 2], + ["lit", 3] + ] + + assert Evaluator.evaluate(instructions) == 3 end end - describe "[\"load\"] instruction" do - test "true" do - inst = [["load", "true_val"]] - assert execute(inst, %TestUser{}) == true + describe "evaluate/2 with load instructions" do + test "loads existing string key from context" do + instructions = [["load", "score"]] + context = %{"score" => 85} + + assert Evaluator.evaluate(instructions, context) == 85 + end + + test "loads existing atom key from context" do + instructions = [["load", "score"]] + context = %{score: 85} + + assert Evaluator.evaluate(instructions, context) == 85 + end + + test "returns :undefined for missing key" do + instructions = [["load", "missing"]] + context = %{"score" => 85} + + assert Evaluator.evaluate(instructions, context) == :undefined + end + + test "returns :undefined for empty context" do + instructions = [["load", "anything"]] + context = %{} + + assert Evaluator.evaluate(instructions, context) == :undefined + end + + test "prefers string key over atom key" do + instructions = [["load", "key"]] + context = %{"key" => "string_value", key: "atom_value"} + + assert Evaluator.evaluate(instructions, context) == "string_value" end - test "false" do - inst = [["load", "false_val"]] - assert execute(inst, %TestUser{}) == false + test "falls back to atom key if string key doesn't exist" do + instructions = [["load", "key"]] + context = %{key: "atom_value"} + + assert Evaluator.evaluate(instructions, context) == "atom_value" end + end + + describe "evaluate/2 with mixed instructions" do + test "load then literal" do + instructions = [ + ["load", "name"], + ["lit", 42] + ] + + context = %{"name" => "Alice"} - test "load NOT true" do - inst = [["load", "true_val"], ["not"]] - assert execute(inst, %TestUser{}) == false + # Should return 42 (last value on stack) + assert Evaluator.evaluate(instructions, context) == 42 end - test "load NOT false" do - inst = [["load", "false_val"], ["not"]] - assert execute(inst, %TestUser{}) == true + test "literal then load" do + instructions = [ + ["lit", "hello"], + ["load", "name"] + ] + + context = %{"name" => "Alice"} + + # Should return "Alice" (last value on stack) + assert Evaluator.evaluate(instructions, context) == "Alice" end end - describe "Errors" do - test "InstructionNotCompleteError when coercion not followed by eval instuction" do - inst1 = execute([["lit", 29],["to_str"]], %TestUser{}) - inst2 = execute([["lit", "29"],["to_int"]], %TestUser{}) - inst3 = execute([["lit", "2010-01-31"],["to_date"]], %TestUser{}) + describe "evaluate/2 error cases" do + test "returns error for empty instruction list" do + result = Evaluator.evaluate([]) + assert {:error, "Evaluation completed with empty stack"} = result + end + + test "returns error for invalid instruction" do + instructions = [["invalid_op"]] + result = Evaluator.evaluate(instructions) + assert {:error, "Unknown instruction: " <> _error_msg} = result + end - assert {:error, %Predicator.InstructionNotCompleteError{}} = inst1 - assert {:error, %Predicator.InstructionNotCompleteError{}} = inst2 - assert {:error, %Predicator.InstructionNotCompleteError{}} = inst3 + test "returns error for malformed instruction" do + # missing argument + instructions = [["lit"]] + result = Evaluator.evaluate(instructions) + assert {:error, "Unknown instruction: " <> _error_msg} = result end + end - test "InstructionError on invalid predicate op" do - inst = [["blabla", 2345], ["something", 342]] - assert {:error, %Predicator.InstructionError{ - error: "Non valid predicate instruction", - instructions: [["blabla", 2345], ["something", 342]], - predicate: "blabla", + describe "step/1 and run/1 - low level API" do + test "step executes single instruction" do + evaluator = %Evaluator{ + instructions: [["lit", 42]], instruction_pointer: 0, - stack: [] - }} = execute(inst) + stack: [], + context: %{} + } + + {:ok, new_evaluator} = Evaluator.step(evaluator) + + assert new_evaluator.stack == [42] + assert new_evaluator.instruction_pointer == 1 + refute new_evaluator.halted end - test "InstructionError correct instruction pointer on invalid predicate op" do - inst = [["lit", 3], ["blabla", 2345]] - assert {:error, %Predicator.InstructionError{ - error: "Non valid predicate instruction", - instructions: [["lit", 3], ["blabla", 2345]], - predicate: "blabla", + test "step halts when all instructions completed" do + evaluator = %Evaluator{ + instructions: [["lit", 42]], + # Past the end instruction_pointer: 1, - stack: [3] - }} = execute(inst) + stack: [42], + context: %{} + } + + {:ok, final_evaluator} = Evaluator.step(evaluator) + + assert final_evaluator.halted + end + + test "run executes all instructions" do + evaluator = %Evaluator{ + instructions: [["lit", 1], ["lit", 2]], + instruction_pointer: 0, + stack: [], + context: %{} + } + + {:ok, final_evaluator} = Evaluator.run(evaluator) + + # Stack order: most recent first + assert final_evaluator.stack == [2, 1] + assert final_evaluator.instruction_pointer == 2 + assert final_evaluator.halted end end end diff --git a/test/predicator/machine.exs b/test/predicator/machine.exs deleted file mode 100644 index f0b6e19..0000000 --- a/test/predicator/machine.exs +++ /dev/null @@ -1,3 +0,0 @@ -defmodule Predicator.MachineTest do - use ExUnit.Case; doctest Predicator.Machine -end diff --git a/test/predicator/types_test.exs b/test/predicator/types_test.exs new file mode 100644 index 0000000..27c39da --- /dev/null +++ b/test/predicator/types_test.exs @@ -0,0 +1,52 @@ +defmodule Predicator.TypesTest do + use ExUnit.Case, async: true + + alias Predicator.Types + + doctest Predicator.Types + + describe "undefined?/1" do + test "returns true for :undefined" do + assert Types.undefined?(:undefined) + end + + test "returns false for other values" do + refute Types.undefined?(nil) + refute Types.undefined?(42) + refute Types.undefined?("hello") + refute Types.undefined?(true) + refute Types.undefined?(false) + refute Types.undefined?([]) + end + end + + describe "types_match?/2" do + test "matches integers" do + assert Types.types_match?(1, 2) + assert Types.types_match?(0, -5) + end + + test "matches booleans" do + assert Types.types_match?(true, false) + assert Types.types_match?(false, true) + end + + test "matches binaries" do + assert Types.types_match?("hello", "world") + assert Types.types_match?("", "test") + end + + test "matches lists" do + assert Types.types_match?([], [1, 2, 3]) + assert Types.types_match?([1, 2], ["a", "b"]) + end + + test "does not match different types" do + refute Types.types_match?(1, "hello") + refute Types.types_match?(true, 42) + refute Types.types_match?("test", []) + refute Types.types_match?([], true) + refute Types.types_match?(:undefined, nil) + end + end +end diff --git a/test/predicator_test.exs b/test/predicator_test.exs index 8c6f2a4..29f80cf 100644 --- a/test/predicator_test.exs +++ b/test/predicator_test.exs @@ -1,100 +1,37 @@ defmodule PredicatorTest do - use ExUnit.Case - import Predicator - doctest Predicator - - @moduletag :parsing - - describe "BETWEEN" do - test "not currently supported" do - assert {:error, _} = Predicator.compile("age between 5 and 10") - end - end - - describe "BLANK" do - test "not currently supported" do - assert {:error, _} = Predicator.compile("name blank") - end - end - - describe "ENDSWITH" do - test "not currently supported" do - assert {:error, _} = Predicator.compile("name ends with 'stuff'") - end - end - - describe "EQ" do - test "compiles" do - assert {:ok, _} = Predicator.compile("foo = 1") - end - - test "returns true if the equality is true" do - assert Predicator.matches?("foo = 1", foo: 1) == true - end - - test "returns false if the equality is untrue" do - assert Predicator.matches?("foo = 1", foo: 2) == false - end - end + use ExUnit.Case, async: true - describe "GT" do - test "compiles" do - assert {:ok, _} = Predicator.compile("foo > 1") - end - - test "returns true if the inequality is true" do - assert Predicator.matches?("foo > 1", foo: 2) == true - end - - test "returns false if the inequality is untrue" do - assert Predicator.matches?("foo > 1", foo: 1) == false - end - end - - describe "IN" do - test "not currently supported" do - assert {:error, _} = Predicator.compile("foo in [1, 2, 3]") - end - end - - describe "JUMP" do - end + doctest Predicator - describe "LT" do - test "compiles" do - assert {:ok, _} = Predicator.compile("foo < 1") + describe "execute/2 API" do + test "executes simple literal instruction" do + assert Predicator.execute([["lit", 42]]) == 42 end - test "returns true if the inequality is true" do - assert Predicator.matches?("foo < 1", foo: 0) == true + test "executes load instruction with context" do + context = %{"score" => 85} + assert Predicator.execute([["load", "score"]], context) == 85 end - test "returns false if the inequality is untrue" do - assert Predicator.matches?("foo < 1", foo: 1) == false + test "handles missing context variables" do + assert Predicator.execute([["load", "missing"]], %{}) == :undefined end end - describe "NOTIN" do - test "not currently supported" do - assert {:error, _} = Predicator.compile("foo not in [1, 2, 3]") - end - end + describe "evaluator/2 and run_evaluator/1" do + test "creates and runs evaluator" do + evaluator = Predicator.evaluator([["lit", 42]]) + {:ok, final_state} = Predicator.run_evaluator(evaluator) - describe "PRESENT" do - test "not currently supported" do - assert {:error, _} = Predicator.compile("name present") + assert final_state.stack == [42] + assert final_state.halted == true end - end - describe "STARTSWITH" do - test "not currently supported" do - assert {:error, _} = Predicator.compile("name starts with 'stuff'") - end - end + test "evaluator preserves context" do + context = %{"x" => 10} + evaluator = Predicator.evaluator([["load", "x"]], context) - describe "OR" do - test "not currently supported" do - assert {:error, _} = Predicator.compile("true or false") + assert evaluator.context == context end end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 8f72200..869559e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1 @@ -ExUnit.start(trace: true) +ExUnit.start()