moxml 0.5.12 → 0.5.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,497 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Adapter
5
+ class Libxml
6
+ # Wire-format knowledge: the C-engine fast path with its guards,
7
+ # the Ruby fallback walker family, namespace-aware emission, and
8
+ # the ReDoS-hardened empty-element expansion.
9
+ module Serialize
10
+ # Possessive-quantifier form: no backtracking, so pathological
11
+ # attribute content cannot blow up the expansion pass.
12
+ EMPTY_ELEMENT_EXPANSION_RE = %r{
13
+ <([A-Za-z_][\w.:-]*+)
14
+ ((?:"[^"]*+"|'[^']*+'|[^<>"'/]++)*+)
15
+ />
16
+ }x
17
+ private_constant :EMPTY_ELEMENT_EXPANSION_RE
18
+
19
+ NON_WHITESPACE_RE = /\S/
20
+ private_constant :NON_WHITESPACE_RE
21
+
22
+ def serialize(node, options = {})
23
+ # FIRST: Check if node is any kind of wrapper with custom to_xml
24
+ if node.is_a?(CustomizedLibxml::Node) || node.is_a?(DoctypeWrapper)
25
+ return node.to_xml
26
+ end
27
+
28
+ native_node = unpatch_node(node)
29
+ return "" unless native_node
30
+
31
+ if native_node.is_a?(::LibXML::XML::Document)
32
+ output = +""
33
+
34
+ # Check if we should include declaration
35
+ # Priority: explicit no_declaration option > default (include)
36
+ should_include_decl = if options.key?(:no_declaration)
37
+ !options[:no_declaration]
38
+ else
39
+ # Default: include declaration
40
+ true
41
+ end
42
+
43
+ if should_include_decl
44
+ # Check if declaration was explicitly managed
45
+ decl = attachments.get(native_node, :declaration)
46
+ if decl
47
+ # Only output declaration if it exists and wasn't removed
48
+ output << decl.to_xml unless decl.removed
49
+ else
50
+ # No declaration stored - create default
51
+ version = native_node.version || "1.0"
52
+ encoding_val = options[:encoding] ||
53
+ encoding_to_string(native_node.encoding) ||
54
+ "UTF-8"
55
+
56
+ # Don't add standalone="yes" by default - only if explicitly set
57
+ decl = CustomizedLibxml::Declaration.new(
58
+ native_node,
59
+ version,
60
+ encoding_val,
61
+ nil, # No standalone by default
62
+ )
63
+ attachments.set(native_node, :declaration, decl)
64
+ output << decl.to_xml
65
+ end
66
+ end
67
+
68
+ # Add DOCTYPE if stored on document
69
+ doctype_wrapper = attachments.get(native_node, :doctype)
70
+ if doctype_wrapper
71
+ output << "\n" unless output.empty?
72
+ output << doctype_wrapper.to_xml
73
+ end
74
+
75
+ # Parse-time document-level parts live on the chain around
76
+ # the root (prolog/epilog PIs and comments); programmatic
77
+ # ones stay in attachments. The chain nodes serialize in
78
+ # document order around the root output below.
79
+ chain_pre = []
80
+ chain_post = []
81
+ if native_node.root
82
+ past_root = false
83
+ chain_node = native_node.child
84
+ while chain_node
85
+ case chain_node.node_type
86
+ when ::LibXML::XML::Node::ELEMENT_NODE then past_root = true
87
+ when ::LibXML::XML::Node::PI_NODE, ::LibXML::XML::Node::COMMENT_NODE
88
+ (past_root ? chain_post : chain_pre) << chain_node.to_s
89
+ end
90
+ chain_node = chain_node.next
91
+ end
92
+ end
93
+ chain_pre.each do |part|
94
+ output << "\n" unless output.empty?
95
+ output << part
96
+ end
97
+
98
+ # Add document-level processing instructions if stored
99
+ pis = attachments.get(native_node, :pis)
100
+ if pis && !pis.empty?
101
+ pis.each do |pi|
102
+ output << "\n" unless output.empty?
103
+ output << pi.to_xml
104
+ end
105
+ end
106
+
107
+ # Add text nodes if stored (for documents without root)
108
+ texts = attachments.get(native_node, :texts)
109
+ if texts && !texts.empty?
110
+ texts.each do |text|
111
+ output << "\n" unless output.empty?
112
+ output << text.to_xml
113
+ end
114
+ end
115
+
116
+ if native_node.root
117
+ indent_size = options[:indent].is_a?(Integer) && options[:indent].positive? ? options[:indent] : 0
118
+ # `eref_active` is computed once here and threaded through the
119
+ # recursion so that the per-element `attachments.key?` Monitor
120
+ # sync only fires for docs that actually have entity refs.
121
+ eref_active = entity_ref_registry(native_node).active?
122
+ root_output = native_root_output(native_node, indent_size)
123
+ root_output ||= serialize_element_with_namespaces(
124
+ native_node.root,
125
+ include_ns: true,
126
+ indent_size: indent_size,
127
+ depth: 0,
128
+ eref_active: eref_active,
129
+ )
130
+
131
+ output << "\n" << root_output unless output.empty?
132
+ output << root_output if output.empty?
133
+ end
134
+
135
+ unless chain_post.empty?
136
+ output << "\n" unless output.empty?
137
+ output << chain_post.join("\n") << "\n"
138
+ end
139
+
140
+ output
141
+ else
142
+ serialize_element_with_namespaces(native_node, include_ns: true)
143
+ end
144
+ end
145
+
146
+ # Serialize the root subtree with libxml's C serializer plus
147
+ # moxml-canonical corrections — roughly 25x faster and ~2600x
148
+ # fewer allocations than the Ruby walker. Returns nil whenever
149
+ # a guard says the walker is still required.
150
+ def native_root_output(native_doc, indent_size)
151
+ return nil unless indent_size == 2
152
+ return nil if entity_ref_registry(native_doc).active?
153
+
154
+ output = native_doc.root.to_s
155
+
156
+ # The walker strips prefixed names on parsed namespaced
157
+ # children; that behavior is load-bearing, so namespaced
158
+ # output keeps the walker.
159
+ return nil if output.include?("xmlns")
160
+
161
+ # The native serializer escapes non-ASCII codepoints in
162
+ # attribute values as numeric character references
163
+ # (x="&#xA9;"); the walker emits literal UTF-8
164
+ return nil if output.include?("&#x")
165
+
166
+ # Layout: pull closing tags onto the last child's line
167
+ output = output.gsub(/\n[ \t]*(<\/[\w.:-]+>)/, '\1')
168
+
169
+ if output.include?("/>")
170
+ # A literal "/>" inside a comment or CDATA section would be
171
+ # falsely expanded — the segment-aware walker is correct here
172
+ return nil if output.include?("<!--") || output.include?("<![CDATA[")
173
+
174
+ output = output.gsub(EMPTY_ELEMENT_EXPANSION_RE, '<\1\2></\1>')
175
+ end
176
+
177
+ # Native escapes attribute apostrophes; moxml keeps them literal
178
+ output.gsub("&apos;", "'")
179
+ end
180
+
181
+ def serialize_element(elem)
182
+ output = "<#{elem.name}"
183
+
184
+ # Add namespace definitions (only on this element, not ancestors)
185
+ if elem.is_a?(::LibXML::XML::Node)
186
+ seen_ns = {}
187
+ elem.namespaces.each do |ns|
188
+ prefix = ns.prefix
189
+ uri = ns.href
190
+ next if seen_ns.key?(prefix)
191
+
192
+ seen_ns[prefix] = true
193
+ output << if prefix.nil? || prefix.empty?
194
+ " xmlns=\"#{XmlEmitter.escape_attribute(uri)}\""
195
+ else
196
+ " xmlns:#{prefix}=\"#{XmlEmitter.escape_attribute(uri)}\""
197
+ end
198
+ end
199
+ end
200
+
201
+ # Add attributes
202
+ if elem.attributes?
203
+ elem.each_attr do |attr|
204
+ next if attr.name.start_with?("xmlns")
205
+
206
+ # Include namespace prefix if attribute has one
207
+ attr_name = if attr.ns&.prefix
208
+ "#{attr.ns.prefix}:#{attr.name}"
209
+ else
210
+ attr.name
211
+ end
212
+ output << " #{attr_name}=\"#{XmlEmitter.escape_attribute(attr.value)}\""
213
+ end
214
+ end
215
+
216
+ # Always use verbose format <tag></tag> for consistency with other adapters
217
+ output << ">"
218
+ if elem.children?
219
+ elem.each_child do |child|
220
+ # Skip whitespace-only text nodes
221
+ next if blank_text_node?(child)
222
+
223
+ output << serialize_node(child)
224
+ end
225
+ end
226
+
227
+ # Append any EntityReference wrappers stored on the document
228
+ doc = elem.doc
229
+ entity_refs = entity_ref_registry(doc).refs_for(elem)
230
+ entity_refs&.each { |ref| output << ref.to_xml }
231
+
232
+ output << "</#{elem.name}>"
233
+
234
+ output
235
+ end
236
+
237
+ def serialize_node(node)
238
+ # Check if node is a wrapper with to_xml method
239
+ case node
240
+ when CustomizedLibxml::ProcessingInstruction,
241
+ CustomizedLibxml::Comment,
242
+ CustomizedLibxml::Cdata,
243
+ CustomizedLibxml::Text,
244
+ CustomizedLibxml::EntityReference
245
+ return node.to_xml
246
+ end
247
+
248
+ case node.node_type
249
+ when ::LibXML::XML::Node::ELEMENT_NODE
250
+ serialize_element(node)
251
+ when ::LibXML::XML::Node::TEXT_NODE
252
+ XmlEmitter.escape_text(node.content)
253
+ when ::LibXML::XML::Node::CDATA_SECTION_NODE
254
+ "<![CDATA[#{node.content}]]>"
255
+ when ::LibXML::XML::Node::COMMENT_NODE
256
+ "<!-- #{node.content} -->"
257
+ when ::LibXML::XML::Node::PI_NODE
258
+ "<?#{node.name} #{node.content}?>"
259
+ else
260
+ node.to_s
261
+ end
262
+ end
263
+
264
+ def serialize_element_with_namespaces(elem, include_ns: true,
265
+ indent_size: 0, depth: 0,
266
+ eref_active: nil)
267
+ # Cache elem.name — it's a libxml C call we'd otherwise make
268
+ # twice (open tag + close tag). Concat with `<<` instead of
269
+ # `"<#{name}"` to avoid the interpolated intermediate string.
270
+ name = elem.name
271
+ output = +"<"
272
+ output << name
273
+ emit_namespace_definitions(output, elem, include_ns)
274
+ emit_attributes(output, elem)
275
+
276
+ # `eref_active` is precomputed at the top-level `serialize` call
277
+ # and threaded down — when nil (top-level non-recursive call into
278
+ # this method), look it up; when false, skip the per-element doc
279
+ # attachment query that otherwise fires for every element under
280
+ # Monitor#synchronize.
281
+ eref_active = doc_eref_active?(elem.doc) if eref_active.nil?
282
+ entity_refs, child_sequence = if eref_active
283
+ lookup_entity_ref_serialization(elem)
284
+ else
285
+ [
286
+ nil, nil
287
+ ]
288
+ end
289
+
290
+ # Always use verbose format <tag></tag> for consistency with other adapters
291
+ output << ">"
292
+
293
+ if entity_refs && child_sequence
294
+ emit_eref_interleaved_children(output, elem, entity_refs, child_sequence,
295
+ indent_size, depth, eref_active: eref_active)
296
+ elsif elem.children?
297
+ emit_children_with_layout(output, elem, indent_size, depth,
298
+ eref_active: eref_active)
299
+ end
300
+
301
+ output << "</" << name << ">"
302
+ output
303
+ end
304
+
305
+ # Emit `xmlns`/`xmlns:foo` declarations onto `output`. On the root
306
+ # (`include_ns: true`) we emit ALL definitions; on children we
307
+ # emit only definitions that OVERRIDE a parent's same-prefix URI.
308
+ # Skips the whole block when the element has no local definitions,
309
+ # which is the common case for child elements in unnamespaced docs.
310
+ def emit_namespace_definitions(output, elem, include_ns)
311
+ return unless elem.is_a?(::LibXML::XML::Node)
312
+
313
+ ns_list = elem.namespaces
314
+ return unless ns_list.is_a?(::LibXML::XML::Namespaces)
315
+
316
+ definitions = ns_list.definitions
317
+ return if definitions.empty?
318
+
319
+ parent_ns_defs = include_ns ? nil : parent_namespace_defs(elem)
320
+ seen_ns = nil
321
+
322
+ definitions.each do |ns|
323
+ prefix = ns.prefix
324
+ uri = ns.href
325
+ next unless include_ns ||
326
+ (parent_ns_defs&.key?(prefix) && parent_ns_defs[prefix] != uri)
327
+
328
+ seen_ns ||= {}
329
+ next if seen_ns.key?(prefix)
330
+
331
+ seen_ns[prefix] = true
332
+ output << format_ns_declaration(prefix, uri)
333
+ end
334
+ end
335
+
336
+ def parent_namespace_defs(elem)
337
+ parent = elem.parent
338
+ return nil unless parent.is_a?(::LibXML::XML::Node)
339
+
340
+ defs = {}
341
+ parent.namespaces.each { |ns| defs[ns.prefix] = ns.href }
342
+ defs
343
+ end
344
+
345
+ def format_ns_declaration(prefix, uri)
346
+ if prefix.nil? || prefix.empty?
347
+ " xmlns=\"#{XmlEmitter.escape_attribute(uri)}\""
348
+ else
349
+ " xmlns:#{prefix}=\"#{XmlEmitter.escape_attribute(uri)}\""
350
+ end
351
+ end
352
+
353
+ def emit_attributes(output, elem)
354
+ return unless elem.attributes?
355
+
356
+ elem.each_attr do |attr|
357
+ next if attr.name.start_with?("xmlns")
358
+
359
+ attr_name = attr.ns&.prefix ? "#{attr.ns.prefix}:#{attr.name}" : attr.name
360
+ output << " #{attr_name}=\"#{XmlEmitter.escape_attribute(attr.value)}\""
361
+ end
362
+ end
363
+
364
+ # Returns [entity_refs, child_sequence] when the element has
365
+ # interleaved entity references that the serializer needs to
366
+ # weave back into the native child stream — otherwise [nil, nil].
367
+ #
368
+ # The caller is responsible for gating this with `eref_active`
369
+ # (precomputed once per `serialize` call). When `eref_active` is
370
+ # false this method is never entered, so the per-element doc
371
+ # attachment query never fires.
372
+ def lookup_entity_ref_serialization(elem)
373
+ doc = elem.doc
374
+ return [nil, nil] unless doc
375
+
376
+ entity_ref_registry(doc).serialization_for(elem)
377
+ end
378
+
379
+ def emit_eref_interleaved_children(output, elem, entity_refs, child_sequence,
380
+ indent_size, depth, eref_active:)
381
+ native_children = collect_non_blank_children(elem)
382
+ child_pad = indent_size.positive? ? " " * (indent_size * (depth + 1)) : nil
383
+ eref_idx = 0
384
+ native_idx = 0
385
+ prev_block = true
386
+
387
+ child_sequence.each do |type|
388
+ case type
389
+ when :native
390
+ if native_idx < native_children.size
391
+ child = native_children[native_idx]
392
+ is_text_like = child.text? || child.cdata?
393
+ if prev_block && !is_text_like
394
+ output << "\n"
395
+ output << child_pad if child_pad
396
+ end
397
+ prev_block = !is_text_like
398
+
399
+ output << serialize_child_to_xml(
400
+ child, indent_size: indent_size, depth: depth,
401
+ eref_active: eref_active
402
+ )
403
+ native_idx += 1
404
+ end
405
+ when :eref
406
+ if eref_idx < entity_refs.size
407
+ output << entity_refs[eref_idx].to_xml
408
+ eref_idx += 1
409
+ prev_block = false
410
+ end
411
+ end
412
+ end
413
+ end
414
+
415
+ def blank_text_node?(child)
416
+ child.text? && blank_content?(child.content)
417
+ end
418
+
419
+ def blank_content?(content)
420
+ content.nil? || !content.match?(NON_WHITESPACE_RE)
421
+ end
422
+
423
+ def collect_non_blank_children(elem)
424
+ children = []
425
+ return children unless elem.children?
426
+
427
+ elem.each_child do |c|
428
+ children << c unless blank_text_node?(c)
429
+ end
430
+ children
431
+ end
432
+
433
+ # Walk native children once and emit them with the same newline +
434
+ # indentation layout the old `add_newlines_to_xml` + `indent_xml`
435
+ # post-passes produced — but in a single recursion with no string
436
+ # rescanning.
437
+ #
438
+ # Newline rule (matching `>(?=<(?!/))` with CDATA-placeholder
439
+ # protection): emit `\n` + per-level padding before a child iff
440
+ # the previous emitted sibling was block-level (ended with `>`)
441
+ # AND the current sibling is block-level. Text and CDATA count
442
+ # as text-like and suppress the newline on both sides (the
443
+ # original CDATA placeholder broke the `>...<` adjacency
444
+ # symmetrically).
445
+ def emit_children_with_layout(output, elem, indent_size, depth,
446
+ eref_active:)
447
+ child_pad = indent_size.positive? ? " " * (indent_size * (depth + 1)) : nil
448
+ prev_block = true
449
+
450
+ elem.each_child do |child|
451
+ # Cache text? — used twice per child (whitespace skip + is_text_like).
452
+ # For element children (the common case) both calls return false, so
453
+ # caching saves a libxml C call.
454
+ is_text = child.text?
455
+ next if is_text && blank_content?(child.content)
456
+
457
+ is_text_like = is_text || child.cdata?
458
+ if prev_block && !is_text_like
459
+ output << "\n"
460
+ output << child_pad if child_pad
461
+ end
462
+ prev_block = !is_text_like
463
+
464
+ output << serialize_child_to_xml(child, indent_size: indent_size, depth: depth,
465
+ eref_active: eref_active)
466
+ end
467
+ end
468
+
469
+ # Serialize one child node. Elements recurse into the layout-aware
470
+ # path; non-element wrappers route through their own `to_xml`;
471
+ # everything else falls through to the per-type serializer.
472
+ # `indent_size:` and `depth:` are required to force callers to
473
+ # decide whether the child should inherit the parent's indent
474
+ # state — the entity-ref interleave path deliberately passes 0/0.
475
+ #
476
+ # Element fast-path checked first to avoid allocating a wrapper
477
+ # we'd immediately throw away (elements always recurse on the
478
+ # raw native node, not the wrapper). For a typical document this
479
+ # skips wrapper allocation for the majority of children.
480
+ def serialize_child_to_xml(child, indent_size:, depth:, eref_active:)
481
+ if child.element?
482
+ return serialize_element_with_namespaces(child, include_ns: false,
483
+ indent_size: indent_size, depth: depth + 1,
484
+ eref_active: eref_active)
485
+ end
486
+
487
+ wrapped_child = patch_node(child)
488
+ if wrapped_child.is_a?(CustomizedLibxml::Node)
489
+ wrapped_child.to_xml
490
+ else
491
+ serialize_node(child)
492
+ end
493
+ end
494
+ end
495
+ end
496
+ end
497
+ end