Collections
CEL uses tuples for fixed positional values and arrays for homogeneous sequences. An empty array uses a type annotation to state its element type. For the complete grammar, see the Reference Manual.
Concept
Parentheses and brackets build different kinds of values. Parentheses can mean unit, grouping, or tuples. Brackets always mean arrays.
Syntax
tuple_or_group = "(" [ expression [ "," [ expression { "," expression } ] ] ] ")" .
array_expression = "[" [ expression { "," expression } ] "]"
[ ":" type_expr ] .
type_expr = identifier [ "(" [ type_expr { "," type_expr } ] ")" ]
| "[" type_expr "]"
| "(" [ type_expr [ "," [ type_expr { "," type_expr } ] ] ] ")" .
Worked examples
(1,)
(1, 2, 3)
[0, 1, 2]
[[0], [1]]
[0, 1]: [i32]
[]: [i32]
[]: [[i32]]
[(7, [1.5]), (8, []: [f64])]
[]: [(i32, [f64])]
[]: [Fn((i32,), [i32])]
if ready { [1, 2] } else { [3, 4] }
Exact rules
()is unit,(expr)is grouping,(expr,)is a 1-tuple, and(a, b, c)is a tuple.[a, b, c]is an array literal.- An unannotated array is non-empty and every element must have the same type.
- A type annotation after
:states the complete array type. It permits an empty array, as in[]: [i32]. - A non-empty array annotation must match the element type exactly.
- Nested arrays are valid when each element is itself an array of the same element type.
- Tuple indexing uses an unsuffixed integer such as
.0or.1. Named postfixes such asvalue.nameinvoke host-provided operations; they do not select tuple fields. - Arrays and tuples compose recursively over registered leaves, unit, other containers, and callables. Callable element types include their complete parameter and result shapes; storing a callable does not require equality.
Rust interoperability
CEL values map recursively to concrete Rust values:
| CEL shape | Rust value | Representation witness |
|---|---|---|
registered leaf T | T | Leaf<T> |
[T] | Array<R(T)> | ArrayOf<W(T)> |
() | CNil<()> | Nil |
(T, U) | CStackList<R(T), CStackList<R(U), CNil<()>>> | Cons<W(T), Cons<W(U), Nil>> |
Fn((A,), R) | Callable<CStackList<R(A), CNil<()>>, R(R)> | FnOf<Cons<W(A), Nil>, W(R)> |
Here R(T) is the recursively canonical value and W(T) its witness.
IntoCStackList and IntoRustTuple move between lists and ordinary Rust tuples
without requiring Clone, equality, or formatting. Logical list order is source
order.
This executable example prepares an array of actual lists, then moves each row into a native Rust tuple:
#![allow(unused)]
fn main() {
/// Moves concrete tuple-list elements out of an interpreted array in source order.
#[test]
fn arrays_of_tuple_lists() {
use cel_parser::{CELParser, OpLookup};
use cel_runtime::{ArrayOf, Cons, IntoCStackList, IntoRustTuple, Leaf, Nil};
type Row = Cons<Leaf<i32>, Cons<ArrayOf<Leaf<f64>>, Nil>>;
let body = CELParser::new(OpLookup::new())
.parse_str("[(7, [1.5, 2.5]), (8, []: [f64])]")
.unwrap();
let mut body = body.prepare::<Nil, ArrayOf<Row>>().unwrap();
let rows = body.call(&().into_c_stack_list()).unwrap();
let actual: Vec<_> = rows
.into_vec()
.into_iter()
.map(|row| {
let (id, values) = row.into_rust_tuple();
(id, values.into_vec())
})
.collect();
assert_eq!(actual, [(7, vec![1.5, 2.5]), (8, vec![])]);
}
}
Nested vectors and slices
Array<T>::from_vec and into_vec transfer the allocation in O(1);
as_slice borrows the same elements as &[T].
The representation of [[i32]] is Array<Array<i32>>. Its allocation-preserving
vector view is Vec<Array<i32>>, and its slice view is &[Array<i32>], not
Vec<Vec<i32>> or &[Vec<i32>]. An explicit deep conversion builds a new outer
vector while transferring each inner allocation independently:
#![allow(unused)]
fn main() {
/// Transfers the outer allocation to a vector of canonical inner arrays.
#[test]
fn nested_array_vector_view() {
use cel_parser::{CELParser, OpLookup};
use cel_runtime::{Array, ArrayOf, IntoCStackList, Leaf, Nil};
let body = CELParser::new(OpLookup::new())
.parse_str("[[1, 2], []: [i32], [3]]")
.unwrap();
let mut body = body.prepare::<Nil, ArrayOf<ArrayOf<Leaf<i32>>>>().unwrap();
let rows: Array<Array<i32>> = body.call(&().into_c_stack_list()).unwrap();
let pointer = rows.as_slice().as_ptr();
let capacity = rows.capacity();
let rows: Vec<Array<i32>> = rows.into_vec();
assert_eq!(rows.as_ptr(), pointer);
assert_eq!(rows.capacity(), capacity);
assert_eq!(rows[0].as_slice(), [1, 2]);
assert!(rows[1].is_empty());
assert_eq!(rows[2].as_slice(), [3]);
// Deep conversion moves each inner allocation, but builds a new outer vector.
let native: Vec<Vec<i32>> = rows.into_iter().map(Array::into_vec).collect();
assert_eq!(native, [vec![1, 2], vec![], vec![3]]);
}
}
One operation for native and interpreted callers
op1_repr binds complete canonical witnesses, op1_vec moves an array into an
owned vector for a callback, and op1_slice borrows its elements for the callback
without consuming or rebuilding their allocation to obtain the borrow. The
operation still owns its operand and drops it after the slice callback.
The following ordinary Rust function serves both native construction and CEL.
register_function binds its ordinary-call name; register_operation supplies
the complete signature and emitter:
#![allow(unused)]
fn main() {
use cel_runtime::{Array, CNil, CStackList};
/// Names the actual Rust value of a CEL `(i32, bool)`, not its witness.
type RowValue = CStackList<i32, CStackList<bool, CNil<()>>>;
/// Reverses rows without cloning their elements.
///
/// - Complexity: O(n) time for n rows; preserves the vector allocation.
fn reverse_rows(mut rows: Vec<RowValue>) -> Array<RowValue> {
rows.reverse();
Array::from_vec(rows)
}
/// Uses one concrete Rust operation from native construction and interpreted CEL.
#[test]
fn shared_native_and_interpreted_operation() {
use cel_parser::{CELParser, OpLookup, OperationSignature, builtin_type_resolver};
use cel_runtime::{ArrayOf, Cons, IntoCStackList, IntoRustTuple, Leaf, Nil};
type Row = Cons<Leaf<i32>, Cons<Leaf<bool>, Nil>>;
let native = reverse_rows(vec![
(7, true).into_c_stack_list(),
(8, false).into_c_stack_list(),
]);
let shape = CELParser::new(OpLookup::new())
.parse_type_expr_str("[(i32, bool)]")
.unwrap()
.resolve(builtin_type_resolver().as_ref())
.unwrap();
let mut lookup = OpLookup::new();
/// Binds the operation's ordinary-call name separately from its signature.
#[derive(Clone)]
struct ReverseRows;
lookup
.register_function("reverse_rows", ReverseRows)
.unwrap();
lookup
.register_operation(OperationSignature {
name: "reverse_rows",
operands: vec![shape.clone()],
result: shape,
emit: Box::new(|body, _| body.op1_vec::<Row, ArrayOf<Row>, _>(reverse_rows)),
})
.unwrap();
let body = CELParser::new(lookup)
.parse_str("reverse_rows([(7, true), (8, false)])")
.unwrap();
let mut body = body.prepare::<Nil, ArrayOf<Row>>().unwrap();
for rows in [native, body.call(&().into_c_stack_list()).unwrap()] {
let actual: Vec<_> = rows
.into_vec()
.into_iter()
.map(IntoRustTuple::into_rust_tuple)
.collect();
assert_eq!(actual, [(8, false), (7, true)]);
}
}
}
Checked boundaries and prepared execution
ValueType records complete identity, layout, ownership, and optional
capabilities. ResolvedType and the checker type Ty retain recursive shapes,
including callable signatures.
OwnedValue::try_into_typed checks arbitrary external ownership against a full
witness. DynSegment::prepare::<Args, Result> instead checks a typed frame once
and produces PreparedSegment; its repeated call borrows canonical arguments
and returns independent owners without descriptor comparisons, downcasts, or
per-element validation. Hosts with runtime-bound storage can use
prepared_segment::PreparedRawSegment: construction checks descriptors, but
its unsafe call requires exact, live, aligned pointers for that prepared frame.
Neither API removes value-dependent bounds checks or body errors.
Clone, equality, and debug formatting are separate capabilities. Construction and moving ownership do not require them; borrowing an emitted argument read requires cloning, and requesting comparison or formatting requires the matching capability.
Edge cases
[]is not valid because an empty array needs a type annotation.[0,]is not valid.[]: [i32]is a valid typed empty array.[0, true]is not valid because the element types differ.[(0, 1)]and[]: [(i32, i32)]are valid tuple arrays.[]: [[i32]]is a valid typed empty nested array.[(0,), (true,)]is invalid because the complete element shapes differ.- Bracket postfix indexing such as
items[0]is not part of the language. - A suffixed tuple index such as
.0i32is not valid.