moxml 0.1.17 → 0.1.19

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: bbd69145e9a360635af848bf0bdda2883e35760b2763021f6bf6f1d6dca9827e
4
- data.tar.gz: aa492e21514fd80a01f98709eddf8c3aa323b584210d56534ad5e2c2b467df18
3
+ metadata.gz: 76508cc0d2699469ade87f48e38ea73289abc4f95cb7a313f6d71548477248f3
4
+ data.tar.gz: 1752cb433953a869cedd1d88fc565c53f77bd08201c5977247150e1e7b75a386
5
5
  SHA512:
6
- metadata.gz: 1cdb7d6c934f1ea788a40d81d987c97d4c1fc21ad71d22eaac73abf45d093680667f3303b35934378b8cce0d99e3fc9db47c85632678247426527d7fb3491bed
7
- data.tar.gz: 79c352eb8df9b86831d554e17538abd4da8a6dfce61b4e566bc236334601e43bff7c670894ea05c72abac40bb1b3b90375ef8caaf6b488a9c43bc33fc70d6785
6
+ metadata.gz: a4219577f2e00a9e4f00f1ce1bbaf4f03d6c841dfe8adb9983162a6389b1e276725984dc010f451cac55fbd8049a81451d641fc073fc1cf8417f90ee6e50cdf5
7
+ data.tar.gz: 44f3e08b174e2689fed3a60ee3ef0b805fdc14cbeeb905ebde15e5241be3e3f168441dc95afe99dd244fc1047c581fce912ec3e061299e4e101f63329f00def9
data/README.adoc CHANGED
@@ -33,7 +33,7 @@ Moxml supports the following XML libraries:
33
33
  REXML:: https://github.com/ruby/rexml[REXML], a pure Ruby XML parser
34
34
  distributed with standard Ruby. Not the fastest, but always available.
35
35
 
36
- Nokogiri:: (default) https://github.com/sparklemotion/nokogiri[Nokogiri], a
36
+ Nokogiri:: https://github.com/sparklemotion/nokogiri[Nokogiri], a
37
37
  widely used implementation which wraps around the performant
38
38
  https://github.com/GNOME/libxml2[libxml2] C library.
39
39
 
@@ -47,6 +47,29 @@ LibXML:: https://github.com/xml4r/libxml-ruby[libxml-ruby], Ruby bindings
47
47
  for the performant https://github.com/GNOME/libxml2[libxml2] C library.
48
48
  Alternative to Nokogiri with similar performance characteristics.
49
49
 
50
+ ==== Default adapter selection
51
+
52
+ When no adapter is explicitly specified, Moxml selects one automatically based on
53
+ the runtime environment:
54
+
55
+ . If running on Opal (`RUBY_ENGINE == "opal"`), Oga is used (pure Ruby, no C extensions)
56
+ . Otherwise, Moxml detects already-loaded XML libraries in this order:
57
+ .. Nokogiri
58
+ .. Ox
59
+ .. Oga
60
+ . If none of the above are loaded, Nokogiri is the fallback default
61
+
62
+ [source,ruby]
63
+ ----
64
+ # Automatic detection — picks the best available adapter
65
+ ctx = Moxml.new
66
+ ctx.config.adapter_name # => :nokogiri (or :oga on Opal)
67
+
68
+ # Explicit override — always takes precedence
69
+ ctx = Moxml.new(:ox)
70
+ ctx.config.adapter_name # => :ox
71
+ ----
72
+
50
73
  === Feature table
51
74
 
52
75
  Moxml exercises its best effort to provide a consistent interface across basic
@@ -294,9 +294,6 @@ module Moxml
294
294
  result = []
295
295
  if native_node.children?
296
296
  native_node.each_child do |child|
297
- # Skip whitespace-only text nodes
298
- next if child.text? && child.content.to_s.strip.empty?
299
-
300
297
  result << patch_node(child)
301
298
  end
302
299
  end
@@ -182,11 +182,7 @@ module Moxml
182
182
  end
183
183
 
184
184
  def children(node)
185
- node.children.reject do |child|
186
- child.text? && child.content.strip.empty? &&
187
- !(child.previous_sibling.nil? && child.next_sibling.nil?) &&
188
- !adjacent_to_entity_reference?(child)
189
- end
185
+ node.children
190
186
  end
191
187
 
192
188
  def adjacent_to_entity_reference?(node)
@@ -193,12 +193,18 @@ module Moxml
193
193
 
194
194
  return all_children unless node.is_a?(::Oga::XML::Node) || node.is_a?(::Oga::XML::Document)
195
195
 
196
- all_children + node.children.reject do |child|
197
- child.is_a?(::Oga::XML::Text) &&
198
- child.text.strip.empty? &&
199
- !(child.previous.nil? && child.next.nil?) &&
200
- !adjacent_to_entity_reference?(child)
196
+ child_nodes = node.children.to_a
197
+ # Filter out whitespace-only text nodes at document level only.
198
+ # Document-level whitespace (between <?xml?> and <root>) is
199
+ # formatting, not content, and differs across adapters.
200
+ # Whitespace inside elements (e.g. "FigureA.1" spacing) is
201
+ # meaningful and must be preserved.
202
+ if node.is_a?(::Oga::XML::Document)
203
+ child_nodes = child_nodes.reject do |child|
204
+ child.is_a?(::Oga::XML::Text) && child.text.strip.empty?
205
+ end
201
206
  end
207
+ all_children + child_nodes
202
208
  end
203
209
 
204
210
  def adjacent_to_entity_reference?(node)
@@ -177,12 +177,8 @@ module Moxml
177
177
  def children(node)
178
178
  return [] unless node.is_a?(::REXML::Parent)
179
179
 
180
- # Get all children and filter out empty text nodes between elements
181
- result = node.children.reject do |child|
182
- child.is_a?(::REXML::Text) &&
183
- child.to_s.strip.empty? &&
184
- !(child.next_sibling.nil? && child.previous_sibling.nil?)
185
- end
180
+ # Return all children preserving whitespace text nodes
181
+ result = node.children.dup
186
182
 
187
183
  # Include any EntityReference wrappers stored alongside native children
188
184
  entity_refs = attachments.get(node, :entity_refs)
data/lib/moxml/config.rb CHANGED
@@ -3,7 +3,8 @@
3
3
  module Moxml
4
4
  class Config
5
5
  VALID_ADAPTERS = %i[nokogiri oga rexml ox headed_ox libxml].freeze
6
- DEFAULT_ADAPTER = VALID_ADAPTERS.first
6
+ DEFAULT_ADAPTER = :nokogiri
7
+ OPAL_DEFAULT_ADAPTER = :oga
7
8
 
8
9
  # Entity loading modes:
9
10
  # - :required - Must load entities, raise error if unavailable (default)
@@ -20,7 +21,21 @@ module Moxml
20
21
  end
21
22
 
22
23
  def default_adapter
23
- @default_adapter ||= DEFAULT_ADAPTER
24
+ @default_adapter || runtime_default_adapter
25
+ end
26
+
27
+ def runtime_default_adapter
28
+ return OPAL_DEFAULT_ADAPTER if RUBY_ENGINE == "opal"
29
+
30
+ detect_loaded_adapter || DEFAULT_ADAPTER
31
+ end
32
+
33
+ def detect_loaded_adapter
34
+ return :nokogiri if Object.const_defined?(:Nokogiri)
35
+ return :ox if Object.const_defined?(:Ox)
36
+ return :oga if Object.const_defined?(:Oga)
37
+
38
+ nil
24
39
  end
25
40
  end
26
41
 
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Moxml
6
+ class NativeAttachment
7
+ # Stores Moxml-specific state associated with native adapter objects
8
+ # without polluting their internals.
9
+ #
10
+ # Uses object_id as key with GC finalizer cleanup to prevent memory leaks.
11
+ # Thread-safe via Monitor (reentrant-safe).
12
+ #
13
+ # Replaces the anti-pattern of using instance_variable_set/get on
14
+ # foreign library objects (Nokogiri, REXML, Oga, Ox, LibXML nodes).
15
+ #
16
+ # @example
17
+ # attachments = NativeAttachment.new
18
+ # attachments.set(native_element, :entity_refs, [])
19
+ # refs = attachments.get(native_element, :entity_refs)
20
+ # attachments.key?(native_element, :doctype) #=> false
21
+ class Native
22
+ def initialize
23
+ @data = {}
24
+ @finalizer_registered = {}
25
+ @monitor = Monitor.new
26
+ end
27
+
28
+ def get(native, key)
29
+ @monitor.synchronize { @data[native.object_id]&.[](key) }
30
+ end
31
+
32
+ def set(native, key, value)
33
+ id = native.object_id
34
+ @monitor.synchronize do
35
+ @data[id] ||= {}
36
+ @data[id][key] = value
37
+ register_finalizer(native, id) unless @finalizer_registered[id]
38
+ end
39
+ end
40
+
41
+ def key?(native, key)
42
+ @monitor.synchronize { @data[native.object_id]&.key?(key) || false }
43
+ end
44
+
45
+ def delete(native, key)
46
+ @monitor.synchronize { @data[native.object_id]&.delete(key) }
47
+ end
48
+
49
+ private
50
+
51
+ def register_finalizer(native, id)
52
+ @finalizer_registered[id] = true
53
+ ObjectSpace.define_finalizer(native, finalizer_for(id))
54
+ end
55
+
56
+ def finalizer_for(id)
57
+ data = @data
58
+ registered = @finalizer_registered
59
+ # Finalizers must NOT use Mutex/Monitor (can't be called from trap context).
60
+ # Direct Hash operations are safe here since finalizers run sequentially
61
+ # and the GC'd object's id won't be accessed by any other thread.
62
+ proc do
63
+ data.delete(id)
64
+ registered.delete(id)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ class NativeAttachment
5
+ # Opal adapter nodes are Ruby objects, so instance variables are sufficient
6
+ # for Moxml-owned attachments without relying on Monitor/Thread support.
7
+ class Opal
8
+ def get(native, key)
9
+ native.instance_variable_get(attachment_ivar_name(key))
10
+ end
11
+
12
+ def set(native, key, value)
13
+ native.instance_variable_set(attachment_ivar_name(key), value)
14
+ end
15
+
16
+ def key?(native, key)
17
+ native.instance_variable_defined?(attachment_ivar_name(key))
18
+ end
19
+
20
+ def delete(native, key)
21
+ ivar_name = attachment_ivar_name(key)
22
+ return unless native.instance_variable_defined?(ivar_name)
23
+
24
+ native.remove_instance_variable(ivar_name)
25
+ end
26
+
27
+ private
28
+
29
+ def attachment_ivar_name(key)
30
+ :"@moxml_attachment_#{key}"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,65 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- # Stores Moxml-specific state associated with native adapter objects
5
- # without polluting their internals.
6
- #
7
- # Uses object_id as key with GC finalizer cleanup to prevent memory leaks.
8
- # Thread-safe via Monitor (reentrant-safe).
9
- #
10
- # Replaces the anti-pattern of using instance_variable_set/get on
11
- # foreign library objects (Nokogiri, REXML, Oga, Ox, LibXML nodes).
12
- #
13
- # @example
14
- # attachments = NativeAttachment.new
15
- # attachments.set(native_element, :entity_refs, [])
16
- # refs = attachments.get(native_element, :entity_refs)
17
- # attachments.key?(native_element, :doctype) #=> false
18
4
  class NativeAttachment
19
- def initialize
20
- @data = {}
21
- @finalizer_registered = {}
22
- @monitor = Monitor.new
5
+ autoload :Opal, "moxml/native_attachment/opal"
6
+ autoload :Native, "moxml/native_attachment/native"
7
+
8
+ def self.default_backend
9
+ constant = RUBY_ENGINE == "opal" ? :Opal : :Native
10
+ const_get(constant).new
11
+ end
12
+
13
+ attr_reader :backend
14
+
15
+ def initialize(backend: self.class.default_backend)
16
+ @backend = backend
23
17
  end
24
18
 
25
19
  def get(native, key)
26
- @monitor.synchronize { @data[native.object_id]&.[](key) }
20
+ @backend.get(native, key)
27
21
  end
28
22
 
29
23
  def set(native, key, value)
30
- id = native.object_id
31
- @monitor.synchronize do
32
- @data[id] ||= {}
33
- @data[id][key] = value
34
- register_finalizer(native, id) unless @finalizer_registered[id]
35
- end
24
+ @backend.set(native, key, value)
36
25
  end
37
26
 
38
27
  def key?(native, key)
39
- @monitor.synchronize { @data[native.object_id]&.key?(key) || false }
28
+ @backend.key?(native, key)
40
29
  end
41
30
 
42
31
  def delete(native, key)
43
- @monitor.synchronize { @data[native.object_id]&.delete(key) }
44
- end
45
-
46
- private
47
-
48
- def register_finalizer(native, id)
49
- @finalizer_registered[id] = true
50
- ObjectSpace.define_finalizer(native, finalizer_for(id))
51
- end
52
-
53
- def finalizer_for(id)
54
- data = @data
55
- registered = @finalizer_registered
56
- # Finalizers must NOT use Mutex/Monitor (can't be called from trap context).
57
- # Direct Hash operations are safe here since finalizers run sequentially
58
- # and the GC'd object's id won't be accessed by any other thread.
59
- proc do
60
- data.delete(id)
61
- registered.delete(id)
62
- end
32
+ @backend.delete(native, key)
63
33
  end
64
34
  end
65
35
  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.1.17"
4
+ VERSION = "0.1.19"
5
5
  end
@@ -167,6 +167,91 @@ RSpec.shared_examples "Moxml Edge Cases" do
167
167
  end
168
168
  end
169
169
 
170
+ describe "whitespace text node preservation" do
171
+ # Ox/HeadedOx do not generate whitespace-only text nodes in their parser,
172
+ # so these tests only apply to adapters that do (Nokogiri, OGA, REXML, LibXML)
173
+ let(:preserves_ws) { !%i[ox headed_ox].include?(context.config.adapter_name) }
174
+
175
+ it "preserves whitespace-only text nodes between sibling elements" do
176
+ unless preserves_ws
177
+ skip "Ox/HeadedOx parser does not generate whitespace-only text nodes"
178
+ end
179
+
180
+ xml = <<~XML
181
+ <root>
182
+ <a>1</a>
183
+ <b>2</b>
184
+ <c>3</c>
185
+ </root>
186
+ XML
187
+
188
+ doc = context.parse(xml)
189
+ children = doc.root.children
190
+
191
+ # Should have whitespace text nodes between elements
192
+ expect(children.size).to be > 3
193
+
194
+ # Whitespace text nodes should be Text nodes
195
+ ws_nodes = children.select { |c| c.is_a?(Moxml::Text) && c.content.strip.empty? }
196
+ expect(ws_nodes).not_to be_empty
197
+
198
+ # Element children should still be accessible
199
+ elements = children.select { |c| c.is_a?(Moxml::Element) }
200
+ expect(elements.map(&:name)).to eq(%w[a b c])
201
+ end
202
+
203
+ it "preserves inline whitespace text nodes between text and elements" do
204
+ xml = "<p>Figure <sub>A</sub>.1</p>"
205
+ doc = context.parse(xml)
206
+
207
+ children = doc.root.children
208
+ expect(children.size).to eq(3)
209
+
210
+ # First child: "Figure " text node
211
+ expect(children[0]).to be_a(Moxml::Text)
212
+ expect(children[0].content).to eq("Figure ")
213
+
214
+ # Second child: <sub> element
215
+ expect(children[1]).to be_a(Moxml::Element)
216
+ expect(children[1].name).to eq("sub")
217
+ expect(children[1].text).to eq("A")
218
+
219
+ # Third child: ".1" text node
220
+ expect(children[2]).to be_a(Moxml::Text)
221
+ expect(children[2].content).to eq(".1")
222
+ end
223
+
224
+ it "preserves space-only text node as meaningful content" do
225
+ xml = "<p>Hello <b>world</b>!</p>"
226
+ doc = context.parse(xml)
227
+
228
+ children = doc.root.children
229
+ expect(children.size).to eq(3)
230
+
231
+ expect(children[0].content).to eq("Hello ")
232
+ expect(children[1]).to be_a(Moxml::Element)
233
+ expect(children[2].content).to eq("!")
234
+ end
235
+
236
+ it "distinguishes whitespace text nodes from element children" do
237
+ unless preserves_ws
238
+ skip "Ox/HeadedOx parser does not generate whitespace-only text nodes"
239
+ end
240
+
241
+ xml = "<root> <child/> </root>"
242
+ doc = context.parse(xml)
243
+
244
+ children = doc.root.children
245
+ # " " before child, " " after child
246
+ expect(children.size).to eq(3)
247
+ expect(children[0]).to be_a(Moxml::Text)
248
+ expect(children[0].content).to eq(" ")
249
+ expect(children[1]).to be_a(Moxml::Element)
250
+ expect(children[2]).to be_a(Moxml::Text)
251
+ expect(children[2].content).to eq(" ")
252
+ end
253
+ end
254
+
170
255
  describe "document structure edge cases" do
171
256
  it "handles deeply nested elements" do
172
257
  doc = context.create_document
@@ -104,7 +104,7 @@ RSpec.shared_examples "Entity Reference Whitespace Preservation" do
104
104
  end
105
105
 
106
106
  describe "structural whitespace filtering" do
107
- it "still filters whitespace between elements" do
107
+ it "preserves whitespace text nodes between elements" do
108
108
  xml = <<~XML
109
109
  <root>
110
110
  <child1/>
@@ -115,8 +115,10 @@ RSpec.shared_examples "Entity Reference Whitespace Preservation" do
115
115
  doc = context.parse(xml)
116
116
  children = doc.root.children
117
117
 
118
- expect(children.length).to eq(2)
119
- expect(children.all?(Moxml::Element)).to be true
118
+ # Whitespace text nodes between elements are preserved
119
+ elements = children.select { |c| c.is_a?(Moxml::Element) }
120
+ expect(elements.length).to eq(2)
121
+ expect(elements.map(&:name)).to eq(%w[child1 child2])
120
122
  end
121
123
  end
122
124
  end
@@ -32,12 +32,14 @@ RSpec.shared_examples "Moxml::DocumentBuilder" do
32
32
 
33
33
  expect(doc.root.namespaces.count).to eq(1)
34
34
  expect(doc.root.namespaces.first.uri).to eq("http://example.org")
35
- expect(doc.root.children[0]).to be_a(Moxml::Comment)
36
- expect(doc.root.children[1]).to be_a(Moxml::Element)
37
- expect(doc.root.children[1].name).to eq("child")
38
- expect(doc.root.children[1]["id"]).to eq("1")
39
- expect(doc.root.children[1].children.first).to be_a(Moxml::Cdata)
40
- expect(doc.root.children[2]).to be_a(Moxml::ProcessingInstruction)
35
+ # Whitespace text nodes are preserved between elements
36
+ non_ws_children = doc.root.children.reject { |c| c.is_a?(Moxml::Text) && c.content.strip.empty? }
37
+ expect(non_ws_children[0]).to be_a(Moxml::Comment)
38
+ expect(non_ws_children[1]).to be_a(Moxml::Element)
39
+ expect(non_ws_children[1].name).to eq("child")
40
+ expect(non_ws_children[1]["id"]).to eq("1")
41
+ expect(non_ws_children[1].children.find { |c| c.is_a?(Moxml::Cdata) }).to be_a(Moxml::Cdata)
42
+ expect(non_ws_children[2]).to be_a(Moxml::ProcessingInstruction)
41
43
  end
42
44
  end
43
45
  end
@@ -113,7 +113,7 @@ RSpec.shared_examples "Moxml Integration" do
113
113
  expect(attr).to eq("value")
114
114
 
115
115
  # Test namespace override
116
- deeper = a_element.children.first
116
+ deeper = a_element.children.find { |c| c.is_a?(Moxml::Element) }
117
117
  expect(deeper.namespace.uri).to eq("http://other.org")
118
118
  end
119
119
  end
@@ -41,7 +41,9 @@ RSpec.shared_examples "Moxml::NodeSet" do
41
41
  end
42
42
 
43
43
  it "compares nodes" do
44
- expect(doc.xpath("//child")).to eq(doc.root.children)
44
+ xpath_results = doc.xpath("//child")
45
+ element_children = doc.root.children.select { |c| c.is_a?(Moxml::Element) }
46
+ expect(xpath_results.map(&:native)).to eq(element_children.map(&:native))
45
47
  end
46
48
  end
47
49
 
@@ -86,7 +86,7 @@ RSpec.describe "Moxml lazy parse" do
86
86
 
87
87
  it "round-trips through serialize" do
88
88
  doc = ctx.parse(xml)
89
- serialized = doc.to_xml
89
+ serialized = doc.to_xml(indent: 0)
90
90
  doc2 = ctx.parse(serialized)
91
91
  expect(doc2.root.name).to eq("root")
92
92
  expect(doc2.root.children.to_a.size).to eq(2)
@@ -23,7 +23,6 @@ RSpec.describe Moxml do
23
23
 
24
24
  describe ".configure" do
25
25
  around do |example|
26
- # preserve the original config because it may be changed in examples
27
26
  described_class.with_config { example.run }
28
27
  end
29
28
 
@@ -47,4 +46,10 @@ RSpec.describe Moxml do
47
46
  expect(context.config.default_encoding).to eq("US-ASCII")
48
47
  end
49
48
  end
49
+
50
+ describe "Config.runtime_default_adapter" do
51
+ it "returns a valid adapter for the current runtime" do
52
+ expect(Moxml::Config::VALID_ADAPTERS).to include(Moxml::Config.runtime_default_adapter)
53
+ end
54
+ end
50
55
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "shared_examples"
5
+
6
+ RSpec.describe Moxml::NativeAttachment::Native do
7
+ it_behaves_like "an attachment backend"
8
+
9
+ it "stores attachments outside native object instance variables" do
10
+ attachments = described_class.new
11
+ native = Object.new
12
+
13
+ attachments.set(native, :entity_refs, ["amp"])
14
+
15
+ aggregate_failures do
16
+ expect(attachments.get(native, :entity_refs)).to eq(["amp"])
17
+ expect(native.instance_variables).to be_empty
18
+ end
19
+ end
20
+
21
+ it "registers one finalizer per native object that clears sidecar storage" do
22
+ attachments = described_class.new
23
+ native = Object.new
24
+ other_native = Object.new
25
+ finalizers = {}.compare_by_identity
26
+
27
+ expect(ObjectSpace).to receive(:define_finalizer)
28
+ .with(native, kind_of(Proc)).once do |object, finalizer|
29
+ finalizers[object] = finalizer
30
+ end
31
+ expect(ObjectSpace).to receive(:define_finalizer)
32
+ .with(other_native, kind_of(Proc)).once do |object, finalizer|
33
+ finalizers[object] = finalizer
34
+ end
35
+
36
+ attachments.set(native, :entity_refs, ["amp"])
37
+ attachments.set(native, :doctype, "html")
38
+ attachments.set(other_native, :entity_refs, ["lt"])
39
+
40
+ native_id = native.object_id
41
+ data = attachments.instance_variable_get(:@data)
42
+ registered = attachments.instance_variable_get(:@finalizer_registered)
43
+
44
+ aggregate_failures do
45
+ expect(data).to have_key(native_id)
46
+ expect(registered).to have_key(native_id)
47
+ end
48
+
49
+ finalizers.fetch(native).call
50
+
51
+ aggregate_failures do
52
+ expect(data).not_to have_key(native_id)
53
+ expect(registered).not_to have_key(native_id)
54
+ expect(attachments.get(other_native, :entity_refs)).to eq(["lt"])
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "shared_examples"
5
+
6
+ RSpec.describe Moxml::NativeAttachment::Opal do
7
+ it_behaves_like "an attachment backend"
8
+
9
+ it "stores attachments in Moxml-owned instance variables" do
10
+ attachments = described_class.new
11
+ native = Object.new
12
+
13
+ attachments.set(native, :entity_refs, ["amp"])
14
+
15
+ expect(native.instance_variable_get(:@moxml_attachment_entity_refs))
16
+ .to eq(["amp"])
17
+ end
18
+
19
+ it "removes the attachment instance variable on delete" do
20
+ attachments = described_class.new
21
+ native = Object.new
22
+
23
+ attachments.set(native, :entity_refs, ["amp"])
24
+ attachments.delete(native, :entity_refs)
25
+
26
+ expect(native.instance_variable_defined?(:@moxml_attachment_entity_refs))
27
+ .to be(false)
28
+ end
29
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_examples "an attachment backend" do
4
+ subject(:attachments) { described_class.new }
5
+
6
+ let(:native) { Object.new }
7
+ let(:other_native) { Object.new }
8
+
9
+ it "stores and reads attachments by native object and key" do
10
+ attachments.set(native, :entity_refs, ["amp"])
11
+ attachments.set(native, :doctype, "html")
12
+ attachments.set(other_native, :entity_refs, ["lt"])
13
+
14
+ aggregate_failures do
15
+ expect(attachments.get(native, :entity_refs)).to eq(["amp"])
16
+ expect(attachments.get(native, :doctype)).to eq("html")
17
+ expect(attachments.get(other_native, :entity_refs)).to eq(["lt"])
18
+ expect(attachments.key?(native, :entity_refs)).to be(true)
19
+ expect(attachments.key?(native, :missing)).to be(false)
20
+ end
21
+ end
22
+
23
+ it "preserves explicit nil attachments" do
24
+ attachments.set(native, :xml_declaration, nil)
25
+
26
+ aggregate_failures do
27
+ expect(attachments.get(native, :xml_declaration)).to be_nil
28
+ expect(attachments.key?(native, :xml_declaration)).to be(true)
29
+ end
30
+ end
31
+
32
+ it "deletes attachments" do
33
+ attachments.set(native, :entity_refs, ["amp"])
34
+
35
+ expect(attachments.delete(native, :entity_refs)).to eq(["amp"])
36
+
37
+ aggregate_failures do
38
+ expect(attachments.get(native, :entity_refs)).to be_nil
39
+ expect(attachments.key?(native, :entity_refs)).to be(false)
40
+ expect(attachments.delete(native, :entity_refs)).to be_nil
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "rbconfig"
5
+ require "spec_helper"
6
+
7
+ RSpec.describe Moxml::NativeAttachment do
8
+ describe ".default_backend" do
9
+ it "uses the Opal backend under Opal" do
10
+ stub_const("RUBY_ENGINE", "opal")
11
+
12
+ expect(described_class.default_backend).to be_a(described_class::Opal)
13
+ end
14
+
15
+ it "uses the native backend under non-Opal Ruby engines" do
16
+ stub_const("RUBY_ENGINE", "ruby")
17
+
18
+ expect(described_class.default_backend).to be_a(described_class::Native)
19
+ end
20
+ end
21
+
22
+ describe "facade" do
23
+ subject(:attachments) { described_class.new }
24
+
25
+ let(:native) { Object.new }
26
+
27
+ it "delegates storage calls to the configured backend" do
28
+ backend = Class.new do
29
+ attr_reader :calls
30
+
31
+ def initialize
32
+ @calls = []
33
+ end
34
+
35
+ def get(native, key)
36
+ @calls << [:get, native, key]
37
+ :stored_value
38
+ end
39
+
40
+ def set(native, key, value)
41
+ @calls << [:set, native, key, value]
42
+ end
43
+
44
+ def key?(native, key)
45
+ @calls << [:key?, native, key]
46
+ true
47
+ end
48
+
49
+ def delete(native, key)
50
+ @calls << [:delete, native, key]
51
+ :stored_value
52
+ end
53
+ end.new
54
+ attachments = described_class.new(backend: backend)
55
+
56
+ expect(attachments.get(native, :entity_refs)).to eq(:stored_value)
57
+ attachments.set(native, :entity_refs, ["amp"])
58
+ expect(attachments.key?(native, :entity_refs)).to be(true)
59
+ expect(attachments.delete(native, :entity_refs)).to eq(:stored_value)
60
+
61
+ expect(backend.calls).to eq(
62
+ [
63
+ [:get, native, :entity_refs],
64
+ [:set, native, :entity_refs, ["amp"]],
65
+ [:key?, native, :entity_refs],
66
+ [:delete, native, :entity_refs],
67
+ ],
68
+ )
69
+ end
70
+
71
+ it "uses the selected runtime backend" do
72
+ expected_class = if RUBY_ENGINE == "opal"
73
+ described_class::Opal
74
+ else
75
+ described_class::Native
76
+ end
77
+
78
+ expect(attachments.backend).to be_a(expected_class)
79
+ end
80
+ end
81
+
82
+ describe "loader" do
83
+ let(:lib_dir) { File.expand_path("../../lib", __dir__) }
84
+ let(:ruby) { RbConfig.ruby }
85
+
86
+ it "loads NativeAttachment through the top-level moxml entrypoint" do
87
+ stdout, stderr, status = Open3.capture3(
88
+ ruby,
89
+ "-I",
90
+ lib_dir,
91
+ "-e",
92
+ 'require "moxml"; puts Moxml::NativeAttachment.new.respond_to?(:set)',
93
+ )
94
+
95
+ expect(status.success?).to be(true), stderr
96
+ expect(stdout).to eq("true\n")
97
+ end
98
+
99
+ it "loads NativeAttachment through the internal facade file" do
100
+ stdout, stderr, status = Open3.capture3(
101
+ ruby,
102
+ "-I",
103
+ lib_dir,
104
+ "-e",
105
+ 'require "moxml/native_attachment"; puts Moxml::NativeAttachment.new.respond_to?(:set)',
106
+ )
107
+
108
+ expect(status.success?).to be(true), stderr
109
+ expect(stdout).to eq("true\n")
110
+ end
111
+
112
+ it "registers backend implementations with require-style autoload paths" do
113
+ stdout, stderr, status = Open3.capture3(
114
+ ruby,
115
+ "-I",
116
+ lib_dir,
117
+ "-e",
118
+ <<~RUBY,
119
+ require "moxml/native_attachment"
120
+
121
+ puts Moxml::NativeAttachment.autoload?(:Opal)
122
+ puts Moxml::NativeAttachment.autoload?(:Native)
123
+ RUBY
124
+ )
125
+
126
+ expect(status.success?).to be(true), stderr
127
+ expect(stdout).to eq(
128
+ "moxml/native_attachment/opal\n" \
129
+ "moxml/native_attachment/native\n",
130
+ )
131
+ end
132
+
133
+ it "loads the native backend lazily when selected" do
134
+ stdout, stderr, status = Open3.capture3(
135
+ ruby,
136
+ "-I",
137
+ lib_dir,
138
+ "-e",
139
+ <<~'RUBY',
140
+ require "moxml/native_attachment"
141
+
142
+ puts $LOADED_FEATURES.grep(%r{/native_attachment/native\.rb\z}).empty?
143
+ puts Moxml::NativeAttachment.new.backend.class
144
+ puts !$LOADED_FEATURES.grep(%r{/native_attachment/native\.rb\z}).empty?
145
+ puts $LOADED_FEATURES.grep(%r{/native_attachment/opal\.rb\z}).empty?
146
+ RUBY
147
+ )
148
+
149
+ expect(status.success?).to be(true), stderr
150
+ expect(stdout).to eq(
151
+ "true\n" \
152
+ "Moxml::NativeAttachment::Native\n" \
153
+ "true\n" \
154
+ "true\n",
155
+ )
156
+ end
157
+
158
+ it "does not load the native backend when Opal is selected" do
159
+ stdout, stderr, status = Open3.capture3(
160
+ ruby,
161
+ "-I",
162
+ lib_dir,
163
+ "-e",
164
+ <<~'RUBY',
165
+ Object.send(:remove_const, :RUBY_ENGINE)
166
+ RUBY_ENGINE = "opal"
167
+
168
+ require "moxml/native_attachment"
169
+
170
+ puts Moxml::NativeAttachment.autoload?(:Native)
171
+ puts Moxml::NativeAttachment.new.backend.class
172
+ puts $LOADED_FEATURES.grep(%r{/native_attachment/native\.rb\z}).empty?
173
+ RUBY
174
+ )
175
+
176
+ expect(status.success?).to be(true), stderr
177
+ expect(stdout).to eq(
178
+ "moxml/native_attachment/native\n" \
179
+ "Moxml::NativeAttachment::Opal\n" \
180
+ "true\n",
181
+ )
182
+ end
183
+ end
184
+ end
@@ -127,7 +127,7 @@ RSpec.describe "XPath Node Functions" do
127
127
  it "inherits language from parent element" do
128
128
  ast = Moxml::XPath::Parser.parse('lang("en")')
129
129
  proc = Moxml::XPath::Compiler.compile_with_cache(ast)
130
- child = doc_with_lang.root.children.first
130
+ child = doc_with_lang.root.children.select { |c| c.is_a?(Moxml::Element) }.first
131
131
  result = proc.call(child)
132
132
 
133
133
  expect(result).to be true
@@ -136,7 +136,8 @@ RSpec.describe "XPath Node Functions" do
136
136
  it "uses closest xml:lang attribute" do
137
137
  ast = Moxml::XPath::Parser.parse('lang("fr")')
138
138
  proc = Moxml::XPath::Compiler.compile_with_cache(ast)
139
- other = doc_with_lang.root.children[1]
139
+ elements = doc_with_lang.root.children.select { |c| c.is_a?(Moxml::Element) }
140
+ other = elements[1]
140
141
  result = proc.call(other)
141
142
 
142
143
  expect(result).to be true
@@ -27,10 +27,10 @@ RSpec.shared_examples "Performance Examples" do
27
27
  {
28
28
  nokogiri: { parser: 15, serializer: 1000 },
29
29
  oga: { parser: 10, serializer: 100 },
30
- rexml: { parser: 0, serializer: 60 },
30
+ rexml: { parser: 0, serializer: 5 },
31
31
  ox: { parser: 2, serializer: 1000 },
32
32
  headed_ox: { parser: 2, serializer: 1000 },
33
- libxml: { parser: 10, serializer: 30 },
33
+ libxml: { parser: 2, serializer: 3 },
34
34
  }
35
35
  end
36
36
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moxml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.17
4
+ version: 0.1.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-24 00:00:00.000000000 Z
11
+ date: 2026-05-01 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Moxml is a unified XML manipulation library that provides a common API
@@ -141,6 +141,8 @@ files:
141
141
  - lib/moxml/error.rb
142
142
  - lib/moxml/namespace.rb
143
143
  - lib/moxml/native_attachment.rb
144
+ - lib/moxml/native_attachment/native.rb
145
+ - lib/moxml/native_attachment/opal.rb
144
146
  - lib/moxml/node.rb
145
147
  - lib/moxml/node_set.rb
146
148
  - lib/moxml/processing_instruction.rb
@@ -325,6 +327,10 @@ files:
325
327
  - spec/moxml/moxml_spec.rb
326
328
  - spec/moxml/namespace_spec.rb
327
329
  - spec/moxml/namespace_uri_validation_spec.rb
330
+ - spec/moxml/native_attachment/native_spec.rb
331
+ - spec/moxml/native_attachment/opal_spec.rb
332
+ - spec/moxml/native_attachment/shared_examples.rb
333
+ - spec/moxml/native_attachment_spec.rb
328
334
  - spec/moxml/node_cache_spec.rb
329
335
  - spec/moxml/node_set_cache_spec.rb
330
336
  - spec/moxml/node_set_spec.rb