diff --git a/packages/markdown_parser/rust/src/lib.rs b/packages/markdown_parser/rust/src/lib.rs index 20ccb7a75..e4f149ec8 100644 --- a/packages/markdown_parser/rust/src/lib.rs +++ b/packages/markdown_parser/rust/src/lib.rs @@ -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; diff --git a/packages/markdown_parser/rust/src/native.rs b/packages/markdown_parser/rust/src/native.rs new file mode 100644 index 000000000..672d5b1bc --- /dev/null +++ b/packages/markdown_parser/rust/src/native.rs @@ -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) -> 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) -> *mut u8 { + let mut boxed = bytes.into_boxed_slice(); + let ptr = boxed.as_mut_ptr(); + std::mem::forget(boxed); + ptr +} diff --git a/packages/markdown_parser/rust/tests/native_abi.rs b/packages/markdown_parser/rust/tests/native_abi.rs new file mode 100644 index 000000000..c0c4dd8c7 --- /dev/null +++ b/packages/markdown_parser/rust/tests/native_abi.rs @@ -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 { + 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|| "; + 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()) }; +}