moxml 0.1.26 → 0.2.0

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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/windows.yml +62 -0
  3. data/Gemfile +3 -1
  4. data/README.adoc +69 -6
  5. data/lib/compat/opal/moxml_boot.rb +5 -0
  6. data/lib/moxml/adapter/base.rb +53 -49
  7. data/lib/moxml/adapter/customized_leptris/declaration.rb +32 -0
  8. data/lib/moxml/adapter/customized_leptris/doctype.rb +31 -0
  9. data/lib/moxml/adapter/customized_leptris/entity_reference.rb +12 -0
  10. data/lib/moxml/adapter/customized_leptris/text_segment.rb +26 -0
  11. data/lib/moxml/adapter/customized_leptris.rb +16 -0
  12. data/lib/moxml/adapter/customized_libxml/cdata.rb +3 -12
  13. data/lib/moxml/adapter/customized_libxml/comment.rb +2 -9
  14. data/lib/moxml/adapter/customized_libxml/declaration.rb +1 -10
  15. data/lib/moxml/adapter/customized_libxml/node.rb +8 -0
  16. data/lib/moxml/adapter/customized_libxml/processing_instruction.rb +2 -10
  17. data/lib/moxml/adapter/customized_libxml/text.rb +2 -9
  18. data/lib/moxml/adapter/customized_oga/entity_decoder.rb +37 -0
  19. data/lib/moxml/adapter/customized_oga/raw_value_override.rb +52 -0
  20. data/lib/moxml/adapter/customized_oga/xml_generator.rb +4 -34
  21. data/lib/moxml/adapter/customized_oga.rb +2 -0
  22. data/lib/moxml/adapter/customized_ox/entity_reference.rb +1 -17
  23. data/lib/moxml/adapter/customized_rexml/entity_reference.rb +1 -11
  24. data/lib/moxml/adapter/headed_ox.rb +9 -128
  25. data/lib/moxml/adapter/leptris.rb +965 -0
  26. data/lib/moxml/adapter/libxml.rb +131 -99
  27. data/lib/moxml/adapter/nokogiri.rb +29 -8
  28. data/lib/moxml/adapter/oga.rb +91 -54
  29. data/lib/moxml/adapter/ox.rb +93 -137
  30. data/lib/moxml/adapter/rexml.rb +61 -50
  31. data/lib/moxml/adapter.rb +2 -1
  32. data/lib/moxml/attribute.rb +8 -4
  33. data/lib/moxml/attribute_resolver.rb +189 -0
  34. data/lib/moxml/config.rb +29 -3
  35. data/lib/moxml/context.rb +12 -0
  36. data/lib/moxml/element.rb +29 -12
  37. data/lib/moxml/entity/reference.rb +28 -0
  38. data/lib/moxml/entity.rb +125 -0
  39. data/lib/moxml/node.rb +90 -1
  40. data/lib/moxml/sax/namespace_splitter.rb +5 -2
  41. data/lib/moxml/signature/model/key/dsa_key_value.rb +1 -2
  42. data/lib/moxml/version.rb +1 -1
  43. data/lib/moxml/xml_emitter.rb +71 -0
  44. data/lib/moxml/xpath/compiler.rb +60 -7
  45. data/lib/moxml/xpath/parser.rb +22 -15
  46. data/lib/moxml.rb +3 -0
  47. data/spec/examples/readme_examples_spec.rb +0 -4
  48. data/spec/examples/xpath_examples_spec.rb +0 -11
  49. data/spec/integration/all_adapters_spec.rb +17 -0
  50. data/spec/integration/sax_parity_spec.rb +58 -0
  51. data/spec/integration/shared_examples/edge_cases.rb +0 -10
  52. data/spec/integration/shared_examples/integration_workflows.rb +0 -10
  53. data/spec/integration/shared_examples/node_wrappers/attribute_behavior.rb +98 -0
  54. data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +26 -0
  55. data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +0 -8
  56. data/spec/moxml/adapter/leptris_spec.rb +75 -0
  57. data/spec/moxml/adapter/ox_spec.rb +20 -6
  58. data/spec/moxml/adapter/platform_spec.rb +15 -2
  59. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +180 -0
  60. data/spec/moxml/attribute_resolver_spec.rb +108 -0
  61. data/spec/moxml/doctype_spec.rb +1 -1
  62. data/spec/moxml/entity_spec.rb +76 -0
  63. data/spec/moxml/node_spec.rb +104 -0
  64. data/spec/moxml/sax_entity_parity_spec.rb +358 -0
  65. data/spec/moxml/xml_emitter_spec.rb +64 -0
  66. data/spec/moxml/xpath/axes_spec.rb +72 -0
  67. data/spec/moxml/xpath/parser_spec.rb +6 -0
  68. data/spec/moxml/xpath_capabilities_spec.rb +5 -3
  69. data/spec/performance/benchmark_spec.rb +13 -6
  70. data/spec/performance/thread_safety_spec.rb +0 -4
  71. data/spec/spec_helper.rb +5 -1
  72. metadata +21 -2
@@ -0,0 +1,965 @@
1
+ # frozen_string_literal: true
2
+
3
+ return if RUBY_ENGINE == "opal"
4
+
5
+ require "stringio"
6
+ require "leptris"
7
+ # leptris-ruby 1.9.0 regression (leptris-ruby#53): the eager ffi load
8
+ # shadows the Leptris::XML autoload manifest, leaving Document/Element
9
+ # unreachable. Loading the manifest explicitly is a no-op when the gem
10
+ # is fixed.
11
+ require "leptris/xml"
12
+
13
+ module Moxml
14
+ module Adapter
15
+ # Adapter over the leptris FFI binding (libleptris C library).
16
+ #
17
+ # libleptris provides DOM parsing, a native XPath 1.0 engine, SAX,
18
+ # and serialization. It has no first-class XML declaration or
19
+ # programmatic DOCTYPE, so those live in CustomizedLeptris value
20
+ # objects stored through NativeAttachment.
21
+ class Leptris < Base
22
+ class << self
23
+ def attachments
24
+ @attachments ||= Moxml::NativeAttachment.new
25
+ end
26
+
27
+ def set_root(doc, element)
28
+ doc.root = element
29
+ end
30
+
31
+ def parse(xml, options = {}, _context = nil)
32
+ xml_string = xml.is_a?(IO) || xml.is_a?(StringIO) ? xml.read : xml.to_s
33
+ processed = preprocess_entities(xml_string)
34
+
35
+ native_doc = begin
36
+ ::Leptris::XML::Document.parse(processed)
37
+ rescue ::Leptris::XML::ParseError => e
38
+ # libleptris has no recovery mode that survives unclosed
39
+ # tags; non-strict callers get an empty document, matching
40
+ # the Libxml adapter's non-strict behavior.
41
+ raise Moxml::ParseError.new(e.message) if options[:strict]
42
+
43
+ create_document
44
+ end
45
+ ctx = _context || Context.new(:leptris)
46
+ doc = Document.new(native_doc, ctx)
47
+
48
+ record_source_declaration(native_doc, processed)
49
+
50
+ doc
51
+ end
52
+
53
+ def create_document(_native_doc = nil)
54
+ ::Leptris::XML::Document.create
55
+ end
56
+
57
+ def create_native_element(name, owner_doc = nil)
58
+ (owner_doc || create_document).create_element(name.to_s)
59
+ end
60
+
61
+ def create_native_text(content, owner_doc = nil)
62
+ (owner_doc || create_document).create_text_node(content)
63
+ end
64
+
65
+ def create_native_cdata(content, owner_doc = nil)
66
+ (owner_doc || create_document).create_cdata(content)
67
+ end
68
+
69
+ def create_native_comment(content, owner_doc = nil)
70
+ (owner_doc || create_document).create_comment(content)
71
+ end
72
+
73
+ def create_native_processing_instruction(target, content)
74
+ doc = create_document
75
+ doc.create_processing_instruction(target.to_s, content.to_s)
76
+ end
77
+
78
+ def create_native_doctype(name, external_id, system_id)
79
+ CustomizedLeptris::Doctype.new(name, external_id, system_id)
80
+ end
81
+
82
+ def create_native_declaration(version, encoding, standalone)
83
+ CustomizedLeptris::Declaration.new(version, encoding, standalone)
84
+ end
85
+
86
+ def create_native_entity_reference(name)
87
+ CustomizedLeptris::EntityReference.new(name)
88
+ end
89
+
90
+ def entity_reference_name(node)
91
+ node.name if node.is_a?(CustomizedLeptris::EntityReference)
92
+ end
93
+
94
+ def declaration_attribute(declaration, attr_name)
95
+ declaration.public_send(attr_name) if attr_matches?(attr_name)
96
+ end
97
+
98
+ def set_declaration_attribute(declaration, attr_name, value)
99
+ declaration.public_send("#{attr_name}=", value) if attr_matches?(attr_name)
100
+ end
101
+
102
+ def attr_matches?(attr_name)
103
+ %w[version encoding standalone].include?(attr_name.to_s)
104
+ end
105
+ private :attr_matches?
106
+
107
+ def has_declaration?(native_doc, _wrapper)
108
+ return true if attachments.get(native_doc, :declaration)
109
+
110
+ attachments.key?(native_doc, :had_source_declaration) &&
111
+ attachments.get(native_doc, :had_source_declaration)
112
+ end
113
+
114
+ def remove_declaration(native_doc)
115
+ attachments.delete(native_doc, :declaration)
116
+ attachments.delete(native_doc, :had_source_declaration)
117
+ end
118
+
119
+ def create_native_namespace(element, prefix, uri)
120
+ element.add_namespace_definition(prefix, uri)
121
+ end
122
+
123
+ def set_namespace(node, namespace)
124
+ return set_attribute_namespace(node, namespace) if node.is_a?(::Leptris::XML::Attr)
125
+
126
+ element = node
127
+ prefix = namespace.is_a?(String) ? nil : namespace.prefix
128
+ uri = namespace.is_a?(String) ? namespace : namespace.href
129
+ if prefix.nil? || prefix.empty?
130
+ element.default_namespace = uri.to_s
131
+ # Drop any prefix from the element name: a default namespace
132
+ # never applies to a prefixed name.
133
+ element.name = element.name.split(":", 2)[-1] if element.name.include?(":")
134
+ else
135
+ element.add_namespace_definition(prefix, uri.to_s)
136
+ element.name = "#{prefix}:#{element.name.split(':', 2)[-1]}"
137
+ end
138
+ element
139
+ end
140
+
141
+ # Attr is an immutable value object: namespace changes go
142
+ # through the owning element, recreating the attribute with a
143
+ # qualified name, and yield a fresh native for the wrapper.
144
+ def set_attribute_namespace(attr, namespace)
145
+ element = attr.element
146
+ local = attr.name.include?(":") ? attr.name.split(":", 2)[1] : attr.name
147
+ value = attr.value
148
+ element.remove_attribute(attr.name)
149
+ prefix = namespace.is_a?(String) ? nil : namespace.prefix
150
+ uri = namespace.is_a?(String) ? namespace : namespace.href
151
+ qualified = prefix.nil? || prefix.empty? ? local : "#{prefix}:#{local}"
152
+ element[qualified] = value
153
+ if prefix && !prefix.empty? && !uri.to_s.empty? && !resolve_prefix_ns(element, prefix)
154
+ element.add_namespace_definition(prefix, uri.to_s)
155
+ end
156
+ element.attribute_nodes.reverse.find { |candidate| candidate.name == qualified }
157
+ end
158
+
159
+ def namespace(node)
160
+ # The binding resolves Attr#namespace to the declaration but
161
+ # without the prefix; when the qualified name carries one,
162
+ # prefer the in-scope declaration that binds it so wrappers
163
+ # keep prefix and uri.
164
+ if node.is_a?(::Leptris::XML::Attr) &&
165
+ (prefix = node.prefix || prefix_part(node.name))
166
+ resolved = resolve_prefix_ns(node.element, prefix)
167
+ return resolved if resolved
168
+ end
169
+
170
+ ns = node.namespace
171
+ return ns unless ns.nil?
172
+
173
+ # Set-side namespaces (created attributes, renamed elements)
174
+ # carry a prefix the native resolver did not bind; resolve
175
+ # through the scope chain so wrapper-level access stays
176
+ # XML-correct.
177
+ owner = node.is_a?(::Leptris::XML::Attr) ? node.element : node
178
+ prefix = if node.is_a?(::Leptris::XML::Attr)
179
+ node.prefix || prefix_part(node.name)
180
+ else
181
+ prefix_part(node.name)
182
+ end
183
+ return nil if prefix.nil?
184
+
185
+ resolve_prefix_ns(owner, prefix)
186
+ end
187
+
188
+ # Nearest in-scope declaration of prefix, walking owner then
189
+ # ancestors. Unbound prefix → nil. Returns the Namespace object
190
+ # so wrappers keep prefix and uri.
191
+ def resolve_prefix_ns(owner, prefix)
192
+ current = owner
193
+ while current.is_a?(::Leptris::XML::Element)
194
+ hit = current.namespace_definitions.find { |ns| ns.prefix == prefix }
195
+ return hit if hit
196
+
197
+ current = current.parent
198
+ end
199
+ nil
200
+ end
201
+
202
+ def prefix_part(name)
203
+ name.include?(":") ? name.split(":", 2)[0] : nil
204
+ end
205
+
206
+ def processing_instruction_target(node)
207
+ node.target
208
+ end
209
+
210
+ def node_type(node)
211
+ case node
212
+ when ::Leptris::XML::Document then :document
213
+ when ::Leptris::XML::DocType, CustomizedLeptris::Doctype then :doctype
214
+ when CustomizedLeptris::Declaration then :declaration
215
+ when CustomizedLeptris::EntityReference then :entity_reference
216
+ when ::Leptris::XML::CDATA then :cdata
217
+ when ::Leptris::XML::Comment then :comment
218
+ when ::Leptris::XML::ProcessingInstruction then :processing_instruction
219
+ when ::Leptris::XML::Text, CustomizedLeptris::TextSegment then :text
220
+ when ::Leptris::XML::Element then :element
221
+ when ::Leptris::XML::Attr then :attribute
222
+ else :unknown
223
+ end
224
+ end
225
+
226
+ def node_name(node)
227
+ return node.root_name if node.is_a?(::Leptris::XML::DocType)
228
+
229
+ node.name.to_s.dup.force_encoding("UTF-8")
230
+ end
231
+
232
+ def set_node_name(node, name)
233
+ case node
234
+ when ::Leptris::XML::ProcessingInstruction then node.target = name
235
+ else node.name = name
236
+ end
237
+ end
238
+
239
+ def duplicate_node(node)
240
+ case node
241
+ when CustomizedLeptris::Declaration
242
+ CustomizedLeptris::Declaration.new(node.version, node.encoding, node.standalone)
243
+ when CustomizedLeptris::Doctype
244
+ CustomizedLeptris::Doctype.new(node.name, node.external_id, node.system_id)
245
+ when CustomizedLeptris::EntityReference
246
+ CustomizedLeptris::EntityReference.new(node.name)
247
+ else
248
+ node.dup
249
+ end
250
+ end
251
+
252
+ def children(node)
253
+ case node
254
+ when ::Leptris::XML::Document
255
+ assemble_document_children(node)
256
+ when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
257
+ CustomizedLeptris::EntityReference, CustomizedLeptris::TextSegment
258
+ []
259
+ else
260
+ split_entity_markers(node.children.to_a, node)
261
+ end
262
+ end
263
+
264
+ # Expand marker-bearing text nodes into the child sequence the
265
+ # moxml contract exposes: text, EntityReference, text, ...
266
+ def split_entity_markers(natives, parent)
267
+ result = []
268
+ natives.each do |child|
269
+ content = child.content.to_s.dup.force_encoding("UTF-8") if child.is_a?(::Leptris::XML::Text)
270
+ if content&.include?(Entity::MARKER)
271
+ content.scan(/([^#{Entity::MARKER}]*)(?:#{Entity::MARKER}([\w.:-]+);)?/o) do
272
+ text_part = Regexp.last_match(1)
273
+ name = Regexp.last_match(2)
274
+ result << CustomizedLeptris::TextSegment.new(text_part, parent) unless text_part.empty?
275
+ result << CustomizedLeptris::EntityReference.new(name) if name
276
+ end
277
+ else
278
+ result << child
279
+ end
280
+ end
281
+ result
282
+ end
283
+
284
+ def parent(node)
285
+ case node
286
+ when ::Leptris::XML::Document then nil
287
+ when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype then node.parent_doc
288
+ when CustomizedLeptris::TextSegment, CustomizedLeptris::EntityReference then node.parent
289
+ else
290
+ # The binding reports the root element as parentless; the
291
+ # moxml contract roots at the document.
292
+ node.parent || (node.document&.root == node ? node.document : nil)
293
+ end
294
+ end
295
+
296
+ def next_sibling(node)
297
+ node.next_sibling if node.is_a?(::Leptris::XML::Node)
298
+ end
299
+
300
+ def previous_sibling(node)
301
+ node.previous_sibling if node.is_a?(::Leptris::XML::Node)
302
+ end
303
+
304
+ def document(node)
305
+ case node
306
+ when ::Leptris::XML::Document then node
307
+ when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype then node.parent_doc
308
+ else node.document
309
+ end
310
+ end
311
+
312
+ def root(document)
313
+ document.root
314
+ end
315
+
316
+ def line_number(node)
317
+ return nil unless node.is_a?(::Leptris::XML::Node)
318
+
319
+ line = node.line
320
+ line.zero? ? nil : line
321
+ end
322
+
323
+ def attributes(element)
324
+ element.attribute_nodes
325
+ end
326
+
327
+ def attribute_element(attr)
328
+ attr.element
329
+ end
330
+
331
+ def attribute_name(attr)
332
+ # leptris Attr names are qualified; the wrapper composes the
333
+ # prefix, so expose the local part.
334
+ return attr.name.split(":", 2)[1] if attr.name.include?(":")
335
+
336
+ attr.name.to_s.dup.force_encoding("UTF-8")
337
+ end
338
+
339
+ def set_attribute(element, name, value)
340
+ element[name.to_s] = value.to_s
341
+ end
342
+
343
+ def set_attribute_name(attr, name)
344
+ # Attr is an immutable value object; renames go through the
345
+ # element and yield a fresh native, which the wrapper adopts.
346
+ element = attr.element
347
+ value = attr.value
348
+ element.remove_attribute(attr.name)
349
+ element[name.to_s] = value
350
+ element.attribute_nodes.reverse.find { |candidate| candidate.name == name.to_s }
351
+ end
352
+
353
+ def set_attribute_value(attr, value)
354
+ attr.value = value.to_s
355
+ attr
356
+ end
357
+
358
+ def get_attribute(element, name)
359
+ element.attribute_nodes.find { |attr| attr.name == name.to_s }
360
+ end
361
+
362
+ def get_attribute_value(element, name)
363
+ element[name.to_s]
364
+ end
365
+
366
+ def remove_attribute(element, name)
367
+ element.remove_attribute(name.to_s)
368
+ end
369
+
370
+ def remove_attribute_native(attr)
371
+ attr.element.remove_attribute(attr.name)
372
+ attr
373
+ end
374
+
375
+ def add_child(parent, child)
376
+ case parent
377
+ when ::Leptris::XML::Document then add_document_child(parent, child)
378
+ else
379
+ if child.is_a?(CustomizedLeptris::EntityReference)
380
+ marker = parent.document.create_text_node("#{Entity::MARKER}#{child.name};")
381
+ parent.add_child(marker)
382
+ return child
383
+ end
384
+ child = parent.document.create_text_node(child) if child.is_a?(String)
385
+ parent.add_child(child)
386
+ end
387
+ end
388
+
389
+ def add_previous_sibling(node, new_node)
390
+ # A PI inserted before the root lives at document level in
391
+ # libleptris's model, not in the element tree.
392
+ if new_node.is_a?(::Leptris::XML::ProcessingInstruction) &&
393
+ node.document&.root == node
394
+ node.document.add_pi(new_node.target, new_node.content.to_s)
395
+ return new_node
396
+ end
397
+ node.add_previous_sibling(new_node)
398
+ end
399
+
400
+ def add_next_sibling(node, new_node)
401
+ node.add_next_sibling(new_node)
402
+ end
403
+
404
+ def remove(node)
405
+ case node
406
+ when CustomizedLeptris::Declaration
407
+ remove_declaration(node.parent_doc) if node.parent_doc
408
+ when CustomizedLeptris::Doctype
409
+ attachments.delete(node.parent_doc, :doctype) if node.parent_doc
410
+ when CustomizedLeptris::EntityReference
411
+ marker_text_for(node.parent, node.name)&.unlink
412
+ else
413
+ node.unlink
414
+ end
415
+ end
416
+
417
+ def replace(node, new_node)
418
+ return node.replace(new_node) if node.is_a?(::Leptris::XML::Element)
419
+
420
+ # libleptris only offers element-anchored insertion, so a
421
+ # content node (text/comment/CDATA/PI) is replaced by
422
+ # unlinking and re-inserting: after an element sibling when
423
+ # one exists, else appended (end-of-list) to the parent.
424
+ parent = node.parent
425
+ unless parent
426
+ raise Moxml::DocumentStructureError.new(
427
+ "cannot replace a detached node",
428
+ )
429
+ end
430
+
431
+ prev = node.previous_sibling
432
+ node.unlink
433
+ if prev.is_a?(::Leptris::XML::Element)
434
+ prev.add_next_sibling(new_node)
435
+ else
436
+ parent.add_child(new_node)
437
+ end
438
+ new_node
439
+ end
440
+
441
+ def replace_children(node, new_children)
442
+ node.children = new_children
443
+ end
444
+
445
+ def text_content(node)
446
+ case node
447
+ when ::Leptris::XML::Document then node.root ? node.root.content : ""
448
+ when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
449
+ CustomizedLeptris::EntityReference
450
+ ""
451
+ when CustomizedLeptris::TextSegment then node.content
452
+ else node.content.to_s
453
+ end
454
+ end
455
+
456
+ def inner_text(node)
457
+ # moxml semantic: direct text children only — no descendant
458
+ # text (that is #text), no comments. Entity references
459
+ # contribute their serialized form.
460
+ children(node).filter_map do |child|
461
+ case child
462
+ when CustomizedLeptris::EntityReference then "&#{child.name};"
463
+ when ::Leptris::XML::CDATA then child.content
464
+ when ::Leptris::XML::Text, CustomizedLeptris::TextSegment
465
+ child.content.to_s.dup.force_encoding("UTF-8")
466
+ end
467
+ end.join
468
+ end
469
+
470
+ def set_text_content(node, content)
471
+ case node
472
+ when ::Leptris::XML::Document
473
+ node.root&.content = content.to_s
474
+ else
475
+ node.content = content.to_s
476
+ end
477
+ end
478
+
479
+ def cdata_content(node)
480
+ node.content
481
+ end
482
+
483
+ def set_cdata_content(node, content)
484
+ node.content = content.to_s
485
+ end
486
+
487
+ def comment_content(node)
488
+ node.content
489
+ end
490
+
491
+ def set_comment_content(node, content)
492
+ node.content = content.to_s
493
+ end
494
+
495
+ def processing_instruction_content(node)
496
+ node.content
497
+ end
498
+
499
+ def set_processing_instruction_content(node, content)
500
+ node.data = content.to_s
501
+ end
502
+
503
+ def namespace_prefix(namespace)
504
+ # Attr#namespace is the bare URI string in leptris 1.9+ (the
505
+ # C model: a namespace handle IS the URI); attributes carry
506
+ # their prefix on the Attr itself.
507
+ return nil if namespace.is_a?(String)
508
+
509
+ namespace.prefix
510
+ end
511
+
512
+ def namespace_uri(namespace)
513
+ return namespace if namespace.is_a?(String)
514
+
515
+ namespace.href
516
+ end
517
+
518
+ def namespace_definitions(element)
519
+ element.namespace_definitions
520
+ end
521
+
522
+ def doctype_name(node)
523
+ node.is_a?(CustomizedLeptris::Doctype) ? node.name : node.root_name
524
+ end
525
+
526
+ def doctype_external_id(node)
527
+ node.is_a?(CustomizedLeptris::Doctype) ? node.external_id : node.public_id
528
+ end
529
+
530
+ def doctype_system_id(node)
531
+ node.system_id
532
+ end
533
+
534
+ # XPath prefers libleptris' native C engine (three orders of
535
+ # magnitude faster than the Ruby engine) for document-context
536
+ # queries, with a conservative gate: the Ruby engine handles
537
+ # element-context evaluation (native relative-context
538
+ # semantics are not contract-verified), variable references,
539
+ # and expressions whose results are attribute nodes (native
540
+ # returns those without name accessors). Both engines resolve
541
+ # expression prefixes against document-declared namespaces.
542
+ NATIVE_XPATH_CACHE = XPath::Cache.new(100)
543
+
544
+ def xpath(node, expression, namespaces = {})
545
+ native = native_xpath(node, expression, namespaces)
546
+ return native unless native.nil?
547
+
548
+ engine_xpath(node, expression, namespaces)
549
+ end
550
+
551
+ def at_xpath(node, expression, namespaces = {})
552
+ native = native_xpath(node, expression, namespaces, first_only: true)
553
+ return native unless native.nil?
554
+
555
+ result = engine_xpath(node, expression, namespaces)
556
+ result.is_a?(Array) ? result.first : result
557
+ end
558
+
559
+ # @return [Array, Object, nil] native results, or nil when the
560
+ # query must run on the Ruby engine
561
+ def native_xpath(node, expression, namespaces, first_only: false)
562
+ return nil unless node.is_a?(::Leptris::XML::Document)
563
+ return nil unless native_expression?(expression)
564
+
565
+ compiled = NATIVE_XPATH_CACHE.get_or_set(expression) do
566
+ ::Leptris::XML::XPath.compile(expression)
567
+ end
568
+ result = if namespaces && !namespaces.empty?
569
+ compiled.eval(node, namespaces)
570
+ else
571
+ compiled.eval(node)
572
+ end
573
+ case result
574
+ when ::Leptris::XML::NodeSet
575
+ nodes = result.to_a
576
+ first_only ? nodes.first : nodes
577
+ else
578
+ result
579
+ end
580
+ rescue ::Leptris::XML::XPathError
581
+ # Not supported by the native engine — the Ruby engine is a
582
+ # full XPath 1.0 implementation, including Moxml's syntax
583
+ # errors for invalid expressions.
584
+ nil
585
+ end
586
+
587
+ # Document-context queries without variable references,
588
+ # attribute-node results, or the nokogiri-compat xmlns:
589
+ # reserved prefix convention.
590
+ def native_expression?(expression)
591
+ ast = XPath::Parser.parse_with_cache(expression)
592
+ return false if ast_contains_type?(ast, :variable)
593
+ return false if uses_xmlns_prefix?(ast)
594
+
595
+ !selects_attribute_results?(ast)
596
+ rescue XPath::SyntaxError
597
+ false
598
+ end
599
+
600
+ def ast_contains_type?(ast, type)
601
+ return true if ast.type == type
602
+
603
+ ast.children.any? do |child|
604
+ child.is_a?(XPath::AST::Node) && ast_contains_type?(child, type)
605
+ end
606
+ end
607
+
608
+ # xmlns:name is a nokogiri-compat convention addressing
609
+ # elements in the default namespace; only the Ruby engine
610
+ # implements it.
611
+ def uses_xmlns_prefix?(ast)
612
+ return true if ast.type == :test && ast.value[:namespace] == "xmlns"
613
+
614
+ ast.children.any? do |child|
615
+ child.is_a?(XPath::AST::Node) && uses_xmlns_prefix?(child)
616
+ end
617
+ end
618
+
619
+ def selects_attribute_results?(ast)
620
+ case ast.type
621
+ when :pipe, :union, :filter_expr
622
+ ast.children.any? { |child| selects_attribute_results?(child) }
623
+ when :absolute_path, :relative_path
624
+ step = ast.children.last
625
+ step = step.children.first if step.type == :step_with_predicates
626
+ step.type == :axis && step.children.first == "attribute"
627
+ else
628
+ false
629
+ end
630
+ end
631
+
632
+ def engine_xpath(node, expression, namespaces = {})
633
+ unless node.is_a?(Moxml::Node)
634
+ node = Moxml::Node.wrap(node, Context.new(:leptris))
635
+ end
636
+
637
+ ast = XPath::Parser.parse(expression)
638
+ proc = XPath::Compiler.compile_with_cache(ast, namespaces: namespaces)
639
+ result = proc.call(node)
640
+
641
+ case result
642
+ when Array, NodeSet
643
+ nodes = result.is_a?(NodeSet) ? result.to_a : result
644
+ seen = {}.compare_by_identity
645
+ nodes.map { |n| n.is_a?(Moxml::Node) ? n.native : n }
646
+ .select do |native|
647
+ if seen.key?(native)
648
+ false
649
+ else
650
+ seen[native] = true
651
+ true
652
+ end
653
+ end
654
+ else
655
+ result
656
+ end
657
+ end
658
+
659
+ def serialize(node, options = {})
660
+ xml = raw_serialize(node, options)
661
+ xml = restore_entities(xml)
662
+ normalize_serialization(xml, options)
663
+ end
664
+
665
+ def raw_serialize(node, options)
666
+ # CDATA must precede Text in this chain: CDATA < Text in the
667
+ # binding, so a Text branch first would swallow CDATA nodes.
668
+ case node
669
+ when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
670
+ CustomizedLeptris::EntityReference
671
+ return node.to_xml
672
+ when ::Leptris::XML::CDATA
673
+ return XmlEmitter.cdata(node.content)
674
+ when ::Leptris::XML::Comment
675
+ return "<!--#{node.content}-->"
676
+ when ::Leptris::XML::ProcessingInstruction
677
+ content = node.content.to_s
678
+ return content.empty? ? "<?#{node.target}?>" : "<?#{node.target} #{content}?>"
679
+ when ::Leptris::XML::Text, CustomizedLeptris::TextSegment
680
+ return XmlEmitter.escape_text(node.content.to_s)
681
+ when ::Leptris::XML::Document
682
+ return serialize_document(node, options)
683
+ end
684
+
685
+ include_decl = options.fetch(:declaration) do
686
+ options[:no_declaration] ? false : document_has_declaration?(node)
687
+ end
688
+ node.to_xml(
689
+ indent: options.fetch(:indent, 0),
690
+ no_decl: !include_decl,
691
+ encoding: options[:encoding],
692
+ )
693
+ end
694
+
695
+ # moxml canonical serialization: apostrophes stay literal
696
+ # (only & < > " are escaped) and empty elements expand when the
697
+ # caller asked for it — matching the other adapters' contract.
698
+ # Runs segment-aware: CDATA content is literal and must not be
699
+ # touched.
700
+ # Regions whose content is literal and must never be rewritten.
701
+ # Scanned positionally (String#index), not with a regex: the
702
+ # scan is linear in the input length, so pathological document
703
+ # content cannot blow up the serializer.
704
+ LITERAL_REGIONS = {
705
+ "<!--" => "-->",
706
+ "<![CDATA[" => "]]>",
707
+ "<?" => "?>",
708
+ }.freeze
709
+
710
+ def normalize_serialization(xml, options)
711
+ needs_apos = xml.include?("&apos;")
712
+ needs_expand = options[:expand_empty] && xml.include?("/>")
713
+ return xml unless needs_apos || needs_expand
714
+
715
+ out = +""
716
+ pos = 0
717
+ while pos < xml.length
718
+ opener_at, terminator = next_literal_region(xml, pos)
719
+ if opener_at.nil?
720
+ out << normalize_markup(xml[pos..], needs_apos, needs_expand)
721
+ break
722
+ end
723
+
724
+ out << normalize_markup(xml[pos...opener_at], needs_apos, needs_expand)
725
+ search_from = opener_at + opener_at_offset(terminator)
726
+ close = xml.index(terminator, search_from)
727
+ close_end = close.nil? ? xml.length : close + terminator.length
728
+ out << xml[opener_at...close_end]
729
+ pos = close_end
730
+ end
731
+ out
732
+ end
733
+
734
+ # Nearest literal region at/after from: [position, terminator].
735
+ def next_literal_region(xml, from)
736
+ best = nil
737
+ best_terminator = nil
738
+ LITERAL_REGIONS.each do |opener, terminator|
739
+ idx = xml.index(opener, from)
740
+ next if idx.nil?
741
+
742
+ if best.nil? || idx < best
743
+ best = idx
744
+ best_terminator = terminator
745
+ end
746
+ end
747
+ best.nil? ? nil : [best, best_terminator]
748
+ end
749
+
750
+ # Search for a terminator past its opener's overlap-safe offset
751
+ # ("-->" cannot start inside "<!--").
752
+ def opener_at_offset(terminator)
753
+ terminator == "-->" ? 4 : 0
754
+ end
755
+
756
+ # All quantifiers are possessive and the bare-part class
757
+ # excludes "/" and ">": the possessive groups can never consume
758
+ # the closing delimiter, so no backtracking is possible and the
759
+ # match is linear even on malformed tags.
760
+ EMPTY_ELEMENT_RE = %r{<([A-Za-z_][\w.:-]*+)((?:"[^"]*+"|'[^']*+'|[^<>"'/]++)*+)/>}
761
+
762
+ def normalize_markup(markup, needs_apos, needs_expand)
763
+ markup = markup.gsub("&apos;", "'") if needs_apos
764
+ if needs_expand
765
+ markup = markup.gsub(EMPTY_ELEMENT_RE) do
766
+ "<#{Regexp.last_match(1)}#{Regexp.last_match(2)}></#{Regexp.last_match(1)}>"
767
+ end
768
+ end
769
+ markup
770
+ end
771
+
772
+ # Documents compose from their parts: the native serializer
773
+ # only walks the root subtree, so declaration, DOCTYPE, PIs and
774
+ # document-level text are assembled around it explicitly.
775
+ def serialize_document(doc, options)
776
+ parts = []
777
+
778
+ include_decl = !options[:no_declaration] && options.fetch(:declaration) do
779
+ document_has_declaration?(doc)
780
+ end
781
+ if include_decl
782
+ declaration = attachments.get(doc, :declaration)
783
+ parts << (declaration ? declaration.to_xml : default_declaration_xml(doc, options))
784
+ end
785
+
786
+ doctype = attachments.get(doc, :doctype)
787
+ parts << doctype.to_xml if doctype
788
+
789
+ native = native_doctype_xml(doc)
790
+ parts << native if native
791
+
792
+ (doc.processing_instructions || []).each do |(target, data)|
793
+ parts << (data.to_s.empty? ? "<?#{target}?>" : "<?#{target} #{data}?>")
794
+ end
795
+
796
+ parts << raw_serialize(doc.root, options) if doc.root
797
+
798
+ texts = attachments.get(doc, :document_text)
799
+ texts&.each { |text| parts << XmlEmitter.escape_text(text.content.to_s) }
800
+
801
+ parts.join
802
+ end
803
+
804
+ def default_declaration_xml(doc, options)
805
+ encoding = options[:encoding] || doc.encoding
806
+ encoding = "UTF-8" if encoding.to_s.empty?
807
+ XmlEmitter.declaration_xml("1.0", encoding, nil)
808
+ end
809
+
810
+ def marker_text_for(parent, name)
811
+ return nil unless parent.is_a?(::Leptris::XML::Element)
812
+
813
+ marker = "#{Entity::MARKER}#{name};"
814
+ parent.children.to_a.find do |child|
815
+ child.is_a?(::Leptris::XML::Text) && child.content == marker
816
+ end
817
+ end
818
+
819
+ def native_doctype_xml(doc)
820
+ dt = doc.doctype
821
+ return nil unless dt
822
+
823
+ XmlEmitter.doctype_xml(dt.root_name, dt.public_id, dt.system_id)
824
+ end
825
+
826
+ def entity_refs?(element)
827
+ element.is_a?(::Leptris::XML::Element) &&
828
+ !attachments.get(element, :entity_refs).to_a.empty?
829
+ end
830
+
831
+ # Registry-based entity references are not in the native tree,
832
+ # so the native serializer cannot see them; compose the element
833
+ # from the wrapper-visible children instead.
834
+ def serialize_entity_bearing_element(element, options)
835
+ name = element.prefix ? "#{element.prefix}:#{element.name}" : element.name
836
+ attrs = element.attribute_nodes.map do |attr|
837
+ %( #{attr.name}="#{XmlEmitter.escape_attribute(attr.value)}")
838
+ end.join
839
+ decls = element.namespace_definitions.map do |ns|
840
+ ns.prefix ? %( xmlns:#{ns.prefix}="#{ns.href}") : %( xmlns="#{ns.href}")
841
+ end.join
842
+ inner = children(element).map { |child| raw_serialize(child, options) }.join
843
+ "<#{name}#{decls}#{attrs}>#{inner}</#{name}>"
844
+ end
845
+
846
+ def sax_parse(xml, handler)
847
+ bridge = LeptrisSAXBridge.new(handler)
848
+ xml_string = xml.is_a?(IO) || xml.is_a?(::StringIO) ? xml.read : xml.to_s
849
+ ::Leptris::XML::SAX::Parser.new(bridge).parse(xml_string)
850
+ rescue ::Leptris::XML::ParseError, ::Leptris::XML::Error => e
851
+ handler.on_error(Moxml::ParseError.new(e.message))
852
+ end
853
+
854
+ private
855
+
856
+ def record_source_declaration(native_doc, xml_string)
857
+ had = xml_string.match?(/\A\s*<\?xml\b/)
858
+ attachments.set(native_doc, :had_source_declaration, had)
859
+ end
860
+
861
+ def document_has_declaration?(native)
862
+ return false unless native.is_a?(::Leptris::XML::Document)
863
+
864
+ return true if attachments.get(native, :declaration)
865
+
866
+ attachments.get(native, :had_source_declaration) ? true : false
867
+ end
868
+
869
+ def assemble_document_children(doc)
870
+ children = []
871
+
872
+ native_doctype = doc.doctype
873
+ children << native_doctype if native_doctype
874
+
875
+ doctype_wrapper = attachments.get(doc, :doctype)
876
+ children << doctype_wrapper if doctype_wrapper
877
+
878
+ children << doc.root if doc.root
879
+
880
+ texts = attachments.get(doc, :document_text)
881
+ children.concat(texts) if texts
882
+ children
883
+ end
884
+
885
+ def add_document_child(doc, child)
886
+ case child
887
+ when CustomizedLeptris::Declaration
888
+ child.parent_doc = doc
889
+ attachments.set(doc, :declaration, child)
890
+ when CustomizedLeptris::Doctype
891
+ child.parent_doc = doc
892
+ attachments.set(doc, :doctype, child)
893
+ when ::Leptris::XML::DocType
894
+ raise Moxml::DocumentStructureError.new(
895
+ "libleptris does not support attaching a native DocType to a document",
896
+ )
897
+ when ::Leptris::XML::Element
898
+ doc.root = child
899
+ when ::Leptris::XML::ProcessingInstruction
900
+ doc.add_pi(child.target, child.content.to_s)
901
+ when ::Leptris::XML::Text
902
+ texts = attachments.get(doc, :document_text) || []
903
+ texts << child
904
+ attachments.set(doc, :document_text, texts)
905
+ child
906
+ else
907
+ raise Moxml::DocumentStructureError.new(
908
+ "Unsupported document child: #{child.class}",
909
+ )
910
+ end
911
+ child
912
+ end
913
+ end
914
+
915
+ # Bridge between the leptris SAX callbacks and Moxml::SAX::Handler.
916
+ #
917
+ # @private
918
+ class LeptrisSAXBridge < ::Leptris::XML::SAX::Document
919
+ include Moxml::SAX::NamespaceSplitter
920
+
921
+ def initialize(handler)
922
+ @handler = handler
923
+ super()
924
+ end
925
+
926
+ def start_document
927
+ @handler.on_start_document
928
+ end
929
+
930
+ def end_document
931
+ @handler.on_end_document
932
+ end
933
+
934
+ def start_element(name, attrs = [])
935
+ attr_hash, ns_hash = split_attributes_and_namespaces(attrs)
936
+ @handler.on_start_element(name, attr_hash, ns_hash)
937
+ end
938
+
939
+ def end_element(name)
940
+ @handler.on_end_element(name)
941
+ end
942
+
943
+ def characters(string)
944
+ @handler.on_characters(string)
945
+ end
946
+
947
+ def comment(string)
948
+ @handler.on_comment(string)
949
+ end
950
+
951
+ def cdata_block(string)
952
+ @handler.on_cdata(string)
953
+ end
954
+
955
+ def processing_instruction(name, content)
956
+ @handler.on_processing_instruction(name, content || "")
957
+ end
958
+
959
+ def error(message, _line = 0, _column = 0)
960
+ @handler.on_error(Moxml::ParseError.new(message))
961
+ end
962
+ end
963
+ end
964
+ end
965
+ end