Celixir
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.
Installation
def deps do
[
{:celixir, "~> 0.2.0"}
]
endQuick Start
# 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)") # => trueCompile Once, Evaluate Many
For hot paths, compile the expression once and evaluate with different bindings:
{:ok, program} = Celixir.compile("user.role == 'admin' && request.method in ['PUT', 'DELETE']")
Celixir.Program.eval(program, %{
user: %{role: "admin"},
request: %{method: "DELETE"}
})
# => {:ok, true}Create Reusable Functions
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}Load Expressions from Files
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}Custom Functions
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.
Basic function
env = Celixir.Environment.new(%{name: "world"})
|> Celixir.Environment.put_function("greet", fn name -> "Hello, #{name}!" end)
Celixir.eval!("greet(name)", env)
# => "Hello, world!"Multi-argument functions
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)
# => 100Using module functions
defmodule 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)
# => 120Namespaced functions
Use 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"
Building a function library with defcel
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()Private environment data
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)
Using with Celixir.Program (compile once, evaluate many)
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}Extensions
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()Celixir.Ext.Math
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 |
Celixir.Ext.Strings
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 |
Celixir.Ext.Lists
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) |
Celixir.Ext.Sets
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 |
Celixir.Ext.Encoders
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) |
Celixir.Ext.Regex
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.
Compile-Time Sigil
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}Supported Features
Types
int, uint, double, bool, string, bytes, list, map, null, timestamp, duration, optional, type
Operators
-
Arithmetic:
+,-,*,/,% -
Comparison:
==,!=,<,<=,>,>= -
Logical:
&&,||,!(with short-circuit error absorption) -
Ternary:
? : -
Membership:
in
Standard Functions
- 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()
Comprehension Macros
all, exists, exists_one, filter, map, transformList, transformMap, sortBy, transformMapEntry
Optional Values
optional.of(), optional.none(), optional.ofNonZeroValue(), .hasValue(), .value(), .orValue(), .or()
Protobuf Integration
Field access, has() presence checks, and automatic well-known type conversion via Celixir.ProtobufAdapter.
Static Type Checking
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})CEL Spec Conformance
Celixir passes 2400/2427 (99%) 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.
The extension modules (Celixir.Ext.*) mirror the ext.* packages from cel-go and are covered by an additional 100+ tests.
License
Apache-2.0