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)

Returns:
  • (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)

Returns:
  • (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)

Returns:
  • (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)

Returns:
  • (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)

Returns:
  • (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: {})

Parameters:
  • match_options (Hash) -- Match options for text/attribute normalization
def initialize(match_options: {})
  @match_options = match_options
end

def to_tree(node)

Returns:
  • (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)

Returns:
  • (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)

Returns:
  • (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)

Returns:
  • (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)

Returns:
  • (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