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

kaidokert / xacro / 20887313049

11 Jan 2026 01:19AM UTC coverage: 88.136%. First build
20887313049

Pull #41

github

web-flow
Merge c42b02178 into 7d7a61865
Pull Request #41: Preserve processing instructions outside root element

191 of 212 new or added lines in 3 files covered. (90.09%)

2511 of 2849 relevant lines covered (88.14%)

185.53 hits per line

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

89.39
/src/utils/document.rs
1
//! XacroDocument - Preserves processing instructions and comments outside root element
2
//!
3

4
use crate::error::XacroError;
5
use std::io::Write;
6
use xmltree::{Element, XMLNode};
7

8
/// Represents a complete xacro document with preamble and root element
9
///
10
/// The preamble contains all nodes that appear before the root element:
11
/// - Processing instructions (<?xml-model?>, <?xml-stylesheet?>, etc.)
12
/// - Comments
13
///
14
/// This structure allows xacro to be "transparent" - preserving all
15
/// document-level constructs while only processing xacro-namespaced elements.
16
///
17
/// # Note on DOCTYPE
18
///
19
/// DOCTYPE declarations are NOT preserved because xmltree silently discards them
20
/// This is accepted as a known limitation.
21
///
22
/// # Example
23
///
24
/// ```rust,ignore
25
/// let xml = r#"<?xml version="1.0"?>
26
/// <?xml-model href="schema.xsd"?>
27
/// <!-- User comment -->
28
/// <robot name="test"/>"#;
29
///
30
/// let doc = XacroDocument::parse(xml.as_bytes())?;
31
/// // doc.preamble contains the PI and comment
32
/// // doc.root is the <robot> element
33
/// ```
34
#[derive(Debug)]
35
pub struct XacroDocument {
36
    /// Nodes that appear before the root element (PIs, comments)
37
    /// Preserves order to maintain document structure
38
    pub preamble: Vec<XMLNode>,
39

40
    /// The root XML element (contains xacro directives to process)
41
    pub root: Element,
42
}
43

44
impl XacroDocument {
45
    /// Parse an XML document preserving preamble nodes
46
    ///
47
    /// Uses `Element::parse_all()` to capture all nodes before the root element.
48
    /// The first Element node becomes the root; all non-element nodes before it
49
    /// go into the preamble.
50
    ///
51
    /// # Errors
52
    ///
53
    /// Returns error if:
54
    /// - XML is malformed
55
    /// - No root element found
56
    /// - Multiple root elements found
57
    ///
58
    /// # Example
59
    ///
60
    /// ```rust,ignore
61
    /// let xml = r#"<?xml version="1.0"?>
62
    /// <?xml-model href="schema.xsd"?>
63
    /// <robot name="test"/>"#;
64
    ///
65
    /// let doc = XacroDocument::parse(xml.as_bytes())?;
66
    /// assert_eq!(doc.preamble.len(), 1); // One PI
67
    /// assert_eq!(doc.root.name, "robot");
68
    /// ```
69
    pub fn parse<R: std::io::Read>(reader: R) -> Result<Self, XacroError> {
261✔
70
        let all_nodes = Element::parse_all(reader)?;
261✔
71

72
        let mut preamble = Vec::new();
259✔
73
        let mut root = None;
259✔
74

75
        for node in all_nodes {
540✔
76
            match node {
282✔
77
                XMLNode::Element(elem) => {
260✔
78
                    // First element is the root
79
                    if root.is_none() {
260✔
80
                        root = Some(elem);
259✔
81
                    } else {
259✔
82
                        // Multiple root elements - invalid XML
83
                        return Err(XacroError::InvalidXml(
1✔
84
                            "Document has multiple root elements".into(),
1✔
85
                        ));
1✔
86
                    }
87
                }
88
                // All other non-element nodes
89
                node => {
22✔
90
                    if root.is_none() {
22✔
91
                        // Nodes before root go in preamble (PIs, comments, text)
22✔
92
                        // CDATA before root is invalid per XML spec, but xmltree rejects it at parse time
22✔
93
                        preamble.push(node);
22✔
94
                    } else {
22✔
95
                        // Nodes after root - warn if significant
NEW
96
                        match &node {
×
NEW
97
                            XMLNode::Text(text) if !text.trim().is_empty() => {
×
NEW
98
                                log::warn!(
×
NEW
99
                                    "Non-whitespace text found after root element: {:?}",
×
NEW
100
                                    text.trim()
×
101
                                );
102
                            }
NEW
103
                            XMLNode::Text(_) => { /* ignore whitespace */ }
×
104
                            XMLNode::CData(_) => {
NEW
105
                                log::warn!("CDATA section found after root element, discarding");
×
106
                            }
107
                            _ => {
NEW
108
                                log::warn!("Unexpected node after root element: {:?}", node);
×
109
                            }
110
                        }
111
                    }
112
                }
113
            }
114
        }
115

116
        let root =
258✔
117
            root.ok_or_else(|| XacroError::InvalidXml("Document has no root element".into()))?;
258✔
118

119
        Ok(XacroDocument { preamble, root })
258✔
120
    }
261✔
121

122
    /// Write the document with preamble preserved
123
    ///
124
    /// Output format:
125
    /// 1. XML declaration: `<?xml version="1.0" ?>`
126
    /// 2. Preamble nodes (PIs, comments) in order
127
    /// 3. Root element
128
    ///
129
    /// # Errors
130
    ///
131
    /// Returns error if:
132
    /// - I/O write fails
133
    /// - XML serialization fails
134
    ///
135
    /// # Example
136
    ///
137
    /// ```rust,ignore
138
    /// let doc = XacroDocument::parse(xml.as_bytes())?;
139
    /// let mut output = Vec::new();
140
    /// doc.write(&mut output)?;
141
    /// let output_str = String::from_utf8(output)?;
142
    /// ```
143
    pub fn write<W: Write>(
206✔
144
        &self,
206✔
145
        writer: &mut W,
206✔
146
    ) -> Result<(), XacroError> {
206✔
147
        // 1. Write XML declaration
148
        writeln!(writer, "<?xml version=\"1.0\" ?>")?;
206✔
149

150
        // 2. Write preamble (PIs, comments)
151
        for node in &self.preamble {
222✔
152
            match node {
16✔
153
                XMLNode::ProcessingInstruction(target, data) => {
11✔
154
                    // Handle empty data (no trailing space)
155
                    if let Some(d) = data.as_ref().filter(|s| !s.is_empty()) {
11✔
156
                        writeln!(writer, "<?{} {}?>", target, d)?;
9✔
157
                    } else {
158
                        writeln!(writer, "<?{}?>", target)?;
2✔
159
                    }
160
                }
161
                XMLNode::Comment(comment) => {
5✔
162
                    writeln!(writer, "<!--{}-->", comment)?;
5✔
163
                }
164
                // Other node types in preamble are unusual but handle gracefully
NEW
165
                XMLNode::Text(text) => {
×
166
                    // Text outside root is usually just whitespace, preserve it
NEW
167
                    write!(writer, "{}", text)?;
×
168
                }
169
                XMLNode::CData(_) | XMLNode::Element(_) => {
170
                    // These are invalid in the preamble and cannot occur here.
171
                    // Element is always captured as root, CDATA is never added to preamble.
NEW
172
                    unreachable!(
×
173
                        "Invalid node type in preamble: CData and Element are not allowed."
174
                    );
175
                }
176
            }
177
        }
178

179
        // 3. Write root element
180
        self.root.write_with_config(
206✔
181
            writer,
206✔
182
            xmltree::EmitterConfig::new()
206✔
183
                .perform_indent(true)
206✔
184
                .write_document_declaration(false) // Already wrote it
206✔
185
                .indent_string("  ")
206✔
186
                .pad_self_closing(false), // Use <tag/> not <tag />
206✔
NEW
187
        )?;
×
188

189
        Ok(())
206✔
190
    }
206✔
191
}
192

193
#[cfg(test)]
194
mod tests {
195
    use super::*;
196

197
    #[test]
198
    fn test_parse_with_pi() {
1✔
199
        let xml = r#"<?xml version="1.0"?>
1✔
200
<?xml-model href="schema.xsd"?>
1✔
201
<robot name="test"/>"#;
1✔
202

203
        let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
1✔
204

205
        // Verify preamble captured
206
        assert_eq!(doc.preamble.len(), 1);
1✔
207
        match &doc.preamble[0] {
1✔
208
            XMLNode::ProcessingInstruction(target, data) => {
1✔
209
                assert_eq!(target, "xml-model");
1✔
210
                assert!(data.as_ref().unwrap().contains("schema.xsd"));
1✔
211
            }
NEW
212
            _ => panic!("Expected ProcessingInstruction in preamble"),
×
213
        }
214

215
        // Verify root captured
216
        assert_eq!(doc.root.name, "robot");
1✔
217
    }
1✔
218

219
    #[test]
220
    fn test_parse_no_preamble() {
1✔
221
        let xml = r#"<?xml version="1.0"?>
1✔
222
<robot name="test"/>"#;
1✔
223

224
        let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
1✔
225

226
        // Empty preamble
227
        assert!(doc.preamble.is_empty());
1✔
228

229
        // Root still parsed
230
        assert_eq!(doc.root.name, "robot");
1✔
231
    }
1✔
232

233
    #[test]
234
    fn test_parse_with_comment() {
1✔
235
        let xml = r#"<?xml version="1.0"?>
1✔
236
<!-- User comment -->
1✔
237
<robot name="test"/>"#;
1✔
238

239
        let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
1✔
240

241
        // Comment in preamble
242
        assert_eq!(doc.preamble.len(), 1);
1✔
243
        match &doc.preamble[0] {
1✔
244
            XMLNode::Comment(text) => {
1✔
245
                assert_eq!(text.trim(), "User comment");
1✔
246
            }
NEW
247
            _ => panic!("Expected Comment in preamble"),
×
248
        }
249
    }
1✔
250

251
    #[test]
252
    fn test_parse_multiple_preamble_nodes() {
1✔
253
        let xml = r#"<?xml version="1.0"?>
1✔
254
<!-- Comment 1 -->
1✔
255
<?pi1 data1?>
1✔
256
<!-- Comment 2 -->
1✔
257
<?pi2 data2?>
1✔
258
<robot name="test"/>"#;
1✔
259

260
        let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
1✔
261

262
        // Should have 4 preamble nodes
263
        assert_eq!(doc.preamble.len(), 4);
1✔
264

265
        // Verify order
266
        assert!(matches!(doc.preamble[0], XMLNode::Comment(_)));
1✔
267
        assert!(matches!(
1✔
268
            doc.preamble[1],
1✔
269
            XMLNode::ProcessingInstruction(ref t, _) if t == "pi1"
1✔
270
        ));
271
        assert!(matches!(doc.preamble[2], XMLNode::Comment(_)));
1✔
272
        assert!(matches!(
1✔
273
            doc.preamble[3],
1✔
274
            XMLNode::ProcessingInstruction(ref t, _) if t == "pi2"
1✔
275
        ));
276
    }
1✔
277

278
    #[test]
279
    fn test_parse_multiple_roots_error() {
1✔
280
        let xml = r#"<?xml version="1.0"?>
1✔
281
<robot name="test1"/>
1✔
282
<robot name="test2"/>"#;
1✔
283

284
        let result = XacroDocument::parse(xml.as_bytes());
1✔
285

286
        // Should error
287
        assert!(result.is_err());
1✔
288
        let err_msg = result.unwrap_err().to_string();
1✔
289
        assert!(
1✔
290
            err_msg.contains("multiple root"),
1✔
NEW
291
            "Error should mention multiple roots, got: {}",
×
292
            err_msg
293
        );
294
    }
1✔
295

296
    #[test]
297
    fn test_parse_no_root_error() {
1✔
298
        let xml = r#"<?xml version="1.0"?>
1✔
299
<?xml-model href="schema.xsd"?>
1✔
300
<!-- Just a comment -->"#;
1✔
301

302
        let result = XacroDocument::parse(xml.as_bytes());
1✔
303

304
        // Should error
305
        assert!(result.is_err());
1✔
306
        let err_msg = result.unwrap_err().to_string();
1✔
307
        assert!(
1✔
308
            err_msg.contains("no root"),
1✔
NEW
309
            "Error should mention no root, got: {}",
×
310
            err_msg
311
        );
312
    }
1✔
313

314
    #[test]
315
    fn test_parse_cdata_in_preamble_error() {
1✔
316
        let xml = r#"<?xml version="1.0"?>
1✔
317
<![CDATA[data before root]]>
1✔
318
<robot name="test"/>"#;
1✔
319

320
        let result = XacroDocument::parse(xml.as_bytes());
1✔
321

322
        // Should error - CDATA not allowed outside root element (xmltree rejects at parse time)
323
        assert!(result.is_err(), "CDATA before root should be rejected");
1✔
324
    }
1✔
325

326
    #[test]
327
    fn test_write_preserves_pi() {
1✔
328
        let xml = r#"<?xml version="1.0"?>
1✔
329
<?xml-model href="schema.xsd"?>
1✔
330
<robot name="test"/>"#;
1✔
331

332
        let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
1✔
333

334
        let mut output = Vec::new();
1✔
335
        doc.write(&mut output).unwrap();
1✔
336
        let output_str = String::from_utf8(output).unwrap();
1✔
337

338
        // PI preserved in output
339
        assert!(
1✔
340
            output_str.contains(r#"<?xml-model href="schema.xsd"?>"#),
1✔
NEW
341
            "PI should be preserved in output"
×
342
        );
343
        assert!(
1✔
344
            output_str.contains(r#"<robot name="test""#),
1✔
NEW
345
            "Root element should be in output"
×
346
        );
347
    }
1✔
348

349
    #[test]
350
    fn test_write_preserves_order() {
1✔
351
        let xml = r#"<?xml version="1.0"?>
1✔
352
<!-- First comment -->
1✔
353
<?xml-model href="schema.xsd"?>
1✔
354
<!-- Second comment -->
1✔
355
<robot name="test"/>"#;
1✔
356

357
        let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
1✔
358

359
        let mut output = Vec::new();
1✔
360
        doc.write(&mut output).unwrap();
1✔
361
        let output_str = String::from_utf8(output).unwrap();
1✔
362

363
        // Find positions
364
        let comment1_pos = output_str
1✔
365
            .find("<!-- First comment -->")
1✔
366
            .expect("First comment should be in output");
1✔
367
        let pi_pos = output_str
1✔
368
            .find("<?xml-model")
1✔
369
            .expect("PI should be in output");
1✔
370
        let comment2_pos = output_str
1✔
371
            .find("<!-- Second comment -->")
1✔
372
            .expect("Second comment should be in output");
1✔
373

374
        // Verify order preserved
375
        assert!(comment1_pos < pi_pos, "First comment should come before PI");
1✔
376
        assert!(
1✔
377
            pi_pos < comment2_pos,
1✔
NEW
378
            "PI should come before second comment"
×
379
        );
380
    }
1✔
381

382
    #[test]
383
    fn test_write_empty_pi_data() {
1✔
384
        let xml = r#"<?xml version="1.0"?>
1✔
385
<?target?>
1✔
386
<robot name="test"/>"#;
1✔
387

388
        let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
1✔
389

390
        let mut output = Vec::new();
1✔
391
        doc.write(&mut output).unwrap();
1✔
392
        let output_str = String::from_utf8(output).unwrap();
1✔
393

394
        // Should preserve PI without extra space
395
        assert!(
1✔
396
            output_str.contains(r#"<?target?>"#),
1✔
NEW
397
            "Empty PI should not have trailing space"
×
398
        );
399
    }
1✔
400

401
    #[test]
402
    fn test_doctype_not_preserved() {
1✔
403
        let xml = r#"<?xml version="1.0"?>
1✔
404
<!DOCTYPE robot SYSTEM "robot.dtd">
1✔
405
<robot name="test"/>"#;
1✔
406

407
        // DOCTYPE is silently discarded by xmltree (known limitation)
408
        let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
1✔
409
        let mut output = Vec::new();
1✔
410
        doc.write(&mut output).unwrap();
1✔
411
        let output_str = String::from_utf8(output).unwrap();
1✔
412

413
        // DOCTYPE should NOT be preserved (known xmltree limitation)
414
        assert!(
1✔
415
            !output_str.contains("<!DOCTYPE"),
1✔
NEW
416
            "DOCTYPE is a known limitation and should not be preserved"
×
417
        );
418
    }
1✔
419
}
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