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"}
  ]
end

Quick 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)")        # => true

Compile 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)
# => 100

Using 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)
# => 120

Namespaced 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)
# => 100

Multiple 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 \N for 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

Standard Functions

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