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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f890c98078986e7be37d59762c08c6387cb173a7260212be428c705e043bd1a
4
- data.tar.gz: 6a5dae862bb917c2906581412c33f0cd9f4506112ba5f24a146feed9f2dc0371
3
+ metadata.gz: 8302b1a98c08b34619df4a30d5b84733fa9125eebeeaf1b39b5ba82d0e8fa625
4
+ data.tar.gz: e96034377b072b1ee9058ced4a4dd0347e819f6354011810831b07a06e88e138
5
5
  SHA512:
6
- metadata.gz: d8acaee20452b9d74ba026cc4fce32d6a8911af51afee523bf474e831786d9e71c31e089360d023c17a62c8610081a8ad4d55b774261279e56966942ab26b040
7
- data.tar.gz: c9e1d227bca2d9f7f5771604de905b5efec5a61451e01b69919ec315484673cafadbe65345423fd65633fcdc1d8e8197de58d6822f5bc42a6fa6fe253ec4b7f3
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
- `materialize_records` yields flattened records, returning nil falls
83
- back to the generic wrapper walk.
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
- `TRAVERSE_SUBTREE_BOUNDED` (Leptris) follow the same idea: one probe
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` allocation — the conversion path for consumers that build their own tree from a document:
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 seven keys on every record):
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
- * The leptris adapter has a bulk path (one `leptris_node_traverse` call); every other adapter uses the generic wrapper walk. Both emit identical streams the parity is spec-pinned.
39
+ * Every record is an independent Hash snapshotsafe 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"
@@ -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, materialize_records
194
- # yields flattened records for the subtree; returning nil
195
- # (e.g. for document shapes the bulk path cannot express)
196
- # falls back to the generic wrapper walk.
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
- # 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.
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 materialize_records(native, &block)
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
- # 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)
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
- yield(record) if record
55
- end
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 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]
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
- def text_material_record(kind, text, depth)
83
- Materializer::Record.text(kind: kind, text: text, depth: depth)
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
- # 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
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
- # leptris-ruby 1.9.32 (#89): traverse is subtree-bounded again —
31
- # earlier 1.9.x walks followed the document chain and swept
32
- # following siblings, so element materialize had to fall back
33
- # to the generic wrapper walk.
34
- TRAVERSE_SUBTREE_BOUNDED =
35
- Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.32")
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.
@@ -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
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "json" unless RUBY_ENGINE == "opal"
4
4
  require "set"
5
- require_relative "entity_registry_opal_data" if RUBY_ENGINE == "opal"
6
5
 
7
6
  module Moxml
8
7
  # EntityRegistry maintains a knowledge base of XML entity definitions.
@@ -7,111 +7,145 @@ module Moxml
7
7
  # consumer tree needs without allocating Moxml::Node or
8
8
  # Moxml::Attribute wrappers.
9
9
  #
10
- # context.materialize(xml) { |r| ... }
11
- # document.materialize.each { |r| ... } # Enumerator
10
+ # Two emission forms share one walk (issue #143):
12
11
  #
13
- # Record shape:
14
- # { kind: :element|:text|:cdata|:comment|:processing_instruction,
15
- # qname: "tag"|"pi-target"|nil, prefix: "p"|nil,
16
- # namespace_uri: "urn:x"|nil,
17
- # attributes: [[name, value, namespace_uri, prefix], ...],
18
- # text: String|nil, depth: Integer }
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) answer bulk_materialize?; the others
22
- # walk the wrapper tree generically.
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 attribute list for non-element records;
25
- # adapters' bulk paths reference it too.
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 one constructor for the record shape both the generic
29
- # walk and adapter bulk paths build records through it, so the
30
- # seven-key contract lives in exactly one place.
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 element(qname:, prefix:, namespace_uri:, namespaces:, attributes:, depth:)
58
+ def from_fields(kind, qname, prefix, namespace_uri, namespaces,
59
+ attributes, text, depth)
35
60
  {
36
- kind: :element,
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: nil,
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 text(kind:, text:, depth:)
48
- {
49
- kind: kind,
50
- qname: nil,
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
- if adapter.bulk_materialize? && adapter.materialize_records(node.native, &block)
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
- walk(node, 0, &block)
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 walk(node, depth, &block)
107
+ def walk_fields(node, depth, buffers, &block)
77
108
  case node
78
109
  when Element
79
- node.children.each { |child| walk(child, depth + 1, &block) }
80
- yield(element_record(node, depth))
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(text_record(:text, node.content, depth))
116
+ yield(:text, nil, nil, nil, EMPTY_ATTRIBUTES, EMPTY_ATTRIBUTES,
117
+ node.content, depth)
83
118
  when Cdata
84
- yield(text_record(:cdata, node.content, depth))
119
+ yield(:cdata, nil, nil, nil, EMPTY_ATTRIBUTES, EMPTY_ATTRIBUTES,
120
+ node.content, depth)
85
121
  when Comment
86
- yield(text_record(:comment, node.content, depth))
122
+ yield(:comment, nil, nil, nil, EMPTY_ATTRIBUTES, EMPTY_ATTRIBUTES,
123
+ node.content, depth)
87
124
  when ProcessingInstruction
88
- yield(text_record(:processing_instruction, node.content, depth).merge(qname: node.target))
125
+ yield(:processing_instruction, node.target, nil, nil,
126
+ EMPTY_ATTRIBUTES, EMPTY_ATTRIBUTES, node.content, depth)
89
127
  when EntityReference
90
- yield(text_record(:entity_reference, "&#{node.name};", depth).merge(qname: node.name))
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 element_record(element, depth)
95
- attributes = element.attributes.map do |attr|
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
- [attr.name, attr.value, ns&.uri, ns&.prefix]
138
+ attrs << attr.name << attr.value << ns&.uri << ns&.prefix
98
139
  end
99
- ns = element.namespace
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
- Record.element(
104
- qname: element.name,
105
- prefix: element.namespace_prefix,
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.13"
4
+ VERSION = "0.5.14"
5
5
  end
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
- if Moxml.new.config.adapter.name.include?("Rexml")
40
- pending("Rexml Encoding upcases the string")
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
- source = %(<root><a/><b>x</b></root>)
147
- target = Nokogiri::XML(source).to_xml(indent: 2, encoding: "UTF-8")
148
- output = ctx.parse(source).to_xml(
149
- indent: 2, declaration: true, expand_empty: false, encoding: "UTF-8",
150
- )
151
-
152
- expect(output).to eq(target)
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 &amp; y</other></root>),
149
+ attributes: %(<r a="1" b="two &lt;three&gt;" c="apos &apos;here&apos;"><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é">日本語テキスト &amp; 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
@@ -298,7 +298,14 @@ RSpec.describe Moxml::Builder do
298
298
  end
299
299
  end.to raise_error(RuntimeError, "test error")
300
300
 
301
- expect(builder.instance_variable_get(:@current)).to equal(builder.document)
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.instance_variable_get(:@registry)[:digest]
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 if first node does not respond to text" do
321
- # Create a mock NodeSet with a node that doesn't respond to text
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moxml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.13
4
+ version: 0.5.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.