use crate::error::XacroError;
use std::io::Write;
use xml::escape::escape_str_pcdata;
use xmltree::{Element, XMLNode};
pub(super) const XACRO_NAMESPACE: &str = "http://www.ros.org/wiki/xacro";
pub(super) const KNOWN_XACRO_URIS: &[&str] = &[
"http://www.ros.org/wiki/xacro",
"http://ros.org/wiki/xacro",
"http://wiki.ros.org/xacro",
"http://www.ros.org/xacro",
"http://playerstage.sourceforge.net/gazebo/xmlschema/#xacro",
"https://ros.org/wiki/xacro",
];
pub(super) fn find_xacro_namespace_in_map(ns: &xmltree::Namespace) -> Option<String> {
ns.0.values()
.find(|uri| KNOWN_XACRO_URIS.contains(&uri.as_str()))
.map(|s| s.to_string())
}
pub(crate) fn is_known_xacro_uri(uri: &str) -> bool {
KNOWN_XACRO_URIS.contains(&uri)
}
pub(crate) fn extract_xacro_namespace(
element: &Element,
lenient_namespace: bool,
) -> Result<String, XacroError> {
if let Some(ns) = element.namespaces.as_ref() {
if let Some(xacro_uri) = ns.get("xacro") {
let uri_str: &str = xacro_uri;
if !lenient_namespace && !is_known_xacro_uri(uri_str) {
return Err(XacroError::MissingNamespace(format!(
"The 'xacro' prefix is bound to an unknown URI: '{}'. \
This might be a typo. Known xacro URIs are: {}",
xacro_uri,
KNOWN_XACRO_URIS.join(", ")
)));
}
return Ok(xacro_uri.to_string());
}
if let Some(uri) = find_xacro_namespace_in_map(ns) {
return Ok(uri);
}
}
Ok(String::new())
}
pub(crate) fn is_xacro_element(
element: &Element,
tag_name: &str,
xacro_ns: &str,
) -> bool {
element.name == tag_name
&& !xacro_ns.is_empty()
&& element
.namespace
.as_deref()
.is_some_and(|ns| ns == xacro_ns || is_known_xacro_uri(ns))
}
pub(crate) fn serialize_nodes(nodes: &[XMLNode]) -> Result<String, XacroError> {
let mut buffer = Vec::new();
for node in nodes {
match node {
XMLNode::Element(elem) => {
elem.write_with_config(
&mut buffer,
xmltree::EmitterConfig::new()
.perform_indent(false) .write_document_declaration(false) .pad_self_closing(false), )?;
}
XMLNode::Text(text) => {
let escaped = escape_str_pcdata(text);
buffer.extend_from_slice(escaped.as_bytes());
}
XMLNode::Comment(comment) => {
if comment.contains("--") || comment.ends_with('-') {
return Err(XacroError::InvalidXml(
"Comments cannot contain '--' or end with '-'".into(),
));
}
write!(&mut buffer, "<!--{}-->", comment)?;
}
XMLNode::CData(data) => {
if data.contains("]]>") {
return Err(XacroError::InvalidXml(
"CDATA sections cannot contain ']]>'".into(),
));
}
write!(&mut buffer, "<![CDATA[{}]]>", data)?;
}
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(),
));
}
write!(&mut buffer, "<?{} {}?>", target, d)?;
} else {
write!(&mut buffer, "<?{}?>", target)?;
}
}
}
}
String::from_utf8(buffer).map_err(XacroError::Utf8)
}
pub(crate) fn parse_xml_fragment(fragment: &str) -> Result<Vec<XMLNode>, XacroError> {
let trimmed = fragment.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
let wrapped = format!(
r#"<xacro_dummy_root xmlns:xacro="{}">{}</xacro_dummy_root>"#,
XACRO_NAMESPACE, fragment
);
let root = Element::parse(wrapped.as_bytes())?;
Ok(root.children)
}
pub(crate) fn has_structural_content(nodes: &[XMLNode]) -> bool {
nodes.iter().any(|n| {
matches!(
n,
XMLNode::Element(_)
| XMLNode::Comment(_)
| XMLNode::CData(_)
| XMLNode::ProcessingInstruction(_, _)
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_serialize_nodes_text_escaping() {
let nodes = vec![
XMLNode::Text("Text with <angle> brackets".to_string()),
XMLNode::Text(" & ampersands".to_string()),
];
let serialized = serialize_nodes(&nodes).unwrap();
assert!(
serialized.contains("<"),
"Expected escaped '<', got: {}",
serialized
);
assert!(
serialized.contains(">"),
"Expected escaped '>', got: {}",
serialized
);
assert!(
serialized.contains("&"),
"Expected escaped '&', got: {}",
serialized
);
assert_eq!(
serialized,
"Text with <angle> brackets & ampersands"
);
}
#[test]
fn test_serialize_nodes_no_escaping_needed() {
let nodes = vec![XMLNode::Text("Normal text 123".to_string())];
let serialized = serialize_nodes(&nodes).unwrap();
assert_eq!(serialized, "Normal text 123");
}
#[test]
fn test_serialize_comment_invalid_double_dash() {
let nodes = vec![XMLNode::Comment("This -- is invalid".to_string())];
let result = serialize_nodes(&nodes);
assert!(result.is_err(), "Should reject comment containing '--'");
assert!(
result.unwrap_err().to_string().contains("--"),
"Error should mention '--'"
);
}
#[test]
fn test_serialize_comment_invalid_trailing_dash() {
let nodes = vec![XMLNode::Comment("Trailing dash-".to_string())];
let result = serialize_nodes(&nodes);
assert!(result.is_err(), "Should reject comment ending with '-'");
}
#[test]
fn test_serialize_comment_valid() {
let nodes = vec![XMLNode::Comment("Valid comment".to_string())];
let serialized = serialize_nodes(&nodes).unwrap();
assert_eq!(serialized, "<!--Valid comment-->");
}
#[test]
fn test_serialize_cdata_invalid() {
let nodes = vec![XMLNode::CData("Invalid ]]> sequence".to_string())];
let result = serialize_nodes(&nodes);
assert!(result.is_err(), "Should reject CDATA containing ']]>'");
assert!(
result.unwrap_err().to_string().contains("]]>"),
"Error should mention ']]>'"
);
}
#[test]
fn test_serialize_cdata_valid() {
let nodes = vec![XMLNode::CData("Valid <raw> content".to_string())];
let serialized = serialize_nodes(&nodes).unwrap();
assert_eq!(serialized, "<![CDATA[Valid <raw> content]]>");
}
#[test]
fn test_serialize_pi_invalid_target_xml() {
let nodes = vec![XMLNode::ProcessingInstruction(
"xml".to_string(),
Some("encoding=\"UTF-8\"".to_string()),
)];
let result = serialize_nodes(&nodes);
assert!(result.is_err(), "Should reject PI target 'xml' (reserved)");
assert!(
result.unwrap_err().to_string().contains("xml"),
"Error should mention 'xml'"
);
}
#[test]
fn test_serialize_pi_invalid_target_xml_case_insensitive() {
let nodes = vec![XMLNode::ProcessingInstruction(
"XmL".to_string(),
Some("data".to_string()),
)];
let result = serialize_nodes(&nodes);
assert!(
result.is_err(),
"Should reject PI target 'XmL' (case-insensitive)"
);
}
#[test]
fn test_serialize_pi_invalid_data() {
let nodes = vec![XMLNode::ProcessingInstruction(
"target".to_string(),
Some("Invalid ?> sequence".to_string()),
)];
let result = serialize_nodes(&nodes);
assert!(result.is_err(), "Should reject PI data containing '?>'");
assert!(
result.unwrap_err().to_string().contains("?>"),
"Error should mention '?>'"
);
}
#[test]
fn test_serialize_pi_valid_with_data() {
let nodes = vec![XMLNode::ProcessingInstruction(
"target".to_string(),
Some("instruction data".to_string()),
)];
let serialized = serialize_nodes(&nodes).unwrap();
assert_eq!(serialized, "<?target instruction data?>");
}
#[test]
fn test_serialize_pi_valid_no_data() {
let nodes = vec![XMLNode::ProcessingInstruction("target".to_string(), None)];
let serialized = serialize_nodes(&nodes).unwrap();
assert_eq!(serialized, "<?target?>");
}
#[test]
fn test_serialize_pi_empty_data_treated_as_none() {
let nodes = vec![XMLNode::ProcessingInstruction(
"target".to_string(),
Some("".to_string()),
)];
let serialized = serialize_nodes(&nodes).unwrap();
assert_eq!(serialized, "<?target?>");
}
#[test]
fn test_parse_xml_fragment_empty() {
let result = parse_xml_fragment("").unwrap();
assert!(result.is_empty(), "Empty string should return empty vec");
}
#[test]
fn test_parse_xml_fragment_whitespace_only() {
let result = parse_xml_fragment(" \n\t ").unwrap();
assert!(
result.is_empty(),
"Whitespace-only string should return empty vec"
);
}
#[test]
fn test_parse_xml_fragment_single_element() {
let result = parse_xml_fragment("<link name=\"test\"/>").unwrap();
assert_eq!(result.len(), 1, "Should parse single element");
let elem = match &result[0] {
XMLNode::Element(e) => e,
_ => panic!("Expected element node"),
};
assert_eq!(elem.name, "link");
assert_eq!(
elem.attributes.get(&xmltree::AttributeName::local("name")),
Some(&"test".to_string())
);
}
#[test]
fn test_parse_xml_fragment_multiple_elements() {
let result = parse_xml_fragment("<link name=\"a\"/><link name=\"b\"/>").unwrap();
assert_eq!(result.len(), 2, "Should parse multiple elements");
let elem1 = match &result[0] {
XMLNode::Element(e) => e,
_ => panic!("Expected element node"),
};
let elem2 = match &result[1] {
XMLNode::Element(e) => e,
_ => panic!("Expected element node"),
};
assert_eq!(
elem1.attributes.get(&xmltree::AttributeName::local("name")),
Some(&"a".to_string())
);
assert_eq!(
elem2.attributes.get(&xmltree::AttributeName::local("name")),
Some(&"b".to_string())
);
}
#[test]
fn test_parse_xml_fragment_mixed_content() {
let result = parse_xml_fragment("Text before<elem/>Text after").unwrap();
assert_eq!(result.len(), 3, "Should parse mixed content");
match &result[0] {
XMLNode::Text(t) => assert_eq!(t, "Text before"),
_ => panic!("Expected text node"),
}
match &result[1] {
XMLNode::Element(e) => assert_eq!(e.name, "elem"),
_ => panic!("Expected element node"),
}
match &result[2] {
XMLNode::Text(t) => assert_eq!(t, "Text after"),
_ => panic!("Expected text node"),
}
}
#[test]
fn test_parse_xml_fragment_with_comment() {
let result = parse_xml_fragment("<!-- comment --><elem/>").unwrap();
assert_eq!(result.len(), 2, "Should parse comment and element");
match &result[0] {
XMLNode::Comment(c) => assert_eq!(c, " comment "),
_ => panic!("Expected comment node"),
}
match &result[1] {
XMLNode::Element(e) => assert_eq!(e.name, "elem"),
_ => panic!("Expected element node"),
}
}
#[test]
fn test_parse_xml_fragment_with_cdata() {
let result = parse_xml_fragment("<![CDATA[raw content]]><elem/>").unwrap();
assert_eq!(result.len(), 2, "Should parse CDATA and element");
match &result[0] {
XMLNode::CData(d) => assert_eq!(d, "raw content"),
_ => panic!("Expected CDATA node"),
}
match &result[1] {
XMLNode::Element(e) => assert_eq!(e.name, "elem"),
_ => panic!("Expected element node"),
}
}
#[test]
fn test_parse_xml_fragment_with_pi() {
let result = parse_xml_fragment("<?target data?><elem/>").unwrap();
assert_eq!(result.len(), 2, "Should parse PI and element");
match &result[0] {
XMLNode::ProcessingInstruction(t, d) => {
assert_eq!(t, "target");
assert_eq!(d.as_deref(), Some("data"));
}
_ => panic!("Expected PI node"),
}
match &result[1] {
XMLNode::Element(e) => assert_eq!(e.name, "elem"),
_ => panic!("Expected element node"),
}
}
#[test]
fn test_parse_xml_fragment_preserves_xacro_directives() {
let result =
parse_xml_fragment("<xacro:if value=\"true\"><link name=\"test\"/></xacro:if>")
.unwrap();
assert_eq!(result.len(), 1, "Should parse xacro directive");
let elem = match &result[0] {
XMLNode::Element(e) => e,
_ => panic!("Expected element node"),
};
assert_eq!(elem.name, "if");
assert_eq!(
elem.namespace.as_deref(),
Some(XACRO_NAMESPACE),
"Should preserve xacro namespace"
);
}
}