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