moxml 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 51298c0a0ce4ed7c7868e294dc8174ada558b4a1c36c168bc18cdcfe2f728aec
4
- data.tar.gz: a5e5ffce0b817e397bf98852aecfd9bb341d5154a94540f486432ea32dee8788
3
+ metadata.gz: d2a6cac576f8bc4819d27a22825f7ab11fd35a004113c70d6c4986adf542d844
4
+ data.tar.gz: 8f7cbfa869296d65002a6d2b74399ede57b16c2bcc25f4c4c7e9ccab611f5be6
5
5
  SHA512:
6
- metadata.gz: bf7d542cb81a21f1cfa517fafa2c07bdd996b9b7665ebf98e91c4e3f87ce79a1e1fa07f0d8f19837f6f45c73c36663ee54d602b70d72228a8de6e1ccb201a764
7
- data.tar.gz: b971104eebb5bb3383bdacbc895d6cd54f0b4df400126f83f9e8002d80ec3e75c0afc1d3369d2c1277732b64326a2c0c044bb156078fdb6c3bb81a1c9c9fdeab
6
+ metadata.gz: e5b6bf4775a56f4d4fd18cea369d84463085c4df14bdef0ee0ad818ff734864e658bfc41c81bf0e6989cdff2b4ffdff69c74bfc512a7b2debecadd7a892432b6
7
+ data.tar.gz: daf1b85571460bbde0553a52603f255577918dd9eb2e9faf37cb156738277bef51d5bfadc207b74d17177d3f281d9ae4ce4b2db571e22ad112bb595291d124c8
@@ -215,6 +215,16 @@ namespace_validation_mode: :strict)
215
215
  child_native
216
216
  end
217
217
 
218
+ # Whether `Node.wrap` may safely memoize the wrapper for this
219
+ # native across calls. Adapters whose parser hands back the
220
+ # same Ruby object for the same logical node (nokogiri, ox,
221
+ # oga, rexml, leptris) opt in (default true). Adapters that
222
+ # mint a new Ruby object per access (libxml) opt out so the
223
+ # identity map does not accumulate dead entries.
224
+ def wrappers_recyclable?
225
+ true
226
+ end
227
+
218
228
  # Returns all namespaces in scope for this element, including
219
229
  # inherited from ancestors. Adapters with native support (Nokogiri)
220
230
  # override this. Default walks the ancestor chain.
@@ -559,8 +559,16 @@ module Moxml
559
559
  # @return [Array, Object, nil] native results, or nil when the
560
560
  # query must run on the Ruby engine
561
561
  def native_xpath(node, expression, namespaces, first_only: false)
562
- return nil unless node.is_a?(::Leptris::XML::Document)
562
+ return nil unless native_context_node?(node)
563
563
  return nil unless native_expression?(expression)
564
+ # The binding reports the root element parentless while moxml
565
+ # roots it at the document, so parent-axis queries from the
566
+ # root element keep the Ruby engine.
567
+ if node.is_a?(::Leptris::XML::Element) &&
568
+ node.document&.root.equal?(node) &&
569
+ expression_uses_parent_axis?(expression)
570
+ return nil
571
+ end
564
572
 
565
573
  compiled = NATIVE_XPATH_CACHE.get_or_set(expression) do
566
574
  ::Leptris::XML::XPath.compile(expression)
@@ -584,6 +592,27 @@ module Moxml
584
592
  nil
585
593
  end
586
594
 
595
+ def native_context_node?(node)
596
+ node.is_a?(::Leptris::XML::Document) ||
597
+ node.is_a?(::Leptris::XML::Element)
598
+ end
599
+
600
+ def expression_uses_parent_axis?(expression)
601
+ ast = XPath::Parser.parse_with_cache(expression)
602
+ contains_parent_axis?(ast)
603
+ rescue XPath::SyntaxError
604
+ true
605
+ end
606
+
607
+ def contains_parent_axis?(ast)
608
+ return true if ast.type == :parent
609
+ return true if ast.type == :axis && ast.children.first == "parent"
610
+
611
+ ast.children.any? do |child|
612
+ child.is_a?(XPath::AST::Node) && contains_parent_axis?(child)
613
+ end
614
+ end
615
+
587
616
  # Document-context queries without variable references,
588
617
  # attribute-node results, or the nokogiri-compat xmlns:
589
618
  # reserved prefix convention.
@@ -67,6 +67,13 @@ module Moxml
67
67
  doc.root = element
68
68
  end
69
69
 
70
+ # libxml-ruby mints a fresh Ruby object for the same C node on
71
+ # each access; the wrapper identity map would accumulate dead
72
+ # entries without bound at full mutation throughput.
73
+ def wrappers_recyclable?
74
+ false
75
+ end
76
+
70
77
  def parse(xml, options = {}, _context = nil)
71
78
  # LibXML doesn't preserve DOCTYPE during parsing, so we need to extract it manually
72
79
  xml_string = if xml.is_a?(String)
@@ -1006,18 +1013,17 @@ module Moxml
1006
1013
 
1007
1014
  if native_node.root
1008
1015
  indent_size = options[:indent].is_a?(Integer) && options[:indent].positive? ? options[:indent] : 0
1009
- # Custom serializer emits newlines AND indentation directly —
1010
- # no separate add_newlines_to_xml / indent_xml passes.
1011
1016
  # `eref_active` is computed once here and threaded through the
1012
1017
  # recursion so that the per-element `attachments.key?` Monitor
1013
1018
  # sync only fires for docs that actually have entity refs.
1014
1019
  eref_active = entity_ref_registry(native_node).active?
1015
- root_output = serialize_element_with_namespaces(
1020
+ root_output = native_root_output(native_node, indent_size)
1021
+ root_output ||= serialize_element_with_namespaces(
1016
1022
  native_node.root,
1017
- true,
1018
- indent_size,
1019
- 0,
1020
- eref_active: eref_active,
1023
+ true,
1024
+ indent_size,
1025
+ 0,
1026
+ eref_active: eref_active,
1021
1027
  )
1022
1028
 
1023
1029
  output << "\n" << root_output unless output.empty?
@@ -1030,6 +1036,46 @@ module Moxml
1030
1036
  end
1031
1037
  end
1032
1038
 
1039
+ # Possessive-quantifier form: no backtracking, so pathological
1040
+ # attribute content cannot blow up the expansion pass.
1041
+ EMPTY_ELEMENT_EXPANSION_RE = %r{
1042
+ <([A-Za-z_][\w.:-]*+)
1043
+ ((?:"[^"]*+"|'[^']*+'|[^<>"'/]++)*+)
1044
+ />
1045
+ }x
1046
+ private_constant :EMPTY_ELEMENT_EXPANSION_RE
1047
+
1048
+ # Serialize the root subtree with libxml's C serializer plus
1049
+ # moxml-canonical corrections — roughly 25x faster and ~2600x
1050
+ # fewer allocations than the Ruby walker. Returns nil whenever
1051
+ # a guard says the walker is still required.
1052
+ def native_root_output(native_doc, indent_size)
1053
+ return nil unless indent_size == 2
1054
+ return nil if entity_ref_registry(native_doc).active?
1055
+
1056
+ output = native_doc.root.to_s
1057
+
1058
+ # The walker strips prefixed names on parsed namespaced
1059
+ # children; that behavior is load-bearing, so namespaced
1060
+ # output keeps the walker.
1061
+ return nil if output.include?("xmlns")
1062
+
1063
+ # Layout: pull closing tags onto the last child's line
1064
+ output = output.gsub(/\n[ \t]*(<\/[\w.:-]+>)/, '\1')
1065
+
1066
+ if output.include?("/>")
1067
+ # A literal "/>" inside a comment or CDATA section would be
1068
+ # falsely expanded — the segment-aware walker is correct here
1069
+ return nil if output.include?("<!--") || output.include?("<![CDATA[")
1070
+
1071
+ output = output.gsub(EMPTY_ELEMENT_EXPANSION_RE, '<\1\2></\1>')
1072
+ end
1073
+
1074
+ # Native escapes attribute apostrophes; moxml keeps them literal
1075
+ output.gsub("&apos;", "'")
1076
+
1077
+ end
1078
+
1033
1079
  # Shallow duplication: copies the node itself (name, attrs, namespaces)
1034
1080
  # but NOT its descendants (deep duplication composes this).
1035
1081
  # walks the source tree and re-adds children one at a time via
data/lib/moxml/context.rb CHANGED
@@ -22,9 +22,12 @@ module Moxml
22
22
  end
23
23
 
24
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.
25
+ # Safety valve + adapter opt-in. Adapters whose natives are
26
+ # recreated per access (libxml mints fresh Ruby objects for the
27
+ # same C node) opt out so the map does not accumulate dead
28
+ # entries; the default is opt-in.
29
+ return if @config&.adapter&.wrappers_recyclable? == false
30
+
28
31
  @wrappers.clear if @wrappers.size >= 65_536
29
32
  @wrappers[native] = wrapper
30
33
  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.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -22,6 +22,7 @@ module Moxml
22
22
  class Compiler
23
23
  # Shared context for compiled Procs
24
24
  CONTEXT = Context.new
25
+ private_constant :CONTEXT
25
26
 
26
27
  # Expression cache
27
28
  CACHE = Cache.new
@@ -1834,7 +1835,7 @@ module Moxml
1834
1835
 
1835
1836
  # Helper: Check if AST node is a number
1836
1837
  def number?(ast)
1837
- %i[int float number].include?(ast.type)
1838
+ ast.type == :number
1838
1839
  end
1839
1840
 
1840
1841
  # Helper: Check if AST contains a call node with given name
@@ -58,8 +58,16 @@ RSpec.describe Moxml::Adapter::Leptris do
58
58
  expect(attrs.map(&:value)).to eq(%w[1 2])
59
59
  end
60
60
 
61
- it "falls back to the Ruby engine for element-context queries" do
61
+ it "evaluates element-context queries on the native engine" do
62
62
  expect(doc.root.xpath(".//item").size).to eq(2)
63
+ first_item = doc.root.children.find(&:element?)
64
+ expect(first_item.xpath("following-sibling::*").map(&:name)).to eq(%w[item])
65
+ end
66
+
67
+ it "falls back to the Ruby engine for parent-axis queries from the root" do
68
+ # The binding reports the root element parentless; moxml roots
69
+ # it at the document
70
+ expect(doc.root.xpath("../*").size).to eq(1)
63
71
  end
64
72
 
65
73
  it "falls back to the Ruby engine for the xmlns: reserved prefix" do
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.