xacro_rs/processor.rs
1use crate::{
2 error::{XacroError, IMPLEMENTED_FEATURES, UNIMPLEMENTED_FEATURES},
3 expander::{expand_node, XacroContext},
4 extensions::ExtensionHandler,
5 parse::xml::{extract_xacro_namespace, is_known_xacro_uri},
6};
7use xmltree::XMLNode;
8
9use ::core::{cell::RefCell, str::FromStr};
10use std::{collections::HashMap, path::PathBuf, rc::Rc};
11use thiserror::Error;
12
13/// Error type for invalid compatibility mode strings
14#[derive(Debug, Error)]
15pub enum CompatModeParseError {
16 #[error("Compatibility mode cannot be empty (valid: all, namespace, duplicate_params)")]
17 Empty,
18 #[error("Unknown compatibility mode: '{0}' (valid: all, namespace, duplicate_params)")]
19 Unknown(String),
20}
21
22/// Python xacro compatibility modes
23#[derive(Debug, Clone, Copy, Default)]
24pub struct CompatMode {
25 /// Accept duplicate macro parameters (last declaration wins)
26 pub duplicate_params: bool,
27 /// Accept namespace URIs that don't match known xacro URIs
28 pub namespace: bool,
29}
30
31impl CompatMode {
32 /// No compatibility mode (strict validation)
33 pub fn none() -> Self {
34 Self {
35 duplicate_params: false,
36 namespace: false,
37 }
38 }
39
40 /// All compatibility modes enabled
41 pub fn all() -> Self {
42 Self {
43 duplicate_params: true,
44 namespace: true,
45 }
46 }
47}
48
49impl FromStr for CompatMode {
50 type Err = CompatModeParseError;
51
52 /// Parse compatibility mode from string
53 ///
54 /// Supported formats:
55 /// - "all" → all modes enabled
56 /// - "namespace" → only namespace mode
57 /// - "duplicate_params" → only duplicate params mode
58 /// - "namespace,duplicate_params" → multiple modes (comma-separated)
59 fn from_str(s: &str) -> Result<Self, Self::Err> {
60 // Reject empty or whitespace-only strings to prevent silent misconfigurations
61 let s = s.trim();
62 if s.is_empty() {
63 return Err(CompatModeParseError::Empty);
64 }
65
66 let mut mode = Self::none();
67
68 for part in s.split(',') {
69 let part = part.trim();
70 if part.is_empty() {
71 continue;
72 }
73 match part {
74 "all" => return Ok(Self::all()),
75 "namespace" => mode.namespace = true,
76 "duplicate_params" => mode.duplicate_params = true,
77 _ => return Err(CompatModeParseError::Unknown(part.to_string())),
78 }
79 }
80
81 Ok(mode)
82 }
83}
84
85pub struct XacroProcessor {
86 /// Maximum recursion depth for macro expansion and insert_block
87 /// Default: 50 (set conservatively to prevent stack overflow)
88 max_recursion_depth: usize,
89 /// CLI arguments passed to the processor (for xacro:arg support)
90 /// These take precedence over XML defaults
91 args: HashMap<String, String>,
92 /// Python xacro compatibility modes
93 compat_mode: CompatMode,
94 /// Extension handlers for $(command args...) resolution
95 extensions: Rc<Vec<Box<dyn ExtensionHandler>>>,
96 /// YAML tag handlers for custom tags (e.g., !degrees, !millimeters)
97 #[cfg(feature = "yaml")]
98 yaml_tag_handlers: Rc<crate::eval::yaml_tag_handler::YamlTagHandlerRegistry>,
99}
100
101/// Builder for configuring XacroProcessor with custom settings.
102///
103/// Provides a fluent API for setting optional parameters without constructor explosion.
104///
105/// # Example
106/// ```
107/// use xacro_rs::XacroProcessor;
108///
109/// let processor = XacroProcessor::builder()
110/// .with_arg("robot_name", "my_robot")
111/// .with_arg("use_lidar", "true")
112/// .with_max_depth(100)
113/// .build();
114/// ```
115pub struct XacroBuilder {
116 args: HashMap<String, String>,
117 max_recursion_depth: usize,
118 compat_mode: CompatMode,
119 extensions: Option<Vec<Box<dyn ExtensionHandler>>>,
120 #[cfg(feature = "yaml")]
121 yaml_tag_handlers: Option<crate::eval::yaml_tag_handler::YamlTagHandlerRegistry>,
122}
123
124impl XacroBuilder {
125 /// Create a new builder with default settings.
126 fn new() -> Self {
127 Self {
128 args: HashMap::new(),
129 max_recursion_depth: XacroContext::DEFAULT_MAX_DEPTH,
130 compat_mode: CompatMode::none(),
131 extensions: None, // None = use default extensions
132 #[cfg(feature = "yaml")]
133 yaml_tag_handlers: None, // None = empty registry (no default handlers)
134 }
135 }
136
137 /// Add a single CLI argument.
138 ///
139 /// # Example
140 /// ```
141 /// use xacro_rs::XacroProcessor;
142 ///
143 /// let processor = XacroProcessor::builder()
144 /// .with_arg("scale", "0.5")
145 /// .build();
146 /// ```
147 pub fn with_arg(
148 mut self,
149 name: impl Into<String>,
150 value: impl Into<String>,
151 ) -> Self {
152 self.args.insert(name.into(), value.into());
153 self
154 }
155
156 /// Add multiple CLI arguments from a HashMap.
157 ///
158 /// # Example
159 /// ```
160 /// use xacro_rs::XacroProcessor;
161 /// use std::collections::HashMap;
162 ///
163 /// let mut args = HashMap::new();
164 /// args.insert("scale".to_string(), "0.5".to_string());
165 /// args.insert("prefix".to_string(), "robot_".to_string());
166 ///
167 /// let processor = XacroProcessor::builder()
168 /// .with_args(args)
169 /// .build();
170 /// ```
171 pub fn with_args(
172 mut self,
173 args: HashMap<String, String>,
174 ) -> Self {
175 self.args.extend(args);
176 self
177 }
178
179 /// Set the maximum recursion depth.
180 ///
181 /// # Example
182 /// ```
183 /// use xacro_rs::XacroProcessor;
184 ///
185 /// let processor = XacroProcessor::builder()
186 /// .with_max_depth(100)
187 /// .build();
188 /// ```
189 pub fn with_max_depth(
190 mut self,
191 max_depth: usize,
192 ) -> Self {
193 self.max_recursion_depth = max_depth;
194 self
195 }
196
197 /// Enable all compatibility modes.
198 ///
199 /// # Example
200 /// ```
201 /// use xacro_rs::XacroProcessor;
202 ///
203 /// let processor = XacroProcessor::builder()
204 /// .with_compat_all()
205 /// .build();
206 /// ```
207 pub fn with_compat_all(mut self) -> Self {
208 self.compat_mode = CompatMode::all();
209 self
210 }
211
212 /// Set a specific compatibility mode.
213 ///
214 /// # Example
215 /// ```
216 /// use xacro_rs::{XacroProcessor, CompatMode};
217 ///
218 /// let compat = "namespace,duplicate_params".parse().unwrap();
219 /// let processor = XacroProcessor::builder()
220 /// .with_compat_mode(compat)
221 /// .build();
222 /// ```
223 pub fn with_compat_mode(
224 mut self,
225 mode: CompatMode,
226 ) -> Self {
227 self.compat_mode = mode;
228 self
229 }
230
231 /// Add a custom extension handler.
232 ///
233 /// By default, the processor includes CwdExtension and EnvExtension.
234 /// Note: $(arg ...) is handled specially, not via an extension handler.
235 /// This method allows adding additional custom extensions without replacing the defaults.
236 ///
237 /// # Example
238 /// ```ignore
239 /// use xacro_rs::XacroProcessor;
240 /// use xacro_rs::extensions::ExtensionHandler;
241 ///
242 /// struct MyExtension;
243 /// impl ExtensionHandler for MyExtension {
244 /// fn resolve(&self, command: &str, args_raw: &str)
245 /// -> Result<Option<String>, Box<dyn std::error::Error>> {
246 /// if command == "my_ext" {
247 /// Ok(Some(format!("Custom: {}", args_raw)))
248 /// } else {
249 /// Ok(None)
250 /// }
251 /// }
252 /// }
253 ///
254 /// let processor = XacroProcessor::builder()
255 /// .with_extension(Box::new(MyExtension))
256 /// .build();
257 /// ```
258 pub fn with_extension(
259 mut self,
260 handler: Box<dyn ExtensionHandler>,
261 ) -> Self {
262 self.extensions
263 .get_or_insert_with(Self::default_extensions)
264 .push(handler);
265 self
266 }
267
268 /// Clear all extensions (including defaults).
269 ///
270 /// Use this if you want to provide a completely custom extension list
271 /// without any of the built-in extensions.
272 ///
273 /// # Example
274 /// ```ignore
275 /// use xacro_rs::XacroProcessor;
276 ///
277 /// let processor = XacroProcessor::builder()
278 /// .clear_extensions()
279 /// .with_extension(Box::new(MyExtension))
280 /// .build();
281 /// ```
282 pub fn clear_extensions(mut self) -> Self {
283 self.extensions = Some(Vec::new());
284 self
285 }
286
287 /// Register a YAML tag handler
288 ///
289 /// Handlers are tried in registration order. Register more specific handlers
290 /// before more general ones.
291 ///
292 /// # Example
293 /// ```ignore
294 /// use xacro_rs::XacroProcessor;
295 ///
296 /// let processor = XacroProcessor::builder()
297 /// .with_yaml_tag_handler(Box::new(MyHandler))
298 /// .build();
299 /// ```
300 #[cfg(feature = "yaml")]
301 pub fn with_yaml_tag_handler(
302 mut self,
303 handler: crate::extensions::DynYamlTagHandler,
304 ) -> Self {
305 self.yaml_tag_handlers
306 .get_or_insert_with(crate::eval::yaml_tag_handler::YamlTagHandlerRegistry::new)
307 .register(handler);
308 self
309 }
310
311 /// Enable ROS unit conversions (degrees, radians, millimeters, etc.)
312 ///
313 /// This is a convenience method for ROS users. Registers the RosUnitTagHandler
314 /// which handles standard ROS unit tags like !degrees, !millimeters, etc.
315 ///
316 /// # Example
317 /// ```ignore
318 /// use xacro_rs::XacroProcessor;
319 ///
320 /// let processor = XacroProcessor::builder()
321 /// .with_ros_yaml_units()
322 /// .build();
323 /// ```
324 #[cfg(feature = "yaml")]
325 pub fn with_ros_yaml_units(self) -> Self {
326 self.with_yaml_tag_handler(Box::new(
327 crate::extensions::ros_yaml_handlers::RosUnitTagHandler::new(),
328 ))
329 }
330
331 /// Build the XacroProcessor with the configured settings.
332 pub fn build(self) -> XacroProcessor {
333 let extensions = Rc::new(self.extensions.unwrap_or_else(Self::default_extensions));
334
335 XacroProcessor {
336 max_recursion_depth: self.max_recursion_depth,
337 args: self.args,
338 compat_mode: self.compat_mode,
339 extensions,
340 #[cfg(feature = "yaml")]
341 yaml_tag_handlers: Rc::new(self.yaml_tag_handlers.unwrap_or_default()),
342 }
343 }
344
345 /// Create default extension handlers (CwdExtension, EnvExtension).
346 ///
347 /// This delegates to the centralized `extensions::core::default_extensions()`.
348 /// ROS extensions (FindExtension, OptEnvExtension) are NOT included by default.
349 /// Library users should explicitly add them via builder pattern if needed.
350 /// The CLI binary adds ROS extensions automatically for user convenience.
351 ///
352 /// Note: $(arg ...) is handled specially in `EvalContext::resolve_extension()`
353 /// to ensure correct interaction with the shared arguments map.
354 fn default_extensions() -> Vec<Box<dyn ExtensionHandler>> {
355 crate::extensions::core::default_extensions()
356 }
357}
358
359impl Default for XacroProcessor {
360 fn default() -> Self {
361 Self::builder().build()
362 }
363}
364
365impl XacroProcessor {
366 /// Create a new builder for configuring the processor.
367 ///
368 /// # Example
369 /// ```
370 /// use xacro_rs::XacroProcessor;
371 ///
372 /// let processor = XacroProcessor::builder()
373 /// .with_arg("robot_name", "my_robot")
374 /// .with_max_depth(100)
375 /// .build();
376 /// ```
377 pub fn builder() -> XacroBuilder {
378 XacroBuilder::new()
379 }
380
381 /// Create a new xacro processor with default settings.
382 ///
383 /// For custom configuration, use [`XacroProcessor::builder()`].
384 ///
385 /// # Example
386 /// ```
387 /// use xacro_rs::XacroProcessor;
388 ///
389 /// let processor = XacroProcessor::new();
390 /// let input = r#"<?xml version="1.0"?>
391 /// <robot xmlns:xacro="http://www.ros.org/wiki/xacro" name="test">
392 /// <xacro:property name="value" value="42"/>
393 /// <link name="base"><inertial><mass value="${value}"/></inertial></link>
394 /// </robot>"#;
395 /// let output = processor.run_from_string(input)?;
396 /// assert!(output.contains("mass value=\"42\""));
397 /// # Ok::<(), xacro_rs::XacroError>(())
398 /// ```
399 pub fn new() -> Self {
400 Self::default()
401 }
402
403 /// Get a reference to the registered extension handlers.
404 ///
405 /// This allows callers to inspect or downcast extensions for
406 /// observability purposes (e.g., extracting package resolution info
407 /// from FindExtension).
408 ///
409 /// # Example
410 /// ```ignore
411 /// use xacro_rs::{XacroProcessor, FindExtension};
412 ///
413 /// let processor = XacroProcessor::new();
414 /// processor.run("robot.xacro")?;
415 ///
416 /// // Extract package info from FindExtension
417 /// for ext in processor.extensions().iter() {
418 /// if let Some(find_ext) = ext.as_any().downcast_ref::<FindExtension>() {
419 /// let packages = find_ext.get_found_packages();
420 /// println!("Found packages: {:?}", packages);
421 /// }
422 /// }
423 /// # Ok::<(), xacro_rs::XacroError>(())
424 /// ```
425 pub fn extensions(&self) -> &[Box<dyn ExtensionHandler>] {
426 &self.extensions
427 }
428
429 /// Process xacro content from a file path
430 pub fn run<P: AsRef<std::path::Path>>(
431 &self,
432 path: P,
433 ) -> Result<String, XacroError> {
434 // Thin wrapper over run_with_deps that discards dependency list
435 self.run_with_deps(path).map(|(output, _)| output)
436 }
437
438 /// Process xacro content from a file path and return included files
439 ///
440 /// Returns a tuple of (processed_output, included_files).
441 /// The included_files list contains paths to all files that were included
442 /// during processing via `<xacro:include>` directives.
443 pub fn run_with_deps<P: AsRef<std::path::Path>>(
444 &self,
445 path: P,
446 ) -> Result<(String, Vec<PathBuf>), XacroError> {
447 let doc = XacroProcessor::parse_file(&path)?;
448 let file_path = path.as_ref();
449 let base_dir = file_path
450 .parent()
451 .unwrap_or_else(|| std::path::Path::new("."));
452 self.run_impl(doc, base_dir, Some(file_path))
453 }
454
455 /// Process xacro content from a string
456 ///
457 /// # Note
458 /// Any `<xacro:include>` directives with relative paths will be resolved
459 /// relative to the current working directory.
460 pub fn run_from_string(
461 &self,
462 content: &str,
463 ) -> Result<String, XacroError> {
464 // Thin wrapper over run_from_string_with_deps that discards dependency list
465 self.run_from_string_with_deps(content)
466 .map(|(output, _)| output)
467 }
468
469 /// Process xacro content from a string and return included files
470 ///
471 /// Returns a tuple of (processed_output, included_files).
472 /// The included_files list contains paths to all files that were included
473 /// during processing via `<xacro:include>` directives.
474 ///
475 /// # Note
476 /// Any `<xacro:include>` directives with relative paths will be resolved
477 /// relative to the current working directory.
478 pub fn run_from_string_with_deps(
479 &self,
480 content: &str,
481 ) -> Result<(String, Vec<PathBuf>), XacroError> {
482 let doc = crate::parse::document::XacroDocument::parse(content.as_bytes())?;
483 // Use current directory as base path for any includes in test content
484 self.run_impl(doc, std::path::Path::new("."), None)
485 }
486
487 /// Notify all extension handlers of file context change
488 ///
489 /// Calls the lifecycle hook on_file_change() for all registered extensions.
490 /// Extensions that need file context (e.g., FindExtension for ancestor package
491 /// detection) can implement this hook to update their state.
492 ///
493 /// Pass Some(path) when entering a file context, None when clearing.
494 fn notify_file_change(
495 extensions: &Rc<Vec<Box<dyn ExtensionHandler>>>,
496 file_path: Option<&std::path::Path>,
497 ) {
498 for handler in extensions.iter() {
499 handler.on_file_change(file_path);
500 }
501 }
502
503 /// Internal implementation
504 fn run_impl(
505 &self,
506 mut doc: crate::parse::document::XacroDocument,
507 base_path: &std::path::Path,
508 file_path: Option<&std::path::Path>,
509 ) -> Result<(String, Vec<PathBuf>), XacroError> {
510 // Extract xacro namespace from document root (if present)
511 // Strategy:
512 // 1. Try standard "xacro" prefix (e.g., xmlns:xacro="...")
513 // 2. Fallback: search for any prefix bound to a known xacro URI
514 // 3. If not found, use empty string (lazy checking - only error if xacro elements actually used)
515 //
516 // Documents with NO xacro elements don't need xacro namespace declaration.
517 // Only error during finalize_tree if xacro elements are found.
518 //
519 // When in namespace compat mode, skip URI validation to accept files with
520 // "typo" URIs like xmlns:xacro="...#interface" that Python xacro accepts
521 let xacro_ns = extract_xacro_namespace(&doc.root, self.compat_mode.namespace)?;
522
523 // Create expansion context with CLI arguments, compat mode, and extensions
524 // Math constants (pi, e, tau, etc.) are automatically initialized by EvalContext::new()
525 // CLI args are passed to the context and take precedence over XML defaults
526 //
527 // Note: $(arg ...) is handled specially in resolve_extension using the shared
528 // args map, so ArgExtension is not included in the extensions list
529 let args_rc = Rc::new(RefCell::new(self.args.clone()));
530
531 let mut ctx = XacroContext::new_with_extensions(
532 base_path.to_path_buf(),
533 xacro_ns.clone(),
534 args_rc,
535 self.compat_mode,
536 self.extensions.clone(),
537 #[cfg(feature = "yaml")]
538 self.yaml_tag_handlers.clone(), // Rc clone is cheap
539 );
540 ctx.set_max_recursion_depth(self.max_recursion_depth);
541
542 // Set actual source file path if provided (replaces directory in namespace_stack)
543 // Always notify extensions to prevent stale file context
544 if let Some(file) = file_path {
545 ctx.set_source_file(file.to_path_buf());
546 Self::notify_file_change(&self.extensions, Some(file));
547 } else {
548 // For run_from_string, clear file context in extensions
549 Self::notify_file_change(&self.extensions, None);
550 }
551
552 // Expand the root element itself. This will handle attributes on the root
553 // and any xacro directives at the root level (though unlikely).
554 let expanded_nodes = expand_node(XMLNode::Element(doc.root), &ctx)?;
555
556 // The expansion of the root must result in a single root element.
557 if expanded_nodes.len() != 1 {
558 return Err(XacroError::InvalidRoot(format!(
559 "Root element expanded to {} nodes, expected 1",
560 expanded_nodes.len()
561 )));
562 }
563
564 doc.root = match expanded_nodes.into_iter().next().unwrap() {
565 XMLNode::Element(elem) => elem,
566 _ => {
567 return Err(XacroError::InvalidRoot(
568 "Root element expanded to a non-element node (e.g., text or comment)"
569 .to_string(),
570 ))
571 }
572 };
573
574 // Final cleanup: check for unprocessed xacro elements and remove namespace
575 Self::finalize_tree(&mut doc.root, &xacro_ns, &self.compat_mode)?;
576
577 let output = XacroProcessor::serialize(&doc)?;
578 let includes = ctx.get_all_includes();
579 Ok((output, includes))
580 }
581
582 fn finalize_tree_children(
583 element: &mut xmltree::Element,
584 xacro_ns: &str,
585 compat_mode: &CompatMode,
586 ) -> Result<(), XacroError> {
587 for child in &mut element.children {
588 if let Some(child_elem) = child.as_mut_element() {
589 Self::finalize_tree(child_elem, xacro_ns, compat_mode)?;
590 }
591 }
592 Ok(())
593 }
594
595 fn finalize_tree(
596 element: &mut xmltree::Element,
597 xacro_ns: &str,
598 compat_mode: &CompatMode,
599 ) -> Result<(), XacroError> {
600 // Check if this element is in the xacro namespace (indicates unprocessed feature)
601 // Must check namespace URI, not prefix, to handle namespace aliasing (e.g., xmlns:x="...")
602
603 // Remove xacro namespace declarations from all elements first
604 // Strategy: Remove prefixes that are:
605 // 1. Literally named "xacro" (regardless of URI)
606 // 2. Bound to known standard xacro URIs (defense-in-depth)
607 // This handles both standard URIs and non-standard URIs accepted in compat mode.
608 // Do this BEFORE checking for unprocessed features to ensure cleanup happens regardless.
609 //
610 // Note: We DON'T remove non-"xacro" prefixes bound to non-standard URIs, even if
611 // that URI happens to be used as xacro_ns in compat mode. Those prefixes may be
612 // actively used in the document (namespace collision case).
613 if !xacro_ns.is_empty() {
614 if let Some(ref mut ns) = element.namespaces {
615 // Find prefixes to remove
616 let prefixes_to_remove: Vec<String> =
617 ns.0.iter()
618 .filter(|(prefix, uri)| {
619 // Always remove "xacro" prefix
620 if prefix.as_str() == "xacro" {
621 return true;
622 }
623 // Remove other prefixes only if bound to known standard URIs
624 is_known_xacro_uri(uri.as_str())
625 })
626 .map(|(prefix, _)| prefix.clone())
627 .collect();
628
629 // Remove all found prefixes
630 for prefix in prefixes_to_remove {
631 ns.0.remove(&prefix);
632 }
633 }
634 }
635
636 // Case 1: Element has namespace and matches declared xacro namespace
637 if !xacro_ns.is_empty() && element.namespace.as_deref() == Some(xacro_ns) {
638 // Compat mode: handle namespace collision (same URI bound to multiple prefixes)
639 // If the element uses a non-"xacro" prefix, Python xacro ignores it based on prefix string check.
640 // In strict mode, this is a hard error (poor XML practice).
641 if compat_mode.namespace {
642 let prefix = element.prefix.as_deref().unwrap_or("");
643 if prefix != "xacro" {
644 let element_display = if prefix.is_empty() {
645 format!("<{}>", element.name)
646 } else {
647 format!("<{}:{}>", prefix, element.name)
648 };
649 log::warn!(
650 "Namespace collision: {} uses xacro namespace URI but different prefix (compat mode)",
651 element_display
652 );
653 // Pass through - recursively finalize children but don't error
654 Self::finalize_tree_children(element, xacro_ns, compat_mode)?;
655 return Ok(());
656 }
657 }
658
659 // Use centralized feature lists for consistent error messages
660 return Err(XacroError::UnimplementedFeature(format!(
661 "<xacro:{}>\n\
662 This element was not processed. Either:\n\
663 1. The feature is not implemented yet (known unimplemented: {})\n\
664 2. There's a bug in the processor\n\
665 \n\
666 Currently implemented: {}",
667 element.name,
668 UNIMPLEMENTED_FEATURES.join(", "),
669 IMPLEMENTED_FEATURES.join(", ")
670 )));
671 }
672
673 // Case 2: Element has a known xacro namespace but no namespace was declared in root
674 // This is the lazy checking: only error if xacro elements are actually used
675 if xacro_ns.is_empty() {
676 if let Some(elem_ns) = element.namespace.as_deref() {
677 if is_known_xacro_uri(elem_ns) {
678 return Err(XacroError::MissingNamespace(format!(
679 "Found xacro element <{}> with namespace '{}', but no xacro namespace declared in document root. \
680 Please add xmlns:xacro=\"{}\" to your root element.",
681 element.name, elem_ns, elem_ns
682 )));
683 }
684 }
685 }
686
687 // Recursively process children
688 Self::finalize_tree_children(element, xacro_ns, compat_mode)?;
689
690 Ok(())
691 }
692
693 /// Parse a xacro document from a file path
694 pub(crate) fn parse_file<P: AsRef<std::path::Path>>(
695 path: P
696 ) -> Result<crate::parse::XacroDocument, XacroError> {
697 let file = std::fs::File::open(path)?;
698 crate::parse::XacroDocument::parse(file)
699 }
700
701 /// Serialize a xacro document to a string
702 pub(crate) fn serialize(doc: &crate::parse::XacroDocument) -> Result<String, XacroError> {
703 let mut writer = Vec::new();
704 doc.write(&mut writer)?;
705 Ok(String::from_utf8(writer)?)
706 }
707}