moxml 0.5.100 → 0.5.102

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.
data/lib/moxml/node.rb CHANGED
@@ -74,6 +74,11 @@ module Moxml
74
74
  end
75
75
 
76
76
  def children
77
+ # Generation-checked memo: mutations bump the context counter, so
78
+ # this self-invalidates even when the same logical node is served
79
+ # by multiple wrapper representations (native vs binding duality).
80
+ @children = nil if @children_gen != context.children_generation
81
+ @children_gen = context.children_generation
77
82
  @children ||= begin
78
83
  # The wrapper's entity memo decides the marker split; the
79
84
  # adapter would otherwise re-derive it per call (a C parent
@@ -99,7 +104,105 @@ module Moxml
99
104
  Moxml::Node.wrap(adapter.previous_sibling(@native), context)
100
105
  end
101
106
 
107
+ # Nokogiri-compatible reader spellings.
108
+ alias next next_sibling
109
+ alias previous previous_sibling
110
+
111
+ # Nokogiri-compatible: nearest sibling elements, skipping text and
112
+ # comment nodes.
113
+ def next_element
114
+ s = next_sibling
115
+ s = s.next_sibling while s && !s.is_a?(Moxml::Element)
116
+ s
117
+ end
118
+
119
+ def previous_element
120
+ s = previous_sibling
121
+ s = s.previous_sibling while s && !s.is_a?(Moxml::Element)
122
+ s
123
+ end
124
+
125
+ # Nokogiri-compatible append sugar: node << appends. Strings
126
+ # parse as fragments (markup, not literal text).
127
+ def <<(node)
128
+ add_child(node)
129
+ self
130
+ end
131
+
132
+ # Nokogiri-compatible: reparent — node.parent = new_parent moves
133
+ # this node (removing it from its current tree) under the target.
134
+ def parent=(new_parent)
135
+ remove
136
+ new_parent.add_child(self)
137
+ end
138
+
139
+ # Insert ahead of the current first child (nokogiri-monkeypatch
140
+ # convention used across metanorma). Strings parse as fragments
141
+ # (the sibling_operand convention).
142
+ def add_first_child(node)
143
+ if first_child
144
+ first_child.add_previous_sibling(node)
145
+ else
146
+ add_child(node)
147
+ end
148
+ self
149
+ end
150
+
102
151
  def add_child(node)
152
+ # Nokogiri semantics: a String operand is MARKUP, parsed and
153
+ # appended (cleanup code inserts "<bibliography/>" style
154
+ # strings); the adapter path would mint a literal text node.
155
+ # Metanorma's Nokogiri returns the new children as a NodeSet
156
+ # for markup (misc.add_child("<UnitsML/>").first idiom), self
157
+ # for nodes.
158
+ if node.is_a?(String)
159
+ nodes = context.parse_fragment(node).to_a
160
+ nodes.each { |child| attach_child(child) }
161
+ return NodeSet.new(nodes.map(&:native), context)
162
+ end
163
+ attach_child(node)
164
+ self
165
+ end
166
+
167
+ def add_previous_sibling(node)
168
+ # String operands insert every fragment root; returns the new
169
+ # nodes as a NodeSet (metanorma idiom: sect.add_next_sibling(
170
+ # "<bibliography/>").first).
171
+ if node.is_a?(String)
172
+ nodes = context.parse_fragment(node).to_a
173
+ nodes.each { |n| attach_previous_sibling(n) }
174
+ return NodeSet.new(nodes.map(&:native), context)
175
+ end
176
+ attach_previous_sibling(node)
177
+ self
178
+ end
179
+
180
+ # Nokogiri-compatible assignment forms: node.next = / node.previous =.
181
+ # String operands are parsed as XML fragments, like Nokogiri.
182
+ def next=(node)
183
+ add_next_sibling(node)
184
+ end
185
+
186
+ def previous=(node)
187
+ add_previous_sibling(node)
188
+ end
189
+
190
+ def add_next_sibling(node)
191
+ if node.is_a?(String)
192
+ nodes = context.parse_fragment(node).to_a
193
+ anchor = self
194
+ nodes.each do |n|
195
+ attach_next_sibling_to(anchor, n)
196
+ anchor = n
197
+ end
198
+ return NodeSet.new(nodes.map(&:native), context)
199
+ end
200
+ attach_next_sibling_to(self, node)
201
+ self
202
+ end
203
+
204
+ def attach_child(node)
205
+ context.bump_children_generation
103
206
  node = prepare_node(node)
104
207
  adapter.add_child(@native, node.native)
105
208
  # Refresh native in case adapter changed identity (e.g., LibXML
@@ -115,21 +218,40 @@ module Moxml
115
218
  self
116
219
  end
117
220
 
118
- def add_previous_sibling(node)
221
+ def attach_previous_sibling(node)
222
+ context.bump_children_generation
119
223
  node = prepare_node(node)
120
224
  adapter.add_previous_sibling(@native, node.native)
225
+ # Invalidate the parent's memoized children list. The wrapper-side
226
+ # @parent_node link is only set when this node was yielded through a
227
+ # parent-aware NodeSet; nodes obtained via at()/xpath() carry no such
228
+ # link, so resolve the parent wrapper from the adapter instead.
229
+ parent&.invalidate_children_cache!
121
230
  invalidate_parent_children_cache!
122
231
  self
123
232
  end
124
233
 
125
- def add_next_sibling(node)
234
+ def attach_next_sibling_to(anchor, node)
235
+ context.bump_children_generation
126
236
  node = prepare_node(node)
127
- adapter.add_next_sibling(@native, node.native)
128
- invalidate_parent_children_cache!
237
+ adapter.add_next_sibling(anchor.native, node.native)
238
+ anchor.parent&.invalidate_children_cache!
239
+ anchor.invalidate_parent_children_cache!
129
240
  self
130
241
  end
131
242
 
132
243
  def remove
244
+ # Nokogiri parity: unlinking an already-detached node is a
245
+ # no-op returning self. Re-attach idioms like
246
+ # `t << n.replace(x).remove` (standoc term cleanup) detach with
247
+ # #replace and rely on this; leptris would raise "Not found".
248
+ # The wrapper-side @parent_node link decides when present
249
+ # (add_child sets it); the adapter resolution covers
250
+ # link-less wrappers (at()-resolved nodes), whose parents are
251
+ # canonical since #313.
252
+ return self if @parent_node.nil? && parent.nil?
253
+
254
+ context.bump_children_generation
133
255
  invalidate_parent_children_cache!
134
256
  adapter.remove(@native)
135
257
  invalidate_children_cache!
@@ -143,6 +265,7 @@ module Moxml
143
265
  def invalidate_namespace_cache!; end
144
266
 
145
267
  def replace(node)
268
+ context.bump_children_generation
146
269
  node = prepare_node(node)
147
270
  invalidate_parent_children_cache!
148
271
  adapter.replace(@native, node.native)
@@ -171,6 +294,18 @@ module Moxml
171
294
  result
172
295
  end
173
296
 
297
+ # Nokogiri-compatible: string interpolation of a node serializes
298
+ # it ("#{node}" in cleanup code), not Object#to_s.
299
+ def to_s
300
+ to_xml
301
+ end
302
+
303
+ # Nokogiri-compatible: to_str yields the text content (their C
304
+ # node_to_str; standoc does x.content = x.to_str on <script>).
305
+ def to_str
306
+ text
307
+ end
308
+
174
309
  # Memoized against the adapter's serialize generation — the
175
310
  # entity-marker flag flips at parse and entity-reference mint,
176
311
  # both adapter-level, and the generation bump is the invalidation
@@ -215,6 +350,42 @@ module Moxml
215
350
  context)
216
351
  end
217
352
 
353
+ # Nokogiri-compatible sugar: at/search/at_css/css. Nokogiri's
354
+ # at/search auto-detect XPath vs CSS; the XPath-first fallback
355
+ # covers the dominant caller shape (XPath expression strings)
356
+ # while css/at_css serve stylesheet-style selectors.
357
+ def at(expression, namespaces = {})
358
+ at_css_or_xpath(expression, namespaces)
359
+ end
360
+
361
+ def search(expression, namespaces = {})
362
+ xpath(expression, namespaces)
363
+ end
364
+
365
+ def css(_expression)
366
+ raise Moxml::NotImplementedError,
367
+ "CSS selectors are not supported; use search with XPath"
368
+ end
369
+
370
+ def at_css(_expression)
371
+ raise Moxml::NotImplementedError,
372
+ "CSS selectors are not supported; use at with XPath"
373
+ end
374
+
375
+ def at_css_or_xpath(expression, namespaces = {})
376
+ at_xpath(expression, namespaces)
377
+ end
378
+
379
+ # Nokogiri-compatible depth-first traversal: yields self, then
380
+ # children recursively (document-order visitor).
381
+ def traverse(&block)
382
+ return to_enum(:traverse) unless block
383
+
384
+ yield self
385
+ children.each { |child| child.traverse(&block) }
386
+ self
387
+ end
388
+
218
389
  # Convenience find methods (aliases for xpath methods)
219
390
  def find(xpath_expression, namespaces = {})
220
391
  at_xpath(xpath_expression, namespaces)
@@ -307,7 +478,15 @@ module Moxml
307
478
  # the document node.
308
479
  #
309
480
  # @return [NodeSet] ancestors ordered nearest-first
310
- def ancestors
481
+ # Nokogiri-compatible: an element-name argument filters the set.
482
+ def ancestors(selector = nil)
483
+ all = _ancestors_all
484
+ return all unless selector
485
+
486
+ all.select { |a| a.name == selector.to_s }
487
+ end
488
+
489
+ def _ancestors_all
311
490
  return NodeSet.new([], context) if document?
312
491
 
313
492
  natives = []
@@ -371,18 +550,9 @@ module Moxml
371
550
  # elements share the same qualified name, keeping paths minimal.
372
551
  #
373
552
  # @return [String] XPath expression
374
- # @raise [Moxml::NotImplementedError] for node types other than
375
- # element and document
376
553
  def path
377
554
  return "/" if document?
378
555
 
379
- unless element?
380
- raise Moxml::NotImplementedError.new(
381
- "path is only supported for element and document nodes",
382
- feature: "path",
383
- )
384
- end
385
-
386
556
  segments = []
387
557
  current = self
388
558
  while current && !current.document?
@@ -392,6 +562,19 @@ module Moxml
392
562
  "/#{segments.join('/')}"
393
563
  end
394
564
 
565
+ # The XPath node-test spelling for this non-element node.
566
+ def path_type_test
567
+ if text? || cdata?
568
+ "text()"
569
+ elsif comment?
570
+ "comment()"
571
+ elsif processing_instruction?
572
+ "processing-instruction()"
573
+ else
574
+ name.to_s
575
+ end
576
+ end
577
+
395
578
  # Returns the 1-based line number where this node appears in the
396
579
  # source XML, or nil when the underlying adapter does not track
397
580
  # source positions.
@@ -496,19 +679,25 @@ module Moxml
496
679
 
497
680
  private
498
681
 
499
- # XPath segment for an element: the qualified name, plus a positional
500
- # predicate only when same-named element siblings make it ambiguous.
501
- def path_segment_for(element)
502
- name = element.name
503
- parent = element.parent
682
+ # XPath segment for a node: elements use their qualified name,
683
+ # other node kinds their XPath type test (text(), comment(), ...).
684
+ # A positional predicate is emitted only when same-kind siblings
685
+ # make the segment ambiguous.
686
+ def path_segment_for(node)
687
+ name = node.element? ? node.name : node.path_type_test
688
+ parent = node.parent
504
689
  return name unless parent
505
690
 
506
- same_name = parent.children.select do |child|
507
- child.element? && child.name == name
691
+ same_kind = parent.children.select do |child|
692
+ if node.element?
693
+ child.element? && child.name == name
694
+ else
695
+ !child.element? && child.path_type_test == name
696
+ end
508
697
  end
509
- return name if same_name.size == 1
698
+ return name if same_kind.size == 1
510
699
 
511
- "#{name}[#{same_name.find_index(element) + 1}]"
700
+ "#{name}[#{same_kind.find_index(node) + 1}]"
512
701
  end
513
702
 
514
703
  def prepare_node(node)
@@ -591,6 +780,13 @@ module Moxml
591
780
  def self.wrap_with(node, context, adapter)
592
781
  return nil if node.nil?
593
782
 
783
+ # One C node may have several Ruby representations (leptris
784
+ # native/binding duality, moxml#311); every wrap resolves to
785
+ # the canonical one so memo holders share one invalidation
786
+ # chain. A hit-only lookup: a memoized holder implies a prior
787
+ # walk, which is what populates the canonical map.
788
+ node = adapter.canonical_native(node)
789
+
594
790
  # Cache-stable binding nodes carry their wrapper directly
595
791
  # (#312): the binding's document-owned cache hands the SAME
596
792
  # node object for the engine node's whole lifetime, so an
@@ -6,6 +6,15 @@ module Moxml
6
6
 
7
7
  attr_reader :context
8
8
 
9
+ # Nokogiri-compatible set difference: nodes in this set absent
10
+ # from the other, identity-compared on natives.
11
+ def -(other)
12
+ excluded = {}.compare_by_identity
13
+ other.native_nodes.each { |n| excluded[n] = true }
14
+ remaining = native_nodes.reject { |n| excluded.key?(n) }
15
+ self.class.new(remaining, @context, @parent_node)
16
+ end
17
+
9
18
  # nodes: Array of natives, or an adapter's LazyNodeSet — the
10
19
  # native set is held unmaterialized until an operation needs an
11
20
  # Array (#+, #<<, #delete, Range slices).
@@ -76,6 +85,23 @@ module Moxml
76
85
  end
77
86
  alias length size
78
87
 
88
+ # Nokogiri-compatible: serialize the set's members in order.
89
+ def to_xml(options = {})
90
+ map { |n| n.to_xml(options) }.join
91
+ end
92
+
93
+ # Nokogiri-compatible: string interpolation of a NodeSet
94
+ # serializes it ("#{nodes}" in cleanup code), not Object#to_s.
95
+ def to_s
96
+ to_xml
97
+ end
98
+
99
+ # Nokogiri-compatible: Array#flatten and multiple-assignment
100
+ # treat a NodeSet as its wrapped-element array via to_ary.
101
+ def to_ary
102
+ to_a
103
+ end
104
+
79
105
  def to_a
80
106
  i = 0
81
107
  wrapped = wrapped_buffer
data/lib/moxml/text.rb CHANGED
@@ -33,5 +33,8 @@ module Moxml
33
33
  def name
34
34
  "text"
35
35
  end
36
+
37
+ # Nokogiri-compatible: renaming non-element nodes is a no-op
38
+ def name=(_value); end
36
39
  end
37
40
  end
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.100"
4
+ VERSION = "0.5.102"
5
5
  end
@@ -892,9 +892,21 @@ module Moxml
892
892
  end
893
893
  end
894
894
 
895
- # Union: | (pipe)
896
- def on_pipe(ast, input)
897
- left, right = ast.children
895
+ # Union: | (pipe). Unions are N-ARY — the parser flattens
896
+ # a|b|c into one union node — and may nest (parenthesized
897
+ # groups become union children). Every member's nodes flow
898
+ # through the block when one is given (nested unions), else
899
+ # into one result NodeSet (#315: the old two-children
900
+ # destructure silently dropped every branch after the second).
901
+ def on_pipe(ast, input, &block)
902
+ if block
903
+ code = nil
904
+ ast.children.each do |child|
905
+ fragment = process(child, input, &block)
906
+ code = code ? code.followed_by(fragment) : fragment
907
+ end
908
+ return code
909
+ end
898
910
 
899
911
  union = unique_literal(:union)
900
912
  context_var = context_literal
@@ -903,22 +915,13 @@ module Moxml
903
915
  nodeset_class = const_ref("Moxml", "NodeSet")
904
916
  empty_array = Ruby::Node.new(:array, [])
905
917
 
906
- # Expressions such as "a | b | c"
907
- if left.type == :pipe
908
- union.assign(process(left, input))
909
- .followed_by(process(right, input) { |node| union << node })
910
- .followed_by(union)
911
- # Expressions such as "a | b"
912
- else
913
- nodeset_new = Ruby::Node.new(:send,
914
- [nodeset_class, "new", empty_array,
915
- context_var])
916
-
917
- union.assign(nodeset_new)
918
- .followed_by(process(left, input) { |node| union << node })
919
- .followed_by(process(right, input) { |node| union << node })
920
- .followed_by(union)
918
+ code = union.assign(Ruby::Node.new(:send,
919
+ [nodeset_class, "new", empty_array,
920
+ context_var]))
921
+ ast.children.each do |child|
922
+ code = code.followed_by(process(child, input) { |node| union << node })
921
923
  end
924
+ code.followed_by(union)
922
925
  end
923
926
 
924
927
  # Variable: $variable
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # 0.5.100's cap valves called .clear on the registry — an
6
+ # ObjectSpace::WeakMap in weak-wrapper mode (Ruby 3.4+) has no
7
+ # #clear, so the first parse crossing 8,192 bridges crashed with
8
+ # NoMethodError. The valve replaces the registry wholesale now.
9
+ RSpec.describe "leptris binding registry cap valve" do
10
+ let(:ctx) { Moxml.new(:leptris) }
11
+ let(:doc) do
12
+ ctx.parse("<r>#{Array.new(8_300) { |i| "<e#{i}/>" }.join}</r>")
13
+ end
14
+
15
+ it "bridges past the cap" do
16
+ children = doc.root.children.to_a
17
+ expect(children.size).to be > 8_192
18
+
19
+ bridged = children.map { |child| Moxml::Adapter::Leptris.to_binding(child.native) }
20
+ expect(bridged.compact.size).to eq(children.size)
21
+ end
22
+
23
+ it "bridges stay identity-stable across the valve" do
24
+ children = doc.root.children.to_a
25
+ first_pass = children.map { |child| Moxml::Adapter::Leptris.to_binding(child.native) }
26
+ second_pass = children.map { |child| Moxml::Adapter::Leptris.to_binding(child.native) }
27
+ expect(first_pass.zip(second_pass).all? { |one, other| one.equal?(other) }).to be(true)
28
+ end
29
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # moxml#311: the native and binding layers hold separate Ruby objects
6
+ # over one C node. An xpath-resolved node that failed to resolve to
7
+ # the tree-walk identity carried no parent link, so sibling inserts
8
+ # through it never invalidated the memoized children of wrappers
9
+ # handed out before the query — the insert serialized but stayed
10
+ # invisible through parent.children.
11
+ RSpec.describe "leptris xpath nodes resolve canonical identity" do
12
+ let(:ctx) { Moxml.new(:leptris) }
13
+ let(:doc) { ctx.parse("<r><a>t</a>\n<b/></r>") }
14
+
15
+ it "at_xpath returns the walked wrapper" do
16
+ root = doc.root
17
+ walked = root.children.first
18
+ expect(root.at_xpath("a")).to be(walked)
19
+ end
20
+
21
+ it "xpath list results resolve canonical identity" do
22
+ root = doc.root
23
+ walked = root.children.first
24
+ expect(root.xpath("a").first).to be(walked)
25
+ end
26
+
27
+ it "namespace-bound at_xpath resolves canonical identity" do
28
+ root = doc.root
29
+ walked = root.children.first
30
+ expect(root.at_xpath("a", "xmlns" => "http://x")).to be(walked)
31
+ end
32
+
33
+ it "sibling insert through at_xpath resolves in parent.children" do
34
+ root = doc.root
35
+ root.children
36
+ a = root.at_xpath("a")
37
+ a.add_next_sibling(ctx.parse_fragment("<x/>").first)
38
+ expect(root.children.map(&:name)).to eq(%w[a x text b])
39
+ end
40
+
41
+ it "sibling insert through the engine path resolves in parent.children" do
42
+ root = doc.root
43
+ root.children
44
+ walked = root.children.first
45
+ a = walked.at_xpath("self::a[parent::r]")
46
+ a.add_next_sibling(ctx.parse_fragment("<x/>").first)
47
+ expect(root.children.map(&:name)).to eq(%w[a x text b])
48
+ end
49
+ end