1use 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
32thread_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 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}