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

kaidokert / xacro / 21130744273

19 Jan 2026 08:39AM UTC coverage: 89.096%. First build
21130744273

Pull #75

github

kaidokert
Address PR review feedback: performance, testing, and documentation

1. Performance: Avoid double-cloning children in expand_macro_call
   - Use core::mem::take to move children instead of clone-then-replace
   - Eliminates extra allocation on every macro call

2. Testing: Check specific error in test_if_around_block_parameter_false
   - Verify panic message contains 'Missing block parameter'
   - Prevents false positives from unrelated panics

3. Testing: Add test_lazy_block_with_conditionals_disabled
   - Covers False case for lazy blocks with conditionals
   - Tests that item 2 is not present when condition is False

4. Documentation: Remove brittle Python line number references
   - Replace with behavioral description
   - More maintainable as Python implementation evolves

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Pull Request #75: Process conditionals before collecting macro block parameters

27 of 28 new or added lines in 2 files covered. (96.43%)

4886 of 5484 relevant lines covered (89.1%)

260.95 hits per line

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

91.33
/src/parse/macro_def.rs
1
use crate::error::XacroError;
2
use std::collections::{HashMap, HashSet};
3
pub use xmltree::Element;
4

5
/// Default value specification for a macro parameter
6
#[derive(Debug, Clone, PartialEq)]
7
pub enum ParamDefault {
8
    /// No default: "param"
9
    None,
10
    /// Regular default: "param:=5" or "param:=${x*2}"
11
    Value(String),
12
    /// Forward required: "param:=^"
13
    /// Must exist in parent scope or error
14
    ForwardRequired(String),
15
    /// Forward with default: "param:=^|5" or "param:=^|"
16
    /// Try parent scope, fall back to default (or empty string if None)
17
    ForwardWithDefault(String, Option<String>),
18
}
19

20
// Type aliases to simplify complex return types
21
pub type ParamsMap = HashMap<String, ParamDefault>;
22
pub type ParamOrder = Vec<String>;
23
pub type BlockParamsSet = HashSet<String>;
24
pub type ParsedParams = (ParamsMap, ParamOrder, BlockParamsSet, BlockParamsSet);
25

26
pub type MacroArgs = HashMap<String, String>;
27
pub type MacroBlocks = HashMap<String, Element>;
28
pub type CollectedArgs = (MacroArgs, MacroBlocks);
29

30
#[derive(Debug, Clone)]
31
pub struct MacroDefinition {
32
    pub name: String,            // Macro name from 'name' attribute (for error messages)
33
    pub params: ParamsMap,       // Regular params with optional defaults
34
    pub param_order: ParamOrder, // Parameter declaration order (critical for block params!)
35
    pub block_params: BlockParamsSet, // Block params (names without * prefix)
36
    pub lazy_block_params: BlockParamsSet, // Lazy block params (**param - insert children only)
37
    pub content: Element,
38
}
39

40
/// Utility functions for parsing and validating macro definitions
41
pub struct MacroProcessor;
42

43
impl MacroProcessor {
44
    /// Helper to unquote a value (removes surrounding quotes if present)
45
    fn unquote_value(value: &str) -> &str {
88✔
46
        value
88✔
47
            .strip_prefix('\'')
88✔
48
            .and_then(|s| s.strip_suffix('\''))
88✔
49
            .or_else(|| value.strip_prefix('"').and_then(|s| s.strip_suffix('"')))
88✔
50
            .unwrap_or(value)
88✔
51
    }
88✔
52

53
    /// Split a parameter string on whitespace, respecting quoted sections.
54
    ///
55
    /// Returns an error if quotes are unbalanced (unclosed quote).
56
    ///
57
    /// Examples:
58
    /// - `"a b c"` → `["a", "b", "c"]`
59
    /// - `"a:='x y' b:=1"` → `["a:='x y'", "b:=1"]`
60
    /// - `"pos:='0 0 0' *block"` → `["pos:='0 0 0'", "*block"]`
61
    /// - `"rpy:='0 0 0"` → Error (unclosed quote)
62
    fn split_params_respecting_quotes(params_str: &str) -> Result<Vec<String>, XacroError> {
246✔
63
        let mut tokens = Vec::new();
246✔
64
        let mut current_token = String::new();
246✔
65
        let mut in_quotes = false;
246✔
66
        let mut quote_char = ' ';
246✔
67
        let mut expecting_value = false; // Track if we're expecting a value after := or =
246✔
68
        let mut collecting_value = false; // Track if we're actively collecting the value
246✔
69

70
        for ch in params_str.chars() {
2,209✔
71
            if in_quotes {
2,209✔
72
                current_token.push(ch);
144✔
73
                if ch == quote_char {
144✔
74
                    in_quotes = false;
23✔
75
                    // If we were collecting a quoted value, we're done with this token after the quote
76
                    if collecting_value {
23✔
77
                        // Value is complete, reset state
23✔
78
                        expecting_value = false;
23✔
79
                        collecting_value = false;
23✔
80
                    }
23✔
81
                }
121✔
82
            } else if ch == '\'' || ch == '"' {
2,065✔
83
                // Start of quoted section
84
                in_quotes = true;
25✔
85
                quote_char = ch;
25✔
86
                current_token.push(ch);
25✔
87
                // If we were expecting a value, mark that we're now collecting it
88
                if expecting_value {
25✔
89
                    collecting_value = true;
24✔
90
                }
24✔
91
            } else if ch.is_whitespace() {
2,040✔
92
                // If we're expecting a value but haven't started collecting it yet,
93
                // skip the whitespace
94
                if expecting_value && !collecting_value {
132✔
NEW
95
                    continue;
×
96
                }
132✔
97
                // If we were collecting an unquoted value, we're done
98
                if collecting_value {
132✔
99
                    expecting_value = false;
39✔
100
                    collecting_value = false;
39✔
101
                }
93✔
102
                // End of token (if not empty)
103
                if !current_token.is_empty() {
132✔
104
                    // Use mem::take to avoid cloning
98✔
105
                    tokens.push(core::mem::take(&mut current_token));
98✔
106
                }
98✔
107
            } else {
108
                // Regular character
109
                current_token.push(ch);
1,908✔
110

111
                // If we were expecting a value, mark that we're now collecting it
112
                if expecting_value && !collecting_value {
1,908✔
113
                    collecting_value = true;
100✔
114
                }
1,808✔
115

116
                // Check if current token ends with := or =
117
                // This indicates we're expecting a value next
118
                if current_token.ends_with(":=") || current_token.ends_with('=') {
1,908✔
119
                    expecting_value = true;
124✔
120
                    collecting_value = false;
124✔
121
                }
1,784✔
122
            }
123
        }
124

125
        // Check for unbalanced quotes before returning
126
        if in_quotes {
246✔
127
            return Err(XacroError::UnbalancedQuote {
2✔
128
                quote_char,
2✔
129
                params_str: params_str.to_string(),
2✔
130
            });
2✔
131
        }
244✔
132

133
        // Don't forget the last token
134
        if !current_token.is_empty() {
244✔
135
            tokens.push(current_token);
181✔
136
        }
181✔
137

138
        Ok(tokens)
244✔
139
    }
246✔
140

141
    /// Parse macro parameters (strict mode - default)
142
    pub fn parse_params(params_str: &str) -> Result<ParsedParams, XacroError> {
236✔
143
        Self::parse_params_impl(params_str, false)
236✔
144
    }
236✔
145

146
    /// Parse macro parameters (compatibility mode - accept duplicates)
147
    pub fn parse_params_compat(params_str: &str) -> Result<ParsedParams, XacroError> {
10✔
148
        Self::parse_params_impl(params_str, true)
10✔
149
    }
10✔
150

151
    /// Internal implementation for parameter parsing
152
    fn parse_params_impl(
246✔
153
        params_str: &str,
246✔
154
        compat_mode: bool,
246✔
155
    ) -> Result<ParsedParams, XacroError> {
246✔
156
        let mut params = HashMap::new();
246✔
157
        let mut param_order = Vec::new();
246✔
158
        let mut block_params = HashSet::new();
246✔
159
        let mut lazy_block_params = HashSet::new();
246✔
160

161
        for token in Self::split_params_respecting_quotes(params_str)? {
276✔
162
            // Parse token to determine parameter type and components
163
            let (param_name_str, is_block, is_lazy, param_default) = if token.starts_with('*') {
276✔
164
                // Block parameter (**param or *param)
165
                // Block parameters CANNOT have defaults
166
                if token.contains(":=") || token.contains('=') {
77✔
167
                    return Err(XacroError::BlockParameterWithDefault {
4✔
168
                        param: token.clone(),
4✔
169
                    });
4✔
170
                }
73✔
171

172
                // Check for lazy block (**param) vs regular block (*param)
173
                let (stripped, is_lazy) = if let Some(s) = token.strip_prefix("**") {
73✔
174
                    // Lazy block parameter (**param - inserts children only)
175
                    (s, true)
10✔
176
                } else if let Some(s) = token.strip_prefix('*') {
63✔
177
                    // Regular block parameter (*param - inserts element itself)
178
                    (s, false)
63✔
179
                } else {
180
                    unreachable!("starts_with('*') check guarantees this branch is unreachable");
×
181
                };
182

183
                // Validate no extra asterisks (reject ***param, ****param, etc.)
184
                if stripped.starts_with('*') {
73✔
185
                    return Err(XacroError::InvalidParameterName {
1✔
186
                        param: token.clone(),
1✔
187
                    });
1✔
188
                }
72✔
189

190
                (stripped.to_string(), true, is_lazy, ParamDefault::None)
72✔
191
            } else if let Some((name, value)) =
115✔
192
                token.split_once(":=").or_else(|| token.split_once('='))
199✔
193
            {
194
                // Regular parameter with default value (supports := or =)
195
                // Python xacro supports both syntaxes:
196
                //   params="width:=5"  (preferred)
197
                //   params="width=5"   (also valid)
198

199
                // Check for ^ operator (parent scope forwarding)
200
                let param_default = if let Some(remainder) = value.strip_prefix('^') {
115✔
201
                    if let Some(default_str) = remainder.strip_prefix('|') {
37✔
202
                        // ^|default syntax - forward with default
203
                        let unquoted = Self::unquote_value(default_str).to_string();
10✔
204
                        if unquoted.is_empty() {
10✔
205
                            ParamDefault::ForwardWithDefault(name.to_string(), None)
1✔
206
                        } else {
207
                            ParamDefault::ForwardWithDefault(name.to_string(), Some(unquoted))
9✔
208
                        }
209
                    } else if remainder.is_empty() {
27✔
210
                        // ^ syntax - required forward
211
                        ParamDefault::ForwardRequired(name.to_string())
27✔
212
                    } else {
213
                        // Invalid: ^something (not ^| or plain ^)
214
                        return Err(XacroError::InvalidForwardSyntax {
×
215
                            param: token.clone(),
×
216
                            hint: "Use ^ for required forward or ^|default for optional"
×
217
                                .to_string(),
×
218
                        });
×
219
                    }
220
                } else {
221
                    // Regular default value
222
                    ParamDefault::Value(Self::unquote_value(value).to_string())
78✔
223
                };
224

225
                (name.to_string(), false, false, param_default)
115✔
226
            } else {
227
                // Regular parameter without default
228
                (token.clone(), false, false, ParamDefault::None)
84✔
229
            };
230

231
            // Validate parameter name is not empty
232
            if param_name_str.is_empty() {
271✔
233
                return Err(XacroError::InvalidParameterName { param: token });
2✔
234
            }
269✔
235

236
            let param_name = param_name_str;
269✔
237

238
            // Detect duplicate declarations (strict mode only)
239
            if params.contains_key(&param_name) && !compat_mode {
269✔
240
                return Err(XacroError::DuplicateParamDeclaration { param: param_name });
9✔
241
            }
260✔
242
            // In compat mode, silently overwrite (last declaration wins)
243

244
            // Insert into appropriate data structures
245
            // Only add to param_order if not already present (handles compat mode duplicates)
246
            if !params.contains_key(&param_name) {
260✔
247
                param_order.push(param_name.clone());
248✔
248
            }
248✔
249
            if is_block {
260✔
250
                block_params.insert(param_name.clone());
69✔
251
                if is_lazy {
69✔
252
                    lazy_block_params.insert(param_name.clone());
9✔
253
                } else {
60✔
254
                    // Regular block, remove from lazy set if previously there
60✔
255
                    lazy_block_params.remove(&param_name);
60✔
256
                }
60✔
257
                params.insert(param_name, ParamDefault::None);
69✔
258
            } else {
259
                // In compat mode, if changing from block to non-block, remove from block_params and lazy_block_params
260
                if compat_mode {
191✔
261
                    block_params.remove(&param_name);
21✔
262
                    lazy_block_params.remove(&param_name);
21✔
263
                }
170✔
264
                params.insert(param_name, param_default);
191✔
265
            }
266
        }
267

268
        Ok((params, param_order, block_params, lazy_block_params))
228✔
269
    }
246✔
270

271
    pub fn collect_macro_args(
207✔
272
        element: &Element,
207✔
273
        macro_def: &MacroDefinition,
207✔
274
    ) -> Result<CollectedArgs, XacroError> {
207✔
275
        let mut param_values = HashMap::new();
207✔
276
        let mut block_values = HashMap::new();
207✔
277

278
        // Extract regular parameters from attributes
279
        // Reject namespaced attributes - macro parameters must be local names only
280
        for (name, value) in &element.attributes {
293✔
281
            let local_name = &name.local_name;
88✔
282

283
            // Reject namespaced attributes on macro calls (Python xacro behavior)
284
            if let Some(prefix) = &name.prefix {
88✔
285
                return Err(XacroError::InvalidMacroParameter {
1✔
286
                    param: format!("{}:{}", prefix, name.local_name),
1✔
287
                    reason: "Macro parameters cannot have namespace prefixes".to_string(),
1✔
288
                });
1✔
289
            }
87✔
290

291
            if macro_def.block_params.contains(local_name) {
87✔
292
                // Block parameters cannot be specified as attributes
293
                return Err(XacroError::BlockParameterAttributeCollision {
1✔
294
                    param: local_name.clone(),
1✔
295
                });
1✔
296
            }
86✔
297
            param_values.insert(local_name.clone(), value.clone());
86✔
298
        }
299

300
        // Extract block parameters from child elements IN ORDER
301
        // Use iterator to avoid double-cloning (Vec allocation + insertion)
302
        let mut children_iter = element
205✔
303
            .children
205✔
304
            .iter()
205✔
305
            .filter_map(xmltree::XMLNode::as_element);
205✔
306

307
        log::debug!(
205✔
308
            "collect_macro_args: macro '{}' has {} block params, {} lazy",
×
309
            macro_def.name,
310
            macro_def.block_params.len(),
×
311
            macro_def.lazy_block_params.len()
×
312
        );
313
        log::debug!(
205✔
314
            "collect_macro_args: macro call has {} child elements",
×
315
            element
×
316
                .children
×
317
                .iter()
×
318
                .filter_map(|n| n.as_element())
×
319
                .count()
×
320
        );
321

322
        // Iterate through params in order they were declared
323
        // Block params consume child elements sequentially from the iterator
324
        for param_name in &macro_def.param_order {
399✔
325
            if macro_def.block_params.contains(param_name) {
198✔
326
                let child_element =
49✔
327
                    children_iter
53✔
328
                        .next()
53✔
329
                        .ok_or_else(|| XacroError::MissingBlockParameter {
53✔
330
                            macro_name: macro_def.name.clone(),
4✔
331
                            param: param_name.clone(),
4✔
332
                        })?;
4✔
333
                log::debug!(
49✔
334
                    "collect_macro_args: captured block param '{}' <- element '<{}>...'",
×
335
                    param_name,
336
                    child_element.name
337
                );
338
                block_values.insert(param_name.clone(), child_element.clone());
49✔
339
            }
145✔
340
        }
341

342
        // Error if extra children provided
343
        if children_iter.next().is_some() {
201✔
344
            let extra_count = 1 + children_iter.count();
2✔
345
            return Err(XacroError::UnusedBlock {
2✔
346
                macro_name: macro_def.name.clone(),
2✔
347
                extra_count,
2✔
348
            });
2✔
349
        }
199✔
350

351
        Ok((param_values, block_values))
199✔
352
    }
207✔
353
}
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