moxml 0.5.12 → 0.5.14

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -0
  3. data/docs/_pages/adapter-protocol.adoc +117 -0
  4. data/docs/_pages/adapters/index.adoc +11 -5
  5. data/docs/_pages/adapters/leptris.adoc +74 -0
  6. data/docs/_pages/adapters/libxml.adoc +1 -1
  7. data/docs/_pages/adapters/nokogiri.adoc +1 -1
  8. data/docs/_pages/conversion-apis.adoc +103 -0
  9. data/docs/_pages/index.adoc +6 -0
  10. data/lib/compat/opal/moxml_boot.rb +1 -0
  11. data/lib/moxml/adapter/base.rb +18 -4
  12. data/lib/moxml/adapter/leptris/document_parts.rb +208 -0
  13. data/lib/moxml/adapter/leptris/markers.rb +51 -0
  14. data/lib/moxml/adapter/leptris/materialize.rb +140 -0
  15. data/lib/moxml/adapter/leptris/sax_bridge.rb +56 -0
  16. data/lib/moxml/adapter/leptris/serialize.rb +135 -0
  17. data/lib/moxml/adapter/leptris.rb +58 -523
  18. data/lib/moxml/adapter/libxml/serialize.rb +497 -0
  19. data/lib/moxml/adapter/libxml.rb +20 -462
  20. data/lib/moxml/adapter/nokogiri.rb +7 -0
  21. data/lib/moxml/context.rb +8 -0
  22. data/lib/moxml/document.rb +18 -0
  23. data/lib/moxml/entity_registry.rb +0 -1
  24. data/lib/moxml/materializer.rb +108 -52
  25. data/lib/moxml/node.rb +8 -0
  26. data/lib/moxml/version.rb +1 -1
  27. data/lib/moxml.rb +3 -0
  28. data/spec/integration/shared_examples/node_wrappers/declaration_behavior.rb +3 -4
  29. data/spec/integration/shared_examples/node_wrappers/document_behavior.rb +4 -0
  30. data/spec/moxml/adapter/leptris_spec.rb +54 -11
  31. data/spec/moxml/adapter/nokogiri_spec.rb +8 -0
  32. data/spec/moxml/builder_spec.rb +8 -1
  33. data/spec/moxml/materializer_spec.rb +51 -0
  34. data/spec/moxml/signature/algorithms_spec.rb +1 -1
  35. data/spec/moxml/xpath/conversion_spec.rb +2 -6
  36. metadata +11 -2
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Adapter
5
+ class Leptris
6
+ module Markers
7
+ # Marker presence is a document-level fact: parse records it,
8
+ # the ER builder path flips it, and the split/restore scans
9
+ # consult it. Customized natives only exist as split products
10
+ # of marker-bearing text, so they always report true.
11
+ def entity_bearing?(native)
12
+ case native
13
+ when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
14
+ CustomizedLeptris::EntityReference, CustomizedLeptris::TextSegment,
15
+ CustomizedLeptris::DocumentPI
16
+ true
17
+ else
18
+ doc = native.document
19
+ doc.nil? || attachments.get(doc, :entity_markers) != false
20
+ end
21
+ end
22
+
23
+ # Expand marker-bearing text nodes into the child sequence the
24
+ # moxml contract exposes: text, EntityReference, text, ...
25
+ def split_entity_markers(natives, parent)
26
+ result = []
27
+ natives.each do |child|
28
+ # FFI Text#content returns a fresh unfrozen BINARY string:
29
+ # retag in place (dup would be a throwaway allocation), but
30
+ # before include? — BINARY.include? with the UTF-8 marker raises
31
+ if child.is_a?(::Leptris::XML::Text)
32
+ content = child.content
33
+ content.force_encoding("UTF-8")
34
+ end
35
+ if content&.include?(Entity::MARKER)
36
+ content.scan(/([^#{Entity::MARKER}]*)(?:#{Entity::MARKER}([\w.:-]+);)?/o) do
37
+ text_part = Regexp.last_match(1)
38
+ name = Regexp.last_match(2)
39
+ result << CustomizedLeptris::TextSegment.new(text_part, parent) unless text_part.empty?
40
+ result << CustomizedLeptris::EntityReference.new(name) if name
41
+ end
42
+ else
43
+ result << child
44
+ end
45
+ end
46
+ result
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Adapter
5
+ class Leptris
6
+ module Materialize
7
+ BINDING_FFI = ::Leptris::XML::FFI
8
+ private_constant :BINDING_FFI
9
+
10
+ EMPTY_FIELDS = Materializer::EMPTY_ATTRIBUTES
11
+
12
+ NODE_ELEMENT = ::Leptris::XML::FFI::NODE_ELEMENT
13
+ NODE_TEXT = ::Leptris::XML::FFI::NODE_TEXT
14
+ NODE_COMMENT = ::Leptris::XML::FFI::NODE_COMMENT
15
+ NODE_CDATA = ::Leptris::XML::FFI::NODE_CDATA
16
+ NODE_PI = ::Leptris::XML::FFI::NODE_PI
17
+ private_constant :NODE_ELEMENT, :NODE_TEXT, :NODE_COMMENT, :NODE_CDATA,
18
+ :NODE_PI
19
+
20
+ # Reused child-pointer staging buffer capacity for the raw
21
+ # pointer walk. Elements with more children grow the buffer
22
+ # geometrically for the rest of the stream.
23
+ CHILDREN_CAPACITY = 64
24
+ private_constant :CHILDREN_CAPACITY
25
+
26
+ # Bulk materialization (issues #132/#143): a Ruby-side
27
+ # post-order recursion over RAW C pointers — no binding
28
+ # wrapper per node, no per-node FFI callback, no depth memo
29
+ # (depth is a recursion parameter), no record allocation.
30
+ # Child lists arrive through the batch leptris_node_children
31
+ # call; element fields are read straight off the C handles
32
+ # (the binding's own first/next attribute iteration face),
33
+ # skipping the per-attribute wrapper its public API
34
+ # allocates. The unprefixed-attribute namespace read is
35
+ # skipped because the answer is nil by definition
36
+ # (no-namespace attributes).
37
+ def bulk_materialize?
38
+ true
39
+ end
40
+
41
+ def materialize_fields(native, buffers, &block)
42
+ return nil unless Leptris::BULK_FIELD_READS
43
+
44
+ doc = native.is_a?(::Leptris::XML::Document) ? native : native.document
45
+ # Marker-bearing text needs the split pipeline (children-level
46
+ # ER expansion); the bulk path has no marker handling.
47
+ return nil if doc.nil? || attachments.get(doc, :entity_markers)
48
+
49
+ root_ptr = if native.is_a?(::Leptris::XML::Document)
50
+ doc.root&.c_ptr
51
+ else
52
+ native.c_ptr
53
+ end
54
+ return nil unless root_ptr
55
+
56
+ walk_fields(root_ptr, 0, buffers,
57
+ ::FFI::MemoryPointer.new(:pointer, CHILDREN_CAPACITY),
58
+ &block)
59
+ true
60
+ end
61
+
62
+ def walk_fields(ptr, depth, buffers, child_buf, &block)
63
+ f = BINDING_FFI
64
+ capacity = CHILDREN_CAPACITY
65
+ count = f.leptris_node_children(ptr, child_buf, capacity)
66
+ while count == capacity
67
+ capacity *= 4
68
+ child_buf = ::FFI::MemoryPointer.new(:pointer, capacity)
69
+ count = f.leptris_node_children(ptr, child_buf, capacity)
70
+ end
71
+
72
+ # Snapshot before recursing — the recursion reuses this
73
+ # buffer for the next level's child list.
74
+ children = child_buf.read_array_of_pointer(count)
75
+
76
+ children.each do |child|
77
+ case f.leptris_node_get_type(child)
78
+ when NODE_ELEMENT
79
+ walk_fields(child, depth + 1, buffers, child_buf, &block)
80
+ when NODE_TEXT
81
+ yield(:text, nil, nil, nil, EMPTY_FIELDS, EMPTY_FIELDS, f.leptris_text_node_get_content(child), depth + 1)
82
+ when NODE_CDATA
83
+ yield(:cdata, nil, nil, nil, EMPTY_FIELDS, EMPTY_FIELDS, f.leptris_cdata_node_get_content(child), depth + 1)
84
+ when NODE_COMMENT
85
+ yield(:comment, nil, nil, nil, EMPTY_FIELDS, EMPTY_FIELDS, f.leptris_comment_node_get_content(child), depth + 1)
86
+ when NODE_PI
87
+ yield(:processing_instruction, f.leptris_pi_node_get_target(child), nil, nil, EMPTY_FIELDS, EMPTY_FIELDS, f.leptris_pi_node_get_data(child), depth + 1)
88
+ end
89
+ end
90
+
91
+ emit_element_fields(ptr, buffers, depth, &block)
92
+ end
93
+
94
+ def emit_element_fields(ptr, buffers, depth, &block)
95
+ f = BINDING_FFI
96
+ ns_buf = buffers.namespaces
97
+ attrs_buf = buffers.attributes
98
+
99
+ name = f.leptris_element_name(ptr)
100
+ prefix = f.leptris_element_prefix(ptr)
101
+ uri = f.leptris_element_namespace(ptr)
102
+ uri = nil if uri && uri.empty?
103
+
104
+ # Own declarations only — same shape as the generic path's
105
+ # Element#declared_namespaces (issue #138).
106
+ ns_buf.clear
107
+ i = 0
108
+ ns_count = f.leptris_element_namespace_count(ptr)
109
+ while i < ns_count
110
+ ns_buf << f.leptris_element_namespace_decl_prefix(ptr, i)
111
+ ns_buf << f.leptris_element_namespace_decl_uri(ptr, i)
112
+ i += 1
113
+ end
114
+
115
+ attrs_buf.clear
116
+ attr = f.leptris_element_first_attribute(ptr)
117
+ until attr.nil? || attr.null?
118
+ attr_name = f.leptris_attribute_get_name(attr)
119
+ attr_value = f.leptris_attribute_get_value(ptr, attr)
120
+ colon = attr_name.index(":")
121
+ if colon
122
+ attr_prefix = attr_name[0, colon]
123
+ # Local name + separate prefix, matching the generic
124
+ # path's resolver semantics (moxml's canonical shape).
125
+ attr_name = attr_name[(colon + 1)..]
126
+ attr_uri = f.leptris_attribute_namespace_uri(attr)
127
+ else
128
+ attr_prefix = nil
129
+ attr_uri = nil
130
+ end
131
+ attrs_buf << attr_name << attr_value << attr_uri << attr_prefix
132
+ attr = f.leptris_attribute_next(attr)
133
+ end
134
+
135
+ yield(:element, name, prefix, uri, ns_buf, attrs_buf, nil, depth)
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Adapter
5
+ class Leptris
6
+ # Bridge between the leptris SAX callbacks and Moxml::SAX::Handler.
7
+ #
8
+ # @private
9
+ class LeptrisSAXBridge < ::Leptris::XML::SAX::Document
10
+ include Moxml::SAX::NamespaceSplitter
11
+
12
+ def initialize(handler)
13
+ @handler = handler
14
+ super()
15
+ end
16
+
17
+ def start_document
18
+ @handler.on_start_document
19
+ end
20
+
21
+ def end_document
22
+ @handler.on_end_document
23
+ end
24
+
25
+ def start_element(name, attrs = [])
26
+ attr_hash, ns_hash = split_attributes_and_namespaces(attrs)
27
+ @handler.on_start_element(name, attr_hash, ns_hash)
28
+ end
29
+
30
+ def end_element(name)
31
+ @handler.on_end_element(name)
32
+ end
33
+
34
+ def characters(string)
35
+ @handler.on_characters(string)
36
+ end
37
+
38
+ def comment(string)
39
+ @handler.on_comment(string)
40
+ end
41
+
42
+ def cdata_block(string)
43
+ @handler.on_cdata(string)
44
+ end
45
+
46
+ def processing_instruction(name, content)
47
+ @handler.on_processing_instruction(name, content || "")
48
+ end
49
+
50
+ def error(message, _line = 0, _column = 0)
51
+ @handler.on_error(Moxml::ParseError.new(message))
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Adapter
5
+ class Leptris
6
+ # Wire-format knowledge: the C serializer entry, the
7
+ # canonicalization pass (apostrophes stay literal, optional
8
+ # empty-element expansion), and the literal-region scanner that
9
+ # keeps both from touching CDATA/comment/PI content.
10
+ module Serialize
11
+ # moxml canonical serialization: apostrophes stay literal
12
+ # (only & < > " are escaped) and empty elements expand when the
13
+ # caller asked for it — matching the other adapters' contract.
14
+ # Runs segment-aware: CDATA content is literal and must not be
15
+ # touched.
16
+ # Regions whose content is literal and must never be rewritten.
17
+ # Scanned positionally (String#index), not with a regex: the
18
+ # scan is linear in the input length, so pathological document
19
+ # content cannot blow up the serializer.
20
+ LITERAL_REGIONS = {
21
+ "<!--" => "-->",
22
+ "<![CDATA[" => "]]>",
23
+ "<?" => "?>",
24
+ }.freeze
25
+
26
+ # All quantifiers are possessive and the bare-part class
27
+ # excludes "/" and ">": the possessive groups can never consume
28
+ # the closing delimiter, so no backtracking is possible and the
29
+ # match is linear even on malformed tags.
30
+ EMPTY_ELEMENT_RE = %r{<([A-Za-z_][\w.:-]*+)((?:"[^"]*+"|'[^']*+'|[^<>"'/]++)*+)/>}
31
+
32
+ def serialize(node, options = {})
33
+ # Entity restoration belongs to the wrapper layer
34
+ # (Node#to_xml runs adapter.restore_entities for every
35
+ # adapter); doing it here scanned the output a second time.
36
+ xml = normalize_serialization(raw_serialize(node, options), options)
37
+ # The binding's FFI strings come back binary-tagged; the
38
+ # engine encoded the bytes per this option, so tag them.
39
+ xml.force_encoding(options[:encoding]) if options[:encoding]
40
+ xml
41
+ end
42
+
43
+ def raw_serialize(node, options)
44
+ # CDATA must precede Text in this chain: CDATA < Text in the
45
+ # binding, so a Text branch first would swallow CDATA nodes.
46
+ case node
47
+ when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
48
+ CustomizedLeptris::EntityReference, CustomizedLeptris::DocumentPI
49
+ return node.to_xml
50
+ when ::Leptris::XML::CDATA
51
+ return XmlEmitter.cdata(node.content)
52
+ when ::Leptris::XML::Comment
53
+ return "<!--#{node.content}-->"
54
+ when ::Leptris::XML::ProcessingInstruction
55
+ content = node.content.to_s
56
+ return content.empty? ? "<?#{node.target}?>" : "<?#{node.target} #{content}?>"
57
+ when ::Leptris::XML::Text, CustomizedLeptris::TextSegment
58
+ return XmlEmitter.escape_text(node.content.to_s)
59
+ when ::Leptris::XML::Document
60
+ return serialize_document(node, options)
61
+ end
62
+
63
+ include_decl = options.fetch(:declaration) do
64
+ options[:no_declaration] ? false : document_has_declaration?(node)
65
+ end
66
+ xml = node.to_xml(
67
+ indent: options.fetch(:indent, 0),
68
+ no_decl: !include_decl,
69
+ encoding: options[:encoding],
70
+ )
71
+ # Element output always ends with the close tag — but the
72
+ # engine's serializer appends a stray trailing newline when
73
+ # the element's last text child is non-ASCII.
74
+ xml.sub(/\n+\z/, "")
75
+ end
76
+
77
+ def normalize_serialization(xml, options)
78
+ needs_apos = xml.include?("&apos;")
79
+ needs_expand = options[:expand_empty] && xml.include?("/>")
80
+ return xml unless needs_apos || needs_expand
81
+
82
+ out = +""
83
+ pos = 0
84
+ while pos < xml.length
85
+ opener_at, terminator = next_literal_region(xml, pos)
86
+ if opener_at.nil?
87
+ out << normalize_markup(xml[pos..], needs_apos, needs_expand)
88
+ break
89
+ end
90
+
91
+ out << normalize_markup(xml[pos...opener_at], needs_apos, needs_expand)
92
+ search_from = opener_at + opener_at_offset(terminator)
93
+ close = xml.index(terminator, search_from)
94
+ close_end = close.nil? ? xml.length : close + terminator.length
95
+ out << xml[opener_at...close_end]
96
+ pos = close_end
97
+ end
98
+ out
99
+ end
100
+
101
+ # Nearest literal region at/after from: [position, terminator].
102
+ def next_literal_region(xml, from)
103
+ best = nil
104
+ best_terminator = nil
105
+ LITERAL_REGIONS.each do |opener, terminator|
106
+ idx = xml.index(opener, from)
107
+ next if idx.nil?
108
+
109
+ if best.nil? || idx < best
110
+ best = idx
111
+ best_terminator = terminator
112
+ end
113
+ end
114
+ best.nil? ? nil : [best, best_terminator]
115
+ end
116
+
117
+ # Search for a terminator past its opener's overlap-safe offset
118
+ # ("-->" cannot start inside "<!--").
119
+ def opener_at_offset(terminator)
120
+ terminator == "-->" ? 4 : 0
121
+ end
122
+
123
+ def normalize_markup(markup, needs_apos, needs_expand)
124
+ markup = markup.gsub("&apos;", "'") if needs_apos
125
+ if needs_expand
126
+ markup = markup.gsub(EMPTY_ELEMENT_RE) do
127
+ "<#{Regexp.last_match(1)}#{Regexp.last_match(2)}></#{Regexp.last_match(1)}>"
128
+ end
129
+ end
130
+ markup
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end