Skip to main content

crepuscularity_abi/
lib.rs

1//! C ABI for View IR rendering sessions.
2//!
3//! # Thread safety
4//!
5//! [`CrepusSession`] is **not** `Sync`. Use one session per thread, or serialize
6//! all calls to a shared session with an external mutex.
7//!
8//! # Panics
9//!
10//! This crate is built with `panic = "abort"` so unwinding never crosses the C ABI.
11//!
12//! # Event callbacks
13//!
14//! Pointers passed to [`CrepusEventCallback`] are valid until the next
15//! `crepus_session_dispatch_event` call on the same session (or until the session
16//! is freed). Do not retain them past that point.
17
18use std::cell::RefCell;
19use std::collections::HashMap;
20use std::ffi::{CStr, CString};
21use std::os::raw::{c_char, c_void};
22use std::path::PathBuf;
23use std::ptr;
24
25use crepuscularity_core::context::{TemplateContext, TemplateValue};
26use crepuscularity_native::{
27    render_component_file_to_ir, render_from_files, render_template_to_ir, to_json,
28};
29use serde::Deserialize;
30use serde_json::{json, Value};
31
32// Safety: all `#[no_mangle] extern "C"` functions validate null pointers before
33// dereferencing. The `session` pointer passed to every function except
34// `crepus_session_new` MUST have been returned by `crepus_session_new` and MUST
35// NOT have been freed via `crepus_session_free`. String pointers (`template_utf8`,
36// `event_json_utf8`, etc.) MUST point to valid null-terminated UTF-8 C strings.
37// `crepus_string_free` MUST only be called on pointers returned by this crate;
38// after calling it the pointer is invalid and MUST NOT be used again.
39// Event callback JSON pointers are valid only until the next dispatch on that session.
40
41thread_local! {
42    static LAST_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
43}
44
45pub type CrepusEventCallback = extern "C" fn(event_json: *const c_char, userdata: *mut c_void);
46
47#[derive(Default)]
48pub struct CrepusSession {
49    source: Option<Source>,
50    component: Option<String>,
51    context: TemplateContext,
52    callback: Option<CrepusEventCallback>,
53    userdata: *mut c_void,
54    last_error: Option<String>,
55    /// Keeps the most recent event callback payload alive until the next dispatch.
56    last_event_payload: Option<CString>,
57}
58
59enum Source {
60    Template {
61        template: String,
62        base_dir: Option<PathBuf>,
63    },
64    Files {
65        entry: String,
66        files: HashMap<String, String>,
67    },
68}
69
70#[derive(Deserialize)]
71#[serde(rename_all = "camelCase")]
72struct FilesEnvelope {
73    entry: String,
74    files: HashMap<String, String>,
75}
76
77#[derive(Deserialize)]
78#[serde(rename_all = "camelCase")]
79struct EventEnvelope {
80    handler: Option<String>,
81    event: Option<String>,
82    payload: Option<Value>,
83    context: Option<Value>,
84}
85
86#[no_mangle]
87pub extern "C" fn crepus_session_new() -> *mut CrepusSession {
88    Box::into_raw(Box::new(CrepusSession::default()))
89}
90
91#[no_mangle]
92pub extern "C" fn crepus_session_free(session: *mut CrepusSession) {
93    if session.is_null() {
94        return;
95    }
96    drop_session(session);
97}
98
99#[no_mangle]
100pub extern "C" fn crepus_session_set_template_string(
101    session: *mut CrepusSession,
102    template_utf8: *const c_char,
103    base_dir_utf8: *const c_char,
104) -> i32 {
105    with_session(session, |session| {
106        let template = read_required(template_utf8, "template")?;
107        let base_dir = read_optional(base_dir_utf8).map(PathBuf::from);
108        session.source = Some(Source::Template { template, base_dir });
109        Ok(())
110    })
111}
112
113#[no_mangle]
114pub extern "C" fn crepus_session_set_component(
115    session: *mut CrepusSession,
116    component_utf8: *const c_char,
117) -> i32 {
118    with_session(session, |session| {
119        session.component = read_optional(component_utf8);
120        Ok(())
121    })
122}
123
124#[no_mangle]
125pub extern "C" fn crepus_session_set_files_json(
126    session: *mut CrepusSession,
127    files_json_utf8: *const c_char,
128) -> i32 {
129    with_session(session, |session| {
130        let raw = read_required(files_json_utf8, "files_json")?;
131        let env: FilesEnvelope =
132            serde_json::from_str(&raw).map_err(|e| format!("files JSON: {e}"))?;
133        session.source = Some(Source::Files {
134            entry: env.entry,
135            files: env.files,
136        });
137        Ok(())
138    })
139}
140
141#[no_mangle]
142pub extern "C" fn crepus_session_set_context_json(
143    session: *mut CrepusSession,
144    context_json_utf8: *const c_char,
145) -> i32 {
146    with_session(session, |session| {
147        let raw = read_required(context_json_utf8, "context_json")?;
148        let value: Value = serde_json::from_str(&raw).map_err(|e| format!("context JSON: {e}"))?;
149        let mut ctx = TemplateContext::new();
150        merge_json_ctx(&value, &mut ctx)?;
151        session.context.vars = ctx.vars;
152        Ok(())
153    })
154}
155
156#[no_mangle]
157pub extern "C" fn crepus_session_apply_context_patch_json(
158    session: *mut CrepusSession,
159    context_json_utf8: *const c_char,
160) -> i32 {
161    with_session(session, |session| {
162        let raw = read_required(context_json_utf8, "context_json")?;
163        let value: Value = serde_json::from_str(&raw).map_err(|e| format!("context JSON: {e}"))?;
164        merge_json_ctx(&value, &mut session.context)?;
165        Ok(())
166    })
167}
168
169#[no_mangle]
170pub extern "C" fn crepus_session_set_event_callback(
171    session: *mut CrepusSession,
172    callback: Option<CrepusEventCallback>,
173    userdata: *mut c_void,
174) -> i32 {
175    with_session(session, |session| {
176        session.callback = callback;
177        session.userdata = userdata;
178        Ok(())
179    })
180}
181
182#[no_mangle]
183pub extern "C" fn crepus_session_render_ir_json(session: *mut CrepusSession) -> *mut c_char {
184    match with_session_result(session, render_session) {
185        Ok(out) => into_c_string(out),
186        Err(e) => {
187            set_error_for(session, e);
188            ptr::null_mut()
189        }
190    }
191}
192
193#[no_mangle]
194pub extern "C" fn crepus_session_dispatch_event_json(
195    session: *mut CrepusSession,
196    event_json_utf8: *const c_char,
197) -> *mut c_char {
198    match with_session_result(session, |session| dispatch_event(session, event_json_utf8)) {
199        Ok(out) => into_c_string(out),
200        Err(e) => {
201            set_error_for(session, e);
202            ptr::null_mut()
203        }
204    }
205}
206
207#[no_mangle]
208pub extern "C" fn crepus_session_take_last_error(session: *mut CrepusSession) -> *mut c_char {
209    if session.is_null() {
210        return crepus_last_error();
211    }
212    match with_session_result(session, |session| Ok(session.last_error.take())) {
213        Ok(Some(error)) => into_c_string(error),
214        Ok(None) => ptr::null_mut(),
215        Err(e) => {
216            set_error_for(session, e);
217            ptr::null_mut()
218        }
219    }
220}
221
222#[no_mangle]
223pub extern "C" fn crepus_last_error() -> *mut c_char {
224    LAST_ERROR
225        .with(|slot| slot.borrow_mut().take())
226        .map(into_c_string)
227        .unwrap_or(ptr::null_mut())
228}
229
230#[no_mangle]
231pub extern "C" fn crepus_string_free(ptr: *mut c_char) {
232    if ptr.is_null() {
233        return;
234    }
235    drop_c_string(ptr);
236}
237
238fn drop_session(session: *mut CrepusSession) {
239    unsafe {
240        drop(Box::from_raw(session));
241    }
242}
243
244fn drop_c_string(ptr: *mut c_char) {
245    unsafe {
246        drop(CString::from_raw(ptr));
247    }
248}
249
250fn dispatch_event(
251    session: &mut CrepusSession,
252    event_json_utf8: *const c_char,
253) -> Result<String, String> {
254    let raw = read_required(event_json_utf8, "event_json")?;
255    let event = parse_event(&raw)?;
256
257    if let Some(context) = &event.context {
258        merge_json_ctx(context, &mut session.context)?;
259    }
260    if let Some((key, value)) = bind_update(&event.handler) {
261        session.context.set(key, TemplateValue::Str(value));
262    }
263
264    let handler = event
265        .handler
266        .clone()
267        .or(event.event.clone())
268        .unwrap_or_else(|| "event".to_string());
269    let payload = json!({
270        "kind": "event",
271        "handler": handler,
272        "payload": event.payload.unwrap_or(Value::Null),
273    });
274    let payload_json =
275        serde_json::to_string(&payload).map_err(|e| format!("serialize event: {e}"))?;
276
277    if let Some(callback) = session.callback {
278        let c_payload = CString::new(payload_json.clone())
279            .map_err(|_| "event payload contains interior NUL".to_string())?;
280        let ptr = c_payload.as_ptr();
281        session.last_event_payload = Some(c_payload);
282        callback(ptr, session.userdata);
283    }
284
285    let ir_json = render_session(session)?;
286    let out = json!({
287        "kind": "event",
288        "handler": handler,
289        "ir": serde_json::from_str::<Value>(&ir_json).map_err(|e| format!("parse rendered IR: {e}"))?,
290    });
291    serde_json::to_string(&out).map_err(|e| format!("serialize event result: {e}"))
292}
293
294fn parse_event(raw: &str) -> Result<EventEnvelope, String> {
295    if let Ok(value) = serde_json::from_str::<Value>(raw) {
296        match value {
297            Value::String(handler) => Ok(EventEnvelope {
298                handler: Some(handler),
299                event: None,
300                payload: None,
301                context: None,
302            }),
303            Value::Object(_) => {
304                serde_json::from_value(value).map_err(|e| format!("event JSON: {e}"))
305            }
306            _ => Err("event JSON must be a string or object".to_string()),
307        }
308    } else {
309        Ok(EventEnvelope {
310            handler: Some(raw.to_string()),
311            event: None,
312            payload: None,
313            context: None,
314        })
315    }
316}
317
318fn bind_update(handler: &Option<String>) -> Option<(String, String)> {
319    let handler = handler.as_ref()?;
320    let rest = handler.strip_prefix("bind:")?;
321    let (key, value) = rest.split_once(':')?;
322    Some((key.to_string(), value.to_string()))
323}
324
325fn render_session(session: &mut CrepusSession) -> Result<String, String> {
326    let source = session
327        .source
328        .as_ref()
329        .ok_or_else(|| "session source is not set".to_string())?;
330    let ir = match source {
331        Source::Template { template, base_dir } => {
332            let mut ctx = session.context.clone();
333            ctx.base_dir = base_dir.clone();
334            if let Some(component) = &session.component {
335                render_component_file_to_ir(template, component, &ctx).map_err(|e| e.to_string())?
336            } else {
337                render_template_to_ir(template, &ctx).map_err(|e| e.to_string())?
338            }
339        }
340        Source::Files { entry, files } => {
341            render_from_files(files, entry, &session.context).map_err(|e| e.to_string())?
342        }
343    };
344    to_json(&ir).map_err(|e| format!("serialize IR: {e}"))
345}
346
347fn with_session<F>(session: *mut CrepusSession, f: F) -> i32
348where
349    F: FnOnce(&mut CrepusSession) -> Result<(), String>,
350{
351    match with_session_result(session, f) {
352        Ok(()) => 0,
353        Err(e) => {
354            set_error_for(session, e);
355            -1
356        }
357    }
358}
359
360fn with_session_result<F, T>(session: *mut CrepusSession, f: F) -> Result<T, String>
361where
362    F: FnOnce(&mut CrepusSession) -> Result<T, String>,
363{
364    if session.is_null() {
365        return Err("session pointer is null".to_string());
366    }
367    let session = unsafe { &mut *session };
368    f(session)
369}
370
371fn read_required(ptr: *const c_char, label: &str) -> Result<String, String> {
372    read_optional(ptr).ok_or_else(|| format!("{label} pointer is null"))
373}
374
375fn read_optional(ptr: *const c_char) -> Option<String> {
376    if ptr.is_null() {
377        return None;
378    }
379    Some(
380        unsafe { CStr::from_ptr(ptr) }
381            .to_string_lossy()
382            .into_owned(),
383    )
384}
385
386fn merge_json_ctx(value: &Value, ctx: &mut TemplateContext) -> Result<(), String> {
387    let Some(obj) = value.as_object() else {
388        return Err("context must be a JSON object".to_string());
389    };
390    for (key, value) in obj {
391        ctx.set(key.clone(), json_to_template_value(value)?);
392    }
393    Ok(())
394}
395
396fn json_to_template_value(value: &Value) -> Result<TemplateValue, String> {
397    match value {
398        Value::Null => Ok(TemplateValue::Null),
399        Value::Bool(v) => Ok(TemplateValue::Bool(*v)),
400        Value::Number(v) => {
401            if let Some(n) = v.as_i64() {
402                Ok(TemplateValue::Int(n))
403            } else if let Some(n) = v.as_f64() {
404                Ok(TemplateValue::Float(n))
405            } else {
406                Err(format!("unsupported number: {v}"))
407            }
408        }
409        Value::String(v) => Ok(TemplateValue::Str(v.clone())),
410        Value::Array(values) => {
411            let mut items = Vec::new();
412            for item in values {
413                let Some(obj) = item.as_object() else {
414                    return Err("context arrays must contain objects".to_string());
415                };
416                let mut child = TemplateContext::new();
417                for (key, value) in obj {
418                    child.set(key.clone(), json_to_template_scalar(value)?);
419                }
420                items.push(child);
421            }
422            Ok(TemplateValue::List(items))
423        }
424        Value::Object(_) => {
425            Err("context object values are only supported inside arrays".to_string())
426        }
427    }
428}
429
430fn json_to_template_scalar(value: &Value) -> Result<TemplateValue, String> {
431    match value {
432        Value::Array(_) | Value::Object(_) => {
433            Err("loop item fields must be scalar JSON values".to_string())
434        }
435        _ => json_to_template_value(value),
436    }
437}
438
439fn set_error_for(session: *mut CrepusSession, error: String) {
440    if session.is_null() {
441        LAST_ERROR.with(|slot| *slot.borrow_mut() = Some(error));
442    } else {
443        unsafe { &mut *session }.last_error = Some(error);
444    }
445}
446
447fn into_c_string(value: String) -> *mut c_char {
448    match CString::new(value) {
449        Ok(value) => value.into_raw(),
450        Err(e) => {
451            LAST_ERROR.with(|slot| {
452                *slot.borrow_mut() = Some(format!("string contains interior NUL: {e}"))
453            });
454            ptr::null_mut()
455        }
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use std::ffi::{CStr, CString};
462    use std::os::raw::{c_char, c_void};
463    use std::sync::Mutex;
464
465    static EVENTS: Mutex<Vec<String>> = Mutex::new(Vec::new());
466
467    extern "C" fn capture(event_json: *const c_char, _userdata: *mut c_void) {
468        let event = unsafe { CStr::from_ptr(event_json) }
469            .to_string_lossy()
470            .into_owned();
471        EVENTS.lock().unwrap().push(event);
472    }
473
474    fn take_string(ptr: *mut c_char) -> String {
475        assert!(!ptr.is_null());
476        let value = unsafe { CStr::from_ptr(ptr) }
477            .to_string_lossy()
478            .into_owned();
479        super::crepus_string_free(ptr);
480        value
481    }
482
483    #[test]
484    fn renders_ir_json_from_session() {
485        let session = super::crepus_session_new();
486        let template = CString::new("button @click=\"increment\"\n  \"Tap {count}\"").unwrap();
487        let context = CString::new(r#"{"count":1}"#).unwrap();
488        assert_eq!(
489            super::crepus_session_set_template_string(session, template.as_ptr(), std::ptr::null()),
490            0
491        );
492        assert_eq!(
493            super::crepus_session_set_context_json(session, context.as_ptr()),
494            0
495        );
496        let out = take_string(super::crepus_session_render_ir_json(session));
497        assert!(out.contains(r#""onClick":"increment""#));
498        assert!(out.contains("Tap 1"));
499        super::crepus_session_free(session);
500    }
501
502    #[test]
503    fn dispatches_event_callback_and_rerenders() {
504        EVENTS.lock().unwrap().clear();
505        let session = super::crepus_session_new();
506        let template = CString::new("input bind=count\nspan\n  \"Count {count}\"").unwrap();
507        let context = CString::new(r#"{"count":"1"}"#).unwrap();
508        let event = CString::new(r#"{"handler":"bind:count:2"}"#).unwrap();
509        assert_eq!(
510            super::crepus_session_set_template_string(session, template.as_ptr(), std::ptr::null()),
511            0
512        );
513        assert_eq!(
514            super::crepus_session_set_context_json(session, context.as_ptr()),
515            0
516        );
517        assert_eq!(
518            super::crepus_session_set_event_callback(session, Some(capture), std::ptr::null_mut()),
519            0
520        );
521        let out = take_string(super::crepus_session_dispatch_event_json(
522            session,
523            event.as_ptr(),
524        ));
525        assert!(out.contains(r#""handler":"bind:count:2""#));
526        assert!(out.contains("Count 2"));
527        assert_eq!(EVENTS.lock().unwrap().len(), 1);
528        super::crepus_session_free(session);
529    }
530
531    #[test]
532    fn reports_errors_without_panics() {
533        let session = super::crepus_session_new();
534        assert!(super::crepus_session_render_ir_json(session).is_null());
535        let error = take_string(super::crepus_session_take_last_error(session));
536        assert!(error.contains("session source is not set"));
537        super::crepus_session_free(session);
538    }
539}