#![allow(unsafe_code, reason = "FFI interface")]
use crate::processor::Processor;
use crate::reference::{Bibliography, Citation, Reference};
use crate::render::djot::Djot;
use crate::render::html::Html;
use crate::render::latex::Latex;
use crate::render::markdown::Markdown;
use crate::render::plain::PlainText;
use crate::render::typst::Typst;
use citum_schema::Style;
use citum_schema::locale::Locale;
use citum_schema::reference::InputReference;
use std::cell::RefCell;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::ptr;
thread_local! {
static LAST_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
}
fn set_error(msg: String) {
LAST_ERROR.with(|e| *e.borrow_mut() = Some(msg));
}
fn safe_c_string(s: String) -> *mut c_char {
match CString::new(s) {
Ok(c) => c.into_raw(),
Err(e) => {
set_error(format!("String contains null bytes: {e}"));
ptr::null_mut()
}
}
}
unsafe fn parse_c_str<'a>(ptr: *const c_char, label: &str) -> Result<&'a str, ()> {
if ptr.is_null() {
set_error(format!("{label} pointer is null"));
return Err(());
}
unsafe { CStr::from_ptr(ptr) }.to_str().map_err(|err| {
set_error(format!("Invalid UTF-8 in {label}: {err}"));
})
}
fn parse_output_format(format: &str) -> Result<&'static str, ()> {
match format {
"html" => Ok("html"),
"latex" => Ok("latex"),
"djot" => Ok("djot"),
"typst" => Ok("typst"),
"plain" => Ok("plain"),
"markdown" => Ok("markdown"),
other => {
set_error(format!("Unsupported output format: {other}"));
Err(())
}
}
}
fn parse_bibliography_json(bib_str: &str) -> Result<Bibliography, String> {
match serde_json::from_str::<Vec<csl_legacy::csl_json::Reference>>(bib_str) {
Ok(legacy_refs) => Ok(legacy_refs
.into_iter()
.map(|r| (r.id.clone(), Reference::from(r)))
.collect()),
Err(_) => {
serde_json::from_str(bib_str).map_err(|e| format!("Bibliography JSON parse error: {e}"))
}
}
}
fn parse_bibliography_yaml(bib_str: &str) -> Result<Bibliography, String> {
let native_err = match serde_yaml::from_str::<citum_schema::InputBibliography>(bib_str) {
Ok(input_bib) => {
let bib: Bibliography = input_bib
.references
.into_iter()
.filter_map(|r| r.id().map(|id| (id.to_string(), r)))
.collect();
return Ok(bib);
}
Err(e) => e,
};
if let Ok(map) = serde_yaml::from_str::<indexmap::IndexMap<String, InputReference>>(bib_str) {
let bib: Bibliography = map
.into_iter()
.map(|(key, mut r)| {
r.set_id(key.clone());
(key, r)
})
.collect();
return Ok(bib);
}
if let Ok(refs) = serde_yaml::from_str::<Vec<InputReference>>(bib_str) {
let bib: Bibliography = refs
.into_iter()
.filter_map(|r| r.id().map(|id| (id.to_string(), r)))
.collect();
return Ok(bib);
}
Err(format!(
"Bibliography YAML parse error (tried InputBibliography, flat map, and Vec): {native_err}"
))
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_get_last_error() -> *mut c_char {
LAST_ERROR.with(|e| {
e.borrow()
.as_ref()
.map_or(ptr::null_mut(), |s| safe_c_string(s.clone()))
})
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_processor_new(
style_json: *const c_char,
bib_json: *const c_char,
) -> *mut Processor {
let Ok(style_str) = (unsafe { parse_c_str(style_json, "style_json") }) else {
return ptr::null_mut();
};
let Ok(bib_str) = (unsafe { parse_c_str(bib_json, "bib_json") }) else {
return ptr::null_mut();
};
let style: Style = match serde_json::from_str(style_str) {
Ok(s) => s,
Err(e) => {
set_error(format!("Style JSON parse error: {e}"));
return ptr::null_mut();
}
};
let bib: Bibliography = match parse_bibliography_json(bib_str) {
Ok(b) => b,
Err(e) => {
set_error(e);
return ptr::null_mut();
}
};
let processor = Box::new(Processor::new(style, bib));
Box::into_raw(processor)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_processor_new_with_locale(
style_json: *const c_char,
bib_json: *const c_char,
locale_json: *const c_char,
) -> *mut Processor {
let Ok(style_str) = (unsafe { parse_c_str(style_json, "style_json") }) else {
return ptr::null_mut();
};
let Ok(bib_str) = (unsafe { parse_c_str(bib_json, "bib_json") }) else {
return ptr::null_mut();
};
let Ok(locale_str) = (unsafe { parse_c_str(locale_json, "locale_json") }) else {
return ptr::null_mut();
};
let style: Style = match serde_json::from_str(style_str) {
Ok(s) => s,
Err(e) => {
set_error(format!("Style JSON parse error: {e}"));
return ptr::null_mut();
}
};
let bib: Bibliography = match parse_bibliography_json(bib_str) {
Ok(b) => b,
Err(e) => {
set_error(e);
return ptr::null_mut();
}
};
let locale: Locale = match serde_json::from_str(locale_str) {
Ok(l) => l,
Err(e) => {
set_error(format!("Locale JSON parse error: {e}"));
return ptr::null_mut();
}
};
let processor = Box::new(Processor::with_locale(style, bib, locale));
Box::into_raw(processor)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_processor_new_from_yaml(
style_yaml: *const c_char,
bib_yaml: *const c_char,
) -> *mut Processor {
let Ok(style_str) = (unsafe { parse_c_str(style_yaml, "style_yaml") }) else {
return ptr::null_mut();
};
let Ok(bib_str) = (unsafe { parse_c_str(bib_yaml, "bib_yaml") }) else {
return ptr::null_mut();
};
let style: Style = match Style::from_yaml_str(style_str) {
Ok(s) => s,
Err(e) => {
set_error(format!("Style YAML parse error: {e}"));
return ptr::null_mut();
}
};
let bib: Bibliography = match parse_bibliography_yaml(bib_str) {
Ok(b) => b,
Err(e) => {
set_error(e);
return ptr::null_mut();
}
};
let processor = Box::new(Processor::new(style, bib));
Box::into_raw(processor)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_processor_new_with_locale_from_yaml(
style_yaml: *const c_char,
bib_yaml: *const c_char,
locale_yaml: *const c_char,
) -> *mut Processor {
let Ok(style_str) = (unsafe { parse_c_str(style_yaml, "style_yaml") }) else {
return ptr::null_mut();
};
let Ok(bib_str) = (unsafe { parse_c_str(bib_yaml, "bib_yaml") }) else {
return ptr::null_mut();
};
let Ok(locale_str) = (unsafe { parse_c_str(locale_yaml, "locale_yaml") }) else {
return ptr::null_mut();
};
let style: Style = match Style::from_yaml_str(style_str) {
Ok(s) => s,
Err(e) => {
set_error(format!("Style YAML parse error: {e}"));
return ptr::null_mut();
}
};
let bib: Bibliography = match parse_bibliography_yaml(bib_str) {
Ok(b) => b,
Err(e) => {
set_error(e);
return ptr::null_mut();
}
};
let locale: Locale = match Locale::from_yaml_str(locale_str) {
Ok(l) => l,
Err(e) => {
set_error(format!("Locale YAML parse error: {e}"));
return ptr::null_mut();
}
};
let processor = Box::new(Processor::with_locale(style, bib, locale));
Box::into_raw(processor)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_processor_free(processor: *mut Processor) {
if !processor.is_null() {
let _ = unsafe { Box::from_raw(processor) };
}
}
unsafe fn render_citation<F>(processor: *mut Processor, cite_json: *const c_char) -> *mut c_char
where
F: crate::render::format::OutputFormat<Output = String>,
{
if processor.is_null() {
set_error("processor pointer is null".to_string());
return ptr::null_mut();
}
let processor = unsafe { &*processor };
let Ok(cite_str) = (unsafe { parse_c_str(cite_json, "cite_json") }) else {
return ptr::null_mut();
};
let citation: Citation = match serde_json::from_str(cite_str) {
Ok(c) => c,
Err(e) => {
set_error(format!("Citation JSON parse error: {e}"));
return ptr::null_mut();
}
};
match processor.process_citation_with_format::<F>(&citation) {
Ok(rendered) => safe_c_string(rendered),
Err(e) => {
set_error(format!("Rendering error: {e}"));
ptr::null_mut()
}
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_render_citation_latex(
processor: *mut Processor,
cite_json: *const c_char,
) -> *mut c_char {
unsafe { render_citation::<Latex>(processor, cite_json) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_render_citation_html(
processor: *mut Processor,
cite_json: *const c_char,
) -> *mut c_char {
unsafe { render_citation::<Html>(processor, cite_json) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_render_citation_plain(
processor: *mut Processor,
cite_json: *const c_char,
) -> *mut c_char {
unsafe { render_citation::<PlainText>(processor, cite_json) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_render_citation_djot(
processor: *mut Processor,
cite_json: *const c_char,
) -> *mut c_char {
unsafe { render_citation::<Djot>(processor, cite_json) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_render_citation_typst(
processor: *mut Processor,
cite_json: *const c_char,
) -> *mut c_char {
unsafe { render_citation::<Typst>(processor, cite_json) }
}
unsafe fn render_bibliography<F>(processor: *mut Processor) -> *mut c_char
where
F: crate::render::format::OutputFormat<Output = String>,
{
if processor.is_null() {
set_error("processor pointer is null".to_string());
return ptr::null_mut();
}
let processor = unsafe { &*processor };
let rendered = processor.render_bibliography_with_format::<F>();
safe_c_string(rendered)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_render_bibliography_latex(processor: *mut Processor) -> *mut c_char {
unsafe { render_bibliography::<Latex>(processor) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_render_bibliography_html(processor: *mut Processor) -> *mut c_char {
unsafe { render_bibliography::<Html>(processor) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_render_bibliography_plain(processor: *mut Processor) -> *mut c_char {
unsafe { render_bibliography::<PlainText>(processor) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_render_bibliography_djot(processor: *mut Processor) -> *mut c_char {
unsafe { render_bibliography::<Djot>(processor) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_render_bibliography_typst(processor: *mut Processor) -> *mut c_char {
unsafe { render_bibliography::<Typst>(processor) }
}
unsafe fn render_grouped_bibliography<F>(processor: *mut Processor) -> *mut c_char
where
F: crate::render::format::OutputFormat<Output = String>,
{
if processor.is_null() {
set_error("processor pointer is null".to_string());
return ptr::null_mut();
}
let processor = unsafe { &*processor };
let rendered = processor.render_grouped_bibliography_with_format::<F>();
safe_c_string(rendered)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_render_bibliography_grouped_html(
processor: *mut Processor,
) -> *mut c_char {
unsafe { render_grouped_bibliography::<Html>(processor) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_render_bibliography_grouped_plain(
processor: *mut Processor,
) -> *mut c_char {
unsafe { render_grouped_bibliography::<PlainText>(processor) }
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_render_citations_json(
processor: *mut Processor,
citations_json: *const c_char,
format: *const c_char,
) -> *mut c_char {
if processor.is_null() {
set_error("processor pointer is null".to_string());
return ptr::null_mut();
}
let processor = unsafe { &*processor };
let Ok(citations_str) = (unsafe { parse_c_str(citations_json, "citations_json") }) else {
return ptr::null_mut();
};
let citations: Vec<Citation> = match serde_json::from_str(citations_str) {
Ok(c) => c,
Err(e) => {
set_error(format!("Citations JSON parse error: {e}"));
return ptr::null_mut();
}
};
let Ok(format_str) = (unsafe { parse_c_str(format, "format") }).and_then(parse_output_format)
else {
return ptr::null_mut();
};
let result = match format_str {
"html" => processor.process_citations_with_format::<Html>(&citations),
"latex" => processor.process_citations_with_format::<Latex>(&citations),
"djot" => processor.process_citations_with_format::<Djot>(&citations),
"typst" => processor.process_citations_with_format::<Typst>(&citations),
"markdown" => processor.process_citations_with_format::<Markdown>(&citations),
_ => processor.process_citations_with_format::<PlainText>(&citations),
};
match result {
Ok(rendered) => match serde_json::to_string(&rendered) {
Ok(json) => safe_c_string(json),
Err(e) => {
set_error(format!("Failed to serialize result: {e}"));
ptr::null_mut()
}
},
Err(e) => {
set_error(format!("Batch rendering error: {e}"));
ptr::null_mut()
}
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_string_free(s: *mut c_char) {
if !s.is_null() {
let _ = unsafe { CString::from_raw(s) };
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn citum_version() -> *mut c_char {
safe_c_string(env!("CARGO_PKG_VERSION").to_string())
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::todo,
clippy::unimplemented,
clippy::unreachable,
clippy::get_unwrap,
reason = "Panicking is acceptable and often desired in tests."
)]
mod tests {
use super::*;
fn c_string(value: &str) -> CString {
CString::new(value).expect("test string has no interior NUL")
}
fn processor() -> *mut Processor {
let style = serde_json::to_string(&Style::default()).expect("style serializes");
let bibliography = "{}";
unsafe { citum_processor_new(c_string(&style).as_ptr(), c_string(bibliography).as_ptr()) }
}
fn last_error() -> String {
let ptr = unsafe { citum_get_last_error() };
assert!(!ptr.is_null(), "last error should be set");
let error = unsafe { CStr::from_ptr(ptr) }
.to_str()
.expect("error is UTF-8")
.to_string();
unsafe { citum_string_free(ptr) };
error
}
#[test]
fn processor_new_rejects_null_style_pointer() {
let bibliography = c_string("{}");
let processor = unsafe { citum_processor_new(ptr::null(), bibliography.as_ptr()) };
assert!(processor.is_null());
assert!(last_error().contains("style_json pointer is null"));
}
#[test]
fn processor_new_rejects_invalid_utf8() {
let invalid = [0xff, 0x00];
let bibliography = c_string("{}");
let processor = unsafe {
citum_processor_new(invalid.as_ptr().cast::<c_char>(), bibliography.as_ptr())
};
assert!(processor.is_null());
assert!(last_error().contains("Invalid UTF-8 in style_json"));
}
#[test]
fn processor_new_rejects_invalid_json() {
let style = c_string("{");
let bibliography = c_string("{}");
let processor = unsafe { citum_processor_new(style.as_ptr(), bibliography.as_ptr()) };
assert!(processor.is_null());
assert!(last_error().contains("Style JSON parse error"));
}
#[test]
fn render_citation_rejects_null_processor() {
let citation = c_string("{}");
let rendered = unsafe { citum_render_citation_plain(ptr::null_mut(), citation.as_ptr()) };
assert!(rendered.is_null());
assert!(last_error().contains("processor pointer is null"));
}
#[test]
fn render_citation_rejects_null_citation_pointer() {
let processor = processor();
assert!(!processor.is_null());
let rendered = unsafe { citum_render_citation_plain(processor, ptr::null()) };
assert!(rendered.is_null());
assert!(last_error().contains("cite_json pointer is null"));
unsafe { citum_processor_free(processor) };
}
#[test]
fn batch_render_rejects_invalid_format() {
let processor = processor();
assert!(!processor.is_null());
let citations = c_string("[]");
let format = c_string("bogus");
let rendered =
unsafe { citum_render_citations_json(processor, citations.as_ptr(), format.as_ptr()) };
assert!(rendered.is_null());
assert!(last_error().contains("Unsupported output format"));
unsafe { citum_processor_free(processor) };
}
#[test]
fn processor_new_from_yaml_returns_valid_pointer() {
let style = serde_yaml::to_string(&Style::default()).expect("style serializes");
let bib = "references: []";
let processor = unsafe {
citum_processor_new_from_yaml(c_string(&style).as_ptr(), c_string(bib).as_ptr())
};
assert!(!processor.is_null());
unsafe { citum_processor_free(processor) };
}
#[test]
fn processor_new_from_yaml_rejects_invalid_style() {
let bib = "references: []";
let processor = unsafe {
citum_processor_new_from_yaml(
c_string("not: valid: yaml: [").as_ptr(),
c_string(bib).as_ptr(),
)
};
assert!(processor.is_null());
assert!(last_error().contains("Style YAML parse error"));
}
#[test]
fn processor_new_with_locale_from_yaml_returns_valid_pointer() {
let style = serde_yaml::to_string(&Style::default()).expect("style serializes");
let bib = "references: []";
let locale = "locale: en-US";
let processor = unsafe {
citum_processor_new_with_locale_from_yaml(
c_string(&style).as_ptr(),
c_string(bib).as_ptr(),
c_string(locale).as_ptr(),
)
};
assert!(!processor.is_null());
unsafe { citum_processor_free(processor) };
}
}