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

kaidokert / xacro / 20882682038

10 Jan 2026 06:39PM UTC coverage: 88.062%. First build
20882682038

Pull #38

github

web-flow
Merge 46e2ce1c6 into ee7088e0b
Pull Request #38: Add granular compatibility mode with --compat=[modes]

51 of 62 new or added lines in 4 files covered. (82.26%)

2331 of 2647 relevant lines covered (88.06%)

179.41 hits per line

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

92.21
/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 {
231✔
33
        Self {
231✔
34
            duplicate_params: false,
231✔
35
            namespace: false,
231✔
36
        }
231✔
37
    }
231✔
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> {
7✔
59
        // Reject empty or whitespace-only strings to prevent silent misconfigurations
60
        let s = s.trim();
7✔
61
        if s.is_empty() {
7✔
NEW
62
            return Err(CompatModeParseError::Empty);
×
63
        }
7✔
64

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

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

80
        Ok(mode)
6✔
81
    }
7✔
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 {
211✔
99
        Self::new_with_args(HashMap::new())
211✔
100
    }
211✔
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 {
215✔
119
        Self::new_with_compat(args, false)
215✔
120
    }
215✔
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(
222✔
155
        args: HashMap<String, String>,
222✔
156
        compat: bool,
222✔
157
    ) -> Self {
222✔
158
        Self {
159
            max_recursion_depth: XacroContext::DEFAULT_MAX_DEPTH,
160
            args,
222✔
161
            compat_mode: if compat {
222✔
162
                CompatMode::all()
7✔
163
            } else {
164
                CompatMode::none()
215✔
165
            },
166
        }
167
    }
222✔
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(
7✔
185
        args: HashMap<String, String>,
7✔
186
        compat_mode: CompatMode,
7✔
187
    ) -> Self {
7✔
188
        Self {
7✔
189
            max_recursion_depth: XacroContext::DEFAULT_MAX_DEPTH,
7✔
190
            args,
7✔
191
            compat_mode,
7✔
192
        }
7✔
193
    }
7✔
194

195
    /// Process xacro content from a file path
196
    pub fn run<P: AsRef<std::path::Path>>(
9✔
197
        &self,
9✔
198
        path: P,
9✔
199
    ) -> Result<String, XacroError> {
9✔
200
        let xml = XacroProcessor::parse_file(&path)?;
9✔
201
        self.run_impl(
9✔
202
            xml,
9✔
203
            path.as_ref()
9✔
204
                .parent()
9✔
205
                .unwrap_or_else(|| std::path::Path::new(".")),
9✔
206
        )
207
    }
9✔
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(
221✔
215
        &self,
221✔
216
        content: &str,
221✔
217
    ) -> Result<String, XacroError> {
221✔
218
        let xml = xmltree::Element::parse(content.as_bytes())?;
221✔
219
        // Use current directory as base path for any includes in test content
220
        self.run_impl(xml, std::path::Path::new("."))
221✔
221
    }
221✔
222

223
    /// Internal implementation
224
    fn run_impl(
230✔
225
        &self,
230✔
226
        mut root: xmltree::Element,
230✔
227
        base_path: &std::path::Path,
230✔
228
    ) -> Result<String, XacroError> {
230✔
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(&root, self.compat_mode.namespace)?;
230✔
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(
224✔
246
            base_path.to_path_buf(),
224✔
247
            xacro_ns.clone(),
224✔
248
            self.args.clone(),
224✔
249
            self.compat_mode,
224✔
250
        );
251
        ctx.set_max_recursion_depth(self.max_recursion_depth);
224✔
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(root), &ctx)?;
224✔
256

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

265
        root = match expanded_nodes.into_iter().next().unwrap() {
184✔
266
            XMLNode::Element(elem) => elem,
184✔
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 root, &xacro_ns)?;
184✔
277

278
        XacroProcessor::serialize(&root)
182✔
279
    }
230✔
280

281
    fn finalize_tree(
531✔
282
        element: &mut xmltree::Element,
531✔
283
        xacro_ns: &str,
531✔
284
    ) -> Result<(), XacroError> {
531✔
285
        // Check if this element is in the xacro namespace (indicates unprocessed feature)
286
        // Must check namespace URI, not prefix, to handle namespace aliasing (e.g., xmlns:x="...")
287

288
        // Case 1: Element has namespace and matches declared xacro namespace
289
        if !xacro_ns.is_empty() && element.namespace.as_deref() == Some(xacro_ns) {
531✔
290
            // Use centralized feature lists for consistent error messages
291
            use crate::error::{IMPLEMENTED_FEATURES, UNIMPLEMENTED_FEATURES};
292
            return Err(XacroError::UnimplementedFeature(format!(
1✔
293
                "<xacro:{}>\n\
1✔
294
                     This element was not processed. Either:\n\
1✔
295
                     1. The feature is not implemented yet (known unimplemented: {})\n\
1✔
296
                     2. There's a bug in the processor\n\
1✔
297
                     \n\
1✔
298
                     Currently implemented: {}",
1✔
299
                element.name,
1✔
300
                UNIMPLEMENTED_FEATURES.join(", "),
1✔
301
                IMPLEMENTED_FEATURES.join(", ")
1✔
302
            )));
1✔
303
        }
530✔
304

305
        // Case 2: Element has a known xacro namespace but no namespace was declared in root
306
        // This is the lazy checking: only error if xacro elements are actually used
307
        if xacro_ns.is_empty() {
530✔
308
            if let Some(elem_ns) = element.namespace.as_deref() {
7✔
309
                if is_known_xacro_uri(elem_ns) {
1✔
310
                    return Err(XacroError::MissingNamespace(format!(
1✔
311
                        "Found xacro element <{}> with namespace '{}', but no xacro namespace declared in document root. \
1✔
312
                         Please add xmlns:xacro=\"{}\" to your root element.",
1✔
313
                        element.name, elem_ns, elem_ns
1✔
314
                    )));
1✔
315
                }
×
316
            }
6✔
317
        }
523✔
318

319
        // Remove ALL known xacro namespace declarations (if namespace was declared)
320
        // This handles cases where included files use different xacro URI variants
321
        // Find and remove whichever prefixes are bound to ANY known xacro namespace URI
322
        // This handles both standard (xmlns:xacro="...") and non-standard (xmlns:foo="...") prefixes
323
        if !xacro_ns.is_empty() {
529✔
324
            if let Some(ref mut ns) = element.namespaces {
523✔
325
                // Find all prefixes bound to ANY known xacro namespace URI
326
                let prefixes_to_remove: Vec<String> =
521✔
327
                    ns.0.iter()
521✔
328
                        .filter(|(_, uri)| is_known_xacro_uri(uri.as_str()))
2,121✔
329
                        .map(|(prefix, _)| prefix.clone())
521✔
330
                        .collect();
521✔
331

332
                // Remove all found prefixes
333
                for prefix in prefixes_to_remove {
1,022✔
334
                    ns.0.remove(&prefix);
501✔
335
                }
501✔
336
            }
2✔
337
        }
6✔
338

339
        // Recursively process children
340
        for child in &mut element.children {
917✔
341
            if let Some(child_elem) = child.as_mut_element() {
390✔
342
                Self::finalize_tree(child_elem, xacro_ns)?;
347✔
343
            }
43✔
344
        }
345

346
        Ok(())
527✔
347
    }
531✔
348
}
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