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.
- checksums.yaml +4 -4
- data/docs/_pages/adapter-protocol.adoc +113 -0
- data/docs/_pages/adapters/index.adoc +11 -5
- data/docs/_pages/adapters/leptris.adoc +69 -0
- data/docs/_pages/adapters/libxml.adoc +1 -1
- data/docs/_pages/adapters/nokogiri.adoc +1 -1
- data/docs/_pages/conversion-apis.adoc +78 -0
- data/docs/_pages/index.adoc +6 -0
- data/lib/moxml/adapter/leptris/document_parts.rb +208 -0
- data/lib/moxml/adapter/leptris/markers.rb +51 -0
- data/lib/moxml/adapter/leptris/materialize.rb +110 -0
- data/lib/moxml/adapter/leptris/sax_bridge.rb +56 -0
- data/lib/moxml/adapter/leptris/serialize.rb +127 -0
- data/lib/moxml/adapter/leptris.rb +14 -518
- data/lib/moxml/adapter/libxml/serialize.rb +497 -0
- data/lib/moxml/adapter/libxml.rb +20 -462
- data/lib/moxml/materializer.rb +39 -17
- data/lib/moxml/version.rb +1 -1
- data/spec/moxml/adapter/leptris_spec.rb +14 -4
- metadata +11 -2
|
@@ -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?("'")
|
|
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("'", "'") 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
|