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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ test/predicator/
## Recent Additions (2025)

### Object Literals (v3.1.0 - JavaScript-Style Objects)

- **Syntax Support**: Complete JavaScript-style object literal syntax (`{}`, `{name: "John"}`, `{user: {role: "admin"}}`)
- **Lexer Extensions**: Added `:lbrace`, `:rbrace`, `:colon` tokens for object parsing
- **Parser Grammar**: Comprehensive object parsing with proper precedence and error handling
Expand All @@ -161,6 +162,7 @@ test/predicator/
- **Type Safety**: Enhanced type matching guards to support maps while preserving Date/DateTime separation
- **Comprehensive Testing**: 47 new tests covering evaluation, edge cases, and integration scenarios
- **Examples**:

```elixir
Predicator.evaluate("{name: 'John', age: 30}", %{}) # Object construction
Predicator.evaluate("{score: 85} = user_data", %{"user_data" => %{"score" => 85}}) # Comparison
Expand Down Expand Up @@ -242,13 +244,15 @@ test/predicator/
- **Examples**: `role in ["admin", "manager"]`, `[1, 2, 3] contains 2`

### Object Literals (v3.1.0 - JavaScript-Style Objects)

- **Syntax**: `{}`, `{name: "John"}`, `{user: {role: "admin", active: true}}`
- **Key Types**: Identifiers (`name`) and strings (`"name"`) supported as keys
- **Nested Objects**: Unlimited nesting depth with proper evaluation order
- **Stack-based Compilation**: Uses `object_new` and `object_set` instructions for efficient evaluation
- **Type Safety**: Object equality comparisons with proper map type guards
- **String Decompilation**: Round-trip formatting preserves original syntax
- **Examples**:

```elixir
Predicator.evaluate("{name: 'John'} = user_data", %{}) # Object comparison
Predicator.evaluate("{score: 85, active: true}", %{}) # Object construction
Expand Down
10 changes: 6 additions & 4 deletions lib/predicator/evaluator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ defmodule Predicator.Evaluator do
- `["call", function_name, arg_count]` - Call function with arguments from stack
"""

alias Predicator.Functions.SystemFunctions
alias Predicator.Functions.{JSONFunctions, MathFunctions, SystemFunctions}
alias Predicator.Types
alias Predicator.Errors.{EvaluationError, TypeMismatchError}

Expand Down Expand Up @@ -79,9 +79,11 @@ defmodule Predicator.Evaluator do
def evaluate(instructions, context \\ %{}, opts \\ [])
when is_list(instructions) and is_map(context) do
# Merge custom functions with system functions
custom_functions = Keyword.get(opts, :functions, %{})
system_functions = SystemFunctions.all_functions()
merged_functions = Map.merge(system_functions, custom_functions)
merged_functions =
SystemFunctions.all_functions()
|> Map.merge(JSONFunctions.all_functions())
|> Map.merge(MathFunctions.all_functions())
|> Map.merge(Keyword.get(opts, :functions, %{}))

evaluator = %__MODULE__{
instructions: instructions,
Expand Down
63 changes: 63 additions & 0 deletions lib/predicator/functions/json_functions.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
defmodule Predicator.Functions.JSONFunctions do
@moduledoc """
JSON manipulation functions for Predicator expressions.

Provides SCXML-compatible JSON functions for serializing and parsing data.

## Available Functions

- `JSON.stringify(value)` - Converts a value to a JSON string
- `JSON.parse(string)` - Parses a JSON string into a value

## Examples

iex> {:ok, result} = Predicator.evaluate("JSON.stringify(user)",
...> %{"user" => %{"name" => "John", "age" => 30}},
...> functions: Predicator.Functions.JSONFunctions.all_functions())
iex> result
~s({"age":30,"name":"John"})

iex> {:ok, result} = Predicator.evaluate("JSON.parse(data)",
...> %{"data" => ~s({"status":"ok"})},
...> functions: Predicator.Functions.JSONFunctions.all_functions())
iex> result
%{"status" => "ok"}
"""

@spec all_functions() :: %{binary() => {non_neg_integer(), function()}}
def all_functions do
%{
"JSON.stringify" => {1, &call_stringify/2},
"JSON.parse" => {1, &call_parse/2}
}
end

defp call_stringify([value], _context) do
case Jason.encode(value) do
{:ok, json} ->
{:ok, json}

{:error, _encode_error} ->
# For values that can't be JSON encoded, convert to string
{:ok, inspect(value)}
end
rescue
error -> {:error, "JSON.stringify failed: #{Exception.message(error)}"}
end

defp call_parse([json_string], _context) when is_binary(json_string) do
case Jason.decode(json_string) do
{:ok, value} ->
{:ok, value}

{:error, error} ->
{:error, "Invalid JSON: #{Exception.message(error)}"}
end
rescue
error -> {:error, "JSON.parse failed: #{Exception.message(error)}"}
end

defp call_parse([_value], _context) do
{:error, "JSON.parse expects a string argument"}
end
end
118 changes: 118 additions & 0 deletions lib/predicator/functions/math_functions.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
defmodule Predicator.Functions.MathFunctions do
@moduledoc """
Mathematical functions for Predicator expressions.

Provides SCXML-compatible Math functions for numerical computations.

## Available Functions

- `Math.pow(base, exponent)` - Raises base to the power of exponent
- `Math.sqrt(value)` - Returns the square root of a number
- `Math.abs(value)` - Returns the absolute value
- `Math.floor(value)` - Rounds down to the nearest integer
- `Math.ceil(value)` - Rounds up to the nearest integer
- `Math.round(value)` - Rounds to the nearest integer
- `Math.min(a, b)` - Returns the smaller of two numbers
- `Math.max(a, b)` - Returns the larger of two numbers
- `Math.random()` - Returns a random float between 0 and 1

## Examples

iex> {:ok, result} = Predicator.evaluate("Math.pow(2, 3)",
...> %{}, functions: Predicator.Functions.MathFunctions.all_functions())
iex> result
8.0

iex> {:ok, result} = Predicator.evaluate("Math.sqrt(16)",
...> %{}, functions: Predicator.Functions.MathFunctions.all_functions())
iex> result
4.0
"""

@spec all_functions() :: %{binary() => {non_neg_integer(), function()}}
def all_functions do
%{
"Math.pow" => {2, &call_pow/2},
"Math.sqrt" => {1, &call_sqrt/2},
"Math.abs" => {1, &call_abs/2},
"Math.floor" => {1, &call_floor/2},
"Math.ceil" => {1, &call_ceil/2},
"Math.round" => {1, &call_round/2},
"Math.min" => {2, &call_min/2},
"Math.max" => {2, &call_max/2},
"Math.random" => {0, &call_random/2}
}
end

defp call_pow([base, exponent], _context) when is_number(base) and is_number(exponent) do
{:ok, :math.pow(base, exponent)}
end

defp call_pow([_base, _exponent], _context) do
{:error, "Math.pow expects two numeric arguments"}
end

defp call_sqrt([value], _context) when is_number(value) and value >= 0 do
{:ok, :math.sqrt(value)}
end

defp call_sqrt([value], _context) when is_number(value) do
{:error, "Math.sqrt expects a non-negative number"}
end

defp call_sqrt([_value], _context) do
{:error, "Math.sqrt expects a numeric argument"}
end

defp call_abs([value], _context) when is_number(value) do
{:ok, abs(value)}
end

defp call_abs([_value], _context) do
{:error, "Math.abs expects a numeric argument"}
end

defp call_floor([value], _context) when is_number(value) do
{:ok, Float.floor(value * 1.0) |> trunc()}
end

defp call_floor([_value], _context) do
{:error, "Math.floor expects a numeric argument"}
end

defp call_ceil([value], _context) when is_number(value) do
{:ok, Float.ceil(value * 1.0) |> trunc()}
end

defp call_ceil([_value], _context) do
{:error, "Math.ceil expects a numeric argument"}
end

defp call_round([value], _context) when is_number(value) do
{:ok, round(value)}
end

defp call_round([_value], _context) do
{:error, "Math.round expects a numeric argument"}
end

defp call_min([a, b], _context) when is_number(a) and is_number(b) do
{:ok, min(a, b)}
end

defp call_min([_a, _b], _context) do
{:error, "Math.min expects two numeric arguments"}
end

defp call_max([a, b], _context) when is_number(a) and is_number(b) do
{:ok, max(a, b)}
end

defp call_max([_a, _b], _context) do
{:error, "Math.max expects two numeric arguments"}
end

defp call_random([], _context) do
{:ok, :rand.uniform()}
end
end
61 changes: 5 additions & 56 deletions lib/predicator/functions/system_functions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,23 @@ defmodule Predicator.Functions.SystemFunctions do
- `lower(string)` - Converts string to lowercase
- `trim(string)` - Removes leading and trailing whitespace

### Numeric Functions
- `abs(number)` - Returns the absolute value of a number
- `max(a, b)` - Returns the larger of two numbers
- `min(a, b)` - Returns the smaller of two numbers

### Date Functions
- `year(date)` - Extracts the year from a date or datetime
- `month(date)` - Extracts the month from a date or datetime
- `day(date)` - Extracts the day from a date or datetime

## Examples

iex> Predicator.BuiltInFunctions.call("len", ["hello"])
iex> Predicator.Functions.SystemFunctions.call("len", ["hello"])
{:ok, 5}

iex> Predicator.BuiltInFunctions.call("upper", ["world"])
iex> Predicator.Functions.SystemFunctions.call("upper", ["world"])
{:ok, "WORLD"}

iex> Predicator.BuiltInFunctions.call("max", [10, 5])
{:ok, 10}
iex> Predicator.Functions.SystemFunctions.call("year", [~D[2023-05-15]])
{:ok, 2023}

iex> Predicator.BuiltInFunctions.call("unknown", [])
iex> Predicator.Functions.SystemFunctions.call("unknown", [])
{:error, "Unknown function: unknown"}
"""

Expand Down Expand Up @@ -69,11 +64,6 @@ defmodule Predicator.Functions.SystemFunctions do
"lower" => {1, &call_lower/2},
"trim" => {1, &call_trim/2},

# Numeric functions
"abs" => {1, &call_abs/2},
"max" => {2, &call_max/2},
"min" => {2, &call_min/2},

# Date functions
"year" => {1, &call_year/2},
"month" => {1, &call_month/2},
Expand Down Expand Up @@ -135,47 +125,6 @@ defmodule Predicator.Functions.SystemFunctions do
{:error, "trim() expects exactly 1 argument"}
end

# Numeric function implementations

@spec call_abs([Types.value()], Types.context()) :: function_result()
defp call_abs([value], _context) when is_integer(value) do
{:ok, abs(value)}
end

defp call_abs([_value], _context) do
{:error, "abs() expects a numeric argument"}
end

defp call_abs(_args, _context) do
{:error, "abs() expects exactly 1 argument"}
end

@spec call_max([Types.value()], Types.context()) :: function_result()
defp call_max([a, b], _context) when is_integer(a) and is_integer(b) do
{:ok, max(a, b)}
end

defp call_max([_a, _b], _context) do
{:error, "max() expects two numeric arguments"}
end

defp call_max(_args, _context) do
{:error, "max() expects exactly 2 arguments"}
end

@spec call_min([Types.value()], Types.context()) :: function_result()
defp call_min([a, b], _context) when is_integer(a) and is_integer(b) do
{:ok, min(a, b)}
end

defp call_min([_a, _b], _context) do
{:error, "min() expects two numeric arguments"}
end

defp call_min(_args, _context) do
{:error, "min() expects exactly 2 arguments"}
end

# Date function implementations

@spec call_year([Types.value()], Types.context()) :: function_result()
Expand Down
Loading