feat(markdown): add a native C ABI for 64-bit FFI hosts (#2115)

This commit is contained in:
M0N7Y5
2026-08-30 15:47:34 +02:00
committed by GitHub
parent bd88c7b04b
commit 69ef46356b
3 changed files with 230 additions and 0 deletions
+1
View File
@@ -7,6 +7,7 @@ pub mod emoji;
pub mod inline;
pub mod json;
pub mod links;
pub mod native;
pub mod normalize;
pub mod parser;
pub mod plaintext;
+100
View File
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![cfg(not(target_arch = "wasm32"))]
#[repr(C)]
pub struct FluxerMdBuffer {
pub data: *mut u8,
pub data_len: usize,
pub error: *mut u8,
pub error_len: usize,
}
#[allow(clippy::missing_safety_doc)]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fluxer_md_parse(
input_ptr: *const u8,
input_len: usize,
flags: u32,
tsv_ptr: *const u8,
tsv_len: usize,
out: *mut FluxerMdBuffer,
) -> u32 {
let Ok(input) = std::str::from_utf8(unsafe { slice(input_ptr, input_len) }) else {
return unsafe { write_error(out, "invalid markdown input") };
};
let Ok(emoji_context) = std::str::from_utf8(unsafe { slice(tsv_ptr, tsv_len) }) else {
return unsafe { write_error(out, "invalid emoji context") };
};
let parsed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
crate::parse_markdown_json(input, flags, emoji_context)
}));
match parsed {
Ok(Ok(json)) => unsafe { write_data(out, json.into_bytes()) },
Ok(Err(_)) => unsafe { write_error(out, "markdown parse failed") },
Err(_) => unsafe { write_error(out, "markdown parser panicked") },
}
}
#[allow(clippy::missing_safety_doc)]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fluxer_md_buffer_free(out: *mut FluxerMdBuffer) {
if out.is_null() {
return;
}
let buffer = unsafe { &mut *out };
unsafe {
free_slice(buffer.data, buffer.data_len);
free_slice(buffer.error, buffer.error_len);
}
buffer.data = std::ptr::null_mut();
buffer.data_len = 0;
buffer.error = std::ptr::null_mut();
buffer.error_len = 0;
}
unsafe fn slice<'a>(ptr: *const u8, len: usize) -> &'a [u8] {
if len == 0 {
&[]
} else {
unsafe { std::slice::from_raw_parts(ptr, len) }
}
}
unsafe fn free_slice(ptr: *mut u8, len: usize) {
if !ptr.is_null() {
drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len)) });
}
}
unsafe fn write_data(out: *mut FluxerMdBuffer, bytes: Vec<u8>) -> u32 {
unsafe {
*out = FluxerMdBuffer {
data_len: bytes.len(),
data: leak(bytes),
error: std::ptr::null_mut(),
error_len: 0,
};
}
0
}
unsafe fn write_error(out: *mut FluxerMdBuffer, message: &str) -> u32 {
let bytes = message.as_bytes().to_vec();
unsafe {
*out = FluxerMdBuffer {
data: std::ptr::null_mut(),
data_len: 0,
error_len: bytes.len(),
error: leak(bytes),
};
}
1
}
fn leak(bytes: Vec<u8>) -> *mut u8 {
let mut boxed = bytes.into_boxed_slice();
let ptr = boxed.as_mut_ptr();
std::mem::forget(boxed);
ptr
}
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![cfg(not(target_arch = "wasm32"))]
use fluxer_markdown_parser::ParserFlags;
use fluxer_markdown_parser::native::{FluxerMdBuffer, fluxer_md_buffer_free, fluxer_md_parse};
fn empty_buffer() -> FluxerMdBuffer {
FluxerMdBuffer {
data: std::ptr::null_mut(),
data_len: 0,
error: std::ptr::null_mut(),
error_len: 0,
}
}
fn parse(input: &str, flags: u32, tsv: &str) -> Result<String, String> {
let mut out = empty_buffer();
let status = unsafe {
fluxer_md_parse(
input.as_ptr(),
input.len(),
flags,
tsv.as_ptr(),
tsv.len(),
&raw mut out,
)
};
let result = if status == 0 {
assert!(!out.data.is_null());
assert!(out.error.is_null());
let payload = unsafe { std::slice::from_raw_parts(out.data, out.data_len) };
Ok(String::from_utf8(payload.to_vec()).expect("payload should be UTF-8"))
} else {
assert!(out.data.is_null());
assert!(!out.error.is_null());
let message = unsafe { std::slice::from_raw_parts(out.error, out.error_len) };
Err(String::from_utf8(message.to_vec()).expect("error should be UTF-8"))
};
unsafe { fluxer_md_buffer_free(&raw mut out) };
assert!(out.data.is_null());
assert_eq!(out.data_len, 0);
assert!(out.error.is_null());
assert_eq!(out.error_len, 0);
result
}
#[test]
fn parses_formatting_to_json_envelope() {
let json = parse("**bold**", ParserFlags::ALL, "").expect("parse should succeed");
assert_eq!(
json,
r#"{"nodes":[{"type":"Strong","children":[{"type":"Text","content":"bold"}]}]}"#
);
}
#[test]
fn matches_wasm_abi_output() {
let input = "# heading\n||spoiler|| <t:1234567890:R>";
let native = parse(input, ParserFlags::ALL, "").expect("native parse should succeed");
let json = fluxer_markdown_parser::parse_markdown_json(input, ParserFlags::ALL, "")
.expect("json parse should succeed");
assert_eq!(native, json);
}
#[test]
fn respects_flags() {
let json = parse("# not a heading", 0, "").expect("parse should succeed");
assert_eq!(
json,
r##"{"nodes":[{"type":"Text","content":"# not a heading"}]}"##
);
}
#[test]
fn uses_emoji_context_records() {
let json = parse(
"hi \u{1F600}",
ParserFlags::ALL,
"S\t3\t4\t\u{1F600}\tgrinning face\t1f600",
)
.expect("parse should succeed");
assert!(json.contains(r#""name":"grinning face""#), "got: {json}");
}
#[test]
fn empty_input_yields_empty_envelope() {
let json = parse("", ParserFlags::ALL, "").expect("parse should succeed");
assert_eq!(json, r#"{"nodes":[]}"#);
}
#[test]
fn rejects_invalid_utf8_input() {
let mut out = empty_buffer();
let invalid = [0xff_u8, 0xfe];
let status = unsafe {
fluxer_md_parse(
invalid.as_ptr(),
invalid.len(),
ParserFlags::ALL,
std::ptr::null(),
0,
&raw mut out,
)
};
assert_eq!(status, 1);
let message = unsafe { std::slice::from_raw_parts(out.error, out.error_len) };
assert_eq!(message, b"invalid markdown input");
unsafe { fluxer_md_buffer_free(&raw mut out) };
}
#[test]
fn double_free_is_a_no_op() {
let mut out = empty_buffer();
let input = "text";
let status = unsafe {
fluxer_md_parse(
input.as_ptr(),
input.len(),
ParserFlags::ALL,
std::ptr::null(),
0,
&raw mut out,
)
};
assert_eq!(status, 0);
unsafe { fluxer_md_buffer_free(&raw mut out) };
unsafe { fluxer_md_buffer_free(&raw mut out) };
unsafe { fluxer_md_buffer_free(std::ptr::null_mut()) };
}