Elex

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

Installation

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

def deps do
  [
    {:elex, "~> 0.4.2"}
  ]
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", context)

Expression Syntax

Literals

# Numbers (decimal, including scientific notation)
Elex.evaluate("42", Elex.new_context())
Elex.evaluate("3.14", Elex.new_context())
Elex.evaluate("-5.5", Elex.new_context())
Elex.evaluate("1e3", Elex.new_context())
Elex.evaluate("1.5E-2", 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("100 * 50%", context) # => #Decimal<50>
{: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. Number arithmetic returns a Decimal, a quantity keeps its unit, and percent +, -, and percent-times-percent return %Elex.Percent{}. * and / have higher precedence than + and -. A % suffix on a number literal is percent points (50% stores 50), so 100 * 50% evaluates to 50.

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, percent, boolean, string, null, or same-dimension quantities when a catalog is attached). Percent points order against another percent; a literal 0 is allowed and other numbers are rejected.

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

Function Description
abs(x) Absolute value
ceil(x), floor(x), round(x) Rounding
sqrt(x) Square root
pow(base, exp) Exponentiation
mod(a, b) Floored modulo (sign follows the divisor)
inc(value, rate), dec(value, rate) Increase or decrease value by a percent rate
max(a, b, …), min(a, b, …) Largest or smallest of two or more numbers, percents, or same-category quantities (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)
convert(value, unit) Convert a quantity into a named unit or formula (needs a catalog)
add_unit(value, unit) Wrap a number as a quantity of a registered symbol (needs a catalog)
remove_unit(value) Magnitude of a quantity as a number (needs a catalog)
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("pi()", context)                  # => #Decimal<3.14159…>
{:ok, result} = Elex.evaluate("if(10 > 5, 1, 0)", context)    # => #Decimal<1>

See Units for convert, add_unit, and remove_unit.

Strings

Function Description
concat(...) Concatenate zero or more strings (variadic)
length(s) String length (character count)
contains(haystack, needle) Substring search
match(text, pattern) Regex match
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[match("hello123", "hello[0-9]+")], 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>

Units

Elex does not ship a catalog. Register categories and attach one with Elex.Context.put_units/2 (or put_units!/2 when piping):

alias Elex.Units.Catalog

{:ok, catalog} = Catalog.add_category(Catalog.new(), :length, default: "m")
{:ok, catalog} = Catalog.add_unit(catalog, :length, "m")
{:ok, catalog} = Catalog.add_unit(catalog, :length, "mm", "value / 1000")

{:ok, context} = Elex.Context.put_units(Elex.new_context(), catalog)

{:ok, qty} = Elex.evaluate("10mm", context)
# qty => #Elex.Quantity<10 mm>

{:ok, %Elex.Dimension{monomial: %{length: 1}}} = Elex.validate("10mm", context)

{:ok, qty} = Elex.evaluate("10mm", context, unit: "m")
# qty => #Elex.Quantity<0.01 m>

{:ok, qty} = Elex.evaluate(~s[convert(10mm, "m")], context)
# qty => #Elex.Quantity<0.01 m>

Suffixes attach to numeric literals: a registered name (10mm), a power (5 m^2), an unbraced pipe (3 m|s), or a braced formula (1 {kg * m | s}). Variables take {n, "unit"} or %Elex.Quantity{} with category::

{:ok, context} = Elex.add_variable(context, "width", {10, "mm"}, category: :length)

Formula strings use | for division ("km | h"), not /. Without a catalog, glued suffixes are unexpected tokens (2mm → unexpected 'mm').

See the Units guide for derived categories, aliases, identity:, non-additive temperature, and custom-function units:.

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, :string, or :percent. When the context has a units catalog, it may also be a category atom such as :length. 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([%Elex.Quantity{value: value, unit: unit}]) do
    {:ok, doubled} = call([value])
    {:ok, %Elex.Quantity{value: doubled, unit: unit}}
  end

  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:

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.