class Canon::TreeDiff::Adapters::XMLAdapter
tree = adapter.to_tree(xml)
adapter = XMLAdapter.new
xml = Nokogiri::XML(“<root><child>text</child></root>”)
@example Convert XML to TreeNode
- Maintains document structure for round-trip conversion
- Handles namespaces appropriately
- Preserves element names, text content, and attributes
- Converts Nokogiri::XML::Document to TreeNode tree
This adapter:
and back, enabling semantic tree diffing on XML documents.
XMLAdapter converts Nokogiri XML documents to TreeNode structures
def build_element(tree_node, doc)
-
(Nokogiri::XML::Element)- XML element
Parameters:
-
doc(Nokogiri::XML::Document) -- Document -
tree_node(Core::TreeNode) -- Tree node
def build_element(tree_node, doc) element = Nokogiri::XML::Element.new(tree_node.label, doc) # Add attributes tree_node.attributes.each do |name, value| element[name] = value end # Add text content if present if tree_node.value && !tree_node.value.empty? element.content = tree_node.value end # Add child elements tree_node.children.each do |child| child_element = build_element(child, doc) element.add_child(child_element) end element end
def convert_element(element)
-
(Core::TreeNode)- Tree node
Parameters:
-
element(Nokogiri::XML::Element) -- XML element
def convert_element(element) # Get element name (with namespace prefix if present) element.name # Create label that includes namespace URI to ensure elements # with different namespaces are treated as different nodes # Format: {namespace_uri}name or just name if no namespace namespace_uri = element.namespace&.href label = if namespace_uri && !namespace_uri.empty? "{#{namespace_uri}}#{element.name}" else element.name end # Collect attributes and sort them alphabetically # This ensures attribute order doesn't affect hash matching # (matches behavior of attribute_order: :ignore in match options) attributes = {} element.attributes.each do |name, attr| attributes[name] = attr.value end # Sort attributes by key to normalize order attributes = attributes.sort.to_h # Get text content (only direct text, not from children) text_value = extract_text_value(element) # Create tree node with source node reference tree_node = Core::TreeNode.new( label: label, value: text_value, attributes: attributes, source_node: element, # Preserve reference to original Nokogiri node ) # Process child elements element.element_children.each do |child| child_node = convert_element(child) tree_node.add_child(child_node) end tree_node end
def extract_canon_attributes(element_node)
-
(Hash)- Attributes hash sorted by key
Parameters:
-
element_node(Canon::Xml::Nodes::ElementNode) -- Element node
def extract_canon_attributes(element_node) # Canon::Xml::Nodes::ElementNode has attribute_nodes array attrs = {} element_node.attribute_nodes.each do |attr| attrs[attr.name] = attr.value end # Sort attributes by key to normalize order attrs.sort.to_h end
def extract_text_value(element)
-
(String, nil)- Text content or nil
Parameters:
-
element(Nokogiri::XML::Element) -- XML element
def extract_text_value(element) # Get only direct text nodes, not from nested elements text_nodes = element.children.select(&:text?) # For mixed content (has both text nodes and element children), # join text nodes with space to handle implicit whitespace around # block-level elements like <br/> # Example: "Text<br/>More" should become "Text More" not "TextMore" separator = element.element_children.any? ? " " : "" text = text_nodes.map(&:text).join(separator) # CRITICAL FIX: Return original text without stripping # Normalization will be applied during comparison based on match_options # Only return nil for truly empty text or whitespace-only text text.match?(/\A[\s\p{Zs}]*\z/) ? nil : text end
def from_tree(tree_node, doc = nil)
-
(Nokogiri::XML::Document, Nokogiri::XML::Element)-
Parameters:
-
doc(Nokogiri::XML::Document) -- Optional document to use -
tree_node(Core::TreeNode) -- Root tree node
def from_tree(tree_node, doc = nil) doc ||= Nokogiri::XML::Document.new element = build_element(tree_node, doc) if doc.root.nil? doc.root = element doc else element end end
def initialize(match_options: {})
-
match_options(Hash) -- Match options for text/attribute normalization
def initialize(match_options: {}) @match_options = match_options end
def to_tree(node)
-
(Core::TreeNode)- Root tree node
Parameters:
-
node(Nokogiri::XML::Document, Nokogiri::XML::Element, Canon::Xml::Node) -- XML node
def to_tree(node) # Handle nil nodes return nil if node.nil? # Handle Canon::Xml::Node types first case node when Canon::Xml::Nodes::RootNode return to_tree_from_canon_root(node) when Canon::Xml::Nodes::ElementNode return to_tree_from_canon_element(node) when Canon::Xml::Nodes::TextNode return to_tree_from_canon_text(node) when Canon::Xml::Nodes::CommentNode return to_tree_from_canon_comment(node) end # Fallback to Nokogiri (legacy support) case node when Nokogiri::XML::Document # Start from root element root = node.root raise ArgumentError, "Document has no root element" if root.nil? to_tree(root) when Nokogiri::XML::Element convert_element(node) else raise ArgumentError, "Unsupported node type: #{node.class}" end end
def to_tree_from_canon_comment(comment_node)
-
(Core::TreeNode)- Tree node
Parameters:
-
comment_node(Canon::Xml::Nodes::CommentNode) -- Comment node
def to_tree_from_canon_comment(comment_node) Core::TreeNode.new( label: "comment", value: comment_node.value, attributes: {}, children: [], source_node: comment_node, ) end
def to_tree_from_canon_element(element_node)
-
(Core::TreeNode)- Tree node
Parameters:
-
element_node(Canon::Xml::Nodes::ElementNode) -- Element node
def to_tree_from_canon_element(element_node) # Create label that includes namespace URI to ensure elements # with different namespaces are treated as different nodes # Format: {namespace_uri}name or just name if no namespace namespace_uri = element_node.namespace_uri label = if namespace_uri && !namespace_uri.empty? "{#{namespace_uri}}#{element_node.name}" else element_node.name end # Create TreeNode from Canon::Xml::Nodes::ElementNode tree_node = Core::TreeNode.new( label: label, value: nil, # Elements don't have values attributes: extract_canon_attributes(element_node), children: [], source_node: element_node, # Preserve reference to Canon node ) # Process children recursively element_node.children.each do |child| child_tree = to_tree(child) tree_node.add_child(child_tree) if child_tree end tree_node end
def to_tree_from_canon_root(root_node)
-
(Core::TreeNode, nil)- Tree node for first child (document element)
Parameters:
-
root_node(Canon::Xml::Nodes::RootNode) -- Root node
def to_tree_from_canon_root(root_node) # Root node: process first child (document element) return nil if root_node.children.empty? to_tree(root_node.children.first) end
def to_tree_from_canon_text(text_node)
-
(Core::TreeNode, nil)- Tree node or nil for whitespace-only text
Parameters:
-
text_node(Canon::Xml::Nodes::TextNode) -- Text node
def to_tree_from_canon_text(text_node) # Extract text value text_value = text_node.value.to_s # Return nil for whitespace-only text # Use \p{Zs} for Unicode space separators (covers em space U+2003, # thin space U+2009, en space U+2002, etc.) plus ASCII whitespace return nil if text_value.match?(/\A[\s\p{Zs}]*\z/) Core::TreeNode.new( label: "text", value: text_value, attributes: {}, children: [], source_node: text_node, ) end