From 338592f2765b60df40509920b1ebd97b076f55c7 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Mon, 22 Jun 2026 03:33:31 +0900 Subject: feat(php-shim): add DOMDocument/DOMNode XML shim Implements a minimal, PHP-compatible subset of \DOMDocument, \DOMNode and \DOMNodeList for use by Symfony's XmlDescriptor. save_xml mirrors libxml2's formatOutput serialization (XML declaration, 2-space indent, self-closing empty elements, and no pretty-printing of text-bearing elements) and writes to a std::io::Write sink. Verified byte-for-byte against real PHP libxml. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/shirabe-php-shim/src/lib.rs | 2 + crates/shirabe-php-shim/src/xml.rs | 431 +++++++++++++++++++++++++++++++++++++ 2 files changed, 433 insertions(+) create mode 100644 crates/shirabe-php-shim/src/xml.rs diff --git a/crates/shirabe-php-shim/src/lib.rs b/crates/shirabe-php-shim/src/lib.rs index 0d7f8ef..10e89fe 100644 --- a/crates/shirabe-php-shim/src/lib.rs +++ b/crates/shirabe-php-shim/src/lib.rs @@ -22,6 +22,7 @@ mod stream; mod string; mod url; mod var; +mod xml; mod zip; pub use array::*; @@ -48,6 +49,7 @@ pub use stream::*; pub use string::*; pub use url::*; pub use var::*; +pub use xml::*; pub use zip::*; use indexmap::IndexMap; diff --git a/crates/shirabe-php-shim/src/xml.rs b/crates/shirabe-php-shim/src/xml.rs new file mode 100644 index 0000000..1a74edc --- /dev/null +++ b/crates/shirabe-php-shim/src/xml.rs @@ -0,0 +1,431 @@ +//! Minimal, PHP-compatible subset of the DOM classes. +//! +//! PHP DOM nodes have reference semantics and form a mutable shared graph, so each node +//! is held behind `Rc>`. `DOMDocument`, `DOMNode` and `DOMNodeList` are thin +//! handles over that graph. + +use std::cell::RefCell; +use std::io::Write; +use std::rc::{Rc, Weak}; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum NodeType { + Document, + Element, + Text, +} + +#[derive(Debug)] +struct NodeInner { + node_type: NodeType, + /// Tag name for elements; unused otherwise. + name: String, + /// Ordered attributes for elements (matches setAttribute insertion order). + attributes: Vec<(String, String)>, + /// Character data for text nodes. + value: String, + children: Vec>>, + owner_document: Weak>, + /// Document-only fields. + version: String, + encoding: String, + format_output: bool, +} + +/// A document handle. PHP \DOMDocument. +#[derive(Debug, Clone)] +pub struct DOMDocument(Rc>); + +/// A node handle (element or text). PHP \DOMNode or \DOMElement. +#[derive(Debug, Clone)] +pub struct DOMNode(Rc>); + +/// An ordered, live-ish snapshot of nodes. PHP \DOMNodeList. +#[derive(Debug, Clone)] +pub struct DOMNodeList(Vec); + +impl DOMDocument { + pub fn new(version: &str, encoding: &str) -> DOMDocument { + DOMDocument(Rc::new(RefCell::new(NodeInner { + node_type: NodeType::Document, + name: String::new(), + attributes: Vec::new(), + value: String::new(), + children: Vec::new(), + owner_document: Weak::new(), + version: version.to_string(), + encoding: encoding.to_string(), + format_output: false, + }))) + } + + /// View the document as a \DOMNode. + pub fn as_node(&self) -> DOMNode { + DOMNode(self.0.clone()) + } + + pub fn create_element(&self, name: &str) -> DOMNode { + DOMNode(Rc::new(RefCell::new(NodeInner { + node_type: NodeType::Element, + name: name.to_string(), + attributes: Vec::new(), + value: String::new(), + children: Vec::new(), + owner_document: Rc::downgrade(&self.0), + version: String::new(), + encoding: String::new(), + format_output: false, + }))) + } + + pub fn create_element_with_value(&self, name: &str, value: &str) -> DOMNode { + let element = self.create_element(name); + let text = self.create_text_node(value); + element.0.borrow_mut().children.push(text.0); + element + } + + pub fn create_text_node(&self, data: &str) -> DOMNode { + DOMNode(Rc::new(RefCell::new(NodeInner { + node_type: NodeType::Text, + name: String::new(), + attributes: Vec::new(), + value: data.to_string(), + children: Vec::new(), + owner_document: Rc::downgrade(&self.0), + version: String::new(), + encoding: String::new(), + format_output: false, + }))) + } + + pub fn append_child(&self, child: DOMNode) -> DOMNode { + self.0.borrow_mut().children.push(child.0.clone()); + child + } + + /// Deep- or shallow-copy a node so it can be inserted into this document. + pub fn import_node(&self, node: &DOMNode, deep: bool) -> DOMNode { + DOMNode(deep_clone(&node.0, &Rc::downgrade(&self.0), deep)) + } + + pub fn get_elements_by_tag_name(&self, name: &str) -> DOMNodeList { + let mut out = Vec::new(); + collect_by_tag(&self.0, name, &mut out); + DOMNodeList(out.into_iter().map(DOMNode).collect()) + } + + /// Corresponds to the PHP `$dom->formatOutput` property. + pub fn set_format_output(&self, value: bool) { + self.0.borrow_mut().format_output = value; + } + + pub fn save_xml(&self, writer: &mut W) -> std::io::Result<()> { + let doc = self.0.borrow(); + writeln!( + writer, + "", + doc.version, doc.encoding + )?; + for child in &doc.children { + serialize_node(child, 0, doc.format_output, writer)?; + writer.write_all(b"\n")?; + } + Ok(()) + } +} + +impl DOMNode { + pub fn append_child(&self, child: DOMNode) -> DOMNode { + self.0.borrow_mut().children.push(child.0.clone()); + child + } + + pub fn set_attribute(&self, name: &str, value: &str) { + let mut node = self.0.borrow_mut(); + if let Some(attr) = node.attributes.iter_mut().find(|(k, _)| k == name) { + attr.1 = value.to_string(); + } else { + node.attributes.push((name.to_string(), value.to_string())); + } + } + + pub fn child_nodes(&self) -> DOMNodeList { + DOMNodeList( + self.0 + .borrow() + .children + .iter() + .map(|c| DOMNode(c.clone())) + .collect(), + ) + } + + pub fn owner_document(&self) -> DOMDocument { + DOMDocument( + self.0 + .borrow() + .owner_document + .upgrade() + .expect("owner document has been dropped"), + ) + } + + pub fn get_elements_by_tag_name(&self, name: &str) -> DOMNodeList { + let mut out = Vec::new(); + collect_by_tag(&self.0, name, &mut out); + DOMNodeList(out.into_iter().map(DOMNode).collect()) + } +} + +impl DOMNodeList { + pub fn item(&self, index: usize) -> Option { + self.0.get(index).cloned() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn iter(&self) -> std::slice::Iter<'_, DOMNode> { + self.0.iter() + } +} + +impl IntoIterator for DOMNodeList { + type Item = DOMNode; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +fn deep_clone( + node: &Rc>, + owner: &Weak>, + deep: bool, +) -> Rc> { + let source = node.borrow(); + let children = if deep { + source + .children + .iter() + .map(|c| deep_clone(c, owner, true)) + .collect() + } else { + Vec::new() + }; + Rc::new(RefCell::new(NodeInner { + node_type: source.node_type, + name: source.name.clone(), + attributes: source.attributes.clone(), + value: source.value.clone(), + children, + owner_document: owner.clone(), + version: source.version.clone(), + encoding: source.encoding.clone(), + format_output: source.format_output, + })) +} + +fn collect_by_tag( + node: &Rc>, + name: &str, + out: &mut Vec>>, +) { + for child in &node.borrow().children { + let is_match = { + let c = child.borrow(); + c.node_type == NodeType::Element && (name == "*" || c.name == name) + }; + if is_match { + out.push(child.clone()); + } + collect_by_tag(child, name, out); + } +} + +fn serialize_node( + node: &Rc>, + depth: usize, + format: bool, + out: &mut W, +) -> std::io::Result<()> { + let node = node.borrow(); + match node.node_type { + NodeType::Text => escape_text(&node.value, out)?, + NodeType::Element => { + out.write_all(b"<")?; + out.write_all(node.name.as_bytes())?; + for (key, value) in &node.attributes { + out.write_all(b" ")?; + out.write_all(key.as_bytes())?; + out.write_all(b"=\"")?; + escape_attribute(value, out)?; + out.write_all(b"\"")?; + } + if node.children.is_empty() { + out.write_all(b"/>")?; + return Ok(()); + } + out.write_all(b">")?; + // libxml only pretty-prints an element whose children are all non-text. + let has_text_child = node + .children + .iter() + .any(|c| c.borrow().node_type == NodeType::Text); + if format && !has_text_child { + for child in &node.children { + out.write_all(b"\n")?; + write_indent(out, depth + 1)?; + serialize_node(child, depth + 1, format, out)?; + } + out.write_all(b"\n")?; + write_indent(out, depth)?; + } else { + for child in &node.children { + serialize_node(child, depth, format, out)?; + } + } + out.write_all(b"")?; + } + NodeType::Document => { + for child in &node.children { + serialize_node(child, depth, format, out)?; + } + } + } + Ok(()) +} + +fn write_indent(out: &mut W, depth: usize) -> std::io::Result<()> { + for _ in 0..depth { + out.write_all(b" ")?; + } + Ok(()) +} + +fn escape_text(s: &str, out: &mut W) -> std::io::Result<()> { + let mut buf = [0u8; 4]; + for c in s.chars() { + match c { + '&' => out.write_all(b"&")?, + '<' => out.write_all(b"<")?, + '>' => out.write_all(b">")?, + '\r' => out.write_all(b" ")?, + _ => out.write_all(c.encode_utf8(&mut buf).as_bytes())?, + } + } + Ok(()) +} + +fn escape_attribute(s: &str, out: &mut W) -> std::io::Result<()> { + let mut buf = [0u8; 4]; + for c in s.chars() { + match c { + '&' => out.write_all(b"&")?, + '<' => out.write_all(b"<")?, + '>' => out.write_all(b">")?, + '"' => out.write_all(b""")?, + '\r' => out.write_all(b" ")?, + '\n' => out.write_all(b" ")?, + '\t' => out.write_all(b" ")?, + _ => out.write_all(c.encode_utf8(&mut buf).as_bytes())?, + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_output_matches_libxml() { + let dom = DOMDocument::new("1.0", "UTF-8"); + let definition = dom.append_child(dom.create_element("definition")); + + let arguments = definition.append_child(dom.create_element("arguments")); + let argument = arguments.append_child(dom.create_element("argument")); + argument.set_attribute("name", "foo"); + argument.set_attribute("is_required", "1"); + argument.set_attribute("is_array", "0"); + let description = argument.append_child(dom.create_element("description")); + description.append_child(dom.create_text_node("The foo arg")); + argument.append_child(dom.create_element("defaults")); + + definition.append_child(dom.create_element("options")); + + dom.set_format_output(true); + + let expected = "\n\ +\n\ +\x20\x20\n\ +\x20\x20\x20\x20\n\ +\x20\x20\x20\x20\x20\x20The foo arg\n\ +\x20\x20\x20\x20\x20\x20\n\ +\x20\x20\x20\x20\n\ +\x20\x20\n\ +\x20\x20\n\ +\n"; + let mut out = Vec::new(); + dom.save_xml(&mut out).unwrap(); + assert_eq!(String::from_utf8(out).unwrap(), expected); + } + + #[test] + fn text_and_create_element_value_are_escaped() { + let dom = DOMDocument::new("1.0", "UTF-8"); + let root = dom.append_child(dom.create_element("root")); + let escaped = root.append_child(dom.create_element("escaped")); + escaped.append_child(dom.create_text_node("a < b & c > d")); + // elements carry synopsis strings such as ""; libxml escapes the + // angle brackets of the createElement value too. + root.append_child(dom.create_element_with_value("usage", "cmd ")); + dom.set_format_output(true); + + let expected = "\n\ +\n\ +\x20\x20a < b & c > d\n\ +\x20\x20cmd <package>\n\ +\n"; + let mut out = Vec::new(); + dom.save_xml(&mut out).unwrap(); + assert_eq!(String::from_utf8(out).unwrap(), expected); + } + + #[test] + fn import_node_deep_copies_and_get_elements_by_tag_name() { + let source = DOMDocument::new("1.0", "UTF-8"); + let wrapper = source.append_child(source.create_element("definition")); + let inner = wrapper.append_child(source.create_element("inner")); + inner.set_attribute("k", "v"); + + let target = DOMDocument::new("1.0", "UTF-8"); + let host = target.append_child(target.create_element("command")); + + // Emulate XmlDescriptor::appendDocument over the subtree. + let found = source + .get_elements_by_tag_name("definition") + .item(0) + .unwrap(); + for child in found.child_nodes() { + host.append_child(host.owner_document().import_node(&child, true)); + } + + // Mutating the source after import must not affect the imported copy. + inner.set_attribute("k", "changed"); + target.set_format_output(true); + + let expected = "\n\ +\n\ +\x20\x20\n\ +\n"; + let mut out = Vec::new(); + target.save_xml(&mut out).unwrap(); + assert_eq!(String::from_utf8(out).unwrap(), expected); + } +} -- cgit v1.3.1