Skip to main content

tinyxml2_capi/
lib.rs

1//! C FFI compatibility layer for tinyxml2-rs.
2//!
3//! This crate provides `extern "C"` functions that expose the tinyxml2 Rust API
4//! through a C-compatible ABI. It produces both static and shared libraries.
5//!
6//! # Safety
7//!
8//! This crate necessarily uses `unsafe` at the FFI boundary. All public functions
9//! validate inputs and catch panics to prevent undefined behavior across the
10//! FFI boundary.
11//!
12//! # String Lifetimes
13//!
14//! Functions returning `*const c_char` return pointers to strings cached inside
15//! the `TxDocument` or `TxPrinter` wrapper. These pointers remain valid until:
16//! - The wrapper is freed (`tx_document_free` / `tx_printer_free`).
17//! - A mutating operation is called on the wrapper (e.g., parse, insert, delete).
18
19// Line numbers are always small positive u32 values; the cast to c_int (i32)
20// will not wrap in any realistic scenario.
21#![allow(clippy::cast_possible_wrap)]
22
23mod types;
24
25pub use types::{TX_NULL_NODE, TxDocument, TxError, TxNodeId, TxNodeType, TxPrinter};
26
27use std::ffi::{CStr, CString, c_char, c_double, c_int};
28use std::ptr;
29
30// ============================================================
31// Internal helpers
32// ============================================================
33
34/// Converts a C string pointer to a Rust `&str`.
35///
36/// Returns `None` if the pointer is null or the string is not valid UTF-8.
37///
38/// # Safety
39///
40/// The caller must ensure that `s` points to a valid, null-terminated C string
41/// (or is null). The returned `&str` borrows the C string's memory.
42unsafe fn cstr_to_str<'a>(s: *const c_char) -> Option<&'a str> {
43    if s.is_null() {
44        return None;
45    }
46    unsafe { CStr::from_ptr(s) }.to_str().ok()
47}
48
49/// Caches a Rust string slice in the document's string cache and returns
50/// a pointer to the cached C string.
51///
52/// Returns null if the string contains interior null bytes.
53fn cache_str(doc: &mut TxDocument, s: &str) -> *const c_char {
54    match CString::new(s) {
55        Ok(cs) => {
56            let ptr = cs.as_ptr();
57            doc.string_cache.push(cs);
58            ptr
59        }
60        Err(_) => ptr::null(),
61    }
62}
63
64/// Wraps a closure in `catch_unwind` with `AssertUnwindSafe`, returning a
65/// default value if a panic occurs.
66macro_rules! ffi_catch {
67    ($default:expr, $body:expr) => {
68        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) {
69            Ok(val) => val,
70            Err(_) => $default,
71        }
72    };
73}
74
75// ============================================================
76// Document Lifecycle
77// ============================================================
78
79/// Creates a new, empty XML document.
80///
81/// The returned pointer must eventually be freed with [`tx_document_free`].
82///
83/// # Safety
84///
85/// The caller must free the returned pointer with [`tx_document_free`] when
86/// done. Returns null on allocation failure.
87#[unsafe(no_mangle)]
88pub extern "C" fn tx_document_new() -> *mut TxDocument {
89    ffi_catch!(ptr::null_mut(), {
90        Box::into_raw(Box::new(TxDocument::new()))
91    })
92}
93
94/// Frees a document previously created with [`tx_document_new`].
95///
96/// # Safety
97///
98/// `doc` must be a valid pointer returned by [`tx_document_new`], or null.
99/// After this call, the pointer must not be used again.
100#[unsafe(no_mangle)]
101pub unsafe extern "C" fn tx_document_free(doc: *mut TxDocument) {
102    if doc.is_null() {
103        return;
104    }
105    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
106        drop(unsafe { Box::from_raw(doc) });
107    }));
108}
109
110/// Clears the document, removing all nodes and resetting to an empty state.
111///
112/// # Safety
113///
114/// `doc` must be a valid, non-null pointer to a `TxDocument`.
115#[unsafe(no_mangle)]
116pub unsafe extern "C" fn tx_document_clear(doc: *mut TxDocument) {
117    if doc.is_null() {
118        return;
119    }
120    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
121        let doc = unsafe { &mut *doc };
122        doc.invalidate_caches();
123        doc.doc.clear();
124    }));
125}
126
127/// Parses an XML string into the document, replacing any existing content.
128///
129/// # Safety
130///
131/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
132/// - `xml` must be a valid, non-null pointer to a null-terminated UTF-8 C string.
133#[unsafe(no_mangle)]
134pub unsafe extern "C" fn tx_document_parse(doc: *mut TxDocument, xml: *const c_char) -> TxError {
135    if doc.is_null() {
136        return TxError::TxErrorInvalidNodeId;
137    }
138    ffi_catch!(TxError::TxErrorInvalidNodeId, {
139        let doc = unsafe { &mut *doc };
140        let Some(xml_str) = (unsafe { cstr_to_str(xml) }) else {
141            return TxError::TxErrorEmptyDocument;
142        };
143        doc.invalidate_caches();
144        TxError::from_result(&doc.doc.parse_str(xml_str))
145    })
146}
147
148/// Loads and parses an XML file.
149///
150/// # Safety
151///
152/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
153/// - `path` must be a valid, non-null pointer to a null-terminated UTF-8 C string
154///   containing a filesystem path.
155#[unsafe(no_mangle)]
156pub unsafe extern "C" fn tx_document_load_file(
157    doc: *mut TxDocument,
158    path: *const c_char,
159) -> TxError {
160    if doc.is_null() {
161        return TxError::TxErrorInvalidNodeId;
162    }
163    ffi_catch!(TxError::TxErrorInvalidNodeId, {
164        let doc = unsafe { &mut *doc };
165        let Some(path_str) = (unsafe { cstr_to_str(path) }) else {
166            return TxError::TxErrorFileNotFound;
167        };
168        doc.invalidate_caches();
169        TxError::from_result(&doc.doc.load_file_mut(path_str))
170    })
171}
172
173/// Saves the document to a file (pretty-printed).
174///
175/// # Safety
176///
177/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
178/// - `path` must be a valid, non-null pointer to a null-terminated UTF-8 C string
179///   containing a filesystem path.
180#[unsafe(no_mangle)]
181pub unsafe extern "C" fn tx_document_save_file(
182    doc: *const TxDocument,
183    path: *const c_char,
184) -> TxError {
185    if doc.is_null() {
186        return TxError::TxErrorInvalidNodeId;
187    }
188    ffi_catch!(TxError::TxErrorInvalidNodeId, {
189        let doc = unsafe { &*doc };
190        let Some(path_str) = (unsafe { cstr_to_str(path) }) else {
191            return TxError::TxErrorFileNotFound;
192        };
193        TxError::from_result(&doc.doc.save_file(path_str))
194    })
195}
196
197/// Returns the pretty-printed XML string for the document.
198///
199/// The returned pointer is valid until the next mutating operation on the
200/// document or until the document is freed.
201///
202/// # Safety
203///
204/// `doc` must be a valid, non-null pointer to a `TxDocument`.
205#[unsafe(no_mangle)]
206pub unsafe extern "C" fn tx_document_to_string(doc: *mut TxDocument) -> *const c_char {
207    if doc.is_null() {
208        return ptr::null();
209    }
210    ffi_catch!(ptr::null(), {
211        let doc = unsafe { &mut *doc };
212        let s = doc.doc.to_string();
213        match CString::new(s) {
214            Ok(cs) => {
215                let ptr = cs.as_ptr();
216                doc.cached_to_string = Some(cs);
217                ptr
218            }
219            Err(_) => ptr::null(),
220        }
221    })
222}
223
224/// Returns the compact XML string for the document.
225///
226/// The returned pointer is valid until the next mutating operation on the
227/// document or until the document is freed.
228///
229/// # Safety
230///
231/// `doc` must be a valid, non-null pointer to a `TxDocument`.
232#[unsafe(no_mangle)]
233pub unsafe extern "C" fn tx_document_to_string_compact(doc: *mut TxDocument) -> *const c_char {
234    if doc.is_null() {
235        return ptr::null();
236    }
237    ffi_catch!(ptr::null(), {
238        let doc = unsafe { &mut *doc };
239        let s = doc.doc.to_string_compact();
240        match CString::new(s) {
241            Ok(cs) => {
242                let ptr = cs.as_ptr();
243                doc.cached_to_string_compact = Some(cs);
244                ptr
245            }
246            Err(_) => ptr::null(),
247        }
248    })
249}
250
251/// Returns the current error code of the document.
252///
253/// Returns [`TxError::TxSuccess`] if no error has occurred.
254///
255/// # Safety
256///
257/// `doc` must be a valid, non-null pointer to a `TxDocument`.
258#[unsafe(no_mangle)]
259pub unsafe extern "C" fn tx_document_error(doc: *const TxDocument) -> TxError {
260    if doc.is_null() {
261        return TxError::TxErrorInvalidNodeId;
262    }
263    ffi_catch!(TxError::TxErrorInvalidNodeId, {
264        let doc = unsafe { &*doc };
265        match doc.doc.error() {
266            Some(e) => TxError::from_xml_error(&e),
267            None => TxError::TxSuccess,
268        }
269    })
270}
271
272/// Returns the line number of the current error, or 0 if no error.
273///
274/// # Safety
275///
276/// `doc` must be a valid, non-null pointer to a `TxDocument`.
277#[unsafe(no_mangle)]
278pub unsafe extern "C" fn tx_document_error_line(doc: *const TxDocument) -> c_int {
279    if doc.is_null() {
280        return 0;
281    }
282    ffi_catch!(0, {
283        let doc = unsafe { &*doc };
284        doc.doc.error_line().unwrap_or(0) as c_int
285    })
286}
287
288/// Returns the error name string, or null if no error.
289///
290/// The returned pointer is valid until the next mutating operation on the
291/// document or until the document is freed.
292///
293/// # Safety
294///
295/// `doc` must be a valid, non-null pointer to a `TxDocument`.
296#[unsafe(no_mangle)]
297pub unsafe extern "C" fn tx_document_error_name(doc: *mut TxDocument) -> *const c_char {
298    if doc.is_null() {
299        return ptr::null();
300    }
301    ffi_catch!(ptr::null(), {
302        let doc = unsafe { &mut *doc };
303        match doc.doc.error() {
304            Some(e) => {
305                let name = e.name();
306                match CString::new(name) {
307                    Ok(cs) => {
308                        let ptr = cs.as_ptr();
309                        doc.cached_error_name = Some(cs);
310                        ptr
311                    }
312                    Err(_) => ptr::null(),
313                }
314            }
315            None => ptr::null(),
316        }
317    })
318}
319
320// ============================================================
321// Factory Functions
322// ============================================================
323
324/// Creates a new element node with the given tag name.
325///
326/// # Safety
327///
328/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
329/// - `name` must be a valid, non-null pointer to a null-terminated UTF-8 string.
330#[unsafe(no_mangle)]
331pub unsafe extern "C" fn tx_new_element(doc: *mut TxDocument, name: *const c_char) -> TxNodeId {
332    if doc.is_null() {
333        return TX_NULL_NODE;
334    }
335    ffi_catch!(TX_NULL_NODE, {
336        let doc = unsafe { &mut *doc };
337        let Some(name_str) = (unsafe { cstr_to_str(name) }) else {
338            return TX_NULL_NODE;
339        };
340        doc.invalidate_caches();
341        TxNodeId::from_node_id(doc.doc.new_element(name_str))
342    })
343}
344
345/// Creates a new text node with the given content.
346///
347/// # Safety
348///
349/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
350/// - `text` must be a valid, non-null pointer to a null-terminated UTF-8 string.
351#[unsafe(no_mangle)]
352pub unsafe extern "C" fn tx_new_text(doc: *mut TxDocument, text: *const c_char) -> TxNodeId {
353    if doc.is_null() {
354        return TX_NULL_NODE;
355    }
356    ffi_catch!(TX_NULL_NODE, {
357        let doc = unsafe { &mut *doc };
358        let Some(text_str) = (unsafe { cstr_to_str(text) }) else {
359            return TX_NULL_NODE;
360        };
361        doc.invalidate_caches();
362        TxNodeId::from_node_id(doc.doc.new_text(text_str))
363    })
364}
365
366/// Creates a new comment node.
367///
368/// # Safety
369///
370/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
371/// - `text` must be a valid, non-null pointer to a null-terminated UTF-8 string.
372#[unsafe(no_mangle)]
373pub unsafe extern "C" fn tx_new_comment(doc: *mut TxDocument, text: *const c_char) -> TxNodeId {
374    if doc.is_null() {
375        return TX_NULL_NODE;
376    }
377    ffi_catch!(TX_NULL_NODE, {
378        let doc = unsafe { &mut *doc };
379        let Some(text_str) = (unsafe { cstr_to_str(text) }) else {
380            return TX_NULL_NODE;
381        };
382        doc.invalidate_caches();
383        TxNodeId::from_node_id(doc.doc.new_comment(text_str))
384    })
385}
386
387/// Creates a new declaration node.
388///
389/// # Safety
390///
391/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
392/// - `text` must be a valid, non-null pointer to a null-terminated UTF-8 string.
393#[unsafe(no_mangle)]
394pub unsafe extern "C" fn tx_new_declaration(doc: *mut TxDocument, text: *const c_char) -> TxNodeId {
395    if doc.is_null() {
396        return TX_NULL_NODE;
397    }
398    ffi_catch!(TX_NULL_NODE, {
399        let doc = unsafe { &mut *doc };
400        let Some(text_str) = (unsafe { cstr_to_str(text) }) else {
401            return TX_NULL_NODE;
402        };
403        doc.invalidate_caches();
404        TxNodeId::from_node_id(doc.doc.new_declaration(text_str))
405    })
406}
407
408/// Creates a new "unknown" node.
409///
410/// # Safety
411///
412/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
413/// - `text` must be a valid, non-null pointer to a null-terminated UTF-8 string.
414#[unsafe(no_mangle)]
415pub unsafe extern "C" fn tx_new_unknown(doc: *mut TxDocument, text: *const c_char) -> TxNodeId {
416    if doc.is_null() {
417        return TX_NULL_NODE;
418    }
419    ffi_catch!(TX_NULL_NODE, {
420        let doc = unsafe { &mut *doc };
421        let Some(text_str) = (unsafe { cstr_to_str(text) }) else {
422            return TX_NULL_NODE;
423        };
424        doc.invalidate_caches();
425        TxNodeId::from_node_id(doc.doc.new_unknown(text_str))
426    })
427}
428
429// ============================================================
430// DOM Tree Mutations
431// ============================================================
432
433/// Inserts `child` as the last child of `parent`.
434///
435/// # Safety
436///
437/// `doc` must be a valid, non-null pointer to a `TxDocument`.
438#[unsafe(no_mangle)]
439pub unsafe extern "C" fn tx_insert_end_child(
440    doc: *mut TxDocument,
441    parent: TxNodeId,
442    child: TxNodeId,
443) -> TxError {
444    if doc.is_null() {
445        return TxError::TxErrorInvalidNodeId;
446    }
447    ffi_catch!(TxError::TxErrorInvalidNodeId, {
448        let doc = unsafe { &mut *doc };
449        doc.invalidate_caches();
450        let result = doc
451            .doc
452            .insert_end_child(parent.to_node_id(), child.to_node_id());
453        TxError::from_result(&result)
454    })
455}
456
457/// Inserts `child` as the first child of `parent`.
458///
459/// # Safety
460///
461/// `doc` must be a valid, non-null pointer to a `TxDocument`.
462#[unsafe(no_mangle)]
463pub unsafe extern "C" fn tx_insert_first_child(
464    doc: *mut TxDocument,
465    parent: TxNodeId,
466    child: TxNodeId,
467) -> TxError {
468    if doc.is_null() {
469        return TxError::TxErrorInvalidNodeId;
470    }
471    ffi_catch!(TxError::TxErrorInvalidNodeId, {
472        let doc = unsafe { &mut *doc };
473        doc.invalidate_caches();
474        let result = doc
475            .doc
476            .insert_first_child(parent.to_node_id(), child.to_node_id());
477        TxError::from_result(&result)
478    })
479}
480
481/// Inserts `child` as the next sibling after `after`.
482///
483/// # Safety
484///
485/// `doc` must be a valid, non-null pointer to a `TxDocument`.
486#[unsafe(no_mangle)]
487pub unsafe extern "C" fn tx_insert_after_child(
488    doc: *mut TxDocument,
489    after: TxNodeId,
490    child: TxNodeId,
491) -> TxError {
492    if doc.is_null() {
493        return TxError::TxErrorInvalidNodeId;
494    }
495    ffi_catch!(TxError::TxErrorInvalidNodeId, {
496        let doc = unsafe { &mut *doc };
497        doc.invalidate_caches();
498        let result = doc
499            .doc
500            .insert_after_child(after.to_node_id(), child.to_node_id());
501        TxError::from_result(&result)
502    })
503}
504
505/// Deletes `child` from `parent`.
506///
507/// # Safety
508///
509/// `doc` must be a valid, non-null pointer to a `TxDocument`.
510#[unsafe(no_mangle)]
511pub unsafe extern "C" fn tx_delete_child(
512    doc: *mut TxDocument,
513    parent: TxNodeId,
514    child: TxNodeId,
515) -> TxError {
516    if doc.is_null() {
517        return TxError::TxErrorInvalidNodeId;
518    }
519    ffi_catch!(TxError::TxErrorInvalidNodeId, {
520        let doc = unsafe { &mut *doc };
521        doc.invalidate_caches();
522        let result = doc
523            .doc
524            .delete_child(parent.to_node_id(), child.to_node_id());
525        TxError::from_result(&result)
526    })
527}
528
529/// Deletes all children of `parent`.
530///
531/// # Safety
532///
533/// `doc` must be a valid, non-null pointer to a `TxDocument`.
534#[unsafe(no_mangle)]
535pub unsafe extern "C" fn tx_delete_children(doc: *mut TxDocument, parent: TxNodeId) -> TxError {
536    if doc.is_null() {
537        return TxError::TxErrorInvalidNodeId;
538    }
539    ffi_catch!(TxError::TxErrorInvalidNodeId, {
540        let doc = unsafe { &mut *doc };
541        doc.invalidate_caches();
542        TxError::from_result(&doc.doc.delete_children(parent.to_node_id()))
543    })
544}
545
546/// Deletes a node and all its descendants from the document.
547///
548/// # Safety
549///
550/// `doc` must be a valid, non-null pointer to a `TxDocument`.
551#[unsafe(no_mangle)]
552pub unsafe extern "C" fn tx_delete_node(doc: *mut TxDocument, node: TxNodeId) -> TxError {
553    if doc.is_null() {
554        return TxError::TxErrorInvalidNodeId;
555    }
556    ffi_catch!(TxError::TxErrorInvalidNodeId, {
557        let doc = unsafe { &mut *doc };
558        doc.invalidate_caches();
559        TxError::from_result(&doc.doc.delete_node(node.to_node_id()))
560    })
561}
562
563// ============================================================
564// DOM Navigation
565// ============================================================
566
567/// Returns the parent of the given node, or `TX_NULL_NODE` if none.
568///
569/// # Safety
570///
571/// `doc` must be a valid, non-null pointer to a `TxDocument`.
572#[unsafe(no_mangle)]
573pub unsafe extern "C" fn tx_parent(doc: *const TxDocument, node: TxNodeId) -> TxNodeId {
574    if doc.is_null() {
575        return TX_NULL_NODE;
576    }
577    ffi_catch!(TX_NULL_NODE, {
578        let doc = unsafe { &*doc };
579        TxNodeId::from_option(doc.doc.parent(node.to_node_id()))
580    })
581}
582
583/// Returns the first child of the given node, or `TX_NULL_NODE` if none.
584///
585/// # Safety
586///
587/// `doc` must be a valid, non-null pointer to a `TxDocument`.
588#[unsafe(no_mangle)]
589pub unsafe extern "C" fn tx_first_child(doc: *const TxDocument, node: TxNodeId) -> TxNodeId {
590    if doc.is_null() {
591        return TX_NULL_NODE;
592    }
593    ffi_catch!(TX_NULL_NODE, {
594        let doc = unsafe { &*doc };
595        TxNodeId::from_option(doc.doc.first_child(node.to_node_id()))
596    })
597}
598
599/// Returns the last child of the given node, or `TX_NULL_NODE` if none.
600///
601/// # Safety
602///
603/// `doc` must be a valid, non-null pointer to a `TxDocument`.
604#[unsafe(no_mangle)]
605pub unsafe extern "C" fn tx_last_child(doc: *const TxDocument, node: TxNodeId) -> TxNodeId {
606    if doc.is_null() {
607        return TX_NULL_NODE;
608    }
609    ffi_catch!(TX_NULL_NODE, {
610        let doc = unsafe { &*doc };
611        TxNodeId::from_option(doc.doc.last_child(node.to_node_id()))
612    })
613}
614
615/// Returns the previous sibling of the given node, or `TX_NULL_NODE` if none.
616///
617/// # Safety
618///
619/// `doc` must be a valid, non-null pointer to a `TxDocument`.
620#[unsafe(no_mangle)]
621pub unsafe extern "C" fn tx_prev_sibling(doc: *const TxDocument, node: TxNodeId) -> TxNodeId {
622    if doc.is_null() {
623        return TX_NULL_NODE;
624    }
625    ffi_catch!(TX_NULL_NODE, {
626        let doc = unsafe { &*doc };
627        TxNodeId::from_option(doc.doc.prev_sibling(node.to_node_id()))
628    })
629}
630
631/// Returns the next sibling of the given node, or `TX_NULL_NODE` if none.
632///
633/// # Safety
634///
635/// `doc` must be a valid, non-null pointer to a `TxDocument`.
636#[unsafe(no_mangle)]
637pub unsafe extern "C" fn tx_next_sibling(doc: *const TxDocument, node: TxNodeId) -> TxNodeId {
638    if doc.is_null() {
639        return TX_NULL_NODE;
640    }
641    ffi_catch!(TX_NULL_NODE, {
642        let doc = unsafe { &*doc };
643        TxNodeId::from_option(doc.doc.next_sibling(node.to_node_id()))
644    })
645}
646
647/// Returns the first child element, optionally filtered by tag name.
648///
649/// If `name` is null, returns the first child element regardless of its name.
650///
651/// # Safety
652///
653/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
654/// - `name`, if non-null, must be a valid null-terminated UTF-8 string.
655#[unsafe(no_mangle)]
656pub unsafe extern "C" fn tx_first_child_element(
657    doc: *const TxDocument,
658    node: TxNodeId,
659    name: *const c_char,
660) -> TxNodeId {
661    if doc.is_null() {
662        return TX_NULL_NODE;
663    }
664    ffi_catch!(TX_NULL_NODE, {
665        let doc = unsafe { &*doc };
666        let name_opt = unsafe { cstr_to_str(name) };
667        TxNodeId::from_option(doc.doc.first_child_element(node.to_node_id(), name_opt))
668    })
669}
670
671/// Returns the next sibling element, optionally filtered by tag name.
672///
673/// If `name` is null, returns the next sibling element regardless of its name.
674///
675/// # Safety
676///
677/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
678/// - `name`, if non-null, must be a valid null-terminated UTF-8 string.
679#[unsafe(no_mangle)]
680pub unsafe extern "C" fn tx_next_sibling_element(
681    doc: *const TxDocument,
682    node: TxNodeId,
683    name: *const c_char,
684) -> TxNodeId {
685    if doc.is_null() {
686        return TX_NULL_NODE;
687    }
688    ffi_catch!(TX_NULL_NODE, {
689        let doc = unsafe { &*doc };
690        let name_opt = unsafe { cstr_to_str(name) };
691        TxNodeId::from_option(doc.doc.next_sibling_element(node.to_node_id(), name_opt))
692    })
693}
694
695/// Returns the root element of the document, or `TX_NULL_NODE` if the
696/// document is empty.
697///
698/// # Safety
699///
700/// `doc` must be a valid, non-null pointer to a `TxDocument`.
701#[unsafe(no_mangle)]
702pub unsafe extern "C" fn tx_root_element(doc: *const TxDocument) -> TxNodeId {
703    if doc.is_null() {
704        return TX_NULL_NODE;
705    }
706    ffi_catch!(TX_NULL_NODE, {
707        let doc = unsafe { &*doc };
708        TxNodeId::from_option(doc.doc.root_element())
709    })
710}
711
712// ============================================================
713// Element & Attribute Helpers
714// ============================================================
715
716/// Returns the tag name of an element node.
717///
718/// The returned pointer is valid until the next mutating operation on the
719/// document or until the document is freed.
720///
721/// # Safety
722///
723/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
724/// - `element` must identify an element node.
725#[unsafe(no_mangle)]
726pub unsafe extern "C" fn tx_element_name(doc: *mut TxDocument, element: TxNodeId) -> *const c_char {
727    if doc.is_null() {
728        return ptr::null();
729    }
730    ffi_catch!(ptr::null(), {
731        let doc = unsafe { &mut *doc };
732        let val = doc
733            .doc
734            .node_ref(element.to_node_id())
735            .map(|nr| nr.value().to_owned());
736        match val {
737            Some(s) => cache_str(doc, &s),
738            None => ptr::null(),
739        }
740    })
741}
742
743/// Returns the value of the named attribute on an element.
744///
745/// Returns null if the element or attribute does not exist.
746///
747/// # Safety
748///
749/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
750/// - `name` must be a valid, non-null pointer to a null-terminated UTF-8 string.
751#[unsafe(no_mangle)]
752pub unsafe extern "C" fn tx_element_attribute(
753    doc: *mut TxDocument,
754    el: TxNodeId,
755    name: *const c_char,
756) -> *const c_char {
757    if doc.is_null() {
758        return ptr::null();
759    }
760    ffi_catch!(ptr::null(), {
761        let doc = unsafe { &mut *doc };
762        let Some(name_str) = (unsafe { cstr_to_str(name) }) else {
763            return ptr::null();
764        };
765        let val = doc
766            .doc
767            .attribute(el.to_node_id(), name_str)
768            .map(str::to_owned);
769        match val {
770            Some(s) => cache_str(doc, &s),
771            None => ptr::null(),
772        }
773    })
774}
775
776/// Sets an attribute on an element. Creates the attribute if it doesn't exist,
777/// or updates its value if it does.
778///
779/// # Safety
780///
781/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
782/// - `name` and `value` must be valid, non-null pointers to null-terminated
783///   UTF-8 strings.
784#[unsafe(no_mangle)]
785pub unsafe extern "C" fn tx_element_set_attribute(
786    doc: *mut TxDocument,
787    el: TxNodeId,
788    name: *const c_char,
789    value: *const c_char,
790) -> TxError {
791    if doc.is_null() {
792        return TxError::TxErrorInvalidNodeId;
793    }
794    ffi_catch!(TxError::TxErrorInvalidNodeId, {
795        let doc = unsafe { &mut *doc };
796        let Some(name_str) = (unsafe { cstr_to_str(name) }) else {
797            return TxError::TxErrorNoAttribute;
798        };
799        let Some(val_str) = (unsafe { cstr_to_str(value) }) else {
800            return TxError::TxErrorNoAttribute;
801        };
802        doc.invalidate_caches();
803        TxError::from_result(&doc.doc.set_attribute(el.to_node_id(), name_str, val_str))
804    })
805}
806
807/// Deletes an attribute from an element by name.
808///
809/// # Safety
810///
811/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
812/// - `name` must be a valid, non-null pointer to a null-terminated UTF-8 string.
813#[unsafe(no_mangle)]
814pub unsafe extern "C" fn tx_element_delete_attribute(
815    doc: *mut TxDocument,
816    el: TxNodeId,
817    name: *const c_char,
818) -> TxError {
819    if doc.is_null() {
820        return TxError::TxErrorInvalidNodeId;
821    }
822    ffi_catch!(TxError::TxErrorInvalidNodeId, {
823        let doc = unsafe { &mut *doc };
824        let Some(name_str) = (unsafe { cstr_to_str(name) }) else {
825            return TxError::TxErrorNoAttribute;
826        };
827        doc.invalidate_caches();
828        TxError::from_result(&doc.doc.delete_attribute(el.to_node_id(), name_str))
829    })
830}
831
832/// Returns the text content of an element's first child text node.
833///
834/// Returns null if no text child exists.
835///
836/// # Safety
837///
838/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
839/// - `element` must identify an element node.
840#[unsafe(no_mangle)]
841pub unsafe extern "C" fn tx_element_get_text(
842    doc: *mut TxDocument,
843    element: TxNodeId,
844) -> *const c_char {
845    if doc.is_null() {
846        return ptr::null();
847    }
848    ffi_catch!(ptr::null(), {
849        let doc = unsafe { &mut *doc };
850        let val = doc.doc.get_text(element.to_node_id()).map(str::to_owned);
851        match val {
852            Some(s) => cache_str(doc, &s),
853            None => ptr::null(),
854        }
855    })
856}
857
858/// Sets the text content of an element.
859///
860/// If a child text node exists, its content is replaced. Otherwise, a new
861/// text node is created and inserted as the first child.
862///
863/// # Safety
864///
865/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
866/// - `text` must be a valid, non-null pointer to a null-terminated UTF-8 string.
867#[unsafe(no_mangle)]
868pub unsafe extern "C" fn tx_element_set_text(
869    doc: *mut TxDocument,
870    element: TxNodeId,
871    text: *const c_char,
872) -> TxError {
873    if doc.is_null() {
874        return TxError::TxErrorInvalidNodeId;
875    }
876    ffi_catch!(TxError::TxErrorInvalidNodeId, {
877        let doc = unsafe { &mut *doc };
878        let Some(text_str) = (unsafe { cstr_to_str(text) }) else {
879            return TxError::TxErrorNoTextNode;
880        };
881        doc.invalidate_caches();
882        TxError::from_result(&doc.doc.set_text(element.to_node_id(), text_str))
883    })
884}
885
886// ============================================================
887// Typed Attribute Accessors
888// ============================================================
889
890/// Queries an integer attribute value.
891///
892/// On success, writes the value to `*value` and returns `TxSuccess`.
893///
894/// # Safety
895///
896/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
897/// - `name` must be a valid, non-null pointer to a null-terminated UTF-8 string.
898/// - `value` must be a valid, non-null pointer to a `c_int`.
899#[unsafe(no_mangle)]
900pub unsafe extern "C" fn tx_query_int_attribute(
901    doc: *const TxDocument,
902    el: TxNodeId,
903    name: *const c_char,
904    value: *mut c_int,
905) -> TxError {
906    if doc.is_null() || value.is_null() {
907        return TxError::TxErrorInvalidNodeId;
908    }
909    ffi_catch!(TxError::TxErrorInvalidNodeId, {
910        let doc = unsafe { &*doc };
911        let Some(name_str) = (unsafe { cstr_to_str(name) }) else {
912            return TxError::TxErrorNoAttribute;
913        };
914        match doc.doc.query_int_attribute(el.to_node_id(), name_str) {
915            Ok(v) => {
916                unsafe { *value = v as c_int };
917                TxError::TxSuccess
918            }
919            Err(e) => TxError::from_xml_error(&e),
920        }
921    })
922}
923
924/// Queries a boolean attribute value.
925///
926/// On success, writes the value to `*value` and returns `TxSuccess`.
927///
928/// # Safety
929///
930/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
931/// - `name` must be a valid, non-null pointer to a null-terminated UTF-8 string.
932/// - `value` must be a valid, non-null pointer to a `bool`.
933#[unsafe(no_mangle)]
934pub unsafe extern "C" fn tx_query_bool_attribute(
935    doc: *const TxDocument,
936    el: TxNodeId,
937    name: *const c_char,
938    value: *mut bool,
939) -> TxError {
940    if doc.is_null() || value.is_null() {
941        return TxError::TxErrorInvalidNodeId;
942    }
943    ffi_catch!(TxError::TxErrorInvalidNodeId, {
944        let doc = unsafe { &*doc };
945        let Some(name_str) = (unsafe { cstr_to_str(name) }) else {
946            return TxError::TxErrorNoAttribute;
947        };
948        match doc.doc.query_bool_attribute(el.to_node_id(), name_str) {
949            Ok(v) => {
950                unsafe { *value = v };
951                TxError::TxSuccess
952            }
953            Err(e) => TxError::from_xml_error(&e),
954        }
955    })
956}
957
958/// Queries a double (f64) attribute value.
959///
960/// On success, writes the value to `*value` and returns `TxSuccess`.
961///
962/// # Safety
963///
964/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
965/// - `name` must be a valid, non-null pointer to a null-terminated UTF-8 string.
966/// - `value` must be a valid, non-null pointer to a `c_double`.
967#[unsafe(no_mangle)]
968pub unsafe extern "C" fn tx_query_double_attribute(
969    doc: *const TxDocument,
970    el: TxNodeId,
971    name: *const c_char,
972    value: *mut c_double,
973) -> TxError {
974    if doc.is_null() || value.is_null() {
975        return TxError::TxErrorInvalidNodeId;
976    }
977    ffi_catch!(TxError::TxErrorInvalidNodeId, {
978        let doc = unsafe { &*doc };
979        let Some(name_str) = (unsafe { cstr_to_str(name) }) else {
980            return TxError::TxErrorNoAttribute;
981        };
982        match doc.doc.query_double_attribute(el.to_node_id(), name_str) {
983            Ok(v) => {
984                unsafe { *value = v };
985                TxError::TxSuccess
986            }
987            Err(e) => TxError::from_xml_error(&e),
988        }
989    })
990}
991
992/// Returns an integer attribute value, or `default_val` if the attribute
993/// does not exist or cannot be parsed.
994///
995/// # Safety
996///
997/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
998/// - `name` must be a valid, non-null pointer to a null-terminated UTF-8 string.
999#[unsafe(no_mangle)]
1000pub unsafe extern "C" fn tx_int_attribute(
1001    doc: *const TxDocument,
1002    el: TxNodeId,
1003    name: *const c_char,
1004    default_val: c_int,
1005) -> c_int {
1006    if doc.is_null() {
1007        return default_val;
1008    }
1009    ffi_catch!(default_val, {
1010        let doc = unsafe { &*doc };
1011        let Some(name_str) = (unsafe { cstr_to_str(name) }) else {
1012            return default_val;
1013        };
1014        doc.doc
1015            .int_attribute(el.to_node_id(), name_str, default_val)
1016    })
1017}
1018
1019/// Returns a boolean attribute value, or `default_val` if the attribute
1020/// does not exist or cannot be parsed.
1021///
1022/// # Safety
1023///
1024/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
1025/// - `name` must be a valid, non-null pointer to a null-terminated UTF-8 string.
1026#[unsafe(no_mangle)]
1027pub unsafe extern "C" fn tx_bool_attribute(
1028    doc: *const TxDocument,
1029    el: TxNodeId,
1030    name: *const c_char,
1031    default_val: bool,
1032) -> bool {
1033    if doc.is_null() {
1034        return default_val;
1035    }
1036    ffi_catch!(default_val, {
1037        let doc = unsafe { &*doc };
1038        let Some(name_str) = (unsafe { cstr_to_str(name) }) else {
1039            return default_val;
1040        };
1041        doc.doc
1042            .bool_attribute(el.to_node_id(), name_str, default_val)
1043    })
1044}
1045
1046/// Returns a double (f64) attribute value, or `default_val` if the attribute
1047/// does not exist or cannot be parsed.
1048///
1049/// # Safety
1050///
1051/// - `doc` must be a valid, non-null pointer to a `TxDocument`.
1052/// - `name` must be a valid, non-null pointer to a null-terminated UTF-8 string.
1053#[unsafe(no_mangle)]
1054pub unsafe extern "C" fn tx_double_attribute(
1055    doc: *const TxDocument,
1056    el: TxNodeId,
1057    name: *const c_char,
1058    default_val: c_double,
1059) -> c_double {
1060    if doc.is_null() {
1061        return default_val;
1062    }
1063    ffi_catch!(default_val, {
1064        let doc = unsafe { &*doc };
1065        let Some(name_str) = (unsafe { cstr_to_str(name) }) else {
1066            return default_val;
1067        };
1068        doc.doc
1069            .double_attribute(el.to_node_id(), name_str, default_val)
1070    })
1071}
1072
1073// ============================================================
1074// Printer / Streaming API
1075// ============================================================
1076
1077/// Creates a new XML printer (pretty-print mode).
1078///
1079/// # Safety
1080///
1081/// The returned pointer must eventually be freed with [`tx_printer_free`].
1082#[unsafe(no_mangle)]
1083pub extern "C" fn tx_printer_new() -> *mut TxPrinter {
1084    ffi_catch!(ptr::null_mut(), {
1085        Box::into_raw(Box::new(TxPrinter::new(false)))
1086    })
1087}
1088
1089/// Creates a new XML printer (compact mode, no whitespace).
1090///
1091/// # Safety
1092///
1093/// The returned pointer must eventually be freed with [`tx_printer_free`].
1094#[unsafe(no_mangle)]
1095pub extern "C" fn tx_printer_new_compact() -> *mut TxPrinter {
1096    ffi_catch!(ptr::null_mut(), {
1097        Box::into_raw(Box::new(TxPrinter::new(true)))
1098    })
1099}
1100
1101/// Frees a printer previously created with [`tx_printer_new`] or
1102/// [`tx_printer_new_compact`].
1103///
1104/// # Safety
1105///
1106/// `printer` must be a valid pointer returned by a printer constructor, or null.
1107/// After this call, the pointer must not be used again.
1108#[unsafe(no_mangle)]
1109pub unsafe extern "C" fn tx_printer_free(printer: *mut TxPrinter) {
1110    if printer.is_null() {
1111        return;
1112    }
1113    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1114        drop(unsafe { Box::from_raw(printer) });
1115    }));
1116}
1117
1118/// Opens an element tag in the printer output.
1119///
1120/// # Safety
1121///
1122/// - `printer` must be a valid, non-null pointer to a `TxPrinter`.
1123/// - `name` must be a valid, non-null pointer to a null-terminated UTF-8 string.
1124#[unsafe(no_mangle)]
1125pub unsafe extern "C" fn tx_printer_open_element(printer: *mut TxPrinter, name: *const c_char) {
1126    if printer.is_null() {
1127        return;
1128    }
1129    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1130        let p = unsafe { &mut *printer };
1131        if let Some(name_str) = unsafe { cstr_to_str(name) } {
1132            p.cached_result = None;
1133            p.printer.open_element(name_str);
1134        }
1135    }));
1136}
1137
1138/// Pushes an attribute onto the currently open element.
1139///
1140/// # Safety
1141///
1142/// - `printer` must be a valid, non-null pointer to a `TxPrinter`.
1143/// - `name` and `value` must be valid, non-null pointers to null-terminated
1144///   UTF-8 strings.
1145#[unsafe(no_mangle)]
1146pub unsafe extern "C" fn tx_printer_push_attribute(
1147    printer: *mut TxPrinter,
1148    name: *const c_char,
1149    value: *const c_char,
1150) {
1151    if printer.is_null() {
1152        return;
1153    }
1154    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1155        let p = unsafe { &mut *printer };
1156        if let (Some(n), Some(v)) = (unsafe { cstr_to_str(name) }, unsafe { cstr_to_str(value) }) {
1157            p.cached_result = None;
1158            p.printer.push_attribute(n, v);
1159        }
1160    }));
1161}
1162
1163/// Closes the most recently opened element.
1164///
1165/// # Safety
1166///
1167/// `printer` must be a valid, non-null pointer to a `TxPrinter`.
1168#[unsafe(no_mangle)]
1169pub unsafe extern "C" fn tx_printer_close_element(printer: *mut TxPrinter) {
1170    if printer.is_null() {
1171        return;
1172    }
1173    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1174        let p = unsafe { &mut *printer };
1175        p.cached_result = None;
1176        p.printer.close_element();
1177    }));
1178}
1179
1180/// Pushes text content into the current element.
1181///
1182/// # Safety
1183///
1184/// - `printer` must be a valid, non-null pointer to a `TxPrinter`.
1185/// - `text` must be a valid, non-null pointer to a null-terminated UTF-8 string.
1186#[unsafe(no_mangle)]
1187pub unsafe extern "C" fn tx_printer_push_text(printer: *mut TxPrinter, text: *const c_char) {
1188    if printer.is_null() {
1189        return;
1190    }
1191    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1192        let p = unsafe { &mut *printer };
1193        if let Some(text_str) = unsafe { cstr_to_str(text) } {
1194            p.cached_result = None;
1195            p.printer.push_text(text_str);
1196        }
1197    }));
1198}
1199
1200/// Pushes a comment into the printer output.
1201///
1202/// # Safety
1203///
1204/// - `printer` must be a valid, non-null pointer to a `TxPrinter`.
1205/// - `text` must be a valid, non-null pointer to a null-terminated UTF-8 string.
1206#[unsafe(no_mangle)]
1207pub unsafe extern "C" fn tx_printer_push_comment(printer: *mut TxPrinter, text: *const c_char) {
1208    if printer.is_null() {
1209        return;
1210    }
1211    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1212        let p = unsafe { &mut *printer };
1213        if let Some(text_str) = unsafe { cstr_to_str(text) } {
1214            p.cached_result = None;
1215            p.printer.push_comment(text_str);
1216        }
1217    }));
1218}
1219
1220/// Returns the accumulated printer output as a C string.
1221///
1222/// The returned pointer is valid until the printer is modified or freed.
1223///
1224/// # Safety
1225///
1226/// `printer` must be a valid, non-null pointer to a `TxPrinter`.
1227#[unsafe(no_mangle)]
1228pub unsafe extern "C" fn tx_printer_result(printer: *mut TxPrinter) -> *const c_char {
1229    if printer.is_null() {
1230        return ptr::null();
1231    }
1232    ffi_catch!(ptr::null(), {
1233        let p = unsafe { &mut *printer };
1234        let s = p.printer.result();
1235        match CString::new(s) {
1236            Ok(cs) => {
1237                let ptr = cs.as_ptr();
1238                p.cached_result = Some(cs);
1239                ptr
1240            }
1241            Err(_) => ptr::null(),
1242        }
1243    })
1244}
1245
1246/// Clears the printer output, resetting it to empty.
1247///
1248/// # Safety
1249///
1250/// `printer` must be a valid, non-null pointer to a `TxPrinter`.
1251#[unsafe(no_mangle)]
1252pub unsafe extern "C" fn tx_printer_clear(printer: *mut TxPrinter) {
1253    if printer.is_null() {
1254        return;
1255    }
1256    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1257        let p = unsafe { &mut *printer };
1258        p.cached_result = None;
1259        p.printer.clear();
1260    }));
1261}
1262
1263// ============================================================
1264// Node Type Inspection
1265// ============================================================
1266
1267/// Returns the type of the given node.
1268///
1269/// # Safety
1270///
1271/// `doc` must be a valid, non-null pointer to a `TxDocument`.
1272#[unsafe(no_mangle)]
1273pub unsafe extern "C" fn tx_node_type(doc: *const TxDocument, node: TxNodeId) -> TxNodeType {
1274    if doc.is_null() {
1275        return TxNodeType::TxNodeUnknown;
1276    }
1277    ffi_catch!(TxNodeType::TxNodeUnknown, {
1278        let doc = unsafe { &*doc };
1279        match doc.doc.node_kind(node.to_node_id()) {
1280            Some(kind) => TxNodeType::from_node_kind(kind),
1281            None => TxNodeType::TxNodeUnknown,
1282        }
1283    })
1284}
1285
1286/// Returns `true` if the given node ID is the null sentinel.
1287///
1288/// This function is safe to call without a document pointer.
1289#[unsafe(no_mangle)]
1290pub extern "C" fn tx_node_is_null(node: TxNodeId) -> bool {
1291    node.is_null()
1292}
1293
1294/// Returns the "value" of a node based on its type:
1295///
1296/// - Element → tag name
1297/// - Text → text content
1298/// - Comment → comment text
1299/// - Declaration → declaration name
1300/// - Unknown → raw content
1301/// - Document → empty string
1302///
1303/// # Safety
1304///
1305/// `doc` must be a valid, non-null pointer to a `TxDocument`.
1306#[unsafe(no_mangle)]
1307pub unsafe extern "C" fn tx_node_value(doc: *mut TxDocument, node: TxNodeId) -> *const c_char {
1308    if doc.is_null() {
1309        return ptr::null();
1310    }
1311    ffi_catch!(ptr::null(), {
1312        let doc = unsafe { &mut *doc };
1313        let val = doc
1314            .doc
1315            .node_ref(node.to_node_id())
1316            .map(|nr| nr.value().to_owned());
1317        match val {
1318            Some(s) => cache_str(doc, &s),
1319            None => ptr::null(),
1320        }
1321    })
1322}
1323
1324/// Returns the 1-based source line number where the node was parsed,
1325/// or 0 if the node was not created by parsing.
1326///
1327/// # Safety
1328///
1329/// `doc` must be a valid, non-null pointer to a `TxDocument`.
1330#[unsafe(no_mangle)]
1331pub unsafe extern "C" fn tx_node_line(doc: *const TxDocument, node: TxNodeId) -> c_int {
1332    if doc.is_null() {
1333        return 0;
1334    }
1335    ffi_catch!(0, {
1336        let doc = unsafe { &*doc };
1337        doc.doc.line_num(node.to_node_id()).unwrap_or(0) as c_int
1338    })
1339}