Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 184 additions & 1 deletion lib/predicator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ defmodule Predicator do
Currently supported instructions:
- `["lit", value]` - Push a literal value onto the stack
- `["load", variable_name]` - Load a variable from context onto the stack
- `["compare", operator]` - Compare top two stack values (GT, LT, EQ, GTE, LTE, NE)

## Context

Expand All @@ -45,7 +46,180 @@ defmodule Predicator do
3. The final result is the top value on the stack when execution completes
"""

alias Predicator.{Evaluator, Types}
alias Predicator.{Compiler, Evaluator, Lexer, Parser, Types}

@doc """
Evaluates a predicate expression or instruction list with an optional context.

This is the main entry point for Predicator evaluation. It accepts either:
- A string expression (e.g., "score > 85") which gets compiled automatically
- A pre-compiled instruction list for maximum performance

## Parameters

- `input` - String expression or instruction list
- `context` - Optional context map with variable bindings (default: `%{}`)

## Returns

- The evaluation result (boolean, value, or `:undefined`)
- `{:error, reason}` if parsing or execution fails

## Examples

# String expressions (compiled automatically)
iex> Predicator.evaluate("score > 85", %{"score" => 90})
true

iex> Predicator.evaluate("name = \\"John\\"", %{"name" => "John"})
true

iex> Predicator.evaluate("age >= 18", %{"age" => 16})
false

# Pre-compiled instruction lists (maximum performance)
iex> Predicator.evaluate([["load", "score"], ["lit", 85], ["compare", "GT"]], %{"score" => 90})
true

iex> Predicator.evaluate([["lit", 42]])
42

# Parse errors are returned
iex> Predicator.evaluate("score >", %{})
{:error, "Expected number, string, boolean, identifier, or '(' but found end of input at line 1, column 8"}
"""
@spec evaluate(binary() | Types.instruction_list(), Types.context()) :: Types.result()
def evaluate(input, context \\ %{})

def evaluate(expression, context) when is_binary(expression) and is_map(context) do
with {:ok, tokens} <- Lexer.tokenize(expression),
{:ok, ast} <- Parser.parse(tokens) do
instructions = Compiler.to_instructions(ast)
Evaluator.evaluate(instructions, context)
else
{:error, message, line, column} -> {:error, "#{message} at line #{line}, column #{column}"}
end
end

def evaluate(instructions, context) when is_list(instructions) and is_map(context) do
Evaluator.evaluate(instructions, context)
end

@doc """
Evaluates a predicate expression or instruction list, raising on errors.

Similar to `evaluate/2` but raises an exception for error results instead
of returning error tuples. Follows the Elixir convention of bang functions.

## Examples

iex> Predicator.evaluate!("score > 85", %{"score" => 90})
true

iex> Predicator.evaluate!([["lit", 42]])
42

# This would raise an exception:
# Predicator.evaluate!("score >", %{})
"""
@spec evaluate!(binary() | Types.instruction_list(), Types.context()) ::
boolean() | Types.value()
def evaluate!(input, context \\ %{}) do
case evaluate(input, context) do
{:error, reason} -> raise "Evaluation failed: #{reason}"
result -> result
end
end

@doc """
Compiles a string expression to instruction list.

This function allows you to pre-compile expressions for maximum performance
when evaluating the same expression multiple times with different contexts.

## Parameters

- `expression` - String expression to compile

## Returns

- `{:ok, instructions}` - Successfully compiled instructions
- `{:error, message}` - Parse error with details

## Examples

iex> {:ok, instructions} = Predicator.compile("score > 85")
iex> instructions
[["load", "score"], ["lit", 85], ["compare", "GT"]]

iex> Predicator.compile("score >")
{:error, "Expected number, string, boolean, identifier, or '(' but found end of input at line 1, column 8"}
"""
@spec compile(binary()) :: {:ok, Types.instruction_list()} | {:error, binary()}
def compile(expression) when is_binary(expression) do
with {:ok, tokens} <- Lexer.tokenize(expression),
{:ok, ast} <- Parser.parse(tokens) do
instructions = Compiler.to_instructions(ast)
{:ok, instructions}
else
{:error, message, line, column} -> {:error, "#{message} at line #{line}, column #{column}"}
end
end

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

This function takes an Abstract Syntax Tree and generates a readable string
representation. This is useful for debugging, displaying expressions to users,
and documentation purposes.

## Parameters

- `ast` - The Abstract Syntax Tree to convert
- `opts` - Optional formatting options:
- `:parentheses` - `:minimal` (default) | `:explicit` | `:none`
- `:spacing` - `:normal` (default) | `:compact` | `:verbose`

## Returns

String representation of the AST

## Examples

iex> ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}
iex> Predicator.decompile(ast)
"score > 85"

iex> ast = {:literal, 42}
iex> Predicator.decompile(ast)
"42"

iex> ast = {:comparison, :eq, {:identifier, "active"}, {:literal, true}}
iex> Predicator.decompile(ast, parentheses: :explicit, spacing: :verbose)
"(active = true)"
"""
@spec decompile(Parser.ast(), keyword()) :: binary()
def decompile(ast, opts \\ []) do
Compiler.to_string(ast, opts)
end

@doc """
Compiles a string expression to instruction list, raising on errors.

Similar to `compile/1` but raises an exception for parse errors.

## Examples

iex> Predicator.compile!("score > 85")
[["load", "score"], ["lit", 85], ["compare", "GT"]]
"""
@spec compile!(binary()) :: Types.instruction_list()
def compile!(expression) when is_binary(expression) do
case compile(expression) do
{:ok, instructions} -> instructions
{:error, reason} -> raise "Compilation failed: #{reason}"
end
end

@doc """
Executes a list of instructions with an optional context.
Expand Down Expand Up @@ -85,6 +259,15 @@ defmodule Predicator do
iex> instructions = [["lit", 1], ["lit", 2], ["lit", 3]]
iex> Predicator.execute(instructions)
3

# Comparison operations
iex> instructions = [["load", "score"], ["lit", 85], ["compare", "GT"]]
iex> Predicator.execute(instructions, %{"score" => 90})
true

iex> instructions = [["load", "age"], ["lit", 18], ["compare", "GTE"]]
iex> Predicator.execute(instructions, %{"age" => 16})
false
"""
@spec execute(Types.instruction_list(), Types.context()) :: Types.result()
def execute(instructions, context \\ %{}) when is_list(instructions) and is_map(context) do
Expand Down
90 changes: 90 additions & 0 deletions lib/predicator/compiler.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
defmodule Predicator.Compiler do
@moduledoc """
Compiler that converts AST to various representations using visitors.

The compiler orchestrates different visitors to transform Abstract Syntax Trees
into executable instructions, string representations, or other formats.

## Examples

iex> ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}
iex> Predicator.Compiler.to_instructions(ast)
[["load", "score"], ["lit", 85], ["compare", "GT"]]

# Future visitors will enable:
# iex> Predicator.Compiler.to_string(ast)
# "score > 85"

# iex> Predicator.Compiler.to_dot(ast)
# "digraph {...}"
"""

alias Predicator.{InstructionsVisitor, Parser, StringVisitor, Visitor}

@doc """
Converts an AST to stack machine instructions.

Uses the InstructionsVisitor to generate a list of instructions that can
be executed by the stack-based evaluator.

## Parameters

- `ast` - The Abstract Syntax Tree to compile
- `opts` - Optional compiler options

## Returns

List of instructions in the format `[["operation", ...args]]`

## Examples

iex> ast = {:literal, 42}
iex> Predicator.Compiler.to_instructions(ast)
[["lit", 42]]

iex> ast = {:comparison, :eq, {:identifier, "name"}, {:literal, "John"}}
iex> Predicator.Compiler.to_instructions(ast)
[["load", "name"], ["lit", "John"], ["compare", "EQ"]]
"""
@spec to_instructions(Parser.ast(), keyword()) :: [[binary() | term()]]
def to_instructions(ast, opts \\ []) do
Visitor.accept(ast, InstructionsVisitor, opts)
end

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

Uses the StringVisitor to generate a readable string representation
of the Abstract Syntax Tree. This is useful for debugging, documentation,
and displaying expressions to users.

## Parameters

- `ast` - The Abstract Syntax Tree to convert
- `opts` - Optional formatting options:
- `:parentheses` - `:minimal` (default) | `:explicit` | `:none`
- `:spacing` - `:normal` (default) | `:compact` | `:verbose`

## Returns

String representation of the AST

## Examples

iex> ast = {:literal, 42}
iex> Predicator.Compiler.to_string(ast)
"42"

iex> ast = {:comparison, :gt, {:identifier, "score"}, {:literal, 85}}
iex> Predicator.Compiler.to_string(ast)
"score > 85"

iex> ast = {:comparison, :gt, {:identifier, "age"}, {:literal, 21}}
iex> Predicator.Compiler.to_string(ast, parentheses: :explicit)
"(age > 21)"
"""
@spec to_string(Parser.ast(), keyword()) :: binary()
def to_string(ast, opts \\ []) do
Visitor.accept(ast, StringVisitor, opts)
end
end
62 changes: 62 additions & 0 deletions lib/predicator/evaluator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,31 @@ defmodule Predicator.Evaluator do
end
end

@doc """
Evaluates a list of instructions with the given context, raising on errors.

Similar to `evaluate/2` but raises an exception for error results instead
of returning error tuples. Follows the Elixir convention of bang functions.

## Examples

iex> Predicator.Evaluator.evaluate!([["lit", 42]], %{})
42

iex> Predicator.Evaluator.evaluate!([["load", "score"]], %{"score" => 85})
85

# This would raise an exception:
# Predicator.Evaluator.evaluate!([["unknown_op"]], %{})
"""
@spec evaluate!(Types.instruction_list(), Types.context()) :: boolean() | :undefined
def evaluate!(instructions, context \\ %{}) when is_list(instructions) and is_map(context) do
case evaluate(instructions, context) do
{:error, reason} -> raise "Evaluation failed: #{reason}"
result -> result
end
end

@doc """
Runs the evaluator until it halts or encounters an error.

Expand Down Expand Up @@ -128,6 +153,9 @@ defmodule Predicator.Evaluator do
value = load_from_context(evaluator.context, variable_name)
{:ok, push_stack(evaluator, value)}

["compare", operator] when operator in ["GT", "LT", "EQ", "GTE", "LTE", "NE"] ->
execute_compare(evaluator, operator)

unknown ->
{:error, "Unknown instruction: #{inspect(unknown)}"}
end
Expand All @@ -141,6 +169,40 @@ defmodule Predicator.Evaluator do

defp advance_instruction_pointer({:error, reason}), do: {:error, reason}

@spec execute_compare(t(), binary()) :: {:ok, t()} | {:error, term()}
defp execute_compare(%__MODULE__{stack: [right | [left | rest]]} = evaluator, operator) do
result = compare_values(left, right, operator)
{:ok, %__MODULE__{evaluator | stack: [result | rest]}}
end

defp execute_compare(%__MODULE__{stack: stack}, _operator) do
{:error, "Comparison requires two values on stack, got: #{length(stack)}"}
end

# Custom guard for type matching
defguard types_match(a, b)
when (is_integer(a) and is_integer(b)) or
(is_boolean(a) and is_boolean(b)) or
(is_binary(a) and is_binary(b)) or
(is_list(a) and is_list(b))

@spec compare_values(Types.value(), Types.value(), binary()) :: Types.value()
defp compare_values(:undefined, _right, _operator), do: :undefined
defp compare_values(_left, :undefined, _operator), do: :undefined

defp compare_values(left, right, operator) when types_match(left, right) do
case operator do
"GT" -> left > right
"LT" -> left < right
"EQ" -> left == right
"GTE" -> left >= right
"LTE" -> left <= right
"NE" -> left != right
end
end

defp compare_values(_left, _right, _operator), do: :undefined

@spec push_stack(t(), Types.value()) :: t()
defp push_stack(%__MODULE__{stack: stack} = evaluator, value) do
%__MODULE__{evaluator | stack: [value | stack]}
Expand Down
Loading