Skip to content

Commit c18a26f

Browse files
Jonathan D.A. Jewellclaude
andcommitted
feat: Add MoSCoW requirements, FFI exports, and document events
- docs/MOSCOW-REQUIREMENTS.adoc: Complete Must/Should/Could lists for all components with seam requirements - crates/formatrix-core/src/ffi.rs (FD-M10): C FFI exports for Ada TUI integration with parse, render, convert functions - crates/formatrix-gui/src/commands.rs (FD-M12): Document event emission with SHA-256 hashing for created/modified/deleted events 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 44ffffb commit c18a26f

4 files changed

Lines changed: 1360 additions & 1 deletion

File tree

crates/formatrix-core/src/ffi.rs

Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
//! C FFI exports for Ada TUI integration (FD-M10)
3+
//!
4+
//! These functions provide a C-compatible interface for the Ada TUI
5+
//! to call into the Rust formatting core.
6+
7+
use std::ffi::{CStr, CString};
8+
use std::os::raw::c_char;
9+
use std::ptr;
10+
11+
use crate::ast::{Block, Document, DocumentMeta, SourceFormat};
12+
use crate::traits::{ParseConfig, Parser, RenderConfig, Renderer};
13+
14+
/// Opaque handle to a document
15+
pub struct DocumentHandle {
16+
doc: Document,
17+
}
18+
19+
/// Result code for FFI operations
20+
#[repr(C)]
21+
pub enum FfiResult {
22+
Success = 0,
23+
InvalidInput = 1,
24+
ParseError = 2,
25+
RenderError = 3,
26+
UnsupportedFormat = 4,
27+
NullPointer = 5,
28+
Utf8Error = 6,
29+
}
30+
31+
/// Document format for FFI
32+
#[repr(C)]
33+
pub enum FfiFormat {
34+
PlainText = 0,
35+
Markdown = 1,
36+
AsciiDoc = 2,
37+
Djot = 3,
38+
OrgMode = 4,
39+
ReStructuredText = 5,
40+
Typst = 6,
41+
}
42+
43+
impl From<FfiFormat> for SourceFormat {
44+
fn from(f: FfiFormat) -> Self {
45+
match f {
46+
FfiFormat::PlainText => SourceFormat::PlainText,
47+
FfiFormat::Markdown => SourceFormat::Markdown,
48+
FfiFormat::AsciiDoc => SourceFormat::AsciiDoc,
49+
FfiFormat::Djot => SourceFormat::Djot,
50+
FfiFormat::OrgMode => SourceFormat::OrgMode,
51+
FfiFormat::ReStructuredText => SourceFormat::ReStructuredText,
52+
FfiFormat::Typst => SourceFormat::Typst,
53+
}
54+
}
55+
}
56+
57+
impl From<SourceFormat> for FfiFormat {
58+
fn from(f: SourceFormat) -> Self {
59+
match f {
60+
SourceFormat::PlainText => FfiFormat::PlainText,
61+
SourceFormat::Markdown => FfiFormat::Markdown,
62+
SourceFormat::AsciiDoc => FfiFormat::AsciiDoc,
63+
SourceFormat::Djot => FfiFormat::Djot,
64+
SourceFormat::OrgMode => FfiFormat::OrgMode,
65+
SourceFormat::ReStructuredText => FfiFormat::ReStructuredText,
66+
SourceFormat::Typst => FfiFormat::Typst,
67+
}
68+
}
69+
}
70+
71+
/// Parse content into a document handle
72+
///
73+
/// # Safety
74+
/// - `content` must be a valid null-terminated UTF-8 string
75+
/// - `out_handle` must be a valid pointer to store the result
76+
#[no_mangle]
77+
pub unsafe extern "C" fn formatrix_parse(
78+
content: *const c_char,
79+
format: FfiFormat,
80+
out_handle: *mut *mut DocumentHandle,
81+
) -> FfiResult {
82+
if content.is_null() || out_handle.is_null() {
83+
return FfiResult::NullPointer;
84+
}
85+
86+
let content_str = match CStr::from_ptr(content).to_str() {
87+
Ok(s) => s,
88+
Err(_) => return FfiResult::Utf8Error,
89+
};
90+
91+
let config = ParseConfig::default();
92+
let source_format: SourceFormat = format.into();
93+
94+
let doc = match source_format {
95+
SourceFormat::PlainText => {
96+
use crate::formats::PlainTextHandler;
97+
match PlainTextHandler::new().parse(content_str, &config) {
98+
Ok(d) => d,
99+
Err(_) => return FfiResult::ParseError,
100+
}
101+
}
102+
SourceFormat::Markdown => {
103+
use crate::formats::MarkdownHandler;
104+
match MarkdownHandler::new().parse(content_str, &config) {
105+
Ok(d) => d,
106+
Err(_) => return FfiResult::ParseError,
107+
}
108+
}
109+
SourceFormat::Djot => {
110+
use crate::formats::DjotHandler;
111+
match DjotHandler::new().parse(content_str, &config) {
112+
Ok(d) => d,
113+
Err(_) => return FfiResult::ParseError,
114+
}
115+
}
116+
SourceFormat::OrgMode => {
117+
use crate::formats::OrgModeHandler;
118+
match OrgModeHandler::new().parse(content_str, &config) {
119+
Ok(d) => d,
120+
Err(_) => return FfiResult::ParseError,
121+
}
122+
}
123+
_ => return FfiResult::UnsupportedFormat,
124+
};
125+
126+
let handle = Box::new(DocumentHandle { doc });
127+
*out_handle = Box::into_raw(handle);
128+
129+
FfiResult::Success
130+
}
131+
132+
/// Render a document to a string in the specified format
133+
///
134+
/// # Safety
135+
/// - `handle` must be a valid document handle from `formatrix_parse`
136+
/// - `out_content` must be a valid pointer to store the result
137+
/// - `out_length` must be a valid pointer to store the length
138+
#[no_mangle]
139+
pub unsafe extern "C" fn formatrix_render(
140+
handle: *const DocumentHandle,
141+
format: FfiFormat,
142+
out_content: *mut *mut c_char,
143+
out_length: *mut usize,
144+
) -> FfiResult {
145+
if handle.is_null() || out_content.is_null() || out_length.is_null() {
146+
return FfiResult::NullPointer;
147+
}
148+
149+
let doc = &(*handle).doc;
150+
let config = RenderConfig::default();
151+
let target_format: SourceFormat = format.into();
152+
153+
let output = match target_format {
154+
SourceFormat::PlainText => {
155+
use crate::formats::PlainTextHandler;
156+
match PlainTextHandler::new().render(doc, &config) {
157+
Ok(s) => s,
158+
Err(_) => return FfiResult::RenderError,
159+
}
160+
}
161+
SourceFormat::Markdown => {
162+
use crate::formats::MarkdownHandler;
163+
match MarkdownHandler::new().render(doc, &config) {
164+
Ok(s) => s,
165+
Err(_) => return FfiResult::RenderError,
166+
}
167+
}
168+
SourceFormat::Djot => {
169+
use crate::formats::DjotHandler;
170+
match DjotHandler::new().render(doc, &config) {
171+
Ok(s) => s,
172+
Err(_) => return FfiResult::RenderError,
173+
}
174+
}
175+
SourceFormat::OrgMode => {
176+
use crate::formats::OrgModeHandler;
177+
match OrgModeHandler::new().render(doc, &config) {
178+
Ok(s) => s,
179+
Err(_) => return FfiResult::RenderError,
180+
}
181+
}
182+
_ => return FfiResult::UnsupportedFormat,
183+
};
184+
185+
let c_string = match CString::new(output.clone()) {
186+
Ok(s) => s,
187+
Err(_) => return FfiResult::InvalidInput,
188+
};
189+
190+
*out_length = output.len();
191+
*out_content = c_string.into_raw();
192+
193+
FfiResult::Success
194+
}
195+
196+
/// Get the title of a document
197+
///
198+
/// # Safety
199+
/// - `handle` must be a valid document handle
200+
/// - `out_title` must be a valid pointer
201+
/// - `out_length` must be a valid pointer
202+
#[no_mangle]
203+
pub unsafe extern "C" fn formatrix_get_title(
204+
handle: *const DocumentHandle,
205+
out_title: *mut *mut c_char,
206+
out_length: *mut usize,
207+
) -> FfiResult {
208+
if handle.is_null() || out_title.is_null() || out_length.is_null() {
209+
return FfiResult::NullPointer;
210+
}
211+
212+
let doc = &(*handle).doc;
213+
let title = doc.meta.title.clone().unwrap_or_default();
214+
215+
let c_string = match CString::new(title.clone()) {
216+
Ok(s) => s,
217+
Err(_) => return FfiResult::InvalidInput,
218+
};
219+
220+
*out_length = title.len();
221+
*out_title = c_string.into_raw();
222+
223+
FfiResult::Success
224+
}
225+
226+
/// Get the number of blocks in a document
227+
///
228+
/// # Safety
229+
/// - `handle` must be a valid document handle
230+
#[no_mangle]
231+
pub unsafe extern "C" fn formatrix_block_count(handle: *const DocumentHandle) -> usize {
232+
if handle.is_null() {
233+
return 0;
234+
}
235+
(*handle).doc.blocks.len()
236+
}
237+
238+
/// Get the source format of a document
239+
///
240+
/// # Safety
241+
/// - `handle` must be a valid document handle
242+
#[no_mangle]
243+
pub unsafe extern "C" fn formatrix_get_format(handle: *const DocumentHandle) -> FfiFormat {
244+
if handle.is_null() {
245+
return FfiFormat::PlainText;
246+
}
247+
(*handle).doc.meta.source_format.into()
248+
}
249+
250+
/// Free a document handle
251+
///
252+
/// # Safety
253+
/// - `handle` must be a valid document handle or null
254+
#[no_mangle]
255+
pub unsafe extern "C" fn formatrix_free_document(handle: *mut DocumentHandle) {
256+
if !handle.is_null() {
257+
drop(Box::from_raw(handle));
258+
}
259+
}
260+
261+
/// Free a string allocated by the library
262+
///
263+
/// # Safety
264+
/// - `s` must be a valid string from this library or null
265+
#[no_mangle]
266+
pub unsafe extern "C" fn formatrix_free_string(s: *mut c_char) {
267+
if !s.is_null() {
268+
drop(CString::from_raw(s));
269+
}
270+
}
271+
272+
/// Get library version
273+
///
274+
/// # Safety
275+
/// Returns a static string, do not free
276+
#[no_mangle]
277+
pub extern "C" fn formatrix_version() -> *const c_char {
278+
static VERSION: &[u8] = b"0.1.0\0";
279+
VERSION.as_ptr() as *const c_char
280+
}
281+
282+
/// Detect format from content
283+
///
284+
/// # Safety
285+
/// - `content` must be a valid null-terminated UTF-8 string
286+
#[no_mangle]
287+
pub unsafe extern "C" fn formatrix_detect_format(content: *const c_char) -> FfiFormat {
288+
if content.is_null() {
289+
return FfiFormat::PlainText;
290+
}
291+
292+
let content_str = match CStr::from_ptr(content).to_str() {
293+
Ok(s) => s,
294+
Err(_) => return FfiFormat::PlainText,
295+
};
296+
297+
let trimmed = content_str.trim();
298+
299+
// Check for org-mode markers
300+
if trimmed.starts_with("#+") || trimmed.contains("\n#+") {
301+
return FfiFormat::OrgMode;
302+
}
303+
304+
// Check for AsciiDoc markers
305+
if trimmed.starts_with("= ") || trimmed.starts_with(":toc:") {
306+
return FfiFormat::AsciiDoc;
307+
}
308+
309+
// Check for Markdown markers
310+
if trimmed.starts_with("# ") || trimmed.contains("```") {
311+
return FfiFormat::Markdown;
312+
}
313+
314+
// Check for Djot markers
315+
if trimmed.contains("{.") || trimmed.contains("[^") {
316+
return FfiFormat::Djot;
317+
}
318+
319+
// Check for RST markers
320+
if trimmed.contains(".. ") && trimmed.contains("::") {
321+
return FfiFormat::ReStructuredText;
322+
}
323+
324+
// Check for Typst markers
325+
if trimmed.contains("#let") || trimmed.contains("#{") {
326+
return FfiFormat::Typst;
327+
}
328+
329+
FfiFormat::PlainText
330+
}
331+
332+
/// Convert content from one format to another
333+
///
334+
/// # Safety
335+
/// - All pointers must be valid
336+
#[no_mangle]
337+
pub unsafe extern "C" fn formatrix_convert(
338+
content: *const c_char,
339+
from_format: FfiFormat,
340+
to_format: FfiFormat,
341+
out_content: *mut *mut c_char,
342+
out_length: *mut usize,
343+
) -> FfiResult {
344+
if content.is_null() || out_content.is_null() || out_length.is_null() {
345+
return FfiResult::NullPointer;
346+
}
347+
348+
// Parse input
349+
let mut handle: *mut DocumentHandle = ptr::null_mut();
350+
let parse_result = formatrix_parse(content, from_format, &mut handle);
351+
if parse_result as u32 != FfiResult::Success as u32 {
352+
return parse_result;
353+
}
354+
355+
// Render output
356+
let render_result = formatrix_render(handle, to_format, out_content, out_length);
357+
358+
// Free the handle
359+
formatrix_free_document(handle);
360+
361+
render_result
362+
}
363+
364+
#[cfg(test)]
365+
mod tests {
366+
use super::*;
367+
use std::ffi::CString;
368+
369+
#[test]
370+
fn test_parse_and_render() {
371+
let content = CString::new("# Hello\n\nWorld").unwrap();
372+
let mut handle: *mut DocumentHandle = ptr::null_mut();
373+
374+
unsafe {
375+
let result = formatrix_parse(content.as_ptr(), FfiFormat::Markdown, &mut handle);
376+
assert_eq!(result as u32, FfiResult::Success as u32);
377+
assert!(!handle.is_null());
378+
379+
let count = formatrix_block_count(handle);
380+
assert!(count > 0);
381+
382+
formatrix_free_document(handle);
383+
}
384+
}
385+
386+
#[test]
387+
fn test_detect_format() {
388+
let md = CString::new("# Heading\n\nContent").unwrap();
389+
let org = CString::new("#+TITLE: Test\n* Heading").unwrap();
390+
391+
unsafe {
392+
assert_eq!(formatrix_detect_format(md.as_ptr()) as u32, FfiFormat::Markdown as u32);
393+
assert_eq!(formatrix_detect_format(org.as_ptr()) as u32, FfiFormat::OrgMode as u32);
394+
}
395+
}
396+
}

0 commit comments

Comments
 (0)