""" Utility functions for XML diff operations. """ import copy from typing import cast from lxml import etree def preprocess_for_diff( elem: etree._Element, ignore_tags: frozenset, ignore_attrs: frozenset, inject_line_markers: bool = False ) -> etree._Element: """ Create a copy of element tree with ignored tags removed, ignored attributes stripped, and text content whitespace normalized. Args: elem: Root element to preprocess ignore_tags: Set of tag names to remove ignore_attrs: Set of attribute names to remove inject_line_markers: If True, add data-line attribute with sourceline to each element Returns: Preprocessed copy of element tree """ # Deep copy to avoid modifying original elem_copy = copy.deepcopy(elem) # Remove ignored tags for tag_name in ignore_tags: ignored_elems = cast(list[etree._Element], elem_copy.xpath(f'.//*[local-name()="{tag_name}"]')) for ignored_elem in ignored_elems: parent = ignored_elem.getparent() if parent is not None: parent.remove(ignored_elem) # Strip ignored attributes and normalize text content for el in elem_copy.iter(): if not isinstance(el.tag, str): continue # Inject line marker if requested if inject_line_markers and hasattr(el, 'sourceline') and el.sourceline: el.set('data-line', str(el.sourceline)) # Normalize text content (collapse whitespace) if el.text: normalized = " ".join(el.text.split()) el.text = normalized if normalized else None # Normalize tail content (text after element) if el.tail: normalized = " ".join(el.tail.split()) el.tail = normalized if normalized else None # Strip ignored attributes for attr_name in list(el.attrib.keys()): attr_name = str(attr_name) # Handle namespaced attributes if "}" in attr_name: ns_uri, local = attr_name.split("}") # Convert to prefix:local format for common namespaces if "www.w3.org/XML" in ns_uri: full_name = f"xml:{local}" else: full_name = local else: full_name = attr_name if full_name in ignore_attrs: del el.attrib[attr_name] return elem_copy def find_element_line_offset(full_xml: str, elem: etree._Element) -> int: """ Find the line number where the element starts in the full document (1-indexed). Uses sourceline if available, otherwise searches for the tag. Args: full_xml: Full XML document string elem: Element to find Returns: Line number (1-indexed) where element starts """ if hasattr(elem, 'sourceline') and elem.sourceline: return elem.sourceline # Fallback: search for opening tag in full XML tag_name = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag lines = full_xml.split('\n') for i, line in enumerate(lines): if f'<{tag_name}' in line or f' str: """ Serialize XML with linebreaks after each element, preserving document order. This creates a more readable diff than single-line serialization, while avoiding the element reordering that can happen with pretty_print=True. Args: elem: Element to serialize Returns: XML string with linebreaks after each closing tag """ # Serialize without pretty printing to preserve order xml_str = etree.tostring(elem, encoding="unicode", pretty_print=False) # Add newline after each closing tag to enable line-by-line diffing # This regex finds closing tags and adds a newline after them import re xml_str = re.sub(r'(]+>)', r'\1\n', xml_str) # Also add newline after self-closing tags #xml_str = re.sub(r'(/>\s*(?!\n', xml_str) return xml_str def escape_html(text: str) -> str: """Escape HTML special characters.""" if not text: return "" return ( text.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """) .replace("'", "'") )