• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kaidokert / xacro / 20901247249

11 Jan 2026 08:17PM UTC coverage: 88.346%. First build
20901247249

Pull #47

github

web-flow
Merge 429695ba7 into e7fc0aa50
Pull Request #47: Fix namespace collision when multiple prefixes share URI

23 of 26 new or added lines in 1 file covered. (88.46%)

2714 of 3072 relevant lines covered (88.35%)

189.65 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.43
/src/processor.rs
1
use crate::{
2
    error::XacroError,
3
    expander::{expand_node, XacroContext},
4
    utils::xml::{extract_xacro_namespace, is_known_xacro_uri},
5
};
6
use xmltree::XMLNode;
7

8
use core::str::FromStr;
9
use std::collections::HashMap;
10
use thiserror::Error;
11

12
/// Error type for invalid compatibility mode strings
13
#[derive(Debug, Error)]
14
pub enum CompatModeParseError {
15
    #[error("Compatibility mode cannot be empty (valid: all, namespace, duplicate_params)")]
16
    Empty,
17
    #[error("Unknown compatibility mode: '{0}' (valid: all, namespace, duplicate_params)")]
18
    Unknown(String),
19
}
20

21
/// Python xacro compatibility modes
22
#[derive(Debug, Clone, Copy, Default)]
23
pub struct CompatMode {
24
    /// Accept duplicate macro parameters (last declaration wins)
25
    pub duplicate_params: bool,
26
    /// Accept namespace URIs that don't match known xacro URIs
27
    pub namespace: bool,
28
}
29

30
impl CompatMode {
31
    /// No compatibility mode (strict validation)
32
    pub fn none() -> Self {
269✔
33
        Self {
269✔
34
            duplicate_params: false,
269✔
35
            namespace: false,
269✔
36
        }
269✔
37
    }
269✔
38

39
    /// All compatibility modes enabled
40
    pub fn all() -> Self {
8✔
41
        Self {
8✔
42
            duplicate_params: true,
8✔
43
            namespace: true,
8✔
44
        }
8✔
45
    }
8✔
46
}
47

48
impl FromStr for CompatMode {
49
    type Err = CompatModeParseError;
50

51
    /// Parse compatibility mode from string
52
    ///
53
    /// Supported formats:
54
    /// - "all" → all modes enabled
55
    /// - "namespace" → only namespace mode
56
    /// - "duplicate_params" → only duplicate params mode
57
    /// - "namespace,duplicate_params" → multiple modes (comma-separated)
58
    fn from_str(s: &str) -> Result<Self, Self::Err> {
9✔
59
        // Reject empty or whitespace-only strings to prevent silent misconfigurations
60
        let s = s.trim();
9✔
61
        if s.is_empty() {
9✔
62
            return Err(CompatModeParseError::Empty);
×
63
        }
9✔
64

65
        let mut mode = Self::none();
9✔
66

67
        for part in s.split(',') {
10✔
68
            let part = part.trim();
10✔
69
            if part.is_empty() {
10✔
70
                continue;
×
71
            }
10✔
72
            match part {
10✔
73
                "all" => return Ok(Self::all()),
10✔
74
                "namespace" => mode.namespace = true,
9✔
75
                "duplicate_params" => mode.duplicate_params = true,
2✔
76
                _ => return Err(CompatModeParseError::Unknown(part.to_string())),
×
77
            }
78
        }
79

80
        Ok(mode)
8✔
81
    }
9✔
82
}
83

84
pub struct XacroProcessor {
85
    /// Maximum recursion depth for macro expansion and insert_block
86
    /// Default: 50 (set conservatively to prevent stack overflow)
87
    max_recursion_depth: usize,
88
    /// CLI arguments passed to the processor (for xacro:arg support)
89
    /// These take precedence over XML defaults
90
    args: HashMap<String, String>,
91
    /// Python xacro compatibility modes
92
    compat_mode: CompatMode,
93
}
94

95
impl XacroProcessor {
96
    /// Create a new xacro processor with default settings
97
    #[allow(clippy::new_without_default)]
98
    pub fn new() -> Self {
247✔
99
        Self::new_with_args(HashMap::new())
247✔
100
    }
247✔
101

102
    /// Create a new xacro processor with CLI arguments
103
    ///
104
    /// # Arguments
105
    /// * `args` - Map of argument names to values (from CLI key:=value format)
106
    ///
107
    /// # Example
108
    /// ```
109
    /// use xacro::XacroProcessor;
110
    /// use std::collections::HashMap;
111
    ///
112
    /// let mut args = HashMap::new();
113
    /// args.insert("scale".to_string(), "0.5".to_string());
114
    /// args.insert("prefix".to_string(), "robot_".to_string());
115
    ///
116
    /// let processor = XacroProcessor::new_with_args(args);
117
    /// ```
118
    pub fn new_with_args(args: HashMap<String, String>) -> Self {
251✔
119
        Self::new_with_compat(args, false)
251✔
120
    }
251✔
121

122
    /// Create a new xacro processor with custom max recursion depth
123
    ///
124
    /// # Arguments
125
    /// * `max_depth` - Maximum recursion depth before triggering error (recommended: 50-100)
126
    ///
127
    /// # Example
128
    /// ```
129
    /// use xacro::XacroProcessor;
130
    /// let processor = XacroProcessor::new_with_depth(100);
131
    /// ```
132
    pub fn new_with_depth(max_depth: usize) -> Self {
1✔
133
        Self {
1✔
134
            max_recursion_depth: max_depth,
1✔
135
            args: HashMap::new(),
1✔
136
            compat_mode: CompatMode::none(),
1✔
137
        }
1✔
138
    }
1✔
139

140
    /// Create a new xacro processor with CLI arguments and compat mode
141
    ///
142
    /// # Arguments
143
    /// * `args` - Map of argument names to values (from CLI key:=value format)
144
    /// * `compat` - Enable Python xacro compatibility mode (accept buggy inputs)
145
    ///
146
    /// # Example
147
    /// ```
148
    /// use xacro::XacroProcessor;
149
    /// use std::collections::HashMap;
150
    ///
151
    /// let args = HashMap::new();
152
    /// let processor = XacroProcessor::new_with_compat(args, true);
153
    /// ```
154
    pub fn new_with_compat(
258✔
155
        args: HashMap<String, String>,
258✔
156
        compat: bool,
258✔
157
    ) -> Self {
258✔
158
        Self {
159
            max_recursion_depth: XacroContext::DEFAULT_MAX_DEPTH,
160
            args,
258✔
161
            compat_mode: if compat {
258✔
162
                CompatMode::all()
7✔
163
            } else {
164
                CompatMode::none()
251✔
165
            },
166
        }
167
    }
258✔
168

169
    /// Create a new xacro processor with specific compatibility modes
170
    ///
171
    /// # Arguments
172
    /// * `args` - Map of argument names to values (from CLI key:=value format)
173
    /// * `compat_mode` - Compatibility mode configuration
174
    ///
175
    /// # Example
176
    /// ```
177
    /// use xacro::{XacroProcessor, CompatMode};
178
    /// use std::collections::HashMap;
179
    ///
180
    /// let args = HashMap::new();
181
    /// let compat = "namespace,duplicate_params".parse().unwrap();
182
    /// let processor = XacroProcessor::new_with_compat_mode(args, compat);
183
    /// ```
184
    pub fn new_with_compat_mode(
9✔
185
        args: HashMap<String, String>,
9✔
186
        compat_mode: CompatMode,
9✔
187
    ) -> Self {
9✔
188
        Self {
9✔
189
            max_recursion_depth: XacroContext::DEFAULT_MAX_DEPTH,
9✔
190
            args,
9✔
191
            compat_mode,
9✔
192
        }
9✔
193
    }
9✔
194

195
    /// Process xacro content from a file path
196
    pub fn run<P: AsRef<std::path::Path>>(
10✔
197
        &self,
10✔
198
        path: P,
10✔
199
    ) -> Result<String, XacroError> {
10✔
200
        let doc = XacroProcessor::parse_file(&path)?;
10✔
201
        self.run_impl(
10✔
202
            doc,
10✔
203
            path.as_ref()
10✔
204
                .parent()
10✔
205
                .unwrap_or_else(|| std::path::Path::new(".")),
10✔
206
        )
207
    }
10✔
208

209
    /// Process xacro content from a string
210
    ///
211
    /// # Note
212
    /// Any `<xacro:include>` directives with relative paths will be resolved
213
    /// relative to the current working directory.
214
    pub fn run_from_string(
258✔
215
        &self,
258✔
216
        content: &str,
258✔
217
    ) -> Result<String, XacroError> {
258✔
218
        let doc = crate::utils::document::XacroDocument::parse(content.as_bytes())?;
258✔
219
        // Use current directory as base path for any includes in test content
220
        self.run_impl(doc, std::path::Path::new("."))
258✔
221
    }
258✔
222

223
    /// Internal implementation
224
    fn run_impl(
268✔
225
        &self,
268✔
226
        mut doc: crate::utils::document::XacroDocument,
268✔
227
        base_path: &std::path::Path,
268✔
228
    ) -> Result<String, XacroError> {
268✔
229
        // Extract xacro namespace from document root (if present)
230
        // Strategy:
231
        // 1. Try standard "xacro" prefix (e.g., xmlns:xacro="...")
232
        // 2. Fallback: search for any prefix bound to a known xacro URI
233
        // 3. If not found, use empty string (lazy checking - only error if xacro elements actually used)
234
        //
235
        // Documents with NO xacro elements don't need xacro namespace declaration.
236
        // Only error during finalize_tree if xacro elements are found.
237
        //
238
        // When in namespace compat mode, skip URI validation to accept files with
239
        // "typo" URIs like xmlns:xacro="...#interface" that Python xacro accepts
240
        let xacro_ns = extract_xacro_namespace(&doc.root, self.compat_mode.namespace)?;
268✔
241

242
        // Create expansion context with CLI arguments and compat mode
243
        // Math constants (pi, e, tau, etc.) are automatically initialized by PropertyProcessor::new()
244
        // CLI args are passed to the context and take precedence over XML defaults
245
        let mut ctx = XacroContext::new_with_compat(
260✔
246
            base_path.to_path_buf(),
260✔
247
            xacro_ns.clone(),
260✔
248
            self.args.clone(),
260✔
249
            self.compat_mode,
260✔
250
        );
251
        ctx.set_max_recursion_depth(self.max_recursion_depth);
260✔
252

253
        // Expand the root element itself. This will handle attributes on the root
254
        // and any xacro directives at the root level (though unlikely).
255
        let expanded_nodes = expand_node(XMLNode::Element(doc.root), &ctx)?;
260✔
256

257
        // The expansion of the root must result in a single root element.
258
        if expanded_nodes.len() != 1 {
219✔
259
            return Err(XacroError::InvalidRoot(format!(
×
260
                "Root element expanded to {} nodes, expected 1",
×
261
                expanded_nodes.len()
×
262
            )));
×
263
        }
219✔
264

265
        doc.root = match expanded_nodes.into_iter().next().unwrap() {
219✔
266
            XMLNode::Element(elem) => elem,
219✔
267
            _ => {
268
                return Err(XacroError::InvalidRoot(
×
269
                    "Root element expanded to a non-element node (e.g., text or comment)"
×
270
                        .to_string(),
×
271
                ))
×
272
            }
273
        };
274

275
        // Final cleanup: check for unprocessed xacro elements and remove namespace
276
        Self::finalize_tree(&mut doc.root, &xacro_ns, &self.compat_mode)?;
219✔
277

278
        XacroProcessor::serialize(&doc)
217✔
279
    }
268✔
280

281
    fn finalize_tree_children(
630✔
282
        element: &mut xmltree::Element,
630✔
283
        xacro_ns: &str,
630✔
284
        compat_mode: &CompatMode,
630✔
285
    ) -> Result<(), XacroError> {
630✔
286
        for child in &mut element.children {
1,113✔
287
            if let Some(child_elem) = child.as_mut_element() {
485✔
288
                Self::finalize_tree(child_elem, xacro_ns, compat_mode)?;
413✔
289
            }
72✔
290
        }
291
        Ok(())
628✔
292
    }
630✔
293

294
    fn finalize_tree(
632✔
295
        element: &mut xmltree::Element,
632✔
296
        xacro_ns: &str,
632✔
297
        compat_mode: &CompatMode,
632✔
298
    ) -> Result<(), XacroError> {
632✔
299
        // Check if this element is in the xacro namespace (indicates unprocessed feature)
300
        // Must check namespace URI, not prefix, to handle namespace aliasing (e.g., xmlns:x="...")
301

302
        // Case 1: Element has namespace and matches declared xacro namespace
303
        if !xacro_ns.is_empty() && element.namespace.as_deref() == Some(xacro_ns) {
632✔
304
            // Compat mode: handle namespace collision (same URI bound to multiple prefixes)
305
            // If the element uses a non-"xacro" prefix, Python xacro ignores it based on prefix string check.
306
            // In strict mode, this is a hard error (poor XML practice).
307
            if compat_mode.namespace {
4✔
308
                let prefix = element.prefix.as_deref().unwrap_or("");
3✔
309
                if prefix != "xacro" {
3✔
310
                    let element_display = if prefix.is_empty() {
3✔
NEW
311
                        format!("<{}>", element.name)
×
312
                    } else {
313
                        format!("<{}:{}>", prefix, element.name)
3✔
314
                    };
315
                    log::warn!(
3✔
NEW
316
                        "Namespace collision: {} uses xacro namespace URI but different prefix (compat mode)",
×
317
                        element_display
318
                    );
319
                    // Pass through - recursively finalize children but don't error
320
                    Self::finalize_tree_children(element, xacro_ns, compat_mode)?;
3✔
321
                    return Ok(());
3✔
NEW
322
                }
×
323
            }
1✔
324

325
            // Use centralized feature lists for consistent error messages
326
            use crate::error::{IMPLEMENTED_FEATURES, UNIMPLEMENTED_FEATURES};
327
            return Err(XacroError::UnimplementedFeature(format!(
1✔
328
                "<xacro:{}>\n\
1✔
329
                     This element was not processed. Either:\n\
1✔
330
                     1. The feature is not implemented yet (known unimplemented: {})\n\
1✔
331
                     2. There's a bug in the processor\n\
1✔
332
                     \n\
1✔
333
                     Currently implemented: {}",
1✔
334
                element.name,
1✔
335
                UNIMPLEMENTED_FEATURES.join(", "),
1✔
336
                IMPLEMENTED_FEATURES.join(", ")
1✔
337
            )));
1✔
338
        }
628✔
339

340
        // Case 2: Element has a known xacro namespace but no namespace was declared in root
341
        // This is the lazy checking: only error if xacro elements are actually used
342
        if xacro_ns.is_empty() {
628✔
343
            if let Some(elem_ns) = element.namespace.as_deref() {
21✔
344
                if is_known_xacro_uri(elem_ns) {
1✔
345
                    return Err(XacroError::MissingNamespace(format!(
1✔
346
                        "Found xacro element <{}> with namespace '{}', but no xacro namespace declared in document root. \
1✔
347
                         Please add xmlns:xacro=\"{}\" to your root element.",
1✔
348
                        element.name, elem_ns, elem_ns
1✔
349
                    )));
1✔
350
                }
×
351
            }
20✔
352
        }
607✔
353

354
        // Remove ALL known xacro namespace declarations (if namespace was declared)
355
        // This handles cases where included files use different xacro URI variants
356
        // Find and remove whichever prefixes are bound to ANY known xacro namespace URI
357
        // This handles both standard (xmlns:xacro="...") and non-standard (xmlns:foo="...") prefixes
358
        if !xacro_ns.is_empty() {
627✔
359
            if let Some(ref mut ns) = element.namespaces {
607✔
360
                // Find all prefixes bound to ANY known xacro namespace URI
361
                let prefixes_to_remove: Vec<String> =
605✔
362
                    ns.0.iter()
605✔
363
                        .filter(|(_, uri)| is_known_xacro_uri(uri.as_str()))
2,466✔
364
                        .map(|(prefix, _)| prefix.clone())
605✔
365
                        .collect();
605✔
366

367
                // Remove all found prefixes
368
                for prefix in prefixes_to_remove {
1,181✔
369
                    ns.0.remove(&prefix);
576✔
370
                }
576✔
371
            }
2✔
372
        }
20✔
373

374
        // Recursively process children
375
        Self::finalize_tree_children(element, xacro_ns, compat_mode)?;
627✔
376

377
        Ok(())
625✔
378
    }
632✔
379
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc