use crate::error::XacroError;
use std::io::Write;
use xmltree::{Element, XMLNode};
#[derive(Debug)]
pub(crate) struct XacroDocument {
pub preamble: Vec<XMLNode>,
pub root: Element,
}
impl XacroDocument {
pub fn parse<R: std::io::Read>(reader: R) -> Result<Self, XacroError> {
let all_nodes = Element::parse_all(reader)?;
let mut preamble = Vec::new();
let mut root = None;
for node in all_nodes {
match node {
XMLNode::Element(elem) => {
if root.is_none() {
root = Some(elem);
} else {
return Err(XacroError::InvalidXml(
"Document has multiple root elements".into(),
));
}
}
node => {
if root.is_none() {
preamble.push(node);
} else {
match &node {
XMLNode::Text(text) if !text.trim().is_empty() => {
log::warn!(
"Non-whitespace text found after root element: {:?}",
text.trim()
);
}
XMLNode::Text(_) => { }
XMLNode::CData(_) => {
log::warn!("CDATA section found after root element, discarding");
}
_ => {
log::warn!("Unexpected node after root element: {:?}", node);
}
}
}
}
}
}
let root =
root.ok_or_else(|| XacroError::InvalidXml("Document has no root element".into()))?;
Ok(XacroDocument { preamble, root })
}
pub fn write<W: Write>(
&self,
writer: &mut W,
) -> Result<(), XacroError> {
writeln!(writer, "<?xml version=\"1.0\" ?>")?;
for node in &self.preamble {
match node {
XMLNode::ProcessingInstruction(target, data) => {
if target.eq_ignore_ascii_case("xml") {
return Err(XacroError::InvalidXml(
"Processing instruction target cannot be 'xml' (reserved)".into(),
));
}
if let Some(d) = data.as_ref().filter(|s| !s.is_empty()) {
if d.contains("?>") {
return Err(XacroError::InvalidXml(
"Processing instruction data cannot contain '?>'".into(),
));
}
writeln!(writer, "<?{} {}?>", target, d)?;
} else {
writeln!(writer, "<?{}?>", target)?;
}
}
XMLNode::Comment(comment) => {
if comment.contains("--") || comment.ends_with('-') {
return Err(XacroError::InvalidXml(
"Comments cannot contain '--' or end with '-'".into(),
));
}
writeln!(writer, "<!--{}-->", comment)?;
}
XMLNode::Text(text) => {
write!(writer, "{}", text)?;
}
XMLNode::CData(_) | XMLNode::Element(_) => {
unreachable!(
"Invalid node type in preamble: CData and Element are not allowed."
);
}
}
}
self.root.write_with_config(
writer,
xmltree::EmitterConfig::new()
.perform_indent(true)
.write_document_declaration(false) .indent_string(" ")
.pad_self_closing(false), )?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_with_pi() {
let xml = r#"<?xml version="1.0"?>
<?xml-model href="schema.xsd"?>
<robot name="test"/>"#;
let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
assert_eq!(doc.preamble.len(), 1);
match &doc.preamble[0] {
XMLNode::ProcessingInstruction(target, data) => {
assert_eq!(target, "xml-model");
assert!(data.as_ref().unwrap().contains("schema.xsd"));
}
_ => panic!("Expected ProcessingInstruction in preamble"),
}
assert_eq!(doc.root.name, "robot");
}
#[test]
fn test_parse_no_preamble() {
let xml = r#"<?xml version="1.0"?>
<robot name="test"/>"#;
let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
assert!(doc.preamble.is_empty());
assert_eq!(doc.root.name, "robot");
}
#[test]
fn test_parse_with_comment() {
let xml = r#"<?xml version="1.0"?>
<!-- User comment -->
<robot name="test"/>"#;
let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
assert_eq!(doc.preamble.len(), 1);
match &doc.preamble[0] {
XMLNode::Comment(text) => {
assert_eq!(text.trim(), "User comment");
}
_ => panic!("Expected Comment in preamble"),
}
}
#[test]
fn test_parse_multiple_preamble_nodes() {
let xml = r#"<?xml version="1.0"?>
<!-- Comment 1 -->
<?pi1 data1?>
<!-- Comment 2 -->
<?pi2 data2?>
<robot name="test"/>"#;
let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
assert_eq!(doc.preamble.len(), 4);
assert!(matches!(doc.preamble[0], XMLNode::Comment(_)));
assert!(matches!(
doc.preamble[1],
XMLNode::ProcessingInstruction(ref t, _) if t == "pi1"
));
assert!(matches!(doc.preamble[2], XMLNode::Comment(_)));
assert!(matches!(
doc.preamble[3],
XMLNode::ProcessingInstruction(ref t, _) if t == "pi2"
));
}
#[test]
fn test_parse_multiple_roots_error() {
let xml = r#"<?xml version="1.0"?>
<robot name="test1"/>
<robot name="test2"/>"#;
let result = XacroDocument::parse(xml.as_bytes());
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("multiple root"),
"Error should mention multiple roots, got: {}",
err_msg
);
}
#[test]
fn test_parse_no_root_error() {
let xml = r#"<?xml version="1.0"?>
<?xml-model href="schema.xsd"?>
<!-- Just a comment -->"#;
let result = XacroDocument::parse(xml.as_bytes());
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("no root"),
"Error should mention no root, got: {}",
err_msg
);
}
#[test]
fn test_parse_cdata_in_preamble_error() {
let xml = r#"<?xml version="1.0"?>
<![CDATA[data before root]]>
<robot name="test"/>"#;
let result = XacroDocument::parse(xml.as_bytes());
assert!(result.is_err(), "CDATA before root should be rejected");
}
#[test]
fn test_write_preserves_pi() {
let xml = r#"<?xml version="1.0"?>
<?xml-model href="schema.xsd"?>
<robot name="test"/>"#;
let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
let mut output = Vec::new();
doc.write(&mut output).unwrap();
let output_str = String::from_utf8(output).unwrap();
assert!(
output_str.contains(r#"<?xml-model href="schema.xsd"?>"#),
"PI should be preserved in output"
);
assert!(
output_str.contains(r#"<robot name="test""#),
"Root element should be in output"
);
}
#[test]
fn test_write_preserves_order() {
let xml = r#"<?xml version="1.0"?>
<!-- First comment -->
<?xml-model href="schema.xsd"?>
<!-- Second comment -->
<robot name="test"/>"#;
let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
let mut output = Vec::new();
doc.write(&mut output).unwrap();
let output_str = String::from_utf8(output).unwrap();
let comment1_pos = output_str
.find("<!-- First comment -->")
.expect("First comment should be in output");
let pi_pos = output_str
.find("<?xml-model")
.expect("PI should be in output");
let comment2_pos = output_str
.find("<!-- Second comment -->")
.expect("Second comment should be in output");
assert!(comment1_pos < pi_pos, "First comment should come before PI");
assert!(
pi_pos < comment2_pos,
"PI should come before second comment"
);
}
#[test]
fn test_write_empty_pi_data() {
let xml = r#"<?xml version="1.0"?>
<?target?>
<robot name="test"/>"#;
let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
let mut output = Vec::new();
doc.write(&mut output).unwrap();
let output_str = String::from_utf8(output).unwrap();
assert!(
output_str.contains(r#"<?target?>"#),
"Empty PI should not have trailing space"
);
}
#[test]
fn test_write_invalid_pi_target_xml() {
let mut doc = XacroDocument {
preamble: vec![XMLNode::ProcessingInstruction(
"xml".to_string(),
Some("data=\"invalid\"".to_string()),
)],
root: xmltree::Element::new("robot"),
};
doc.root
.attributes
.insert(xmltree::AttributeName::local("name"), "test".to_string());
let mut output = Vec::new();
let result = doc.write(&mut output);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Processing instruction target cannot be 'xml'"));
}
#[test]
fn test_write_invalid_pi_data_contains_close() {
let mut doc = XacroDocument {
preamble: vec![XMLNode::ProcessingInstruction(
"target".to_string(),
Some("data with ?> inside".to_string()),
)],
root: xmltree::Element::new("robot"),
};
doc.root
.attributes
.insert(xmltree::AttributeName::local("name"), "test".to_string());
let mut output = Vec::new();
let result = doc.write(&mut output);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Processing instruction data cannot contain '?>'"));
}
#[test]
fn test_write_invalid_comment_contains_double_dash() {
let mut doc = XacroDocument {
preamble: vec![XMLNode::Comment(" Invalid -- comment ".to_string())],
root: xmltree::Element::new("robot"),
};
doc.root
.attributes
.insert(xmltree::AttributeName::local("name"), "test".to_string());
let mut output = Vec::new();
let result = doc.write(&mut output);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Comments cannot contain '--'"));
}
#[test]
fn test_write_invalid_comment_ends_with_dash() {
let mut doc = XacroDocument {
preamble: vec![XMLNode::Comment(" Invalid comment-".to_string())],
root: xmltree::Element::new("robot"),
};
doc.root
.attributes
.insert(xmltree::AttributeName::local("name"), "test".to_string());
let mut output = Vec::new();
let result = doc.write(&mut output);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Comments cannot contain '--' or end with '-'"));
}
#[test]
fn test_doctype_not_preserved() {
let xml = r#"<?xml version="1.0"?>
<!DOCTYPE robot SYSTEM "robot.dtd">
<robot name="test"/>"#;
let doc = XacroDocument::parse(xml.as_bytes()).unwrap();
let mut output = Vec::new();
doc.write(&mut output).unwrap();
let output_str = String::from_utf8(output).unwrap();
assert!(
!output_str.contains("<!DOCTYPE"),
"DOCTYPE is a known limitation and should not be preserved"
);
}
}