CI

Elex is a powerful expression language library for Elixir that provides parsing, validation, and evaluation of mathematical and logical expressions.

Documentation

Full guides are available on hexdocs.pm:

Features

  • Arithmetic Operations: +, -, *, /, % (modulo), unary -
  • Comparison Operators: <, >, <=, >=, ==, != (numbers and strings)
  • Logical Operations: and, or, not (with short-circuit evaluation)
  • Literals: Decimal numbers, booleans (true/false, yes/no), strings, and null
  • Variables: Dynamic variable substitution
  • Functions: Built-in math, string, and utility functions (see Functions) and custom functions via Elex.Function
  • Type System: Static type checking and validation
  • Decimal Precision: Uses Decimal for accurate arithmetic
  • Expression Inversion: Solve for variables in simple expressions
  • Ash Integration: Optional Ash validation for resource attributes

Installation

Add elex to your list of dependencies in mix.exs:

def deps do
  [
    {:elex, "~> 0.2.0"}
  ]
end

Quick Start

# Create a context with variables
context = Elex.new_context()
  |> Elex.add_variable("price", 100)
  |> Elex.add_variable("tax_rate", 0.08)

# Evaluate an expression
{:ok, result} = Elex.evaluate("price * (1 + tax_rate)", context)
# result => Decimal.new("108")

# Validate expression type
{:ok, :decimal} = Elex.validate("price + 10", context)
{:ok, :boolean} = Elex.validate("price > 50", context)

# Extract variables from an expression
{:ok, ["price", "quantity"]} = Elex.extract_variables("price * quantity")

Expression Syntax

Literals

# Numbers (decimal)
Elex.evaluate("42", Elex.new_context())
Elex.evaluate("3.14", Elex.new_context())
Elex.evaluate("-5.5", Elex.new_context())

# Booleans
Elex.evaluate("true", Elex.new_context())
Elex.evaluate("false", Elex.new_context())
Elex.evaluate("yes", Elex.new_context())  # alias for true
Elex.evaluate("no", Elex.new_context())   # alias for false

# Strings
Elex.evaluate("\"hello\"", Elex.new_context())

# Null
Elex.evaluate("null", Elex.new_context())

null compares equal only to null or nil variables (null == null, x == null when x is nil). It cannot be compared to numbers, booleans, or strings.

Variables

Variable names must start with a lowercase letter and may contain letters, digits, and underscores. The words and, or, not, null, true, false, yes, and no are reserved and cannot be used as variable names.

context =
  Elex.new_context()
  |> Elex.add_variable("price", 100)
  |> Elex.add_variable("tax_rate", 0.08)

{:ok, result} = Elex.evaluate("price * (1 + tax_rate)", context)
# result => #Decimal<108>

Arithmetic

context = Elex.new_context()

{:ok, result} = Elex.evaluate("10 + 5", context)   # => #Decimal<15>
{:ok, result} = Elex.evaluate("10 - 5", context)   # => #Decimal<5>
{:ok, result} = Elex.evaluate("10 * 5", context)   # => #Decimal<50>
{:ok, result} = Elex.evaluate("10 / 5", context)   # => #Decimal<2>
{:ok, result} = Elex.evaluate("10 % 3", context)   # => #Decimal<1>
{:ok, result} = Elex.evaluate("2 + 3 * 4", context) # => #Decimal<14> (respects precedence)
{:ok, result} = Elex.evaluate("-5", context)       # => #Decimal<-5> (unary minus)
{:ok, result} = Elex.evaluate("-(1 + 2)", context) # => #Decimal<-3>

Note: Elex.evaluate/2 returns {:ok, result} on success or {:error, reason} on failure. Arithmetic operations use Decimal and return Decimal values. The % operator has the same precedence as * and /.

Comparisons

{:ok, true} = Elex.evaluate("10 > 5", Elex.new_context())
{:ok, false} = Elex.evaluate("10 < 5", Elex.new_context())
{:ok, true} = Elex.evaluate("10 >= 10", Elex.new_context())
{:ok, false} = Elex.evaluate("10 <= 5", Elex.new_context())
{:ok, true} = Elex.evaluate("10 == 10", Elex.new_context())
{:ok, true} = Elex.evaluate("10 != 5", Elex.new_context())

# String ordering (lexicographic)
{:ok, true} = Elex.evaluate(~s["a" < "b"], Elex.new_context())
{:ok, true} = Elex.evaluate(~s["b" >= "a"], Elex.new_context())

Comparison operands must have the same type (decimal, boolean, string, or null).

Logical Operations

{:ok, false} = Elex.evaluate("true and false", Elex.new_context())
{:ok, true} = Elex.evaluate("true or false", Elex.new_context())
{:ok, false} = Elex.evaluate("not true", Elex.new_context())

and, or, and if(condition, a, b) use short-circuit evaluation: the right-hand operand (or unselected branch) is not evaluated when its result cannot change the outcome. This avoids errors such as division by zero in guard expressions:

{:ok, false} = Elex.evaluate("false and (1 / 0 > 0)", Elex.new_context())
{:ok, true} = Elex.evaluate("true or (1 / 0 > 0)", Elex.new_context())
{:ok, result} = Elex.evaluate("if(false, 1 / 0, 2)", Elex.new_context()) # => #Decimal<2>

Functions

Math

FunctionDescription
abs(x)Absolute value
ceil(x), floor(x), round(x)Rounding
sqrt(x)Square root
pow(base, exp)Exponentiation
rem(a, b)Remainder (sign follows the dividend; same as %)
mod(a, b)Floored modulo (sign follows the divisor)
max(a, b, …), min(a, b, …)Largest or smallest of two or more numbers (variadic)
clamp(x, min, max)Clamp x to an inclusive range
between(x, low, high)true when x is in the inclusive range
pi()Mathematical constant π
if(cond, a, b)Conditional (short-circuits; branches must share a type)
context = Elex.new_context()

{:ok, result} = Elex.evaluate("max(10, 20)", context)           # => #Decimal<20>
{:ok, result} = Elex.evaluate("max(3, 7, 9)", context)         # => #Decimal<9>
{:ok, result} = Elex.evaluate("min(10, 20)", context)          # => #Decimal<10>
{:ok, result} = Elex.evaluate("abs(-5)", context)              # => #Decimal<5>
{:ok, result} = Elex.evaluate("pow(2, 3)", context)             # => #Decimal<8>
{:ok, result} = Elex.evaluate("mod(10, 3)", context)            # => #Decimal<1>
{:ok, result} = Elex.evaluate("clamp(15, 0, 10)", context)     # => #Decimal<10>
{:ok, result} = Elex.evaluate("between(5, 0, 10)", context)   # => true
{:ok, result} = Elex.evaluate("ceil(3.2)", context)            # => #Decimal<4>
{:ok, result} = Elex.evaluate("floor(3.8)", context)           # => #Decimal<3>
{:ok, result} = Elex.evaluate("round(3.5)", context)           # => #Decimal<4>
{:ok, result} = Elex.evaluate("sqrt(16)", context)              # => #Decimal<4>
{:ok, result} = Elex.evaluate("rem(10, 3)", context)           # => #Decimal<1>
{:ok, result} = Elex.evaluate("pi()", context)                  # => #Decimal<3.14159…>
{:ok, result} = Elex.evaluate("if(10 > 5, 1, 0)", context)    # => #Decimal<1>

Strings

FunctionDescription
concat(a, b)Concatenate two strings
length(s)String length (character count)
contains(haystack, needle)Substring search
starts_with(s, prefix), ends_with(s, suffix)Prefix/suffix test
lower(s), upper(s), trim(s)Case and whitespace transforms
coalesce(a, b, …)First non-null argument (variadic; short-circuits)
context = Elex.new_context()

{:ok, result} = Elex.evaluate(~s[concat("hello", " world")], context)     # => "hello world"
{:ok, result} = Elex.evaluate(~s[length("abc")], context)                  # => #Decimal<3>
{:ok, result} = Elex.evaluate(~s[contains("hello", "ell")], context)       # => true
{:ok, result} = Elex.evaluate(~s[starts_with("hello", "he")], context)     # => true
{:ok, result} = Elex.evaluate(~s[ends_with("hello", "lo")], context)        # => true
{:ok, result} = Elex.evaluate(~s[lower("ABC")], context)                      # => "abc"
{:ok, result} = Elex.evaluate(~s[upper("abc")], context)                   # => "ABC"
{:ok, result} = Elex.evaluate(~s[trim("  x  ")], context)                   # => "x"
{:ok, result} = Elex.evaluate("coalesce(null, 5)", context)                  # => #Decimal<5>

Ash Integration

Elex provides an optional Ash validation for validating expressions in resource attributes:

defmodule MyApp.Resource do
  use Ash.Resource

  attributes do
    attribute :formula, :string do
      allow_nil? false
    end
  end

  validations do
    validate Elex.AshValidation,
      attribute: :formula,
      context: Elex.new_context(),
      expected_type: :decimal
  end
end

The expected_type option accepts :decimal, :boolean, or :string. Use add_value_type_from_attribute to inject a value variable typed from another attribute — useful when validating formulas that reference the current value.

Expression Inversion

Elex can invert simple arithmetic expressions to solve for a variable:

alias Elex.{Parser, Inverter}

context = Elex.new_context()
{:ok, ast, _type} = Parser.parse("value * 2 + 5", context, validate: false)
{:ok, inverted_ast} = Inverter.invert(ast, "value")

# The inverted expression solves for "value":
# value = (result - 5) / 2

Custom Functions

Implement the Elex.Function behaviour and register your module with Elex.Context.add_function/2:

defmodule MyApp.Functions.Double do
  @behaviour Elex.Function

  @impl true
  def signature, do: %{name: :double, arity: 1}

  @impl true
  def validate([arg], ctx), do: Elex.Validator.validate(arg, ctx)

  @impl true
  def call([arg]), do: {:ok, Decimal.mult(arg, Decimal.new(2))}

  @impl true
  def documentation, do: %{signature: "double(x)", description: "doubles a number"}
end

context =
  Elex.new_context()
  |> Elex.Context.add_function(MyApp.Functions.Double)

{:ok, result} = Elex.evaluate("double(5)", context)

Development

Setup

mix deps.get
mix test

Git Hooks

To enable the pre-commit hook that runs quality checks before each commit:

git config core.hooksPath .git-hooks

The pre-commit hook runs:

  • Code compilation with warnings as errors
  • Code formatting
  • Credo linting
  • Sobelow security checks
  • Dependency audit
  • All tests

You can also run these checks manually:

mix precommit

Code Quality

mix check          # Run all quality checks
mix format         # Format code
mix credo --strict # Run Credo linter
mix dialyzer       # Run Dialyzer type checker
mix sobelow        # Run security analysis

License

Copyright (c) 2025 bandesz

See LICENSE for details.