cel-rs-rb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4a2986048cff8c1e335c72ffc410487bae67a02251ff9ef9815c34a430199b39
4
+ data.tar.gz: 14df42d51ffda3ecdbfd0a9455a9e11d9990b0f30f11c9f261d5367768d35498
5
+ SHA512:
6
+ metadata.gz: 1af27f75d923ded581e5dfab5d046cd97ccb3837822f2cbae96f751c6dc7e41e2db539e86e61a70520efdf98c314b5a20ba557e242ffe2fee9788b5a9f29b843
7
+ data.tar.gz: 16f711a65c9ef6b529410d8b3d823bb6317d80626460fa5cd43d01bd365b84efb4a6d800897bf8e927b56e8ce216aa2960e811e9f6000c959c0ec9b24c7c8207
data/Cargo.toml ADDED
@@ -0,0 +1,9 @@
1
+ [workspace]
2
+ members = ["ext/cel"]
3
+ resolver = "2"
4
+
5
+ [profile.release]
6
+ strip = true
7
+
8
+ [profile.dev]
9
+ strip = true
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Atkins
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # cel-rs-rb
2
+
3
+ Ruby bindings for the Rust [`cel`](https://crates.io/crates/cel) crate, implemented with [Magnus](https://github.com/matsadler/magnus).
4
+
5
+ ## Goals
6
+
7
+ - Ruby-first API over the Rust CEL engine
8
+ - Close semantic compatibility with Rust `cel::Program` + `cel::Context`
9
+ - Ruby variable and function interop
10
+ - Thread-safe concurrent execution
11
+ - GVL released while CEL evaluation runs
12
+
13
+ ## Installation
14
+
15
+ ```ruby
16
+ gem "cel-rs-rb"
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```ruby
22
+ require "cel"
23
+
24
+ context = CEL::Context.build(user: {"name" => "Ada"}, scores: [10, 20, 30]) do |ctx|
25
+ ctx.define_function("sum") { |*args| args.sum }
26
+ end
27
+
28
+ program = CEL.compile("sum(scores[0], scores[1], scores[2])")
29
+ program.execute(context)
30
+ # => 60
31
+ ```
32
+
33
+ ### API mapping
34
+
35
+ - `CEL.compile(source)` -> `CEL::Program`
36
+ - `CEL::Program.compile(source)` -> `CEL::Program`
37
+ - `CEL::Program#execute(context = nil)` -> Ruby value
38
+ - `CEL::Program#references` -> `{ "variables" => [...], "functions" => [...] }`
39
+ - `CEL::Context.new(empty = false)` -> context with builtins (`false`) or empty (`true`)
40
+ - `CEL::Context#add_variable(name, value)`
41
+ - `CEL::Context#define_function(name) { |*args| ... }`
42
+ - `CEL::Duration.new(seconds)` -> duration value for context variables and duration results
43
+
44
+ ## Type support
45
+
46
+ ### Ruby -> CEL
47
+
48
+ - `nil` -> `null`
49
+ - `true/false` -> `bool`
50
+ - `Integer` -> `int`
51
+ - `Float` -> `double`
52
+ - `String` / `Symbol` -> `string`
53
+ - binary `String` (`ASCII-8BIT`, e.g. `"abc".b`) -> `bytes`
54
+ - `Time` -> `timestamp`
55
+ - `CEL::Duration` -> `duration`
56
+ - `Array` -> `list`
57
+ - `Hash` with keys `String|Symbol|Integer|Boolean` -> `map`
58
+
59
+ ### CEL -> Ruby
60
+
61
+ - `null` -> `nil`
62
+ - scalar primitives map naturally
63
+ - `bytes` -> binary Ruby `String`
64
+ - `timestamp` -> `Time`
65
+ - `duration` -> `CEL::Duration`
66
+ - `list` -> `Array`
67
+ - `map` -> `Hash`
68
+
69
+ ## Error classes
70
+
71
+ - `CEL::Error`
72
+ - `CEL::ParseError`
73
+ - `CEL::ExecutionError`
74
+ - `CEL::TypeError`
75
+
76
+ ## Thread safety and concurrency
77
+
78
+ - Context data is mutex-protected and immutable during execution snapshots.
79
+ - CEL execution is run with the Ruby GVL released so other Ruby threads can run.
80
+ - Ruby callbacks from CEL functions temporarily re-acquire the GVL.
81
+
82
+ ## Development
83
+
84
+ ### Tooling
85
+
86
+ Uses [mise](https://mise.jdx.dev/) for versions:
87
+
88
+ ```toml
89
+ [tools]
90
+ ruby = "4.0"
91
+ rust = "1.96.0"
92
+ ```
93
+
94
+ ### Test + lint
95
+
96
+ ```bash
97
+ bundle exec rake
98
+ ```
99
+
100
+ This runs:
101
+
102
+ - `standard` (format/lint)
103
+ - native extension compile
104
+ - `rspec`
105
+
106
+ CI also runs the test suite on Ruby 3.3, Ruby 3.4, and Ruby 4.0 through Buildkite.
107
+
108
+ ## Compatibility notes
109
+
110
+ - Some CEL value variants (e.g. opaque custom Rust types) cannot be fully marshaled to Ruby and raise `CEL::TypeError`.
111
+ - This project targets broad CEL compatibility, but parity for every Rust-only extension point is still evolving.
@@ -0,0 +1,14 @@
1
+ [package]
2
+ name = "cel"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ publish = false
6
+
7
+ [lib]
8
+ crate-type = ["cdylib"]
9
+
10
+ [dependencies]
11
+ magnus = { version = "0.8.2", features = ["chrono", "rb-sys"] }
12
+ rb-sys = { version = "0.9.128", features = ["stable-api-compiled-fallback"] }
13
+ cel = { version = "0.13.0", features = ["chrono", "regex", "json"] }
14
+ chrono = "0.4.45"
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "rb_sys/mkmf"
5
+
6
+ create_rust_makefile("cel/cel")
@@ -0,0 +1,499 @@
1
+ use cel::{
2
+ Context as CelContext, ExecutionError as CelExecutionError, FunctionContext, ParseErrors,
3
+ Program as CelProgram, ResolveResult, Value as CelValue,
4
+ };
5
+ use magnus::block::Proc;
6
+ use magnus::prelude::*;
7
+ use magnus::typed_data::Obj;
8
+ use magnus::{function, method, Error, IntoValue, RHash, RString, Ruby, TryConvert, Value};
9
+ use rb_sys::{rb_thread_call_with_gvl, rb_thread_call_without_gvl};
10
+ use std::collections::HashMap;
11
+ use std::ffi::c_void;
12
+ use std::panic::{self, AssertUnwindSafe};
13
+ use std::sync::{Arc, Mutex};
14
+
15
+ mod errors {
16
+ use magnus::prelude::*;
17
+ use magnus::{Error, ExceptionClass, RModule, Ruby};
18
+ use std::cell::RefCell;
19
+
20
+ thread_local! {
21
+ static PARSE: RefCell<Option<ExceptionClass>> = const { RefCell::new(None) };
22
+ static EXECUTION: RefCell<Option<ExceptionClass>> = const { RefCell::new(None) };
23
+ static TYPE: RefCell<Option<ExceptionClass>> = const { RefCell::new(None) };
24
+ }
25
+
26
+ pub fn define(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
27
+ let standard = ruby.exception_standard_error();
28
+ let base = module.define_error("Error", standard)?;
29
+ let parse = module.define_error("ParseError", base)?;
30
+ let execution = module.define_error("ExecutionError", base)?;
31
+ let ty = module.define_error("TypeError", base)?;
32
+ PARSE.with(|slot| *slot.borrow_mut() = Some(parse));
33
+ EXECUTION.with(|slot| *slot.borrow_mut() = Some(execution));
34
+ TYPE.with(|slot| *slot.borrow_mut() = Some(ty));
35
+ Ok(())
36
+ }
37
+
38
+ fn fallback(msg: String) -> Error {
39
+ let ruby = Ruby::get().expect("ruby runtime");
40
+ Error::new(ruby.exception_runtime_error(), msg)
41
+ }
42
+
43
+ pub fn parse(msg: impl Into<String>) -> Error {
44
+ let msg = msg.into();
45
+ PARSE.with(|slot| {
46
+ slot.borrow()
47
+ .map(|exc| Error::new(exc, msg.clone()))
48
+ .unwrap_or_else(|| fallback(msg))
49
+ })
50
+ }
51
+
52
+ pub fn execution(msg: impl Into<String>) -> Error {
53
+ let msg = msg.into();
54
+ EXECUTION.with(|slot| {
55
+ slot.borrow()
56
+ .map(|exc| Error::new(exc, msg.clone()))
57
+ .unwrap_or_else(|| fallback(msg))
58
+ })
59
+ }
60
+
61
+ pub fn ty(msg: impl Into<String>) -> Error {
62
+ let msg = msg.into();
63
+ TYPE.with(|slot| {
64
+ slot.borrow()
65
+ .map(|exc| Error::new(exc, msg.clone()))
66
+ .unwrap_or_else(|| fallback(msg))
67
+ })
68
+ }
69
+ }
70
+
71
+ fn without_gvl<F, T>(f: F) -> T
72
+ where
73
+ F: FnOnce() -> T,
74
+ T: Send,
75
+ {
76
+ struct State<F, T>
77
+ where
78
+ F: FnOnce() -> T,
79
+ T: Send,
80
+ {
81
+ f: Option<F>,
82
+ result: Option<T>,
83
+ }
84
+
85
+ unsafe extern "C" fn call<F, T>(ptr: *mut c_void) -> *mut c_void
86
+ where
87
+ F: FnOnce() -> T,
88
+ T: Send,
89
+ {
90
+ let state = &mut *(ptr as *mut State<F, T>);
91
+ let fun = state.f.take().expect("closure missing");
92
+ state.result = Some(fun());
93
+ std::ptr::null_mut()
94
+ }
95
+
96
+ let mut state = State {
97
+ f: Some(f),
98
+ result: None,
99
+ };
100
+
101
+ unsafe {
102
+ rb_thread_call_without_gvl(
103
+ Some(call::<F, T>),
104
+ &mut state as *mut _ as *mut c_void,
105
+ None,
106
+ std::ptr::null_mut(),
107
+ );
108
+ }
109
+
110
+ state.result.expect("result missing")
111
+ }
112
+
113
+ fn with_gvl<F, T>(f: F) -> T
114
+ where
115
+ F: FnOnce() -> T,
116
+ {
117
+ struct State<F, T>
118
+ where
119
+ F: FnOnce() -> T,
120
+ {
121
+ f: Option<F>,
122
+ result: Option<T>,
123
+ }
124
+
125
+ unsafe extern "C" fn call<F, T>(ptr: *mut c_void) -> *mut c_void
126
+ where
127
+ F: FnOnce() -> T,
128
+ {
129
+ let state = &mut *(ptr as *mut State<F, T>);
130
+ let fun = state.f.take().expect("closure missing");
131
+ state.result = Some(fun());
132
+ std::ptr::null_mut()
133
+ }
134
+
135
+ let mut state = State {
136
+ f: Some(f),
137
+ result: None,
138
+ };
139
+ unsafe {
140
+ rb_thread_call_with_gvl(Some(call::<F, T>), &mut state as *mut _ as *mut c_void);
141
+ }
142
+ state.result.expect("result missing")
143
+ }
144
+
145
+ #[derive(Clone)]
146
+ struct CallbackFunction {
147
+ proc: Proc,
148
+ }
149
+ unsafe impl Send for CallbackFunction {}
150
+ unsafe impl Sync for CallbackFunction {}
151
+
152
+ #[derive(Clone)]
153
+ struct FunctionRegistration {
154
+ name: String,
155
+ callback: Arc<CallbackFunction>,
156
+ }
157
+
158
+ #[derive(Default)]
159
+ #[magnus::wrap(class = "CEL::Context", free_immediately, size)]
160
+ struct ContextWrap {
161
+ use_empty: bool,
162
+ variables: Mutex<HashMap<String, CelValue>>,
163
+ functions: Mutex<Vec<FunctionRegistration>>,
164
+ }
165
+
166
+ #[magnus::wrap(class = "CEL::Program", free_immediately, size)]
167
+ struct ProgramWrap {
168
+ inner: CelProgram,
169
+ }
170
+
171
+ #[derive(Clone)]
172
+ #[magnus::wrap(class = "CEL::Duration", free_immediately, size)]
173
+ struct DurationWrap {
174
+ inner: chrono::Duration,
175
+ }
176
+
177
+ fn ruby_to_cel_value(value: Value) -> Result<CelValue, Error> {
178
+ let ruby = Ruby::get().expect("ruby runtime");
179
+
180
+ if value.is_nil() {
181
+ return Ok(CelValue::Null);
182
+ }
183
+ if value.is_kind_of(ruby.class_true_class()) || value.is_kind_of(ruby.class_false_class()) {
184
+ return Ok(CelValue::Bool(<bool as TryConvert>::try_convert(value)?));
185
+ }
186
+ if value.is_kind_of(ruby.class_integer()) {
187
+ return Ok(CelValue::Int(<i64 as TryConvert>::try_convert(value)?));
188
+ }
189
+ if value.is_kind_of(ruby.class_float()) {
190
+ return Ok(CelValue::Float(<f64 as TryConvert>::try_convert(value)?));
191
+ }
192
+ if value.is_kind_of(ruby.class_string()) {
193
+ let string = <RString as TryConvert>::try_convert(value)?;
194
+ if string.enc_get() == ruby.ascii8bit_encindex() {
195
+ return Ok(CelValue::Bytes(Arc::new(unsafe {
196
+ string.as_slice().to_vec()
197
+ })));
198
+ }
199
+
200
+ return Ok(CelValue::String(Arc::new(
201
+ <String as TryConvert>::try_convert(value)?,
202
+ )));
203
+ }
204
+ if value.is_kind_of(ruby.class_symbol()) {
205
+ let sym = <magnus::Symbol as TryConvert>::try_convert(value)?;
206
+ return Ok(CelValue::String(Arc::new(sym.name()?.to_string())));
207
+ }
208
+ if value.is_kind_of(ruby.class_time()) {
209
+ return Ok(CelValue::Timestamp(
210
+ <chrono::DateTime<chrono::FixedOffset> as TryConvert>::try_convert(value)?,
211
+ ));
212
+ }
213
+ if let Ok(duration) = <Obj<DurationWrap> as TryConvert>::try_convert(value) {
214
+ return Ok(CelValue::Duration(duration.inner));
215
+ }
216
+ if value.is_kind_of(ruby.class_array()) {
217
+ let array = <magnus::RArray as TryConvert>::try_convert(value)?;
218
+ let mut out = Vec::with_capacity(array.len());
219
+ for element in array.into_iter() {
220
+ out.push(ruby_to_cel_value(element)?);
221
+ }
222
+ return Ok(CelValue::List(Arc::new(out)));
223
+ }
224
+ if value.is_kind_of(ruby.class_hash()) {
225
+ let hash = <RHash as TryConvert>::try_convert(value)?;
226
+ let mut out = HashMap::new();
227
+ hash.foreach(|k: Value, v: Value| {
228
+ let key = if let Ok(s) = <String as TryConvert>::try_convert(k) {
229
+ cel::objects::Key::from(s)
230
+ } else if let Ok(sym) = <magnus::Symbol as TryConvert>::try_convert(k) {
231
+ cel::objects::Key::from(sym.name()?.to_string())
232
+ } else if let Ok(i) = <i64 as TryConvert>::try_convert(k) {
233
+ cel::objects::Key::from(i)
234
+ } else if let Ok(b) = <bool as TryConvert>::try_convert(k) {
235
+ cel::objects::Key::from(b)
236
+ } else {
237
+ return Err(errors::ty(
238
+ "Hash keys must be String/Symbol/Integer/Boolean",
239
+ ));
240
+ };
241
+ out.insert(key, ruby_to_cel_value(v)?);
242
+ Ok(magnus::r_hash::ForEach::Continue)
243
+ })?;
244
+ return Ok(CelValue::Map(cel::objects::Map { map: Arc::new(out) }));
245
+ }
246
+
247
+ Err(errors::ty("Unsupported Ruby type"))
248
+ }
249
+
250
+ fn cel_to_ruby(ruby: &Ruby, value: &CelValue) -> Result<Value, Error> {
251
+ Ok(match value {
252
+ CelValue::Int(v) => (*v).into_value_with(ruby),
253
+ CelValue::UInt(v) => (*v).into_value_with(ruby),
254
+ CelValue::Float(v) => (*v).into_value_with(ruby),
255
+ CelValue::String(v) => v.to_string().into_value_with(ruby),
256
+ CelValue::Bytes(v) => ruby
257
+ .enc_str_new(v.as_slice(), ruby.ascii8bit_encoding())
258
+ .into_value_with(ruby),
259
+ CelValue::Bool(v) => (*v).into_value_with(ruby),
260
+ CelValue::Duration(v) => ruby
261
+ .obj_wrap(DurationWrap { inner: *v })
262
+ .into_value_with(ruby),
263
+ CelValue::Timestamp(v) => (*v).into_value_with(ruby),
264
+ CelValue::Null => ruby.qnil().as_value(),
265
+ CelValue::List(v) => {
266
+ let ary = ruby.ary_new();
267
+ for element in v.iter() {
268
+ ary.push(cel_to_ruby(ruby, element)?)?;
269
+ }
270
+ ary.into_value_with(ruby)
271
+ }
272
+ CelValue::Map(v) => {
273
+ let hash = ruby.hash_new();
274
+ for (k, val) in v.map.iter() {
275
+ let key: Value = match k {
276
+ cel::objects::Key::Int(i) => (*i).into_value_with(ruby),
277
+ cel::objects::Key::Uint(u) => (*u).into_value_with(ruby),
278
+ cel::objects::Key::Bool(b) => (*b).into_value_with(ruby),
279
+ cel::objects::Key::String(s) => s.to_string().into_value_with(ruby),
280
+ };
281
+ hash.aset(key, cel_to_ruby(ruby, val)?)?;
282
+ }
283
+ hash.into_value_with(ruby)
284
+ }
285
+ _ => {
286
+ return Err(errors::ty(format!(
287
+ "Unsupported CEL value variant: {value:?}"
288
+ )))
289
+ }
290
+ })
291
+ }
292
+
293
+ impl DurationWrap {
294
+ fn new(seconds: f64) -> Result<Self, Error> {
295
+ if !seconds.is_finite() {
296
+ return Err(errors::ty("Duration seconds must be finite"));
297
+ }
298
+
299
+ let nanos = (seconds * 1_000_000_000.0).round();
300
+ if nanos < i64::MIN as f64 || nanos > i64::MAX as f64 {
301
+ return Err(errors::ty("Duration is out of range"));
302
+ }
303
+
304
+ Ok(Self {
305
+ inner: chrono::Duration::nanoseconds(nanos as i64),
306
+ })
307
+ }
308
+
309
+ fn total_seconds(&self) -> f64 {
310
+ let nanos = self.inner.num_nanoseconds().unwrap_or_else(|| {
311
+ if self.inner < chrono::Duration::zero() {
312
+ i64::MIN
313
+ } else {
314
+ i64::MAX
315
+ }
316
+ });
317
+
318
+ nanos as f64 / 1_000_000_000.0
319
+ }
320
+
321
+ fn to_s(&self) -> String {
322
+ format!("{}s", self.total_seconds())
323
+ }
324
+
325
+ fn inspect(&self) -> String {
326
+ format!("#<CEL::Duration {}>", self.to_s())
327
+ }
328
+
329
+ fn eq(&self, other: Obj<DurationWrap>) -> bool {
330
+ self.inner == other.inner
331
+ }
332
+ }
333
+
334
+ impl ContextWrap {
335
+ fn new(empty: bool) -> Self {
336
+ Self {
337
+ use_empty: empty,
338
+ variables: Mutex::new(HashMap::new()),
339
+ functions: Mutex::new(Vec::new()),
340
+ }
341
+ }
342
+
343
+ fn add_variable(&self, name: String, value: Value) -> Result<(), Error> {
344
+ self.variables
345
+ .lock()
346
+ .unwrap()
347
+ .insert(name, ruby_to_cel_value(value)?);
348
+ Ok(())
349
+ }
350
+
351
+ fn add_function(&self, name: String, proc: Proc) {
352
+ self.functions.lock().unwrap().push(FunctionRegistration {
353
+ name,
354
+ callback: Arc::new(CallbackFunction { proc }),
355
+ });
356
+ }
357
+
358
+ fn build_context(&self) -> CelContext<'static> {
359
+ let mut ctx = if self.use_empty {
360
+ CelContext::empty()
361
+ } else {
362
+ CelContext::default()
363
+ };
364
+
365
+ for (name, value) in self.variables.lock().unwrap().iter() {
366
+ ctx.add_variable_from_value(name.as_str(), value.clone());
367
+ }
368
+
369
+ for registration in self.functions.lock().unwrap().iter() {
370
+ let callback = registration.callback.clone();
371
+ let function_name = registration.name.clone();
372
+ ctx.add_function(
373
+ &function_name,
374
+ move |ftx: &FunctionContext,
375
+ cel::extractors::Arguments(args): cel::extractors::Arguments|
376
+ -> ResolveResult {
377
+ let callback = callback.clone();
378
+ let this = ftx.this.clone();
379
+ let args = args.clone();
380
+
381
+ with_gvl(|| {
382
+ let ruby = Ruby::get().expect("ruby runtime");
383
+ let mut ruby_args = Vec::new();
384
+
385
+ if let Some(target) = this {
386
+ ruby_args.push(cel_to_ruby(&ruby, &target).map_err(|e| {
387
+ CelExecutionError::function_error(ftx.name, e.to_string())
388
+ })?);
389
+ }
390
+
391
+ for arg in args.iter() {
392
+ ruby_args.push(cel_to_ruby(&ruby, arg).map_err(|e| {
393
+ CelExecutionError::function_error(ftx.name, e.to_string())
394
+ })?);
395
+ }
396
+
397
+ let proc_result =
398
+ callback.proc.call(ruby_args.as_slice()).map_err(|e| {
399
+ CelExecutionError::function_error(
400
+ ftx.name,
401
+ format!("Ruby callback error: {e}"),
402
+ )
403
+ })?;
404
+
405
+ ruby_to_cel_value(proc_result)
406
+ .map_err(|e| CelExecutionError::function_error(ftx.name, e.to_string()))
407
+ })
408
+ },
409
+ );
410
+ }
411
+
412
+ ctx
413
+ }
414
+ }
415
+
416
+ impl ProgramWrap {
417
+ fn compile(source: String) -> Result<Self, Error> {
418
+ CelProgram::compile(&source)
419
+ .map(|inner| Self { inner })
420
+ .map_err(|e: ParseErrors| errors::parse(e.to_string()))
421
+ }
422
+
423
+ fn execute(&self) -> Result<Value, Error> {
424
+ self.execute_with_context_internal(&CelContext::default())
425
+ }
426
+
427
+ fn execute_with_context(&self, context: &ContextWrap) -> Result<Value, Error> {
428
+ self.execute_with_context_internal(&context.build_context())
429
+ }
430
+
431
+ fn execute_with_context_internal(&self, ctx: &CelContext<'_>) -> Result<Value, Error> {
432
+ let run = || self.inner.execute(ctx);
433
+ let result = panic::catch_unwind(AssertUnwindSafe(|| without_gvl(run)))
434
+ .map_err(|_| errors::execution("CEL execution panicked"))?;
435
+
436
+ let ruby = Ruby::get().expect("ruby runtime");
437
+ result
438
+ .map_err(|e| errors::execution(e.to_string()))
439
+ .and_then(|value| cel_to_ruby(&ruby, &value))
440
+ }
441
+
442
+ fn references(&self) -> Result<RHash, Error> {
443
+ let ruby = Ruby::get().expect("ruby runtime");
444
+ let refs = self.inner.references();
445
+
446
+ let vars = ruby.ary_new();
447
+ for var in refs.variables() {
448
+ vars.push(var)?;
449
+ }
450
+
451
+ let funcs = ruby.ary_new();
452
+ for func in refs.functions() {
453
+ funcs.push(func)?;
454
+ }
455
+
456
+ let out = ruby.hash_new();
457
+ out.aset("variables", vars)?;
458
+ out.aset("functions", funcs)?;
459
+ Ok(out)
460
+ }
461
+
462
+ fn expression(&self) -> String {
463
+ format!("{:?}", self.inner.expression())
464
+ }
465
+ }
466
+
467
+ #[magnus::init]
468
+ fn init(ruby: &Ruby) -> Result<(), Error> {
469
+ let module = ruby.define_module("CEL")?;
470
+ errors::define(ruby, &module)?;
471
+
472
+ let context_class = module.define_class("Context", ruby.class_object())?;
473
+ context_class.define_singleton_method("new", function!(ContextWrap::new, 1))?;
474
+ context_class.define_method("add_variable", method!(ContextWrap::add_variable, 2))?;
475
+ context_class.define_method("[]=", method!(ContextWrap::add_variable, 2))?;
476
+ context_class.define_method("add_function", method!(ContextWrap::add_function, 2))?;
477
+
478
+ let duration_class = module.define_class("Duration", ruby.class_object())?;
479
+ duration_class.define_singleton_method("new", function!(DurationWrap::new, 1))?;
480
+ duration_class.define_method("total_seconds", method!(DurationWrap::total_seconds, 0))?;
481
+ duration_class.define_method("to_f", method!(DurationWrap::total_seconds, 0))?;
482
+ duration_class.define_method("to_s", method!(DurationWrap::to_s, 0))?;
483
+ duration_class.define_method("inspect", method!(DurationWrap::inspect, 0))?;
484
+ duration_class.define_method("==", method!(DurationWrap::eq, 1))?;
485
+
486
+ let program_class = module.define_class("Program", ruby.class_object())?;
487
+ program_class.define_singleton_method("compile", function!(ProgramWrap::compile, 1))?;
488
+ program_class.define_method("execute", method!(ProgramWrap::execute, 0))?;
489
+ program_class.define_method(
490
+ "execute_with_context",
491
+ method!(ProgramWrap::execute_with_context, 1),
492
+ )?;
493
+ program_class.define_method("references", method!(ProgramWrap::references, 0))?;
494
+ program_class.define_method("expression", method!(ProgramWrap::expression, 0))?;
495
+
496
+ module.define_singleton_method("compile", function!(ProgramWrap::compile, 1))?;
497
+
498
+ Ok(())
499
+ }
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CEL
4
+ VERSION = "0.1.0"
5
+ end
data/lib/cel.rb ADDED
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cel/version"
4
+
5
+ begin
6
+ RUBY_VERSION =~ /(\d+\.\d+)/
7
+ require "cel/#{Regexp.last_match(1)}/cel"
8
+ rescue LoadError
9
+ require "cel/cel"
10
+ end
11
+
12
+ module CEL
13
+ class Context
14
+ class << self
15
+ alias_method :__native_new, :new
16
+
17
+ def new(empty = false)
18
+ __native_new(!!empty)
19
+ end
20
+
21
+ def build(empty: false, **variables)
22
+ ctx = new(empty)
23
+ variables.each { |k, v| ctx.add_variable(k.to_s, v) }
24
+ yield(ctx) if block_given?
25
+ ctx
26
+ end
27
+ end
28
+
29
+ def define_function(name, &block)
30
+ raise ArgumentError, "block required" unless block
31
+
32
+ add_function(name.to_s, block)
33
+ end
34
+ end
35
+
36
+ class Program
37
+ alias_method :__native_execute, :execute
38
+
39
+ def execute(context = nil)
40
+ return __native_execute if context.nil?
41
+
42
+ execute_with_context(context)
43
+ end
44
+
45
+ def call(context = nil)
46
+ execute(context)
47
+ end
48
+ end
49
+ end
data/spec/cel_spec.rb ADDED
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "time"
5
+
6
+ RSpec.describe CEL do
7
+ describe ".compile" do
8
+ it "compiles and executes simple expressions" do
9
+ program = CEL.compile("1 + 1")
10
+ expect(program.execute).to eq(2)
11
+ end
12
+
13
+ it "raises parse errors for invalid programs" do
14
+ expect { CEL.compile("1 +") }.to raise_error(CEL::ParseError)
15
+ end
16
+ end
17
+
18
+ describe CEL::Context do
19
+ it "supports ruby variables for basic types" do
20
+ context = CEL::Context.new
21
+ context.add_variable("nil_value", nil)
22
+ context.add_variable("flag", true)
23
+ context.add_variable("num", 41)
24
+ context.add_variable("float_num", 1.5)
25
+ context.add_variable("name", "cel")
26
+ context.add_variable("ary", [1, 2, 3])
27
+ context.add_variable("obj", {"a" => 1, :b => 2})
28
+
29
+ expect(CEL.compile("nil_value == null").execute(context)).to eq(true)
30
+ expect(CEL.compile("flag && true").execute(context)).to eq(true)
31
+ expect(CEL.compile("num + 1").execute(context)).to eq(42)
32
+ expect(CEL.compile("float_num + 0.5").execute(context)).to eq(2.0)
33
+ expect(CEL.compile("name.startsWith('c')").execute(context)).to eq(true)
34
+ expect(CEL.compile("ary[2]").execute(context)).to eq(3)
35
+ expect(CEL.compile("obj.a + obj.b").execute(context)).to eq(3)
36
+ end
37
+
38
+ it "supports ruby values for CEL bytes, timestamp, and duration types" do
39
+ context = CEL::Context.build(
40
+ bytes: "abc".b,
41
+ at: Time.utc(2023, 5, 29),
42
+ delay: CEL::Duration.new(90)
43
+ )
44
+
45
+ expect(CEL.compile("bytes == b'abc'").execute(context)).to eq(true)
46
+ expect(CEL.compile("at == timestamp('2023-05-29T00:00:00Z')").execute(context)).to eq(true)
47
+ expect(CEL.compile("delay == duration('90s')").execute(context)).to eq(true)
48
+ end
49
+
50
+ it "registers ruby functions and supports variadic calls" do
51
+ context = CEL::Context.new
52
+ context.define_function("sum") do |*values|
53
+ values.flatten.sum
54
+ end
55
+
56
+ expect(CEL.compile("sum(1, 2, 3)").execute(context)).to eq(6)
57
+ end
58
+
59
+ it "passes method receiver as first block arg for method calls" do
60
+ context = CEL::Context.new(true)
61
+ context.define_function("startsWith") { |target, prefix| target.start_with?(prefix) }
62
+
63
+ expect(CEL.compile("'hello'.startsWith('he')").execute(context)).to eq(true)
64
+ end
65
+
66
+ it "raises clear type error for unsupported variable types" do
67
+ context = CEL::Context.new
68
+ expect { context.add_variable("bad", Object.new) }.to raise_error(CEL::TypeError)
69
+ end
70
+ end
71
+
72
+ describe CEL::Program do
73
+ it "exposes references" do
74
+ program = CEL.compile("size(foo) > 0")
75
+ refs = program.references
76
+ expect(refs["variables"]).to include("foo")
77
+ expect(refs["functions"]).to include("size")
78
+ end
79
+
80
+ it "ports core CEL suite behavior examples" do
81
+ tests = {
82
+ "size([1,2,3]) == 3" => true,
83
+ "[1,2,3].map(x, x * 2)" => [2, 4, 6],
84
+ "[1,2,3].filter(x, x > 1)" => [2, 3],
85
+ "[1,2,3].exists(x, x == 2)" => true,
86
+ "[1,2,3].all(x, x > 0)" => true,
87
+ "{'a': 1}.contains('a')" => true,
88
+ "optional.of(1).hasValue()" => true
89
+ }
90
+
91
+ tests.each do |expr, expected|
92
+ expect(CEL.compile(expr).execute).to eq(expected)
93
+ end
94
+ end
95
+
96
+ it "marshals current CEL scalar value variants back to ruby" do
97
+ bytes = CEL.compile("b'abc'").execute
98
+ expect(bytes).to eq("abc".b)
99
+ expect(bytes.encoding).to eq(Encoding::ASCII_8BIT)
100
+
101
+ timestamp = CEL.compile("timestamp('2023-05-29T00:00:00Z')").execute
102
+ expect(timestamp).to be_a(Time)
103
+ expect(timestamp.getutc.iso8601).to eq("2023-05-29T00:00:00Z")
104
+
105
+ duration = CEL.compile("duration('1m30s')").execute
106
+ expect(duration).to be_a(CEL::Duration)
107
+ expect(duration.total_seconds).to eq(90.0)
108
+ expect(duration).to eq(CEL::Duration.new(90))
109
+ end
110
+
111
+ it "returns execution errors as CEL::ExecutionError" do
112
+ program = CEL.compile("1 / 0")
113
+ expect { program.execute }.to raise_error(CEL::ExecutionError)
114
+ end
115
+
116
+ it "releases GVL so other ruby threads can progress" do
117
+ context = CEL::Context.new
118
+ context.add_variable("items", Array.new(15_000, 1))
119
+ program = CEL.compile("items.map(x, x + 1)")
120
+
121
+ marker = Queue.new
122
+ worker = Thread.new do
123
+ 100.times do
124
+ marker << :tick
125
+ sleep(0.001)
126
+ end
127
+ end
128
+
129
+ runner = Thread.new { program.execute(context) }
130
+
131
+ sleep(0.01)
132
+ expect(marker.size).to be > 0
133
+
134
+ runner.join
135
+ worker.join
136
+ end
137
+
138
+ it "is thread-safe when executing concurrently" do
139
+ context = CEL::Context.new
140
+ context.add_variable("n", 10)
141
+ program = CEL.compile("n * 2")
142
+
143
+ threads = Array.new(8) { Thread.new { 50.times.map { program.execute(context) } } }
144
+ values = threads.flat_map(&:value)
145
+
146
+ expect(values.uniq).to eq([20])
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "simplecov"
4
+
5
+ SimpleCov.start do
6
+ add_filter "/spec/"
7
+ end
8
+
9
+ require "cel"
metadata ADDED
@@ -0,0 +1,137 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cel-rs-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - CEL Ruby Contributors
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rb_sys
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.9.128
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.9.128
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.4'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.4'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake-compiler
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.3'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.3'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.13'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.13'
68
+ - !ruby/object:Gem::Dependency
69
+ name: simplecov
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.22'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.22'
82
+ - !ruby/object:Gem::Dependency
83
+ name: standard
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '1.55'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '1.55'
96
+ description: Robust Ruby bindings to the Rust CEL implementation using Magnus
97
+ email: []
98
+ executables: []
99
+ extensions:
100
+ - ext/cel/extconf.rb
101
+ extra_rdoc_files: []
102
+ files:
103
+ - Cargo.toml
104
+ - LICENSE
105
+ - README.md
106
+ - ext/cel/Cargo.toml
107
+ - ext/cel/extconf.rb
108
+ - ext/cel/src/lib.rs
109
+ - lib/cel.rb
110
+ - lib/cel/version.rb
111
+ - spec/cel_spec.rb
112
+ - spec/spec_helper.rb
113
+ homepage: https://github.com/catkins/cel-rs-rb
114
+ licenses:
115
+ - MIT
116
+ metadata:
117
+ homepage_uri: https://github.com/catkins/cel-rs-rb
118
+ source_code_uri: https://github.com/catkins/cel-rs-rb
119
+ rubygems_mfa_required: 'true'
120
+ rdoc_options: []
121
+ require_paths:
122
+ - lib
123
+ required_ruby_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: 3.3.0
128
+ required_rubygems_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ requirements: []
134
+ rubygems_version: 4.0.10
135
+ specification_version: 4
136
+ summary: Ruby bindings for the Rust CEL crate
137
+ test_files: []