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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "rexml/document"
4
4
  require "rexml/xpath"
5
+ require "rexml/streamlistener"
5
6
  require "set" unless RUBY_ENGINE == "opal"
6
7
  require "stringio" if RUBY_ENGINE == "opal"
7
8
 
@@ -40,7 +41,9 @@ module Moxml
40
41
  end
41
42
 
42
43
  ctx = _context || Context.new(:rexml)
43
- 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
44
47
  end
45
48
 
46
49
  def extract_encoding_from_xml(xml)
@@ -60,18 +63,21 @@ module Moxml
60
63
  # @param handler [Moxml::SAX::Handler] Moxml SAX handler
61
64
  # @return [void]
62
65
  def sax_parse(xml, handler)
63
- require "rexml/parsers/sax2parser"
66
+ require "rexml/parsers/streamparser"
64
67
  require "rexml/source"
65
- require "stringio"
66
68
 
67
- bridge = REXMLSAX2Bridge.new(handler)
69
+ bridge = REXMLStreamBridge.new(handler)
68
70
 
69
71
  xml_string = xml.is_a?(IO) || xml.is_a?(StringIO) ? xml.read : xml.to_s
70
- source = ::REXML::IOSource.new(StringIO.new(xml_string))
71
72
 
72
- parser = ::REXML::Parsers::SAX2Parser.new(source)
73
- parser.listen(bridge)
74
- parser.parse
73
+ # StreamParser (rather than SAX2Parser) is used because
74
+ # SAX2Parser normalizes plain "&#NN;" and "&#NN;" attribute
75
+ # literals to the same raw string, making spec-correct decoding
76
+ # impossible after the fact. StreamParser delivers values in
77
+ # the same decoded form as REXML's DOM.
78
+ handler.on_start_document
79
+ ::REXML::Parsers::StreamParser.new(xml_string, bridge).parse
80
+ handler.on_end_document
75
81
  rescue ::REXML::ParseException => e
76
82
  error = Moxml::ParseError.new(e.message, line: e.line)
77
83
  handler.on_error(error)
@@ -117,15 +123,12 @@ module Moxml
117
123
  def create_native_doctype(name, external_id, system_id)
118
124
  return nil unless name
119
125
 
120
- parts = [name]
121
- if external_id
122
- parts.push("PUBLIC", %("#{external_id}"))
123
- parts << %("#{system_id}") if system_id
124
- elsif system_id
125
- parts.push("SYSTEM", %("#{system_id}"))
126
- end
127
-
128
- ::REXML::DocType.new(parts.join(" "))
126
+ external = if external_id
127
+ system_id ? %(PUBLIC "#{external_id}" "#{system_id}") : %(PUBLIC "#{external_id}")
128
+ elsif system_id
129
+ %(SYSTEM "#{system_id}")
130
+ end
131
+ external ? ::REXML::DocType.new(name.to_s, external) : ::REXML::DocType.new(name.to_s)
129
132
  end
130
133
 
131
134
  def set_root(doc, element)
@@ -250,9 +253,17 @@ module Moxml
250
253
  def attributes(element)
251
254
  return [] unless element.is_a?(::REXML::Element)
252
255
 
253
- # Only return non-namespace attributes
254
- element.attributes.values
255
- .reject { |attr| attr.prefix.to_s.start_with?("xmlns") }
256
+ # Each real Attribute node, not Attributes#values: when two
257
+ # attributes share a local name across namespaces, REXML's
258
+ # values leaks its internal prefix-grouping Hash alongside
259
+ # the Attribute objects (issue #95).
260
+ result = []
261
+ element.attributes.each_attribute do |attr|
262
+ next if attr.prefix.to_s.start_with?("xmlns")
263
+
264
+ result << attr
265
+ end
266
+ result
256
267
  end
257
268
 
258
269
  def attribute_element(attribute)
@@ -271,10 +282,17 @@ module Moxml
271
282
  element = attribute.element
272
283
  element.attributes.delete(old_name)
273
284
  element.attributes << attribute
285
+ attribute
274
286
  end
275
287
 
276
288
  def set_attribute_value(attribute, value)
277
289
  attribute.normalized = value
290
+ attribute
291
+ end
292
+
293
+ def remove_attribute_native(attribute)
294
+ attribute.element.attributes.delete(attribute.expanded_name)
295
+ attribute
278
296
  end
279
297
 
280
298
  def get_attribute(element, name)
@@ -468,9 +486,11 @@ module Moxml
468
486
  element.add_namespace(prefix,
469
487
  ns.value)
470
488
  end
489
+ # Renames the node in place (works for elements and
490
+ # attributes alike); the caller keeps tracking the same
491
+ # native.
471
492
  element.name = "#{prefix}:#{element.name}"
472
- owner = element.is_a?(::REXML::Attribute) ? element.element : element
473
- ::REXML::Attribute.new(prefix, ns.value, owner)
493
+ element
474
494
  end
475
495
 
476
496
  def namespace_prefix(node)
@@ -518,11 +538,17 @@ module Moxml
518
538
  end
519
539
 
520
540
  def doctype_external_id(native)
521
- native.public
541
+ # Constructed doctypes keep the identifier as a raw
542
+ # external_id string; parsed ones populate public directly
543
+ native.public ||
544
+ native.external_id&.match(/PUBLIC\s+"([^"]*)"/)&.[](1)
522
545
  end
523
546
 
524
547
  def doctype_system_id(native)
525
- native.system
548
+ return native.system if native.system
549
+
550
+ quoted = native.external_id&.scan(/"([^"]*)"/)
551
+ quoted&.last&.first
526
552
  end
527
553
 
528
554
  # not used at the moment
@@ -642,29 +668,33 @@ module Moxml
642
668
  end
643
669
  end
644
670
 
645
- # Bridge between REXML SAX2 and Moxml SAX
671
+ # Bridge between REXML StreamParser and Moxml SAX
646
672
  #
647
- # Translates REXML::SAX2Parser events to Moxml::SAX::Handler events
673
+ # StreamParser (rather than SAX2Parser) is used because SAX2Parser
674
+ # normalizes plain "&#NN;" and "&amp;#NN;" attribute literals to
675
+ # the same raw string, which makes spec-correct decoding impossible
676
+ # after the fact. StreamParser delivers attribute values in the
677
+ # same decoded form as REXML's DOM.
648
678
  #
649
679
  # @private
650
- class REXMLSAX2Bridge
680
+ class REXMLStreamBridge
681
+ include ::REXML::StreamListener
651
682
  include Moxml::SAX::NamespaceSplitter
652
683
 
653
684
  def initialize(handler)
654
685
  @handler = handler
655
686
  end
656
687
 
657
- # REXML splits element name into uri/localname/qname
658
- def start_element(_uri, _localname, qname, attributes)
688
+ def tag_start(name, attributes)
659
689
  attr_hash, ns_hash = split_attributes_and_namespaces(attributes)
660
- @handler.on_start_element(qname, attr_hash, ns_hash)
690
+ @handler.on_start_element(name, attr_hash, ns_hash)
661
691
  end
662
692
 
663
- def end_element(_uri, _localname, qname)
664
- @handler.on_end_element(qname)
693
+ def tag_end(name)
694
+ @handler.on_end_element(name)
665
695
  end
666
696
 
667
- def characters(text)
697
+ def text(text)
668
698
  @handler.on_characters(text)
669
699
  end
670
700
 
@@ -676,26 +706,9 @@ module Moxml
676
706
  @handler.on_comment(text)
677
707
  end
678
708
 
679
- def processing_instruction(target, data)
709
+ def instruction(target, data)
680
710
  @handler.on_processing_instruction(target, data || "")
681
711
  end
682
-
683
- def start_document
684
- @handler.on_start_document
685
- end
686
-
687
- def end_document
688
- @handler.on_end_document
689
- end
690
-
691
- # REXML calls these but we don't need to handle them
692
- def xmldecl(version, encoding, standalone)
693
- # XML declaration - we don't need to do anything
694
- end
695
-
696
- def progress(position)
697
- # Progress callback - we don't need to do anything
698
- end
699
712
  end
700
713
  end
701
714
  end
data/lib/moxml/adapter.rb CHANGED
@@ -7,8 +7,9 @@ module Moxml
7
7
  autoload :CustomizedOx, "moxml/adapter/customized_ox"
8
8
  autoload :CustomizedRexml, "moxml/adapter/customized_rexml"
9
9
  autoload :CustomizedLibxml, "moxml/adapter/customized_libxml"
10
+ autoload :CustomizedLeptris, "moxml/adapter/customized_leptris"
10
11
 
11
- AVAILABLE_ADAPTERS = %i[nokogiri oga rexml ox headed_ox libxml].freeze
12
+ AVAILABLE_ADAPTERS = %i[nokogiri oga rexml ox headed_ox libxml leptris].freeze
12
13
 
13
14
  # Adapters that work under the Opal (JavaScript) runtime.
14
15
  # Oga is pure Ruby and designed with Opal compatibility in mind — it is the
@@ -3,11 +3,14 @@
3
3
  module Moxml
4
4
  class Attribute < Node
5
5
  def name
6
- @native.name
6
+ adapter.attribute_name(@native)
7
7
  end
8
8
 
9
9
  def name=(new_name)
10
- adapter.set_attribute_name(@native, new_name)
10
+ # Mutation return contract (Adapter::Base): returns the native
11
+ # to keep tracking — same object for in-place adapters, fresh
12
+ # object for value-object adapters (leptris).
13
+ @native = adapter.set_attribute_name(@native, new_name)
11
14
  end
12
15
 
13
16
  # Returns the primary identifier for this attribute (its name)
@@ -44,7 +47,8 @@ module Moxml
44
47
  end
45
48
 
46
49
  def namespace=(ns)
47
- adapter.set_namespace(@native, ns&.native)
50
+ # See name= for the mutation return contract.
51
+ @native = adapter.set_namespace(@native, ns&.native)
48
52
  end
49
53
 
50
54
  def element
@@ -53,7 +57,7 @@ module Moxml
53
57
  end
54
58
 
55
59
  def remove
56
- adapter.remove_attribute(adapter.attribute_element(@native), name)
60
+ adapter.remove_attribute_native(@native)
57
61
  if @parent_node.is_a?(Moxml::Element)
58
62
  @parent_node.invalidate_attribute_cache!
59
63
  end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ # Canonical, adapter-independent attribute-name resolution.
5
+ #
6
+ # XML Namespaces 1.0 semantics, implemented once on the Moxml object
7
+ # model (the unified interface); adapters provide raw access only:
8
+ #
9
+ # - An attribute's expanded name is (namespace URI or none) + local
10
+ # name. Unprefixed attributes are never in a namespace — the
11
+ # default namespace declaration does not apply to attributes
12
+ # (Namespaces 1.0 §5.2).
13
+ # - "prefix:local" resolves the prefix against the element's
14
+ # in-scope declarations and matches by expanded name: an attribute
15
+ # written as q:type matches a lookup as p:type whenever both
16
+ # prefixes are bound to the same URI.
17
+ # - "xml" is prebound to the XML namespace URI; it never needs a
18
+ # declaration (Namespaces 1.0 §4).
19
+ # - "xmlns"/"xmlns:*" are declarations, not attributes: never match.
20
+ # - An undeclared prefix names nothing: nil. No silent prefix-string
21
+ # or bare-name fallback.
22
+ #
23
+ # When several attributes match (only possible in namespace-ill-formed
24
+ # documents), the first in document order wins, matching every
25
+ # adapter's attributes() ordering contract.
26
+ module AttributeResolver
27
+ XML_NAMESPACE_URI = "http://www.w3.org/XML/1998/namespace"
28
+
29
+ module_function
30
+
31
+ # @return [Moxml::Attribute, nil] the matching attribute wrapper
32
+ def resolve(element, name)
33
+ name = name.to_s
34
+ if name.include?(":")
35
+ prefix, local = name.split(":", 2)
36
+ return nil if prefix == "xmlns"
37
+
38
+ uri = prefix_uri(element, prefix)
39
+ return nil if uri.nil?
40
+
41
+ element.attributes.find do |attr|
42
+ local_name(attr.name) == local && attribute_uri(element, attr) == uri
43
+ end
44
+ else
45
+ element.attributes.find do |attr|
46
+ local_name(attr.name) == name && attribute_uri(element, attr).nil?
47
+ end
48
+ end
49
+ end
50
+
51
+ # URI a prefix is bound to for lookups on this element, or nil when
52
+ # the prefix is undeclared.
53
+ def prefix_uri(element, prefix)
54
+ return XML_NAMESPACE_URI if prefix == "xml"
55
+
56
+ element.in_scope_namespaces
57
+ .find { |candidate| candidate.prefix == prefix }&.uri
58
+ end
59
+
60
+ # Assign a value to the attribute named by XML-correct resolution,
61
+ # mirroring #resolve:
62
+ #
63
+ # - "xmlns"/"xmlns:*" are namespace declarations, not attributes:
64
+ # delegated verbatim to the adapter (declaration creation is
65
+ # adapter territory).
66
+ # - A bare name replaces the no-namespace attribute only — a
67
+ # namespaced attribute sharing the local name is left alone.
68
+ # - A prefixed name replaces by expanded name: assigning p:x
69
+ # overwrites an attribute spelled q:x when both prefixes are
70
+ # bound to the same URI.
71
+ #
72
+ # When nothing matches, the attribute is created with the given
73
+ # spelling. A prefix not yet in scope is stored raw — builders
74
+ # legitimately write detached children before attaching them under
75
+ # the declaring ancestor, and reads resolve by expanded name once
76
+ # the prefix is in scope (matching nokogiri/libxml creation
77
+ # behavior; namespace validity is a property of the serialized
78
+ # document).
79
+ #
80
+ # @return [String] the assigned value
81
+ def assign(element, name, value)
82
+ name = name.to_s
83
+ adapter = element.context.config.adapter
84
+ if name == "xmlns" || name.start_with?("xmlns:")
85
+ adapter.set_attribute(element.native, name, value)
86
+ element.invalidate_attribute_cache!
87
+ return value
88
+ end
89
+
90
+ existing = resolve(element, name)
91
+ if existing
92
+ existing.value = value
93
+ return value
94
+ end
95
+
96
+ adapter.set_attribute(element.native, name, value)
97
+ element.invalidate_attribute_cache!
98
+ value
99
+ end
100
+
101
+ # Remove the attribute named by XML-correct resolution (see
102
+ # #assign for the name semantics).
103
+ #
104
+ # @return [Moxml::Attribute, nil] the removed attribute
105
+ def remove(element, name)
106
+ name = name.to_s
107
+ adapter = element.context.config.adapter
108
+ if name == "xmlns" || name.start_with?("xmlns:")
109
+ adapter.remove_attribute(element.native, name)
110
+ element.invalidate_attribute_cache!
111
+ return nil
112
+ end
113
+
114
+ attr = resolve(element, name)
115
+ return nil unless attr
116
+
117
+ adapter.remove_attribute_native(attr.native)
118
+ element.invalidate_attribute_cache!
119
+ attr
120
+ end
121
+
122
+ # XPath 1.0 attribute node test, sharing the resolver's
123
+ # expanded-name semantics:
124
+ #
125
+ # @param prefix [String, Symbol, nil]
126
+ # - String: match by namespace URI resolved against the
127
+ # element's in-scope declarations; the prefix spelling is
128
+ # irrelevant (q:x matches a p:x test when URIs agree)
129
+ # - :any: any namespace, including none (*:local)
130
+ # - nil: bare name — no-namespace attributes only (Namespaces
131
+ # 1.0 §5.2)
132
+ # @param local [String, nil] local name, or nil for any (p:*)
133
+ # @return [Boolean]
134
+ def attribute_test?(element, attr, prefix, local)
135
+ return false if local && local_name(attr.name) != local
136
+
137
+ case prefix
138
+ when :any then true
139
+ when nil then attribute_uri(element, attr).nil?
140
+ else
141
+ uri = prefix_uri(element, prefix)
142
+ !uri.nil? && uri == attribute_uri(element, attr)
143
+ end
144
+ end
145
+
146
+ # Attribute node test with a URI resolved at XPath compile time
147
+ # (the static context's prefix mapping wins over the document's
148
+ # own declarations). See #attribute_test? for local semantics.
149
+ #
150
+ # @return [Boolean]
151
+ def attribute_test_uri?(element, attr, uri, local)
152
+ return false if local && local_name(attr.name) != local
153
+
154
+ attribute_uri(element, attr) == uri
155
+ end
156
+
157
+ # Local part of a wrapper attribute name. Local names cannot
158
+ # contain ':' in namespace-well-formed documents; splitting is for
159
+ # adapters whose natives carry the qualified name (leptris).
160
+ def local_name(name)
161
+ name.include?(":") ? name.split(":", 2)[1] : name
162
+ end
163
+
164
+ # URI of an attribute's namespace, nil for the no-namespace case.
165
+ #
166
+ # Uses the attribute's own namespace when the native binding
167
+ # exposes one with a URI (nokogiri/oga/libxml, and rexml/ox for
168
+ # declared prefixes). Otherwise reconstructs it from the attribute
169
+ # prefix via the element's in-scope declarations — covering
170
+ # adapters that report a prefix without resolving it (rexml/ox for
171
+ # the prebound xml prefix) and adapters that expose neither
172
+ # namespace nor separate prefix (leptris qualified names).
173
+ def attribute_uri(element, attr)
174
+ ns = attr.namespace
175
+ return ns.uri unless ns.nil? || ns.uri.to_s.empty?
176
+
177
+ prefix = ns&.prefix || prefix_part(attr.name)
178
+ return nil if prefix.nil? || prefix.empty?
179
+ return XML_NAMESPACE_URI if prefix == "xml"
180
+
181
+ element.in_scope_namespaces
182
+ .find { |candidate| candidate.prefix == prefix }&.uri
183
+ end
184
+
185
+ def prefix_part(name)
186
+ name.include?(":") ? name.split(":", 2)[0] : nil
187
+ end
188
+ end
189
+ end
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
@@ -5,8 +5,15 @@ module Moxml
5
5
  LINE_ENDING_LF = "\n"
6
6
  LINE_ENDING_CRLF = "\r\n"
7
7
  VALID_LINE_ENDINGS = [LINE_ENDING_LF, LINE_ENDING_CRLF].freeze
8
- VALID_ADAPTERS = %i[nokogiri oga rexml ox headed_ox libxml].freeze
9
- DEFAULT_ADAPTER = :nokogiri
8
+ VALID_ADAPTERS = %i[nokogiri oga rexml ox headed_ox libxml leptris].freeze
9
+ # Preferred CRuby default (issue #96): fastest on every measured
10
+ # operation and the cleanest deployment story. Only takes effect
11
+ # when the installed leptris supports programmatic document
12
+ # construction (Leptris::XML::Document.create); otherwise moxml
13
+ # falls back to FALLBACK_ADAPTER so released-gem environments keep
14
+ # working. Oga remains the Opal default (pure Ruby).
15
+ PREFERRED_ADAPTER = :leptris
16
+ FALLBACK_ADAPTER = :nokogiri
10
17
  OPAL_DEFAULT_ADAPTER = :oga
11
18
 
12
19
  # Entity loading modes:
@@ -30,7 +37,26 @@ module Moxml
30
37
  def runtime_default_adapter
31
38
  return OPAL_DEFAULT_ADAPTER if RUBY_ENGINE == "opal"
32
39
 
33
- detect_loaded_adapter || DEFAULT_ADAPTER
40
+ return PREFERRED_ADAPTER if leptris_preferred_available?
41
+
42
+ detect_loaded_adapter || FALLBACK_ADAPTER
43
+ end
44
+
45
+ # True when the leptris gem is installed AND new enough for the
46
+ # adapter (it needs programmatic document construction, which
47
+ # released 1.1.x lacks). Memoized: the require probe runs once.
48
+ def leptris_preferred_available?
49
+ return @leptris_preferred_available if defined?(@leptris_preferred_available)
50
+
51
+ @leptris_preferred_available = begin
52
+ require "leptris"
53
+ # leptris-ruby 1.9.0 regression (leptris-ruby#53): the XML
54
+ # autoload manifest is shadowed; load it explicitly.
55
+ require "leptris/xml"
56
+ ::Leptris::XML::Document.respond_to?(:create)
57
+ rescue LoadError, NameError
58
+ false
59
+ end
34
60
  end
35
61
 
36
62
  def detect_loaded_adapter
@@ -52,7 +78,6 @@ module Moxml
52
78
  attr_reader :adapter_name, :default_line_ending
53
79
  attr_accessor :strict_parsing,
54
80
  :default_encoding,
55
- :entity_encoding,
56
81
  :default_indent,
57
82
  :restore_entities,
58
83
  :preload_entity_sets,
@@ -78,7 +103,6 @@ module Moxml
78
103
  @default_encoding = default_encoding || Config.default.default_encoding
79
104
  @default_indent = 2
80
105
  @default_line_ending = LINE_ENDING_LF
81
- @entity_encoding = :basic
82
106
  @restore_entities = false
83
107
  @preload_entity_sets = []
84
108
  @entity_load_mode = :required
data/lib/moxml/context.rb CHANGED
@@ -6,6 +6,39 @@ module Moxml
6
6
 
7
7
  def initialize(adapter = nil)
8
8
  @config = Config.new(adapter)
9
+ # Bumped on any namespace-scope mutation; Element scope caches
10
+ # compare against it so every wrapper sees changes without
11
+ # needing wrapper identity.
12
+ @namespace_scope_generation = 0
13
+ # Native → wrapper identity map: repeated traversals hand back
14
+ # the same wrapper instead of allocating a fresh one per
15
+ # access. Keyed by object identity; re-keyed by
16
+ # Node#refresh_native! when an adapter swaps a native.
17
+ @wrappers = {}.compare_by_identity
18
+ end
19
+
20
+ def wrapper_for(native)
21
+ @wrappers[native]
22
+ end
23
+
24
+ def register_wrapper(native, wrapper)
25
+ # Safety valve: adapters whose natives are recreated per access
26
+ # (nokogiri mints new Ruby objects for the same C node) would
27
+ # grow the map without bound in pathological loops.
28
+ @wrappers.clear if @wrappers.size >= 65_536
29
+ @wrappers[native] = wrapper
30
+ end
31
+
32
+ def unregister_wrapper(native)
33
+ @wrappers.delete(native)
34
+ end
35
+
36
+ def namespace_scope_generation
37
+ @namespace_scope_generation
38
+ end
39
+
40
+ def bump_namespace_scope_generation
41
+ @namespace_scope_generation += 1
9
42
  end
10
43
 
11
44
  def entity_registry
@@ -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)
data/lib/moxml/element.rb CHANGED
@@ -37,19 +37,23 @@ module Moxml
37
37
  ns&.uri
38
38
  end
39
39
 
40
+ # Write path shares the resolver's expanded-name semantics (see
41
+ # AttributeResolver.assign): bare names target no-namespace
42
+ # attributes only; prefixed names replace by namespace URI and
43
+ # require a declared prefix. Cache coherence is the resolver's.
40
44
  def []=(name, value)
41
- adapter.set_attribute(@native, name, normalize_xml_value(value))
42
- @attributes = nil
45
+ Moxml::AttributeResolver.assign(self, name, normalize_xml_value(value))
43
46
  end
44
47
 
48
+ # Unified XML-correct name resolution (see AttributeResolver):
49
+ # bare names match only no-namespace attributes; prefixed names
50
+ # resolve through in-scope declarations to expanded names.
45
51
  def [](name)
46
- val = adapter.get_attribute_value(@native, name)
47
- val ? adapter.restore_entities(val) : val
52
+ Moxml::AttributeResolver.resolve(self, name)&.value
48
53
  end
49
54
 
50
55
  def attribute(name)
51
- native_attr = adapter.get_attribute(@native, name)
52
- native_attr && Attribute.new(native_attr, context)
56
+ Moxml::AttributeResolver.resolve(self, name)
53
57
  end
54
58
 
55
59
  # Returns attribute value by name (used by XPath engine)
@@ -66,15 +70,14 @@ module Moxml
66
70
  end
67
71
 
68
72
  def remove_attribute(name)
69
- adapter.remove_attribute(@native, name)
70
- @attributes = nil
73
+ Moxml::AttributeResolver.remove(self, name)
71
74
  self
72
75
  end
73
76
 
74
77
  def add_namespace(prefix, uri)
75
78
  adapter.create_namespace(@native, prefix, uri,
76
79
  namespace_validation_mode: context.config.namespace_validation_mode)
77
- @namespaces = nil
80
+ invalidate_namespace_cache!
78
81
  self
79
82
  rescue ValidationError => e
80
83
  # Re-raise as NamespaceError, provide attributes for error context
@@ -106,7 +109,7 @@ module Moxml
106
109
  else
107
110
  adapter.set_namespace(@native, ns_or_hash&.native)
108
111
  end
109
- @namespaces = nil
112
+ invalidate_namespace_cache!
110
113
  end
111
114
 
112
115
  def namespaces
@@ -119,9 +122,14 @@ module Moxml
119
122
  # Returns all namespaces in scope for this element,
120
123
  # including those inherited from ancestor elements.
121
124
  def in_scope_namespaces
122
- adapter.in_scope_namespaces(@native).map do |ns|
123
- Namespace.new(ns, context)
125
+ generation = context.namespace_scope_generation
126
+ if @in_scope_namespaces.nil? || @in_scope_generation != generation
127
+ @in_scope_namespaces = adapter.in_scope_namespaces(@native).map do |ns|
128
+ Namespace.new(ns, context)
129
+ end
130
+ @in_scope_generation = generation
124
131
  end
132
+ @in_scope_namespaces
125
133
  end
126
134
 
127
135
  # Returns the namespace URI of this element (alias for namespace_uri)
@@ -209,5 +217,14 @@ module Moxml
209
217
  def invalidate_attribute_cache!
210
218
  @attributes = nil
211
219
  end
220
+
221
+ # Clear the namespace caches and bump the context's scope
222
+ # generation so every wrapper — including ones not reachable from
223
+ # any children cache — recomputes on next read.
224
+ def invalidate_namespace_cache!
225
+ @namespaces = nil
226
+ @in_scope_namespaces = nil
227
+ context.bump_namespace_scope_generation
228
+ end
212
229
  end
213
230
  end