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, Firebase rules, and configuration validation.
def deps do
[
{:celixir, "~> 0.3.0"}
]
end# Simple expressions
Celixir.eval!("1 + 2") # => 3
Celixir.eval!("'hello' + ' ' + 'world'") # => "hello world"
# Variable bindings
Celixir.eval!("age >= 18", %{age: 21}) # => true
# Complex expressions
Celixir.eval!(
"request.method == 'GET' && resource.public",
%{request: %{method: "GET"}, resource: %{public: true}}
)
# => true
# Comprehensions
Celixir.eval!("[1, 2, 3].filter(x, x > 1)") # => [2, 3]
Celixir.eval!("[1, 2, 3].map(x, x * 2)") # => [2, 4, 6]
Celixir.eval!("[1, 2, 3].all(x, x > 0)") # => trueCelixir.compile/1 parses the expression and translates it to a native BEAM
function via Module.create/3. Subsequent calls skip all parsing and AST
interpretation — only the compiled code runs:
{:ok, program} = Celixir.compile("user.role == 'admin' && request.method in ['PUT', 'DELETE']")
Celixir.Program.eval(program, %{
user: %{role: "admin"},
request: %{method: "DELETE"}
})
# => {:ok, true}Compile a CEL expression into a plain anonymous function you can pass around:
validator = Celixir.to_fun!("age >= 18 && status == 'active'")
validator.(%{age: 25, status: "active"}) # => {:ok, true}
validator.(%{age: 15, status: "active"}) # => {:ok, false}
# Use in pipelines, pass to other modules, store in config
rules = %{
can_edit: Celixir.to_fun!("user.role in ['admin', 'editor']"),
is_active: Celixir.to_fun!("user.status == 'active'")
}
rules.can_edit.(%{user: %{role: "admin"}}) # => {:ok, true}Store CEL expressions in files for config-driven rule engines:
# rules/access_policy.cel contains: user.role == 'admin' || resource.public
{:ok, program} = Celixir.load_file("rules/access_policy.cel")
Celixir.Program.eval(program, %{user: %{role: "viewer"}, resource: %{public: true}})
# => {:ok, true}Extend CEL with your own functions written in Elixir. Custom functions receive plain Elixir values (unwrapped from CEL internal types) and should return plain Elixir values.
env = Celixir.Environment.new(%{name: "world"})
|> Celixir.Environment.put_function("greet", fn name -> "Hello, #{name}!" end)
Celixir.eval!("greet(name)", env)
# => "Hello, world!"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)
# => 100defmodule MyFunctions do
def factorial(0), do: 1
def factorial(n) when n > 0, do: n * factorial(n - 1)
end
env = Celixir.Environment.new()
|> Celixir.Environment.put_function("factorial", &MyFunctions.factorial/1)
Celixir.eval!("factorial(5)", env)
# => 120Use dot-separated names to organize functions into logical groups:
env = Celixir.Environment.new()
|> Celixir.Environment.put_function("str.reverse", fn s ->
s |> String.graphemes() |> Enum.reverse() |> Enum.join()
end)
|> Celixir.Environment.put_function("str.repeat", fn s, n ->
String.duplicate(s, n)
end)
Celixir.eval!(~S|str.reverse("hello")|, env)
# => "olleh"
Celixir.eval!(~S|str.repeat("ab", 3)|, env)
# => "ababab"Use Celixir.API to define function libraries declaratively:
defmodule MyApp.CelMath do
use Celixir.API, scope: "mymath"
defcel abs(x) do
Kernel.abs(x)
end
defcel clamp(val, lo, hi) do
val |> max(lo) |> min(hi)
end
end
env = Celixir.Environment.new() |> MyApp.CelMath.register()
Celixir.eval!("mymath.abs(-42)", env)
# => 42
Celixir.eval!("mymath.clamp(150, 0, 100)", env)
# => 100Multiple API modules can be composed on the same environment:
env =
Celixir.Environment.new(%{price: 100})
|> MyApp.CelMath.register()
|> MyApp.CelFormatting.register()Store opaque data on the environment for use in custom functions, without exposing it as a CEL variable:
env =
Celixir.Environment.new()
|> Celixir.Environment.put_private(:api_key, "secret-123")
|> Celixir.Environment.put_function("fetch", fn url ->
# api_key is accessible from Elixir but not from CEL expressions
# ...
end)env = Celixir.Environment.new()
|> Celixir.Environment.put_function("discount", fn price, pct -> price * (1 - pct) end)
{:ok, program} = Celixir.compile("discount(price, 0.1)")
Celixir.Program.eval(program, env |> Celixir.Environment.put_variable("price", 100))
# => {:ok, 90.0}
Celixir.Program.eval(program, env |> Celixir.Environment.put_variable("price", 50))
# => {:ok, 45.0}Celixir ships optional extension modules that mirror the ext.* packages from
cel-go. Each module exposes
a register/1 function you pipe into your environment to activate.
env =
Celixir.Environment.new()
|> Celixir.Ext.Math.register()
|> Celixir.Ext.Strings.register()
|> Celixir.Ext.Lists.register()
|> Celixir.Ext.Sets.register()
|> Celixir.Ext.Encoders.register()
|> Celixir.Ext.Regex.register()Numeric and bitwise functions under the math.* namespace.
env = Celixir.Environment.new() |> Celixir.Ext.Math.register()
Celixir.eval!("math.sqrt(16.0)", env) # => 4.0
Celixir.eval!("math.ceil(1.2)", env) # => 2.0
Celixir.eval!("math.abs(-7)", env) # => 7
Celixir.eval!("math.isNaN(1.0/0.0)", env) # => false
Celixir.eval!("math.greatest(1, 3, 2)", env) # => 3
Celixir.eval!("math.least([5, 1, 3])", env) # => 1
Celixir.eval!("math.bitAnd(0b1010, 0b1100)", env) # => 8| Function | Description |
|---|---|
math.ceil(double) |
ceiling |
math.floor(double) |
floor |
math.round(double) |
round (ties away from zero) |
math.trunc(double) |
truncate fractional part |
math.abs(int|uint|double) |
absolute value |
math.sign(int|uint|double) |
-1, 0, or 1 |
math.sqrt(int|uint|double) |
square root (NaN for negative) |
math.isNaN(double) |
true if NaN |
math.isInf(double) |
true if ±Inf |
math.isFinite(double) |
true if neither NaN nor Inf |
math.bitAnd(int, int) |
bitwise AND |
math.bitOr(int, int) |
bitwise OR |
math.bitXor(int, int) |
bitwise XOR |
math.bitNot(int) |
bitwise NOT |
math.bitShiftLeft(int, int) |
left shift |
math.bitShiftRight(int, int) |
right shift |
math.greatest(args...) |
variadic max |
math.least(args...) |
variadic min |
Additional string functions.
env = Celixir.Environment.new() |> Celixir.Ext.Strings.register()
Celixir.eval!(~s|strings.quote("hello\nworld")|, env)
# => "\"hello\\nworld\""| Function | Description |
|---|---|
strings.quote(string) |
wrap in double quotes with Go-style escaping |
Extra list operations and the sortBy/transformMapEntry comprehension macros.
env = Celixir.Environment.new() |> Celixir.Ext.Lists.register()
Celixir.eval!("lists.range(5)", env) # => [0, 1, 2, 3, 4]
Celixir.eval!("[1, 2, 1, 3].distinct()", env) # => [1, 2, 3]
Celixir.eval!("[1, 2, 3].first()", env) # => optional(1)
Celixir.eval!("[1, 2, 3].last()", env) # => optional(3)
Celixir.eval!("[1, [2, [3]]].flatten(2)", env) # => [1, 2, 3]
# sortBy macro
Celixir.eval!(~s|[{"b": 2}, {"a": 1}].sortBy(x, x.key)|, env)
# transformMapEntry macro
Celixir.eval!(~s|{"a": 1, "b": 2}.transformMapEntry(k, v, {k: v * 10})|, env)| Function | Description |
|---|---|
lists.range(n) |
[0, 1, ..., n-1] |
list.distinct() |
deduplicate preserving order |
list.first() |
optional first element |
list.last() |
optional last element |
list.flatten(depth) |
flatten to given depth |
list.sortBy(var, key_expr) |
sort by computed key (macro) |
map.transformMapEntry(k, v, transform [, filter]) |
transform map entries (macro) |
Set-theoretic operations on lists treated as sets.
env = Celixir.Environment.new() |> Celixir.Ext.Sets.register()
Celixir.eval!("sets.contains([1,2,3], [2,3])", env) # => true
Celixir.eval!("sets.equivalent([1,2], [2,1])", env) # => true
Celixir.eval!("sets.intersects([1,2], [2,3])", env) # => true| Function | Description |
|---|---|
sets.contains(list, list) |
true if first list contains all elements of second |
sets.equivalent(list, list) |
true if lists contain the same elements (order-independent) |
sets.intersects(list, list) |
true if lists share at least one element |
Base64 encoding and decoding.
env = Celixir.Environment.new() |> Celixir.Ext.Encoders.register()
Celixir.eval!("base64.encode(b'hello')", env) # => "aGVsbG8="
Celixir.eval!("base64.decode('aGVsbG8=')", env) # => b"hello"| Function | Description |
|---|---|
base64.encode(bytes) |
encode bytes to base64 string |
base64.decode(string) |
decode base64 string to bytes (error if invalid) |
Regular expression functions under the regex.* namespace.
env = Celixir.Environment.new() |> Celixir.Ext.Regex.register()
Celixir.eval!(~s|regex.replace("hello world", "hello", "hi")|, env)
# => "hi world"
Celixir.eval!(~s|regex.replace("aabbcc", "[a-z]", "x", 3)|, env)
# => "xxxbcc"
Celixir.eval!(~s|regex.extract("item-A", "item-(\\w+)").value()|, env)
# => "A"
Celixir.eval!(~s|regex.extractAll("id:1, id:2", "id:\\d+")|, env)
# => ["id:1", "id:2"]| Function | Description |
|---|---|
regex.replace(target, pattern, replacement) |
replace all matches |
regex.replace(target, pattern, replacement, count) |
replace first N matches (0=keep, <0=all) |
regex.extract(target, pattern) |
optional first match or first capture group |
regex.extractAll(target, pattern) |
list of all matches or capture groups |
Use
\Nfor backreferences in replacements.$N-style references are not supported.
Parse expressions at compile time for zero runtime parsing cost:
import Celixir.Sigil
ast = ~CEL|request.method == "GET"|
Celixir.eval_ast(ast, %{request: %{method: "GET"}})
# => {:ok, true}int, uint, double, bool, string, bytes, list, map, null, timestamp, duration, optional, type
- Arithmetic:
+,-,*,/,% - Comparison:
==,!=,<,<=,>,>= - Logical:
&&,||,!(with short-circuit error absorption) - Ternary:
? : - Membership:
in
- String:
contains,startsWith,endsWith,matches,size,charAt,indexOf,lastIndexOf,lowerAscii,upperAscii,replace,split,substring,trim,join,reverse - Math:
math.least,math.greatest,math.ceil,math.floor,math.round,math.abs,math.sign,math.isNaN,math.isInf,math.isFinite - Lists:
size,sort,slice,flatten,reverse,lists.range - Sets:
sets.contains,sets.intersects,sets.equivalent - Type conversions:
int(),uint(),double(),string(),bool(),bytes(),timestamp(),duration(),dyn(),type() - Encoding:
base64.encode(),base64.decode()
all, exists, exists_one, filter, map, transformList, transformMap, sortBy, transformMapEntry
optional.of(), optional.none(), optional.ofNonZeroValue(), .hasValue(), .value(), .orValue(), .or()
Field access, has() presence checks, and automatic well-known type conversion via Celixir.ProtobufAdapter.
Optional pre-evaluation type validation:
{:ok, ast} = Celixir.parse("x + 1")
:ok = Celixir.Checker.check(ast, %{"x" => :int})
{:error, _} = Celixir.Checker.check(ast, %{"x" => :string})Celixir passes ~97% of the upstream cel-spec conformance tests across 30 test suites covering arithmetic, strings, lists, comparisons, logic, macros, conversions, timestamps, protobuf field access, namespaces, optionals, type deductions, and more.
Known intentional deviations (v0.3.0): integer overflow is not enforced (Elixir bigints are used), type(1u) returns :int, and type(b"hi") returns :string — the uint and bytes distinction is dropped in favour of native Elixir types.
The extension modules (Celixir.Ext.*) mirror the ext.* packages from cel-go and are covered by an additional 100+ tests.
Apache-2.0