moxml 0.1.26 → 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.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/windows.yml +62 -0
  3. data/Gemfile +3 -1
  4. data/README.adoc +69 -6
  5. data/lib/compat/opal/moxml_boot.rb +6 -1
  6. data/lib/moxml/adapter/base.rb +53 -56
  7. data/lib/moxml/adapter/customized_leptris/declaration.rb +32 -0
  8. data/lib/moxml/adapter/customized_leptris/doctype.rb +31 -0
  9. data/lib/moxml/adapter/customized_leptris/entity_reference.rb +12 -0
  10. data/lib/moxml/adapter/customized_leptris/text_segment.rb +26 -0
  11. data/lib/moxml/adapter/customized_leptris.rb +16 -0
  12. data/lib/moxml/adapter/customized_libxml/cdata.rb +3 -12
  13. data/lib/moxml/adapter/customized_libxml/comment.rb +2 -9
  14. data/lib/moxml/adapter/customized_libxml/declaration.rb +1 -10
  15. data/lib/moxml/adapter/customized_libxml/node.rb +8 -0
  16. data/lib/moxml/adapter/customized_libxml/processing_instruction.rb +2 -10
  17. data/lib/moxml/adapter/customized_libxml/text.rb +2 -9
  18. data/lib/moxml/adapter/customized_oga/entity_decoder.rb +37 -0
  19. data/lib/moxml/adapter/customized_oga/raw_value_override.rb +52 -0
  20. data/lib/moxml/adapter/customized_oga/xml_generator.rb +4 -34
  21. data/lib/moxml/adapter/customized_oga.rb +2 -0
  22. data/lib/moxml/adapter/customized_ox/entity_reference.rb +1 -17
  23. data/lib/moxml/adapter/customized_rexml/entity_reference.rb +1 -11
  24. data/lib/moxml/adapter/headed_ox.rb +9 -128
  25. data/lib/moxml/adapter/leptris.rb +965 -0
  26. data/lib/moxml/adapter/libxml.rb +137 -117
  27. data/lib/moxml/adapter/nokogiri.rb +29 -8
  28. data/lib/moxml/adapter/oga.rb +139 -55
  29. data/lib/moxml/adapter/ox.rb +93 -137
  30. data/lib/moxml/adapter/rexml.rb +64 -51
  31. data/lib/moxml/adapter.rb +2 -1
  32. data/lib/moxml/attribute.rb +8 -4
  33. data/lib/moxml/attribute_resolver.rb +189 -0
  34. data/lib/moxml/builder.rb +0 -18
  35. data/lib/moxml/config.rb +29 -5
  36. data/lib/moxml/context.rb +33 -0
  37. data/lib/moxml/document.rb +1 -1
  38. data/lib/moxml/element.rb +29 -12
  39. data/lib/moxml/entity/reference.rb +28 -0
  40. data/lib/moxml/entity/restorer.rb +95 -0
  41. data/lib/moxml/entity.rb +126 -0
  42. data/lib/moxml/entity_registry.rb +1 -1
  43. data/lib/moxml/error.rb +0 -42
  44. data/lib/moxml/node.rb +135 -10
  45. data/lib/moxml/node_set.rb +1 -1
  46. data/lib/moxml/sax/namespace_splitter.rb +5 -2
  47. data/lib/moxml/signature/model/key/dsa_key_value.rb +1 -2
  48. data/lib/moxml/version.rb +1 -1
  49. data/lib/moxml/xml_emitter.rb +71 -0
  50. data/lib/moxml/xpath/compiler.rb +143 -8
  51. data/lib/moxml/xpath/parser.rb +22 -15
  52. data/lib/moxml.rb +3 -3
  53. data/spec/examples/readme_examples_spec.rb +0 -4
  54. data/spec/examples/xpath_examples_spec.rb +0 -11
  55. data/spec/integration/all_adapters_spec.rb +18 -1
  56. data/spec/integration/sax_parity_spec.rb +58 -0
  57. data/spec/integration/shared_examples/edge_cases.rb +0 -10
  58. data/spec/integration/shared_examples/high_level/{document_builder_behavior.rb → parse_behavior.rb} +1 -2
  59. data/spec/integration/shared_examples/integration_workflows.rb +0 -10
  60. data/spec/integration/shared_examples/node_wrappers/attribute_behavior.rb +98 -0
  61. data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +26 -0
  62. data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +15 -8
  63. data/spec/moxml/adapter/leptris_spec.rb +75 -0
  64. data/spec/moxml/adapter/libxml_internals_spec.rb +1 -1
  65. data/spec/moxml/adapter/ox_spec.rb +20 -6
  66. data/spec/moxml/adapter/platform_spec.rb +15 -2
  67. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +180 -0
  68. data/spec/moxml/attribute_resolver_spec.rb +108 -0
  69. data/spec/moxml/config_spec.rb +0 -1
  70. data/spec/moxml/context_spec.rb +22 -0
  71. data/spec/moxml/doctype_spec.rb +1 -1
  72. data/spec/moxml/entity_spec.rb +76 -0
  73. data/spec/moxml/error_spec.rb +0 -50
  74. data/spec/moxml/node_spec.rb +104 -0
  75. data/spec/moxml/sax_entity_parity_spec.rb +358 -0
  76. data/spec/moxml/xml_emitter_spec.rb +64 -0
  77. data/spec/moxml/xpath/axes_spec.rb +112 -0
  78. data/spec/moxml/xpath/parser_spec.rb +6 -0
  79. data/spec/moxml/xpath_capabilities_spec.rb +5 -3
  80. data/spec/performance/benchmark_spec.rb +13 -6
  81. data/spec/performance/thread_safety_spec.rb +0 -4
  82. data/spec/spec_helper.rb +5 -1
  83. metadata +23 -6
  84. data/lib/moxml/adapter/libxml/entity_restorer.rb +0 -94
  85. data/lib/moxml/document_builder.rb +0 -167
  86. data/spec/moxml/document_builder_spec.rb +0 -30
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Entity
5
+ # A named entity reference (&name;) as a value object, for
6
+ # adapters whose native trees cannot represent entity references
7
+ # (ox stores them in the tree; rexml and leptris carry them
8
+ # alongside). Adapter-specific namespaces alias this class so
9
+ # node_type dispatch keeps working.
10
+ class Reference
11
+ attr_reader :name
12
+ attr_accessor :parent
13
+
14
+ def initialize(name)
15
+ @name = name
16
+ @parent = nil
17
+ end
18
+
19
+ def to_xml
20
+ "&#{name};"
21
+ end
22
+
23
+ def ==(other)
24
+ other.is_a?(Reference) && name == other.name
25
+ end
26
+ end
27
+ end
28
+ end
@@ -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
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ # Entity-reference round-trip pipeline.
5
+ #
6
+ # XML libraries split into two camps: those that preserve named
7
+ # entity references natively (nokogiri) and those that expand or
8
+ # reject them at parse (ox, rexml, oga, libxml, leptris). Moxml
9
+ # bridges the camps with one pipeline, owned here and delegated to
10
+ # by Adapter::Base:
11
+ #
12
+ # 1. Parse time — .preprocess_entities rewrites non-standard
13
+ # entity references to a two-character marker (see MARKER) so
14
+ # the reference survives native parsing verbatim. The five
15
+ # standard entities (&amp; &lt; &gt; &quot; &apos;) are NOT
16
+ # converted.
17
+ # 2. Read time — marker-bearing text is split into text and
18
+ # Entity::Reference nodes (adapters whose natives cannot hold
19
+ # references), or decoded in place (.decode_entities covers the
20
+ # five standard entities plus numeric character references).
21
+ # 3. Serialize time — .restore_entities maps markers back to named
22
+ # references, including markers a native serializer already
23
+ # rendered as character references.
24
+ #
25
+ # How references are STORED between parse and serialize is
26
+ # adapter-shaped (in-tree value objects for ox, attachment
27
+ # sequences for rexml/libxml, text markers for oga/leptris); the
28
+ # marker lifecycle and the Entity::Reference value type are the
29
+ # shared, single-sourced parts.
30
+ module Entity
31
+ autoload :Reference, "moxml/entity/reference"
32
+ autoload :Restorer, "moxml/entity/restorer"
33
+
34
+ # Marker for adapters that resolve entities during parsing.
35
+ # U+FFFC (Object Replacement Character) + U+FEFF (BOM) is a
36
+ # two-character sentinel chosen because this exact sequence
37
+ # followed by a valid entity name pattern is vanishingly unlikely
38
+ # in real XML content.
39
+ MARKER = "\u{FFFC}\u{FEFF}"
40
+ NAME_PATTERN = "[a-zA-Z_][\\w.:-]*"
41
+ NAME_RE = /&(#{NAME_PATTERN});/
42
+ MARKER_RE = /\u{FFFC}\u{FEFF}(#{NAME_PATTERN});/
43
+ SERIALIZED_MARKER_RE = /&#xFFFC;&#xFEFF;(#{NAME_PATTERN});/
44
+ STANDARD_ENTITIES = %w[amp lt gt quot apos].freeze
45
+
46
+ NAMED_DECODE_MAP = {
47
+ "amp" => "&", "lt" => "<", "gt" => ">",
48
+ "quot" => '"', "apos" => "'"
49
+ }.freeze
50
+ private_constant :NAMED_DECODE_MAP
51
+
52
+ DECODE_RE = /&(?:(amp|lt|gt|quot|apos)|#(?:(\d+)|[xX]([0-9a-fA-F]+)));/
53
+ private_constant :DECODE_RE
54
+
55
+ module_function
56
+
57
+ # Replace non-standard entity references with markers before
58
+ # parsing. Always returns a UTF-8 encoded string.
59
+ def preprocess_entities(xml)
60
+ return "" if xml.nil?
61
+
62
+ str = if xml.encoding == Encoding::BINARY
63
+ # Binary strings are assumed to be UTF-8. If the bytes are
64
+ # not valid UTF-8, fall back to encoding as UTF-8 with
65
+ # replacement to avoid raising on gsub.
66
+ dup = xml.dup.force_encoding("UTF-8")
67
+ if dup.valid_encoding?
68
+ dup
69
+ else
70
+ xml.dup.encode("UTF-8",
71
+ "ASCII-8BIT", invalid: :replace, undef: :replace)
72
+ end
73
+ elsif xml.encoding == Encoding::UTF_8
74
+ xml
75
+ else
76
+ xml.encode("UTF-8")
77
+ end
78
+ # Fast path: no `&` means no entity references to mark — skip
79
+ # the regex scan and string allocation entirely. The vast
80
+ # majority of XML payloads contain no entity references.
81
+ return str unless str.include?("&")
82
+
83
+ str.gsub(NAME_RE) do |match|
84
+ STANDARD_ENTITIES.include?(::Regexp.last_match(1)) ? match : "#{MARKER}#{::Regexp.last_match(1)};"
85
+ end
86
+ end
87
+
88
+ # Resolve numeric (&#NN; / &#xNN;) and the five standard named
89
+ # (&amp; &lt; &gt; &quot; &apos;) XML entity references in one
90
+ # single pass. The resulting characters are data; no further
91
+ # decoding is applied. Numeric refs producing invalid UTF-8
92
+ # (NUL, surrogate halves, > U+10FFFF) are preserved verbatim
93
+ # to avoid silently emitting malformed bytes.
94
+ def decode_entities(text)
95
+ return text unless text.is_a?(String) && text.include?("&")
96
+
97
+ text.gsub(DECODE_RE) do
98
+ if (named = ::Regexp.last_match(1))
99
+ NAMED_DECODE_MAP[named]
100
+ else
101
+ code = ::Regexp.last_match(2) ? ::Regexp.last_match(2).to_i : ::Regexp.last_match(3).to_i(16)
102
+ if code.zero? || code.between?(0xD800, 0xDFFF) || code > 0x10FFFF
103
+ ::Regexp.last_match(0)
104
+ else
105
+ [code].pack("U")
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ # Restore entity markers back to named entity references.
112
+ def restore_entities(text)
113
+ return text unless text.is_a?(String)
114
+
115
+ # Force UTF-8 encoding since markers are UTF-8 characters
116
+ str = text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding("UTF-8")
117
+ # Fast path: the vast majority of documents carry no entity
118
+ # markers — two C-level scans beat two regex passes.
119
+ return str unless str.include?(MARKER) ||
120
+ str.include?("&#xFFFC;")
121
+
122
+ result = str.gsub(MARKER_RE, '&\1;')
123
+ result.gsub(SERIALIZED_MARKER_RE, '&\1;')
124
+ end
125
+ end
126
+ end
@@ -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
@@ -55,6 +60,8 @@ module Moxml
55
60
  refreshed = adapter.actual_native(node.native, @native)
56
61
  node.refresh_native!(refreshed) if refreshed && refreshed != node.native
57
62
  node.parent_node = self
63
+ # The adopted subtree's in-scope namespaces changed
64
+ node.invalidate_namespace_cache!
58
65
  invalidate_children_cache!
59
66
  self
60
67
  end
@@ -77,9 +84,15 @@ module Moxml
77
84
  invalidate_parent_children_cache!
78
85
  adapter.remove(@native)
79
86
  invalidate_children_cache!
87
+ # The detached subtree left its declaring ancestors behind
88
+ invalidate_namespace_cache!
80
89
  self
81
90
  end
82
91
 
92
+ # Namespace-scope caches live on Element; the base no-op lets tree
93
+ # mutations invalidate uniformly without type checks.
94
+ def invalidate_namespace_cache!; end
95
+
83
96
  def replace(node)
84
97
  node = prepare_node(node)
85
98
  invalidate_parent_children_cache!
@@ -103,7 +116,10 @@ module Moxml
103
116
  end
104
117
 
105
118
  def xpath(expression, namespaces = {})
106
- NodeSet.new(adapter.xpath(@native, expression, namespaces), context)
119
+ result = adapter.xpath(@native, expression, namespaces)
120
+ # Adapter contract: Array<native> | scalar. Scalars (count(),
121
+ # string-length(), booleans) pass through unwrapped.
122
+ result.is_a?(Array) ? NodeSet.new(result, context) : result
107
123
  end
108
124
 
109
125
  def at_xpath(expression, namespaces = {})
@@ -186,6 +202,104 @@ module Moxml
186
202
  children.each(&block)
187
203
  end
188
204
 
205
+ # Returns all ancestor nodes from the parent up to and including
206
+ # the document node.
207
+ #
208
+ # @return [NodeSet] ancestors ordered nearest-first
209
+ def ancestors
210
+ return NodeSet.new([], context) if document?
211
+
212
+ natives = []
213
+ current = parent
214
+ while current
215
+ natives << current.native
216
+ break if current.document?
217
+
218
+ current = current.parent
219
+ end
220
+ NodeSet.new(natives, context)
221
+ end
222
+
223
+ # Returns all descendant nodes (children, grandchildren, and so on),
224
+ # excluding the node itself.
225
+ #
226
+ # @return [NodeSet] descendants in document order
227
+ def descendants
228
+ natives = []
229
+ each_node { |node| natives << node.native }
230
+ NodeSet.new(natives, context)
231
+ end
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
+
268
+ # Returns an XPath expression that uniquely locates this node within
269
+ # its document. Positional predicates are emitted only when sibling
270
+ # elements share the same qualified name, keeping paths minimal.
271
+ #
272
+ # @return [String] XPath expression
273
+ # @raise [Moxml::NotImplementedError] for node types other than
274
+ # element and document
275
+ def path
276
+ return "/" if document?
277
+
278
+ unless element?
279
+ raise Moxml::NotImplementedError.new(
280
+ "path is only supported for element and document nodes",
281
+ feature: "path",
282
+ )
283
+ end
284
+
285
+ segments = []
286
+ current = self
287
+ while current && !current.document?
288
+ segments.unshift(path_segment_for(current))
289
+ current = current.parent
290
+ end
291
+ "/#{segments.join('/')}"
292
+ end
293
+
294
+ # Returns the 1-based line number where this node appears in the
295
+ # source XML, or nil when the underlying adapter does not track
296
+ # source positions.
297
+ #
298
+ # @return [Integer, nil]
299
+ def line_number
300
+ adapter.line_number(@native)
301
+ end
302
+
189
303
  def outer_xml
190
304
  to_xml
191
305
  end
@@ -202,13 +316,6 @@ module Moxml
202
316
  text.strip.empty?
203
317
  end
204
318
 
205
- # Deep copy of the node (both dup and clone create deep copies for XML nodes)
206
- def dup
207
- Moxml::Node.wrap(adapter.dup(@native), context)
208
- end
209
-
210
- alias clone dup
211
-
212
319
  def ==(other)
213
320
  self.class == other.class && @native == other.native
214
321
  end
@@ -251,10 +358,13 @@ module Moxml
251
358
  def self.wrap(node, context)
252
359
  return nil if node.nil?
253
360
 
361
+ cached = context.wrapper_for(node)
362
+ return cached if cached
363
+
254
364
  type = adapter(context).node_type(node)
255
365
  klass = node_type_map[type] || self
256
366
 
257
- klass.new(node, context)
367
+ klass.new(node, context).tap { |wrapper| context.register_wrapper(node, wrapper) }
258
368
  end
259
369
 
260
370
  # Internal: Set the parent node for cache invalidation tracking.
@@ -286,6 +396,21 @@ module Moxml
286
396
 
287
397
  private
288
398
 
399
+ # XPath segment for an element: the qualified name, plus a positional
400
+ # predicate only when same-named element siblings make it ambiguous.
401
+ def path_segment_for(element)
402
+ name = element.name
403
+ parent = element.parent
404
+ return name unless parent
405
+
406
+ same_name = parent.children.select do |child|
407
+ child.element? && child.name == name
408
+ end
409
+ return name if same_name.size == 1
410
+
411
+ "#{name}[#{same_name.find_index(element) + 1}]"
412
+ end
413
+
289
414
  def prepare_node(node)
290
415
  case node
291
416
  when String then Text.new(adapter.create_text(node), context)
@@ -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)
@@ -9,6 +9,8 @@ module Moxml
9
9
  # attribute. This module provides a single implementation.
10
10
  module NamespaceSplitter
11
11
  # @param attributes [Hash, Array<Array>] attributes as a hash or array of pairs
12
+ # @yieldparam value [Object] raw attribute/namespace value
13
+ # @yieldreturn [Object] transformed value to store
12
14
  # @return [Array(Hash, Hash)] [regular_attrs, namespaces]
13
15
  def split_attributes_and_namespaces(attributes)
14
16
  attrs = {}
@@ -16,11 +18,12 @@ module Moxml
16
18
 
17
19
  each_attribute(attributes) do |name, value|
18
20
  name_s = name.to_s
21
+ v = block_given? ? yield(value) : value
19
22
  if name_s == "xmlns" || name_s.start_with?("xmlns:")
20
23
  prefix = name_s == "xmlns" ? nil : name_s.sub("xmlns:", "")
21
- ns[prefix] = value
24
+ ns[prefix] = v
22
25
  else
23
- attrs[name_s] = value
26
+ attrs[name_s] = v
24
27
  end
25
28
  end
26
29
 
@@ -10,7 +10,7 @@ module Moxml
10
10
  class DSAKeyValue
11
11
  attr_accessor :p, :q, :g, :y, :j, :seed, :pgen_counter
12
12
 
13
- # rubocop:disable Naming/MethodParameterName -- P/Q/G/Y/J are W3C spec names
13
+ # rubocop:disable-next Naming/MethodParameterName -- P/Q/G/Y/J are W3C spec names
14
14
  def initialize(p:, q:, y:, g: nil, j: nil, seed: nil,
15
15
  pgen_counter: nil)
16
16
  @p = p
@@ -21,7 +21,6 @@ module Moxml
21
21
  @seed = seed
22
22
  @pgen_counter = pgen_counter
23
23
  end
24
- # rubocop:enable Naming/MethodParameterName
25
24
  end
26
25
  end
27
26
  end
data/lib/moxml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.1.26"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ # Single source of XML emission knowledge: character escaping,
5
+ # CDATA end-sequence segmentation, and declaration/DOCTYPE
6
+ # rendering. Adapters keep their tree walks (those are
7
+ # adapter-shaped) but stop re-deriving the wire format:
8
+ #
9
+ # - text escapes & < >; attribute values also escape " —
10
+ # apostrophes stay literal (moxml-canonical form)
11
+ # - a CDATA section's end sequence is split into adjacent sections
12
+ # - the moxml declaration shape is <?xml version="..."
13
+ # encoding="..." standalone="..."?> with only the set attributes
14
+ # - DOCTYPE renders PUBLIC and SYSTEM external identifiers
15
+ module XmlEmitter
16
+ TEXT_ESCAPE_RE = /[<>&]/
17
+ TEXT_ESCAPE_MAP = {
18
+ "<" => "&lt;", ">" => "&gt;", "&" => "&amp;"
19
+ }.freeze
20
+ ATTRIBUTE_ESCAPE_RE = /[<>&"]/
21
+ ATTRIBUTE_ESCAPE_MAP = {
22
+ "<" => "&lt;", ">" => "&gt;", "&" => "&amp;", '"' => "&quot;"
23
+ }.freeze
24
+ CDATA_END = "]]>"
25
+ CDATA_END_SPLIT = "]]]]><![CDATA[>"
26
+
27
+ module_function
28
+
29
+ # @return [String] text with & < > escaped
30
+ def escape_text(text)
31
+ str = text.is_a?(String) ? text : text.to_s
32
+ return str unless str.match?(TEXT_ESCAPE_RE)
33
+
34
+ str.gsub(TEXT_ESCAPE_RE, TEXT_ESCAPE_MAP)
35
+ end
36
+
37
+ # @return [String] attribute value with & < > " escaped
38
+ def escape_attribute(value)
39
+ str = value.is_a?(String) ? value : value.to_s
40
+ return str unless str.match?(ATTRIBUTE_ESCAPE_RE)
41
+
42
+ str.gsub(ATTRIBUTE_ESCAPE_RE, ATTRIBUTE_ESCAPE_MAP)
43
+ end
44
+
45
+ # @return [String] a CDATA section, splitting any embedded end
46
+ # sequence into adjacent sections
47
+ def cdata(content)
48
+ "<![CDATA[#{content.to_s.gsub(CDATA_END, CDATA_END_SPLIT)}]]>"
49
+ end
50
+
51
+ # @return [String] an XML declaration with only the set attributes
52
+ def declaration_xml(version, encoding, standalone)
53
+ output = %(<?xml version="#{version}")
54
+ output << %( encoding="#{encoding}") if encoding && !encoding.to_s.empty?
55
+ output << %( standalone="#{standalone}") unless standalone.nil?
56
+ output << "?>"
57
+ end
58
+
59
+ # @return [String] a DOCTYPE with its external identifier
60
+ def doctype_xml(name, public_id, system_id)
61
+ output = "<!DOCTYPE #{name}"
62
+ if public_id && !public_id.to_s.empty?
63
+ output << %( PUBLIC "#{public_id}")
64
+ output << %( "#{system_id}") if system_id
65
+ elsif system_id && !system_id.to_s.empty?
66
+ output << %( SYSTEM "#{system_id}")
67
+ end
68
+ output << ">"
69
+ end
70
+ end
71
+ end