Module: Moxml::Adapter::Libxml::Serialize

Included in:
Moxml::Adapter::Libxml
Defined in:
lib/moxml/adapter/libxml/serialize.rb

Overview

Wire-format knowledge: the C-engine fast path with its guards, the Ruby fallback walker family, namespace-aware emission, and the ReDoS-hardened empty-element expansion.

Instance Method Summary collapse

Instance Method Details

#blank_content?(content) ⇒ Boolean

Returns:

  • (Boolean)


419
420
421
# File 'lib/moxml/adapter/libxml/serialize.rb', line 419

def blank_content?(content)
  content.nil? || !content.match?(NON_WHITESPACE_RE)
end

#blank_text_node?(child) ⇒ Boolean

Returns:

  • (Boolean)


415
416
417
# File 'lib/moxml/adapter/libxml/serialize.rb', line 415

def blank_text_node?(child)
  child.text? && blank_content?(child.content)
end

#collect_non_blank_children(elem) ⇒ Object



423
424
425
426
427
428
429
430
431
# File 'lib/moxml/adapter/libxml/serialize.rb', line 423

def collect_non_blank_children(elem)
  children = []
  return children unless elem.children?

  elem.each_child do |c|
    children << c unless blank_text_node?(c)
  end
  children
end

#emit_attributes(output, elem) ⇒ Object



353
354
355
356
357
358
359
360
361
362
# File 'lib/moxml/adapter/libxml/serialize.rb', line 353

def emit_attributes(output, elem)
  return unless elem.attributes?

  elem.each_attr do |attr|
    next if attr.name.start_with?("xmlns")

    attr_name = attr.ns&.prefix ? "#{attr.ns.prefix}:#{attr.name}" : attr.name
    output << " #{attr_name}=\"#{XmlEmitter.escape_attribute(attr.value)}\""
  end
end

#emit_children_with_layout(output, elem, indent_size, depth, eref_active:) ⇒ Object

Walk native children once and emit them with the same newline + indentation layout the old add_newlines_to_xml + indent_xml post-passes produced — but in a single recursion with no string rescanning.

Newline rule (matching >(?=<(?!/)) with CDATA-placeholder protection): emit \n + per-level padding before a child iff the previous emitted sibling was block-level (ended with >) AND the current sibling is block-level. Text and CDATA count as text-like and suppress the newline on both sides (the original CDATA placeholder broke the >...< adjacency symmetrically).



445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
# File 'lib/moxml/adapter/libxml/serialize.rb', line 445

def emit_children_with_layout(output, elem, indent_size, depth,
                               eref_active:)
  child_pad = indent_size.positive? ? " " * (indent_size * (depth + 1)) : nil
  prev_block = true

  elem.each_child do |child|
    # Cache text? — used twice per child (whitespace skip + is_text_like).
    # For element children (the common case) both calls return false, so
    # caching saves a libxml C call.
    is_text = child.text?
    next if is_text && blank_content?(child.content)

    is_text_like = is_text || child.cdata?
    if prev_block && !is_text_like
      output << "\n"
      output << child_pad if child_pad
    end
    prev_block = !is_text_like

    output << serialize_child_to_xml(child, indent_size: indent_size, depth: depth,
                                            eref_active: eref_active)
  end
end

#emit_eref_interleaved_children(output, elem, entity_refs, child_sequence, indent_size, depth, eref_active:) ⇒ Object



379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# File 'lib/moxml/adapter/libxml/serialize.rb', line 379

def emit_eref_interleaved_children(output, elem, entity_refs, child_sequence,
                                    indent_size, depth, eref_active:)
  native_children = collect_non_blank_children(elem)
  child_pad = indent_size.positive? ? " " * (indent_size * (depth + 1)) : nil
  eref_idx = 0
  native_idx = 0
  prev_block = true

  child_sequence.each do |type|
    case type
    when :native
      if native_idx < native_children.size
        child = native_children[native_idx]
        is_text_like = child.text? || child.cdata?
        if prev_block && !is_text_like
          output << "\n"
          output << child_pad if child_pad
        end
        prev_block = !is_text_like

        output << serialize_child_to_xml(
          child, indent_size: indent_size, depth: depth,
                 eref_active: eref_active
        )
        native_idx += 1
      end
    when :eref
      if eref_idx < entity_refs.size
        output << entity_refs[eref_idx].to_xml
        eref_idx += 1
        prev_block = false
      end
    end
  end
end

#emit_namespace_definitions(output, elem, include_ns) ⇒ Object

Emit xmlns/xmlns:foo declarations onto output. On the root (include_ns: true) we emit ALL definitions; on children we emit only definitions that OVERRIDE a parent's same-prefix URI. Skips the whole block when the element has no local definitions, which is the common case for child elements in unnamespaced docs.



310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/moxml/adapter/libxml/serialize.rb', line 310

def emit_namespace_definitions(output, elem, include_ns)
  return unless elem.is_a?(::LibXML::XML::Node)

  ns_list = elem.namespaces
  return unless ns_list.is_a?(::LibXML::XML::Namespaces)

  definitions = ns_list.definitions
  return if definitions.empty?

  parent_ns_defs = include_ns ? nil : parent_namespace_defs(elem)
  seen_ns = nil

  definitions.each do |ns|
    prefix = ns.prefix
    uri = ns.href
    next unless include_ns ||
      (parent_ns_defs&.key?(prefix) && parent_ns_defs[prefix] != uri)

    seen_ns ||= {}
    next if seen_ns.key?(prefix)

    seen_ns[prefix] = true
    output << format_ns_declaration(prefix, uri)
  end
end

#format_ns_declaration(prefix, uri) ⇒ Object



345
346
347
348
349
350
351
# File 'lib/moxml/adapter/libxml/serialize.rb', line 345

def format_ns_declaration(prefix, uri)
  if prefix.nil? || prefix.empty?
    " xmlns=\"#{XmlEmitter.escape_attribute(uri)}\""
  else
    " xmlns:#{prefix}=\"#{XmlEmitter.escape_attribute(uri)}\""
  end
end

#lookup_entity_ref_serialization(elem) ⇒ Object

Returns [entity_refs, child_sequence] when the element has interleaved entity references that the serializer needs to weave back into the native child stream — otherwise [nil, nil].

The caller is responsible for gating this with eref_active (precomputed once per serialize call). When eref_active is false this method is never entered, so the per-element doc attachment query never fires.



372
373
374
375
376
377
# File 'lib/moxml/adapter/libxml/serialize.rb', line 372

def lookup_entity_ref_serialization(elem)
  doc = elem.doc
  return [nil, nil] unless doc

  entity_ref_registry(doc).serialization_for(elem)
end

#native_root_output(native_doc, indent_size) ⇒ Object

Serialize the root subtree with libxml's C serializer plus moxml-canonical corrections — roughly 25x faster and ~2600x fewer allocations than the Ruby walker. Returns nil whenever a guard says the walker is still required.



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
# File 'lib/moxml/adapter/libxml/serialize.rb', line 150

def native_root_output(native_doc, indent_size)
  return nil unless indent_size == 2
  return nil if entity_ref_registry(native_doc).active?

  output = native_doc.root.to_s

  # The walker strips prefixed names on parsed namespaced
  # children; that behavior is load-bearing, so namespaced
  # output keeps the walker.
  return nil if output.include?("xmlns")

  # The native serializer escapes non-ASCII codepoints in
  # attribute values as numeric character references
  # (x="&#xA9;"); the walker emits literal UTF-8
  return nil if output.include?("&#x")

  # Layout: pull closing tags onto the last child's line
  output = output.gsub(/\n[ \t]*(<\/[\w.:-]+>)/, '\1')

  if output.include?("/>")
    # A literal "/>" inside a comment or CDATA section would be
    # falsely expanded — the segment-aware walker is correct here
    return nil if output.include?("<!--") || output.include?("<![CDATA[")

    output = output.gsub(EMPTY_ELEMENT_EXPANSION_RE, '<\1\2></\1>')
  end

  # Native escapes attribute apostrophes; moxml keeps them literal
  output.gsub("&apos;", "'")
end

#parent_namespace_defs(elem) ⇒ Object



336
337
338
339
340
341
342
343
# File 'lib/moxml/adapter/libxml/serialize.rb', line 336

def parent_namespace_defs(elem)
  parent = elem.parent
  return nil unless parent.is_a?(::LibXML::XML::Node)

  defs = {}
  parent.namespaces.each { |ns| defs[ns.prefix] = ns.href }
  defs
end

#serialize(node, options = {}) ⇒ Object



22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
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
# File 'lib/moxml/adapter/libxml/serialize.rb', line 22

def serialize(node, options = {})
  # FIRST: Check if node is any kind of wrapper with custom to_xml
  if node.is_a?(CustomizedLibxml::Node) || node.is_a?(DoctypeWrapper)
    return node.to_xml
  end

  native_node = unpatch_node(node)
  return "" unless native_node

  if native_node.is_a?(::LibXML::XML::Document)
    output = +""

    # Check if we should include declaration
    # Priority: explicit no_declaration option > default (include)
    should_include_decl = if options.key?(:no_declaration)
                            !options[:no_declaration]
                          else
                            # Default: include declaration
                            true
                          end

    if should_include_decl
      # Check if declaration was explicitly managed
      decl = attachments.get(native_node, :declaration)
      if decl
        # Only output declaration if it exists and wasn't removed
        output << decl.to_xml unless decl.removed
      else
        # No declaration stored - create default
        version = native_node.version || "1.0"
        encoding_val = options[:encoding] ||
          encoding_to_string(native_node.encoding) ||
          "UTF-8"

        # Don't add standalone="yes" by default - only if explicitly set
        decl = CustomizedLibxml::Declaration.new(
          native_node,
          version,
          encoding_val,
          nil, # No standalone by default
        )
        attachments.set(native_node, :declaration, decl)
        output << decl.to_xml
      end
    end

    # Add DOCTYPE if stored on document
    doctype_wrapper = attachments.get(native_node, :doctype)
    if doctype_wrapper
      output << "\n" unless output.empty?
      output << doctype_wrapper.to_xml
    end

    # Parse-time document-level parts live on the chain around
    # the root (prolog/epilog PIs and comments); programmatic
    # ones stay in attachments. The chain nodes serialize in
    # document order around the root output below.
    chain_pre = []
    chain_post = []
    if native_node.root
      past_root = false
      chain_node = native_node.child
      while chain_node
        case chain_node.node_type
        when ::LibXML::XML::Node::ELEMENT_NODE then past_root = true
        when ::LibXML::XML::Node::PI_NODE, ::LibXML::XML::Node::COMMENT_NODE
          (past_root ? chain_post : chain_pre) << chain_node.to_s
        end
        chain_node = chain_node.next
      end
    end
    chain_pre.each do |part|
      output << "\n" unless output.empty?
      output << part
    end

    # Add document-level processing instructions if stored
    pis = attachments.get(native_node, :pis)
    if pis && !pis.empty?
      pis.each do |pi|
        output << "\n" unless output.empty?
        output << pi.to_xml
      end
    end

    # Add text nodes if stored (for documents without root)
    texts = attachments.get(native_node, :texts)
    if texts && !texts.empty?
      texts.each do |text|
        output << "\n" unless output.empty?
        output << text.to_xml
      end
    end

    if native_node.root
      indent_size = options[:indent].is_a?(Integer) && options[:indent].positive? ? options[:indent] : 0
      # `eref_active` is computed once here and threaded through the
      # recursion so that the per-element `attachments.key?` Monitor
      # sync only fires for docs that actually have entity refs.
      eref_active = entity_ref_registry(native_node).active?
      root_output = native_root_output(native_node, indent_size)
      root_output ||= serialize_element_with_namespaces(
        native_node.root,
        include_ns: true,
        indent_size: indent_size,
        depth: 0,
        eref_active: eref_active,
      )

      output << "\n" << root_output unless output.empty?
      output << root_output if output.empty?
    end

    unless chain_post.empty?
      output << "\n" unless output.empty?
      output << chain_post.join("\n") << "\n"
    end

    output
  else
    serialize_element_with_namespaces(native_node, include_ns: true)
  end
end

#serialize_child_to_xml(child, indent_size:, depth:, eref_active:) ⇒ Object

Serialize one child node. Elements recurse into the layout-aware path; non-element wrappers route through their own to_xml; everything else falls through to the per-type serializer. indent_size: and depth: are required to force callers to decide whether the child should inherit the parent's indent state — the entity-ref interleave path deliberately passes 0/0.

Element fast-path checked first to avoid allocating a wrapper we'd immediately throw away (elements always recurse on the raw native node, not the wrapper). For a typical document this skips wrapper allocation for the majority of children.



480
481
482
483
484
485
486
487
488
489
490
491
492
493
# File 'lib/moxml/adapter/libxml/serialize.rb', line 480

def serialize_child_to_xml(child, indent_size:, depth:, eref_active:)
  if child.element?
    return serialize_element_with_namespaces(child, include_ns: false,
                                                    indent_size: indent_size, depth: depth + 1,
                                                    eref_active: eref_active)
  end

  wrapped_child = patch_node(child)
  if wrapped_child.is_a?(CustomizedLibxml::Node)
    wrapped_child.to_xml
  else
    serialize_node(child)
  end
end

#serialize_element(elem) ⇒ Object



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/moxml/adapter/libxml/serialize.rb', line 181

def serialize_element(elem)
  output = "<#{elem.name}"

  # Add namespace definitions (only on this element, not ancestors)
  if elem.is_a?(::LibXML::XML::Node)
    seen_ns = {}
    elem.namespaces.each do |ns|
      prefix = ns.prefix
      uri = ns.href
      next if seen_ns.key?(prefix)

      seen_ns[prefix] = true
      output << if prefix.nil? || prefix.empty?
                  " xmlns=\"#{XmlEmitter.escape_attribute(uri)}\""
                else
                  " xmlns:#{prefix}=\"#{XmlEmitter.escape_attribute(uri)}\""
                end
    end
  end

  # Add attributes
  if elem.attributes?
    elem.each_attr do |attr|
      next if attr.name.start_with?("xmlns")

      # Include namespace prefix if attribute has one
      attr_name = if attr.ns&.prefix
                    "#{attr.ns.prefix}:#{attr.name}"
                  else
                    attr.name
                  end
      output << " #{attr_name}=\"#{XmlEmitter.escape_attribute(attr.value)}\""
    end
  end

  # Always use verbose format <tag></tag> for consistency with other adapters
  output << ">"
  if elem.children?
    elem.each_child do |child|
      # Skip whitespace-only text nodes
      next if blank_text_node?(child)

      output << serialize_node(child)
    end
  end

  # Append any EntityReference wrappers stored on the document
  doc = elem.doc
  entity_refs = entity_ref_registry(doc).refs_for(elem)
  entity_refs&.each { |ref| output << ref.to_xml }

  output << "</#{elem.name}>"

  output
end

#serialize_element_with_namespaces(elem, include_ns: true, indent_size: 0, depth: 0, eref_active: nil) ⇒ Object



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/moxml/adapter/libxml/serialize.rb', line 264

def serialize_element_with_namespaces(elem, include_ns: true,
                                       indent_size: 0, depth: 0,
                                       eref_active: nil)
  # Cache elem.name — it's a libxml C call we'd otherwise make
  # twice (open tag + close tag). Concat with `<<` instead of
  # `"<#{name}"` to avoid the interpolated intermediate string.
  name = elem.name
  output = +"<"
  output << name
  emit_namespace_definitions(output, elem, include_ns)
  emit_attributes(output, elem)

  # `eref_active` is precomputed at the top-level `serialize` call
  # and threaded down — when nil (top-level non-recursive call into
  # this method), look it up; when false, skip the per-element doc
  # attachment query that otherwise fires for every element under
  # Monitor#synchronize.
  eref_active = doc_eref_active?(elem.doc) if eref_active.nil?
  entity_refs, child_sequence = if eref_active
                                  lookup_entity_ref_serialization(elem)
                                else
                                  [
                                    nil, nil
                                  ]
                                end

  # Always use verbose format <tag></tag> for consistency with other adapters
  output << ">"

  if entity_refs && child_sequence
    emit_eref_interleaved_children(output, elem, entity_refs, child_sequence,
                                   indent_size, depth, eref_active: eref_active)
  elsif elem.children?
    emit_children_with_layout(output, elem, indent_size, depth,
                              eref_active: eref_active)
  end

  output << "</" << name << ">"
  output
end

#serialize_node(node) ⇒ Object



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/moxml/adapter/libxml/serialize.rb', line 237

def serialize_node(node)
  # Check if node is a wrapper with to_xml method
  case node
  when CustomizedLibxml::ProcessingInstruction,
       CustomizedLibxml::Comment,
       CustomizedLibxml::Cdata,
       CustomizedLibxml::Text,
       CustomizedLibxml::EntityReference
    return node.to_xml
  end

  case node.node_type
  when ::LibXML::XML::Node::ELEMENT_NODE
    serialize_element(node)
  when ::LibXML::XML::Node::TEXT_NODE
    XmlEmitter.escape_text(node.content)
  when ::LibXML::XML::Node::CDATA_SECTION_NODE
    "<![CDATA[#{node.content}]]>"
  when ::LibXML::XML::Node::COMMENT_NODE
    "<!-- #{node.content} -->"
  when ::LibXML::XML::Node::PI_NODE
    "<?#{node.name} #{node.content}?>"
  else
    node.to_s
  end
end