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
@@ -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
 
@@ -60,18 +61,21 @@ module Moxml
60
61
  # @param handler [Moxml::SAX::Handler] Moxml SAX handler
61
62
  # @return [void]
62
63
  def sax_parse(xml, handler)
63
- require "rexml/parsers/sax2parser"
64
+ require "rexml/parsers/streamparser"
64
65
  require "rexml/source"
65
- require "stringio"
66
66
 
67
- bridge = REXMLSAX2Bridge.new(handler)
67
+ bridge = REXMLStreamBridge.new(handler)
68
68
 
69
69
  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
70
 
72
- parser = ::REXML::Parsers::SAX2Parser.new(source)
73
- parser.listen(bridge)
74
- parser.parse
71
+ # StreamParser (rather than SAX2Parser) is used because
72
+ # SAX2Parser normalizes plain "&#NN;" and "&#NN;" attribute
73
+ # literals to the same raw string, making spec-correct decoding
74
+ # impossible after the fact. StreamParser delivers values in
75
+ # the same decoded form as REXML's DOM.
76
+ handler.on_start_document
77
+ ::REXML::Parsers::StreamParser.new(xml_string, bridge).parse
78
+ handler.on_end_document
75
79
  rescue ::REXML::ParseException => e
76
80
  error = Moxml::ParseError.new(e.message, line: e.line)
77
81
  handler.on_error(error)
@@ -117,15 +121,12 @@ module Moxml
117
121
  def create_native_doctype(name, external_id, system_id)
118
122
  return nil unless name
119
123
 
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(" "))
124
+ external = if external_id
125
+ system_id ? %(PUBLIC "#{external_id}" "#{system_id}") : %(PUBLIC "#{external_id}")
126
+ elsif system_id
127
+ %(SYSTEM "#{system_id}")
128
+ end
129
+ external ? ::REXML::DocType.new(name.to_s, external) : ::REXML::DocType.new(name.to_s)
129
130
  end
130
131
 
131
132
  def set_root(doc, element)
@@ -250,9 +251,17 @@ module Moxml
250
251
  def attributes(element)
251
252
  return [] unless element.is_a?(::REXML::Element)
252
253
 
253
- # Only return non-namespace attributes
254
- element.attributes.values
255
- .reject { |attr| attr.prefix.to_s.start_with?("xmlns") }
254
+ # Each real Attribute node, not Attributes#values: when two
255
+ # attributes share a local name across namespaces, REXML's
256
+ # values leaks its internal prefix-grouping Hash alongside
257
+ # the Attribute objects (issue #95).
258
+ result = []
259
+ element.attributes.each_attribute do |attr|
260
+ next if attr.prefix.to_s.start_with?("xmlns")
261
+
262
+ result << attr
263
+ end
264
+ result
256
265
  end
257
266
 
258
267
  def attribute_element(attribute)
@@ -271,10 +280,17 @@ module Moxml
271
280
  element = attribute.element
272
281
  element.attributes.delete(old_name)
273
282
  element.attributes << attribute
283
+ attribute
274
284
  end
275
285
 
276
286
  def set_attribute_value(attribute, value)
277
287
  attribute.normalized = value
288
+ attribute
289
+ end
290
+
291
+ def remove_attribute_native(attribute)
292
+ attribute.element.attributes.delete(attribute.expanded_name)
293
+ attribute
278
294
  end
279
295
 
280
296
  def get_attribute(element, name)
@@ -468,9 +484,11 @@ module Moxml
468
484
  element.add_namespace(prefix,
469
485
  ns.value)
470
486
  end
487
+ # Renames the node in place (works for elements and
488
+ # attributes alike); the caller keeps tracking the same
489
+ # native.
471
490
  element.name = "#{prefix}:#{element.name}"
472
- owner = element.is_a?(::REXML::Attribute) ? element.element : element
473
- ::REXML::Attribute.new(prefix, ns.value, owner)
491
+ element
474
492
  end
475
493
 
476
494
  def namespace_prefix(node)
@@ -518,11 +536,17 @@ module Moxml
518
536
  end
519
537
 
520
538
  def doctype_external_id(native)
521
- native.public
539
+ # Constructed doctypes keep the identifier as a raw
540
+ # external_id string; parsed ones populate public directly
541
+ native.public ||
542
+ native.external_id&.match(/PUBLIC\s+"([^"]*)"/)&.[](1)
522
543
  end
523
544
 
524
545
  def doctype_system_id(native)
525
- native.system
546
+ return native.system if native.system
547
+
548
+ quoted = native.external_id&.scan(/"([^"]*)"/)
549
+ quoted&.last&.first
526
550
  end
527
551
 
528
552
  # not used at the moment
@@ -642,29 +666,33 @@ module Moxml
642
666
  end
643
667
  end
644
668
 
645
- # Bridge between REXML SAX2 and Moxml SAX
669
+ # Bridge between REXML StreamParser and Moxml SAX
646
670
  #
647
- # Translates REXML::SAX2Parser events to Moxml::SAX::Handler events
671
+ # StreamParser (rather than SAX2Parser) is used because SAX2Parser
672
+ # normalizes plain "&#NN;" and "&amp;#NN;" attribute literals to
673
+ # the same raw string, which makes spec-correct decoding impossible
674
+ # after the fact. StreamParser delivers attribute values in the
675
+ # same decoded form as REXML's DOM.
648
676
  #
649
677
  # @private
650
- class REXMLSAX2Bridge
678
+ class REXMLStreamBridge
679
+ include ::REXML::StreamListener
651
680
  include Moxml::SAX::NamespaceSplitter
652
681
 
653
682
  def initialize(handler)
654
683
  @handler = handler
655
684
  end
656
685
 
657
- # REXML splits element name into uri/localname/qname
658
- def start_element(_uri, _localname, qname, attributes)
686
+ def tag_start(name, attributes)
659
687
  attr_hash, ns_hash = split_attributes_and_namespaces(attributes)
660
- @handler.on_start_element(qname, attr_hash, ns_hash)
688
+ @handler.on_start_element(name, attr_hash, ns_hash)
661
689
  end
662
690
 
663
- def end_element(_uri, _localname, qname)
664
- @handler.on_end_element(qname)
691
+ def tag_end(name)
692
+ @handler.on_end_element(name)
665
693
  end
666
694
 
667
- def characters(text)
695
+ def text(text)
668
696
  @handler.on_characters(text)
669
697
  end
670
698
 
@@ -676,26 +704,9 @@ module Moxml
676
704
  @handler.on_comment(text)
677
705
  end
678
706
 
679
- def processing_instruction(target, data)
707
+ def instruction(target, data)
680
708
  @handler.on_processing_instruction(target, data || "")
681
709
  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
710
  end
700
711
  end
701
712
  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/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
data/lib/moxml/context.rb CHANGED
@@ -6,6 +6,18 @@ 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
+ end
14
+
15
+ def namespace_scope_generation
16
+ @namespace_scope_generation
17
+ end
18
+
19
+ def bump_namespace_scope_generation
20
+ @namespace_scope_generation += 1
9
21
  end
10
22
 
11
23
  def entity_registry
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
@@ -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