moxml 0.5.12 → 0.5.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Adapter
5
+ class Leptris
6
+ module Materialize
7
+ # Bulk materialization (issue #132): leptris_node_traverse
8
+ # walks the subtree with one FFI call; the only per-node cost
9
+ # is the C->Ruby callback and the property reads below. No
10
+ # Moxml::Node or Attribute wrapper is allocated.
11
+ def bulk_materialize?
12
+ true
13
+ end
14
+
15
+ def materialize_records(native, &block)
16
+ doc = native.is_a?(::Leptris::XML::Document) ? native : native.document
17
+ # Marker-bearing text needs the split pipeline (children-level
18
+ # ER expansion); the bulk path has no marker handling.
19
+ return nil if doc.nil? || attachments.get(doc, :entity_markers)
20
+
21
+ # On bindings whose traverse follows the document chain
22
+ # (leptris 1.9.28–1.9.31), element materialize falls back to
23
+ # the generic wrapper walk — only document materialization
24
+ # can filter safely there (issue #140).
25
+ root = if native.is_a?(::Leptris::XML::Document)
26
+ doc.root
27
+ else
28
+ return nil unless TRAVERSE_SUBTREE_BOUNDED
29
+
30
+ native
31
+ end
32
+ return nil if root.nil?
33
+
34
+ depth_memo = {}.compare_by_identity
35
+ root.traverse do |node|
36
+ depth = material_depth_in_subtree(node, root, depth_memo)
37
+ next if depth.nil?
38
+
39
+ record = case node
40
+ when ::Leptris::XML::Element
41
+ element_material_record(node, depth)
42
+ when ::Leptris::XML::CDATA
43
+ # CDATA < Text in the binding: this arm must come
44
+ # first or CDATA content reports as text.
45
+ text_material_record(:cdata, node.content, depth)
46
+ when ::Leptris::XML::Text
47
+ text_material_record(:text, node.content, depth)
48
+ when ::Leptris::XML::Comment
49
+ text_material_record(:comment, node.content, depth)
50
+ when ::Leptris::XML::ProcessingInstruction
51
+ text_material_record(:processing_instruction, node.content, depth)
52
+ .merge(qname: node.target)
53
+ end
54
+ yield(record) if record
55
+ end
56
+ true
57
+ end
58
+
59
+ def element_material_record(node, depth)
60
+ attributes = node.each_attribute.map do |attr|
61
+ # Local name + separate prefix, matching the generic
62
+ # path's resolver semantics (moxml's canonical shape).
63
+ name = attr.name
64
+ prefix = attr.prefix
65
+ name = name.split(":", 2)[1] || name if prefix
66
+ [name, attr.value, attr.namespace_uri, prefix]
67
+ end
68
+ ns = node.namespace
69
+ # Own declarations only — same shape as the generic
70
+ # path's Element#declared_namespaces (issue #138).
71
+ decls = node.namespace_definitions.map { |d| [d.prefix, d.href] }
72
+ Materializer::Record.element(
73
+ qname: node.name,
74
+ prefix: node.prefix,
75
+ namespace_uri: ns&.href,
76
+ namespaces: decls.empty? ? Materializer::EMPTY_ATTRIBUTES : decls,
77
+ attributes: attributes,
78
+ depth: depth,
79
+ )
80
+ end
81
+
82
+ def text_material_record(kind, text, depth)
83
+ Materializer::Record.text(kind: kind, text: text, depth: depth)
84
+ end
85
+
86
+ # Depth relative to the subtree root, or nil when the node
87
+ # lies outside it (traverse follows the document chain since
88
+ # leptris 1.9.28, so epilog siblings can appear in the
89
+ # stream). Binding wrappers are address-stable and #parent is
90
+ # memoized, so each edge resolves once through the
91
+ # identity-keyed memo.
92
+ def material_depth_in_subtree(node, root, memo)
93
+ memo[node] ||= if node.equal?(root)
94
+ 0
95
+ else
96
+ parent = node.parent
97
+ if parent.nil? || parent.is_a?(::Leptris::XML::Document)
98
+ nil
99
+ else
100
+
101
+ parent_depth = material_depth_in_subtree(parent, root, memo)
102
+ parent_depth.nil? ? nil : parent_depth + 1
103
+
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ 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,127 @@
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
+ normalize_serialization(raw_serialize(node, options), options)
37
+ end
38
+
39
+ def raw_serialize(node, options)
40
+ # CDATA must precede Text in this chain: CDATA < Text in the
41
+ # binding, so a Text branch first would swallow CDATA nodes.
42
+ case node
43
+ when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
44
+ CustomizedLeptris::EntityReference, CustomizedLeptris::DocumentPI
45
+ return node.to_xml
46
+ when ::Leptris::XML::CDATA
47
+ return XmlEmitter.cdata(node.content)
48
+ when ::Leptris::XML::Comment
49
+ return "<!--#{node.content}-->"
50
+ when ::Leptris::XML::ProcessingInstruction
51
+ content = node.content.to_s
52
+ return content.empty? ? "<?#{node.target}?>" : "<?#{node.target} #{content}?>"
53
+ when ::Leptris::XML::Text, CustomizedLeptris::TextSegment
54
+ return XmlEmitter.escape_text(node.content.to_s)
55
+ when ::Leptris::XML::Document
56
+ return serialize_document(node, options)
57
+ end
58
+
59
+ include_decl = options.fetch(:declaration) do
60
+ options[:no_declaration] ? false : document_has_declaration?(node)
61
+ end
62
+ node.to_xml(
63
+ indent: options.fetch(:indent, 0),
64
+ no_decl: !include_decl,
65
+ encoding: options[:encoding],
66
+ )
67
+ end
68
+
69
+ def normalize_serialization(xml, options)
70
+ needs_apos = xml.include?("&apos;")
71
+ needs_expand = options[:expand_empty] && xml.include?("/>")
72
+ return xml unless needs_apos || needs_expand
73
+
74
+ out = +""
75
+ pos = 0
76
+ while pos < xml.length
77
+ opener_at, terminator = next_literal_region(xml, pos)
78
+ if opener_at.nil?
79
+ out << normalize_markup(xml[pos..], needs_apos, needs_expand)
80
+ break
81
+ end
82
+
83
+ out << normalize_markup(xml[pos...opener_at], needs_apos, needs_expand)
84
+ search_from = opener_at + opener_at_offset(terminator)
85
+ close = xml.index(terminator, search_from)
86
+ close_end = close.nil? ? xml.length : close + terminator.length
87
+ out << xml[opener_at...close_end]
88
+ pos = close_end
89
+ end
90
+ out
91
+ end
92
+
93
+ # Nearest literal region at/after from: [position, terminator].
94
+ def next_literal_region(xml, from)
95
+ best = nil
96
+ best_terminator = nil
97
+ LITERAL_REGIONS.each do |opener, terminator|
98
+ idx = xml.index(opener, from)
99
+ next if idx.nil?
100
+
101
+ if best.nil? || idx < best
102
+ best = idx
103
+ best_terminator = terminator
104
+ end
105
+ end
106
+ best.nil? ? nil : [best, best_terminator]
107
+ end
108
+
109
+ # Search for a terminator past its opener's overlap-safe offset
110
+ # ("-->" cannot start inside "<!--").
111
+ def opener_at_offset(terminator)
112
+ terminator == "-->" ? 4 : 0
113
+ end
114
+
115
+ def normalize_markup(markup, needs_apos, needs_expand)
116
+ markup = markup.gsub("&apos;", "'") if needs_apos
117
+ if needs_expand
118
+ markup = markup.gsub(EMPTY_ELEMENT_RE) do
119
+ "<#{Regexp.last_match(1)}#{Regexp.last_match(2)}></#{Regexp.last_match(1)}>"
120
+ end
121
+ end
122
+ markup
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end