moxml 0.5.30 → 0.5.32
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 +7 -0
- data/docs/_pages/adapters/leptris.adoc +68 -0
- data/docs/_pages/conversion-apis.adoc +18 -0
- data/docs/_pages/performance.adoc +142 -0
- data/lib/compat/opal/moxml_boot.rb +1 -0
- data/lib/moxml/adapter/base.rb +64 -0
- data/lib/moxml/adapter/leptris/document_parts.rb +8 -0
- data/lib/moxml/adapter/leptris/markers.rb +4 -1
- data/lib/moxml/adapter/leptris/serialize.rb +71 -19
- data/lib/moxml/adapter/leptris.rb +151 -4
- data/lib/moxml/adapter/libxml.rb +13 -2
- data/lib/moxml/adapter/nokogiri.rb +55 -4
- data/lib/moxml/adapter/ox.rb +8 -0
- data/lib/moxml/adapter/rexml.rb +8 -0
- data/lib/moxml/attribute_resolver.rb +35 -0
- data/lib/moxml/c14n.rb +30 -0
- data/lib/moxml/context.rb +67 -7
- data/lib/moxml/document.rb +2 -2
- data/lib/moxml/element.rb +50 -5
- data/lib/moxml/lazy_node_set.rb +16 -0
- data/lib/moxml/native_attachment/native.rb +8 -2
- data/lib/moxml/node.rb +33 -22
- data/lib/moxml/node_set.rb +48 -25
- data/lib/moxml/sax/block_handler.rb +8 -4
- data/lib/moxml/sax/namespace_splitter.rb +16 -7
- data/lib/moxml/version.rb +1 -1
- data/lib/moxml/xml_utils.rb +12 -1
- data/lib/moxml.rb +1 -0
- data/spec/moxml/adapter/leptris_spec.rb +247 -1
- data/spec/moxml/adapter/nokogiri_spec.rb +17 -0
- data/spec/moxml/context_spec.rb +13 -0
- data/spec/moxml/element_spec.rb +25 -0
- metadata +3 -2
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
# Marker for an adapter's native node-set result that NodeSet can
|
|
5
|
+
# hold without materializing the natives. The leptris binding's
|
|
6
|
+
# NodeSet mints one Ruby wrapper per result node in #to_a — the
|
|
7
|
+
# whole 900-node result set is built even when the caller only
|
|
8
|
+
# asks .size or .first — so the adapter hands the native set
|
|
9
|
+
# through instead. Contract: responds to length, empty?, [],
|
|
10
|
+
# each, to_a (yielding natives).
|
|
11
|
+
module LazyNodeSet
|
|
12
|
+
def size
|
|
13
|
+
length
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -25,8 +25,13 @@ module Moxml
|
|
|
25
25
|
@monitor = Monitor.new
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Reads are lock-free: MRI Hash#[] is atomic under the GVL, and
|
|
29
|
+
# the only mid-write race (set assigns the inner hash before its
|
|
30
|
+
# key) resolves to a miss — the same answer as "not set yet",
|
|
31
|
+
# which every reader treats conservatively. This method sits on
|
|
32
|
+
# per-element hot paths (entity_bearing? on every serialize).
|
|
28
33
|
def get(native, key)
|
|
29
|
-
@
|
|
34
|
+
@data[native.object_id]&.[](key)
|
|
30
35
|
end
|
|
31
36
|
|
|
32
37
|
def set(native, key, value)
|
|
@@ -39,7 +44,8 @@ module Moxml
|
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
def key?(native, key)
|
|
42
|
-
|
|
47
|
+
h = @data[native.object_id]
|
|
48
|
+
!h.nil? && h.key?(key)
|
|
43
49
|
end
|
|
44
50
|
|
|
45
51
|
def delete(native, key)
|
data/lib/moxml/node.rb
CHANGED
|
@@ -35,6 +35,7 @@ module Moxml
|
|
|
35
35
|
def clear_native_memo!
|
|
36
36
|
@name = nil
|
|
37
37
|
@attribute_cache = nil
|
|
38
|
+
@entity_bearing_gen = nil
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
def document
|
|
@@ -64,9 +65,12 @@ module Moxml
|
|
|
64
65
|
def add_child(node)
|
|
65
66
|
node = prepare_node(node)
|
|
66
67
|
adapter.add_child(@native, node.native)
|
|
67
|
-
# Refresh native in case adapter changed identity (e.g., LibXML
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
# Refresh native in case adapter changed identity (e.g., LibXML
|
|
69
|
+
# doc.root=); stable-identity adapters skip the round trip.
|
|
70
|
+
unless adapter.native_identity_stable?
|
|
71
|
+
refreshed = adapter.actual_native(node.native, @native)
|
|
72
|
+
node.refresh_native!(refreshed) if refreshed && refreshed != node.native
|
|
73
|
+
end
|
|
70
74
|
node.parent_node = self
|
|
71
75
|
# The adopted subtree's in-scope namespaces changed
|
|
72
76
|
node.invalidate_namespace_cache!
|
|
@@ -113,23 +117,43 @@ module Moxml
|
|
|
113
117
|
# Determine if we should include XML declaration
|
|
114
118
|
# For Document nodes: check native then wrapper, unless explicitly overridden
|
|
115
119
|
# For other nodes: default to no declaration unless explicitly set
|
|
116
|
-
serialize_options =
|
|
117
|
-
|
|
120
|
+
serialize_options = if options.empty? && !is_a?(Document)
|
|
121
|
+
context.default_element_serialize_options
|
|
122
|
+
else
|
|
123
|
+
merged = context.default_serialize_options.merge(options)
|
|
124
|
+
merged[:no_declaration] = !should_include_declaration?(options)
|
|
125
|
+
merged
|
|
126
|
+
end
|
|
118
127
|
|
|
119
128
|
result = adapter.serialize(@native, serialize_options)
|
|
120
129
|
result = apply_line_ending(result, serialize_options[:line_ending])
|
|
121
130
|
|
|
122
131
|
# Restore entity markers to named entity references; skipped
|
|
123
132
|
# when the adapter knows the document carries no markers.
|
|
124
|
-
result = adapter.restore_entities(result) if
|
|
133
|
+
result = adapter.restore_entities(result) if entity_bearing?
|
|
125
134
|
result
|
|
126
135
|
end
|
|
127
136
|
|
|
137
|
+
# Memoized against the adapter's serialize generation — the
|
|
138
|
+
# entity-marker flag flips at parse and entity-reference mint,
|
|
139
|
+
# both adapter-level, and the generation bump is the invalidation
|
|
140
|
+
# signal. Adapters with static answers (base class) never bump.
|
|
141
|
+
def entity_bearing?
|
|
142
|
+
gen = adapter.serialize_generation
|
|
143
|
+
if @entity_bearing_gen == gen
|
|
144
|
+
@entity_bearing
|
|
145
|
+
else
|
|
146
|
+
@entity_bearing_gen = gen
|
|
147
|
+
@entity_bearing = adapter.entity_bearing?(@native)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
128
151
|
def xpath(expression, namespaces = {})
|
|
129
152
|
result = adapter.xpath(@native, expression, namespaces)
|
|
130
|
-
# Adapter contract: Array<native> | scalar.
|
|
131
|
-
# string-length(), booleans) pass through
|
|
132
|
-
|
|
153
|
+
# Adapter contract: Array<native> | LazyNodeSet | scalar.
|
|
154
|
+
# Scalars (count(), string-length(), booleans) pass through
|
|
155
|
+
# unwrapped; the set forms wrap lazily.
|
|
156
|
+
result.is_a?(Array) || result.is_a?(LazyNodeSet) ? NodeSet.new(result, context) : result
|
|
133
157
|
end
|
|
134
158
|
|
|
135
159
|
# Flattened post-order records for this subtree without
|
|
@@ -459,19 +483,6 @@ module Moxml
|
|
|
459
483
|
end
|
|
460
484
|
end
|
|
461
485
|
|
|
462
|
-
def default_options
|
|
463
|
-
{
|
|
464
|
-
encoding: context.config.default_encoding,
|
|
465
|
-
indent: context.config.default_indent,
|
|
466
|
-
line_ending: context.config.default_line_ending,
|
|
467
|
-
# The short format of empty tags in Oga and Nokogiri isn't configurable
|
|
468
|
-
# Oga: <empty /> (with a space)
|
|
469
|
-
# Nokogiri: <empty/> (without a space)
|
|
470
|
-
# The expanded format is enforced to avoid this conflict
|
|
471
|
-
expand_empty: true,
|
|
472
|
-
}
|
|
473
|
-
end
|
|
474
|
-
|
|
475
486
|
def should_include_declaration?(options)
|
|
476
487
|
return options[:declaration] if options.key?(:declaration)
|
|
477
488
|
return options.fetch(:declaration, false) unless is_a?(Document)
|
data/lib/moxml/node_set.rb
CHANGED
|
@@ -6,10 +6,14 @@ module Moxml
|
|
|
6
6
|
|
|
7
7
|
attr_reader :context
|
|
8
8
|
|
|
9
|
+
# nodes: Array of natives, or an adapter's LazyNodeSet — the
|
|
10
|
+
# native set is held unmaterialized until an operation needs an
|
|
11
|
+
# Array (#+, #<<, #delete, Range slices).
|
|
9
12
|
def initialize(nodes, context, parent_node = nil)
|
|
10
|
-
@nodes = Array
|
|
13
|
+
@nodes = nodes.is_a?(Array) ? nodes : nil
|
|
14
|
+
@lazy = @nodes ? nil : nodes
|
|
11
15
|
@context = context
|
|
12
|
-
@wrapped =
|
|
16
|
+
@wrapped = nil
|
|
13
17
|
@parent_node = parent_node
|
|
14
18
|
end
|
|
15
19
|
|
|
@@ -17,15 +21,17 @@ module Moxml
|
|
|
17
21
|
# The public surface is wrapper-only: every Enumerable access
|
|
18
22
|
# goes through #each/#[]/#to_a, which wrap.
|
|
19
23
|
def native_nodes
|
|
20
|
-
@nodes
|
|
24
|
+
return @nodes unless @nodes.nil?
|
|
25
|
+
|
|
26
|
+
@nodes = @lazy.to_a
|
|
21
27
|
end
|
|
22
28
|
|
|
23
29
|
def each
|
|
24
30
|
return to_enum(:each) unless block_given?
|
|
25
31
|
|
|
26
|
-
wrapped =
|
|
32
|
+
wrapped = wrapped_buffer
|
|
27
33
|
index = 0
|
|
28
|
-
@nodes.each do |node|
|
|
34
|
+
(@nodes || @lazy).each do |node|
|
|
29
35
|
wrapper = wrapped[index]
|
|
30
36
|
unless wrapper
|
|
31
37
|
wrapper = wrap_with_parent(node)
|
|
@@ -40,51 +46,56 @@ module Moxml
|
|
|
40
46
|
def [](index)
|
|
41
47
|
case index
|
|
42
48
|
when Integer
|
|
43
|
-
actual = index.negative? ?
|
|
44
|
-
return nil unless actual >= 0 && actual <
|
|
49
|
+
actual = index.negative? ? native_size + index : index
|
|
50
|
+
return nil unless actual >= 0 && actual < native_size
|
|
45
51
|
|
|
46
|
-
|
|
52
|
+
wrapped_buffer[actual] ||= wrap_with_parent((@nodes || @lazy)[actual])
|
|
47
53
|
when Range
|
|
48
|
-
self.class.new(
|
|
54
|
+
self.class.new(native_nodes[index], @context)
|
|
49
55
|
end
|
|
50
56
|
end
|
|
51
57
|
|
|
52
58
|
def first(n = nil)
|
|
53
59
|
if n.nil?
|
|
54
|
-
|
|
60
|
+
native_size.zero? ? nil : self[0]
|
|
55
61
|
else
|
|
56
62
|
n.times.filter_map { |i| self[i] }
|
|
57
63
|
end
|
|
58
64
|
end
|
|
59
65
|
|
|
60
66
|
def last
|
|
61
|
-
|
|
67
|
+
native_size.zero? ? nil : self[native_size - 1]
|
|
62
68
|
end
|
|
63
69
|
|
|
64
70
|
def empty?
|
|
65
|
-
|
|
71
|
+
native_size.zero?
|
|
66
72
|
end
|
|
67
73
|
|
|
68
74
|
def size
|
|
69
|
-
|
|
75
|
+
native_size
|
|
70
76
|
end
|
|
71
77
|
alias length size
|
|
72
78
|
|
|
73
79
|
def to_a
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
i = 0
|
|
81
|
+
wrapped = wrapped_buffer
|
|
82
|
+
(@nodes || @lazy).each do |node|
|
|
83
|
+
wrapped[i] ||= wrap_with_parent(node)
|
|
84
|
+
i += 1
|
|
76
85
|
end
|
|
77
|
-
|
|
86
|
+
wrapped.compact
|
|
78
87
|
end
|
|
79
88
|
|
|
80
89
|
def +(other)
|
|
81
|
-
self.class.new(
|
|
90
|
+
self.class.new(native_nodes + other.native_nodes, @context, @parent_node)
|
|
82
91
|
end
|
|
83
92
|
|
|
84
93
|
def <<(node)
|
|
85
94
|
native_node = node.is_a?(Node) ? node.native : node
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
# Materialize the buffer first so it allocates at the pre-append
|
|
96
|
+
# size and stays in lockstep with the natives.
|
|
97
|
+
wrapped_buffer << nil
|
|
98
|
+
native_nodes << native_node
|
|
88
99
|
self
|
|
89
100
|
end
|
|
90
101
|
alias push <<
|
|
@@ -94,7 +105,7 @@ module Moxml
|
|
|
94
105
|
# which may yield the same native node multiple times
|
|
95
106
|
def uniq_by_native
|
|
96
107
|
seen = {}
|
|
97
|
-
unique_natives =
|
|
108
|
+
unique_natives = native_nodes.select do |native|
|
|
98
109
|
id = native.object_id
|
|
99
110
|
if seen[id]
|
|
100
111
|
false
|
|
@@ -109,7 +120,7 @@ module Moxml
|
|
|
109
120
|
def ==(other)
|
|
110
121
|
self.class == other.class &&
|
|
111
122
|
length == other.length &&
|
|
112
|
-
|
|
123
|
+
native_nodes.each_with_index.all? do |_node, index|
|
|
113
124
|
self[index] == other[index]
|
|
114
125
|
end
|
|
115
126
|
end
|
|
@@ -127,18 +138,30 @@ module Moxml
|
|
|
127
138
|
# Accepts both wrapped Moxml nodes and native nodes
|
|
128
139
|
def delete(node)
|
|
129
140
|
native_node = node.is_a?(Node) ? node.native : node
|
|
130
|
-
idx =
|
|
141
|
+
idx = native_nodes.index(native_node)
|
|
131
142
|
if idx
|
|
132
|
-
|
|
133
|
-
|
|
143
|
+
native_nodes.delete_at(idx)
|
|
144
|
+
wrapped_buffer.delete_at(idx) if @wrapped
|
|
134
145
|
else
|
|
135
|
-
|
|
146
|
+
native_nodes.delete(native_node)
|
|
136
147
|
end
|
|
137
148
|
self
|
|
138
149
|
end
|
|
139
150
|
|
|
140
151
|
private
|
|
141
152
|
|
|
153
|
+
# Allocated on first wrapped access — .size/.empty? consumers of
|
|
154
|
+
# large result sets never pay for the slot array.
|
|
155
|
+
def wrapped_buffer
|
|
156
|
+
return @wrapped unless @wrapped.nil?
|
|
157
|
+
|
|
158
|
+
@wrapped = Array.new(native_size)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def native_size
|
|
162
|
+
@nodes ? @nodes.size : @lazy.length
|
|
163
|
+
end
|
|
164
|
+
|
|
142
165
|
def wrap_with_parent(native_node)
|
|
143
166
|
wrapped = Moxml::Node.wrap(native_node, @context)
|
|
144
167
|
if @parent_node && wrapped
|
|
@@ -139,24 +139,28 @@ module Moxml
|
|
|
139
139
|
@handlers[:start_document]&.call
|
|
140
140
|
end
|
|
141
141
|
|
|
142
|
+
# The per-event hash lookup fires for every node in the
|
|
143
|
+
# document; the DSL is fixed after construction, so the
|
|
144
|
+
# resolved block memoizes into an ivar on first fire (nil
|
|
145
|
+
# short-circuits the same way &. does).
|
|
142
146
|
# @private
|
|
143
147
|
def on_end_document
|
|
144
|
-
@handlers[:end_document]&.call
|
|
148
|
+
(@end_document_block ||= @handlers[:end_document])&.call
|
|
145
149
|
end
|
|
146
150
|
|
|
147
151
|
# @private
|
|
148
152
|
def on_start_element(name, attributes = {}, namespaces = {})
|
|
149
|
-
@handlers[:start_element]&.call(name, attributes, namespaces)
|
|
153
|
+
(@start_element_block ||= @handlers[:start_element])&.call(name, attributes, namespaces)
|
|
150
154
|
end
|
|
151
155
|
|
|
152
156
|
# @private
|
|
153
157
|
def on_end_element(name)
|
|
154
|
-
@handlers[:end_element]&.call(name)
|
|
158
|
+
(@end_element_block ||= @handlers[:end_element])&.call(name)
|
|
155
159
|
end
|
|
156
160
|
|
|
157
161
|
# @private
|
|
158
162
|
def on_characters(text)
|
|
159
|
-
@handlers[:characters]&.call(text)
|
|
163
|
+
(@characters_block ||= @handlers[:characters])&.call(text)
|
|
160
164
|
end
|
|
161
165
|
|
|
162
166
|
# @private
|
|
@@ -12,22 +12,31 @@ module Moxml
|
|
|
12
12
|
# @yieldparam value [Object] raw attribute/namespace value
|
|
13
13
|
# @yieldreturn [Object] transformed value to store
|
|
14
14
|
# @return [Array(Hash, Hash)] [regular_attrs, namespaces]
|
|
15
|
+
# Shared for the overwhelmingly common no-declaration element:
|
|
16
|
+
# one Hash allocation per start_element event saved. Frozen —
|
|
17
|
+
# event hashes are read-only data, not scratch.
|
|
18
|
+
EMPTY_NAMESPACES = {}.freeze
|
|
19
|
+
|
|
15
20
|
def split_attributes_and_namespaces(attributes)
|
|
16
21
|
attrs = {}
|
|
17
|
-
ns =
|
|
22
|
+
ns = nil
|
|
18
23
|
|
|
19
24
|
each_attribute(attributes) do |name, value|
|
|
20
25
|
name_s = name.to_s
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
if name_s.start_with?("xmlns")
|
|
27
|
+
if name_s == "xmlns"
|
|
28
|
+
(ns ||= {})[nil] = block_given? ? yield(value) : value
|
|
29
|
+
elsif name_s.bytesize > 5 && name_s.getbyte(5) == 58 # ":"
|
|
30
|
+
(ns ||= {})[name_s[6..]] = block_given? ? yield(value) : value
|
|
31
|
+
else
|
|
32
|
+
attrs[name_s] = block_given? ? yield(value) : value
|
|
33
|
+
end
|
|
25
34
|
else
|
|
26
|
-
attrs[name_s] =
|
|
35
|
+
attrs[name_s] = block_given? ? yield(value) : value
|
|
27
36
|
end
|
|
28
37
|
end
|
|
29
38
|
|
|
30
|
-
[attrs, ns]
|
|
39
|
+
[attrs, ns || EMPTY_NAMESPACES]
|
|
31
40
|
end
|
|
32
41
|
|
|
33
42
|
private
|
data/lib/moxml/version.rb
CHANGED
data/lib/moxml/xml_utils.rb
CHANGED
|
@@ -42,8 +42,19 @@ module Moxml
|
|
|
42
42
|
raise ValidationError, "XML comment cannot contain double hyphens (--)"
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# Real documents reuse a handful of distinct element names; the
|
|
46
|
+
# memo turns the per-create regex into a hash probe after the
|
|
47
|
+
# first sight of a name. Capped — hostile vocabularies fall back
|
|
48
|
+
# to the regex.
|
|
49
|
+
VALID_ELEMENT_NAMES = {}.compare_by_identity
|
|
50
|
+
|
|
45
51
|
def validate_element_name(name)
|
|
46
|
-
return if name.is_a?(String) &&
|
|
52
|
+
return if name.is_a?(String) && VALID_ELEMENT_NAMES.key?(name)
|
|
53
|
+
|
|
54
|
+
if name.is_a?(String) && name.match?(/^[a-zA-Z_][\w\-.:]*$/)
|
|
55
|
+
VALID_ELEMENT_NAMES[name] = true if VALID_ELEMENT_NAMES.size < 1024
|
|
56
|
+
return
|
|
57
|
+
end
|
|
47
58
|
|
|
48
59
|
raise ValidationError, "Invalid XML element name: #{name}"
|
|
49
60
|
end
|
data/lib/moxml.rb
CHANGED
|
@@ -68,6 +68,7 @@ module Moxml
|
|
|
68
68
|
autoload :Context, "moxml/context"
|
|
69
69
|
autoload :Node, "moxml/node"
|
|
70
70
|
autoload :NodeSet, "moxml/node_set"
|
|
71
|
+
autoload :LazyNodeSet, "moxml/lazy_node_set"
|
|
71
72
|
autoload :Document, "moxml/document"
|
|
72
73
|
autoload :Element, "moxml/element"
|
|
73
74
|
autoload :Text, "moxml/text"
|
|
@@ -52,10 +52,15 @@ RSpec.describe Moxml::Adapter::Leptris do
|
|
|
52
52
|
expect(doc.xpath("//item[@p:kind='a']").map { |n| n["id"] }).to eq(["1"])
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
it "
|
|
55
|
+
it "evaluates attribute-node queries with proper wrappers" do
|
|
56
|
+
# Native since 1.9.105 (leptris-ruby#153: ResultAttr with
|
|
57
|
+
# name/value); earlier bindings returned generic nodes whose
|
|
58
|
+
# #name raised, so the Ruby engine owned them.
|
|
59
|
+
skip "requires native attr results (leptris 1.9.105+)" unless described_class::ATTR_RESULT_NATIVE
|
|
56
60
|
attrs = doc.xpath("//item/@id")
|
|
57
61
|
expect(attrs.map(&:name)).to eq(%w[id id])
|
|
58
62
|
expect(attrs.map(&:value)).to eq(%w[1 2])
|
|
63
|
+
expect(attrs.first).to be_a(Moxml::Attribute)
|
|
59
64
|
end
|
|
60
65
|
|
|
61
66
|
it "evaluates element-context queries on the native engine" do
|
|
@@ -314,6 +319,247 @@ RSpec.describe Moxml::Adapter::Leptris do
|
|
|
314
319
|
end
|
|
315
320
|
end
|
|
316
321
|
|
|
322
|
+
describe "HTML parsing (leptris/leptris#659)" do
|
|
323
|
+
before do
|
|
324
|
+
skip "requires leptris 1.9.80+ (HTML engine mode)" unless described_class::HTML_PARSE_SUPPORTED
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
let(:ctx) { Moxml.new(:leptris) }
|
|
328
|
+
|
|
329
|
+
it "synthesizes the html/body structure with lowercased names" do
|
|
330
|
+
doc = ctx.parse_html(%(<DIV CLASS="x">t</DIV>))
|
|
331
|
+
expect(doc.root.name).to eq("html")
|
|
332
|
+
body = doc.root.children.find(&:element?)
|
|
333
|
+
expect(body.name).to eq("body")
|
|
334
|
+
div = body.at_xpath(".//div")
|
|
335
|
+
expect(div["class"]).to eq("x")
|
|
336
|
+
expect(div.text).to eq("t")
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
it "implies end tags for list items" do
|
|
340
|
+
doc = ctx.parse_html(%(<ul><li>one<li>two</ul>))
|
|
341
|
+
expect(doc.xpath("//li").map(&:text)).to eq(%w[one two])
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
it "keeps void elements as empty elements" do
|
|
345
|
+
doc = ctx.parse_html(%(<br><img src="x.png">))
|
|
346
|
+
expect(doc.xpath("//br").size).to eq(1)
|
|
347
|
+
expect(doc.xpath("//img").first["src"]).to eq("x.png")
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
it "decodes HTML named entities into text" do
|
|
351
|
+
doc = ctx.parse_html(%(<p>café ©</p>))
|
|
352
|
+
expect(doc.at_xpath("//p").text).to eq("caf\u00e9 \u00a0\u00a9")
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
it "materializes boolean attributes" do
|
|
356
|
+
doc = ctx.parse_html(%(<a href="/x" disabled>t</a>))
|
|
357
|
+
link = doc.at_xpath("//a")
|
|
358
|
+
expect(link["href"]).to eq("/x")
|
|
359
|
+
expect(link["disabled"]).to eq("")
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
it "reads script content as raw text" do
|
|
363
|
+
doc = ctx.parse_html(%(<script>if (a < b) { x("</div>"); }</script>))
|
|
364
|
+
expect(doc.at_xpath("//script").text).to include("a < b")
|
|
365
|
+
expect(doc.at_xpath("//script").text).to include("</div>")
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
it "serializes to well-formed XML that reparses strictly" do
|
|
369
|
+
doc = ctx.parse_html(%(<p>a & b < c</p><script>y = "</q>";</script>))
|
|
370
|
+
out = doc.to_xml
|
|
371
|
+
expect(out).to include("<")
|
|
372
|
+
reparsed = Nokogiri::XML(out, &:strict)
|
|
373
|
+
expect(reparsed).to be_a(Nokogiri::XML::Document)
|
|
374
|
+
expect(reparsed.errors).to be_empty
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
it "preserves foreign content (SVG/MathML) in HTML documents" do
|
|
378
|
+
doc = ctx.parse_html(%(<p>a</p><svg viewBox="0 0 1 1"><circle r="1"/></svg><math><mi>a</mi></math>))
|
|
379
|
+
body = doc.root.children.find(&:element?)
|
|
380
|
+
names = body.children.select(&:element?).map(&:name)
|
|
381
|
+
expect(names).to include("svg", "math")
|
|
382
|
+
expect(body.at_xpath(".//circle")["r"]).to eq("1")
|
|
383
|
+
expect(body.at_xpath(".//mi").text).to eq("a")
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
it "preserves foreignObject content Nokogiri drops (name lowercased)" do
|
|
387
|
+
# WHATWG keeps foreign-content camelCase (foreignObject,
|
|
388
|
+
# viewBox); the engine currently lowercases like it does HTML
|
|
389
|
+
# names, but preserves the subtree — Nokogiri drops it
|
|
390
|
+
# entirely. Pins current behavior; engine conformance note
|
|
391
|
+
# filed for the adjust-tables.
|
|
392
|
+
doc = ctx.parse_html(%(<svg><foreignObject><p>x</p></foreignObject></svg>))
|
|
393
|
+
out = doc.to_xml
|
|
394
|
+
expect(out).to include("<foreignobject>")
|
|
395
|
+
expect(out).to include("<p>x</p>")
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
it "round-trips an HTML doctype with external identifiers" do
|
|
399
|
+
doc = ctx.parse_html(%(<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "x.dtd"><p>a</p>))
|
|
400
|
+
expect(doc.to_xml).to include(%(<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "x.dtd">))
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
it "preserves template element placement" do
|
|
404
|
+
doc = ctx.parse_html(%(<div><template><p>t</p></template></div>))
|
|
405
|
+
expect(doc.at_xpath("//template/p")&.text).to eq("t")
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
it "round-trips a parsed tree through mutation" do
|
|
409
|
+
doc = ctx.parse_html(%(<ul><li>a<li>b</ul>))
|
|
410
|
+
doc.at_xpath("//ul").add_child(doc.create_element("li"))
|
|
411
|
+
expect(doc.xpath("//li").map(&:name)).to eq(%w[li li li])
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
describe "iterparse streaming" do
|
|
416
|
+
let(:ctx) { Moxml.new(:leptris) }
|
|
417
|
+
let(:xml) do
|
|
418
|
+
%(<catalog>#{Array.new(3) { |i| %(<record id="r#{i}"><field name="f">v#{i} & x</field></record>) }.join}</catalog>)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
it "yields completed top-level children with readable attributes" do
|
|
422
|
+
seen = []
|
|
423
|
+
ctx.iterparse(xml) { |e| seen << [e.name, e["id"]] }
|
|
424
|
+
expect(seen).to eq([["record", "r0"], ["record", "r1"], ["record", "r2"]])
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
it "full_document yields every element in completion order" do
|
|
428
|
+
seen = []
|
|
429
|
+
ctx.iterparse(xml, mode: :full_document) { |e| seen << e.name }
|
|
430
|
+
expect(seen).to eq(%w[field record field record field record catalog])
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
it "reads children, text, and serializes inside the block" do
|
|
434
|
+
outs = []
|
|
435
|
+
ctx.iterparse(xml) do |e|
|
|
436
|
+
outs << [e.children.first["name"], e.children.first.text,
|
|
437
|
+
Nokogiri::XML(e.to_xml, &:strict).root["id"]]
|
|
438
|
+
end
|
|
439
|
+
expect(outs.map(&:first)).to all(eq("f"))
|
|
440
|
+
expect(outs.map { |row| row[1] }).to all(match(/v\d & x/))
|
|
441
|
+
expect(outs.map { |row| row[2] }).to eq(%w[r0 r1 r2])
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
it "streams from a file" do
|
|
445
|
+
require "tmpdir"
|
|
446
|
+
Dir.mktmpdir do |dir|
|
|
447
|
+
path = File.join(dir, "doc.xml")
|
|
448
|
+
File.write(path, xml)
|
|
449
|
+
seen = []
|
|
450
|
+
ctx.iterparse_file(path) { |e| seen << e["id"] }
|
|
451
|
+
expect(seen).to eq(%w[r0 r1 r2])
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
it "answers subqueries on yielded elements via the Ruby engine" do
|
|
456
|
+
# Parentless elements have no document handle for the compiled
|
|
457
|
+
# native eval — the gate must route them, not crash.
|
|
458
|
+
seen = []
|
|
459
|
+
ctx.iterparse(xml) do |e|
|
|
460
|
+
seen << [e.at_xpath(".//field")["name"], e.xpath("count(.//field)")]
|
|
461
|
+
end
|
|
462
|
+
expect(seen).to eq([["f", 1.0]] * 3)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
it "requires a block" do
|
|
466
|
+
expect { ctx.iterparse(xml) }.to raise_error(ArgumentError, /block/)
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
describe "C14n native delegation" do
|
|
471
|
+
it "matches the Ruby reference byte-for-byte on the default path" do
|
|
472
|
+
# Whether the NATIVE_C14N_BYTE_SAFE probe is armed (fixed
|
|
473
|
+
# engine builds delegate to the C canonicalizer) or not, the
|
|
474
|
+
# default-path output must equal the Ruby reference — this is
|
|
475
|
+
# the safety net that lets the probe auto-adopt future builds.
|
|
476
|
+
xml = %(<?xml version="1.0"?><doc xmlns:p="urn:p" xmlns="urn:d" b="2" a="1"><e p:x="v" z="w">t & u</e><!-- c --></doc>)
|
|
477
|
+
ctx = Moxml.new(:leptris)
|
|
478
|
+
root = ctx.parse(xml).root
|
|
479
|
+
expect(Moxml::C14n.canonicalize(root))
|
|
480
|
+
.to eq(Moxml::C14n::Inclusive10.new.canonicalize(root))
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
describe "wrapper lifecycle" do
|
|
485
|
+
it "releases wrappers when documents are dropped (WeakMap registry)" do
|
|
486
|
+
# The identity map must hold wrappers weakly: parse-and-drop
|
|
487
|
+
# workloads otherwise pin wrappers, natives, and (via the
|
|
488
|
+
# binding finalizer never running) the C subtrees.
|
|
489
|
+
xml = %(<r>#{Array.new(50) { |i| "<e id=\"i#{i}\">x</e>" }.join}</r>)
|
|
490
|
+
ctx = Moxml.new(:leptris)
|
|
491
|
+
count = -> {
|
|
492
|
+
n = 0
|
|
493
|
+
ObjectSpace.each_object(Moxml::Element) { n += 1 }
|
|
494
|
+
n
|
|
495
|
+
}
|
|
496
|
+
walk = ->(doc) { doc.root.children.to_a }
|
|
497
|
+
|
|
498
|
+
GC.start
|
|
499
|
+
before = count.call
|
|
500
|
+
20.times do
|
|
501
|
+
doc = ctx.parse(xml)
|
|
502
|
+
walk.(doc)
|
|
503
|
+
nil
|
|
504
|
+
end
|
|
505
|
+
5.times { GC.start }
|
|
506
|
+
|
|
507
|
+
# The binding retains a constant one-document wrapper set of
|
|
508
|
+
# its own; moxml must not retain beyond a couple of dropped
|
|
509
|
+
# documents' worth (was: all 20 pinned under the strong map).
|
|
510
|
+
expect(count.call - before).to be < 2 * 51
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
it "keeps wrapper identity while a document is alive" do
|
|
514
|
+
ctx = Moxml.new(:leptris)
|
|
515
|
+
doc = ctx.parse(%(<r><a/></r>))
|
|
516
|
+
expect(doc.root.children.first).to equal(doc.root.children.first)
|
|
517
|
+
GC.start
|
|
518
|
+
expect(doc.root.children.first).to equal(doc.root.children.first)
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
describe "lazy xpath result sets" do
|
|
523
|
+
let(:ctx) { Moxml.new(:leptris) }
|
|
524
|
+
let(:doc) do
|
|
525
|
+
ctx.parse(%(<r>#{Array.new(50) { |i| "<li>#{i}</li>" }.join}</r>))
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
it "answers size, first, and indexing without materializing" do
|
|
529
|
+
set = doc.xpath("//li")
|
|
530
|
+
expect(set.size).to eq(50)
|
|
531
|
+
expect(set.first.text).to eq("0")
|
|
532
|
+
expect(set[10].text).to eq("10")
|
|
533
|
+
expect(set[-1].text).to eq("49")
|
|
534
|
+
expect(set.empty?).to be(false)
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
it "enumerates and wraps on demand" do
|
|
538
|
+
expect(doc.xpath("//li").each.to_a.size).to eq(50)
|
|
539
|
+
expect(doc.xpath("//li").to_a.map(&:name).uniq).to eq(%w[li])
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
it "keeps the mutating set operations working" do
|
|
543
|
+
set = doc.xpath("//li")
|
|
544
|
+
expect((set + set).size).to eq(100)
|
|
545
|
+
expect(set.uniq_by_native.size).to eq(50)
|
|
546
|
+
set << doc.create_element("li")
|
|
547
|
+
expect(set.size).to eq(51)
|
|
548
|
+
expect(set.last.name).to eq("li")
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
it "slices ranges" do
|
|
552
|
+
expect(doc.xpath("//li")[0..2].size).to eq(3)
|
|
553
|
+
expect(doc.xpath("//li")[5...8].map(&:text)).to eq(%w[5 6 7])
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
it "returns scalars and at_xpath firsts unwrapped-set" do
|
|
557
|
+
expect(doc.xpath("count(//li)")).to eq(50.0)
|
|
558
|
+
expect(doc.at_xpath("//li").text).to eq("0")
|
|
559
|
+
expect(doc.at_xpath("//nope")).to be_nil
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
317
563
|
describe "DTD ATTLIST defaults" do
|
|
318
564
|
# libleptris 1.9.8: plain parse excludes ATTLIST defaults,
|
|
319
565
|
# matching libxml2/Nokogiri/REXML; dtdattr: true opts in.
|