A pure Elixir implementation of Google's Common Expression Language (CEL).
CEL is a non-Turing-complete expression language designed for simplicity, speed, and safety. It is commonly used in security policies, protocol buffers, and configuration validation.
Quick Start
iex> Celixir.eval("1 + 2")
{:ok, 3}
iex> Celixir.eval("name.startsWith('hello')", %{name: "hello world"})
{:ok, true}
iex> Celixir.eval("x > 10 ? 'big' : 'small'", %{x: 42})
{:ok, "big"}String-keyed maps are supported directly, avoiding atom creation from untrusted input (e.g., JSON from user submissions or databases):
iex> Celixir.eval("severity == 'high'", %{"severity" => "high"})
{:ok, true}Compile Once, Evaluate Many
{:ok, program} = Celixir.compile("x * 2 + y")
Celixir.Program.eval(program, %{x: 5, y: 1}) # => {:ok, 11}
Celixir.Program.eval(program, %{x: 10, y: 3}) # => {:ok, 23}Create Reusable Functions
validator = Celixir.to_fun!("age >= 18 && status == 'active'")
validator.(%{age: 25, status: "active"}) # => {:ok, true}
validator.(%{age: 15, status: "active"}) # => {:ok, false}Supported Features
- Types: int, uint, double, bool, string, bytes, list, map, null, timestamp, duration, optional, type
- Operators: arithmetic (
+,-,*,/,%), comparison (==,!=,<,<=,>,>=), logical (&&,||,!), ternary (?:), membership (in) - String functions:
contains,startsWith,endsWith,matches,size,charAt,indexOf,lastIndexOf,lowerAscii,upperAscii,replace,split,substring,trim,join,reverse - Math functions:
math.least,math.greatest,math.ceil,math.floor,math.round,math.abs,math.sign,math.isNaN,math.isInf,math.isFinite - List functions:
size,sort,slice,flatten,reverse,lists.range - Set functions:
sets.contains,sets.intersects,sets.equivalent - Comprehension macros:
all,exists,exists_one,filter,map - Type conversions:
int(),uint(),double(),string(),bool(),bytes(),timestamp(),duration(),dyn(),type() - Optional values:
optional.of(),optional.none(),optional.ofNonZeroValue(),.hasValue(),.value(),.orValue(),.or() - Encoding:
base64.encode(),base64.decode() - Custom functions: register your own via
Celixir.Environment.put_function/3or declaratively withCelixir.APIanddefcel - Reusable functions:
to_fun/1compiles to a plain anonymous function - File loading:
load_file/1loads expressions from files - Value encoding:
encode/1converts Elixir values to CEL internal types - Protobuf integration: field access, has() checks, well-known type
conversion via
Celixir.ProtobufAdapter - Static type checking: optional pre-evaluation validation via
Celixir.Checker - Compile-time sigil:
~CEL|expr|for zero-cost parsed ASTs
Custom Functions
Register Elixir functions to call from CEL expressions. Functions receive plain Elixir values and should return plain Elixir values.
# Simple function
env = Celixir.Environment.new(%{name: "world"})
|> Celixir.Environment.put_function("greet", fn name -> "Hello, #{name}!" end)
Celixir.eval("greet(name)", env)
# => {:ok, "Hello, world!"}
# Multi-argument
env = Celixir.Environment.new()
|> Celixir.Environment.put_function("clamp", fn val, lo, hi ->
val |> max(lo) |> min(hi)
end)
Celixir.eval("clamp(150, 0, 100)", env)
# => {:ok, 100}
# Module function reference
env = Celixir.Environment.new()
|> Celixir.Environment.put_function("factorial", &MyMath.factorial/1)
# Namespaced functions (dot-separated names)
env = Celixir.Environment.new()
|> Celixir.Environment.put_function("str.reverse", &MyString.reverse/1)
|> Celixir.Environment.put_function("str.repeat", &MyString.repeat/2)To build a reusable function library, group registrations in a module:
defmodule MyApp.CelLibrary do
def register(env \\ Celixir.Environment.new()) do
env
|> Celixir.Environment.put_function("slugify", &slugify/1)
|> Celixir.Environment.put_function("format.currency", &format_currency/2)
end
defp slugify(s), do: s |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-")
defp format_currency(amount, cur), do: "#{cur} #{:erlang.float_to_binary(amount / 1.0, decimals: 2)}"
end
Summary
Functions
Compiles a CEL expression into a reusable Celixir.Program.
Encodes a plain Elixir value into CEL representation.
Parses and evaluates a CEL expression with optional variable bindings.
Parses and evaluates a CEL expression, raising on error.
Evaluates a pre-parsed AST with the given environment or bindings map.
Loads a CEL expression from a file and compiles it into a Celixir.Program.
Like load_file/1 but raises on error.
Parses a CEL expression string into an AST.
Compiles a CEL expression and returns a callable function.
Like to_fun/1 but raises on parse error.
Functions
@spec compile(String.t()) :: {:ok, Celixir.Program.t()} | {:error, String.t()}
Compiles a CEL expression into a reusable Celixir.Program.
Parse once, evaluate many times with different bindings.
Examples
{:ok, program} = Celixir.compile("x > threshold")
Celixir.Program.eval(program, %{x: 100, threshold: 50})Compile untrusted expressions at your own risk
Each call generates and loads a uniquely-named BEAM module, costing two
permanent atoms and two resident modules — :"Celixir.Compiled.<n>" for the
generated code, plus an :elixir_compiler_<n> wrapper that Module.create/3
leaves behind. None of it is ever reclaimed: atoms are never garbage collected
and the modules stay loaded for the life of the VM, at roughly 8 KiB of code
memory per compile. Compiling an unbounded stream of distinct expressions
will exhaust the atom table (default limit 1,048,576, so ~500k compiles) and
crash the node unrecoverably.
Variable bindings are safe — they create no atoms, and identifiers within an expression are mapped to positional atoms bounded by the variable count. It is the number of distinct expression strings compiled that matters.
If expressions come from users, config reloads, or anything else that can vary without bound, either cache programs by expression text so each string is compiled once:
program =
case :persistent_term.get({:cel, expr}, nil) do
nil ->
{:ok, p} = Celixir.compile(expr)
:persistent_term.put({:cel, expr}, p)
p
p ->
p
endor skip compilation entirely and use eval/2, which walks the AST and creates
no modules:
Celixir.eval(untrusted_expr, bindings)The same applies to to_fun/1, to_fun!/1, load_file/1 and load_file!/1,
which all compile through this path.
Encodes a plain Elixir value into CEL representation.
This is the inverse of unwrap/1. Since CEL types are now native Elixir
types, this is mostly an identity function — integers, strings, floats,
booleans, lists, and maps pass through unchanged.
Atoms have no CEL counterpart, so they are converted to strings, recursing
through lists and through both the keys and values of plain maps. The atoms
that do carry CEL meaning are preserved: nil is CEL null, true/false
are CEL bool, and :nan/:infinity/:neg_infinity are the double
sentinels. Structs are left untouched for the type adapter to handle.
This is the same encoding Celixir.Environment applies to variable
bindings, so encode/1 and Environment.new/1 agree on every value.
Examples
iex> Celixir.encode(42)
42
iex> Celixir.encode("hello")
"hello"
iex> Celixir.encode([1, 2, 3])
[1, 2, 3]
iex> Celixir.encode(:admin)
"admin"
iex> Celixir.encode(%{role: :admin})
%{"role" => "admin"}
iex> Celixir.encode([nil, true, :nan])
[nil, true, :nan]
iex> Celixir.encode(:optional_none)
%Celixir.Types.Optional{has_value: false}
@spec eval(String.t(), map() | Celixir.Environment.t()) :: {:ok, any()} | {:error, String.t()}
Parses and evaluates a CEL expression with optional variable bindings.
Returns {:ok, result} on success or {:error, message} on failure.
Results are unwrapped from internal tagged types to plain Elixir values.
Examples
iex> Celixir.eval("1 + 2")
{:ok, 3}
iex> Celixir.eval("x > 0", %{x: 5})
{:ok, true}
iex> Celixir.eval("severity == 'high'", %{"severity" => "high"})
{:ok, true}
iex> Celixir.eval("undefined_var")
{:error, "undefined variable: undefined_var"}
@spec eval!(String.t(), map() | Celixir.Environment.t()) :: any()
Parses and evaluates a CEL expression, raising on error.
Examples
iex> Celixir.eval!("2 * 3")
6
@spec eval_ast(Celixir.AST.expr(), Celixir.Environment.t() | map()) :: {:ok, any()} | {:error, String.t()}
Evaluates a pre-parsed AST with the given environment or bindings map.
@spec load_file(String.t()) :: {:ok, Celixir.Program.t()} | {:error, String.t()}
Loads a CEL expression from a file and compiles it into a Celixir.Program.
Examples
{:ok, program} = Celixir.load_file("path/to/rule.cel")
Celixir.Program.eval(program, %{x: 42})
@spec load_file!(String.t()) :: Celixir.Program.t()
Like load_file/1 but raises on error.
@spec parse(String.t()) :: {:ok, Celixir.AST.expr()} | {:error, String.t()}
Parses a CEL expression string into an AST.
The AST can be evaluated later with eval_ast/2 or stored for reuse.
Examples
iex> {:ok, ast} = Celixir.parse("1 + 2")
iex> Celixir.eval_ast(ast, %{})
{:ok, 3}
@spec to_fun(String.t()) :: {:ok, (map() -> {:ok, any()} | {:error, String.t()})} | {:error, String.t()}
Compiles a CEL expression and returns a callable function.
The returned function takes a bindings map (or Celixir.Environment) and
returns {:ok, result} or {:error, message}.
Like compile/1, each call loads a uniquely-named module and creates one
permanent atom — see the atom-table note in compile/1 before calling this
with expressions you do not control.
Examples
iex> fun = Celixir.to_fun!("x * 2 + y")
iex> fun.(%{x: 5, y: 1})
{:ok, 11}
iex> fun = Celixir.to_fun!("name.startsWith('hello')")
iex> fun.(%{name: "hello world"})
{:ok, true}
Like to_fun/1 but raises on parse error.
Examples
iex> fun = Celixir.to_fun!("x + 1")
iex> fun.(%{x: 10})
{:ok, 11}