moxml 0.5.101 → 0.5.103

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: 9109c62e1dc81916376846aa35756d02e7caa0caedbf96937c6c89fb99687389
4
- data.tar.gz: cab100fc1e408a0c511d330c81bc5f2b71e8dc0f6fa30e2c1b7a7e094d1c81a1
3
+ metadata.gz: 566f2842eac4e01753965cb5f56eada53d6683b3ef6b576015eb4b0da572ae67
4
+ data.tar.gz: ccc15ef083358a018e1536983017c6681c70c11ccbb4678ac123e5b0b2c9aada
5
5
  SHA512:
6
- metadata.gz: d041b4af64d6fe7d0d87d129ce35053da5405ba23b866e7dd3ef8c7237a21b73ff60d9baefafacf39781d5691e44b95ec295fcfe9eb54fa5e2a091ed01ea6ab7
7
- data.tar.gz: 2f21edaf882c65e3229c617eb5df04e49b0198de3133d968e898fa9ec9218d34b52c5f165791804aafb328bae55db20fddc80c0976d596c686e66630bbd0293d
6
+ metadata.gz: 2c29296b60bedabbc512c9f5c454b0195b3632dbf9952b0c397fc3bad6554135dd3096a04a34e1abddf9ce40088af4856b2ead7d8ca69a4eb251dc3db38286b3
7
+ data.tar.gz: fd1684a85d2ace2191b33bf996a12e8bea6cb535a340732cccee3318abc6649ba2e07548509d1469959e10ee711ecec650f8ec6b4525beb6a66e2c9a75a81c1d
@@ -13,6 +13,13 @@ on:
13
13
  repository_dispatch:
14
14
  types: [ release-passed ]
15
15
 
16
+ # One live run per workflow+event+ref: a superseded push to the
17
+ # same PR/branch cancels its predecessors instead of queueing
18
+ # behind them (the runner queue otherwise stacks generations).
19
+ concurrency:
20
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
21
+ cancel-in-progress: true
22
+
16
23
  jobs:
17
24
  rake:
18
25
  uses: metanorma/ci/.github/workflows/dependent-rake.yml@main
@@ -6,6 +6,13 @@ on:
6
6
  tags: [ v* ]
7
7
  pull_request:
8
8
 
9
+ # One live run per workflow+event+ref: a superseded push to the
10
+ # same PR/branch cancels its predecessors instead of queueing
11
+ # behind them (the runner queue otherwise stacks generations).
12
+ concurrency:
13
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
9
16
  permissions:
10
17
  contents: read
11
18
 
@@ -6,6 +6,13 @@ on:
6
6
  tags: [ v* ]
7
7
  pull_request:
8
8
 
9
+ # One live run per workflow+event+ref: a superseded push to the
10
+ # same PR/branch cancels its predecessors instead of queueing
11
+ # behind them (the runner queue otherwise stacks generations).
12
+ concurrency:
13
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
9
16
  permissions:
10
17
  contents: read
11
18
 
@@ -5,6 +5,13 @@ on:
5
5
  branches: [ main ]
6
6
  pull_request:
7
7
 
8
+ # One live run per workflow+event+ref: a superseded push to the
9
+ # same PR/branch cancels its predecessors instead of queueing
10
+ # behind them (the runner queue otherwise stacks generations).
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
8
15
  permissions:
9
16
  contents: read
10
17
 
@@ -6,6 +6,13 @@ on:
6
6
  pull_request:
7
7
  workflow_dispatch:
8
8
 
9
+ # One live run per workflow+event+ref: a superseded push to the
10
+ # same PR/branch cancels its predecessors instead of queueing
11
+ # behind them (the runner queue otherwise stacks generations).
12
+ concurrency:
13
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
9
16
  permissions:
10
17
  contents: read
11
18
 
@@ -47,8 +47,11 @@ module Moxml
47
47
 
48
48
  def parse_fragment(xml, _context = nil)
49
49
  doc = parse("<m>#{xml}</m>")
50
- root = root(doc)
51
- root ? children(root) : []
50
+ # doc.root is a wrapper (adapter root overrides answer
51
+ # patched/unpatched natives indifferently); children() and
52
+ # every adapter below expect natives.
53
+ root_node = doc.root
54
+ root_node ? children(root_node.native) : []
52
55
  end
53
56
 
54
57
  # Streaming incremental parse (leptris engine): yields each
@@ -154,6 +157,14 @@ module Moxml
154
157
  node
155
158
  end
156
159
 
160
+ # Element-only children: adapters whose engine carries the
161
+ # face override (leptris C filter, nokogiri native); the
162
+ # default type-filters the native child list so non-element
163
+ # wrappers never mint on element-only traversals.
164
+ def element_children(node)
165
+ children(node).select { |child| node_type(child) == :element }
166
+ end
167
+
157
168
  # Prefixed-attribute value fast path: adapters whose engine
158
169
  # resolves expanded-name (uri, local) lookups natively answer
159
170
  # the value here; others fall back to the resolver's
@@ -394,6 +405,13 @@ namespace_validation_mode: :strict)
394
405
  true
395
406
  end
396
407
 
408
+ # Serialization of the node's children (Element#inner_xml).
409
+ # Built over the children/serialize contract; adapters with a
410
+ # native inner-serialization face may override.
411
+ def inner_xml(node)
412
+ children(node).map { |child| serialize(child) }.join
413
+ end
414
+
397
415
  # Whether the engine offers a bulk materialization path for
398
416
  # Materializer (issue #132). When true, the adapter gets
399
417
  # #materialize_fields(native, buffers, &block) — fill the
@@ -100,6 +100,10 @@ module Moxml
100
100
  NN_NEXT_SIBLING = NN.instance_method(:next_sibling)
101
101
  NN_CONTENT = NN.instance_method(:content)
102
102
  NN_CHILDREN = NN.instance_method(:children)
103
+ # The C element filter — captured because ElementBehavior's
104
+ # element_children alias shadows the face on Identity natives
105
+ # (a plain dispatch would recurse into the wrapper method).
106
+ NN_ELEMENT_CHILDREN = NN.instance_method(:element_children)
103
107
  NN_NODE_TYPE = NN.instance_method(:node_type)
104
108
  NN_DOCUMENT = NN.instance_method(:document)
105
109
  NN_ADD_CHILD = NN.instance_method(:add_child)
@@ -503,7 +507,37 @@ module Moxml
503
507
  raise Moxml::XsltError, "stylesheet compile failed: #{e.message}"
504
508
  end
505
509
 
510
+ # Bindings >= 1.9.290.0 thread top-level xsl:param overrides
511
+ # (leptris#1478); older bindings raise the typed error (the
512
+ # #360 face gap, face-probed so a misrelease degrades instead
513
+ # of NoMethodError-ing — the 1.9.199.0 lesson).
514
+ NATIVE_XSLT_PARAMS =
515
+ NATIVE_READ_LAYER &&
516
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.290.0") &&
517
+ ::Leptris::XML::XSLT::Stylesheet.instance_method(:apply_to)
518
+ .parameters.size > 1
519
+
520
+ # The seam contract passes the libxslt flat quoted array; the
521
+ # binding wants the raw name => value hash (leptris#1478
522
+ # matches nokogiri's quoted-string semantics internally).
523
+ def self.flat_params_to_hash(params)
524
+ return params if params.is_a?(Hash)
525
+ return {} if params.nil? || params.empty?
526
+
527
+ params.each_slice(2).to_h { |name, value| [name, unquote_param(value)] }
528
+ end
529
+
530
+ def self.unquote_param(value)
531
+ str = value.to_s
532
+ str.start_with?("'") && str.end_with?("'") && str.length >= 2 ? str[1..-2] : str
533
+ end
534
+
506
535
  def self.xslt_apply_document(sheet, document_native, params)
536
+ if NATIVE_XSLT_PARAMS
537
+ return sheet.apply_to(document_native,
538
+ params: flat_params_to_hash(params))
539
+ end
540
+
507
541
  assert_xslt_no_params(params)
508
542
  sheet.apply_to(document_native)
509
543
  rescue ::Leptris::XML::XPathError => e
@@ -511,6 +545,11 @@ module Moxml
511
545
  end
512
546
 
513
547
  def self.xslt_apply_string(sheet, document_native, params)
548
+ if NATIVE_XSLT_PARAMS
549
+ return sheet.serialize(document_native,
550
+ params: flat_params_to_hash(params))
551
+ end
552
+
514
553
  assert_xslt_no_params(params)
515
554
  sheet.serialize(document_native)
516
555
  rescue ::Leptris::XML::XPathError => e
@@ -521,8 +560,9 @@ module Moxml
521
560
  return if params.nil? || params.empty?
522
561
 
523
562
  raise Moxml::XsltError,
524
- "XSLT params are not threaded by the leptris engine yet " \
525
- "(leptris-ruby#360); pass an empty params hash"
563
+ "XSLT params are not threaded by this binding " \
564
+ "(leptris-ruby#360); upgrade to leptris >= 1.9.290.0 or " \
565
+ "pass an empty params hash"
526
566
  end
527
567
 
528
568
  def self.native_c14n_byte_safe?
@@ -1397,6 +1437,19 @@ module Moxml
1397
1437
  end
1398
1438
  end
1399
1439
 
1440
+ # Element-only children through the engine-level filter: the
1441
+ # C face mints element-kind natives directly (klass-children),
1442
+ # so interleaved text/comment nodes cost nothing. Entity
1443
+ # markers are text-kind — the element filter excludes them by
1444
+ # construction, no marker split needed.
1445
+ def element_children(node)
1446
+ if NATIVE_READ_LAYER && node.is_a?(::Leptris::XML::NativeNode)
1447
+ return NN_ELEMENT_CHILDREN.bind_call(node).to_a
1448
+ end
1449
+
1450
+ to_binding(node).element_children
1451
+ end
1452
+
1400
1453
  def children(node, entity_bearing: false)
1401
1454
  # NATIVE_READ_LAYER gates the whole keep-path: it consults
1402
1455
  # NN_DOCUMENT (defined only with the native layer), and
@@ -1596,10 +1649,25 @@ module Moxml
1596
1649
  [copied, pointers]
1597
1650
  end
1598
1651
 
1599
- # The binding reports the root element as parentless; the
1600
- # moxml contract roots at the document.
1652
+ # The binding reports the root element and the document-level
1653
+ # parts as parentless; the moxml contract roots them at the
1654
+ # document. DocType carries no #parent at all (plain
1655
+ # c_ptr+document value object) and a parentless PI is a
1656
+ # document-level one by construction — in-tree parts have
1657
+ # element parents.
1601
1658
  def root_parent(node)
1602
- node.parent || (node.document&.root == node ? node.document : nil)
1659
+ return node.document if node.is_a?(::Leptris::XML::DocType)
1660
+
1661
+ parent = node.parent
1662
+ return parent if parent
1663
+
1664
+ doc = node.document
1665
+ return nil unless doc
1666
+
1667
+ return doc if doc.root == node ||
1668
+ node.is_a?(::Leptris::XML::ProcessingInstruction)
1669
+
1670
+ nil
1603
1671
  end
1604
1672
 
1605
1673
  def next_sibling(node)
@@ -1949,7 +2017,19 @@ module Moxml
1949
2017
  node = to_binding(node)
1950
2018
  new_node = to_binding(new_node)
1951
2019
  end
1952
- return node.replace(new_node) if node.is_a?(::Leptris::XML::Element)
2020
+ if node.is_a?(::Leptris::XML::Element)
2021
+ # NOT Element#replace: it detaches via
2022
+ # leptris_element_remove_child, which leaves the replaced
2023
+ # node's sibling links pointing into the live tree. A later
2024
+ # append of that node splices the stale run under the new
2025
+ # parent, and when the run contains the append target the
2026
+ # tree cycles (standoc term cleanup does
2027
+ # t << n.replace(t2)). Insert + unlink instead: the unlink
2028
+ # path clears the sibling links.
2029
+ node.add_next_sibling(new_node)
2030
+ node.unlink
2031
+ return new_node
2032
+ end
1953
2033
 
1954
2034
  # libleptris only offers element-anchored insertion, so a
1955
2035
  # content node (text/comment/CDATA/PI) is replaced by
@@ -1972,11 +2052,23 @@ module Moxml
1972
2052
  new_node
1973
2053
  end
1974
2054
 
2055
+ NATIVE_APPEND_CHILDREN =
2056
+ NATIVE_READ_LAYER &&
2057
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.290.0")
2058
+
1975
2059
  def replace_children(node, new_children)
1976
2060
  if NATIVE_READ_LAYER
1977
2061
  node = to_binding(node)
1978
2062
  new_children = new_children.map { |child| to_binding(child) }
1979
2063
  end
2064
+ if NATIVE_APPEND_CHILDREN && !new_children.empty?
2065
+ # One crossing to clear, one to append N (leptris-ruby#366's
2066
+ # bulk face; the old path paid an append crossing per child).
2067
+ ::Leptris::XML::FFI.check_status(
2068
+ ::Leptris::XML::FFI.leptris_element_remove_children(node.c_ptr),
2069
+ )
2070
+ return node.append_children(new_children)
2071
+ end
1980
2072
  node.children = new_children
1981
2073
  end
1982
2074
 
@@ -2175,7 +2267,22 @@ module Moxml
2175
2267
  node = to_binding(node)
2176
2268
  end
2177
2269
  return nil unless native_context_node?(node)
2178
- return nil unless native_expression?(expression)
2270
+ # Hash-bound xmlns rides native on probing bindings
2271
+ # (leptris resolves the reserved prefix from the hash);
2272
+ # empty mappings (xmlns="") select no-namespace elements
2273
+ # and stay on the Ruby engine — the C pairs-set rejects
2274
+ # empty URIs. Everything else keeps the expression-keyed
2275
+ # gate.
2276
+ return nil unless (namespaces.is_a?(Hash) &&
2277
+ namespaces["xmlns"].to_s.length.positive? &&
2278
+ xmlns_native?) ||
2279
+ native_expression?(expression)
2280
+ # The leptris engine mishandles namespace-prefixed node
2281
+ # tests: "m:*" matches elements in ANY namespace and
2282
+ # "m:name" matches none, regardless of the binding. Route
2283
+ # namespace-bound queries to the Ruby engine until the
2284
+ # engine grows correct prefix resolution.
2285
+ return nil if namespaces && !namespaces.empty?
2179
2286
  # The binding reports the root element parentless while moxml
2180
2287
  # roots it at the document, so parent-axis queries from the
2181
2288
  # root element keep the Ruby engine.
@@ -2311,6 +2418,28 @@ module Moxml
2311
2418
  # xmlns:name is a nokogiri-compat convention addressing
2312
2419
  # elements in the default namespace; only the Ruby engine
2313
2420
  # implements it.
2421
+ # One-time capability probe: does the binding's native engine
2422
+ # resolve the reserved xmlns prefix from the namespace hash?
2423
+ # Memoized class-level — the answer never changes for an
2424
+ # install. Only hash-bound xmlns queries may ride native
2425
+ # (the gate below is expression-keyed and cannot see the
2426
+ # call's namespaces); unbound xmlns stays on the Ruby engine,
2427
+ # whose reserved-prefix branch matches default-namespace
2428
+ # elements.
2429
+ def xmlns_native?
2430
+ return @xmlns_native unless @xmlns_native.nil?
2431
+
2432
+ @xmlns_native = begin
2433
+ probe = parse('<r xmlns="urn:p"><a>1</a></r>', {}, nil)
2434
+ raw = to_binding(probe.root.native)
2435
+ res = raw.xpath("./xmlns:a", { "xmlns" => "urn:p" })
2436
+ list = res.respond_to?(:to_a) ? res.to_a : [res].compact
2437
+ list.size == 1 && list.first.text.to_s == "1"
2438
+ rescue StandardError
2439
+ false
2440
+ end
2441
+ end
2442
+
2314
2443
  def uses_xmlns_prefix?(ast)
2315
2444
  return true if ast.type == :test && ast.value[:namespace] == "xmlns"
2316
2445
 
@@ -1099,8 +1099,9 @@ module Moxml
1099
1099
  target_doc = doc || (element.is_a?(::LibXML::XML::Node) ? element.doc : nil)
1100
1100
 
1101
1101
  if target_doc
1102
- # Use deep import to ensure all descendants are included
1103
- imported = target_doc.import(child, true)
1102
+ # libxml-ruby 6.0 dropped import's deep flag; the import
1103
+ # walks the subtree either way.
1104
+ imported = target_doc.import(child)
1104
1105
  element << imported
1105
1106
  else
1106
1107
  # No target document - create a deep copy of the node instead
@@ -318,6 +318,10 @@ module Moxml
318
318
  node.children
319
319
  end
320
320
 
321
+ def element_children(node)
322
+ node.element_children
323
+ end
324
+
321
325
  def adjacent_to_entity_reference?(node)
322
326
  node.previous_sibling.is_a?(::Nokogiri::XML::EntityReference) ||
323
327
  node.next_sibling.is_a?(::Nokogiri::XML::EntityReference)
@@ -16,6 +16,38 @@ module Moxml
16
16
  end
17
17
 
18
18
  def root=(element)
19
+ owner = element.is_a?(Node) && element.document
20
+ # Only a genuinely ATTACHED foreign element adopts: the document
21
+ # face answers arbitrary Document wrappers for FLOATING elements
22
+ # (leptris: a stale mint-time document; ox: an Element), and
23
+ # adopting a floating element splices its (empty) copy as root —
24
+ # the caller keeps appending to the detached original and the
25
+ # tree serializes empty (lutaml-model's builder lost every
26
+ # child this way). Attached means parented, or another
27
+ # document's current root.
28
+ attached = !element.parent.nil? ||
29
+ (owner.is_a?(Document) && owner.root.equal?(element))
30
+ if owner.is_a?(Document) && attached &&
31
+ !owner.equal?(self) && element.context.equal?(context)
32
+ # libleptris refuses a root owned by another document (the
33
+ # engine's set_root ownership check), while Nokogiri adopts.
34
+ # Cross-document CHILD attaches are supported, so build the
35
+ # root here, mirror its declarations and attributes, and
36
+ # splice the foreign subtree child-by-child — each attach
37
+ # rides adapter add_child, which pins the owner document
38
+ # against GC (issue #304).
39
+ new_root = create_element(element.name)
40
+ element.attribute_pairs.each { |k, v| new_root[k] = v }
41
+ element.declared_namespaces.each do |prefix, uri|
42
+ new_root.add_namespace(prefix, uri)
43
+ end
44
+ adapter.set_root(@native, new_root.native)
45
+ new_root.parent_node = self
46
+ element.children.to_a.each { |child| new_root.add_child(child) }
47
+ context.bump_children_generation
48
+ invalidate_children_cache!
49
+ return
50
+ end
19
51
  adapter.set_root(@native, element.native)
20
52
  element.parent_node = self
21
53
  invalidate_children_cache!
data/lib/moxml/element.rb CHANGED
@@ -159,6 +159,40 @@ module Moxml
159
159
  end
160
160
  end
161
161
 
162
+ # Nokogiri-compatible: delete(name) removes the attribute
163
+ def delete(name)
164
+ remove_attribute(name)
165
+ end
166
+
167
+ # Nokogiri-compatible: element-only children. The adapter seam
168
+ # filters at the engine level where the binding carries the face
169
+ # (leptris bulk element children, nokogiri native), so interleaved
170
+ # text/comment nodes never mint wrappers on hot traversals.
171
+ # Memo rides the children generation (#310's mutation clock).
172
+ def elements
173
+ @elements = nil if @elements_gen != context.children_generation
174
+ @elements_gen = context.children_generation
175
+ @elements ||= NodeSet.new(adapter.element_children(@native), context, self)
176
+ end
177
+ alias element_children elements
178
+
179
+ # Nokogiri-compatible: children= replaces the entire child list
180
+ def children=(node_or_nodes)
181
+ natives = case node_or_nodes
182
+ when String
183
+ wrapper = "_moxml_children_#{Process.pid}_#{object_id}"
184
+ context.parse("<#{wrapper}>#{node_or_nodes}</#{wrapper}>")
185
+ .root.children.map(&:native)
186
+ when Moxml::Node then [node_or_nodes.native]
187
+ when Array, NodeSet then node_or_nodes.map(&:native)
188
+ else
189
+ raise ArgumentError, "children= accepts String, Node, or Array"
190
+ end
191
+ adapter.replace_children(@native, natives)
192
+ context.bump_children_generation
193
+ invalidate_children_cache!
194
+ end
195
+
162
196
  def remove_attribute(name)
163
197
  # Both remove paths end in invalidate_attribute_cache!, which
164
198
  # bumps the generation.
@@ -233,6 +267,19 @@ module Moxml
233
267
  invalidate_namespace_cache!
234
268
  end
235
269
 
270
+ # Nokogiri-compatible: bind the element to +uri+ as its DEFAULT
271
+ # namespace — the name drops any prefix and the declaration lands
272
+ # on this element. Children keep their own bindings. Skip when an
273
+ # identical default namespace is already in scope: declaring again
274
+ # serializes a duplicate xmlns attribute. (add_namespace is the
275
+ # primitive: namespace=({nil => uri}) declares twice on leptris.)
276
+ def default_namespace=(uri)
277
+ self.name = name.split(":", 2)[-1] if name.to_s.include?(":")
278
+ return if namespace_prefix.to_s.empty? && namespace_uri.to_s == uri.to_s
279
+
280
+ add_namespace(nil, uri.to_s)
281
+ end
282
+
236
283
  # All namespaces IN SCOPE for this element — its own
237
284
  # declarations plus everything inherited from ancestors —
238
285
  # matching the Nokogiri #namespaces contract consumers port
@@ -296,6 +343,8 @@ module Moxml
296
343
  invalidate_children_cache!
297
344
  end
298
345
 
346
+ alias content= text=
347
+
299
348
  def inner_text
300
349
  text = raw_inner_text
301
350
  entity_bearing? ? adapter.restore_entities(text) : text
data/lib/moxml/node.rb CHANGED
@@ -104,7 +104,104 @@ module Moxml
104
104
  Moxml::Node.wrap(adapter.previous_sibling(@native), context)
105
105
  end
106
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
+
107
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)
108
205
  context.bump_children_generation
109
206
  node = prepare_node(node)
110
207
  adapter.add_child(@native, node.native)
@@ -121,7 +218,7 @@ module Moxml
121
218
  self
122
219
  end
123
220
 
124
- def add_previous_sibling(node)
221
+ def attach_previous_sibling(node)
125
222
  context.bump_children_generation
126
223
  node = prepare_node(node)
127
224
  adapter.add_previous_sibling(@native, node.native)
@@ -134,32 +231,26 @@ module Moxml
134
231
  self
135
232
  end
136
233
 
137
- # Nokogiri-compatible assignment forms: node.next = / node.previous =.
138
- # String operands are parsed as XML fragments, like Nokogiri.
139
- def next=(node)
140
- add_next_sibling(sibling_operand(node))
141
- end
142
-
143
- def previous=(node)
144
- add_previous_sibling(sibling_operand(node))
145
- end
146
-
147
- def sibling_operand(node)
148
- return node unless node.is_a?(String)
149
-
150
- context.parse_fragment(node).first
151
- end
152
-
153
- def add_next_sibling(node)
234
+ def attach_next_sibling_to(anchor, node)
154
235
  context.bump_children_generation
155
236
  node = prepare_node(node)
156
- adapter.add_next_sibling(@native, node.native)
157
- parent&.invalidate_children_cache!
158
- 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!
159
240
  self
160
241
  end
161
242
 
162
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
+
163
254
  context.bump_children_generation
164
255
  invalidate_parent_children_cache!
165
256
  adapter.remove(@native)
@@ -203,6 +294,18 @@ module Moxml
203
294
  result
204
295
  end
205
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
+
206
309
  # Memoized against the adapter's serialize generation — the
207
310
  # entity-marker flag flips at parse and entity-reference mint,
208
311
  # both adapter-level, and the generation bump is the invalidation
@@ -375,7 +478,15 @@ module Moxml
375
478
  # the document node.
376
479
  #
377
480
  # @return [NodeSet] ancestors ordered nearest-first
378
- 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
379
490
  return NodeSet.new([], context) if document?
380
491
 
381
492
  natives = []
@@ -439,18 +550,9 @@ module Moxml
439
550
  # elements share the same qualified name, keeping paths minimal.
440
551
  #
441
552
  # @return [String] XPath expression
442
- # @raise [Moxml::NotImplementedError] for node types other than
443
- # element and document
444
553
  def path
445
554
  return "/" if document?
446
555
 
447
- unless element?
448
- raise Moxml::NotImplementedError.new(
449
- "path is only supported for element and document nodes",
450
- feature: "path",
451
- )
452
- end
453
-
454
556
  segments = []
455
557
  current = self
456
558
  while current && !current.document?
@@ -460,6 +562,19 @@ module Moxml
460
562
  "/#{segments.join('/')}"
461
563
  end
462
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
+
463
578
  # Returns the 1-based line number where this node appears in the
464
579
  # source XML, or nil when the underlying adapter does not track
465
580
  # source positions.
@@ -564,19 +679,25 @@ module Moxml
564
679
 
565
680
  private
566
681
 
567
- # XPath segment for an element: the qualified name, plus a positional
568
- # predicate only when same-named element siblings make it ambiguous.
569
- def path_segment_for(element)
570
- name = element.name
571
- 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
572
689
  return name unless parent
573
690
 
574
- same_name = parent.children.select do |child|
575
- 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
576
697
  end
577
- return name if same_name.size == 1
698
+ return name if same_kind.size == 1
578
699
 
579
- "#{name}[#{same_name.find_index(element) + 1}]"
700
+ "#{name}[#{same_kind.find_index(node) + 1}]"
580
701
  end
581
702
 
582
703
  def prepare_node(node)
@@ -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/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.101"
4
+ VERSION = "0.5.103"
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
@@ -82,4 +82,305 @@ RSpec.describe "Nokogiri-compatible node sugar" do
82
82
  expect(doc.root.children.map(&:name)).to eq(%w[a x text b])
83
83
  end
84
84
  end
85
+
86
+ describe "Node#<< append sugar" do
87
+ it "appends parsed fragments from strings" do
88
+ doc = Moxml.parse("<r/>")
89
+ doc.root << "<a/><b/>"
90
+ expect(doc.root.children.map(&:name)).to eq(%w[a b])
91
+ end
92
+
93
+ it "appends plain text as a text node" do
94
+ text_parent = Moxml.parse("<p/>").root
95
+ text_parent << "hello"
96
+ expect(text_parent.text).to eq("hello")
97
+ end
98
+
99
+ it "appends nodes" do
100
+ doc = Moxml.parse("<r/>")
101
+ doc.root << Moxml.parse("<b/>").root
102
+ expect(doc.root.children.map(&:name)).to eq(%w[b])
103
+ end
104
+
105
+ it "returns self for chaining" do
106
+ doc = Moxml.parse("<r/>")
107
+ expect(doc.root << "<a/>").to be(doc.root)
108
+ end
109
+ end
110
+
111
+ describe "Element#delete" do
112
+ it "removes an attribute by name" do
113
+ doc = Moxml.parse("<r annex='yes' id='x'/>")
114
+ doc.root.delete("annex")
115
+ expect(doc.root["annex"]).to be_nil
116
+ expect(doc.root["id"]).to eq("x")
117
+ end
118
+ end
119
+
120
+ describe "Element#children=" do
121
+ it "replaces all children with a string" do
122
+ doc = Moxml.parse("<r><old/>text</r>")
123
+ doc.root.children = "<a/><b/>"
124
+ expect(doc.root.children.map(&:name)).to eq(%w[a b])
125
+ end
126
+
127
+ it "replaces with a node" do
128
+ doc = Moxml.parse("<r><old/></r>")
129
+ node = Moxml.parse("<x/>").root
130
+ doc.root.children = node
131
+ expect(doc.root.children.map(&:name)).to eq(%w[x])
132
+ end
133
+
134
+ it "replaces with an array of nodes" do
135
+ doc = Moxml.parse("<r><old/></r>")
136
+ frag = Moxml.parse("<w><x/><y/></w>").root.children
137
+ doc.root.children = frag
138
+ expect(doc.root.children.map(&:name)).to eq(%w[x y])
139
+ end
140
+
141
+ it "invalidates memoized children holders" do
142
+ doc = Moxml.parse("<r><old/></r>")
143
+ root = doc.root
144
+ root.children
145
+ root.children = "<new/>"
146
+ expect(root.children.map(&:name)).to eq(%w[new])
147
+ end
148
+ end
149
+
150
+ describe "Element#elements" do
151
+ it "returns only element children" do
152
+ doc = Moxml.parse("<r>text<a/><!-- c --><b/></r>")
153
+ expect(doc.root.elements.map(&:name)).to eq(%w[a b])
154
+ expect(doc.root.element_children.map(&:name)).to eq(%w[a b])
155
+ end
156
+
157
+ it "stays fresh across mutations" do
158
+ doc = Moxml.parse("<r><a/></r>")
159
+ root = doc.root
160
+ expect(root.elements.map(&:name)).to eq(%w[a])
161
+ root.add_child(doc.create_element("b"))
162
+ expect(root.elements.map(&:name)).to eq(%w[a b])
163
+ end
164
+ end
165
+
166
+ describe "NodeSet#- difference" do
167
+ it "removes nodes present in the other set" do
168
+ doc = Moxml.parse("<r><a/><b/><c/></r>")
169
+ diff = doc.root.children - doc.search("//a")
170
+ expect(diff.map(&:name).sort).to eq(%w[b c])
171
+ end
172
+ end
173
+
174
+ describe "NodeSet#to_ary" do
175
+ it "flattens inside arrays" do
176
+ doc = Moxml.parse("<r><a/><b/></r>")
177
+ flat = [doc.root, doc.root.elements].flatten
178
+ expect(flat.map(&:name)).to include("r", "a", "b")
179
+ end
180
+ end
181
+
182
+ describe "Node#ancestors(selector)" do
183
+ it "filters ancestors by element name" do
184
+ doc = Moxml.parse("<r><table><tr><td><p/></td></tr></table></r>")
185
+ p_node = doc.at("//p")
186
+ expect(p_node.ancestors("table").map(&:name)).to eq(%w[table])
187
+ expect(p_node.ancestors.map(&:name)).to include("td", "tr", "table")
188
+ end
189
+ end
190
+
191
+ describe "Node#next_element/previous_element" do
192
+ it "skips interleaved text nodes" do
193
+ doc = Moxml.parse("<r><a/>text<b/></r>")
194
+ a = doc.at("//a")
195
+ expect(a.next_element.name).to eq("b")
196
+ expect(doc.at("//b").previous_element.name).to eq("a")
197
+ end
198
+ end
199
+
200
+ describe "Node#parent= reparenting" do
201
+ it "moves a node under a new parent" do
202
+ doc = Moxml.parse("<r><from><p/></from><to/></r>")
203
+ p_node = doc.at("//p")
204
+ p_node.parent = doc.at("//to")
205
+ expect(doc.at("//to/p")).not_to be_nil
206
+ expect(doc.at("//from/p")).to be_nil
207
+ end
208
+ end
209
+
210
+ describe "NodeSet#to_xml" do
211
+ it "serializes the set's members in order" do
212
+ doc = Moxml.parse("<r><a>x</a>tail</r>")
213
+ expect(doc.root.children.to_xml).to eq("<a>x</a>tail")
214
+ end
215
+ end
216
+
217
+ describe "Node#add_first_child" do
218
+ it "inserts before existing children and handles empty parents" do
219
+ doc = Moxml.parse("<r><b/></r>")
220
+ doc.root.add_first_child("<a/>")
221
+ expect(doc.root.children.map(&:name)).to eq(%w[a b])
222
+ Moxml.parse("<r/>").root.add_first_child("<x/>")
223
+ end
224
+ end
225
+
226
+ describe "Node#remove on a detached node" do
227
+ it "is a no-op returning self (Nokogiri parity)" do
228
+ doc = Moxml.parse("<r><p><c/></p><t/></r>")
229
+ p_node = doc.at("//p")
230
+ t = doc.create_element("d")
231
+ p_node.replace(t)
232
+ expect(p_node.parent).to be_nil
233
+ expect(p_node.remove).to equal(p_node)
234
+ t << p_node
235
+ expect(t.children.map(&:name)).to eq(%w[p])
236
+ expect(doc.at("//d/p/c")).not_to be_nil
237
+ end
238
+ end
239
+
240
+ describe "Element#replace + re-attach under the replacement" do
241
+ it "does not cycle the tree (leptris stale-run splice)" do
242
+ doc = Moxml.parse("<r><a>x</a><b/></r>")
243
+ a = doc.at("//a")
244
+ t = doc.create_element("t")
245
+ a.replace(t)
246
+ t << a
247
+ expect(doc.root.children.map(&:name)).to eq(%w[t b])
248
+ expect(t.children.map(&:name)).to eq(%w[a])
249
+ expect(doc.at("//t/a/text()").text).to eq("x")
250
+ end
251
+ end
252
+
253
+ describe "Node/NodeSet#to_s interpolation" do
254
+ it "serializes instead of Object#to_s" do
255
+ doc = Moxml.parse("<r><a>x</a><b/></r>")
256
+ # rubocop:disable Style/RedundantInterpolation -- the interpolation IS the contract under test
257
+ node = doc.at("//a")
258
+ expect("#{node}").to eq("<a>x</a>")
259
+ children = doc.root.children
260
+ expect("#{children}").to eq("<a>x</a><b></b>")
261
+ # rubocop:enable Style/RedundantInterpolation
262
+ end
263
+ end
264
+
265
+ describe "Element#default_namespace=" do
266
+ it "binds the element to the default namespace" do
267
+ doc = Moxml.parse("<r><math><mi>x</mi></math></r>")
268
+ math = doc.at("//math")
269
+ math.default_namespace = "http://www.w3.org/1998/Math/MathML"
270
+ expect(math.namespace_uri.to_s).to eq("http://www.w3.org/1998/Math/MathML")
271
+ expect(math.to_xml).to include('xmlns="http://www.w3.org/1998/Math/MathML"')
272
+ end
273
+ end
274
+
275
+ describe "Node#to_str" do
276
+ it "yields the text content" do
277
+ doc = Moxml.parse("<r><s>a<b>c</b>d</s></r>")
278
+ expect(doc.at("//s").to_str).to eq("acd")
279
+ expect(doc.at("//b").to_str).to eq("c")
280
+ end
281
+ end
282
+
283
+ describe "Element#content=" do
284
+ it "replaces children with the given text" do
285
+ doc = Moxml.parse("<r><s><b>x</b>y</s></r>")
286
+ s = doc.at("//s")
287
+ s.content = "1 < 2"
288
+ expect(s.children.size).to eq(1)
289
+ expect(s.content).to eq("1 < 2")
290
+ expect(s.to_xml).to eq("<s>1 &lt; 2</s>")
291
+ end
292
+ end
293
+
294
+ describe "Node#next/previous readers" do
295
+ it "return adjacent siblings" do
296
+ doc = Moxml.parse("<r><a/>mid<b/></r>")
297
+ a = doc.at("//a")
298
+ expect(a.next.name).to eq("text")
299
+ expect(a.next.next.name).to eq("b")
300
+ expect(doc.at("//b").previous.name).to eq("text")
301
+ expect(doc.at("//b").previous.previous.name).to eq("a")
302
+ end
303
+ end
304
+
305
+ describe "Node#path for non-element nodes" do
306
+ it "builds type-test segments" do
307
+ doc = Moxml.parse("<r><p>a<!--c--></p><p>b</p></r>")
308
+ expect(doc.at("//p").path).to eq("/r/p[1]")
309
+ texts = doc.xpath("//p/text()")
310
+ expect(texts[0].path).to eq("/r/p[1]/text()")
311
+ expect(doc.xpath("//p/comment()").first.path).to eq("/r/p[1]/comment()")
312
+ end
313
+ end
314
+
315
+ describe "Document#root= with a foreign root" do
316
+ it "adopts the subtree from another document" do
317
+ doc = Moxml.parse("<old><x/></old>")
318
+ other = Moxml.parse(%(<r xmlns="urn:x" a="1"><p>hi<b>t</b></p></r>))
319
+ doc.root = other.root
320
+ expect(doc.to_xml(indent: 0).strip)
321
+ .to eq(%(<r xmlns="urn:x" a="1"><p>hi<b>t</b></p></r>))
322
+ expect(doc.root.namespace_uri.to_s).to eq("urn:x")
323
+ end
324
+ end
325
+
326
+ describe "String operands are markup, not literal text" do
327
+ it "add_child / add_next_sibling / add_previous_sibling parse strings" do
328
+ doc = Moxml.parse("<r><a/></r>")
329
+ a = doc.at("//a")
330
+ a.add_child("<c/>")
331
+ a.add_next_sibling("<d/>")
332
+ a.add_previous_sibling("<b/>")
333
+ expect(a.children.map(&:name)).to eq(%w[c])
334
+ expect(doc.root.children.map(&:name)).to eq(%w[b a d])
335
+ expect(doc.at("//r/a/c")).not_to be_nil
336
+ end
337
+ end
338
+
339
+ describe "Markup-string insertion returns a NodeSet of new nodes" do
340
+ it "add_child / add_next_sibling return the new nodes; nodes return self" do
341
+ doc = Moxml.parse("<r><a/></r>")
342
+ a = doc.at("//a")
343
+ kids = a.add_child("<c1/><c2/>")
344
+ expect(kids).to be_a(Moxml::NodeSet)
345
+ expect(kids.map(&:name)).to eq(%w[c1 c2])
346
+ sibs = a.add_next_sibling("<n1/><n2/>")
347
+ expect(sibs.map(&:name)).to eq(%w[n1 n2])
348
+ expect(doc.root.children.map(&:name)).to eq(%w[a n1 n2])
349
+ prev = a.add_previous_sibling("<p1/><p2/>")
350
+ expect(prev.map(&:name)).to eq(%w[p1 p2])
351
+ expect(doc.root.children.map(&:name)).to eq(%w[p1 p2 a n1 n2])
352
+ expect(a.add_child(doc.at("//p1"))).to equal(a)
353
+ end
354
+ end
355
+
356
+ describe "default_namespace= idempotence" do
357
+ it "does not duplicate an identical in-scope default namespace" do
358
+ doc = Moxml.parse(%(<r xmlns="urn:x"><math xmlns="urn:m"/></r>))
359
+ math = doc.at("//m:math", "m" => "urn:m")
360
+ math.default_namespace = "urn:m"
361
+ expect(math.to_xml).to eq(%(<math xmlns="urn:m"></math>))
362
+ end
363
+ end
364
+
365
+ describe "xpath with namespace bindings" do
366
+ it "prefixed star and name tests honor the namespace URI" do
367
+ # m must be DECLARED on the element — a bare m:title with only
368
+ # a default xmlns is a no-namespace element whose literal name
369
+ # carries the colon, and no prefix binding can select it.
370
+ doc = Moxml.parse(%(<r><bibdata/><m:title xmlns:m="urn:x"/></r>))
371
+ hits = doc.root.xpath(".//m:*", "m" => "urn:unitsml")
372
+ expect(hits.map(&:name)).to eq([])
373
+ named = doc.root.at_xpath(".//m:title", "m" => "urn:x")
374
+ expect(named&.name).to eq("title")
375
+ none = doc.root.at_xpath(".//m:title", "m" => "urn:unitsml")
376
+ expect(none).to be_nil
377
+ end
378
+ end
379
+
380
+ describe "Element#inner_xml" do
381
+ it "serializes the children" do
382
+ doc = Moxml.parse("<r><a>x</a><b/></r>")
383
+ expect(doc.root.inner_xml).to eq("<a>x</a><b/>")
384
+ end
385
+ end
85
386
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # moxml#315: unions are N-ARY in the parser (a|b|c parses to one
6
+ # union node); the compiler's two-children destructure silently
7
+ # dropped every branch after the second. The reserved xmlns prefix
8
+ # hid the drop for namespaced consumers because those queries rode
9
+ # the native seam; when they fell back to the Ruby engine (or on
10
+ # adapters whose engine IS this one, e.g. ox), branches vanished.
11
+ RSpec.describe "XPath union compilation" do
12
+ let(:ctx) { Moxml.new(:leptris) }
13
+ let(:doc) { ctx.parse('<r xmlns="urn:x"><a>1</a><b>2</b><c>3</c></r>') }
14
+
15
+ it "keeps every branch of a flat n-ary union (moxml#315)" do
16
+ r = doc.at_xpath("/xmlns:r", { "xmlns" => "urn:x" })
17
+ names = r.xpath("./xmlns:a|./xmlns:b|./xmlns:c", { "xmlns" => "urn:x" })
18
+ .map(&:text)
19
+ expect(names).to eq(%w[1 2 3])
20
+ end
21
+
22
+ it "keeps branches under the reserved xmlns prefix through the engine" do
23
+ r = doc.at_xpath("/xmlns:r", { "xmlns" => "urn:x" })
24
+ ast = Moxml::XPath::Parser.parse("./xmlns:a|./xmlns:b|./xmlns:c")
25
+ proc_c = Moxml::XPath::Compiler.compile_with_cache(ast, namespaces: { "xmlns" => "urn:x" })
26
+ expect(proc_c.call(r).to_a.map(&:text)).to eq(%w[1 2 3])
27
+ end
28
+
29
+ it "keeps parenthesized nested unions" do
30
+ r = doc.at_xpath("/xmlns:r", { "xmlns" => "urn:x" })
31
+ ast = Moxml::XPath::Parser.parse("(./xmlns:a|./xmlns:b)|./xmlns:c")
32
+ proc_c = Moxml::XPath::Compiler.compile_with_cache(ast, namespaces: { "xmlns" => "urn:x" })
33
+ expect(proc_c.call(r).to_a.map(&:text)).to eq(%w[1 2 3])
34
+ end
35
+
36
+ it "unions still work on adapters whose engine is the Ruby engine" do
37
+ ctx_ox = Moxml.new(:ox)
38
+ doc_ox = ctx_ox.parse("<r><a>1</a><b>2</b><c>3</c></r>")
39
+ r = doc_ox.root
40
+ expect(r.xpath("./a|./b|./c").map(&:text)).to eq(%w[1 2 3])
41
+ end
42
+ end
@@ -4,8 +4,9 @@ require "spec_helper"
4
4
 
5
5
  # The Moxml::XSLT contract: compile once, apply many. Only engines
6
6
  # with an XSLT processor implement it (nokogiri/libxslt; leptris
7
- # covering XSLT 1.0-3.0). leptris does not thread top-level params
8
- # yet (leptris-ruby#360) — non-empty params raise Moxml::XsltError.
7
+ # covering XSLT 1.0-3.0). Bindings >= 1.9.290.0 thread top-level
8
+ # params (leptris#1478); older bindings raise Moxml::XsltError
9
+ # citing leptris-ruby#360.
9
10
  SHEET_COUNT = <<~XSL
10
11
  <xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
11
12
  <xsl:template match="/"><out><xsl:value-of select="count(//i)"/></out></xsl:template>
@@ -77,10 +78,22 @@ RSpec.describe "Moxml::XSLT" do
77
78
  expect(sheet.apply_to_string(doc)).to include("plain text result")
78
79
  end
79
80
 
80
- it "raises XsltError on non-empty params (leptris-ruby#360)" do
81
- sheet = ctx.xslt(SHEET_COUNT)
82
- expect { sheet.apply_to(doc, params: { "n" => "7" }) }
83
- .to raise_error(Moxml::XsltError, /params.*#360/)
81
+ if Gem::Version.new(Leptris::VERSION) >= Gem::Version.new("1.9.290.0")
82
+ it "threads top-level params (leptris#1478)" do
83
+ sheet = ctx.xslt(<<~XSL)
84
+ <xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
85
+ <xsl:param name="n" select="'0'"/>
86
+ <xsl:template match="/"><out><xsl:value-of select="$n"/></out></xsl:template>
87
+ </xsl:stylesheet>
88
+ XSL
89
+ expect(sheet.apply_to_string(doc, params: { "n" => "7" })).to include("7")
90
+ end
91
+ else
92
+ it "raises XsltError on non-empty params (leptris-ruby#360)" do
93
+ sheet = ctx.xslt(SHEET_COUNT)
94
+ expect { sheet.apply_to(doc, params: { "n" => "7" }) }
95
+ .to raise_error(Moxml::XsltError, /params.*#360/)
96
+ end
84
97
  end
85
98
 
86
99
  it "reports support" 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.101
4
+ version: 0.5.103
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -560,6 +560,7 @@ files:
560
560
  - spec/moxml/xpath/parser_spec.rb
561
561
  - spec/moxml/xpath/ruby/generator_spec.rb
562
562
  - spec/moxml/xpath/ruby/node_spec.rb
563
+ - spec/moxml/xpath/union_spec.rb
563
564
  - spec/moxml/xpath_capabilities_spec.rb
564
565
  - spec/moxml/xpath_spec.rb
565
566
  - spec/moxml/xslt/stylesheet_spec.rb