moxml 0.2.0 → 0.4.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: d2a6cac576f8bc4819d27a22825f7ab11fd35a004113c70d6c4986adf542d844
4
+ data.tar.gz: 8f7cbfa869296d65002a6d2b74399ede57b16c2bcc25f4c4c7e9ccab611f5be6
5
5
  SHA512:
6
- metadata.gz: 845a7de92860b25e68353e14efee1cc0015abfdc5a32f526b0baf1c45870e2966c1db6560eb45267a2fd0beab62936063493ac559fcca4317feffdcd0e54042a
7
- data.tar.gz: 8242097334c0d1287bcc5c61658042e095ca1a34e375ff034c1504b9607f272937d62bd3ded2c439f45e7d6ae71ea58d86f5af37992858ab9a5fbadbe31fe738
6
+ metadata.gz: e5b6bf4775a56f4d4fd18cea369d84463085c4df14bdef0ee0ad818ff734864e658bfc41c81bf0e6989cdff2b4ffdff69c74bfc512a7b2debecadd7a892432b6
7
+ data.tar.gz: daf1b85571460bbde0553a52603f255577918dd9eb2e9faf37cb156738277bef51d5bfadc207b74d17177d3f281d9ae4ce4b2db571e22ad112bb595291d124c8
@@ -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
@@ -222,6 +215,16 @@ namespace_validation_mode: :strict)
222
215
  child_native
223
216
  end
224
217
 
218
+ # Whether `Node.wrap` may safely memoize the wrapper for this
219
+ # native across calls. Adapters whose parser hands back the
220
+ # same Ruby object for the same logical node (nokogiri, ox,
221
+ # oga, rexml, leptris) opt in (default true). Adapters that
222
+ # mint a new Ruby object per access (libxml) opt out so the
223
+ # identity map does not accumulate dead entries.
224
+ def wrappers_recyclable?
225
+ true
226
+ end
227
+
225
228
  # Returns all namespaces in scope for this element, including
226
229
  # inherited from ancestors. Adapters with native support (Nokogiri)
227
230
  # override this. Default walks the ancestor chain.
@@ -559,8 +559,16 @@ module Moxml
559
559
  # @return [Array, Object, nil] native results, or nil when the
560
560
  # query must run on the Ruby engine
561
561
  def native_xpath(node, expression, namespaces, first_only: false)
562
- return nil unless node.is_a?(::Leptris::XML::Document)
562
+ return nil unless native_context_node?(node)
563
563
  return nil unless native_expression?(expression)
564
+ # The binding reports the root element parentless while moxml
565
+ # roots it at the document, so parent-axis queries from the
566
+ # root element keep the Ruby engine.
567
+ if node.is_a?(::Leptris::XML::Element) &&
568
+ node.document&.root.equal?(node) &&
569
+ expression_uses_parent_axis?(expression)
570
+ return nil
571
+ end
564
572
 
565
573
  compiled = NATIVE_XPATH_CACHE.get_or_set(expression) do
566
574
  ::Leptris::XML::XPath.compile(expression)
@@ -584,6 +592,27 @@ module Moxml
584
592
  nil
585
593
  end
586
594
 
595
+ def native_context_node?(node)
596
+ node.is_a?(::Leptris::XML::Document) ||
597
+ node.is_a?(::Leptris::XML::Element)
598
+ end
599
+
600
+ def expression_uses_parent_axis?(expression)
601
+ ast = XPath::Parser.parse_with_cache(expression)
602
+ contains_parent_axis?(ast)
603
+ rescue XPath::SyntaxError
604
+ true
605
+ end
606
+
607
+ def contains_parent_axis?(ast)
608
+ return true if ast.type == :parent
609
+ return true if ast.type == :axis && ast.children.first == "parent"
610
+
611
+ ast.children.any? do |child|
612
+ child.is_a?(XPath::AST::Node) && contains_parent_axis?(child)
613
+ end
614
+ end
615
+
587
616
  # Document-context queries without variable references,
588
617
  # attribute-node results, or the nokogiri-compat xmlns:
589
618
  # reserved prefix convention.
@@ -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
@@ -68,6 +67,13 @@ module Moxml
68
67
  doc.root = element
69
68
  end
70
69
 
70
+ # libxml-ruby mints a fresh Ruby object for the same C node on
71
+ # each access; the wrapper identity map would accumulate dead
72
+ # entries without bound at full mutation throughput.
73
+ def wrappers_recyclable?
74
+ false
75
+ end
76
+
71
77
  def parse(xml, options = {}, _context = nil)
72
78
  # LibXML doesn't preserve DOCTYPE during parsing, so we need to extract it manually
73
79
  xml_string = if xml.is_a?(String)
@@ -121,10 +127,7 @@ module Moxml
121
127
 
122
128
  ctx = _context || Context.new(:libxml)
123
129
  # 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.
130
+ # directly (same pattern as the other adapters).
128
131
  # Doctype/declaration/PI attachments set above remain on
129
132
  # native_doc, so the serialize path still sees them.
130
133
  #
@@ -134,7 +137,7 @@ module Moxml
134
137
  # path; the restoration walk is just one of potentially several
135
138
  # post-processing steps and doesn't fork the construction.
136
139
  doc = Document.new(native_doc, ctx)
137
- EntityRestorer.new(doc).run if ctx.config.restore_entities
140
+ Entity::Restorer.new(doc).run if ctx.config.restore_entities
138
141
  doc
139
142
  end
140
143
 
@@ -219,7 +222,7 @@ module Moxml
219
222
  return :unknown unless node
220
223
 
221
224
  # Fast path: native libxml nodes are the vast majority during
222
- # parse traversal (DocumentBuilder visits raw libxml children).
225
+ # parse traversal (wrapping visits raw libxml children).
223
226
  # Skip the wrapper checks below for them.
224
227
  if node.is_a?(::LibXML::XML::Node)
225
228
  return NATIVE_NODE_TYPE_MAP[node.node_type] || :unknown
@@ -1010,18 +1013,17 @@ module Moxml
1010
1013
 
1011
1014
  if native_node.root
1012
1015
  indent_size = options[:indent].is_a?(Integer) && options[:indent].positive? ? options[:indent] : 0
1013
- # Custom serializer emits newlines AND indentation directly —
1014
- # no separate add_newlines_to_xml / indent_xml passes.
1015
1016
  # `eref_active` is computed once here and threaded through the
1016
1017
  # recursion so that the per-element `attachments.key?` Monitor
1017
1018
  # sync only fires for docs that actually have entity refs.
1018
1019
  eref_active = entity_ref_registry(native_node).active?
1019
- root_output = serialize_element_with_namespaces(
1020
+ root_output = native_root_output(native_node, indent_size)
1021
+ root_output ||= serialize_element_with_namespaces(
1020
1022
  native_node.root,
1021
- true,
1022
- indent_size,
1023
- 0,
1024
- eref_active: eref_active,
1023
+ true,
1024
+ indent_size,
1025
+ 0,
1026
+ eref_active: eref_active,
1025
1027
  )
1026
1028
 
1027
1029
  output << "\n" << root_output unless output.empty?
@@ -1034,8 +1036,48 @@ module Moxml
1034
1036
  end
1035
1037
  end
1036
1038
 
1039
+ # Possessive-quantifier form: no backtracking, so pathological
1040
+ # attribute content cannot blow up the expansion pass.
1041
+ EMPTY_ELEMENT_EXPANSION_RE = %r{
1042
+ <([A-Za-z_][\w.:-]*+)
1043
+ ((?:"[^"]*+"|'[^']*+'|[^<>"'/]++)*+)
1044
+ />
1045
+ }x
1046
+ private_constant :EMPTY_ELEMENT_EXPANSION_RE
1047
+
1048
+ # Serialize the root subtree with libxml's C serializer plus
1049
+ # moxml-canonical corrections — roughly 25x faster and ~2600x
1050
+ # fewer allocations than the Ruby walker. Returns nil whenever
1051
+ # a guard says the walker is still required.
1052
+ def native_root_output(native_doc, indent_size)
1053
+ return nil unless indent_size == 2
1054
+ return nil if entity_ref_registry(native_doc).active?
1055
+
1056
+ output = native_doc.root.to_s
1057
+
1058
+ # The walker strips prefixed names on parsed namespaced
1059
+ # children; that behavior is load-bearing, so namespaced
1060
+ # output keeps the walker.
1061
+ return nil if output.include?("xmlns")
1062
+
1063
+ # Layout: pull closing tags onto the last child's line
1064
+ output = output.gsub(/\n[ \t]*(<\/[\w.:-]+>)/, '\1')
1065
+
1066
+ if output.include?("/>")
1067
+ # A literal "/>" inside a comment or CDATA section would be
1068
+ # falsely expanded — the segment-aware walker is correct here
1069
+ return nil if output.include?("<!--") || output.include?("<![CDATA[")
1070
+
1071
+ output = output.gsub(EMPTY_ELEMENT_EXPANSION_RE, '<\1\2></\1>')
1072
+ end
1073
+
1074
+ # Native escapes attribute apostrophes; moxml keeps them literal
1075
+ output.gsub("&apos;", "'")
1076
+
1077
+ end
1078
+
1037
1079
  # Shallow duplication: copies the node itself (name, attrs, namespaces)
1038
- # but NOT its descendants. This is what DocumentBuilder needs — it
1080
+ # but NOT its descendants (deep duplication composes this).
1039
1081
  # walks the source tree and re-adds children one at a time via
1040
1082
  # add_child, so a deep copy here would be done only to be stripped
1041
1083
  # by replace_children, then rebuilt — O(N²) waste on parse.
@@ -1061,7 +1103,8 @@ module Moxml
1061
1103
  node
1062
1104
  end
1063
1105
  when :element
1064
- shallow_duplicate_element(native_node)
1106
+ # Public dup contract: a full deep copy of the subtree
1107
+ deep_duplicate_node(native_node)
1065
1108
  when :text
1066
1109
  ::LibXML::XML::Node.new_text(native_node.content)
1067
1110
  when :cdata
@@ -1106,15 +1149,6 @@ module Moxml
1106
1149
  end
1107
1150
  end
1108
1151
 
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
1152
  def has_declaration?(native_doc, wrapper)
1119
1153
  decl = attachments.get(native_doc, :declaration)
1120
1154
  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,30 @@ 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 + adapter opt-in. Adapters whose natives are
26
+ # recreated per access (libxml mints fresh Ruby objects for the
27
+ # same C node) opt out so the map does not accumulate dead
28
+ # entries; the default is opt-in.
29
+ return if @config&.adapter&.wrappers_recyclable? == false
30
+
31
+ @wrappers.clear if @wrappers.size >= 65_536
32
+ @wrappers[native] = wrapper
33
+ end
34
+
35
+ def unregister_wrapper(native)
36
+ @wrappers.delete(native)
13
37
  end
14
38
 
15
39
  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)