moxml 0.5.143 → 0.5.146

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7ef456b1833d080915877e15238d23fd1e3d38bd638a434711bc98d423b78fc
4
- data.tar.gz: 680fe1bbe7097895d5fdb46c29e5e2c624b13ed29792be2ffa941ef3c8bbe3bb
3
+ metadata.gz: b529948a14e67202c6ad6fc9acbe6c06c033ba022f93ada6bc9e4001554ea583
4
+ data.tar.gz: 68a76e1a69397aef0d0356e3ddd7bc200737a41333a70d6688cfca34d44e4c27
5
5
  SHA512:
6
- metadata.gz: 01e458bb31733bbb8a9bf379d37ba30b4d5b30d1670eceffbfad5ae953afa85f1dc272ce3cc99cf719808417a13035dadf00e14fdb6a01d5cce3f4bcc855e8ac
7
- data.tar.gz: e0eae403141507dcc8dd23e3b6962935cac3481382ef268a41105ae919e014fc53e657e4b44d261cdb8e59799678dc0a2f1f103937580ee6bb7b8f4e93372667
6
+ metadata.gz: 3775a27419325f8c764d6d686c547e88f75bdc073cbe354bdd97d2e0516e68a4f83896d4cd840c25be26f7d6aafdca24a596d3c6aa2893e8596ed95d646c16fa
7
+ data.tar.gz: 84c2f0abec2a55d990cdc921f9abac0c9206def8190d609fd298256dd035d586334dedc04bb97af496a98fe9053b2fa3b65f716a11e22e94516f2f8b91837e1b
@@ -463,25 +463,72 @@ module Moxml
463
463
  # instances). The hot bare reads super into the C methods
464
464
  # (exact-name = the bare-name contract).
465
465
  module Identity
466
+ # The C faces are captured before the contract modules
467
+ # shadow them — alias copies bind at this point, so the
468
+ # overrides below plain-dispatch the native reads
469
+ # (leptris-ruby#463 completed the surface: every contract
470
+ # face is a C method at 50-84ns).
466
471
  ELEMENT = Class.new(::Leptris::XML::NativeNode) do
472
+ alias_method :native_name, :name
467
473
  include ::Moxml::Node
468
474
  include ::Moxml::Element
475
+
476
+ # Memo form kept: a warm read is an ivar hit (~30ns
477
+ # vs the C call's 84ns); the cold read drops the
478
+ # adapter round-trip and its PI-check bind_call.
479
+ def name
480
+ @name ||= native_name
481
+ end
469
482
  end
470
483
  TEXT = Class.new(::Leptris::XML::NativeNode) do
484
+ alias_method :native_content, :content
471
485
  include ::Moxml::Node
472
486
  include ::Moxml::Text
487
+
488
+ # Text#content's memo + entity restore are the
489
+ # contract; the backing read drops the adapter
490
+ # round-trip (dispatch + is_a? + bind_call) for the
491
+ # C face.
492
+ def raw_content
493
+ native_content
494
+ end
473
495
  end
474
496
  CDATA = Class.new(::Leptris::XML::NativeNode) do
497
+ alias_method :native_content, :content
475
498
  include ::Moxml::Node
476
499
  include ::Moxml::Cdata
500
+
501
+ def content
502
+ native_content
503
+ end
477
504
  end
478
505
  COMMENT = Class.new(::Leptris::XML::NativeNode) do
506
+ alias_method :native_content, :content
479
507
  include ::Moxml::Node
480
508
  include ::Moxml::Comment
509
+
510
+ # The nil bridge stays for floor-admitted bindings
511
+ # where the native face answers nil for comment-kind
512
+ # (leptris-ruby#313).
513
+ def content
514
+ content = native_content
515
+ return ::Moxml::Adapter::Leptris.to_binding(self).content if content.nil?
516
+
517
+ content
518
+ end
481
519
  end
482
520
  PROCESSING_INSTRUCTION = Class.new(::Leptris::XML::NativeNode) do
521
+ alias_method :native_content, :content
483
522
  include ::Moxml::Node
484
523
  include ::Moxml::ProcessingInstruction
524
+
525
+ # Same nil bridge as Comment (leptris-ruby#344).
526
+ def content
527
+ content = native_content
528
+ return ::Moxml::Adapter::Leptris.to_binding(self).content if content.nil?
529
+
530
+ content
531
+ end
485
532
  end
486
533
 
487
534
  TYPES = {
@@ -537,6 +584,46 @@ module Moxml
537
584
  end
538
585
  ELEMENT.include(Reads)
539
586
  TEXT.include(Reads)
587
+
588
+ # leptris-ruby#503: name_read joins attr_read/text_read as
589
+ # the unshadowed contract faces. With them, the hot reads
590
+ # plain-dispatch the C faces (one Ruby frame over the
591
+ # ~45ns native call) instead of the adapter bind_call
592
+ # crossing. Face-probed — older bindings keep the
593
+ # adapter paths. The memos ride the existing
594
+ # invalidation: name= and clear_native_memo! clear
595
+ # @name, content= clears @content.
596
+ if NN.method_defined?(:name_read)
597
+ module ContractReads
598
+ def name
599
+ @name ||= name_read
600
+ end
601
+ end
602
+ ELEMENT.include(ContractReads)
603
+ end
604
+
605
+ if NN.method_defined?(:text_read)
606
+ module TextContractReads
607
+ # The stamp fast path is inlined (same ivar protocol
608
+ # as Reads#doc_entity_bearing?, which stays the
609
+ # single writer on recompute) — the two frames it
610
+ # saves are ~20% of the cold read.
611
+ def content
612
+ value = @content
613
+ return value if value
614
+
615
+ value = text_read
616
+ stamp = @doc_entity_stamp
617
+ if stamp && (stamp >> 1) == ::Moxml::Adapter::Leptris.serialize_generation
618
+ value = ::Moxml::Adapter::Leptris.restore_entities(value) if stamp.allbits?(1)
619
+ elsif doc_entity_bearing?
620
+ value = ::Moxml::Adapter::Leptris.restore_entities(value)
621
+ end
622
+ @content = value
623
+ end
624
+ end
625
+ TEXT.include(TextContractReads)
626
+ end
540
627
  end
541
628
 
542
629
  # leptris-ruby#246 klass-propagating reads: the read
@@ -2842,6 +2929,7 @@ module Moxml
2842
2929
  PARENT_AXIS_CACHE = XPath::Cache.new(100)
2843
2930
 
2844
2931
  def xpath(node, expression, namespaces = {})
2932
+ namespaces = normalize_xpath_namespaces(namespaces)
2845
2933
  node = to_binding(node) if NATIVE_READ_LAYER
2846
2934
  native = native_xpath(node, expression, namespaces)
2847
2935
  return native unless native.nil?
@@ -2873,6 +2961,7 @@ module Moxml
2873
2961
  end
2874
2962
 
2875
2963
  def at_xpath(node, expression, namespaces = {})
2964
+ namespaces = normalize_xpath_namespaces(namespaces)
2876
2965
  native = native_xpath(node, expression, namespaces, first_only: true)
2877
2966
  return native unless native.nil?
2878
2967
 
@@ -2880,6 +2969,16 @@ module Moxml
2880
2969
  result.is_a?(Array) ? result.first : result
2881
2970
  end
2882
2971
 
2972
+ # Bindings arrive in both spellings (symbol keys are the
2973
+ # common Nokogiri idiom); every seam below — the "xmlns"
2974
+ # reserved-key check and the native pairs set — is
2975
+ # string-keyed (issue #395).
2976
+ def normalize_xpath_namespaces(namespaces)
2977
+ return namespaces unless namespaces.is_a?(Hash)
2978
+
2979
+ namespaces.transform_keys(&:to_s)
2980
+ end
2981
+
2883
2982
  # @return [Array, Object, nil] native results, or nil when the
2884
2983
  # query must run on the Ruby engine
2885
2984
  def native_xpath(node, expression, namespaces, first_only: false)
@@ -607,6 +607,11 @@ module Moxml
607
607
  # XPath.match for every expression: get_elements only
608
608
  # handles simple child-name paths — attribute terminals,
609
609
  # absolute paths and predicates silently degrade there.
610
+ # REXML resolves prefixes by string spelling — symbol-keyed
611
+ # bindings (the common Nokogiri idiom) must normalize or
612
+ # namespaced elements never match (issue #395).
613
+ namespaces = namespaces.transform_keys(&:to_s) if
614
+ namespaces.is_a?(Hash)
610
615
  if namespaces && !namespaces.empty?
611
616
  ::REXML::XPath.match(node, expression, namespaces).to_a
612
617
  else
@@ -227,7 +227,21 @@ module Moxml
227
227
  self
228
228
  end
229
229
 
230
+ # Relative name-test paths on the document resolve laxly, as
231
+ # descendant-or-self anchored at the root — Nokogiri's own
232
+ # Document#at/#search semantics (moxml#393; its #xpath/#at_xpath are
233
+ # strict document-context, where the only element child is the root,
234
+ # and the adapters disagree there anyway: leptris's engine returns []
235
+ # for every relative path — leptris-ruby#502 — while Nokogiri's
236
+ # #at/#xpath disagree with each other). Every strict match survives
237
+ # the lax rewrite (the only strict-matchable first step is the root
238
+ # itself), so this is purely additive. Non-name-test expressions
239
+ # (axes, node types, ".", "..") keep document context.
240
+ RELATIVE_NAME_TEST = /\A\s*(\*|[A-Za-z_][\w.-]*)(\s*\[|\s*\||\z|\/)/
241
+
230
242
  def xpath(expression, namespaces = nil)
243
+ expression = lax_document_path(expression) if relative_name_test?(expression)
244
+
231
245
  result = adapter.xpath(@native, expression, namespaces)
232
246
 
233
247
  # Handle different result types:
@@ -246,6 +260,8 @@ module Moxml
246
260
  end
247
261
 
248
262
  def at_xpath(expression, namespaces = nil)
263
+ expression = lax_document_path(expression) if relative_name_test?(expression)
264
+
249
265
  if (native_node = adapter.at_xpath(@native, expression, namespaces))
250
266
  Moxml::Node.wrap(native_node, context)
251
267
  end
@@ -268,5 +284,44 @@ module Moxml
268
284
  def find_all(xpath)
269
285
  xpath(xpath).to_a
270
286
  end
287
+
288
+ private
289
+
290
+ def relative_name_test?(expression)
291
+ expression.match?(RELATIVE_NAME_TEST)
292
+ end
293
+
294
+ # Anchor every top-level union branch at the document root
295
+ # ("//"= descendant-or-self): "a/b | c" -> "//a/b | //c" — one
296
+ # adapter call, engine document order preserved, and the
297
+ # attribute-terminal idiom (pinned below) stays intact, which the
298
+ # ".//" form does not (libxml2 rejects .//r/a/@id).
299
+ def lax_document_path(expression)
300
+ split_top_level_unions(expression)
301
+ .map { |branch| "//#{branch.strip}" }
302
+ .join(" | ")
303
+ end
304
+
305
+ def split_top_level_unions(expression)
306
+ branches = [+""]
307
+ depth = 0
308
+ quote = nil
309
+ expression.each_char do |ch|
310
+ if quote
311
+ quote = nil if ch == quote
312
+ elsif ["'", '"'].include?(ch)
313
+ quote = ch
314
+ elsif ["(", "["].include?(ch)
315
+ depth += 1
316
+ elsif [")", "]"].include?(ch)
317
+ depth -= 1
318
+ elsif ch == "|" && depth.zero?
319
+ branches << +""
320
+ next
321
+ end
322
+ branches.last << ch
323
+ end
324
+ branches
325
+ end
271
326
  end
272
327
  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.5.143"
4
+ VERSION = "0.5.146"
5
5
  end
@@ -40,6 +40,7 @@ module Moxml
40
40
  # @param namespaces [Hash, nil] Optional namespace prefix mappings
41
41
  # @return [Proc] Compiled Proc that accepts a document
42
42
  def self.compile_with_cache(ast, namespaces: nil)
43
+ namespaces = normalize_namespaces(namespaces)
43
44
  cache_key = namespaces ? [ast, namespaces] : ast
44
45
  CACHE.get_or_set(cache_key) { new(namespaces: namespaces).compile(ast) }
45
46
  end
@@ -48,12 +49,24 @@ module Moxml
48
49
  #
49
50
  # @param namespaces [Hash, nil] Optional namespace prefix mappings
50
51
  def initialize(namespaces: nil)
51
- @namespaces = namespaces
52
+ @namespaces = self.class.normalize_namespaces(namespaces)
52
53
  @literal_id = 0
53
54
  @predicate_nodesets = []
54
55
  @predicate_indexes = []
55
56
  end
56
57
 
58
+ # Prefix lookups are string-keyed (the parser yields prefix
59
+ # strings); symbol-keyed bindings — the common Nokogiri spelling
60
+ # (`xpath("//cml:molecule", cml: URI)`) — must resolve too, or
61
+ # the miss falls through to prefix-spelling matching and
62
+ # namespaced elements never match (issue #395).
63
+ def self.normalize_namespaces(namespaces)
64
+ return nil if namespaces.nil?
65
+ return namespaces unless namespaces.is_a?(Hash)
66
+
67
+ namespaces.transform_keys(&:to_s)
68
+ end
69
+
57
70
  # Compiles an XPath AST into a Ruby Proc
58
71
  #
59
72
  # @param ast [AST::Node] XPath AST to compile
@@ -11,11 +11,22 @@ require "spec_helper"
11
11
  # - Attribute-terminal steps work in every path position
12
12
  # (leptris-ruby#458's table: //a/@id, //@id, r/a/@id,
13
13
  # //*[f()]/@id — fixed by the engine's VM path work).
14
+ CML_NS = "http://www.xml-cml.org/schema"
15
+ ACI_NS = "http://www.xml-cml.org/schema/asciichem"
16
+
14
17
  RSpec.describe "XPath idioms across adapters" do
15
18
  let(:default_ns_xml) do
16
19
  '<svgmap xmlns="http://www.example.org/doc"><target href="a"/></svgmap>'
17
20
  end
18
21
  let(:plain_xml) { '<r><a id="x"><b/></a></r>' }
22
+ let(:cml_xml) do
23
+ <<~XML
24
+ <molecule xmlns="#{CML_NS}" xmlns:aci="#{ACI_NS}" id="m1">
25
+ <atomArray><atom id="a1"/></atomArray>
26
+ <aci:group multiplicity="2" bracket="paren" atomRefs="a1"/>
27
+ </molecule>
28
+ XML
29
+ end
19
30
 
20
31
  Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
21
32
  next if adapter_name == :headed_ox # same engine as :ox
@@ -53,9 +64,48 @@ RSpec.describe "XPath idioms across adapters" do
53
64
  expect(doc.xpath("//a/@id").first&.value).to eq("x")
54
65
  expect(doc.xpath("//@id").first&.value).to eq("x")
55
66
  expect(doc.xpath("r/a/@id").first&.value).to eq("x")
56
- expect(
57
- doc.xpath("//*[local-name()='a']/@id").first&.value,
58
- ).to eq("x")
67
+ expect(doc.xpath("//*[local-name()='a']/@id").first&.value).to eq("x")
68
+ end
69
+
70
+ # Issue #395: bindings hashes arrive in both key spellings
71
+ # (symbols are the common Nokogiri idiom); the engines must
72
+ # resolve both — a symbol-key miss falls through to
73
+ # prefix-spelling matching and namespaced elements never match.
74
+ # asciichem's CML group extraction rides exactly this shape.
75
+ it "resolves symbol-keyed namespace bindings" do
76
+ doc = ctx.parse(cml_xml)
77
+ mols = doc.xpath("//cml:molecule", cml: CML_NS)
78
+ expect(mols.to_a.size).to eq(1)
79
+
80
+ groups = mols.first.xpath("./aci:group", aci: ACI_NS)
81
+ expect(groups.to_a.size).to eq(1)
82
+ expect(groups.first["multiplicity"]).to eq("2")
83
+ end
84
+
85
+ it "resolves string-keyed namespace bindings identically" do
86
+ doc = ctx.parse(cml_xml)
87
+ expect(doc.xpath("//cml:molecule", "cml" => CML_NS).to_a.size).to eq(1)
88
+ end
89
+
90
+ # moxml#393: a relative name-test path on the DOCUMENT resolves
91
+ # laxly, descendant-anchored at the root — Nokogiri's own
92
+ # Document#at/#search semantics, now pinned for every adapter.
93
+ # Axes, node types and "." keep strict document context.
94
+ it "resolves document-context relative name-test paths from the root" do
95
+ doc = ctx.parse(plain_xml)
96
+ expect(doc.at("a/b")&.name).to eq("b")
97
+ expect(doc.at_xpath("a/b")&.name).to eq("b")
98
+ expect(doc.xpath("a/b").map(&:name)).to eq(["b"])
99
+ expect(doc.xpath("a | r").map(&:name).sort).to eq(%w(a r))
100
+ expect(doc.xpath("missing").map(&:name)).to eq([])
101
+ expect(doc.at_xpath("missing")).to be_nil
102
+ end
103
+
104
+ it "keeps non-name-test expressions in document context" do
105
+ doc = ctx.parse(plain_xml)
106
+ expect(doc.at_xpath(".")).to be_truthy
107
+ expect(doc.at_xpath("/*")&.name).to eq("r")
108
+ expect(doc.at_xpath("/r")&.name).to eq("r")
59
109
  end
60
110
  end
61
111
  end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # leptris-ruby#460 — the Identity contract reads ride the C faces
6
+ # by plain dispatch (NativeNode subclasses inherit the C-defined
7
+ # name/content since binding 1.9.359). The wrapper memo stays; the
8
+ # ADAPTER CROSSING (bind_call through node_name/text_content) goes.
9
+ RSpec.describe "leptris Identity contract reads" do
10
+ let(:ctx) { Moxml.new(:leptris) }
11
+
12
+ before do
13
+ unless Moxml::Adapter::Leptris::NATIVE_READ_LAYER &&
14
+ Leptris::XML::NativeNode.method_defined?(:name) &&
15
+ Leptris::XML::NativeNode.method_defined?(:content)
16
+ skip "binding #{defined?(Leptris) && Leptris::VERSION} lacks the C contract faces"
17
+ end
18
+ end
19
+
20
+ def bust(node, *ivars)
21
+ ivars.each do |iv|
22
+ node.remove_instance_variable(iv) if node.instance_variable_defined?(iv)
23
+ end
24
+ end
25
+
26
+ def forbid_crossing!(method)
27
+ singleton = Moxml::Adapter::Leptris.singleton_class
28
+ singleton.send(:alias_method, :"#{method}_without_460", method)
29
+ singleton.send(:define_method, method) { |*| raise "adapter #{method} crossing" }
30
+ method
31
+ end
32
+
33
+ def restore_crossing!(method)
34
+ singleton = Moxml::Adapter::Leptris.singleton_class
35
+ singleton.send(:alias_method, method, :"#{method}_without_460")
36
+ singleton.send(:remove_method, :"#{method}_without_460")
37
+ end
38
+
39
+ describe "element#name" do
40
+ it "answers without the adapter node_name crossing" do
41
+ unless Leptris::XML::NativeNode.method_defined?(:name_read)
42
+ skip "binding #{Leptris::VERSION} has no name_read face (adapter path is correct there)"
43
+ end
44
+ doc = ctx.parse('<root><item id="1">v</item></root>')
45
+ el = doc.root.children.first
46
+ expect(el.native).to be_a(Leptris::XML::NativeNode)
47
+
48
+ bust(el, :@name)
49
+ forbid_crossing!(:node_name)
50
+ begin
51
+ expect(el.name).to eq("item")
52
+ ensure
53
+ restore_crossing!(:node_name)
54
+ end
55
+ end
56
+
57
+ it "answers the local name for prefixed elements (cross-adapter contract)" do
58
+ doc = ctx.parse('<root xmlns:ns="urn:u"><ns:p/></root>')
59
+ el = doc.root.children.first
60
+ bust(el, :@name)
61
+ expect(el.name).to eq("p")
62
+ end
63
+
64
+ it "memoizes: warm reads return the same String" do
65
+ doc = ctx.parse("<root><item/></root>")
66
+ el = doc.root.children.first
67
+ first = el.name
68
+ expect(el.name).to equal(first)
69
+ end
70
+
71
+ it "re-reads after name=" do
72
+ doc = ctx.parse("<root><item/></root>")
73
+ el = doc.root.children.first
74
+ el.name = "renamed"
75
+ expect(el.name).to eq("renamed")
76
+ end
77
+ end
78
+
79
+ describe "text#content" do
80
+ it "answers without the adapter text_content crossing" do
81
+ unless Leptris::XML::NativeNode.method_defined?(:text_read)
82
+ skip "binding #{Leptris::VERSION} has no text_read face (adapter path is correct there)"
83
+ end
84
+ doc = ctx.parse("<root><item>v</item></root>")
85
+ tx = doc.root.children.first.children.first
86
+ expect(tx.native).to be_a(Leptris::XML::NativeNode)
87
+
88
+ bust(tx, :@content)
89
+ forbid_crossing!(:text_content)
90
+ begin
91
+ expect(tx.content).to eq("v")
92
+ ensure
93
+ restore_crossing!(:text_content)
94
+ end
95
+ end
96
+
97
+ it "memoizes: warm reads return the same String" do
98
+ doc = ctx.parse("<root><item>v</item></root>")
99
+ tx = doc.root.children.first.children.first
100
+ first = tx.content
101
+ expect(tx.content).to equal(first)
102
+ end
103
+
104
+ it "re-reads after content=" do
105
+ doc = ctx.parse("<root><item>v</item></root>")
106
+ tx = doc.root.children.first.children.first
107
+ tx.content = "changed"
108
+ expect(tx.content).to eq("changed")
109
+ end
110
+
111
+ it "restores entities on entity-bearing documents" do
112
+ ctx2 = Moxml.new(:leptris) { |c| c.restore_entities = true }
113
+ doc = ctx2.parse("<root><item>a &amp; b</item></root>")
114
+ tx = doc.root.children.first.children.first
115
+ bust(tx, :@content)
116
+ expect(tx.content).to eq("a & b")
117
+ end
118
+ end
119
+
120
+ describe "the untouched faces" do
121
+ it "keeps text wrappers named text" do
122
+ doc = ctx.parse("<root><item>v</item></root>")
123
+ tx = doc.root.children.first.children.first
124
+ expect(tx.name).to eq("text")
125
+ end
126
+
127
+ it "keeps element#text on the existing path" do
128
+ doc = ctx.parse("<root><item>v</item></root>")
129
+ el = doc.root.children.first
130
+ expect(el.text).to eq("v")
131
+ end
132
+ end
133
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moxml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.143
4
+ version: 0.5.146
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -452,6 +452,7 @@ files:
452
452
  - spec/moxml/adapter/leptris_append_gc_spec.rb
453
453
  - spec/moxml/adapter/leptris_attribute_snapshot_spec.rb
454
454
  - spec/moxml/adapter/leptris_binding_cap_spec.rb
455
+ - spec/moxml/adapter/leptris_contract_reads_spec.rb
455
456
  - spec/moxml/adapter/leptris_doc_parts_spec.rb
456
457
  - spec/moxml/adapter/leptris_document_declaration_spec.rb
457
458
  - spec/moxml/adapter/leptris_inbody_comment_spec.rb