moxml 0.5.13 → 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.
- checksums.yaml +4 -4
- data/.rubocop.yml +9 -0
- data/docs/_pages/adapter-protocol.adoc +8 -4
- data/docs/_pages/adapters/leptris.adoc +5 -0
- data/docs/_pages/conversion-apis.adoc +28 -3
- data/lib/compat/opal/moxml_boot.rb +1 -0
- data/lib/moxml/adapter/base.rb +18 -4
- data/lib/moxml/adapter/leptris/materialize.rb +112 -82
- data/lib/moxml/adapter/leptris/serialize.rb +10 -2
- data/lib/moxml/adapter/leptris.rb +46 -7
- data/lib/moxml/adapter/nokogiri.rb +7 -0
- data/lib/moxml/context.rb +8 -0
- data/lib/moxml/document.rb +18 -0
- data/lib/moxml/entity_registry.rb +0 -1
- data/lib/moxml/materializer.rb +92 -58
- data/lib/moxml/node.rb +8 -0
- data/lib/moxml/version.rb +1 -1
- data/lib/moxml.rb +3 -0
- data/spec/integration/shared_examples/node_wrappers/declaration_behavior.rb +3 -4
- data/spec/integration/shared_examples/node_wrappers/document_behavior.rb +4 -0
- data/spec/moxml/adapter/leptris_spec.rb +40 -7
- data/spec/moxml/adapter/nokogiri_spec.rb +8 -0
- data/spec/moxml/builder_spec.rb +8 -1
- data/spec/moxml/materializer_spec.rb +51 -0
- data/spec/moxml/signature/algorithms_spec.rb +1 -1
- data/spec/moxml/xpath/conversion_spec.rb +2 -6
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8302b1a98c08b34619df4a30d5b84733fa9125eebeeaf1b39b5ba82d0e8fa625
|
|
4
|
+
data.tar.gz: e96034377b072b1ee9058ced4a4dd0347e819f6354011810831b07a06e88e138
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bbe5b80c8b6a656e14b45307bf9b41d1ba1a384c33906ed11fc7d861c2f8fbfad33fb08f2b091ea8b295608c025b5045bc6d0ff73927420e07a23635750fec38
|
|
7
|
+
data.tar.gz: 994ca51d9e27649ef6d70d2f225408dafdd73e35d5948903e33c978b75c5523d5c11ce889c361ded3c7af74473fe75b107168df5f6ab88ec4af5841f5f530ebf
|
data/.rubocop.yml
CHANGED
|
@@ -18,3 +18,12 @@ AllCops:
|
|
|
18
18
|
- 'tmp/**/*'
|
|
19
19
|
- 'spec/consistency/round_trip_spec.rb'
|
|
20
20
|
- 'lib/compat/**/*'
|
|
21
|
+
|
|
22
|
+
# materialize_fields yields the eight record fields positionally —
|
|
23
|
+
# the zero-allocation streaming form (issue #143). One parameter per
|
|
24
|
+
# record key is the interface; a shorter list would need a wrapper
|
|
25
|
+
# object, which is the allocation the API exists to avoid.
|
|
26
|
+
Metrics/ParameterLists:
|
|
27
|
+
Exclude:
|
|
28
|
+
- 'lib/moxml/materializer.rb'
|
|
29
|
+
- 'spec/moxml/materializer_spec.rb'
|
|
@@ -23,7 +23,9 @@ Parse and document lifecycle::
|
|
|
23
23
|
`create_native_comment`, `create_native_processing_instruction`,
|
|
24
24
|
`create_native_declaration`, `create_native_doctype`,
|
|
25
25
|
`create_native_entity_reference`, `create_native_namespace`),
|
|
26
|
-
`free_document` (#134; no-op on GC-managed engines)
|
|
26
|
+
`free_document` (#134; no-op on GC-managed engines), `parse_errors`
|
|
27
|
+
(#147; recover-mode diagnostics — `[]` on clean parses; engines with
|
|
28
|
+
an error channel map it here, e.g. Nokogiri's `doc.errors`).
|
|
27
29
|
|
|
28
30
|
Tree navigation::
|
|
29
31
|
`children`, `parent`, `next_sibling`, `previous_sibling`, `root`,
|
|
@@ -79,11 +81,13 @@ post-serialize restore scan.
|
|
|
79
81
|
|
|
80
82
|
`bulk_materialize?`::
|
|
81
83
|
Whether the adapter offers a bulk path for `Moxml::Materializer`;
|
|
82
|
-
`
|
|
83
|
-
back to the
|
|
84
|
+
`materialize_fields` fills the reused flat buffers and yields the
|
|
85
|
+
eight record fields per node, returning nil falls back to the
|
|
86
|
+
generic wrapper walk. Leptris walks raw C pointers with the batch
|
|
87
|
+
`leptris_node_children` call — no wrappers, no per-node callback.
|
|
84
88
|
|
|
85
89
|
Constants like `DOC_NODE_SUPPORTED` / `DTDATTR_SUPPORTED` /
|
|
86
|
-
`
|
|
90
|
+
`BULK_FIELD_READS` (Leptris) follow the same idea: one probe
|
|
87
91
|
per engine capability, computed at load.
|
|
88
92
|
|
|
89
93
|
== Return shapes
|
|
@@ -61,6 +61,11 @@ implementation over one `leptris_node_traverse` call.
|
|
|
61
61
|
|
|
62
62
|
* Tab indentation (`indent_text`) needs a serialize-time parameter in
|
|
63
63
|
libleptris; post-hoc rewriting would corrupt mixed content.
|
|
64
|
+
* Child PIs serialize inline (engine emits `<r>\n<?pi x?> <e/>`
|
|
65
|
+
rather than libxml2's one-PI-per-indented-line layout); safe
|
|
66
|
+
post-hoc rewriting is impossible in mixed content.
|
|
67
|
+
* DOCTYPE internal subsets serialize inline; libxml2 separates the
|
|
68
|
+
declarations with newlines.
|
|
64
69
|
* Document-level PIs are read-only natives (`target=`/`data=`/
|
|
65
70
|
`unlink` rejected); write-through and removal are pending C
|
|
66
71
|
surface (leptris/leptris#612).
|
|
@@ -9,7 +9,9 @@ Scanned streaming (`materialize`), deterministic release (`Document#free`), and
|
|
|
9
9
|
|
|
10
10
|
== Bulk materialization — `materialize` (issue #132)
|
|
11
11
|
|
|
12
|
-
Flattened, post-order records for a subtree with no `Moxml::Node`
|
|
12
|
+
Flattened, post-order records for a subtree with no `Moxml::Node`
|
|
13
|
+
allocation — the conversion path for consumers that build their own
|
|
14
|
+
tree from a document:
|
|
13
15
|
|
|
14
16
|
[source,ruby]
|
|
15
17
|
----
|
|
@@ -18,7 +20,7 @@ document.materialize.each # Enumerator, root subtree
|
|
|
18
20
|
any_element.materialize # any subtree
|
|
19
21
|
----
|
|
20
22
|
|
|
21
|
-
Record shape (all
|
|
23
|
+
Record shape (all eight keys on every record):
|
|
22
24
|
|
|
23
25
|
[source,ruby]
|
|
24
26
|
----
|
|
@@ -34,9 +36,32 @@ Record shape (all seven keys on every record):
|
|
|
34
36
|
|
|
35
37
|
* Post-order: children arrive before their parent — build with a stack.
|
|
36
38
|
* `Document#materialize` yields exactly the root subtree (issue #140); document-level parts (prolog/epilog PIs and comments, DOCTYPE) stay enumerable via `children`.
|
|
37
|
-
*
|
|
39
|
+
* Every record is an independent Hash snapshot — safe to retain.
|
|
38
40
|
* Entity-bearing documents fall back to the generic walk so entity references still split.
|
|
39
41
|
|
|
42
|
+
=== The streaming form — `materialize_fields` (issue #143)
|
|
43
|
+
|
|
44
|
+
The conversion hot path: the same walk with zero per-record
|
|
45
|
+
allocation. The block receives the eight fields positionally, and the
|
|
46
|
+
two list arguments are flat, reused buffers:
|
|
47
|
+
|
|
48
|
+
[source,ruby]
|
|
49
|
+
----
|
|
50
|
+
context.materialize_fields(xml) do |kind, qname, prefix, uri,
|
|
51
|
+
namespaces, attributes, text, depth|
|
|
52
|
+
# namespaces: flat [prefix, uri, ...] (stride 2)
|
|
53
|
+
# attributes: flat [name, value, uri, prefix, ...] (stride 4)
|
|
54
|
+
end
|
|
55
|
+
----
|
|
56
|
+
|
|
57
|
+
The buffers are refilled per element record — valid only inside the
|
|
58
|
+
block. Copy what you keep, or use `materialize` (which layers a fresh
|
|
59
|
+
Hash snapshot on this same stream). On CRuby + leptris the walk runs
|
|
60
|
+
over raw C pointers with the batch `leptris_node_children` call — no
|
|
61
|
+
wrapper objects, no per-node FFI callback, no depth bookkeeping;
|
|
62
|
+
measured ~1.2x faster than an equivalent raw-Nokogiri DOM walk.
|
|
63
|
+
Requires a block (raises `ArgumentError` otherwise).
|
|
64
|
+
|
|
40
65
|
== Deterministic release — `Document#free` (issue #134)
|
|
41
66
|
|
|
42
67
|
Adapters backed by C trees hold native memory until a GC finalizer runs. Batch workloads (parse → convert → discard, thousands of documents) release it deterministically:
|
|
@@ -24,6 +24,7 @@ require "moxml/namespace"
|
|
|
24
24
|
require "moxml/doctype"
|
|
25
25
|
require "moxml/entity_reference"
|
|
26
26
|
require "moxml/entity_registry"
|
|
27
|
+
require "moxml/entity_registry_opal_data"
|
|
27
28
|
require "moxml/entity"
|
|
28
29
|
require "moxml/entity/restorer"
|
|
29
30
|
require "moxml/entity/reference"
|
data/lib/moxml/adapter/base.rb
CHANGED
|
@@ -190,14 +190,19 @@ namespace_validation_mode: :strict)
|
|
|
190
190
|
end
|
|
191
191
|
|
|
192
192
|
# Whether the engine offers a bulk materialization path for
|
|
193
|
-
# Materializer (issue #132). When true,
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
#
|
|
193
|
+
# Materializer (issue #132). When true, the adapter gets
|
|
194
|
+
# #materialize_fields(native, buffers, &block) — fill the
|
|
195
|
+
# reused flat buffers and yield the eight record fields per
|
|
196
|
+
# node. Returning nil (e.g. for document shapes the bulk path
|
|
197
|
+
# cannot express) falls back to the generic wrapper walk.
|
|
197
198
|
def bulk_materialize?
|
|
198
199
|
false
|
|
199
200
|
end
|
|
200
201
|
|
|
202
|
+
def materialize_fields(_native, _buffers)
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
201
206
|
# Deterministic native-memory release for adapters backed by
|
|
202
207
|
# C trees (issue #134). GC-managed engines no-op; released
|
|
203
208
|
# documents raise the engine's use-after-free error on
|
|
@@ -206,6 +211,15 @@ namespace_validation_mode: :strict)
|
|
|
206
211
|
nil
|
|
207
212
|
end
|
|
208
213
|
|
|
214
|
+
# Recover-mode parse diagnostics (issue #147): the error
|
|
215
|
+
# messages the engine recorded while parsing, [] when the
|
|
216
|
+
# parse was clean. Engines with a native recover channel
|
|
217
|
+
# (Nokogiri's `doc.errors`) or a non-strict path that loses
|
|
218
|
+
# the raised error (leptris) override this.
|
|
219
|
+
def parse_errors(_native_doc)
|
|
220
|
+
[]
|
|
221
|
+
end
|
|
222
|
+
|
|
209
223
|
# Check if the native document has an XML declaration
|
|
210
224
|
# @param native_doc the native document object
|
|
211
225
|
# @param wrapper [Moxml::Document] the wrapper with has_xml_declaration flag
|
|
@@ -4,105 +4,135 @@ module Moxml
|
|
|
4
4
|
module Adapter
|
|
5
5
|
class Leptris
|
|
6
6
|
module Materialize
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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).
|
|
11
37
|
def bulk_materialize?
|
|
12
38
|
true
|
|
13
39
|
end
|
|
14
40
|
|
|
15
|
-
def
|
|
41
|
+
def materialize_fields(native, buffers, &block)
|
|
42
|
+
return nil unless Leptris::BULK_FIELD_READS
|
|
43
|
+
|
|
16
44
|
doc = native.is_a?(::Leptris::XML::Document) ? native : native.document
|
|
17
45
|
# Marker-bearing text needs the split pipeline (children-level
|
|
18
46
|
# ER expansion); the bulk path has no marker handling.
|
|
19
47
|
return nil if doc.nil? || attachments.get(doc, :entity_markers)
|
|
20
48
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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)
|
|
49
|
+
root_ptr = if native.is_a?(::Leptris::XML::Document)
|
|
50
|
+
doc.root&.c_ptr
|
|
51
|
+
else
|
|
52
|
+
native.c_ptr
|
|
53
53
|
end
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
return nil unless root_ptr
|
|
55
|
+
|
|
56
|
+
walk_fields(root_ptr, 0, buffers,
|
|
57
|
+
::FFI::MemoryPointer.new(:pointer, CHILDREN_CAPACITY),
|
|
58
|
+
&block)
|
|
56
59
|
true
|
|
57
60
|
end
|
|
58
61
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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)
|
|
67
70
|
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
71
|
|
|
82
|
-
|
|
83
|
-
|
|
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)
|
|
84
92
|
end
|
|
85
93
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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)
|
|
106
136
|
end
|
|
107
137
|
end
|
|
108
138
|
end
|
|
@@ -33,7 +33,11 @@ module Moxml
|
|
|
33
33
|
# Entity restoration belongs to the wrapper layer
|
|
34
34
|
# (Node#to_xml runs adapter.restore_entities for every
|
|
35
35
|
# adapter); doing it here scanned the output a second time.
|
|
36
|
-
normalize_serialization(raw_serialize(node, options), options)
|
|
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
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
def raw_serialize(node, options)
|
|
@@ -59,11 +63,15 @@ module Moxml
|
|
|
59
63
|
include_decl = options.fetch(:declaration) do
|
|
60
64
|
options[:no_declaration] ? false : document_has_declaration?(node)
|
|
61
65
|
end
|
|
62
|
-
node.to_xml(
|
|
66
|
+
xml = node.to_xml(
|
|
63
67
|
indent: options.fetch(:indent, 0),
|
|
64
68
|
no_decl: !include_decl,
|
|
65
69
|
encoding: options[:encoding],
|
|
66
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/, "")
|
|
67
75
|
end
|
|
68
76
|
|
|
69
77
|
def normalize_serialization(xml, options)
|
|
@@ -27,12 +27,24 @@ 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
|
-
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
|
|
31
|
+
# The raw field-read surface for the bulk materializer (issue
|
|
32
|
+
# #143). Bindings without the full set fall back to the generic
|
|
33
|
+
# wrapper walk.
|
|
34
|
+
BULK_FIELD_READS = %i[
|
|
35
|
+
leptris_node_children leptris_node_get_type
|
|
36
|
+
leptris_element_name leptris_element_prefix leptris_element_namespace
|
|
37
|
+
leptris_element_namespace_count leptris_element_namespace_decl_prefix
|
|
38
|
+
leptris_element_namespace_decl_uri leptris_element_first_attribute
|
|
39
|
+
leptris_attribute_get_name leptris_attribute_get_value
|
|
40
|
+
leptris_attribute_next leptris_attribute_namespace_uri
|
|
41
|
+
leptris_text_node_get_content leptris_cdata_node_get_content
|
|
42
|
+
leptris_comment_node_get_content leptris_pi_node_get_target
|
|
43
|
+
leptris_pi_node_get_data
|
|
44
|
+
].all? { |fn| ::Leptris::XML::FFI.respond_to?(fn) }
|
|
45
|
+
|
|
46
|
+
NO_PARSE_ERRORS = [].freeze
|
|
47
|
+
private_constant :NO_PARSE_ERRORS
|
|
36
48
|
|
|
37
49
|
# Cohesive clusters extracted from this class — the adapter
|
|
38
50
|
# protocol surface is unchanged; the modules hold the document
|
|
@@ -77,9 +89,12 @@ module Moxml
|
|
|
77
89
|
rescue ::Leptris::XML::ParseError => e
|
|
78
90
|
# libleptris has no recovery mode that survives unclosed
|
|
79
91
|
# tags; non-strict callers get an empty document, matching
|
|
80
|
-
# the Libxml adapter's non-strict behavior.
|
|
92
|
+
# the Libxml adapter's non-strict behavior. The fatal
|
|
93
|
+
# error rides the document as parse diagnostics (issue
|
|
94
|
+
# #147) — otherwise nothing says why it came back empty.
|
|
81
95
|
raise Moxml::ParseError.new(e.message) if options[:strict]
|
|
82
96
|
|
|
97
|
+
recover_errors = [e.message]
|
|
83
98
|
create_document
|
|
84
99
|
end
|
|
85
100
|
ctx = _context || Context.new(:leptris)
|
|
@@ -87,6 +102,7 @@ module Moxml
|
|
|
87
102
|
|
|
88
103
|
record_source_declaration(native_doc, processed)
|
|
89
104
|
attachments.set(native_doc, :entity_markers, entity_markers)
|
|
105
|
+
attachments.set(native_doc, :parse_errors, recover_errors) if recover_errors
|
|
90
106
|
|
|
91
107
|
doc
|
|
92
108
|
end
|
|
@@ -99,6 +115,10 @@ module Moxml
|
|
|
99
115
|
::Leptris::XML::ParseOptions.dtdattr
|
|
100
116
|
end
|
|
101
117
|
|
|
118
|
+
def parse_errors(native_doc)
|
|
119
|
+
attachments.get(native_doc, :parse_errors) || NO_PARSE_ERRORS
|
|
120
|
+
end
|
|
121
|
+
|
|
102
122
|
def create_document(_native_doc = nil)
|
|
103
123
|
::Leptris::XML::Document.create
|
|
104
124
|
end
|
|
@@ -685,6 +705,7 @@ module Moxml
|
|
|
685
705
|
ast = XPath::Parser.parse_with_cache(expression)
|
|
686
706
|
next false if ast_contains_type?(ast, :variable)
|
|
687
707
|
next false if uses_xmlns_prefix?(ast)
|
|
708
|
+
next false if prefixed_attribute_test?(ast)
|
|
688
709
|
|
|
689
710
|
!selects_attribute_results?(ast)
|
|
690
711
|
end
|
|
@@ -724,6 +745,24 @@ module Moxml
|
|
|
724
745
|
end
|
|
725
746
|
end
|
|
726
747
|
|
|
748
|
+
# Some released native engines do not match prefixed attribute
|
|
749
|
+
# tests inside predicates (@p:kind='a'); the Ruby engine does.
|
|
750
|
+
# An attribute test is a :test whose parent axis is
|
|
751
|
+
# "attribute"; bare ones (namespace nil) stay native.
|
|
752
|
+
def prefixed_attribute_test?(ast, parent_axis = nil)
|
|
753
|
+
if ast.type == :test && parent_axis == "attribute"
|
|
754
|
+
ns = ast.value[:namespace]
|
|
755
|
+
return true if ns && !ns.empty? && ns != "xmlns"
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
axis = ast.children.first if ast.type == :axis
|
|
759
|
+
|
|
760
|
+
ast.children.any? do |child|
|
|
761
|
+
child.is_a?(XPath::AST::Node) &&
|
|
762
|
+
prefixed_attribute_test?(child, axis || (child.type == :axis ? nil : parent_axis))
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
|
|
727
766
|
def engine_xpath(node, expression, namespaces = {})
|
|
728
767
|
unless node.is_a?(Moxml::Node)
|
|
729
768
|
node = Moxml::Node.wrap(node, Context.new(:leptris))
|
|
@@ -43,6 +43,13 @@ module Moxml
|
|
|
43
43
|
Document.new(native_doc, ctx)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# Nokogiri parses with `config.recover` unless strict, so
|
|
47
|
+
# recoverable syntax errors land on `doc.errors` instead of
|
|
48
|
+
# raising (issue #147).
|
|
49
|
+
def parse_errors(native_doc)
|
|
50
|
+
native_doc.errors.map(&:message)
|
|
51
|
+
end
|
|
52
|
+
|
|
46
53
|
# SAX parsing implementation for Nokogiri
|
|
47
54
|
#
|
|
48
55
|
# @param xml [String, IO] XML to parse
|
data/lib/moxml/context.rb
CHANGED
|
@@ -81,6 +81,14 @@ module Moxml
|
|
|
81
81
|
parse(xml, options).materialize(&block)
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# Parse then stream the zero-allocation field form (issue #143) —
|
|
85
|
+
# see Moxml::Materializer.
|
|
86
|
+
def materialize_fields(xml, options = {}, &block)
|
|
87
|
+
raise ArgumentError, "materialize_fields requires a block" unless block
|
|
88
|
+
|
|
89
|
+
parse(xml, options).materialize_fields(&block)
|
|
90
|
+
end
|
|
91
|
+
|
|
84
92
|
# Parse XML using SAX (event-driven) parsing
|
|
85
93
|
#
|
|
86
94
|
# SAX parsing is memory-efficient and suitable for large XML files.
|
data/lib/moxml/document.rb
CHANGED
|
@@ -31,6 +31,14 @@ module Moxml
|
|
|
31
31
|
root&.materialize(&block)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Zero-allocation streaming form over the root subtree (issue
|
|
35
|
+
# #143) — see Moxml::Materializer.
|
|
36
|
+
def materialize_fields(&block)
|
|
37
|
+
raise ArgumentError, "materialize_fields requires a block" unless block
|
|
38
|
+
|
|
39
|
+
root&.materialize_fields(&block)
|
|
40
|
+
end
|
|
41
|
+
|
|
34
42
|
# Deterministically release the adapter's native memory for this
|
|
35
43
|
# document (issue #134) — batch workloads parsing thousands of
|
|
36
44
|
# documents otherwise hold C trees until GC finalizers run.
|
|
@@ -42,6 +50,16 @@ module Moxml
|
|
|
42
50
|
nil
|
|
43
51
|
end
|
|
44
52
|
|
|
53
|
+
# Parse diagnostics from the engine's recover path (issue #147):
|
|
54
|
+
# [] when the parse was clean, otherwise the recorded error
|
|
55
|
+
# messages. A non-strict leptris parse that came back empty
|
|
56
|
+
# reports the fatal error that emptied it; Nokogiri reports its
|
|
57
|
+
# recover-mode syntax errors; engines without an error channel
|
|
58
|
+
# answer [].
|
|
59
|
+
def parse_errors
|
|
60
|
+
adapter.parse_errors(@native)
|
|
61
|
+
end
|
|
62
|
+
|
|
45
63
|
def create_element(name)
|
|
46
64
|
Element.new(adapter.create_element(name, owner_doc: @native), context)
|
|
47
65
|
end
|
data/lib/moxml/materializer.rb
CHANGED
|
@@ -7,111 +7,145 @@ module Moxml
|
|
|
7
7
|
# consumer tree needs without allocating Moxml::Node or
|
|
8
8
|
# Moxml::Attribute wrappers.
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
# document.materialize.each { |r| ... } # Enumerator
|
|
10
|
+
# Two emission forms share one walk (issue #143):
|
|
12
11
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
12
|
+
# document.materialize { |record| ... } # Hash snapshot per node
|
|
13
|
+
# document.materialize.to_a # Enumerator of snapshots
|
|
14
|
+
# document.materialize_fields { |kind, qname, prefix, namespace_uri,
|
|
15
|
+
# namespaces, attributes, text, depth| }
|
|
16
|
+
#
|
|
17
|
+
# materialize_fields is the conversion hot path: the two list
|
|
18
|
+
# arguments are flat reused buffers — attributes stride 4 (name,
|
|
19
|
+
# value, namespace_uri, prefix), namespaces stride 2 (prefix, uri;
|
|
20
|
+
# nil prefix = default) — valid only inside the block. Copy anything
|
|
21
|
+
# you intend to keep, or use materialize, whose records are
|
|
22
|
+
# independent Hash snapshots.
|
|
23
|
+
#
|
|
24
|
+
# Record shape (both forms):
|
|
25
|
+
# kind: :element|:text|:cdata|:comment|:processing_instruction,
|
|
26
|
+
# qname: "tag"|"pi-target"|nil, prefix: "p"|nil,
|
|
27
|
+
# namespace_uri: "urn:x"|nil,
|
|
28
|
+
# namespaces: [[prefix, uri], ...], # element's OWN declarations
|
|
29
|
+
# attributes: [[name, value, namespace_uri, prefix], ...],
|
|
30
|
+
# text: String|nil, depth: Integer
|
|
19
31
|
#
|
|
20
32
|
# Adapters with a bulk path (leptris: one leptris_node_traverse FFI
|
|
21
|
-
# call for the whole subtree
|
|
22
|
-
# walk the wrapper
|
|
33
|
+
# call for the whole subtree, reading properties straight off the C
|
|
34
|
+
# handles) answer bulk_materialize?; the others walk the wrapper
|
|
35
|
+
# tree generically. Both emit identical streams — spec-pinned.
|
|
23
36
|
module Materializer
|
|
24
|
-
# Shared frozen empty
|
|
25
|
-
#
|
|
37
|
+
# Shared frozen empty list for records with no list fields;
|
|
38
|
+
# adapter bulk paths yield it too.
|
|
26
39
|
EMPTY_ATTRIBUTES = [].freeze
|
|
27
40
|
|
|
28
|
-
# The
|
|
29
|
-
#
|
|
30
|
-
#
|
|
41
|
+
# The two flat, reused field buffers a materialize_fields block
|
|
42
|
+
# receives (issue #143). One instance streams an entire subtree;
|
|
43
|
+
# both arrays are cleared and refilled per element record.
|
|
44
|
+
class Buffers
|
|
45
|
+
attr_reader :attributes, :namespaces
|
|
46
|
+
|
|
47
|
+
def initialize
|
|
48
|
+
@attributes = []
|
|
49
|
+
@namespaces = []
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Builds the Hash snapshot form from the field stream — the one
|
|
54
|
+
# place the eight-key record shape is materialized.
|
|
31
55
|
module Record
|
|
32
56
|
module_function
|
|
33
57
|
|
|
34
|
-
def
|
|
58
|
+
def from_fields(kind, qname, prefix, namespace_uri, namespaces,
|
|
59
|
+
attributes, text, depth)
|
|
35
60
|
{
|
|
36
|
-
kind:
|
|
61
|
+
kind: kind,
|
|
37
62
|
qname: qname,
|
|
38
63
|
prefix: prefix,
|
|
39
64
|
namespace_uri: namespace_uri,
|
|
40
|
-
namespaces: namespaces,
|
|
41
|
-
attributes: attributes,
|
|
42
|
-
text:
|
|
65
|
+
namespaces: group_flat(namespaces, 2),
|
|
66
|
+
attributes: group_flat(attributes, 4),
|
|
67
|
+
text: text,
|
|
43
68
|
depth: depth,
|
|
44
69
|
}
|
|
45
70
|
end
|
|
46
71
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
prefix: nil,
|
|
52
|
-
namespace_uri: nil,
|
|
53
|
-
namespaces: EMPTY_ATTRIBUTES,
|
|
54
|
-
attributes: EMPTY_ATTRIBUTES,
|
|
55
|
-
text: text,
|
|
56
|
-
depth: depth,
|
|
57
|
-
}
|
|
72
|
+
def group_flat(flat, stride)
|
|
73
|
+
return EMPTY_ATTRIBUTES if flat.empty?
|
|
74
|
+
|
|
75
|
+
flat.each_slice(stride).to_a
|
|
58
76
|
end
|
|
59
77
|
end
|
|
60
78
|
|
|
61
79
|
module_function
|
|
62
80
|
|
|
81
|
+
# Hash-snapshot form: every record is an independent Hash.
|
|
63
82
|
def materialize(node, &block)
|
|
64
|
-
adapter = node.context.config.adapter
|
|
65
83
|
return enum_for(:materialize, node) unless block
|
|
66
84
|
|
|
67
|
-
|
|
85
|
+
materialize_fields(node) do |kind, qname, prefix, namespace_uri, namespaces, attributes, text, depth|
|
|
86
|
+
yield(Record.from_fields(kind, qname, prefix, namespace_uri,
|
|
87
|
+
namespaces, attributes, text, depth))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Zero-allocation streaming form — see the module docs. Requires
|
|
92
|
+
# a block: the reused buffers are only valid inside it.
|
|
93
|
+
def materialize_fields(node, buffers = Buffers.new, &block)
|
|
94
|
+
raise ArgumentError, "materialize_fields requires a block" unless block
|
|
95
|
+
|
|
96
|
+
adapter = node.context.config.adapter
|
|
97
|
+
if adapter.bulk_materialize? &&
|
|
98
|
+
adapter.materialize_fields(node.native, buffers, &block)
|
|
68
99
|
return
|
|
69
100
|
end
|
|
70
101
|
|
|
71
|
-
|
|
102
|
+
walk_fields(node, 0, buffers, &block)
|
|
72
103
|
end
|
|
73
104
|
|
|
74
105
|
# Generic post-order walk over the wrapper tree. Works on every
|
|
75
106
|
# adapter; the fast bulk path exists where the engine offers one.
|
|
76
|
-
def
|
|
107
|
+
def walk_fields(node, depth, buffers, &block)
|
|
77
108
|
case node
|
|
78
109
|
when Element
|
|
79
|
-
node.children.each { |child|
|
|
80
|
-
|
|
110
|
+
node.children.each { |child| walk_fields(child, depth + 1, buffers, &block) }
|
|
111
|
+
fill_element_buffers(node, buffers)
|
|
112
|
+
ns = node.namespace
|
|
113
|
+
yield(:element, node.name, node.namespace_prefix, ns&.uri,
|
|
114
|
+
buffers.namespaces, buffers.attributes, nil, depth)
|
|
81
115
|
when Text
|
|
82
|
-
yield(
|
|
116
|
+
yield(:text, nil, nil, nil, EMPTY_ATTRIBUTES, EMPTY_ATTRIBUTES,
|
|
117
|
+
node.content, depth)
|
|
83
118
|
when Cdata
|
|
84
|
-
yield(
|
|
119
|
+
yield(:cdata, nil, nil, nil, EMPTY_ATTRIBUTES, EMPTY_ATTRIBUTES,
|
|
120
|
+
node.content, depth)
|
|
85
121
|
when Comment
|
|
86
|
-
yield(
|
|
122
|
+
yield(:comment, nil, nil, nil, EMPTY_ATTRIBUTES, EMPTY_ATTRIBUTES,
|
|
123
|
+
node.content, depth)
|
|
87
124
|
when ProcessingInstruction
|
|
88
|
-
yield(
|
|
125
|
+
yield(:processing_instruction, node.target, nil, nil,
|
|
126
|
+
EMPTY_ATTRIBUTES, EMPTY_ATTRIBUTES, node.content, depth)
|
|
89
127
|
when EntityReference
|
|
90
|
-
yield(
|
|
128
|
+
yield(:entity_reference, node.name, nil, nil,
|
|
129
|
+
EMPTY_ATTRIBUTES, EMPTY_ATTRIBUTES, "&#{node.name};", depth)
|
|
91
130
|
end
|
|
92
131
|
end
|
|
93
132
|
|
|
94
|
-
def
|
|
95
|
-
|
|
133
|
+
def fill_element_buffers(element, buffers)
|
|
134
|
+
attrs = buffers.attributes
|
|
135
|
+
attrs.clear
|
|
136
|
+
element.attributes.each do |attr|
|
|
96
137
|
ns = attr.namespace
|
|
97
|
-
|
|
138
|
+
attrs << attr.name << attr.value << ns&.uri << ns&.prefix
|
|
98
139
|
end
|
|
99
|
-
|
|
140
|
+
|
|
141
|
+
ns_buf = buffers.namespaces
|
|
142
|
+
ns_buf.clear
|
|
100
143
|
# declared_namespaces: the element's OWN declarations ([prefix,
|
|
101
144
|
# uri] pairs; nil prefix = default), not the in-scope set —
|
|
102
145
|
# enough for a consumer to rebuild scope while walking (#138).
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
namespace_uri: ns&.uri,
|
|
107
|
-
namespaces: element.declared_namespaces,
|
|
108
|
-
attributes: attributes,
|
|
109
|
-
depth: depth,
|
|
110
|
-
)
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def text_record(kind, text, depth)
|
|
114
|
-
Record.text(kind: kind, text: text, depth: depth)
|
|
146
|
+
element.declared_namespaces.each do |prefix, uri|
|
|
147
|
+
ns_buf << prefix << uri
|
|
148
|
+
end
|
|
115
149
|
end
|
|
116
150
|
end
|
|
117
151
|
end
|
data/lib/moxml/node.rb
CHANGED
|
@@ -131,6 +131,14 @@ module Moxml
|
|
|
131
131
|
Materializer.materialize(self, &block)
|
|
132
132
|
end
|
|
133
133
|
|
|
134
|
+
# Zero-allocation streaming form — flat reused buffers valid only
|
|
135
|
+
# inside the block (issue #143). See Moxml::Materializer.
|
|
136
|
+
def materialize_fields(&block)
|
|
137
|
+
raise ArgumentError, "materialize_fields requires a block" unless block
|
|
138
|
+
|
|
139
|
+
Materializer.materialize_fields(self, &block)
|
|
140
|
+
end
|
|
141
|
+
|
|
134
142
|
def at_xpath(expression, namespaces = {})
|
|
135
143
|
Moxml::Node.wrap(adapter.at_xpath(@native, expression, namespaces),
|
|
136
144
|
context)
|
data/lib/moxml/version.rb
CHANGED
data/lib/moxml.rb
CHANGED
|
@@ -84,6 +84,9 @@ module Moxml
|
|
|
84
84
|
autoload :Builder, "moxml/builder"
|
|
85
85
|
autoload :Entity, "moxml/entity"
|
|
86
86
|
autoload :EntityRegistry, "moxml/entity_registry"
|
|
87
|
+
# Opal has no autoload; the compat boot file requires this eagerly
|
|
88
|
+
# alongside the registry.
|
|
89
|
+
autoload :EntityRegistryOpalData, "moxml/entity_registry_opal_data"
|
|
87
90
|
autoload :NativeAttachment, "moxml/native_attachment"
|
|
88
91
|
autoload :XmlUtils, "moxml/xml_utils"
|
|
89
92
|
autoload :XmlEmitter, "moxml/xml_emitter"
|
|
@@ -36,11 +36,10 @@ RSpec.shared_examples "Moxml::Declaration" do
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
it "normalizes encoding" do
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
end
|
|
39
|
+
# Encoding names are case-insensitive (XML 1.0 §4.3.3); some
|
|
40
|
+
# engine/REXML builds upcase the stored value.
|
|
42
41
|
declaration.encoding = "utf-8"
|
|
43
|
-
expect(declaration.encoding).to eq("utf-8")
|
|
42
|
+
expect(declaration.encoding.downcase).to eq("utf-8")
|
|
44
43
|
end
|
|
45
44
|
end
|
|
46
45
|
|
|
@@ -42,6 +42,10 @@ RSpec.shared_examples "Moxml::Document" do
|
|
|
42
42
|
context.config.strict_parsing = true
|
|
43
43
|
expect { context.parse("<invalid>") }.to raise_error(Moxml::ParseError)
|
|
44
44
|
end
|
|
45
|
+
|
|
46
|
+
it "answers no parse errors on clean documents" do
|
|
47
|
+
expect(doc.parse_errors).to eq([])
|
|
48
|
+
end
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
describe "node creation" do
|
|
@@ -143,13 +143,27 @@ RSpec.describe Moxml::Adapter::Leptris do
|
|
|
143
143
|
end
|
|
144
144
|
|
|
145
145
|
it "matches raw Nokogiri byte-for-byte for pretty-printing (issue #129)" do
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
146
|
+
cases = {
|
|
147
|
+
minimal: %(<root><a/><b>x</b></root>),
|
|
148
|
+
namespaces: %(<root xmlns="urn:a" xmlns:p="urn:p"><p:child p:attr="v" plain="w"/><other>x & y</other></root>),
|
|
149
|
+
attributes: %(<r a="1" b="two <three>" c="apos 'here'"><e/></r>),
|
|
150
|
+
mixed: %(<r>text <b>bold</b> tail<!-- c --></r>),
|
|
151
|
+
cdata: %(<r><![CDATA[raw <stuff> & things]]></r>),
|
|
152
|
+
deep: %(<l1><l2><l3><l4><leaf/></l4></l3></l2></l1>),
|
|
153
|
+
unicode: %(<r name="Ünïcödé">日本語テキスト & more</r>),
|
|
154
|
+
unicode_nested: %(<r>a<b>日本</b>c</r>),
|
|
155
|
+
longtext: %(<r>#{'word ' * 30}</r>),
|
|
156
|
+
empty_root: %(<r/>),
|
|
157
|
+
selfclosing: %(<r><a/><b/><c>t</c><d/></r>),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
cases.each do |name, source|
|
|
161
|
+
target = Nokogiri::XML(source).to_xml(indent: 2, encoding: "UTF-8")
|
|
162
|
+
output = ctx.parse(source).to_xml(
|
|
163
|
+
indent: 2, declaration: true, expand_empty: false, encoding: "UTF-8",
|
|
164
|
+
)
|
|
165
|
+
expect(output).to eq(target), "byte-parity failed for #{name}"
|
|
166
|
+
end
|
|
153
167
|
end
|
|
154
168
|
|
|
155
169
|
it "reports the tracked native for a re-added document PI" do
|
|
@@ -188,6 +202,25 @@ RSpec.describe Moxml::Adapter::Leptris do
|
|
|
188
202
|
end
|
|
189
203
|
end
|
|
190
204
|
|
|
205
|
+
describe "recover-path parse diagnostics" do
|
|
206
|
+
let(:ctx) { Moxml.new(:leptris) }
|
|
207
|
+
|
|
208
|
+
it "records the fatal error on non-strict parses" do
|
|
209
|
+
doc = ctx.parse("<root><unclosed>", strict: false)
|
|
210
|
+
expect(doc.root).to be_nil
|
|
211
|
+
expect(doc.parse_errors).not_to be_empty
|
|
212
|
+
expect(doc.parse_errors).to all(be_a(String))
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
it "answers [] on clean parses" do
|
|
216
|
+
expect(ctx.parse("<root/>").parse_errors).to eq([])
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it "still raises on strict parses" do
|
|
220
|
+
expect { ctx.parse("<root><unclosed>") }.to raise_error(Moxml::ParseError)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
191
224
|
describe "entity-marker tracking" do
|
|
192
225
|
let(:ctx) { Moxml.new(:leptris) }
|
|
193
226
|
|
|
@@ -11,4 +11,12 @@ RSpec.describe Moxml::Adapter::Nokogiri do
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
it_behaves_like "xml adapter"
|
|
14
|
+
|
|
15
|
+
describe "recover-mode error channel" do
|
|
16
|
+
it "exposes recoverable syntax errors as parse_errors" do
|
|
17
|
+
doc = Moxml.new(:nokogiri).parse("<root><unclosed>", strict: false)
|
|
18
|
+
expect(doc.parse_errors).not_to be_empty
|
|
19
|
+
expect(doc.parse_errors).to all(be_a(String))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
14
22
|
end
|
data/spec/moxml/builder_spec.rb
CHANGED
|
@@ -298,7 +298,14 @@ RSpec.describe Moxml::Builder do
|
|
|
298
298
|
end
|
|
299
299
|
end.to raise_error(RuntimeError, "test error")
|
|
300
300
|
|
|
301
|
-
|
|
301
|
+
# Restoration contract, observed behaviorally: the cursor is
|
|
302
|
+
# fully unwound to the document — an empty build on the same
|
|
303
|
+
# builder completes against the document and returns it, and
|
|
304
|
+
# the root built before the failure is intact. (The partially
|
|
305
|
+
# built subtree stays; restoration moves the cursor, it does
|
|
306
|
+
# not roll back the tree.)
|
|
307
|
+
expect(builder.build { nil }).to equal(builder.document)
|
|
308
|
+
expect(builder.document.root.name).to eq("root")
|
|
302
309
|
end
|
|
303
310
|
end
|
|
304
311
|
end
|
|
@@ -94,4 +94,55 @@ RSpec.describe Moxml::Materializer do
|
|
|
94
94
|
# tree does not: far fewer allocations than 2 wrappers per node.
|
|
95
95
|
expect(allocated).to be < node_count * 24
|
|
96
96
|
end
|
|
97
|
+
|
|
98
|
+
describe "materialize_fields (issue #143)" do
|
|
99
|
+
it "streams the same data as the Hash snapshot form on every adapter" do
|
|
100
|
+
%i[leptris nokogiri rexml oga ox].each do |adapter|
|
|
101
|
+
skip "adapter not installed" unless Moxml::Adapter.available?(adapter)
|
|
102
|
+
|
|
103
|
+
ctx = Moxml.new(adapter)
|
|
104
|
+
snapshot = ctx.materialize(xml).to_a.map do |r|
|
|
105
|
+
[r[:kind], r[:qname], r[:prefix], r[:namespace_uri],
|
|
106
|
+
r[:namespaces], r[:attributes], r[:text], r[:depth]]
|
|
107
|
+
end
|
|
108
|
+
fields = []
|
|
109
|
+
ctx.materialize_fields(xml) do |kind, qname, prefix, uri, namespaces, attributes, text, depth|
|
|
110
|
+
# Copy out of the reused buffers before they are refilled.
|
|
111
|
+
fields << [
|
|
112
|
+
kind, qname, prefix, uri,
|
|
113
|
+
namespaces.each_slice(2).to_a,
|
|
114
|
+
attributes.each_slice(4).to_a,
|
|
115
|
+
text, depth
|
|
116
|
+
]
|
|
117
|
+
end
|
|
118
|
+
expect(fields).to eq(snapshot)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "reuses one buffer pair across the element stream" do
|
|
123
|
+
skip "leptris not installed" unless Moxml::Adapter.available?(:leptris)
|
|
124
|
+
|
|
125
|
+
element_buffers = []
|
|
126
|
+
Moxml.new(:leptris).parse(xml).root.materialize_fields do |kind, _qname, _p, _u, _ns, attrs, _t, _d|
|
|
127
|
+
element_buffers << attrs if kind == :element
|
|
128
|
+
end
|
|
129
|
+
expect(element_buffers.map(&:object_id).uniq.size).to eq(1)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "builds independent snapshots in the Hash form" do
|
|
133
|
+
ctx = Moxml.new(:nokogiri)
|
|
134
|
+
first = ctx.materialize(xml).to_a
|
|
135
|
+
first.each { |r| r[:attributes] = nil }
|
|
136
|
+
second = ctx.materialize(xml).to_a
|
|
137
|
+
expect(second.find { |r| r[:qname] == "book" }[:attributes])
|
|
138
|
+
.to include(["id", "b1", nil, nil])
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "requires a block" do
|
|
142
|
+
expect { Moxml.new(:nokogiri).materialize_fields(xml) }
|
|
143
|
+
.to raise_error(ArgumentError)
|
|
144
|
+
expect { Moxml.new(:nokogiri).parse(xml).materialize_fields }
|
|
145
|
+
.to raise_error(ArgumentError)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
97
148
|
end
|
|
@@ -56,7 +56,7 @@ RSpec.describe Moxml::Signature::Algorithms do
|
|
|
56
56
|
|
|
57
57
|
describe "custom algorithm registration" do
|
|
58
58
|
after do
|
|
59
|
-
described_class.
|
|
59
|
+
described_class.registry[:digest]
|
|
60
60
|
&.delete("http://test.example/custom-digest")
|
|
61
61
|
end
|
|
62
62
|
|
|
@@ -317,12 +317,8 @@ RSpec.describe Moxml::XPath::Conversion do
|
|
|
317
317
|
expect(described_class.first_node_text(nodes)).to eq("first")
|
|
318
318
|
end
|
|
319
319
|
|
|
320
|
-
it "returns empty string
|
|
321
|
-
|
|
322
|
-
node_set = [double("node")]
|
|
323
|
-
allow(node_set).to receive(:[]).with(0).and_return(node_set[0])
|
|
324
|
-
|
|
325
|
-
expect(described_class.first_node_text(node_set)).to eq("")
|
|
320
|
+
it "returns empty string when the set is empty" do
|
|
321
|
+
expect(described_class.first_node_text([])).to eq("")
|
|
326
322
|
end
|
|
327
323
|
|
|
328
324
|
it "handles nodes with empty text" do
|