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

kaidokert / xacro / 20886938563

11 Jan 2026 12:46AM UTC coverage: 88.235%. First build
20886938563

Pull #41

github

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

170 of 185 new or added lines in 3 files covered. (91.89%)

2490 of 2822 relevant lines covered (88.24%)

187.16 hits per line

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

91.23
/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> {
258✔
70
        let all_nodes = Element::parse_all(reader)?;
258✔
71

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

75
        for node in all_nodes {
536✔
76
            match node {
280✔
77
                XMLNode::Element(elem) => {
258✔
78
                    // First element is the root
79
                    if root.is_none() {
258✔
80
                        root = Some(elem);
257✔
81
                    } else {
257✔
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 non-element nodes before root go in preamble
89
                _ => {
90
                    if root.is_none() {
22✔
91
                        preamble.push(node);
22✔
92
                    }
22✔
93
                    // Note: Nodes AFTER root are discarded (invalid XML anyway)
94
                }
95
            }
96
        }
97

98
        let root =
256✔
99
            root.ok_or_else(|| XacroError::InvalidXml("Document has no root element".into()))?;
256✔
100

101
        Ok(XacroDocument { preamble, root })
256✔
102
    }
258✔
103

104
    /// Write the document with preamble preserved
105
    ///
106
    /// Output format:
107
    /// 1. XML declaration: `<?xml version="1.0" ?>`
108
    /// 2. Preamble nodes (PIs, comments) in order
109
    /// 3. Root element
110
    ///
111
    /// # Errors
112
    ///
113
    /// Returns error if:
114
    /// - I/O write fails
115
    /// - XML serialization fails
116
    ///
117
    /// # Example
118
    ///
119
    /// ```rust,ignore
120
    /// let doc = XacroDocument::parse(xml.as_bytes())?;
121
    /// let mut output = Vec::new();
122
    /// doc.write(&mut output)?;
123
    /// let output_str = String::from_utf8(output)?;
124
    /// ```
125
    pub fn write<W: Write>(
204✔
126
        &self,
204✔
127
        writer: &mut W,
204✔
128
    ) -> Result<(), XacroError> {
204✔
129
        // 1. Write XML declaration
130
        writeln!(writer, "<?xml version=\"1.0\" ?>")?;
204✔
131

132
        // 2. Write preamble (PIs, comments)
133
        for node in &self.preamble {
220✔
134
            match node {
16✔
135
                XMLNode::ProcessingInstruction(target, data) => {
11✔
136
                    // Handle empty data (no trailing space)
137
                    if let Some(d) = data.as_ref().filter(|s| !s.is_empty()) {
11✔
138
                        writeln!(writer, "<?{} {}?>", target, d)?;
9✔
139
                    } else {
140
                        writeln!(writer, "<?{}?>", target)?;
2✔
141
                    }
142
                }
143
                XMLNode::Comment(comment) => {
5✔
144
                    writeln!(writer, "<!--{}-->", comment)?;
5✔
145
                }
146
                // Other node types in preamble are unusual but handle gracefully
NEW
147
                XMLNode::Text(text) => {
×
148
                    // Text outside root is usually just whitespace, preserve it
NEW
149
                    write!(writer, "{}", text)?;
×
150
                }
NEW
151
                XMLNode::CData(_) | XMLNode::Element(_) => {
×
NEW
152
                    // CDATA or Element in preamble is invalid XML, but don't crash
×
NEW
153
                    // Element would have been captured as root, so shouldn't reach here
×
NEW
154
                }
×
155
            }
156
        }
157

158
        // 3. Write root element
159
        self.root.write_with_config(
204✔
160
            writer,
204✔
161
            xmltree::EmitterConfig::new()
204✔
162
                .perform_indent(true)
204✔
163
                .write_document_declaration(false) // Already wrote it
204✔
164
                .indent_string("  ")
204✔
165
                .pad_self_closing(false), // Use <tag/> not <tag />
204✔
NEW
166
        )?;
×
167

168
        Ok(())
204✔
169
    }
204✔
170
}
171

172
#[cfg(test)]
173
mod tests {
174
    use super::*;
175

176
    #[test]
177
    fn test_parse_with_pi() {
1✔
178
        let xml = r#"<?xml version="1.0"?>
1✔
179
<?xml-model href="schema.xsd"?>
1✔
180
<robot name="test"/>"#;
1✔
181

182
        let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
1✔
183

184
        // Verify preamble captured
185
        assert_eq!(doc.preamble.len(), 1);
1✔
186
        match &doc.preamble[0] {
1✔
187
            XMLNode::ProcessingInstruction(target, data) => {
1✔
188
                assert_eq!(target, "xml-model");
1✔
189
                assert!(data.as_ref().unwrap().contains("schema.xsd"));
1✔
190
            }
NEW
191
            _ => panic!("Expected ProcessingInstruction in preamble"),
×
192
        }
193

194
        // Verify root captured
195
        assert_eq!(doc.root.name, "robot");
1✔
196
    }
1✔
197

198
    #[test]
199
    fn test_parse_no_preamble() {
1✔
200
        let xml = r#"<?xml version="1.0"?>
1✔
201
<robot name="test"/>"#;
1✔
202

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

205
        // Empty preamble
206
        assert!(doc.preamble.is_empty());
1✔
207

208
        // Root still parsed
209
        assert_eq!(doc.root.name, "robot");
1✔
210
    }
1✔
211

212
    #[test]
213
    fn test_parse_with_comment() {
1✔
214
        let xml = r#"<?xml version="1.0"?>
1✔
215
<!-- User comment -->
1✔
216
<robot name="test"/>"#;
1✔
217

218
        let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
1✔
219

220
        // Comment in preamble
221
        assert_eq!(doc.preamble.len(), 1);
1✔
222
        match &doc.preamble[0] {
1✔
223
            XMLNode::Comment(text) => {
1✔
224
                assert_eq!(text.trim(), "User comment");
1✔
225
            }
NEW
226
            _ => panic!("Expected Comment in preamble"),
×
227
        }
228
    }
1✔
229

230
    #[test]
231
    fn test_parse_multiple_preamble_nodes() {
1✔
232
        let xml = r#"<?xml version="1.0"?>
1✔
233
<!-- Comment 1 -->
1✔
234
<?pi1 data1?>
1✔
235
<!-- Comment 2 -->
1✔
236
<?pi2 data2?>
1✔
237
<robot name="test"/>"#;
1✔
238

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

241
        // Should have 4 preamble nodes
242
        assert_eq!(doc.preamble.len(), 4);
1✔
243

244
        // Verify order
245
        assert!(matches!(doc.preamble[0], XMLNode::Comment(_)));
1✔
246
        assert!(matches!(
1✔
247
            doc.preamble[1],
1✔
248
            XMLNode::ProcessingInstruction(ref t, _) if t == "pi1"
1✔
249
        ));
250
        assert!(matches!(doc.preamble[2], XMLNode::Comment(_)));
1✔
251
        assert!(matches!(
1✔
252
            doc.preamble[3],
1✔
253
            XMLNode::ProcessingInstruction(ref t, _) if t == "pi2"
1✔
254
        ));
255
    }
1✔
256

257
    #[test]
258
    fn test_parse_multiple_roots_error() {
1✔
259
        let xml = r#"<?xml version="1.0"?>
1✔
260
<robot name="test1"/>
1✔
261
<robot name="test2"/>"#;
1✔
262

263
        let result = XacroDocument::parse(xml.as_bytes());
1✔
264

265
        // Should error
266
        assert!(result.is_err());
1✔
267
        let err_msg = result.unwrap_err().to_string();
1✔
268
        assert!(
1✔
269
            err_msg.contains("multiple root"),
1✔
NEW
270
            "Error should mention multiple roots, got: {}",
×
271
            err_msg
272
        );
273
    }
1✔
274

275
    #[test]
276
    fn test_parse_no_root_error() {
1✔
277
        let xml = r#"<?xml version="1.0"?>
1✔
278
<?xml-model href="schema.xsd"?>
1✔
279
<!-- Just a comment -->"#;
1✔
280

281
        let result = XacroDocument::parse(xml.as_bytes());
1✔
282

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

293
    #[test]
294
    fn test_write_preserves_pi() {
1✔
295
        let xml = r#"<?xml version="1.0"?>
1✔
296
<?xml-model href="schema.xsd"?>
1✔
297
<robot name="test"/>"#;
1✔
298

299
        let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
1✔
300

301
        let mut output = Vec::new();
1✔
302
        doc.write(&mut output).unwrap();
1✔
303
        let output_str = String::from_utf8(output).unwrap();
1✔
304

305
        // PI preserved in output
306
        assert!(
1✔
307
            output_str.contains(r#"<?xml-model href="schema.xsd"?>"#),
1✔
NEW
308
            "PI should be preserved in output"
×
309
        );
310
        assert!(
1✔
311
            output_str.contains(r#"<robot name="test""#),
1✔
NEW
312
            "Root element should be in output"
×
313
        );
314
    }
1✔
315

316
    #[test]
317
    fn test_write_preserves_order() {
1✔
318
        let xml = r#"<?xml version="1.0"?>
1✔
319
<!-- First comment -->
1✔
320
<?xml-model href="schema.xsd"?>
1✔
321
<!-- Second comment -->
1✔
322
<robot name="test"/>"#;
1✔
323

324
        let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
1✔
325

326
        let mut output = Vec::new();
1✔
327
        doc.write(&mut output).unwrap();
1✔
328
        let output_str = String::from_utf8(output).unwrap();
1✔
329

330
        // Find positions
331
        let comment1_pos = output_str
1✔
332
            .find("<!-- First comment -->")
1✔
333
            .expect("First comment should be in output");
1✔
334
        let pi_pos = output_str
1✔
335
            .find("<?xml-model")
1✔
336
            .expect("PI should be in output");
1✔
337
        let comment2_pos = output_str
1✔
338
            .find("<!-- Second comment -->")
1✔
339
            .expect("Second comment should be in output");
1✔
340

341
        // Verify order preserved
342
        assert!(comment1_pos < pi_pos, "First comment should come before PI");
1✔
343
        assert!(
1✔
344
            pi_pos < comment2_pos,
1✔
NEW
345
            "PI should come before second comment"
×
346
        );
347
    }
1✔
348

349
    #[test]
350
    fn test_write_empty_pi_data() {
1✔
351
        let xml = r#"<?xml version="1.0"?>
1✔
352
<?target?>
1✔
353
<robot name="test"/>"#;
1✔
354

355
        let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
1✔
356

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

361
        // Should preserve PI without extra space
362
        assert!(
1✔
363
            output_str.contains(r#"<?target?>"#),
1✔
NEW
364
            "Empty PI should not have trailing space"
×
365
        );
366
    }
1✔
367
}
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