Class: Canon::Xml::SaxBuilder
- Inherits:
-
Object
- Object
- Canon::Xml::SaxBuilder
- Defined in:
- lib/canon/xml/sax_builder.rb
Overview
Builds Canon::Xml::Node tree from SAX events.
Engine-neutral: the event protocol is Nokogiri-shaped (qname + attribute pairs with xmlns declarations inline); Canon::Xml::Sax selects the driver. Much faster than DOM parsing + conversion — no intermediate engine DOM tree, no traversal conversion pass.
Construction goes through TreeBuilder like every other feed: this class owns only what is SAX-specific — qname parsing, xmlns separation, character-reference decoding, adjacency combining, the namespace stack, and document-level reordering.
Usage:
root = SaxBuilder.parse(xml_string, preserve_whitespace: false)
# root is a Canon::Xml::Nodes::RootNode
For C14N, use strip_doctype: true to avoid DTD default attribute expansion:
root = SaxBuilder.parse(xml_string, strip_doctype: true)
Class Method Summary collapse
-
.parse(xml_string, preserve_whitespace: false, strip_doctype: false) ⇒ Nodes::RootNode
Parse XML string and return Canon::Xml::Node tree.
-
.strip_doctype_declaration(xml) ⇒ String
Strip DOCTYPE declaration without using complex regex This avoids ReDoS vulnerability from patterns like \s+ and [^>]*.
Instance Method Summary collapse
-
#cdata(string) ⇒ Object
Called for CDATA content.
-
#characters(string) ⇒ Object
Called for text content.
-
#comment(string) ⇒ Object
Called for comments.
-
#end_element(_name) ⇒ Object
Called when an element ends.
-
#error(string) ⇒ Object
SAX callbacks for libxml errors and warnings.
-
#initialize(preserve_whitespace: false) ⇒ SaxBuilder
constructor
Initialize the SAX builder.
-
#processing_instruction(name, content) ⇒ Object
Called for processing instructions.
-
#reorder_children(root) ⇒ Object
Reorder root children so document element comes first followed by PIs and comments (outside document element).
-
#result ⇒ Nodes::RootNode
Return the built tree.
-
#start_element(name, attrs = []) ⇒ Object
Called when an element starts.
- #warning(string) ⇒ Object
Constructor Details
#initialize(preserve_whitespace: false) ⇒ SaxBuilder
Initialize the SAX builder
80 81 82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/canon/xml/sax_builder.rb', line 80 def initialize(preserve_whitespace: false) @preserve_whitespace = preserve_whitespace @root = Nodes::RootNode.new @stack = [@root] # Track in-scope namespaces at each level # Each entry is a hash of prefix => uri @namespace_stack = [build_initial_namespaces] # Captured libxml errors during SAX parsing. Surfaced on the # resulting RootNode so the diff report can warn the user # when a FATAL parse error has caused content loss # (see lutaml/canon#130). @parse_errors = [] end |
Class Method Details
.parse(xml_string, preserve_whitespace: false, strip_doctype: false) ⇒ Nodes::RootNode
Parse XML string and return Canon::Xml::Node tree
31 32 33 34 35 36 37 38 39 40 41 42 43 |
# File 'lib/canon/xml/sax_builder.rb', line 31 def self.parse(xml_string, preserve_whitespace: false, strip_doctype: false) # Strip DOCTYPE to prevent the SAX engine from expanding DTD default attributes # This is needed for C14N which should NOT include default attributes from DTD # Use string methods instead of complex regex to avoid ReDoS vulnerability if strip_doctype xml_string = strip_doctype_declaration(xml_string) end builder = new(preserve_whitespace: preserve_whitespace) Canon::Xml::Sax.parse(xml_string, builder) builder.result end |
.strip_doctype_declaration(xml) ⇒ String
Strip DOCTYPE declaration without using complex regex This avoids ReDoS vulnerability from patterns like \s+ and [^>]*
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/canon/xml/sax_builder.rb', line 50 def self.strip_doctype_declaration(xml) # Find DOCTYPE start (case-insensitive) doctype_start = xml.upcase.index("<!DOCTYPE") return xml unless doctype_start # Find the end of DOCTYPE - it ends with > # Handle both simple DOCTYPE and those with internal subset [...] pos = doctype_start + 9 # length of "<!DOCTYPE" in_bracket = false while pos < xml.length char = xml[pos] if char == "[" && !in_bracket in_bracket = true elsif char == "]" && in_bracket in_bracket = false elsif char == ">" && !in_bracket # Found the end of DOCTYPE return xml[0...doctype_start] + xml[(pos + 1)..] end pos += 1 end # If we didn't find a proper end, just return original xml end |
Instance Method Details
#cdata(string) ⇒ Object
Called for CDATA content. CDATA is literal character data: character references inside it are NOT decoded (a literal A stays as written), unlike regular text where they are resolved. Whitespace and adjacency rules match characters so the two forms of character data build identical trees.
176 177 178 179 180 |
# File 'lib/canon/xml/sax_builder.rb', line 176 def cdata(string) return if string.nil? append_text(string, string) end |
#characters(string) ⇒ Object
Called for text content
163 164 165 166 167 |
# File 'lib/canon/xml/sax_builder.rb', line 163 def characters(string) return if string.nil? append_text(decode_character_references(string), string) end |
#comment(string) ⇒ Object
Called for comments
220 221 222 |
# File 'lib/canon/xml/sax_builder.rb', line 220 def comment(string) @stack.last.add_child(TreeBuilder::DEFAULT.comment(string)) end |
#end_element(_name) ⇒ Object
Called when an element ends
155 156 157 158 |
# File 'lib/canon/xml/sax_builder.rb', line 155 def end_element(_name) @stack.pop @namespace_stack.pop end |
#error(string) ⇒ Object
SAX callbacks for libxml errors and warnings. Without these overrides the default handlers swallow the events; with them, libxml's "Attribute xml:lang redefined" and similar messages land in @parse_errors and ride through to ComparisonResult.
98 99 100 |
# File 'lib/canon/xml/sax_builder.rb', line 98 def error(string) @parse_errors << string.to_s.strip end |
#processing_instruction(name, content) ⇒ Object
Called for processing instructions
228 229 230 231 232 |
# File 'lib/canon/xml/sax_builder.rb', line 228 def processing_instruction(name, content) @stack.last.add_child( TreeBuilder::DEFAULT.processing_instruction(name, content || ""), ) end |
#reorder_children(root) ⇒ Object
Reorder root children so document element comes first followed by PIs and comments (outside document element)
248 249 250 251 252 253 254 |
# File 'lib/canon/xml/sax_builder.rb', line 248 def reorder_children(root) doc_element = root.children.find { |c| c.node_type == :element } return unless doc_element other_children = root.children.reject { |c| c.node_type == :element } root.children = [doc_element] + other_children end |
#result ⇒ Nodes::RootNode
Return the built tree
237 238 239 240 241 242 243 244 |
# File 'lib/canon/xml/sax_builder.rb', line 237 def result # Reorder children so that the document element comes first, # followed by PIs and comments outside the document element # (C14N requires this ordering) reorder_children(@root) @root.parse_errors = @parse_errors if @parse_errors.any? @root end |
#start_element(name, attrs = []) ⇒ Object
Called when an element starts
110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/canon/xml/sax_builder.rb', line 110 def start_element(name, attrs = []) parent = @stack.last # Parse namespace from name (prefix:localname or just localname) prefix, local_name = parse_qname(name) # Separate namespace declarations from regular attributes ns_decls, regular_attrs = separate_namespaces(attrs) # Check for relative namespace URIs (before building hash) # Convert to hash for iteration ns_hash = build_ns_hash(ns_decls) ns_hash.each_value do |uri| next if uri.nil? || uri.empty? if relative_uri?(uri) raise Canon::Error, "Relative namespace URI not allowed: #{uri}" end end # Push new namespace scope with declarations (own shadows # inherited — the same merge the TreeBuilder scope kernel applies) new_scope = @namespace_stack.last.merge(ns_hash) @namespace_stack.push(new_scope) element = TreeBuilder::DEFAULT.element( name: local_name, prefix: prefix, namespace_uri: new_scope[prefix.to_s], namespace_scope: new_scope, attributes: regular_attrs.map do |attr_name, attr_value| attr_prefix, attr_local = parse_qname(attr_name) attr_ns_uri = attr_prefix ? new_scope[attr_prefix] : nil [attr_local, decode_character_references(attr_value || ""), attr_ns_uri, attr_prefix] end, ) parent.add_child(element) @stack.push(element) end |
#warning(string) ⇒ Object
102 103 104 |
# File 'lib/canon/xml/sax_builder.rb', line 102 def warning(string) @parse_errors << string.to_s.strip end |