moxml 0.5.113 → 0.5.115

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: c61331ea3536aa41170080eb6233643b3b48da707d268da9185ed11fbdcad87d
4
- data.tar.gz: ef1765b2e2651438c8d38012ea374e2a8b9fe6ebcaff63d2afaf476eb0f727ab
3
+ metadata.gz: 3269b76bcc581101e3b7f6219c58fc5010752ab1af4f4bb35b8e505989f08e04
4
+ data.tar.gz: 1c255835027897f446de2bb88b175a5b71432636b2a7ab3810480323865a4dac
5
5
  SHA512:
6
- metadata.gz: 2058949ca13a105d38f86b5c38acf3fb974ca9445faedda64ea7d857bf8f10561d5b6df42d7b56ede501d0fcb9186f0347349faee29dd66868b83ec47a94ac4c
7
- data.tar.gz: c7c7b4f0b0d529ec793e790072d2115fe6ff7c17a389e08a84abadc2ab324e058f4f49d1e5d5a6e1143b0f688ac893b5ebae083638f1482b9b526b99d7c636fd
6
+ metadata.gz: 12d97fd4ae488aee81c5e04e5b55be1336bea7bad60f14fc2481ee9fdd80689edac7dfc3113a6dc65be605a555d388d10e89566a206993c84438d0fcbd9e8d41
7
+ data.tar.gz: 1bf50772dde870a02dd80c1d4d686d2d47149b1d4b9210113bed8e2732dec6c48291105ee26969c92c1d46d6f7086931516c0f6110ce868d662cb2bfa1ea2871
data/.rubocop.yml CHANGED
@@ -23,6 +23,10 @@ AllCops:
23
23
  # the zero-allocation streaming form (issue #143). One parameter per
24
24
  # record key is the interface; a shorter list would need a wrapper
25
25
  # object, which is the allocation the API exists to avoid.
26
+ Metrics/BlockLength:
27
+ Exclude:
28
+ - 'Rakefile'
29
+
26
30
  Metrics/ParameterLists:
27
31
  Exclude:
28
32
  - 'lib/moxml/materializer.rb'
data/Rakefile CHANGED
@@ -190,7 +190,12 @@ namespace :spec do
190
190
 
191
191
  desc "Run unit tests only"
192
192
  RSpec::Core::RakeTask.new(:unit) do |t|
193
- t.pattern = "spec/unit/**/*_spec.rb"
193
+ # Unit specs live under spec/moxml/ — the stale glob matched
194
+ # nothing (spec/unit/ holds only the rexml_isolated_test.rb
195
+ # entity-era investigation artifact), so this leg ran zero
196
+ # examples and hid the moxml suite from the fast gate.
197
+ t.pattern = "spec/moxml/**/*_spec.rb"
198
+ t.exclude_pattern = "spec/moxml/adapter/**/*_spec.rb"
194
199
  end
195
200
 
196
201
  desc "Run adapter tests only"
@@ -13,6 +13,18 @@ module Moxml
13
13
  class << self
14
14
  include XmlUtils
15
15
 
16
+ # Binding-level markup-accumulating builder face
17
+ # (leptris 1.9.313, #374 lever 2): nil = the adapter has no
18
+ # face and the Builder takes the per-node tree path.
19
+ def builder_face(_native_document)
20
+ nil
21
+ end
22
+
23
+ # A built subtree carried entity references as marker text —
24
+ # arm the document's marker restoration (the entity
25
+ # preservation contract).
26
+ def mark_entity_markers(_native_document); end
27
+
16
28
  def preprocess_entities(xml)
17
29
  Entity.preprocess_entities(xml)
18
30
  end
@@ -161,6 +161,16 @@ module Moxml
161
161
  ::Leptris::XML::Document.method_defined?(:absorb) &&
162
162
  Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.311.3")
163
163
 
164
+ # Markup-accumulating builder face (leptris-ruby#374 lever 2,
165
+ # binding 1.9.313): the DSL accumulates markup in a plain
166
+ # String and ONE native crossing (parse + attach) flushes the
167
+ # whole subtree. See Adapter::Base#builder_face.
168
+ NATIVE_BUILDER =
169
+ defined?(::Leptris::XML::Builder) &&
170
+ defined?(::Leptris::XML::Document) &&
171
+ ::Leptris::XML::Document.method_defined?(:build) &&
172
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.313.0")
173
+
164
174
  # XSLT 1.0/2.0/3.0 through the engine's compiled-stylesheet
165
175
  # faces (Moxml::XSLT contract). Top-level parameters are not
166
176
  # threaded through the faces yet (leptris-ruby#360) — non-empty
@@ -208,6 +218,27 @@ module Moxml
208
218
  # (and on installs without the native layer — the constant
209
219
  # check short-circuits), a Node.wrap over the shared C
210
220
  # pointer for NativeNodes.
221
+ def builder_face(native_document)
222
+ return nil unless NATIVE_BUILDER && NATIVE_READ_LAYER
223
+
224
+ doc = if native_document.is_a?(NN)
225
+ to_binding(native_document)
226
+ else
227
+ native_document
228
+ end
229
+ ::Leptris::XML::Builder.new(doc)
230
+ end
231
+
232
+ def mark_entity_markers(native_document)
233
+ doc = if native_document.is_a?(NN)
234
+ to_binding(native_document)
235
+ else
236
+ native_document
237
+ end
238
+ attachments.set(doc, :entity_markers, true)
239
+ bump_serialize_generation
240
+ end
241
+
211
242
  def to_binding(node)
212
243
  return node unless NATIVE_READ_LAYER &&
213
244
  node.is_a?(::Leptris::XML::NativeNode)
@@ -1900,6 +1931,16 @@ module Moxml
1900
1931
  # contract); every other kind carries no cross-document
1901
1932
  # identity worth preserving, so reconstruct it in the parent's
1902
1933
  # document through the binding's create faces.
1934
+ def contains_foreign_child?(parent_nn, children)
1935
+ parent_doc = NN_DOCUMENT.bind_call(parent_nn)
1936
+ return false if parent_doc.nil?
1937
+
1938
+ children.any? do |child|
1939
+ child_doc = NN_DOCUMENT.bind_call(child)
1940
+ child_doc && !child_doc.equal?(parent_doc)
1941
+ end
1942
+ end
1943
+
1903
1944
  def rebuild_foreign_child(parent_nn, child_nn)
1904
1945
  doc = NN_DOCUMENT.bind_call(parent_nn)
1905
1946
  rebuilt =
@@ -2348,6 +2389,32 @@ module Moxml
2348
2389
  Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.290.0")
2349
2390
 
2350
2391
  def replace_children(node, new_children)
2392
+ # Cross-pool children: the binding's bulk face adopts per
2393
+ # node and fails on mixed kinds (inner_xml= with
2394
+ # text+elements died with Memory allocation failed —
2395
+ # pre-existing). Rebuild foreign children in this pool
2396
+ # after the clear — fragment children carry no identity
2397
+ # worth preserving; same-document children keep the plain
2398
+ # add, in order.
2399
+ if NATIVE_MUTATIONS_COHERENT && node.is_a?(NN) &&
2400
+ new_children.all?(NN) &&
2401
+ contains_foreign_child?(node, new_children)
2402
+ ::Leptris::XML::FFI.check_status(
2403
+ ::Leptris::XML::FFI.leptris_element_remove_children(
2404
+ ::FFI::Pointer.new(node.address),
2405
+ ),
2406
+ )
2407
+ node_doc = NN_DOCUMENT.bind_call(node)
2408
+ new_children.each do |child|
2409
+ child_doc = NN_DOCUMENT.bind_call(child)
2410
+ if child_doc && !child_doc.equal?(node_doc)
2411
+ rebuild_foreign_child(node, child)
2412
+ else
2413
+ NN_ADD_CHILD.bind_call(node, child)
2414
+ end
2415
+ end
2416
+ return node
2417
+ end
2351
2418
  if NATIVE_READ_LAYER
2352
2419
  node = to_binding(node)
2353
2420
  new_children = new_children.map { |child| to_binding(child) }
data/lib/moxml/builder.rb CHANGED
@@ -11,14 +11,40 @@ module Moxml
11
11
  @context = context
12
12
  @current = @document = context.create_document
13
13
  @namespaces = {}
14
+ # Markup-accumulating face (leptris 1.9.313, #374 lever 2):
15
+ # leaf DSL ops write markup into one String and a single
16
+ # native crossing flushes the whole subtree at build end.
17
+ # Tree-only ops (declaration/doctype/PI/entity/namespace)
18
+ # flush first and run on the document; the face retires after
19
+ # its first flush.
20
+ @face = context.config.adapter.builder_face(@document.native)
21
+ @face_stack = []
22
+ @face_counts = Hash.new(0)
23
+ @face_deferred = []
24
+ @face_entity_markers = false
14
25
  end
15
26
 
16
27
  def build(&block)
17
28
  instance_eval(&block)
29
+ flush_face
18
30
  @document
19
31
  end
20
32
 
33
+ # Element ops inside a face-accumulated subtree cannot touch the
34
+ # tree yet (nothing exists until the flush): PIs ride the raw
35
+ # hatch in exact position; namespace/entity/declaration/doctype
36
+ # defer to the flush and re-target their element by position (an
37
+ # element's namespace set is unordered, so deferral is exact;
38
+ # deferred children land at their element's end).
39
+ def face_defer(operation, args)
40
+ @face_deferred << [operation, args, @face_stack.dup]
41
+ end
42
+
21
43
  def declaration(version: "1.0", encoding: "UTF-8", standalone: nil)
44
+ args = [version, encoding, standalone]
45
+ return face_defer(:declaration, args) if @face && @face_stack.any?
46
+
47
+ flush_face
22
48
  @current.add_child(
23
49
  @document.create_declaration(version, encoding, standalone),
24
50
  )
@@ -41,34 +67,65 @@ module Moxml
41
67
  end
42
68
 
43
69
  def text(content)
70
+ return face_content { @face.text(content) } if @face
71
+
44
72
  @current.add_child(@document.create_text(content))
45
73
  end
46
74
 
47
75
  def cdata(content)
76
+ return face_content { @face.cdata(content) } if @face
77
+
48
78
  @current.add_child(@document.create_cdata(content))
49
79
  end
50
80
 
51
81
  def comment(content)
82
+ return face_content { @face.comment(content) } if @face
83
+
52
84
  @current.add_child(@document.create_comment(content))
53
85
  end
54
86
 
55
87
  def entity_reference(name)
88
+ # In-position through the marker face: the reference lands as
89
+ # marker text exactly where the DSL puts it, and the flush
90
+ # arms the document's entity-marker restoration.
91
+ if @face && @face_stack.any?
92
+ @face.raw("#{Entity::MARKER}#{name};")
93
+ @face_entity_markers = true
94
+ return
95
+ end
96
+
97
+ flush_face
56
98
  @current.add_child(@document.create_entity_reference(name))
57
99
  end
58
100
 
59
101
  def processing_instruction(target, content)
102
+ if @face
103
+ if @face_stack.empty?
104
+ flush_face
105
+ else
106
+ @face.raw("<?#{target} #{content}?>")
107
+ return
108
+ end
109
+ end
60
110
  @current.add_child(
61
111
  @document.create_processing_instruction(target, content),
62
112
  )
63
113
  end
64
114
 
65
115
  def namespace(prefix, uri)
116
+ return face_defer(:namespace, [prefix, uri]) if @face && @face_stack.any?
117
+
118
+ flush_face
66
119
  @current.add_namespace(prefix, uri)
67
120
  @namespaces[prefix] = uri
68
121
  end
69
122
 
70
123
  # Convenience method for DOCTYPE
71
124
  def doctype(name, external_id = nil, system_id = nil)
125
+ args = [name, external_id, system_id]
126
+ return face_defer(:doctype, args) if @face && @face_stack.any?
127
+
128
+ flush_face
72
129
  @current.add_child(
73
130
  @document.create_doctype(name, external_id, system_id),
74
131
  )
@@ -109,13 +166,139 @@ module Moxml
109
166
  true
110
167
  end
111
168
 
169
+ FACE_NAME = /\A[a-zA-Z_][\w.-]*\z/
170
+ FACE_ESCAPE = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;",
171
+ '"' => "&quot;" }.freeze
172
+ FACE_ESCAPE_RE = /[&<>"]/
173
+
112
174
  private
113
175
 
176
+ # One face.element per DSL element — the face opens/closes the
177
+ # markup around the block itself, so nesting is free. Prefixed
178
+ # names (p:tag) fall outside the face's name shape and ride the
179
+ # raw hatch with manual escaping. Returns no wrapper: nothing
180
+ # exists until the flush.
181
+ def face_element(tag_name, attrs, text_content, block, eval_block)
182
+ unless FACE_NAME.match?(tag_name)
183
+ return face_raw_element(tag_name, attrs, text_content, block,
184
+ eval_block)
185
+ end
186
+
187
+ face_attrs = {}
188
+ attrs.each do |key, value|
189
+ k = key.to_s
190
+ if k == "xmlns" || k.start_with?("xmlns:")
191
+ face_attrs[k] = value.to_s
192
+ @namespaces[k.delete_prefix("xmlns:")] = value.to_s
193
+ else
194
+ face_attrs[k] = value
195
+ end
196
+ end
197
+ ordinal = @face_counts[@face_stack.length]
198
+ @face_counts[@face_stack.length] = ordinal + 1
199
+ @face_stack << ordinal
200
+ begin
201
+ @face.element(tag_name, face_attrs) do
202
+ @face.text(text_content) if text_content
203
+ run_builder_block(block, eval_block) if block
204
+ end
205
+ ensure
206
+ @face_stack.pop
207
+ @face_counts.delete(@face_stack.length + 1)
208
+ end
209
+ end
210
+
211
+ def face_raw_element(tag_name, attrs, text_content, block, eval_block)
212
+ open = "<#{tag_name}"
213
+ attrs.each do |key, value|
214
+ k = key.to_s
215
+ if k == "xmlns" || k.start_with?("xmlns:")
216
+ @namespaces[k.delete_prefix("xmlns:")] = value.to_s
217
+ end
218
+ open << " " << k << '="' << face_escape(value) << '"'
219
+ end
220
+ ordinal = @face_counts[@face_stack.length]
221
+ @face_counts[@face_stack.length] = ordinal + 1
222
+ @face_stack << ordinal
223
+ begin
224
+ if block || text_content
225
+ @face.raw("#{open}>")
226
+ @face.text(text_content) if text_content
227
+ run_builder_block(block, eval_block) if block
228
+ @face.raw("</#{tag_name}>")
229
+ else
230
+ @face.raw("#{open}/>")
231
+ end
232
+ ensure
233
+ @face_stack.pop
234
+ @face_counts.delete(@face_stack.length + 1)
235
+ end
236
+ end
237
+
238
+ # Content at document level has no root to attach to and the
239
+ # Document face flush demands exactly one root — retire the face
240
+ # and take the tree path (legacy semantics: doc-level text is a
241
+ # document child). Inside a subtree the text accumulates in
242
+ # position; @current never leaves the document in face mode, so
243
+ # the face stack is the nesting test.
244
+ def face_content
245
+ return yield if @face && @face_stack.any?
246
+
247
+ flush_face if @face
248
+ end
249
+
250
+ def run_builder_block(block, eval_block)
251
+ eval_block ? instance_eval(&block) : block.call
252
+ end
253
+
254
+ def face_escape(value)
255
+ value.to_s.gsub(FACE_ESCAPE_RE, FACE_ESCAPE)
256
+ end
257
+
258
+ def flush_face
259
+ return unless @face
260
+
261
+ face = @face
262
+ @face = nil
263
+ face.flush
264
+ if @face_entity_markers
265
+ @context.config.adapter.mark_entity_markers(@document.native)
266
+ end
267
+ apply_face_deferred
268
+ end
269
+
270
+ # Deferred tree-ops re-target their element by position: the
271
+ # stack records each element's ordinal among its parent's
272
+ # elements, and the flushed tree preserves markup order.
273
+ def apply_face_deferred
274
+ @face_deferred.each do |op, args, stack|
275
+ target = @document.root
276
+ stack[1..].each do |ordinal|
277
+ target = target.elements.to_a[ordinal]
278
+ end
279
+ case op
280
+ when :namespace
281
+ target.add_namespace(*args)
282
+ @namespaces[args[0]] = args[1]
283
+ when :declaration
284
+ target.add_child(@document.create_declaration(*args))
285
+ when :doctype
286
+ target.add_child(@document.create_doctype(*args))
287
+ end
288
+ end
289
+ @face_deferred = []
290
+ end
291
+
114
292
  # Single method for all element creation.
115
293
  # eval_block: true → instance_eval (build DSL context)
116
294
  # eval_block: false → yield (preserves caller's self)
117
295
  def create_element_node(tag_name, attrs = {}, text_content: nil,
118
296
  block: nil, eval_block: true)
297
+ if @face
298
+ face_element(tag_name, attrs, text_content, block, eval_block)
299
+ return nil
300
+ end
301
+
119
302
  el = @document.create_element(tag_name)
120
303
 
121
304
  attrs.each do |key, value|
data/lib/moxml/element.rb CHANGED
@@ -400,14 +400,17 @@ module Moxml
400
400
  end
401
401
 
402
402
  def inner_xml=(xml)
403
- wrapper = "_moxml_inner_#{Process.pid}_#{object_id}"
404
- doc = context.parse("<#{wrapper}>#{xml}</#{wrapper}>")
403
+ # Fragment parse, not a wrapper element around interpolated
404
+ # input (TODO.refactor/03): the fragment face is the same one
405
+ # add_child(String) uses, so semantics match and no wrapper
406
+ # name can collide with content.
407
+ nodes = context.parse_fragment(xml).to_a
405
408
  @text = nil
406
409
  # Whole-list replacement rides the children generation like
407
410
  # children= does (moxml#310) — text and elements memos across
408
411
  # wrappers recompute on next read.
409
412
  context.bump_children_generation
410
- adapter.replace_children(@native, doc.root.children.map(&:native))
413
+ adapter.replace_children(@native, nodes.map(&:native))
411
414
  invalidate_children_cache!
412
415
  end
413
416
 
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.113"
4
+ VERSION = "0.5.115"
5
5
  end
@@ -89,6 +89,25 @@ RSpec.shared_examples "Moxml::Element" do
89
89
  expect(element.children.first.name).to eq("child")
90
90
  expect(element.children.first.text).to eq("text")
91
91
  end
92
+
93
+ it "sets inner XML with mixed top-level nodes" do
94
+ # Structure, not serialization spelling: Ox renders bare
95
+ # text as <s> nodes and rexml/oga expand empty tags.
96
+ element.inner_xml = "a<c1/>b<c2 v='1'/>c"
97
+ kids = element.children
98
+ expect(
99
+ kids.map { |c| c.element? ? :element : :content },
100
+ ).to eq(%i[content element content element content])
101
+ expect(kids[0].text).to eq("a")
102
+ expect(kids[2].text).to eq("b")
103
+ expect(kids[4].text).to eq("c")
104
+ expect(kids[3]["v"]).to eq("1")
105
+ end
106
+
107
+ it "sets inner XML containing element-like text and entities" do
108
+ element.inner_xml = "<c>&lt;/element&gt; &amp; &#65;</c>"
109
+ expect(element.children.first.text).to eq("</element> & A")
110
+ end
92
111
  end
93
112
 
94
113
  describe "node manipulation" do
@@ -168,9 +168,10 @@ RSpec.describe Moxml::Node do
168
168
  end
169
169
  end
170
170
 
171
- it "raises for node types without a path representation" do
171
+ it "locates non-element nodes through typed node tests" do
172
172
  text = doc.root.children.first.children.first
173
- expect { text.path }.to raise_error(Moxml::NotImplementedError, /element and document/)
173
+ expect(text.path).to end_with("/text()")
174
+ expect(doc.at_xpath(text.path)).to eq(text)
174
175
  end
175
176
  end
176
177
 
@@ -3,12 +3,18 @@
3
3
  require "spec_helper"
4
4
  require "moxml/adapter/shared_examples/adapter_contract"
5
5
 
6
- RSpec.describe Moxml::Adapter::Oga, if: RUBY_ENGINE == "opal" do
7
- around do |example|
8
- Moxml.with_config(:oga, true, "UTF-8") do
9
- example.run
6
+ # The describe resolves the adapter constant at load time — under
7
+ # MRI nothing loads the Opal compat shim, so the whole describe must
8
+ # sit behind the engine check (the `if:` metadata alone still
9
+ # evaluates the constant reference).
10
+ if RUBY_ENGINE == "opal"
11
+ RSpec.describe Moxml::Adapter::Oga do
12
+ around do |example|
13
+ Moxml.with_config(:oga, true, "UTF-8") do
14
+ example.run
15
+ end
10
16
  end
11
- end
12
17
 
13
- it_behaves_like "xml adapter"
18
+ it_behaves_like "xml adapter"
19
+ end
14
20
  end
@@ -164,7 +164,11 @@ RSpec.describe "SAX/DOM entity parity" do
164
164
  # double-escaping on round-trip.
165
165
  doc = ctx.create_document
166
166
  el = doc.create_element("doc")
167
- el.add_child("a & b")
167
+ # Programmatically-built: a literal Text node, no markup parse.
168
+ # (add_child with a String operand is MARKUP since the nokogiri
169
+ # compat round — a bare "&" is ill-formed there and each
170
+ # engine recovers differently.)
171
+ el.add_child(doc.create_text("a & b"))
168
172
  doc.add_child(el)
169
173
 
170
174
  serialized = doc.to_xml.sub(/\A<\?[^>]*\?>\s*/, "").strip
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.5.113
4
+ version: 0.5.115
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-10-06 00:00:00.000000000 Z
11
+ date: 2026-10-07 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