Packages

A high-performance, extensible expression evaluation engine for Elixir.

Current section

Files

Jump to
excellerate lib excellerate.ex
Raw

lib/excellerate.ex

defmodule ExCellerate do
@moduledoc """
ExCellerate is a high-performance expression evaluation engine for Elixir.
It parses text expressions into an intermediate representation (IR) and then
compiles them into native Elixir AST for near-native performance.
## Operators
- **Arithmetic**: `+`, `-`, `*`, `/`, `^` (power), `%` (modulo), `n!` (factorial)
- **Comparison**: `==`, `!=`, `<`, `<=`, `>`, `>=`
- **Logical**: `&&`, `||`, `not`
- **Bitwise**: `&`, `|`, `|^` (xor), `<<`, `>>`, `~` (bnot)
- **Ternary**: `condition ? true_val : false_val`
- **Data access**: `user.profile.name`, `list[0]`, `list[-1]`, `list[*].field` (spread)
## Built-in Functions
### Math
| Function | Description |
|----------|-------------|
| `abs(n)` | Absolute value |
| `round(n)` or `round(n, digits)` | Rounds to nearest integer, or to `digits` decimal places |
| `floor(n)` | Largest integer ≤ `n` |
| `ceil(n)` | Smallest integer ≥ `n` |
| `trunc(n)` | Truncates toward zero (unlike `floor` for negatives) |
| `max(a, b, ...)` or `max(list)` | Maximum of arguments or a list |
| `min(a, b, ...)` or `min(list)` | Minimum of arguments or a list |
| `sign(n)` | Returns -1, 0, or 1 |
| `sqrt(n)` | Square root |
| `exp(n)` | e raised to the power `n` |
| `ln(n)` | Natural logarithm (base e) |
| `log(n, base)` | Logarithm with specified base |
| `log10(n)` | Base-10 logarithm |
| `sum(a, b, ...)` or `sum(list)` | Sums arguments or a list |
| `avg(a, b, ...)` or `avg(list)` | Arithmetic mean of arguments or a list |
### String
| Function | Description |
|----------|-------------|
| `len(s)` or `len(list)` | String length or list length |
| `left(s)` or `left(s, n)` | First character, or first `n` characters |
| `right(s)` or `right(s, n)` | Last character, or last `n` characters |
| `substring(s, start)` | Substring from `start` to end |
| `substring(s, start, len)` | Substring of `len` characters |
| `upper(s)` | Converts to uppercase |
| `lower(s)` | Converts to lowercase |
| `trim(s)` | Removes leading/trailing whitespace |
| `concat(a, b, ...)` | Concatenates values into a string |
| `textjoin(delim, a, b, ...)` | Joins values with a delimiter |
| `replace(s, old, new)` | Replaces all occurrences of `old` with `new` |
| `find(search, text)` or `find(search, text, start)` | 0-based position of `search` in `text`, optionally from `start` |
| `contains(s, term)` | Returns `true` if `term` exists within `s` |
| `underscore(s)` | Downcases, replaces spaces/slashes with underscores, strips special chars |
| `slug(s)` | Downcases, replaces spaces/slashes with hyphens, strips special chars |
### Utility
| Function | Description |
|----------|-------------|
| `if(cond, t)` or `if(cond, t, f)` | Returns `t` if truthy; `f` (or `nil`) otherwise |
| `ifs(c1, v1, c2, v2, ...)` | Returns value for first truthy condition; `nil` if none match |
| `ifnull(val, default)` | Returns `default` if `val` is nil |
| `isnull(val)` | Returns `true` if `val` is nil, `false` otherwise |
| `isblank(val)` | Returns `true` if `val` is nil or a whitespace-only string |
| `coalesce(a, b, ...)` | Returns the first non-nil value |
| `switch(expr, c1, v1, ..., default)` | Multi-way value matching |
| `and(a, b, ...)` | Returns `true` if all arguments are truthy |
| `or(a, b, ...)` | Returns `true` if any argument is truthy |
| `lookup(coll, key)` | Looks up `key` in a map or index in a list (negative indices count from the end) |
| `lookup(coll, key, default)` | Same, with a default for missing keys |
| `match(value, list)` | Returns the 0-based position of `value` in `list` (exact match) |
| `match(value, list, type)` | Approximate match: `1` for ascending (<=), `-1` for descending (>=), `0` for exact |
| `index(list, row)` | Returns the element at 0-based position `row` (negative indices count from the end) |
| `index(array, row, col)` | Returns the element at `row` and `col` in a 2D array |
| `sort(a, b, ...)` or `sort(list)` | Sorts values in ascending order |
| `unique(a, b, ...)` or `unique(list)` | Returns unique values, preserving order of first occurrence |
| `filter(list, predicates)` | Returns items where predicate is `true` |
| `table(key1, list1, ...)` | Builds a list of maps from key/list pairs |
| `take(list, rows)` | Takes the first/last `rows` elements (negative counts from end) |
| `take(list, rows, cols)` | Takes rows and columns from a 2D array; pass `null` to skip a dimension |
| `slice(list, start)` | Returns elements from `start` index to end (negative counts from end) |
| `slice(list, start, len)` | Returns `len` elements starting at `start` |
### Special Forms
| Form | Description |
|------|-------------|
| `let(name, value, expr)` | Lexically binds `name` within `expr` only |
Custom functions can be added via the `ExCellerate.Registry` system.
## Multi-line Expressions
Expressions can be formatted across multiple lines for readability.
Newlines are treated as whitespace by the parser:
expr = \"\"\"
ifs(
score > 90, 'A',
score > 80, 'B',
score > 70, 'C',
true, 'F'
)
\"\"\"
ExCellerate.eval!(expr, %{"score" => 85})
# => "B"
This works with `validate/2` and `compile/2` as well.
## Nil Propagation
Dot and bracket access use **nil propagation**: if any key in a path is
missing or the target is `nil`, the entire expression returns `nil` instead
of raising an error. This mirrors how spreadsheets treat empty cells and
avoids the need for defensive checks at every level of a nested path.
ExCellerate.eval!("user.profile.name", %{"user" => %{}})
# => nil (profile is missing, so .name is never attempted)
ExCellerate.eval!("user.name", %{"user" => nil})
# => nil (user is nil, short-circuits)
ExCellerate.eval!("list[99]", %{"list" => [1, 2, 3]})
# => nil (index out of bounds)
Negative indices count from the end of the list, the same way they work
in Elixir:
ExCellerate.eval!("items[-1]", %{"items" => [1, 2, 3]})
# => 3 (last element)
ExCellerate.eval!("items[-2]", %{"items" => [1, 2, 3]})
# => 2 (second to last)
Use `ifnull/2`, `coalesce/2+`, or a ternary to provide defaults:
ExCellerate.eval!("ifnull(user.name, 'anonymous')", %{"user" => %{}})
# => "anonymous"
**Root variable lookup still raises.** Referencing a variable that doesn't
exist in the scope at all (e.g., `unknown_var`) is treated as a likely typo
and returns `{:error, %ExCellerate.Error{}}`:
ExCellerate.eval("totally_unknown", %{})
# => {:error, %ExCellerate.Error{message: "variable not found: totally_unknown"}}
## Examples
iex> ExCellerate.eval!("1 + 2 * 3")
7
iex> ExCellerate.eval("a + b", %{"a" => 10, "b" => 20})
{:ok, 30}
iex> ExCellerate.eval!("user.name", %{"user" => %{"name" => "Alice"}})
"Alice"
## Column Spread (`[*]`)
The `[*]` operator extracts a field from every element of a list,
returning a new list of values. This enables column-oriented operations
on lists of maps, structs, or nested lists:
# Given a list of maps in scope
scope = %{"orders" => [
%{"product" => "Widget", "price" => 10, "qty" => 2},
%{"product" => "Gadget", "price" => 25, "qty" => 1}
]}
ExCellerate.eval!("orders[*].product", scope)
# => ["Widget", "Gadget"]
ExCellerate.eval!("sum(orders[*].price)", scope)
# => 35
Spread results work with any function that accepts a list: `sum`, `avg`,
`max`, `min`, `len`, and `textjoin`. Subsequent access chains apply to
each element:
ExCellerate.eval!("orders[*].price", scope) # => [10, 25]
ExCellerate.eval!("users[*].profile.name", scope) # deep access
Bracket indexing after a spread selects a specific position from each
element's sub-list:
# scores[1] from each row
ExCellerate.eval!("rows[*].scores[1]", scope)
Nested `[*]` operators flatten across levels:
# departments[*].employees[*].name => all employee names, flattened
### Computed Spread (`.(expr)`)
To evaluate an expression *per element* of a spread, use the `.(expr)`
syntax. Inside the parentheses, bare variable names resolve against each
element rather than the outer scope:
ExCellerate.eval!("orders[*].(qty * price)", scope)
# => [20, 25] (per-row product)
ExCellerate.eval!("sum(orders[*].(qty * price))", scope)
# => 45 (total of per-row products)
Computed spreads also compose with nested `[*]`:
# departments[*].employees[*].(salary * 12) => annualised salaries, flattened
## Let, Filter, and Table
`let/3` introduces a lexical binding that is visible only inside the body
expression; it does not mutate the outer scope:
# Given scope = %{"orders" => [%{"price" => 10, "qty" => 2}, ...]}
let(total, sum(orders[*].price), total + 5)
`filter/2` selects items from a list using a boolean list produced by a
computed spread. The predicate list must be the same length as the input list:
filter(orders, orders[*].(qty > 1))
`table` builds a list of maps from alternating key/list pairs. Use spread
or computed spread to produce the list columns:
table('product', orders[*].product, 'total', orders[*].(qty * price))
# => [%{"product" => "Widget", "total" => 20}, ...]
These compose naturally — filter first, then build a summary table:
let(big, filter(orders, orders[*].(qty > 1)),
table('product', big[*].product, 'total', big[*].(qty * price)))
## Take and Slice
`take/2` and `take/3` extract rows, columns, or both from a list or 2D
array. Positive counts take from the beginning, negative from the end.
Pass `null` to skip a dimension:
take(data, 3) # first 3 rows
take(data, -3) # last 3 rows
take(data, null, 2) # first 2 columns (all rows)
take(data, 2, 2) # first 2 rows and 2 columns
`slice/2` and `slice/3` extract a contiguous section of a list by
start index and optional length. The start index is zero-based, and
negative indices count from the end:
slice(items, 1) # from index 1 to end
slice(items, 1, 3) # 3 elements starting at index 1
slice(items, -2) # last 2 elements
"""
alias ExCellerate.Compiler
alias ExCellerate.Parser
@type scope :: %{optional(String.t()) => any()}
@type registry :: module() | nil
@doc """
Evaluates a text expression against an optional scope and registry.
Returns `{:ok, result}` on success or `{:error, %ExCellerate.Error{}}` on failure.
## Parameters
- `expression` — a string containing the expression.
- `scope` — a map of variables available to the expression. Supports string
keys, atom keys, and structs. Defaults to `%{}`.
- `registry` — an optional module created with `use ExCellerate.Registry`.
## Examples
iex> ExCellerate.eval("1 + 2 * 3")
{:ok, 7}
iex> ExCellerate.eval("a + b", %{"a" => 10, "b" => 20})
{:ok, 30}
iex> ExCellerate.eval("user.name", %{"user" => %{"name" => "Alice"}})
{:ok, "Alice"}
iex> {:error, _} = ExCellerate.eval("1 + * 2")
"""
@spec eval(String.t(), scope(), registry()) :: {:ok, any()} | {:error, Exception.t()}
def eval(expression, scope \\ %{}, registry \\ nil) do
case compile(expression, registry) do
{:ok, fun} ->
try do
{:ok, fun.(scope)}
rescue
e -> {:error, e}
end
{:error, reason} ->
{:error, reason}
end
end
@doc """
Similar to `eval/3`, but returns the result directly or raises on error.
## Examples
iex> ExCellerate.eval!("1 + 2 * 3")
7
iex> ExCellerate.eval!("a > 10 ? 'high' : 'low'", %{"a" => 15})
"high"
iex> ExCellerate.eval!("concat('Hello', ' ', name)", %{"name" => "Alice"})
"Hello Alice"
iex> ExCellerate.eval!("user.profile.id", %{"user" => %{"profile" => %{"id" => 1}}})
1
"""
@spec eval!(String.t(), scope(), registry()) :: any()
def eval!(expression, scope \\ %{}, registry \\ nil) do
case eval(expression, scope, registry) do
{:ok, result} -> result
{:error, error} -> raise error
end
end
@doc """
Compiles an expression into a reusable function.
Returns `{:ok, fun}` where `fun` is a 1-arity function that accepts a
scope map and returns the result. The compiled function is cached, so
calling `compile/2` repeatedly with the same expression is cheap.
This is useful when you need to evaluate the same expression many times
with different scopes — the parsing and compilation happen only once.
## Examples
iex> {:ok, fun} = ExCellerate.compile("a + b")
iex> fun.(%{"a" => 1, "b" => 2})
3
"""
@spec compile(String.t(), registry()) ::
{:ok, (scope() -> any())} | {:error, ExCellerate.Error.t()}
def compile(expression, registry \\ nil) do
case ExCellerate.Cache.get(registry, expression) do
{:ok, fun} ->
{:ok, fun}
:error ->
case compile_to_function(expression, registry) do
{:ok, fun} ->
ExCellerate.Cache.put(registry, expression, fun)
{:ok, fun}
{:error, _} = error ->
error
end
end
end
@doc """
Similar to `compile/2`, but returns the function directly or raises on error.
## Examples
iex> fun = ExCellerate.compile!("x * 2")
iex> fun.(%{"x" => 5})
10
"""
@spec compile!(String.t(), registry()) :: (scope() -> any())
def compile!(expression, registry \\ nil) do
case compile(expression, registry) do
{:ok, fun} -> fun
{:error, error} -> raise error
end
end
@doc """
Validates that an expression is syntactically correct and all referenced
functions exist in the registry or defaults.
Returns `:ok` if the expression is valid, or `{:error, %ExCellerate.Error{}}`
if it contains syntax errors or references unknown functions.
**Note:** Validation checks syntax, function existence, and arity, but does
not perform type checking. Since scope values are not known until runtime,
type errors (e.g., passing a number to `upper/1`) will only be caught at
evaluation time.
## Examples
iex> ExCellerate.validate("1 + 2")
:ok
iex> ExCellerate.validate("abs(-1)")
:ok
iex> {:error, _} = ExCellerate.validate("1 +")
iex> {:error, _} = ExCellerate.validate("unknown_func(1)")
"""
@spec validate(String.t(), registry()) :: :ok | {:error, ExCellerate.Error.t()}
def validate(expression, registry \\ nil) do
case compile(expression, registry) do
{:ok, _fun} -> :ok
{:error, reason} -> {:error, reason}
end
end
# Parses and compiles an expression string into a reusable function.
# The function accepts a scope map and returns the evaluation result.
# The scope parameter must use var!(scope) in the Compiler context
# to match the references generated by Compiler.compile/2.
defp compile_to_function(expression, registry) do
case Parser.parse(expression) do
{:ok, ast} ->
try do
elixir_ast = Compiler.compile(ast, registry)
scope_var = Compiler.scope_var()
fun_ast =
{:fn, [], [{:->, [], [[scope_var], elixir_ast]}]}
{fun, _} = Code.eval_quoted(fun_ast, [], __ENV__)
{:ok, fun}
rescue
e -> {:error, e}
end
{:error, reason} ->
{:error, reason}
end
end
end