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)
Constant Summary collapse
- NO_NAMESPACE_DECLS =
Shared empties for the no-declaration common case: both consumers only iterate them.
[].freeze
- EMPTY_NS_HASH =
{}.freeze
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
85 86 87 88 89 90 91 92 93 94 95 96 97 |
# File 'lib/canon/xml/sax_builder.rb', line 85 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
35 36 37 38 39 40 41 42 43 44 45 46 47 |
# File 'lib/canon/xml/sax_builder.rb', line 35 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 [^>]*
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/canon/xml/sax_builder.rb', line 54 def self.strip_doctype_declaration(xml) # Find DOCTYPE start (case-insensitive). A literal + /i index # scans in place — upcase would copy the whole document per call. doctype_start = xml.index(/<!DOCTYPE/i) 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.
217 218 219 220 221 |
# File 'lib/canon/xml/sax_builder.rb', line 217 def cdata(string) return if string.nil? append_text(string, string) end |
#characters(string) ⇒ Object
Called for text content
204 205 206 207 208 |
# File 'lib/canon/xml/sax_builder.rb', line 204 def characters(string) return if string.nil? append_text(decode_character_references(string), string) end |
#comment(string) ⇒ Object
Called for comments
261 262 263 |
# File 'lib/canon/xml/sax_builder.rb', line 261 def comment(string) @stack.last.add_child(TreeBuilder::DEFAULT.comment(string)) end |
#end_element(_name) ⇒ Object
Called when an element ends
196 197 198 199 |
# File 'lib/canon/xml/sax_builder.rb', line 196 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.
103 104 105 |
# File 'lib/canon/xml/sax_builder.rb', line 103 def error(string) @parse_errors << string.to_s.strip end |
#processing_instruction(name, content) ⇒ Object
Called for processing instructions
269 270 271 272 273 |
# File 'lib/canon/xml/sax_builder.rb', line 269 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)
289 290 291 292 293 294 295 |
# File 'lib/canon/xml/sax_builder.rb', line 289 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
278 279 280 281 282 283 284 285 |
# File 'lib/canon/xml/sax_builder.rb', line 278 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
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 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'lib/canon/xml/sax_builder.rb', line 115 def start_element(name, attrs = []) parent = @stack.last # Fast path: attribute-less elements (the bulk of most # documents) declare no namespaces — no separation, no hash, # no qname pair array; the inherited scope object is pushed # as-is (shared scope → cached namespace-node array). if attrs.empty? if (colon = name.index(":")) prefix = name[0...colon] local_name = name[(colon + 1)..] else prefix = nil local_name = name end new_scope = @namespace_stack.last @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, ) parent.add_child(element) @stack.push(element) return end # 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). Elements that declare nothing push the inherited # scope object itself, so scopes — and their cached # namespace-node arrays in TreeBuilder — are shared down runs # of undeclaring elements instead of re-merged per element. inherited_scope = @namespace_stack.last new_scope = ns_hash.empty? ? inherited_scope : inherited_scope.merge(ns_hash) @namespace_stack.push(new_scope) # Flat stride-4 attribute array (TreeBuilder#element contract): # one array per element instead of one sub-array per attribute. flat_attributes = [] regular_attrs.each do |attr_name, attr_value| attr_prefix, attr_local = parse_qname(attr_name) attr_ns_uri = attr_prefix ? new_scope[attr_prefix] : nil flat_attributes << attr_local << decode_character_references(attr_value || "") << attr_ns_uri << attr_prefix end element = TreeBuilder::DEFAULT.element( name: local_name, prefix: prefix, namespace_uri: new_scope[prefix.to_s], namespace_scope: new_scope, attributes: flat_attributes, ) parent.add_child(element) @stack.push(element) end |
#warning(string) ⇒ Object
107 108 109 |
# File 'lib/canon/xml/sax_builder.rb', line 107 def warning(string) @parse_errors << string.to_s.strip end |