Module: Moxml::NodeBehavior

Includes:
Enumerable, XmlUtils
Included in:
Node
Defined in:
lib/moxml/node.rb

Overview

Instance behavior for Moxml::Node, extracted so the leptris adapter can extend natives with it in place (issue #230); the class remains the consumer-facing contract and the wrap factory.

Constant Summary collapse

TYPES =
%i[
  element text cdata comment processing_instruction document
  declaration doctype namespace attribute unknown entity_reference
].freeze

Constants included from XmlUtils

XmlUtils::VALID_ELEMENT_NAMES

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from XmlUtils

#encode_entities, #normalize_xml_value, #validate_comment_content, #validate_declaration_encoding, #validate_declaration_standalone, #validate_declaration_version, #validate_element_name, #validate_entity_reference_name, #validate_pi_target, #validate_prefix, #validate_uri

Instance Attribute Details

#contextObject (readonly)

Returns the value of attribute context.



16
17
18
# File 'lib/moxml/node.rb', line 16

def context
  @context
end

#nativeObject (readonly)

Returns the value of attribute native.



16
17
18
# File 'lib/moxml/node.rb', line 16

def native
  @native
end

#parent_nodeObject

Internal: Set the parent node for cache invalidation tracking. Called by NodeSet, Document, Element when establishing parent-child relationships. Public to allow cross-class usage within Moxml internals.



466
467
468
# File 'lib/moxml/node.rb', line 466

def parent_node
  @parent_node
end

Instance Method Details

#==(other) ⇒ Object



429
430
431
432
433
434
435
# File 'lib/moxml/node.rb', line 429

def ==(other)
  # Native equality goes through the adapter: engines with a
  # native read layer hand out two wrapper classes over one C
  # node, and raw native == is false across that seam.
  other.is_a?(Moxml::Node) &&
    adapter.same_node?(@native, other.native)
end

#adapterObject



468
469
470
471
472
# File 'lib/moxml/node.rb', line 468

def adapter
  # A context's adapter object is fixed for its lifetime; the
  # chain deref ran on every node access.
  @adapter ||= context.config.adapter
end

#add_child(node) ⇒ Object



91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/moxml/node.rb', line 91

def add_child(node)
  node = prepare_node(node)
  adapter.add_child(@native, node.native)
  # Refresh native in case adapter changed identity (e.g., LibXML
  # doc.root=); stable-identity adapters skip the round trip.
  unless adapter.native_identity_stable?
    refreshed = adapter.actual_native(node.native, @native)
    node.refresh_native!(refreshed) if refreshed && refreshed != node.native
  end
  node.parent_node = self
  # The adopted subtree's in-scope namespaces changed
  node.invalidate_namespace_cache!
  invalidate_children_cache!
  self
end

#add_next_sibling(node) ⇒ Object



114
115
116
117
118
119
# File 'lib/moxml/node.rb', line 114

def add_next_sibling(node)
  node = prepare_node(node)
  adapter.add_next_sibling(@native, node.native)
  invalidate_parent_children_cache!
  self
end

#add_previous_sibling(node) ⇒ Object



107
108
109
110
111
112
# File 'lib/moxml/node.rb', line 107

def add_previous_sibling(node)
  node = prepare_node(node)
  adapter.add_previous_sibling(@native, node.native)
  invalidate_parent_children_cache!
  self
end

#after(node) ⇒ Object



421
422
423
# File 'lib/moxml/node.rb', line 421

def after(node)
  add_next_sibling(node)
end

#ancestorsNodeSet

Returns all ancestor nodes from the parent up to and including the document node.

Returns:

  • ancestors ordered nearest-first



299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'lib/moxml/node.rb', line 299

def ancestors
  return NodeSet.new([], context) if document?

  natives = []
  current = parent
  while current
    natives << current.native
    break if current.document?

    current = current.parent
  end
  NodeSet.new(natives, context)
end

#at_xpath(expression, namespaces = {}) ⇒ Object



202
203
204
205
# File 'lib/moxml/node.rb', line 202

def at_xpath(expression, namespaces = {})
  Moxml::Node.wrap(adapter.at_xpath(@native, expression, namespaces),
                   context)
end

#before(node) ⇒ Object



417
418
419
# File 'lib/moxml/node.rb', line 417

def before(node)
  add_previous_sibling(node)
end

#blank?Boolean

Returns:



425
426
427
# File 'lib/moxml/node.rb', line 425

def blank?
  text.strip.empty?
end

#childrenObject



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/moxml/node.rb', line 65

def children
  @children ||= begin
    # The wrapper's entity memo decides the marker split; the
    # adapter would otherwise re-derive it per call (a C parent
    # climb on the native layer). The kwarg rides along only
    # for adapters that accept it — downstream overrides with
    # the pre-0.5.36 one-argument signature raise ArgumentError
    # otherwise (issue #218).
    natives = if adapter.children_accepts_entity_flag?
                adapter.children(@native, entity_bearing: entity_bearing?)
              else
                adapter.children(@native)
              end
    natives = natives.map { adapter.patch_node(_1, @native) } if adapter.patches_children?
    NodeSet.new(natives, context, self)
  end
end

#clear_native_memo!Object

Wrapper-local memos derived from the native (name, attribute reads) die with the native they were computed from.



51
52
53
54
55
# File 'lib/moxml/node.rb', line 51

def clear_native_memo!
  @name = nil
  @attribute_cache = nil
  @entity_bearing_gen = nil
end

#contentObject

Returns the content/value of this node as a string. Each subclass overrides this with type-specific semantics:

  • Text, Comment, Cdata: raw text content
  • ProcessingInstruction: instruction content
  • Attribute: attribute value
  • Element: delegates to text (descendant text concatenation)


243
244
245
# File 'lib/moxml/node.rb', line 243

def content
  ""
end

#descendantsNodeSet

Returns all descendant nodes (children, grandchildren, and so on), excluding the node itself.

Returns:

  • descendants in document order



317
318
319
320
321
# File 'lib/moxml/node.rb', line 317

def descendants
  natives = []
  each_node { |node| natives << node.native }
  NodeSet.new(natives, context)
end

#digest(drop_ws_text: false) ⇒ Integer?

Content-defined Merkle digest of this subtree (issue #173, companion to leptris#869): a u64 Integer where the backend computes one, nil everywhere else. Consumers gate on nil and fall back to walking. Equal digests imply subtree equivalence under the flag set; unequal digests imply nothing (descend). drop_ws_text skips whitespace-only text nodes.

Returns:



409
410
411
# File 'lib/moxml/node.rb', line 409

def digest(drop_ws_text: false)
  adapter.digest(@native, drop_ws_text: drop_ws_text)
end

#documentObject



57
58
59
# File 'lib/moxml/node.rb', line 57

def document
  Moxml::Node.wrap(adapter.document(@native), context)
end

#dupObject Also known as: clone

Deep copy of the node (both dup and clone create deep copies for XML nodes)



352
353
354
# File 'lib/moxml/node.rb', line 352

def dup
  Moxml::Node.wrap(adapter.duplicate_node(@native), context)
end

#each(&block) ⇒ Object

Yield direct children, enabling Enumerable on the node.



289
290
291
292
293
# File 'lib/moxml/node.rb', line 289

def each(&block)
  return to_enum(:each) unless block

  children.each(&block)
end

#each_node(&block) ⇒ Object

Recursively yield all descendant nodes Used by XPath descendant-or-self and descendant axes



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/moxml/node.rb', line 268

def each_node(&block)
  unless block
    # Eager materialization: the leptris C-side walk rb_yields
    # from the C callback and segfaults across fiber-suspended
    # enumerator frames, so the no-block form never enters it.
    nodes = []
    each_node { |node| nodes << node }
    return nodes.each
  end

  # Adapters with a C-side subtree walk take it in one
  # dispatch; the recursive children walk stays the fallback.
  return if adapter.walk_descendants(@native, context, &block)

  children.each do |child|
    yield child
    child.each_node(&block)
  end
end

#entity_bearing?Boolean

Memoized against the adapter's serialize generation — the entity-marker flag flips at parse and entity-reference mint, both adapter-level, and the generation bump is the invalidation signal. Adapters with static answers (base class) never bump. The adapter resolves the owning document itself (doc_for is a single C read on the native layer); wrappers no longer climb.

Returns:



169
170
171
172
173
174
175
176
177
# File 'lib/moxml/node.rb', line 169

def entity_bearing?
  gen = adapter.serialize_generation
  if @entity_bearing_gen == gen
    @entity_bearing
  else
    @entity_bearing_gen = gen
    @entity_bearing = adapter.entity_bearing?(@native)
  end
end

#find(xpath_expression, namespaces = {}) ⇒ Object

Convenience find methods (aliases for xpath methods)



208
209
210
# File 'lib/moxml/node.rb', line 208

def find(xpath_expression, namespaces = {})
  at_xpath(xpath_expression, namespaces)
end

#find_all(xpath_expression, namespaces = {}) ⇒ Object



212
213
214
# File 'lib/moxml/node.rb', line 212

def find_all(xpath_expression, namespaces = {})
  xpath(xpath_expression, namespaces).to_a
end

#first_childObject

Get first/last child



222
223
224
# File 'lib/moxml/node.rb', line 222

def first_child
  children.first
end

#following_siblingsNodeSet

Returns the siblings after this node, in document order.

Returns:



326
327
328
329
330
331
332
333
334
335
# File 'lib/moxml/node.rb', line 326

def following_siblings
  parent = self.parent
  return NodeSet.new([], context) unless parent

  siblings = parent.children.to_a
  index = siblings.index { |child| child.native.equal?(@native) }
  return NodeSet.new([], context) if index.nil?

  NodeSet.new(siblings[(index + 1)..].map(&:native), context)
end

#has_children?Boolean

Check if node has any children

Returns:



217
218
219
# File 'lib/moxml/node.rb', line 217

def has_children?
  !children.empty?
end

#identifierString?

Returns the primary identifier for this node type For Element: the tag name For Attribute: the attribute name For ProcessingInstruction: the target For content nodes (Text, Comment, Cdata, Declaration): nil (no identifier) For Doctype: nil (not fully implemented across adapters)

Returns:

  • the node's primary identifier or nil



459
460
461
# File 'lib/moxml/node.rb', line 459

def identifier
  nil
end

#initialize(native, context, adapter = nil, node_type = nil) ⇒ Object

adapter/node_type are primed by Node.wrap, which resolves both before choosing the wrapper class — a fresh wrapper would otherwise pay the context hop and the type probe again on its first access.



22
23
24
# File 'lib/moxml/node.rb', line 22

def initialize(native, context, adapter = nil, node_type = nil)
  prime_contract(native, context, adapter, node_type)
end

#invalidate_namespace_cache!Object

Namespace-scope caches live on Element; the base no-op lets tree mutations invalidate uniformly without type checks.



132
# File 'lib/moxml/node.rb', line 132

def invalidate_namespace_cache!; end

#last_childObject



226
227
228
# File 'lib/moxml/node.rb', line 226

def last_child
  children.last
end

#line_numberInteger?

Returns the 1-based line number where this node appears in the source XML, or nil when the underlying adapter does not track source positions.

Returns:



389
390
391
# File 'lib/moxml/node.rb', line 389

def line_number
  adapter.line_number(@native)
end

#materialize(&block) ⇒ Object

Flattened post-order records for this subtree without allocating wrappers — see Moxml::Materializer (issue #132). Returns an Enumerator when no block is given.



190
191
192
# File 'lib/moxml/node.rb', line 190

def materialize(&block)
  Materializer.materialize(self, &block)
end

#materialize_fields(&block) ⇒ Object

Zero-allocation streaming form — flat reused buffers valid only inside the block (issue #143). See Moxml::Materializer.

Raises:



196
197
198
199
200
# File 'lib/moxml/node.rb', line 196

def materialize_fields(&block)
  raise ArgumentError, "materialize_fields requires a block" unless block

  Materializer.materialize_fields(self, &block)
end

#namespaceObject

Returns the namespace of this node Only applicable to Element nodes, returns nil for others



249
250
251
252
253
254
# File 'lib/moxml/node.rb', line 249

def namespace
  return nil unless element?

  ns = adapter.namespace(@native)
  ns && Wrappers::Namespace.new(ns, context)
end

#namespacesObject

Returns all namespace definitions on this node Only applicable to Element nodes, returns empty array for others



258
259
260
261
262
263
264
# File 'lib/moxml/node.rb', line 258

def namespaces
  return [] unless element?

  adapter.namespace_definitions(@native).map do |ns|
    Wrappers::Namespace.new(ns, context)
  end
end

#next_siblingObject



83
84
85
# File 'lib/moxml/node.rb', line 83

def next_sibling
  Moxml::Node.wrap(adapter.next_sibling(@native), context)
end

#outer_xmlObject



413
414
415
# File 'lib/moxml/node.rb', line 413

def outer_xml
  to_xml
end

#parentObject



61
62
63
# File 'lib/moxml/node.rb', line 61

def parent
  Moxml::Node.wrap(adapter.parent(@native), context)
end

#pathString

Returns an XPath expression that uniquely locates this node within its document. Positional predicates are emitted only when sibling elements share the same qualified name, keeping paths minimal.

Returns:

  • XPath expression

Raises:

  • for node types other than element and document



365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
# File 'lib/moxml/node.rb', line 365

def path
  return "/" if document?

  unless element?
    raise Moxml::NotImplementedError.new(
      "path is only supported for element and document nodes",
      feature: "path",
    )
  end

  segments = []
  current = self
  while current && !current.document?
    segments.unshift(path_segment_for(current))
    current = current.parent
  end
  "/#{segments.join('/')}"
end

#preceding_siblingsNodeSet

Returns the siblings before this node, in document order.

Returns:



340
341
342
343
344
345
346
347
348
349
# File 'lib/moxml/node.rb', line 340

def preceding_siblings
  parent = self.parent
  return NodeSet.new([], context) unless parent

  siblings = parent.children.to_a
  index = siblings.index { |child| child.native.equal?(@native) }
  return NodeSet.new([], context) if index.nil?

  NodeSet.new(siblings[0...index].map(&:native), context)
end

#previous_siblingObject



87
88
89
# File 'lib/moxml/node.rb', line 87

def previous_sibling
  Moxml::Node.wrap(adapter.previous_sibling(@native), context)
end

#prime_contract(native, context, adapter = nil, node_type = nil) ⇒ Object

Update native reference after identity-changing operations (e.g., LibXML doc.root= creates a new Ruby wrapper) Contract priming shared by the constructor and the extend-in-place mint (#230): an extended native IS its own @native.



30
31
32
33
34
35
36
37
# File 'lib/moxml/node.rb', line 30

def prime_contract(native, context, adapter = nil, node_type = nil)
  @context = context
  @native = native
  @parent_node = nil
  @adapter = adapter
  @node_type_cached = node_type
  self
end

#refresh_native!(new_native) ⇒ Object



39
40
41
42
43
44
45
46
47
# File 'lib/moxml/node.rb', line 39

def refresh_native!(new_native)
  unless new_native.equal?(@native)
    context.unregister_wrapper(@native)
    @native = new_native
    context.register_wrapper(new_native, self)
    clear_native_memo!
  end
  self
end

#removeObject



121
122
123
124
125
126
127
128
# File 'lib/moxml/node.rb', line 121

def remove
  invalidate_parent_children_cache!
  adapter.remove(@native)
  invalidate_children_cache!
  # The detached subtree left its declaring ancestors behind
  invalidate_namespace_cache!
  self
end

#replace(node) ⇒ Object



134
135
136
137
138
139
140
# File 'lib/moxml/node.rb', line 134

def replace(node)
  node = prepare_node(node)
  invalidate_parent_children_cache!
  adapter.replace(@native, node.native)
  invalidate_children_cache!
  self
end

#source_positionObject

col_start, col_end where the engine exposes source positions (leptris 1.9.181+); nil elsewhere. Created nodes answer zeros upstream — distinguishable from nil by callers that care.



397
398
399
# File 'lib/moxml/node.rb', line 397

def source_position
  adapter.source_position(@native)
end

#textObject

Returns the text content of this node Subclasses should override this method Element and Text have their own implementations



233
234
235
# File 'lib/moxml/node.rb', line 233

def text
  ""
end

#to_xml(options = {}) ⇒ Object



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/moxml/node.rb', line 142

def to_xml(options = {})
  # Determine if we should include XML declaration
  # For Document nodes: check native then wrapper, unless explicitly overridden
  # For other nodes: default to no declaration unless explicitly set
  serialize_options = if options.empty? && !is_a?(Document)
                        context.default_element_serialize_options
                      else
                        merged = context.default_serialize_options.merge(options)
                        merged[:no_declaration] = !should_include_declaration?(options)
                        merged
                      end

  result = adapter.serialize(@native, serialize_options)
  result = apply_line_ending(result, serialize_options[:line_ending])

  # Restore entity markers to named entity references; skipped
  # when the adapter knows the document carries no markers.
  result = adapter.restore_entities(result) if entity_bearing?
  result
end

#xpath(expression, namespaces = {}) ⇒ Object



179
180
181
182
183
184
185
# File 'lib/moxml/node.rb', line 179

def xpath(expression, namespaces = {})
  result = adapter.xpath(@native, expression, namespaces)
  # Adapter contract: Array<native> | LazyNodeSet | scalar.
  # Scalars (count(), string-length(), booleans) pass through
  # unwrapped; the set forms wrap lazily.
  result.is_a?(Array) || result.is_a?(LazyNodeSet) ? NodeSet.new(result, context) : result
end