moxml 0.2.0 → 0.3.0

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: 1e82117ebd56032bed4f9edffbb2714dec306b7bec52999193ef69fe62835ab9
4
- data.tar.gz: 824fb9a07952249c2c97e650d5610d90c53ee23f279980aa5987e2a53922d74a
3
+ metadata.gz: 51298c0a0ce4ed7c7868e294dc8174ada558b4a1c36c168bc18cdcfe2f728aec
4
+ data.tar.gz: a5e5ffce0b817e397bf98852aecfd9bb341d5154a94540f486432ea32dee8788
5
5
  SHA512:
6
- metadata.gz: 845a7de92860b25e68353e14efee1cc0015abfdc5a32f526b0baf1c45870e2966c1db6560eb45267a2fd0beab62936063493ac559fcca4317feffdcd0e54042a
7
- data.tar.gz: 8242097334c0d1287bcc5c61658042e095ca1a34e375ff034c1504b9607f272937d62bd3ded2c439f45e7d6ae71ea58d86f5af37992858ab9a5fbadbe31fe738
6
+ metadata.gz: bf7d542cb81a21f1cfa517fafa2c07bdd996b9b7665ebf98e91c4e3f87ce79a1e1fa07f0d8f19837f6f45c73c36663ee54d602b70d72228a8de6e1ccb201a764
7
+ data.tar.gz: b971104eebb5bb3383bdacbc895d6cd54f0b4df400126f83f9e8002d80ec3e75c0afc1d3369d2c1277732b64326a2c0c044bb156078fdb6c3bb81a1c9c9fdeab
@@ -25,6 +25,7 @@ require "moxml/doctype"
25
25
  require "moxml/entity_reference"
26
26
  require "moxml/entity_registry"
27
27
  require "moxml/entity"
28
+ require "moxml/entity/restorer"
28
29
  require "moxml/entity/reference"
29
30
  require "moxml/xml_emitter"
30
31
  require "moxml/adapter"
@@ -44,7 +45,6 @@ require "moxml/adapter/customized_oga/raw_value_override"
44
45
  require "moxml/adapter/customized_oga/xml_declaration"
45
46
  require "moxml/adapter/customized_oga/xml_generator"
46
47
  require "moxml/adapter/oga"
47
- require "moxml/document_builder"
48
48
  require "moxml/builder"
49
49
  require "moxml/context"
50
50
  require "moxml/config"
@@ -183,13 +183,6 @@ namespace_validation_mode: :strict)
183
183
  node
184
184
  end
185
185
 
186
- def prepare_for_new_document(node, _target_doc)
187
- # Hook for adapters that need special handling when moving nodes
188
- # between documents (e.g., LibXML's document.import)
189
- # Default: no-op for backward compatibility
190
- node
191
- end
192
-
193
186
  # Check if the native document has an XML declaration
194
187
  # @param native_doc the native document object
195
188
  # @param wrapper [Moxml::Document] the wrapper with has_xml_declaration flag
@@ -8,7 +8,6 @@ module Moxml
8
8
  module Adapter
9
9
  class Libxml < Base
10
10
  autoload :EntityRefRegistry, "moxml/adapter/libxml/entity_ref_registry"
11
- autoload :EntityRestorer, "moxml/adapter/libxml/entity_restorer"
12
11
 
13
12
  # Wrapper class to store DOCTYPE information
14
13
  class DoctypeWrapper
@@ -121,10 +120,7 @@ module Moxml
121
120
 
122
121
  ctx = _context || Context.new(:libxml)
123
122
  # Single parse path: wrap libxml's already-complete C-parsed tree
124
- # directly (same pattern as nokogiri/ox). The previous
125
- # DocumentBuilder.build path walked the entire parsed tree and
126
- # re-added every node to a fresh moxml-managed document, which
127
- # made parse O(N) Ruby work on top of an already-complete parse.
123
+ # directly (same pattern as the other adapters).
128
124
  # Doctype/declaration/PI attachments set above remain on
129
125
  # native_doc, so the serialize path still sees them.
130
126
  #
@@ -134,7 +130,7 @@ module Moxml
134
130
  # path; the restoration walk is just one of potentially several
135
131
  # post-processing steps and doesn't fork the construction.
136
132
  doc = Document.new(native_doc, ctx)
137
- EntityRestorer.new(doc).run if ctx.config.restore_entities
133
+ Entity::Restorer.new(doc).run if ctx.config.restore_entities
138
134
  doc
139
135
  end
140
136
 
@@ -219,7 +215,7 @@ module Moxml
219
215
  return :unknown unless node
220
216
 
221
217
  # Fast path: native libxml nodes are the vast majority during
222
- # parse traversal (DocumentBuilder visits raw libxml children).
218
+ # parse traversal (wrapping visits raw libxml children).
223
219
  # Skip the wrapper checks below for them.
224
220
  if node.is_a?(::LibXML::XML::Node)
225
221
  return NATIVE_NODE_TYPE_MAP[node.node_type] || :unknown
@@ -1035,7 +1031,7 @@ module Moxml
1035
1031
  end
1036
1032
 
1037
1033
  # Shallow duplication: copies the node itself (name, attrs, namespaces)
1038
- # but NOT its descendants. This is what DocumentBuilder needs — it
1034
+ # but NOT its descendants (deep duplication composes this).
1039
1035
  # walks the source tree and re-adds children one at a time via
1040
1036
  # add_child, so a deep copy here would be done only to be stripped
1041
1037
  # by replace_children, then rebuilt — O(N²) waste on parse.
@@ -1061,7 +1057,8 @@ module Moxml
1061
1057
  node
1062
1058
  end
1063
1059
  when :element
1064
- shallow_duplicate_element(native_node)
1060
+ # Public dup contract: a full deep copy of the subtree
1061
+ deep_duplicate_node(native_node)
1065
1062
  when :text
1066
1063
  ::LibXML::XML::Node.new_text(native_node.content)
1067
1064
  when :cdata
@@ -1106,15 +1103,6 @@ module Moxml
1106
1103
  end
1107
1104
  end
1108
1105
 
1109
- def prepare_for_new_document(node, target_doc)
1110
- return node unless node && target_doc
1111
-
1112
- # For LibXML, we need to duplicate ALL nodes to avoid
1113
- # document ownership issues. Simply importing doesn't work
1114
- # because nodes from the parsed document still have references.
1115
- duplicate_node(node)
1116
- end
1117
-
1118
1106
  def has_declaration?(native_doc, wrapper)
1119
1107
  decl = attachments.get(native_doc, :declaration)
1120
1108
  if decl
@@ -40,8 +40,23 @@ module Moxml
40
40
  )
41
41
  end
42
42
 
43
+ # Oga parses multiple root elements leniently; the moxml
44
+ # contract requires a single root. to_a because the vendored
45
+ # fork's NodeSet#count ignores its block.
46
+ roots = native_doc.children.to_a.count do |child|
47
+ child.instance_of?(::Oga::XML::Element)
48
+ end
49
+ if roots > 1
50
+ raise Moxml::ParseError.new(
51
+ "multiple root elements",
52
+ source: xml.is_a?(String) ? xml[0..100] : nil,
53
+ )
54
+ end
55
+
43
56
  ctx = _context || Context.new(:oga)
44
- DocumentBuilder.new(ctx).build(native_doc)
57
+ doc = Document.new(native_doc, ctx)
58
+ Entity::Restorer.new(doc).run if ctx.config.restore_entities
59
+ doc
45
60
  end
46
61
 
47
62
  # SAX parsing implementation for Oga.
@@ -239,6 +254,38 @@ module Moxml
239
254
  node.parent if node.is_a?(::Oga::XML::Node)
240
255
  end
241
256
 
257
+ # Oga's Node#dup shallow-copies the ivars, leaving attributes
258
+ # and children shared with the original; the public dup
259
+ # contract needs a full deep copy.
260
+ def duplicate_node(node)
261
+ case node
262
+ when ::Oga::XML::Element
263
+ copy = ::Oga::XML::Element.new(name: node.name)
264
+ copy.namespace_name = node.namespace_name if node.namespace_name
265
+ node.attributes.each do |attr|
266
+ copy.add_attribute(::Oga::XML::Attribute.new(
267
+ name: attr.name,
268
+ namespace_name: attr.namespace_name,
269
+ value: attr.value,
270
+ ))
271
+ end
272
+ node.children.each do |child|
273
+ copy.children << duplicate_node(child)
274
+ end
275
+ copy
276
+ when ::Oga::XML::Text
277
+ ::Oga::XML::Text.new(text: node.text)
278
+ when ::Oga::XML::Cdata
279
+ ::Oga::XML::Cdata.new(text: node.text)
280
+ when ::Oga::XML::Comment
281
+ ::Oga::XML::Comment.new(text: node.text)
282
+ when ::Oga::XML::ProcessingInstruction
283
+ ::Oga::XML::ProcessingInstruction.new(name: node.name, text: node.text)
284
+ else
285
+ node.dup
286
+ end
287
+ end
288
+
242
289
  def next_sibling(node)
243
290
  node.next
244
291
  end
@@ -41,7 +41,9 @@ module Moxml
41
41
  end
42
42
 
43
43
  ctx = _context || Context.new(:rexml)
44
- DocumentBuilder.new(ctx).build(native_doc)
44
+ doc = Document.new(native_doc, ctx)
45
+ Entity::Restorer.new(doc).run if ctx.config.restore_entities
46
+ doc
45
47
  end
46
48
 
47
49
  def extract_encoding_from_xml(xml)
data/lib/moxml/builder.rb CHANGED
@@ -75,24 +75,6 @@ module Moxml
75
75
  end
76
76
 
77
77
  # Batch element creation
78
- def elements(element_specs)
79
- element_specs.each do |name, content_or_attrs|
80
- if content_or_attrs.is_a?(Hash)
81
- element(name, content_or_attrs)
82
- else
83
- element(name) { text(content_or_attrs) }
84
- end
85
- end
86
- end
87
-
88
- # Helper for creating namespaced elements
89
- def ns_element(namespace_uri, name, attributes = {}, &block)
90
- el = element(name, attributes, &block)
91
- prefix = @namespaces.key(namespace_uri)
92
- el.namespace = { prefix => namespace_uri } if prefix
93
- el
94
- end
95
-
96
78
  # Dynamic element creation DSL.
97
79
  # xml.schema(attrs) { } creates <schema> with those attributes.
98
80
  # Uses yield so blocks preserve the caller's self context.
data/lib/moxml/config.rb CHANGED
@@ -78,7 +78,6 @@ module Moxml
78
78
  attr_reader :adapter_name, :default_line_ending
79
79
  attr_accessor :strict_parsing,
80
80
  :default_encoding,
81
- :entity_encoding,
82
81
  :default_indent,
83
82
  :restore_entities,
84
83
  :preload_entity_sets,
@@ -104,7 +103,6 @@ module Moxml
104
103
  @default_encoding = default_encoding || Config.default.default_encoding
105
104
  @default_indent = 2
106
105
  @default_line_ending = LINE_ENDING_LF
107
- @entity_encoding = :basic
108
106
  @restore_entities = false
109
107
  @preload_entity_sets = []
110
108
  @entity_load_mode = :required
data/lib/moxml/context.rb CHANGED
@@ -10,6 +10,27 @@ module Moxml
10
10
  # compare against it so every wrapper sees changes without
11
11
  # needing wrapper identity.
12
12
  @namespace_scope_generation = 0
13
+ # Native → wrapper identity map: repeated traversals hand back
14
+ # the same wrapper instead of allocating a fresh one per
15
+ # access. Keyed by object identity; re-keyed by
16
+ # Node#refresh_native! when an adapter swaps a native.
17
+ @wrappers = {}.compare_by_identity
18
+ end
19
+
20
+ def wrapper_for(native)
21
+ @wrappers[native]
22
+ end
23
+
24
+ def register_wrapper(native, wrapper)
25
+ # Safety valve: adapters whose natives are recreated per access
26
+ # (nokogiri mints new Ruby objects for the same C node) would
27
+ # grow the map without bound in pathological loops.
28
+ @wrappers.clear if @wrappers.size >= 65_536
29
+ @wrappers[native] = wrapper
30
+ end
31
+
32
+ def unregister_wrapper(native)
33
+ @wrappers.delete(native)
13
34
  end
14
35
 
15
36
  def namespace_scope_generation
@@ -21,7 +21,7 @@ module Moxml
21
21
 
22
22
  def root
23
23
  root_element = adapter.root(@native)
24
- root_element ? Element.new(root_element, context) : nil
24
+ root_element ? Moxml::Node.wrap(root_element, context) : nil
25
25
  end
26
26
 
27
27
  def create_element(name)
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Entity
5
+ # Restores configured character entities into explicit
6
+ # Moxml::Entity::Reference nodes after a native tree has been
7
+ # parsed and wrapped — for adapters whose parser resolves entities
8
+ # during parsing (libxml, and the restore_entities option on
9
+ # rexml/oga).
10
+ class Restorer
11
+ def initialize(doc)
12
+ @doc = doc
13
+ @ctx = doc.context
14
+ @registry = @ctx.entity_registry
15
+ @config = @ctx.config
16
+ @adapter = @ctx.config.adapter
17
+ end
18
+
19
+ def run
20
+ return unless @registry && @doc.root
21
+
22
+ walk(@doc.root)
23
+ end
24
+
25
+ private
26
+
27
+ def walk(element)
28
+ # Snapshot because we may add/remove siblings during the walk.
29
+ element.children.to_a.each do |child|
30
+ if child.is_a?(::Moxml::Text)
31
+ restore_text_node(child)
32
+ elsif child.is_a?(::Moxml::Element)
33
+ walk(child)
34
+ end
35
+ end
36
+ end
37
+
38
+ # Includes the adapter limitation that adjacent native text
39
+ # nodes get merged.
40
+ def restore_text_node(text_node)
41
+ content = text_node.content
42
+ return unless content
43
+
44
+ chunks = chunk_text(content)
45
+ return if chunks.size == 1 && chunks.first.first == :text
46
+
47
+ parent = text_node.parent
48
+ return unless parent
49
+
50
+ text_node.remove
51
+ chunks.each { |type, payload| append_chunk(parent, type, payload) }
52
+ end
53
+
54
+ def chunk_text(content)
55
+ chunks = []
56
+ buffer = +""
57
+ restorable = @registry.restorable_codepoints
58
+
59
+ content.each_char do |char|
60
+ cp = char.ord
61
+ if restorable.include?(cp) &&
62
+ (name = @registry.primary_name_for_codepoint(cp)) &&
63
+ @registry.should_restore?(cp, config: @config)
64
+ unless buffer.empty?
65
+ chunks << [:text, buffer.dup]
66
+ buffer.clear
67
+ end
68
+ chunks << [:eref, name]
69
+ else
70
+ buffer << char
71
+ end
72
+ end
73
+
74
+ chunks << [:text, buffer.dup] unless buffer.empty?
75
+ chunks
76
+ end
77
+
78
+ def append_chunk(parent, type, payload)
79
+ case type
80
+ when :text
81
+ parent.add_child(::Moxml::Text.new(
82
+ @adapter.create_native_text(payload), @ctx
83
+ ))
84
+ when :eref
85
+ parent.add_child(
86
+ ::Moxml::EntityReference.new(
87
+ @adapter.create_native_entity_reference(payload),
88
+ @ctx,
89
+ ),
90
+ )
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
data/lib/moxml/entity.rb CHANGED
@@ -29,6 +29,7 @@ module Moxml
29
29
  # shared, single-sourced parts.
30
30
  module Entity
31
31
  autoload :Reference, "moxml/entity/reference"
32
+ autoload :Restorer, "moxml/entity/restorer"
32
33
 
33
34
  # Marker for adapters that resolve entities during parsing.
34
35
  # U+FFFC (Object Replacement Character) + U+FEFF (BOM) is a
@@ -199,7 +199,7 @@ module Moxml
199
199
  end
200
200
 
201
201
  # Returns the set of codepoints that could potentially be restored as entities.
202
- # Used by DocumentBuilder for O(1) fast-path checks.
202
+ # Used by Entity::Restorer for O(1) fast-path checks.
203
203
  # @return [Set<Integer>]
204
204
  def restorable_codepoints
205
205
  @restorable_codepoints ||= if @by_name.empty?
data/lib/moxml/error.rb CHANGED
@@ -106,27 +106,6 @@ module Moxml
106
106
  end
107
107
  end
108
108
 
109
- # Error raised when serialization fails
110
- class SerializationError < Error
111
- attr_reader :node, :adapter, :format
112
-
113
- def initialize(message, node: nil, adapter: nil, format: nil)
114
- @node = node
115
- @adapter = adapter
116
- @format = format
117
- super(message)
118
- end
119
-
120
- def to_s
121
- msg = super
122
- msg += "\n Node: <#{@node.name}>" if @node.is_a?(Element) || @node.is_a?(Attribute)
123
- msg += "\n Adapter: #{@adapter}" if @adapter
124
- msg += "\n Format: #{@format}" if @format
125
- msg += "\n Hint: Check that the node structure is valid for serialization"
126
- msg
127
- end
128
- end
129
-
130
109
  # Error raised when document structure is invalid
131
110
  class DocumentStructureError < Error
132
111
  attr_reader :attempted_operation, :current_state
@@ -146,27 +125,6 @@ module Moxml
146
125
  end
147
126
  end
148
127
 
149
- # Error raised when attribute operations fail
150
- class AttributeError < Error
151
- attr_reader :attribute_name, :element, :value
152
-
153
- def initialize(message, name: nil, element: nil, value: nil)
154
- @attribute_name = name
155
- @element = element
156
- @value = value
157
- super(message)
158
- end
159
-
160
- def to_s
161
- msg = super
162
- msg += "\n Attribute: #{@attribute_name}" if @attribute_name
163
- msg += "\n Element: <#{@element.name}>" if @element.is_a?(Element)
164
- msg += "\n Value: #{@value.inspect}" if @value
165
- msg += "\n Hint: Verify attribute name follows XML naming rules"
166
- msg
167
- end
168
- end
169
-
170
128
  # Error raised when a feature is not implemented by an adapter
171
129
  class NotImplementedError < Error
172
130
  attr_reader :feature, :adapter
data/lib/moxml/node.rb CHANGED
@@ -21,7 +21,12 @@ module Moxml
21
21
  # Update native reference after identity-changing operations
22
22
  # (e.g., LibXML doc.root= creates a new Ruby wrapper)
23
23
  def refresh_native!(new_native)
24
- @native = new_native
24
+ unless new_native.equal?(@native)
25
+ context.unregister_wrapper(@native)
26
+ @native = new_native
27
+ context.register_wrapper(new_native, self)
28
+ end
29
+ self
25
30
  end
26
31
 
27
32
  def document
@@ -225,6 +230,41 @@ module Moxml
225
230
  NodeSet.new(natives, context)
226
231
  end
227
232
 
233
+ # Returns the siblings after this node, in document order.
234
+ #
235
+ # @return [NodeSet]
236
+ def following_siblings
237
+ parent = self.parent
238
+ return NodeSet.new([], context) unless parent
239
+
240
+ siblings = parent.children.to_a
241
+ index = siblings.index { |child| child.native.equal?(@native) }
242
+ return NodeSet.new([], context) if index.nil?
243
+
244
+ NodeSet.new(siblings[(index + 1)..].map(&:native), context)
245
+ end
246
+
247
+ # Returns the siblings before this node, in document order.
248
+ #
249
+ # @return [NodeSet]
250
+ def preceding_siblings
251
+ parent = self.parent
252
+ return NodeSet.new([], context) unless parent
253
+
254
+ siblings = parent.children.to_a
255
+ index = siblings.index { |child| child.native.equal?(@native) }
256
+ return NodeSet.new([], context) if index.nil?
257
+
258
+ NodeSet.new(siblings[0...index].map(&:native), context)
259
+ end
260
+
261
+ # Deep copy of the node (both dup and clone create deep copies for XML nodes)
262
+ def dup
263
+ Moxml::Node.wrap(adapter.duplicate_node(@native), context)
264
+ end
265
+
266
+ alias clone dup
267
+
228
268
  # Returns an XPath expression that uniquely locates this node within
229
269
  # its document. Positional predicates are emitted only when sibling
230
270
  # elements share the same qualified name, keeping paths minimal.
@@ -276,13 +316,6 @@ module Moxml
276
316
  text.strip.empty?
277
317
  end
278
318
 
279
- # Deep copy of the node (both dup and clone create deep copies for XML nodes)
280
- def dup
281
- Moxml::Node.wrap(adapter.dup(@native), context)
282
- end
283
-
284
- alias clone dup
285
-
286
319
  def ==(other)
287
320
  self.class == other.class && @native == other.native
288
321
  end
@@ -325,10 +358,13 @@ module Moxml
325
358
  def self.wrap(node, context)
326
359
  return nil if node.nil?
327
360
 
361
+ cached = context.wrapper_for(node)
362
+ return cached if cached
363
+
328
364
  type = adapter(context).node_type(node)
329
365
  klass = node_type_map[type] || self
330
366
 
331
- klass.new(node, context)
367
+ klass.new(node, context).tap { |wrapper| context.register_wrapper(node, wrapper) }
332
368
  end
333
369
 
334
370
  # Internal: Set the parent node for cache invalidation tracking.
@@ -64,7 +64,7 @@ module Moxml
64
64
  end
65
65
 
66
66
  def +(other)
67
- self.class.new(@nodes + other.nodes, @context)
67
+ self.class.new(@nodes + other.nodes, @context, @parent_node)
68
68
  end
69
69
 
70
70
  def <<(node)
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.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -294,6 +294,12 @@ module Moxml
294
294
 
295
295
  handler = axis_name.gsub("-", "_")
296
296
 
297
+ unless respond_to?(:"on_axis_#{handler}", true)
298
+ raise XPath::Error.new(
299
+ "XPath axis #{axis_name.inspect} is not supported by this engine",
300
+ )
301
+ end
302
+
297
303
  send(:"on_axis_#{handler}", test, input, &block)
298
304
  end
299
305
 
@@ -362,6 +368,78 @@ module Moxml
362
368
  end
363
369
  end
364
370
 
371
+ # AXIS: ancestor - all ancestors up to the document
372
+ def on_axis_ancestor(ast, input)
373
+ ancestor = unique_literal(:ancestor)
374
+
375
+ document_or_node(input).if_true do
376
+ input.ancestors.each.add_block(ancestor) do
377
+ condition = process(ast, ancestor)
378
+ if block_given?
379
+ condition.if_true { yield ancestor }
380
+ else
381
+ condition.if_true { ancestor }
382
+ end
383
+ end
384
+ end
385
+ end
386
+
387
+ # AXIS: ancestor-or-self - the context node and its ancestors
388
+ def on_axis_ancestor_or_self(ast, input)
389
+ ancestor = unique_literal(:ancestor)
390
+
391
+ document_or_node(input).if_true do
392
+ self_result = process(ast, input)
393
+ self_collection = if block_given?
394
+ self_result.if_true { yield input }
395
+ else
396
+ self_result.if_true { input }
397
+ end
398
+ self_collection.followed_by do
399
+ input.ancestors.each.add_block(ancestor) do
400
+ condition = process(ast, ancestor)
401
+ if block_given?
402
+ condition.if_true { yield ancestor }
403
+ else
404
+ condition.if_true { ancestor }
405
+ end
406
+ end
407
+ end
408
+ end
409
+ end
410
+
411
+ # AXIS: following-sibling - siblings after the context node
412
+ def on_axis_following_sibling(ast, input)
413
+ sibling = unique_literal(:sibling)
414
+
415
+ document_or_node(input).if_true do
416
+ input.following_siblings.each.add_block(sibling) do
417
+ condition = process(ast, sibling)
418
+ if block_given?
419
+ condition.if_true { yield sibling }
420
+ else
421
+ condition.if_true { sibling }
422
+ end
423
+ end
424
+ end
425
+ end
426
+
427
+ # AXIS: preceding-sibling - siblings before the context node
428
+ def on_axis_preceding_sibling(ast, input)
429
+ sibling = unique_literal(:sibling)
430
+
431
+ document_or_node(input).if_true do
432
+ input.preceding_siblings.each.add_block(sibling) do
433
+ condition = process(ast, sibling)
434
+ if block_given?
435
+ condition.if_true { yield sibling }
436
+ else
437
+ condition.if_true { sibling }
438
+ end
439
+ end
440
+ end
441
+ end
442
+
365
443
  # AXIS: descendant-or-self - Enables // operator
366
444
  def on_axis_descendant_or_self(ast, input)
367
445
  node = unique_literal(:descendant)
@@ -623,7 +701,11 @@ module Moxml
623
701
 
624
702
  # Parent node (..)
625
703
  def on_parent(_ast, input)
626
- input.parent
704
+ if block_given?
705
+ yield input.parent
706
+ else
707
+ input.parent
708
+ end
627
709
  end
628
710
 
629
711
  # ===== OPERATORS =====
data/lib/moxml.rb CHANGED
@@ -26,7 +26,6 @@ module Moxml
26
26
  default_indent: original.default_indent,
27
27
  default_line_ending: original.default_line_ending,
28
28
  entity_load_mode: original.entity_load_mode,
29
- entity_encoding: original.entity_encoding,
30
29
  restore_entities: original.restore_entities,
31
30
  namespace_validation_mode: original.namespace_validation_mode,
32
31
  entity_restoration_mode: original.entity_restoration_mode,
@@ -48,7 +47,6 @@ module Moxml
48
47
  config.default_indent = saved_values[:default_indent]
49
48
  config.default_line_ending = saved_values[:default_line_ending]
50
49
  config.entity_load_mode = saved_values[:entity_load_mode]
51
- config.entity_encoding = saved_values[:entity_encoding]
52
50
  config.restore_entities = saved_values[:restore_entities]
53
51
  config.namespace_validation_mode = saved_values[:namespace_validation_mode]
54
52
  config.entity_restoration_mode = saved_values[:entity_restoration_mode]
@@ -82,7 +80,6 @@ module Moxml
82
80
  autoload :Namespace, "moxml/namespace"
83
81
  autoload :Doctype, "moxml/doctype"
84
82
  autoload :EntityReference, "moxml/entity_reference"
85
- autoload :DocumentBuilder, "moxml/document_builder"
86
83
  autoload :Builder, "moxml/builder"
87
84
  autoload :Entity, "moxml/entity"
88
85
  autoload :EntityRegistry, "moxml/entity_registry"
@@ -31,7 +31,7 @@ RSpec.describe "Cross-adapter integration" do
31
31
  "Moxml::EntityReference",
32
32
  "Moxml::Context",
33
33
  "Moxml::Builder",
34
- "Moxml::DocumentBuilder",
34
+ "Moxml::Parse Behavior",
35
35
  "Moxml Integration",
36
36
  "Moxml Edge Cases",
37
37
  "Attribute Examples",
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- RSpec.shared_examples "Moxml::DocumentBuilder" do
3
+ RSpec.shared_examples "Moxml::Parse Behavior" do
4
4
  let(:context) { Moxml.new }
5
- let(:builder) { Moxml::DocumentBuilder.new(context) }
6
5
 
7
6
  describe "#build" do
8
7
  it "builds a document model from native document" do
@@ -106,5 +106,20 @@ RSpec.shared_examples "Moxml::Node" do
106
106
  expect(node.text).to eq("1")
107
107
  end
108
108
  end
109
+
110
+ describe "duplication" do
111
+ it "returns an independent deep copy" do
112
+ doc = context.parse("<root><a id='1'>text</a></root>")
113
+ original = doc.at_xpath("//a")
114
+ copy = original.dup
115
+
116
+ expect(copy).not_to equal(original)
117
+ expect(copy.name).to eq("a")
118
+ expect(copy["id"]).to eq("1")
119
+ expect(copy.text).to eq("text")
120
+ copy["id"] = "changed"
121
+ expect(original["id"]).to eq("1")
122
+ end
123
+ end
109
124
  end
110
125
  end
@@ -154,7 +154,7 @@ RSpec.describe Moxml::Adapter::Libxml do
154
154
  end
155
155
  end
156
156
 
157
- describe Moxml::Adapter::Libxml::EntityRestorer do
157
+ describe Moxml::Entity::Restorer do
158
158
  let(:context) { Moxml.new(:libxml) }
159
159
 
160
160
  it "restores entities through its public entry point" do
@@ -11,7 +11,6 @@ RSpec.describe Moxml::Config do
11
11
  expect(config.default_encoding).to eq("UTF-8")
12
12
  expect(config.default_indent).to eq(2)
13
13
  expect(config.default_line_ending).to eq("\n")
14
- expect(config.entity_encoding).to eq(:basic)
15
14
  end
16
15
 
17
16
  it "sets default entity_load_mode to :required" do
@@ -39,4 +39,26 @@ RSpec.describe Moxml::Context do
39
39
  expect(doc.root.name).to eq("root")
40
40
  end
41
41
  end
42
+
43
+ describe "wrapper identity map" do
44
+ it "hands back the same wrapper for the same native" do
45
+ doc = context.parse("<root><item id='1'/></root>")
46
+ first = doc.xpath("//item").first
47
+ second = doc.xpath("//item").first
48
+ expect(first).to equal(second)
49
+
50
+ again = doc.root.children.last
51
+ expect(again).to equal(first)
52
+ end
53
+
54
+ it "re-keys when refresh_native! swaps the native" do
55
+ doc = context.create_document
56
+ element = doc.create_element("x")
57
+ doc.add_child(element)
58
+
59
+ refreshed = doc.root
60
+ expect(refreshed.name).to eq("x")
61
+ expect(context.wrapper_for(refreshed.native)).to equal(refreshed)
62
+ end
63
+ end
42
64
  end
@@ -150,31 +150,6 @@ RSpec.describe "Moxml errors" do
150
150
  end
151
151
  end
152
152
 
153
- describe Moxml::SerializationError do
154
- it "includes node, adapter, and format information" do
155
- error = described_class.new(
156
- "Failed to serialize",
157
- adapter: "LibXML",
158
- format: "xml",
159
- )
160
- expect(error.adapter).to eq("LibXML")
161
- expect(error.format).to eq("xml")
162
- end
163
-
164
- it "provides helpful error message" do
165
- error = described_class.new(
166
- "Failed to serialize",
167
- adapter: "Ox",
168
- format: "xml",
169
- )
170
- message = error.to_s
171
- expect(message).to include("Failed to serialize")
172
- expect(message).to include("Adapter: Ox")
173
- expect(message).to include("Format: xml")
174
- expect(message).to include("Hint:")
175
- end
176
- end
177
-
178
153
  describe Moxml::DocumentStructureError do
179
154
  it "includes attempted operation and state" do
180
155
  error = described_class.new(
@@ -200,31 +175,6 @@ RSpec.describe "Moxml errors" do
200
175
  end
201
176
  end
202
177
 
203
- describe Moxml::AttributeError do
204
- it "includes attribute name, element, and value" do
205
- error = described_class.new(
206
- "Invalid attribute",
207
- name: "id",
208
- value: 123,
209
- )
210
- expect(error.attribute_name).to eq("id")
211
- expect(error.value).to eq(123)
212
- end
213
-
214
- it "provides helpful error message" do
215
- error = described_class.new(
216
- "Invalid attribute name",
217
- name: "123invalid",
218
- value: "test",
219
- )
220
- message = error.to_s
221
- expect(message).to include("Invalid attribute name")
222
- expect(message).to include("Attribute: 123invalid")
223
- expect(message).to include("Value: \"test\"")
224
- expect(message).to include("Hint:")
225
- end
226
- end
227
-
228
178
  describe Moxml::NotImplementedError do
229
179
  it "includes feature and adapter information" do
230
180
  error = described_class.new(
@@ -205,6 +205,46 @@ RSpec.describe "XPath Axes" do
205
205
  end
206
206
  end
207
207
 
208
+ describe "ancestor and sibling axes" do
209
+ let(:family) do
210
+ context.parse("<root><parent id='p1'><b id='1'/><b id='2'/><c/><d/></parent></root>")
211
+ end
212
+
213
+ def select(expression, node = family)
214
+ ast = Moxml::XPath::Parser.parse(expression)
215
+ proc = Moxml::XPath::Compiler.compile_with_cache(ast)
216
+ proc.call(node).map(&:name)
217
+ end
218
+
219
+ it "selects ancestors with ancestor::" do
220
+ expect(select("//c/ancestor::*")).to eq(%w[parent root])
221
+ end
222
+
223
+ it "includes the context node with ancestor-or-self::" do
224
+ expect(select("//c/ancestor-or-self::*")).to eq(%w[c parent root])
225
+ end
226
+
227
+ it "selects following siblings" do
228
+ expect(select("//c/following-sibling::*")).to eq(%w[d])
229
+ expect(select("//b[@id='1']/following-sibling::b")).to eq(%w[b])
230
+ expect(select("//b[@id='1']/following-sibling::*").size).to eq(3)
231
+ end
232
+
233
+ it "selects preceding siblings" do
234
+ expect(select("//c/preceding-sibling::*")).to eq(%w[b b])
235
+ end
236
+
237
+ it "resolves the bare parent step and steps after it" do
238
+ expect(select("//c/..")).to eq(%w[parent])
239
+ expect(select("//c/../*").size).to eq(4)
240
+ end
241
+
242
+ it "raises a clear error for unimplemented axes" do
243
+ expect { select("//c/following::*") }
244
+ .to raise_error(Moxml::XPath::Error, /not supported/)
245
+ end
246
+ end
247
+
208
248
  describe "descendant-or-self shorthand from an element" do
209
249
  it "selects descendants with .//step" do
210
250
  ast = Moxml::XPath::Parser.parse(".//grandchild")
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.2.0
4
+ version: 0.3.0
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-08-25 00:00:00.000000000 Z
11
+ date: 2026-08-26 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
@@ -151,7 +151,6 @@ files:
151
151
  - lib/moxml/adapter/leptris.rb
152
152
  - lib/moxml/adapter/libxml.rb
153
153
  - lib/moxml/adapter/libxml/entity_ref_registry.rb
154
- - lib/moxml/adapter/libxml/entity_restorer.rb
155
154
  - lib/moxml/adapter/nokogiri.rb
156
155
  - lib/moxml/adapter/oga.rb
157
156
  - lib/moxml/adapter/ox.rb
@@ -187,10 +186,10 @@ files:
187
186
  - lib/moxml/declaration.rb
188
187
  - lib/moxml/doctype.rb
189
188
  - lib/moxml/document.rb
190
- - lib/moxml/document_builder.rb
191
189
  - lib/moxml/element.rb
192
190
  - lib/moxml/entity.rb
193
191
  - lib/moxml/entity/reference.rb
192
+ - lib/moxml/entity/restorer.rb
194
193
  - lib/moxml/entity_reference.rb
195
194
  - lib/moxml/entity_registry.rb
196
195
  - lib/moxml/entity_registry_opal_data.rb
@@ -390,7 +389,7 @@ files:
390
389
  - spec/integration/shared_examples/high_level/.gitkeep
391
390
  - spec/integration/shared_examples/high_level/builder_behavior.rb
392
391
  - spec/integration/shared_examples/high_level/context_behavior.rb
393
- - spec/integration/shared_examples/high_level/document_builder_behavior.rb
392
+ - spec/integration/shared_examples/high_level/parse_behavior.rb
394
393
  - spec/integration/shared_examples/integration_workflows.rb
395
394
  - spec/integration/shared_examples/line_ending_behavior.rb
396
395
  - spec/integration/shared_examples/node_wrappers/.gitkeep
@@ -444,7 +443,6 @@ files:
444
443
  - spec/moxml/declaration_preservation_spec.rb
445
444
  - spec/moxml/declaration_spec.rb
446
445
  - spec/moxml/doctype_spec.rb
447
- - spec/moxml/document_builder_spec.rb
448
446
  - spec/moxml/document_spec.rb
449
447
  - spec/moxml/element_spec.rb
450
448
  - spec/moxml/entity_preservation_spec.rb
@@ -1,94 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Moxml
4
- module Adapter
5
- class Libxml < Base
6
- # Restores configured character entities into explicit Moxml
7
- # EntityReference nodes after LibXML has parsed the native tree.
8
- class EntityRestorer
9
- def initialize(doc, adapter: Libxml)
10
- @doc = doc
11
- @ctx = doc.context
12
- @registry = @ctx.entity_registry
13
- @config = @ctx.config
14
- @adapter = adapter
15
- end
16
-
17
- def run
18
- return unless @registry && @doc.root
19
-
20
- walk(@doc.root)
21
- end
22
-
23
- private
24
-
25
- def walk(element)
26
- # Snapshot because we may add/remove siblings during the walk.
27
- element.children.to_a.each do |child|
28
- if child.is_a?(::Moxml::Text)
29
- restore_text_node(child)
30
- elsif child.is_a?(::Moxml::Element)
31
- walk(child)
32
- end
33
- end
34
- end
35
-
36
- # Matches DocumentBuilder's previous behavior, including the libxml
37
- # limitation that adjacent native text nodes get merged.
38
- def restore_text_node(text_node)
39
- content = text_node.content
40
- return unless content
41
-
42
- chunks = chunk_text(content)
43
- return if chunks.size == 1 && chunks.first.first == :text
44
-
45
- parent = text_node.parent
46
- return unless parent
47
-
48
- text_node.remove
49
- chunks.each { |type, payload| append_chunk(parent, type, payload) }
50
- end
51
-
52
- def chunk_text(content)
53
- chunks = []
54
- buffer = +""
55
- restorable = @registry.restorable_codepoints
56
-
57
- content.each_char do |char|
58
- cp = char.ord
59
- if restorable.include?(cp) &&
60
- (name = @registry.primary_name_for_codepoint(cp)) &&
61
- @registry.should_restore?(cp, config: @config)
62
- unless buffer.empty?
63
- chunks << [:text, buffer.dup]
64
- buffer.clear
65
- end
66
- chunks << [:eref, name]
67
- else
68
- buffer << char
69
- end
70
- end
71
-
72
- chunks << [:text, buffer.dup] unless buffer.empty?
73
- chunks
74
- end
75
-
76
- def append_chunk(parent, type, payload)
77
- case type
78
- when :text
79
- parent.add_child(::Moxml::Text.new(
80
- @adapter.create_native_text(payload), @ctx
81
- ))
82
- when :eref
83
- parent.add_child(
84
- ::Moxml::EntityReference.new(
85
- @adapter.create_native_entity_reference(payload),
86
- @ctx,
87
- ),
88
- )
89
- end
90
- end
91
- end
92
- end
93
- end
94
- end
@@ -1,167 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Moxml
4
- class DocumentBuilder
5
- attr_reader :context
6
-
7
- def initialize(context)
8
- @context = context
9
- @node_stack = []
10
- end
11
-
12
- def build(native_doc)
13
- @current_doc = context.create_document(native_doc)
14
-
15
- # Transfer has_declaration flag if present in attachments
16
- if adapter.attachments.key?(native_doc, :has_declaration)
17
- has_declaration = adapter.attachments.get(native_doc, :has_declaration)
18
- @current_doc.has_xml_declaration = has_declaration
19
- end
20
-
21
- # Transfer DOCTYPE from parsed document if it exists in attachments
22
- if adapter.attachments.key?(native_doc, :doctype)
23
- doctype = adapter.attachments.get(native_doc, :doctype)
24
- if doctype
25
- adapter.attachments.set(@current_doc.native, :doctype, doctype)
26
- end
27
- end
28
-
29
- visit_node(native_doc)
30
- @current_doc
31
- end
32
-
33
- private
34
-
35
- def visit_node(node)
36
- case node_type(node)
37
- when :document then visit_document(node)
38
- when :element then visit_element(node)
39
- when :text then visit_text(node)
40
- when :cdata then visit_cdata(node)
41
- when :comment then visit_comment(node)
42
- when :processing_instruction then visit_processing_instruction(node)
43
- when :doctype then visit_doctype(node)
44
- when :entity_reference then visit_entity_reference(node)
45
- end
46
- end
47
-
48
- def visit_document(doc)
49
- @node_stack.push(@current_doc)
50
- visit_children(doc)
51
- @node_stack.clear
52
- end
53
-
54
- def visit_element(node)
55
- childless_node = adapter.duplicate_node(node)
56
- adapter.replace_children(childless_node, [])
57
- # Prepare node for new document (LibXML needs this)
58
- childless_node = adapter.prepare_for_new_document(
59
- childless_node,
60
- @current_doc.native,
61
- )
62
- element = Element.new(childless_node, context)
63
- @node_stack.last.add_child(element)
64
-
65
- @node_stack.push(element) # add a parent for its children
66
- visit_children(node)
67
- @node_stack.pop # remove the parent
68
- end
69
-
70
- def visit_text(node)
71
- # Prepare node for new document before wrapping
72
- prepared = adapter.prepare_for_new_document(node, @current_doc.native)
73
- content = adapter.text_content(node)
74
-
75
- # Check if we should restore entity references for this text
76
- if context.config.restore_entities && text_has_restorable_entities?(content)
77
- restore_entities_in_text(content)
78
- else
79
- @node_stack.last&.add_child(Text.new(prepared, context))
80
- end
81
- end
82
-
83
- def text_has_restorable_entities?(content)
84
- return false unless content
85
-
86
- registry = context.entity_registry
87
- codepoints = registry.restorable_codepoints
88
- content.each_char do |char|
89
- return true if codepoints.include?(char.ord)
90
- end
91
- false
92
- end
93
-
94
- def restore_entities_in_text(content)
95
- parent = @node_stack.last
96
- return unless parent
97
-
98
- registry = context.entity_registry
99
- config = context.config
100
- buffer = +""
101
-
102
- content.to_s.each_char do |char|
103
- codepoint = char.ord
104
- name = registry.primary_name_for_codepoint(codepoint)
105
-
106
- if name && registry.should_restore?(codepoint, config: config)
107
- # Flush buffered text before the entity
108
- unless buffer.empty?
109
- parent.add_child(Text.new(adapter.create_text(buffer), context))
110
- buffer.clear
111
- end
112
- parent.add_child(
113
- EntityReference.new(adapter.create_entity_reference(name), context),
114
- )
115
- else
116
- buffer << char
117
- end
118
- end
119
-
120
- # Flush remaining buffer
121
- unless buffer.empty?
122
- parent.add_child(Text.new(adapter.create_text(buffer), context))
123
- end
124
- end
125
-
126
- def visit_cdata(node)
127
- prepared = adapter.prepare_for_new_document(node, @current_doc.native)
128
- @node_stack.last&.add_child(Cdata.new(prepared, context))
129
- end
130
-
131
- def visit_comment(node)
132
- prepared = adapter.prepare_for_new_document(node, @current_doc.native)
133
- @node_stack.last&.add_child(Comment.new(prepared, context))
134
- end
135
-
136
- def visit_processing_instruction(node)
137
- prepared = adapter.prepare_for_new_document(node, @current_doc.native)
138
- @node_stack.last&.add_child(ProcessingInstruction.new(prepared, context))
139
- end
140
-
141
- def visit_doctype(node)
142
- prepared = adapter.prepare_for_new_document(node, @current_doc.native)
143
- @node_stack.last&.add_child(Doctype.new(prepared, context))
144
- end
145
-
146
- def visit_entity_reference(node)
147
- prepared = adapter.prepare_for_new_document(node, @current_doc.native)
148
- @node_stack.last&.add_child(EntityReference.new(prepared, context))
149
- end
150
-
151
- def visit_children(node)
152
- children(node).each { |child| visit_node(child) }
153
- end
154
-
155
- def node_type(node)
156
- context.config.adapter.node_type(node)
157
- end
158
-
159
- def children(node)
160
- context.config.adapter.children(node)
161
- end
162
-
163
- def adapter
164
- context.config.adapter
165
- end
166
- end
167
- end
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "spec_helper"
4
-
5
- # DocumentBuilder is an internal class for parsing
6
- # These tests verify it correctly builds Moxml document from native documents
7
- RSpec.describe Moxml::DocumentBuilder do
8
- let(:context) { Moxml.new }
9
-
10
- describe "#build" do
11
- it "builds a Moxml document from native document" do
12
- native_doc = context.parse("<root><child>content</child></root>").native
13
- builder = described_class.new(context)
14
- doc = builder.build(native_doc)
15
-
16
- expect(doc).to be_a(Moxml::Document)
17
- expect(doc.root.name).to eq("root")
18
- end
19
- end
20
-
21
- describe "element handling" do
22
- it "handles nested elements" do
23
- native_doc = context.parse("<parent><child1/><child2/></parent>").native
24
- builder = described_class.new(context)
25
- doc = builder.build(native_doc)
26
-
27
- expect(doc.root.children.length).to eq(2)
28
- end
29
- end
30
- end