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.
@@ -34,6 +34,20 @@ module Moxml
34
34
  TRAVERSE_SUBTREE_BOUNDED =
35
35
  Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.32")
36
36
 
37
+ # Cohesive clusters extracted from this class — the adapter
38
+ # protocol surface is unchanged; the modules hold the document
39
+ # parts assembly, the entity-marker pipeline, and the bulk
40
+ # materializer respectively.
41
+ autoload :DocumentParts, "moxml/adapter/leptris/document_parts"
42
+ autoload :Markers, "moxml/adapter/leptris/markers"
43
+ autoload :Materialize, "moxml/adapter/leptris/materialize"
44
+ autoload :Serialize, "moxml/adapter/leptris/serialize"
45
+ autoload :LeptrisSAXBridge, "moxml/adapter/leptris/sax_bridge"
46
+ extend Serialize
47
+ extend DocumentParts
48
+ extend Markers
49
+ extend Materialize
50
+
37
51
  class << self
38
52
  def attachments
39
53
  @attachments ||= Moxml::NativeAttachment.new
@@ -85,22 +99,6 @@ module Moxml
85
99
  ::Leptris::XML::ParseOptions.dtdattr
86
100
  end
87
101
 
88
- # Issue #134: deterministic release of the C tree. The binding
89
- # clears its wrapper cache and raises UseAfterFreeError on
90
- # later access; moxml-side attachments for the document are
91
- # swept too (the context wrapper identity map self-cleans via
92
- # its size valve).
93
- DOCUMENT_ATTACHMENT_KEYS = %i[
94
- entity_markers doc_pi_nodes declaration doctype
95
- had_source_declaration document_text
96
- ].freeze
97
-
98
- def free_document(native)
99
- DOCUMENT_ATTACHMENT_KEYS.each { |key| attachments.delete(native, key) }
100
- native.free
101
- nil
102
- end
103
-
104
102
  def create_document(_native_doc = nil)
105
103
  ::Leptris::XML::Document.create
106
104
  end
@@ -258,136 +256,6 @@ module Moxml
258
256
  node.target
259
257
  end
260
258
 
261
- # Marker presence is a document-level fact: parse records it,
262
- # the ER builder path flips it, and the split/restore scans
263
- # consult it. Customized natives only exist as split products
264
- # of marker-bearing text, so they always report true.
265
- def entity_bearing?(native)
266
- case native
267
- when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
268
- CustomizedLeptris::EntityReference, CustomizedLeptris::TextSegment,
269
- CustomizedLeptris::DocumentPI
270
- true
271
- else
272
- doc = native.document
273
- doc.nil? || attachments.get(doc, :entity_markers) != false
274
- end
275
- end
276
-
277
- # Bulk materialization (issue #132): leptris_node_traverse
278
- # walks the subtree with one FFI call; the only per-node cost
279
- # is the C->Ruby callback and the property reads below. No
280
- # Moxml::Node or Attribute wrapper is allocated.
281
- def bulk_materialize?
282
- true
283
- end
284
-
285
- def materialize_records(native, &block)
286
- doc = native.is_a?(::Leptris::XML::Document) ? native : native.document
287
- # Marker-bearing text needs the split pipeline (children-level
288
- # ER expansion); the bulk path has no marker handling.
289
- return nil if doc.nil? || attachments.get(doc, :entity_markers)
290
-
291
- # On bindings whose traverse follows the document chain
292
- # (leptris 1.9.28–1.9.31), element materialize falls back to
293
- # the generic wrapper walk — only document materialization
294
- # can filter safely there (issue #140).
295
- root = if native.is_a?(::Leptris::XML::Document)
296
- doc.root
297
- else
298
- return nil unless TRAVERSE_SUBTREE_BOUNDED
299
-
300
- native
301
- end
302
- return nil if root.nil?
303
-
304
- depth_memo = {}.compare_by_identity
305
- root.traverse do |node|
306
- depth = material_depth_in_subtree(node, root, depth_memo)
307
- next if depth.nil?
308
-
309
- record = case node
310
- when ::Leptris::XML::Element
311
- element_material_record(node, depth)
312
- when ::Leptris::XML::CDATA
313
- # CDATA < Text in the binding: this arm must come
314
- # first or CDATA content reports as text.
315
- text_material_record(:cdata, node.content, depth)
316
- when ::Leptris::XML::Text
317
- text_material_record(:text, node.content, depth)
318
- when ::Leptris::XML::Comment
319
- text_material_record(:comment, node.content, depth)
320
- when ::Leptris::XML::ProcessingInstruction
321
- text_material_record(:processing_instruction, node.content, depth)
322
- .merge(qname: node.target)
323
- end
324
- yield(record) if record
325
- end
326
- true
327
- end
328
-
329
- def element_material_record(node, depth)
330
- attributes = node.each_attribute.map do |attr|
331
- # Local name + separate prefix, matching the generic
332
- # path's resolver semantics (moxml's canonical shape).
333
- name = attr.name
334
- prefix = attr.prefix
335
- name = name.split(":", 2)[1] || name if prefix
336
- [name, attr.value, attr.namespace_uri, prefix]
337
- end
338
- ns = node.namespace
339
- {
340
- kind: :element,
341
- qname: node.name,
342
- prefix: node.prefix,
343
- namespace_uri: ns&.href,
344
- # Own declarations only — same shape as the generic
345
- # path's Element#declared_namespaces (issue #138).
346
- namespaces: begin
347
- decls = node.namespace_definitions.map { |d| [d.prefix, d.href] }
348
- decls.empty? ? Materializer::EMPTY_ATTRIBUTES : decls
349
- end,
350
- attributes: attributes,
351
- text: nil,
352
- depth: depth,
353
- }
354
- end
355
-
356
- def text_material_record(kind, text, depth)
357
- {
358
- kind: kind,
359
- qname: nil,
360
- prefix: nil,
361
- namespace_uri: nil,
362
- namespaces: Materializer::EMPTY_ATTRIBUTES,
363
- attributes: Materializer::EMPTY_ATTRIBUTES,
364
- text: text,
365
- depth: depth,
366
- }
367
- end
368
-
369
- # Depth relative to the subtree root, or nil when the node
370
- # lies outside it (traverse follows the document chain since
371
- # leptris 1.9.28, so epilog siblings can appear in the
372
- # stream). Binding wrappers are address-stable and #parent is
373
- # memoized, so each edge resolves once through the
374
- # identity-keyed memo.
375
- def material_depth_in_subtree(node, root, memo)
376
- memo[node] ||= if node.equal?(root)
377
- 0
378
- else
379
- parent = node.parent
380
- if parent.nil? || parent.is_a?(::Leptris::XML::Document)
381
- nil
382
- else
383
-
384
- parent_depth = material_depth_in_subtree(parent, root, memo)
385
- parent_depth.nil? ? nil : parent_depth + 1
386
-
387
- end
388
- end
389
- end
390
-
391
259
  def node_type(node)
392
260
  # Frequency-ordered: elements and text dominate every real
393
261
  # document, and Node.wrap dispatches here once per cold
@@ -465,32 +333,6 @@ module Moxml
465
333
  end
466
334
  end
467
335
 
468
- # Expand marker-bearing text nodes into the child sequence the
469
- # moxml contract exposes: text, EntityReference, text, ...
470
- def split_entity_markers(natives, parent)
471
- result = []
472
- natives.each do |child|
473
- # FFI Text#content returns a fresh unfrozen BINARY string:
474
- # retag in place (dup would be a throwaway allocation), but
475
- # before include? — BINARY.include? with the UTF-8 marker raises
476
- if child.is_a?(::Leptris::XML::Text)
477
- content = child.content
478
- content.force_encoding("UTF-8")
479
- end
480
- if content&.include?(Entity::MARKER)
481
- content.scan(/([^#{Entity::MARKER}]*)(?:#{Entity::MARKER}([\w.:-]+);)?/o) do
482
- text_part = Regexp.last_match(1)
483
- name = Regexp.last_match(2)
484
- result << CustomizedLeptris::TextSegment.new(text_part, parent) unless text_part.empty?
485
- result << CustomizedLeptris::EntityReference.new(name) if name
486
- end
487
- else
488
- result << child
489
- end
490
- end
491
- result
492
- end
493
-
494
336
  def parent(node)
495
337
  case node
496
338
  when ::Leptris::XML::Document then nil
@@ -909,191 +751,6 @@ module Moxml
909
751
  end
910
752
  end
911
753
 
912
- def serialize(node, options = {})
913
- # Entity restoration belongs to the wrapper layer
914
- # (Node#to_xml runs adapter.restore_entities for every
915
- # adapter); doing it here scanned the output a second time.
916
- normalize_serialization(raw_serialize(node, options), options)
917
- end
918
-
919
- def raw_serialize(node, options)
920
- # CDATA must precede Text in this chain: CDATA < Text in the
921
- # binding, so a Text branch first would swallow CDATA nodes.
922
- case node
923
- when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
924
- CustomizedLeptris::EntityReference, CustomizedLeptris::DocumentPI
925
- return node.to_xml
926
- when ::Leptris::XML::CDATA
927
- return XmlEmitter.cdata(node.content)
928
- when ::Leptris::XML::Comment
929
- return "<!--#{node.content}-->"
930
- when ::Leptris::XML::ProcessingInstruction
931
- content = node.content.to_s
932
- return content.empty? ? "<?#{node.target}?>" : "<?#{node.target} #{content}?>"
933
- when ::Leptris::XML::Text, CustomizedLeptris::TextSegment
934
- return XmlEmitter.escape_text(node.content.to_s)
935
- when ::Leptris::XML::Document
936
- return serialize_document(node, options)
937
- end
938
-
939
- include_decl = options.fetch(:declaration) do
940
- options[:no_declaration] ? false : document_has_declaration?(node)
941
- end
942
- node.to_xml(
943
- indent: options.fetch(:indent, 0),
944
- no_decl: !include_decl,
945
- encoding: options[:encoding],
946
- )
947
- end
948
-
949
- # moxml canonical serialization: apostrophes stay literal
950
- # (only & < > " are escaped) and empty elements expand when the
951
- # caller asked for it — matching the other adapters' contract.
952
- # Runs segment-aware: CDATA content is literal and must not be
953
- # touched.
954
- # Regions whose content is literal and must never be rewritten.
955
- # Scanned positionally (String#index), not with a regex: the
956
- # scan is linear in the input length, so pathological document
957
- # content cannot blow up the serializer.
958
- LITERAL_REGIONS = {
959
- "<!--" => "-->",
960
- "<![CDATA[" => "]]>",
961
- "<?" => "?>",
962
- }.freeze
963
-
964
- def normalize_serialization(xml, options)
965
- needs_apos = xml.include?("&apos;")
966
- needs_expand = options[:expand_empty] && xml.include?("/>")
967
- return xml unless needs_apos || needs_expand
968
-
969
- out = +""
970
- pos = 0
971
- while pos < xml.length
972
- opener_at, terminator = next_literal_region(xml, pos)
973
- if opener_at.nil?
974
- out << normalize_markup(xml[pos..], needs_apos, needs_expand)
975
- break
976
- end
977
-
978
- out << normalize_markup(xml[pos...opener_at], needs_apos, needs_expand)
979
- search_from = opener_at + opener_at_offset(terminator)
980
- close = xml.index(terminator, search_from)
981
- close_end = close.nil? ? xml.length : close + terminator.length
982
- out << xml[opener_at...close_end]
983
- pos = close_end
984
- end
985
- out
986
- end
987
-
988
- # Nearest literal region at/after from: [position, terminator].
989
- def next_literal_region(xml, from)
990
- best = nil
991
- best_terminator = nil
992
- LITERAL_REGIONS.each do |opener, terminator|
993
- idx = xml.index(opener, from)
994
- next if idx.nil?
995
-
996
- if best.nil? || idx < best
997
- best = idx
998
- best_terminator = terminator
999
- end
1000
- end
1001
- best.nil? ? nil : [best, best_terminator]
1002
- end
1003
-
1004
- # Search for a terminator past its opener's overlap-safe offset
1005
- # ("-->" cannot start inside "<!--").
1006
- def opener_at_offset(terminator)
1007
- terminator == "-->" ? 4 : 0
1008
- end
1009
-
1010
- # All quantifiers are possessive and the bare-part class
1011
- # excludes "/" and ">": the possessive groups can never consume
1012
- # the closing delimiter, so no backtracking is possible and the
1013
- # match is linear even on malformed tags.
1014
- EMPTY_ELEMENT_RE = %r{<([A-Za-z_][\w.:-]*+)((?:"[^"]*+"|'[^']*+'|[^<>"'/]++)*+)/>}
1015
-
1016
- def normalize_markup(markup, needs_apos, needs_expand)
1017
- markup = markup.gsub("&apos;", "'") if needs_apos
1018
- if needs_expand
1019
- markup = markup.gsub(EMPTY_ELEMENT_RE) do
1020
- "<#{Regexp.last_match(1)}#{Regexp.last_match(2)}></#{Regexp.last_match(1)}>"
1021
- end
1022
- end
1023
- markup
1024
- end
1025
-
1026
- # Documents compose from their parts: the native serializer
1027
- # only walks the root subtree, so declaration, DOCTYPE, PIs and
1028
- # document-level text are assembled around it explicitly.
1029
- def serialize_document(doc, options)
1030
- # Nokogiri's document shape: every top-level part is
1031
- # newline-terminated, at any indent — declaration, DOCTYPE,
1032
- # document PIs, the root element, trailing newline after it.
1033
- # Document-level text is content, not structure: no added
1034
- # newline.
1035
- parts = []
1036
-
1037
- include_decl = !options[:no_declaration] && options.fetch(:declaration) do
1038
- document_has_declaration?(doc)
1039
- end
1040
- if include_decl
1041
- declaration = attachments.get(doc, :declaration)
1042
- parts << (declaration ? declaration.to_xml : default_declaration_xml(doc, options)) << "\n"
1043
- end
1044
-
1045
- doctype = attachments.get(doc, :doctype)
1046
- parts << doctype.to_xml << "\n" if doctype
1047
-
1048
- native = native_doctype_xml(doc)
1049
- parts << native << "\n" if native
1050
-
1051
- if DOC_NODE_SUPPORTED
1052
- # The libxml2-model document node: prolog PIs/comments,
1053
- # the root, epilog PIs/comments — in document order, so
1054
- # epilog parts serialize after the root (issue #130).
1055
- doc_children = document_node_children(doc)
1056
- if doc_children
1057
- doc_children.each { |child| parts << raw_serialize(child, options) << "\n" }
1058
- else
1059
- document_pi_nodes(doc).each { |pi| parts << pi.to_xml << "\n" }
1060
- parts << raw_serialize(doc.root, options) << "\n" if doc.root
1061
- end
1062
- else
1063
- document_pi_nodes(doc).each { |pi| parts << pi.to_xml << "\n" }
1064
-
1065
- parts << raw_serialize(doc.root, options) << "\n" if doc.root
1066
- end
1067
-
1068
- texts = attachments.get(doc, :document_text)
1069
- texts&.each { |text| parts << XmlEmitter.escape_text(text.content.to_s) }
1070
-
1071
- parts.join
1072
- end
1073
-
1074
- def default_declaration_xml(doc, options)
1075
- encoding = options[:encoding] || doc.encoding
1076
- encoding = "UTF-8" if encoding.to_s.empty?
1077
- XmlEmitter.declaration_xml("1.0", encoding, nil)
1078
- end
1079
-
1080
- def marker_text_for(parent, name)
1081
- return nil unless parent.is_a?(::Leptris::XML::Element)
1082
-
1083
- marker = "#{Entity::MARKER}#{name};"
1084
- parent.children.to_a.find do |child|
1085
- child.is_a?(::Leptris::XML::Text) && child.content == marker
1086
- end
1087
- end
1088
-
1089
- def native_doctype_xml(doc)
1090
- dt = doc.doctype
1091
- return nil unless dt
1092
-
1093
- subset = dt.internal_subset if dt.class.method_defined?(:internal_subset)
1094
- XmlEmitter.doctype_xml(dt.root_name, dt.public_id, dt.system_id, subset)
1095
- end
1096
-
1097
754
  def sax_parse(xml, handler)
1098
755
  bridge = LeptrisSAXBridge.new(handler)
1099
756
  xml_string = xml.is_a?(IO) || xml.is_a?(::StringIO) ? xml.read : xml.to_s
@@ -1123,167 +780,6 @@ module Moxml
1123
780
  end
1124
781
  attachments.set(native_doc, :had_source_declaration, had)
1125
782
  end
1126
-
1127
- def document_has_declaration?(native)
1128
- return false unless native.is_a?(::Leptris::XML::Document)
1129
-
1130
- return true if attachments.get(native, :declaration)
1131
-
1132
- attachments.get(native, :had_source_declaration) ? true : false
1133
- end
1134
-
1135
- # Document-level PI pseudo-nodes, materialized once from the C
1136
- # list and cached per document: children and serialization read
1137
- # the same objects, so wrapper mutations round-trip. Build the
1138
- # cache BEFORE appending a PI with add_pi, or the C-side
1139
- # addition would be double-counted.
1140
- # The document node's children, or nil when the node does not
1141
- # reflect reality: parsed documents always list the root
1142
- # element among their children, but programmatically built
1143
- # ones do not (binding gap) — those keep the legacy parts
1144
- # path.
1145
- def document_node_children(doc)
1146
- doc_children = doc.children.to_a
1147
- has_root = doc_children.any?(::Leptris::XML::Element)
1148
- return doc_children if has_root
1149
- return doc_children if doc.root.nil?
1150
-
1151
- nil
1152
- end
1153
-
1154
- def document_pi_nodes(doc)
1155
- attachments.get(doc, :doc_pi_nodes) || begin
1156
- nodes = doc.processing_instructions.map do |(target, data)|
1157
- CustomizedLeptris::DocumentPI.new(target, data, doc)
1158
- end
1159
- attachments.set(doc, :doc_pi_nodes, nodes)
1160
- nodes
1161
- end
1162
- end
1163
-
1164
- def assemble_document_children(doc)
1165
- children = []
1166
-
1167
- native_doctype = doc.doctype
1168
- children << native_doctype if native_doctype
1169
-
1170
- doctype_wrapper = attachments.get(doc, :doctype)
1171
- children << doctype_wrapper if doctype_wrapper
1172
-
1173
- if DOC_NODE_SUPPORTED
1174
- # The libxml2-model document node lists prolog PIs/comments,
1175
- # the root, and epilog PIs/comments in document order —
1176
- # the Nokogiri-shaped contract, epilog anchoring included
1177
- # (issue #130). Built (programmatic) documents are not yet
1178
- # fully reflected by the node (binding gap: an attached
1179
- # root does not appear); document_node_children answers
1180
- # nil there for the legacy parts path.
1181
- doc_children = document_node_children(doc)
1182
- if doc_children
1183
- children.concat(doc_children)
1184
- else
1185
- children.concat(document_pi_nodes(doc))
1186
- children << doc.root if doc.root
1187
- end
1188
- else
1189
- # Legacy path: document-level PIs live outside the element
1190
- # tree in a flat pre-root list (libleptris < 1.9.7 C
1191
- # model); epilog anchoring is not representable there.
1192
- children.concat(document_pi_nodes(doc))
1193
-
1194
- children << doc.root if doc.root
1195
- end
1196
-
1197
- texts = attachments.get(doc, :document_text)
1198
- children.concat(texts) if texts
1199
- children
1200
- end
1201
-
1202
- def add_document_child(doc, child)
1203
- case child
1204
- when CustomizedLeptris::Declaration
1205
- child.parent_doc = doc
1206
- attachments.set(doc, :declaration, child)
1207
- when CustomizedLeptris::Doctype
1208
- child.parent_doc = doc
1209
- attachments.set(doc, :doctype, child)
1210
- when ::Leptris::XML::DocType
1211
- raise Moxml::DocumentStructureError.new(
1212
- "libleptris does not support attaching a native DocType to a document",
1213
- )
1214
- when ::Leptris::XML::Element
1215
- doc.root = child
1216
- when ::Leptris::XML::ProcessingInstruction
1217
- document_pi_nodes(doc)
1218
- doc.add_pi(child.target, child.content.to_s)
1219
- document_pi_nodes(doc) << CustomizedLeptris::DocumentPI.new(
1220
- child.target, child.content.to_s, doc
1221
- )
1222
- when CustomizedLeptris::DocumentPI
1223
- document_pi_nodes(doc)
1224
- doc.add_pi(child.target, child.data)
1225
- document_pi_nodes(doc) << child
1226
- when ::Leptris::XML::Text
1227
- texts = attachments.get(doc, :document_text) || []
1228
- texts << child
1229
- attachments.set(doc, :document_text, texts)
1230
- child
1231
- else
1232
- raise Moxml::DocumentStructureError.new(
1233
- "Unsupported document child: #{child.class}",
1234
- )
1235
- end
1236
- child
1237
- end
1238
- end
1239
-
1240
- # Bridge between the leptris SAX callbacks and Moxml::SAX::Handler.
1241
- #
1242
- # @private
1243
- class LeptrisSAXBridge < ::Leptris::XML::SAX::Document
1244
- include Moxml::SAX::NamespaceSplitter
1245
-
1246
- def initialize(handler)
1247
- @handler = handler
1248
- super()
1249
- end
1250
-
1251
- def start_document
1252
- @handler.on_start_document
1253
- end
1254
-
1255
- def end_document
1256
- @handler.on_end_document
1257
- end
1258
-
1259
- def start_element(name, attrs = [])
1260
- attr_hash, ns_hash = split_attributes_and_namespaces(attrs)
1261
- @handler.on_start_element(name, attr_hash, ns_hash)
1262
- end
1263
-
1264
- def end_element(name)
1265
- @handler.on_end_element(name)
1266
- end
1267
-
1268
- def characters(string)
1269
- @handler.on_characters(string)
1270
- end
1271
-
1272
- def comment(string)
1273
- @handler.on_comment(string)
1274
- end
1275
-
1276
- def cdata_block(string)
1277
- @handler.on_cdata(string)
1278
- end
1279
-
1280
- def processing_instruction(name, content)
1281
- @handler.on_processing_instruction(name, content || "")
1282
- end
1283
-
1284
- def error(message, _line = 0, _column = 0)
1285
- @handler.on_error(Moxml::ParseError.new(message))
1286
- end
1287
783
  end
1288
784
  end
1289
785
  end