moxml 0.1.26 → 0.2.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 (72) 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 +5 -0
  6. data/lib/moxml/adapter/base.rb +53 -49
  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 +131 -99
  27. data/lib/moxml/adapter/nokogiri.rb +29 -8
  28. data/lib/moxml/adapter/oga.rb +91 -54
  29. data/lib/moxml/adapter/ox.rb +93 -137
  30. data/lib/moxml/adapter/rexml.rb +61 -50
  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/config.rb +29 -3
  35. data/lib/moxml/context.rb +12 -0
  36. data/lib/moxml/element.rb +29 -12
  37. data/lib/moxml/entity/reference.rb +28 -0
  38. data/lib/moxml/entity.rb +125 -0
  39. data/lib/moxml/node.rb +90 -1
  40. data/lib/moxml/sax/namespace_splitter.rb +5 -2
  41. data/lib/moxml/signature/model/key/dsa_key_value.rb +1 -2
  42. data/lib/moxml/version.rb +1 -1
  43. data/lib/moxml/xml_emitter.rb +71 -0
  44. data/lib/moxml/xpath/compiler.rb +60 -7
  45. data/lib/moxml/xpath/parser.rb +22 -15
  46. data/lib/moxml.rb +3 -0
  47. data/spec/examples/readme_examples_spec.rb +0 -4
  48. data/spec/examples/xpath_examples_spec.rb +0 -11
  49. data/spec/integration/all_adapters_spec.rb +17 -0
  50. data/spec/integration/sax_parity_spec.rb +58 -0
  51. data/spec/integration/shared_examples/edge_cases.rb +0 -10
  52. data/spec/integration/shared_examples/integration_workflows.rb +0 -10
  53. data/spec/integration/shared_examples/node_wrappers/attribute_behavior.rb +98 -0
  54. data/spec/integration/shared_examples/node_wrappers/namespace_behavior.rb +26 -0
  55. data/spec/integration/shared_examples/node_wrappers/node_behavior.rb +0 -8
  56. data/spec/moxml/adapter/leptris_spec.rb +75 -0
  57. data/spec/moxml/adapter/ox_spec.rb +20 -6
  58. data/spec/moxml/adapter/platform_spec.rb +15 -2
  59. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +180 -0
  60. data/spec/moxml/attribute_resolver_spec.rb +108 -0
  61. data/spec/moxml/doctype_spec.rb +1 -1
  62. data/spec/moxml/entity_spec.rb +76 -0
  63. data/spec/moxml/node_spec.rb +104 -0
  64. data/spec/moxml/sax_entity_parity_spec.rb +358 -0
  65. data/spec/moxml/xml_emitter_spec.rb +64 -0
  66. data/spec/moxml/xpath/axes_spec.rb +72 -0
  67. data/spec/moxml/xpath/parser_spec.rb +6 -0
  68. data/spec/moxml/xpath_capabilities_spec.rb +5 -3
  69. data/spec/performance/benchmark_spec.rb +13 -6
  70. data/spec/performance/thread_safety_spec.rb +0 -4
  71. data/spec/spec_helper.rb +5 -1
  72. metadata +21 -2
@@ -0,0 +1,125 @@
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 (& < > " ') 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
+
33
+ # Marker for adapters that resolve entities during parsing.
34
+ # U+FFFC (Object Replacement Character) + U+FEFF (BOM) is a
35
+ # two-character sentinel chosen because this exact sequence
36
+ # followed by a valid entity name pattern is vanishingly unlikely
37
+ # in real XML content.
38
+ MARKER = "\u{FFFC}\u{FEFF}"
39
+ NAME_PATTERN = "[a-zA-Z_][\\w.:-]*"
40
+ NAME_RE = /&(#{NAME_PATTERN});/
41
+ MARKER_RE = /\u{FFFC}\u{FEFF}(#{NAME_PATTERN});/
42
+ SERIALIZED_MARKER_RE = /(#{NAME_PATTERN});/
43
+ STANDARD_ENTITIES = %w[amp lt gt quot apos].freeze
44
+
45
+ NAMED_DECODE_MAP = {
46
+ "amp" => "&", "lt" => "<", "gt" => ">",
47
+ "quot" => '"', "apos" => "'"
48
+ }.freeze
49
+ private_constant :NAMED_DECODE_MAP
50
+
51
+ DECODE_RE = /&(?:(amp|lt|gt|quot|apos)|#(?:(\d+)|[xX]([0-9a-fA-F]+)));/
52
+ private_constant :DECODE_RE
53
+
54
+ module_function
55
+
56
+ # Replace non-standard entity references with markers before
57
+ # parsing. Always returns a UTF-8 encoded string.
58
+ def preprocess_entities(xml)
59
+ return "" if xml.nil?
60
+
61
+ str = if xml.encoding == Encoding::BINARY
62
+ # Binary strings are assumed to be UTF-8. If the bytes are
63
+ # not valid UTF-8, fall back to encoding as UTF-8 with
64
+ # replacement to avoid raising on gsub.
65
+ dup = xml.dup.force_encoding("UTF-8")
66
+ if dup.valid_encoding?
67
+ dup
68
+ else
69
+ xml.dup.encode("UTF-8",
70
+ "ASCII-8BIT", invalid: :replace, undef: :replace)
71
+ end
72
+ elsif xml.encoding == Encoding::UTF_8
73
+ xml
74
+ else
75
+ xml.encode("UTF-8")
76
+ end
77
+ # Fast path: no `&` means no entity references to mark — skip
78
+ # the regex scan and string allocation entirely. The vast
79
+ # majority of XML payloads contain no entity references.
80
+ return str unless str.include?("&")
81
+
82
+ str.gsub(NAME_RE) do |match|
83
+ STANDARD_ENTITIES.include?(::Regexp.last_match(1)) ? match : "#{MARKER}#{::Regexp.last_match(1)};"
84
+ end
85
+ end
86
+
87
+ # Resolve numeric (&#NN; / &#xNN;) and the five standard named
88
+ # (&amp; &lt; &gt; &quot; &apos;) XML entity references in one
89
+ # single pass. The resulting characters are data; no further
90
+ # decoding is applied. Numeric refs producing invalid UTF-8
91
+ # (NUL, surrogate halves, > U+10FFFF) are preserved verbatim
92
+ # to avoid silently emitting malformed bytes.
93
+ def decode_entities(text)
94
+ return text unless text.is_a?(String) && text.include?("&")
95
+
96
+ text.gsub(DECODE_RE) do
97
+ if (named = ::Regexp.last_match(1))
98
+ NAMED_DECODE_MAP[named]
99
+ else
100
+ code = ::Regexp.last_match(2) ? ::Regexp.last_match(2).to_i : ::Regexp.last_match(3).to_i(16)
101
+ if code.zero? || code.between?(0xD800, 0xDFFF) || code > 0x10FFFF
102
+ ::Regexp.last_match(0)
103
+ else
104
+ [code].pack("U")
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ # Restore entity markers back to named entity references.
111
+ def restore_entities(text)
112
+ return text unless text.is_a?(String)
113
+
114
+ # Force UTF-8 encoding since markers are UTF-8 characters
115
+ str = text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding("UTF-8")
116
+ # Fast path: the vast majority of documents carry no entity
117
+ # markers — two C-level scans beat two regex passes.
118
+ return str unless str.include?(MARKER) ||
119
+ str.include?("&#xFFFC;")
120
+
121
+ result = str.gsub(MARKER_RE, '&\1;')
122
+ result.gsub(SERIALIZED_MARKER_RE, '&\1;')
123
+ end
124
+ end
125
+ end
data/lib/moxml/node.rb CHANGED
@@ -55,6 +55,8 @@ module Moxml
55
55
  refreshed = adapter.actual_native(node.native, @native)
56
56
  node.refresh_native!(refreshed) if refreshed && refreshed != node.native
57
57
  node.parent_node = self
58
+ # The adopted subtree's in-scope namespaces changed
59
+ node.invalidate_namespace_cache!
58
60
  invalidate_children_cache!
59
61
  self
60
62
  end
@@ -77,9 +79,15 @@ module Moxml
77
79
  invalidate_parent_children_cache!
78
80
  adapter.remove(@native)
79
81
  invalidate_children_cache!
82
+ # The detached subtree left its declaring ancestors behind
83
+ invalidate_namespace_cache!
80
84
  self
81
85
  end
82
86
 
87
+ # Namespace-scope caches live on Element; the base no-op lets tree
88
+ # mutations invalidate uniformly without type checks.
89
+ def invalidate_namespace_cache!; end
90
+
83
91
  def replace(node)
84
92
  node = prepare_node(node)
85
93
  invalidate_parent_children_cache!
@@ -103,7 +111,10 @@ module Moxml
103
111
  end
104
112
 
105
113
  def xpath(expression, namespaces = {})
106
- NodeSet.new(adapter.xpath(@native, expression, namespaces), context)
114
+ result = adapter.xpath(@native, expression, namespaces)
115
+ # Adapter contract: Array<native> | scalar. Scalars (count(),
116
+ # string-length(), booleans) pass through unwrapped.
117
+ result.is_a?(Array) ? NodeSet.new(result, context) : result
107
118
  end
108
119
 
109
120
  def at_xpath(expression, namespaces = {})
@@ -186,6 +197,69 @@ module Moxml
186
197
  children.each(&block)
187
198
  end
188
199
 
200
+ # Returns all ancestor nodes from the parent up to and including
201
+ # the document node.
202
+ #
203
+ # @return [NodeSet] ancestors ordered nearest-first
204
+ def ancestors
205
+ return NodeSet.new([], context) if document?
206
+
207
+ natives = []
208
+ current = parent
209
+ while current
210
+ natives << current.native
211
+ break if current.document?
212
+
213
+ current = current.parent
214
+ end
215
+ NodeSet.new(natives, context)
216
+ end
217
+
218
+ # Returns all descendant nodes (children, grandchildren, and so on),
219
+ # excluding the node itself.
220
+ #
221
+ # @return [NodeSet] descendants in document order
222
+ def descendants
223
+ natives = []
224
+ each_node { |node| natives << node.native }
225
+ NodeSet.new(natives, context)
226
+ end
227
+
228
+ # Returns an XPath expression that uniquely locates this node within
229
+ # its document. Positional predicates are emitted only when sibling
230
+ # elements share the same qualified name, keeping paths minimal.
231
+ #
232
+ # @return [String] XPath expression
233
+ # @raise [Moxml::NotImplementedError] for node types other than
234
+ # element and document
235
+ def path
236
+ return "/" if document?
237
+
238
+ unless element?
239
+ raise Moxml::NotImplementedError.new(
240
+ "path is only supported for element and document nodes",
241
+ feature: "path",
242
+ )
243
+ end
244
+
245
+ segments = []
246
+ current = self
247
+ while current && !current.document?
248
+ segments.unshift(path_segment_for(current))
249
+ current = current.parent
250
+ end
251
+ "/#{segments.join('/')}"
252
+ end
253
+
254
+ # Returns the 1-based line number where this node appears in the
255
+ # source XML, or nil when the underlying adapter does not track
256
+ # source positions.
257
+ #
258
+ # @return [Integer, nil]
259
+ def line_number
260
+ adapter.line_number(@native)
261
+ end
262
+
189
263
  def outer_xml
190
264
  to_xml
191
265
  end
@@ -286,6 +360,21 @@ module Moxml
286
360
 
287
361
  private
288
362
 
363
+ # XPath segment for an element: the qualified name, plus a positional
364
+ # predicate only when same-named element siblings make it ambiguous.
365
+ def path_segment_for(element)
366
+ name = element.name
367
+ parent = element.parent
368
+ return name unless parent
369
+
370
+ same_name = parent.children.select do |child|
371
+ child.element? && child.name == name
372
+ end
373
+ return name if same_name.size == 1
374
+
375
+ "#{name}[#{same_name.find_index(element) + 1}]"
376
+ end
377
+
289
378
  def prepare_node(node)
290
379
  case node
291
380
  when String then Text.new(adapter.create_text(node), context)
@@ -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.2.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
@@ -425,15 +425,22 @@ module Moxml
425
425
  end
426
426
  end
427
427
 
428
- # AXIS: attribute - Enables @attribute syntax
428
+ # AXIS: attribute - Enables @attribute syntax. Name tests share
429
+ # the resolver's expanded-name semantics (Namespaces 1.0): bare
430
+ # names match no-namespace attributes only; prefixed names match
431
+ # by namespace URI, never by prefix spelling.
429
432
  def on_axis_attribute(ast, input)
430
433
  elem_class = const_ref("Moxml", "Element")
431
434
  attribute = unique_literal(:attribute)
432
435
 
433
436
  input.is_a?(elem_class).if_true do
434
437
  input.attributes.each.add_block(attribute) do
435
- # Use process to handle both :test and :wildcard nodes
436
- condition = process(ast, attribute)
438
+ condition = if ast.type == :test
439
+ attribute_test_condition(ast, input, attribute)
440
+ else
441
+ # Wildcards and node types keep the generic path
442
+ process(ast, attribute)
443
+ end
437
444
 
438
445
  if block_given?
439
446
  condition.if_true { yield attribute }
@@ -444,6 +451,34 @@ module Moxml
444
451
  end
445
452
  end
446
453
 
454
+ # Attribute node-test condition built on AttributeResolver. The
455
+ # XPath context's prefix mapping (when provided) wins over the
456
+ # document's own declarations and is resolved at compile time.
457
+ # Returns nil for the both-wildcard test (@*): no name constraint.
458
+ def attribute_test_condition(ast, element, attribute)
459
+ resolver = const_ref("Moxml", "AttributeResolver")
460
+ ns = ast.value[:namespace]
461
+ name = ast.value[:name]
462
+
463
+ return nil if name == STAR && (!ns || ns == STAR)
464
+
465
+ local = name == STAR ? self_nil : string(name)
466
+
467
+ if ns && ns != STAR && @namespaces && (uri = @namespaces[ns])
468
+ return resolver.attribute_test_uri?(element, attribute,
469
+ string(uri.to_s), local)
470
+ end
471
+
472
+ prefix = if ns.nil?
473
+ self_nil
474
+ elsif ns == STAR
475
+ symbol(:any)
476
+ else
477
+ string(ns)
478
+ end
479
+ resolver.attribute_test?(element, attribute, prefix, local)
480
+ end
481
+
447
482
  # AXIS: descendant - All descendant nodes (without self)
448
483
  def on_axis_descendant(ast, input)
449
484
  node = unique_literal(:descendant)
@@ -494,6 +529,13 @@ module Moxml
494
529
  when "node"
495
530
  # node() matches everything — use a truthy literal
496
531
  Ruby::Node.new(:lit, ["true"])
532
+ when "text"
533
+ # CDATA sections are text nodes in the XPath data model
534
+ text_class = const_ref("Moxml", "Text")
535
+ cdata_class = const_ref("Moxml", "Cdata")
536
+ input.is_a?(text_class).or(input.is_a?(cdata_class))
537
+ when "comment"
538
+ input.is_a?(const_ref("Moxml", "Comment"))
497
539
  else element_or_attribute(input)
498
540
  end
499
541
  end
@@ -531,9 +573,21 @@ module Moxml
531
573
  # Match namespace if specified
532
574
  if ns && ns != STAR
533
575
  if @namespaces && @namespaces[ns]
534
- # Resolve prefix to URI using namespace mappings
576
+ # Resolve prefix to URI using namespace mappings. An empty
577
+ # URI binding ("xmlns" => "") selects elements in no
578
+ # namespace, e.g. after an xmlns="" undeclaration.
535
579
  ns_uri = @namespaces[ns]
536
- ns_match = input.namespace.and(input.namespace.uri.eq(string(ns_uri)))
580
+ ns_match = if ns_uri.to_s.empty?
581
+ input.namespace.nil?.or(input.namespace.uri.eq(string("")))
582
+ else
583
+ input.namespace.and(input.namespace.uri.eq(string(ns_uri)))
584
+ end
585
+ elsif ns == "xmlns"
586
+ # Nokogiri-compatible reserved prefix: xmlns:name addresses
587
+ # elements in the default namespace (no prefix).
588
+ ns_match = input.namespace.and(
589
+ input.namespace.prefix.to_s.eq(string("")),
590
+ )
537
591
  else
538
592
  # No mapping provided - check against element's namespace prefix
539
593
  # Need to ensure input.namespace exists first
@@ -977,10 +1031,9 @@ module Moxml
977
1031
  conversions << conversion.to_string(arg_var)
978
1032
  end
979
1033
 
980
- # rubocop:disable Style/RedundantSum, Performance/Sum
1034
+ # rubocop:disable-next Performance/Sum
981
1035
  concatted = assigns.inject(:followed_by)
982
1036
  .followed_by(conversions.inject(:+))
983
- # rubocop:enable Style/RedundantSum, Performance/Sum
984
1037
 
985
1038
  block_given? ? concatted.empty?.if_false { yield concatted } : concatted
986
1039
  end
@@ -327,11 +327,18 @@ module Moxml
327
327
  def parse_relative_path
328
328
  steps = [parse_step]
329
329
 
330
- while match?(:slash) && !at_end?
330
+ # Continue on both step separators: "/step" and the
331
+ # descendant-or-self shorthand "//step". A "//" arrives as a
332
+ # single :dslash (".//step", "a//step") or occasionally as a
333
+ # :slash/:slash or :slash/:dslash pair.
334
+ while !at_end? && match?(:slash, :dslash)
335
+ is_dslash = match?(:dslash)
331
336
  advance
332
- if match?(:slash)
333
- # Double slash within path: expands to descendant-or-self::node()
337
+ if !is_dslash && match?(:slash, :dslash)
334
338
  advance
339
+ is_dslash = true
340
+ end
341
+ if is_dslash
335
342
  steps << AST::Node.axis("descendant-or-self", AST::Node.node_type("node"))
336
343
  end
337
344
  steps << parse_step unless at_end? || match?(:pipe, :rbracket,
@@ -352,14 +359,10 @@ module Moxml
352
359
  return AST::Node.parent
353
360
  elsif match?(:at)
354
361
  advance
355
- # Attribute: @name or @*
356
- if match?(:star)
357
- advance
358
- node_test = AST::Node.wildcard
359
- else
360
- name = consume(:name, "Expected attribute name after @")
361
- node_test = AST::Node.test(nil, name[1])
362
- end
362
+ # Attribute: @name, @prefix:name, @prefix:* or @* (XPath 1.0
363
+ # §3.2, NameTest). parse_node_test handles prefixed and
364
+ # wildcard names; bare names land in its plain-name branch.
365
+ node_test = parse_node_test
363
366
  step = AST::Node.axis("attribute", node_test)
364
367
  return parse_predicates(step)
365
368
  end
@@ -385,14 +388,18 @@ module Moxml
385
388
  if match?(:star)
386
389
  advance
387
390
  return AST::Node.wildcard
388
- elsif match?(:node_type)
391
+ elsif match?(:node_type) && peek_is?(:lparen)
389
392
  type_name = current_value
390
393
  advance
391
394
  consume(:lparen, "Expected '(' after node type")
392
395
  consume(:rparen, "Expected ')' after node type")
393
396
  return AST::Node.node_type(type_name)
394
- elsif match?(:name, :and, :or, :mod, :div)
395
- # Accept keywords as valid element names (they're valid XML names)
397
+ elsif match?(:name, :node_type, :and, :or, :mod, :div)
398
+ # Accept keywords as valid element names (they're valid XML
399
+ # names). Node-type words (text, comment, node) also land here
400
+ # when not followed by '(' — they name elements, either alone
401
+ # ("//text") or as the local part of a prefixed name
402
+ # ("//svg:text").
396
403
  name = current_value
397
404
  advance
398
405
 
@@ -402,7 +409,7 @@ module Moxml
402
409
  if match?(:star)
403
410
  advance
404
411
  return AST::Node.test(name, "*")
405
- elsif match?(:name, :and, :or, :mod, :div)
412
+ elsif match?(:name, :node_type, :and, :or, :mod, :div)
406
413
  # Accept keywords as local names too
407
414
  local_name = current_value
408
415
  advance
data/lib/moxml.rb CHANGED
@@ -76,6 +76,7 @@ module Moxml
76
76
  autoload :Cdata, "moxml/cdata"
77
77
  autoload :Comment, "moxml/comment"
78
78
  autoload :Attribute, "moxml/attribute"
79
+ autoload :AttributeResolver, "moxml/attribute_resolver"
79
80
  autoload :ProcessingInstruction, "moxml/processing_instruction"
80
81
  autoload :Declaration, "moxml/declaration"
81
82
  autoload :Namespace, "moxml/namespace"
@@ -83,9 +84,11 @@ module Moxml
83
84
  autoload :EntityReference, "moxml/entity_reference"
84
85
  autoload :DocumentBuilder, "moxml/document_builder"
85
86
  autoload :Builder, "moxml/builder"
87
+ autoload :Entity, "moxml/entity"
86
88
  autoload :EntityRegistry, "moxml/entity_registry"
87
89
  autoload :NativeAttachment, "moxml/native_attachment"
88
90
  autoload :XmlUtils, "moxml/xml_utils"
91
+ autoload :XmlEmitter, "moxml/xml_emitter"
89
92
  autoload :Adapter, "moxml/adapter"
90
93
  autoload :XPath, "moxml/xpath"
91
94
  autoload :SAX, "moxml/sax"
@@ -110,10 +110,6 @@ RSpec.shared_examples "README Examples" do
110
110
  describe "Error handling example" do
111
111
  it "handles errors as shown in README" do
112
112
  context = Moxml.new
113
- if context.config.adapter_name == :ox
114
- pending "Ox doesn't have a native XPath"
115
- end
116
-
117
113
  expect do
118
114
  context.parse("<invalid>")
119
115
  end.to raise_error(Moxml::ParseError)
@@ -24,20 +24,12 @@ RSpec.shared_examples "XPath Examples" do
24
24
  end
25
25
 
26
26
  it "finds nodes with namespaces" do
27
- if context.config.adapter_name == :ox
28
- pending "Ox doesn't have a native XPath"
29
- end
30
-
31
27
  titles = doc.xpath("//dc:title",
32
28
  "dc" => "http://purl.org/dc/elements/1.1/")
33
29
  expect(titles.map(&:text)).to eq(%w[First Second])
34
30
  end
35
31
 
36
32
  it "finds nodes by attributes" do
37
- if %i[ox headed_ox].include?(context.config.adapter_name)
38
- pending "Ox doesn't have native XPath, HeadedOx parser doesn't support .// syntax yet"
39
- end
40
-
41
33
  book = doc.at_xpath('//book[@id="2"]')
42
34
  expect(book).not_to be_nil
43
35
  title = book.at_xpath(".//dc:title",
@@ -46,9 +38,6 @@ RSpec.shared_examples "XPath Examples" do
46
38
  end
47
39
 
48
40
  it "finds nested attributes efficiently" do
49
- if %i[ox headed_ox].include?(context.config.adapter_name)
50
- pending "Ox doesn't have native XPath, HeadedOx parser doesn't support .// syntax yet"
51
- end
52
41
  # More efficient - specific path
53
42
  titles1 = doc.xpath("//book/dc:title")
54
43
 
@@ -1,5 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Leptris needs the unreleased programmatic-document-construction C
4
+ # API; skip its integration matrix until the binding ships it.
5
+ #
6
+ # const_defined? only — deliberately NO require here: loading a native
7
+ # gem at example-group definition time is a load-order side effect
8
+ # (and the only new load-time behavior on this branch). The leptris
9
+ # adapter spec is what loads the gem in its own process.
10
+ def leptris_ready?
11
+ return false unless Object.const_defined?(:Leptris)
12
+
13
+ Leptris::XML::Document.respond_to?(:create)
14
+ end
15
+
3
16
  RSpec.describe "Cross-adapter integration" do
4
17
  # Integration shared examples - use the names as defined in the files
5
18
  all_shared_examples = [
@@ -41,6 +54,10 @@ RSpec.describe "Cross-adapter integration" do
41
54
  end
42
55
  end
43
56
 
57
+ if adapter_name == :leptris && !leptris_ready?
58
+ before { skip "requires leptris with Leptris::XML::Document.create (unreleased C API)" }
59
+ end
60
+
44
61
  all_shared_examples.each do |shared_example_name|
45
62
  it_behaves_like shared_example_name
46
63
  end