Celixir (Celixir v0.4.0-rc1)

Copy Markdown View Source

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/3 or declaratively with Celixir.API and defcel
  • Reusable functions: to_fun/1 compiles to a plain anonymous function
  • File loading: load_file/1 loads expressions from files
  • Value encoding: encode/1 converts 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

compile(expression)

@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
  end

or 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.

encode(value)

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}

eval(expression, bindings \\ %{})

@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"}

eval!(expression, bindings \\ %{})

@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

eval_ast(ast, env)

@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.

load_file(path)

@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})

load_file!(path)

@spec load_file!(String.t()) :: Celixir.Program.t()

Like load_file/1 but raises on error.

parse(expression)

@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}

to_fun(expression)

@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}

to_fun!(expression)

@spec to_fun!(String.t()) :: (map() -> {:ok, any()} | {:error, String.t()})

Like to_fun/1 but raises on parse error.

Examples

iex> fun = Celixir.to_fun!("x + 1")
iex> fun.(%{x: 10})
{:ok, 11}