moxml 0.5.18 → 0.5.19

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: c793d84315c4005a5141698aeb716c001d23b7157b768c77cbdb3539a4b08f53
4
- data.tar.gz: 35c1d60e6d3a8e17e9768c733d028962bcb6e5798e3c8528b18deaebd855ae5f
3
+ metadata.gz: 62f62c2c744354f3dc8c6436cd5404eac78738bc3da00509c1daed9e827d5195
4
+ data.tar.gz: 25cddfe9066c8ebda91925a47834fb3180aaefc2b7be5f74b32409a3bdd44ffb
5
5
  SHA512:
6
- metadata.gz: '092b193420e9117adbd6dade8718474c9a72de401eb0b7a741d36639bbd7e9285c7e53d55f96b482b17bea3382d1da3adb1cae6bbc7b787c552859d80377db39'
7
- data.tar.gz: 18a342a60a7dda872730dd2a9158aa852948596e21fc4613961d0336ece2f12977d02fe434e7fa64429f3ce73015a4434285f3f052425c4eb6d88c20a9571db7
6
+ metadata.gz: 4598f34a5af3aa247349f0bf0cd5ea99fceb2a60c02412b31b3e6ecc391f0e5292668b5ca9c8bb2be304dee1cc7e3706e932ffe3c73ceaa9e0f9d45476c4fa24
7
+ data.tar.gz: e7c39fcc1a5dad848d9b4484e6065b3af1a890a8b4085a5d7cf731d20010a480d6ef0e2c6f8cbd7a579cc9c524360364475c2fb29c1704632fa7472a25a2eabc
@@ -53,3 +53,8 @@ jobs:
53
53
 
54
54
  - name: Run rubocop
55
55
  run: bundle exec rubocop
56
+
57
+ - name: Run performance gates
58
+ env:
59
+ RUN_PERFORMANCE: "1"
60
+ run: bundle exec rake spec:performance
data/Rakefile CHANGED
@@ -222,8 +222,9 @@ namespace :spec do
222
222
  t.pattern = "spec/examples/**/*_spec.rb"
223
223
  end
224
224
 
225
- desc "Run performance benchmarks"
225
+ desc "Run performance benchmarks (sets RUN_PERFORMANCE=1)"
226
226
  RSpec::Core::RakeTask.new(:performance) do |t|
227
+ ENV["RUN_PERFORMANCE"] = "1"
227
228
  t.pattern = "spec/performance/**/*_spec.rb"
228
229
  t.rspec_opts = "--tag performance"
229
230
  end
@@ -288,4 +289,4 @@ namespace :opal do
288
289
  end
289
290
  end
290
291
 
291
- task default: %i[spec rubocop]
292
+ task default: :spec
@@ -59,8 +59,8 @@ Serialization::
59
59
  `serialize(node, options)`. Options: `indent`, `encoding`,
60
60
  `declaration`, `no_declaration`, `expand_empty`, `line_ending`,
61
61
  `indent_text` (the indent-unit string — Nokogiri and
62
- leptris >= 1.9.45; leptris text-bearing leaves wait on
63
- leptris/leptris#658).
62
+ leptris >= 1.9.45; its element unit path
63
+ drops comments — leptris-ruby#115).
64
64
  Entity-marker restoration is owned by the wrapper layer
65
65
  (`Node#to_xml`), guarded by `entity_bearing?`.
66
66
 
@@ -31,9 +31,11 @@ ctx.config.adapter_name # => :leptris / :nokogiri / :rexml (Opal)
31
31
  == Parse options
32
32
 
33
33
  `noblanks: true`::
34
- Drop whitespace-only text nodes (libxml2 `XML_PARSE_NOBLANKS`
35
- parity, issue #153) the parse shape canon's pretty-printer
36
- compares against `Nokogiri::XML(xml, &:noblanks)`.
34
+ Drop whitespace-only text nodes libxml2 `XML_PARSE_NOBLANKS`
35
+ semantics (issues #153/#156): boundary spaces of text-bearing
36
+ nodes survive. Implemented moxml-side (the engine flag trims
37
+ boundaries too — leptris/leptris/#677); requires a mutable
38
+ document, so `readonly: true` + `noblanks: true` raises.
37
39
 
38
40
  `readonly: true`::
39
41
  The binding memoizes every read and refuses mutations — the
@@ -68,9 +70,10 @@ implementation over one `leptris_node_traverse` call.
68
70
  == Known limitations (tracked upstream)
69
71
 
70
72
  * Tab indentation honors the indent-unit string on leptris >= 1.9.45
71
- (`indent_text:`), byte-identical to Nokogiri on element-only
72
- trees; text-bearing leaves still emit spaces until the engine fix
73
- (leptris/leptris#658).
73
+ (`indent_text:`), byte-identical to Nokogiri including
74
+ text-bearing children since the engine fix (leptris/leptris#658,
75
+ 1.9.46); comment children still diverge on the element unit path
76
+ (leptris-ruby#115).
74
77
  * A PI preceding the DOCTYPE reorders on serialization — libleptris
75
78
  normalizes DOCTYPE-first and the document node does not record its
76
79
  relative position.
@@ -106,6 +106,18 @@ module Moxml
106
106
  ctx = _context || Context.new(:leptris)
107
107
  doc = Document.new(native_doc, ctx)
108
108
 
109
+ if options[:noblanks]
110
+ # The engine's DROP_WS_TEXT trims boundary whitespace of
111
+ # NON-blank text nodes at parse (leptris/leptris#677), so
112
+ # the flag is never forwarded — blanks drop here instead.
113
+ if options[:readonly] == true
114
+ raise ArgumentError,
115
+ "noblanks: true requires a mutable document (readonly: true freezes the tree at parse)"
116
+ end
117
+
118
+ strip_blank_text_nodes!(native_doc)
119
+ end
120
+
109
121
  record_source_declaration(native_doc, processed)
110
122
  attachments.set(native_doc, :entity_markers, entity_markers)
111
123
  attachments.set(native_doc, :parse_errors, recover_errors) if recover_errors
@@ -116,15 +128,57 @@ module Moxml
116
128
  # nil when no parse flag is requested — the binding treats a
117
129
  # nil options hash as plain defaults (matching libxml2/
118
130
  # Nokogiri semantics: blanks kept, no ATTLIST defaults).
119
- # noblanks (issue #153) drops whitespace-only text nodes,
120
- # libxml2 XML_PARSE_NOBLANKS parity.
131
+ # noblanks is deliberately absent: the engine's
132
+ # LEPTRIS_PARSE_DROP_WS_TEXT trims boundary whitespace of
133
+ # non-blank text nodes too (leptris/leptris#677), so moxml
134
+ # implements it itself after parse — see
135
+ # strip_blank_text_nodes!.
121
136
  def parse_flags(options)
122
137
  flags = 0
123
138
  flags |= ::Leptris::XML::ParseOptions::DTDATTR if options[:dtdattr] == true
124
- flags |= ::Leptris::XML::ParseOptions::NOBLANKS if options[:noblanks] == true
125
139
  flags.zero? ? nil : ::Leptris::XML::ParseOptions.new(flags)
126
140
  end
127
141
 
142
+ # XML whitespace exactly — \v and \f are not XML space.
143
+ BLANK_TEXT_RE = /\A[ \t\r\n]*\z/
144
+
145
+ # libxml2's XML_PARSE_NOBLANKS semantics (issues #153/#156):
146
+ # drop WHOLLY-whitespace text nodes; the boundary spaces of
147
+ # text-bearing nodes stay (mixed content cannot be
148
+ # re-indented). Raw-pointer walk over the root subtree — the
149
+ # same batch child-pointer pattern the materializer uses.
150
+ def strip_blank_text_nodes!(doc)
151
+ binding_ffi = ::Leptris::XML::FFI
152
+ root = binding_ffi.leptris_document_root(doc.c_ptr)
153
+ return unless root && !root.null?
154
+
155
+ strip_blanks_under(root, binding_ffi, ::FFI::MemoryPointer.new(:pointer, 64), 64)
156
+ end
157
+
158
+ def strip_blanks_under(ptr, binding_ffi, buf, capacity)
159
+ count = binding_ffi.leptris_node_children(ptr, buf, capacity)
160
+ while count == capacity
161
+ capacity *= 4
162
+ buf = ::FFI::MemoryPointer.new(:pointer, capacity)
163
+ count = binding_ffi.leptris_node_children(ptr, buf, capacity)
164
+ end
165
+
166
+ # Snapshot before unlinking or recursing — both reuse the
167
+ # buffer for the next level.
168
+ children = buf.read_array_of_pointer(count)
169
+ children.each do |child|
170
+ case binding_ffi.leptris_node_get_type(child)
171
+ when ::Leptris::XML::FFI::NODE_ELEMENT
172
+ strip_blanks_under(child, binding_ffi, buf, capacity)
173
+ when ::Leptris::XML::FFI::NODE_TEXT
174
+ content = binding_ffi.leptris_text_node_get_content(child)
175
+ next unless content.match?(BLANK_TEXT_RE)
176
+
177
+ binding_ffi.check_status(binding_ffi.leptris_node_unlink(child))
178
+ end
179
+ end
180
+ end
181
+
128
182
  def parse_errors(native_doc)
129
183
  attachments.get(native_doc, :parse_errors) || NO_PARSE_ERRORS
130
184
  end
@@ -104,7 +104,12 @@ module Moxml
104
104
  node = prepare_node(node)
105
105
 
106
106
  if node.is_a?(Declaration)
107
- # Mark that document now has a declaration
107
+ # A proper XML document carries at most one declaration
108
+ # (issue #23).
109
+ if @has_xml_declaration || adapter.has_declaration?(@native, self)
110
+ raise Moxml::ValidationError, "Document already has an XML declaration"
111
+ end
112
+
108
113
  @has_xml_declaration = true
109
114
  adapter.add_child(@native, node.native)
110
115
  elsif root && !node.is_a?(ProcessingInstruction) && !node.is_a?(Comment) && !node.is_a?(Doctype)
@@ -4,7 +4,7 @@ module Moxml
4
4
  class NodeSet
5
5
  include Enumerable
6
6
 
7
- attr_reader :nodes, :context
7
+ attr_reader :context
8
8
 
9
9
  def initialize(nodes, context, parent_node = nil)
10
10
  @nodes = Array(nodes)
@@ -13,6 +13,13 @@ module Moxml
13
13
  @parent_node = parent_node
14
14
  end
15
15
 
16
+ # The raw native nodes — adapter-internal work only (issue #26).
17
+ # The public surface is wrapper-only: every Enumerable access
18
+ # goes through #each/#[]/#to_a, which wrap.
19
+ def native_nodes
20
+ @nodes
21
+ end
22
+
16
23
  def each
17
24
  return to_enum(:each) unless block_given?
18
25
 
@@ -64,7 +71,7 @@ module Moxml
64
71
  end
65
72
 
66
73
  def +(other)
67
- self.class.new(@nodes + other.nodes, @context, @parent_node)
74
+ self.class.new(@nodes + other.native_nodes, @context, @parent_node)
68
75
  end
69
76
 
70
77
  def <<(node)
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.18"
4
+ VERSION = "0.5.19"
5
5
  end
@@ -59,7 +59,15 @@ RSpec.describe "Cross-adapter integration" do
59
59
  end
60
60
 
61
61
  all_shared_examples.each do |shared_example_name|
62
- it_behaves_like shared_example_name
62
+ # The performance battery runs only under RUN_PERFORMANCE /
63
+ # `rake spec:performance` (issue #45) - tagged at inclusion:
64
+ # definition-site metadata on the shared group does not
65
+ # propagate through this indirection.
66
+ if shared_example_name == "Performance Examples"
67
+ it_behaves_like shared_example_name, performance: true
68
+ else
69
+ it_behaves_like shared_example_name
70
+ end
63
71
  end
64
72
  end
65
73
  end
@@ -101,5 +101,11 @@ RSpec.shared_examples "Moxml::Declaration" do
101
101
  declaration.remove
102
102
  expect(doc.to_xml).not_to include("<?xml")
103
103
  end
104
+
105
+ it "rejects a second declaration (issue #23)" do
106
+ doc.add_child(doc.create_declaration("1.0"))
107
+ expect { doc.add_child(doc.create_declaration("1.0")) }
108
+ .to raise_error(Moxml::ValidationError, /already has an XML declaration/)
109
+ end
104
110
  end
105
111
  end
@@ -185,31 +185,60 @@ RSpec.describe Moxml::Adapter::Leptris do
185
185
  expect(doc.to_xml).to include("<?renamed changed?>")
186
186
  end
187
187
 
188
- it "matches raw Nokogiri with tab units on element-only trees" do
188
+ it "matches raw Nokogiri with tab units" do
189
189
  skip "requires the indent unit (leptris 1.9.45+)" unless described_class::INDENT_UNIT_SUPPORTED
190
190
 
191
- source = %(<r><a><b/></a><c><d/><e/></c></r>)
192
- target = Nokogiri::XML(source).to_xml(indent: 2, indent_text: "\t", encoding: "UTF-8")
193
- output = ctx.parse(source).to_xml(
194
- indent: 2, declaration: true, expand_empty: false,
195
- encoding: "UTF-8", indent_text: "\t"
196
- )
197
- expect(output).to eq(target)
191
+ # Text-bearing children included since the engine fix for
192
+ # leptris/leptris#658 (leptris 1.9.46). Comment children are
193
+ # excluded: the binding's element+unit path drops them
194
+ # (leptris-ruby#115).
195
+ sources = [
196
+ %(<r><a><b/></a><c><d/><e/></c></r>),
197
+ %(<root><child>content</child><empty/></root>),
198
+ %(<r>text <b>bold</b> tail</r>),
199
+ ]
200
+ sources.each do |source|
201
+ target = Nokogiri::XML(source).to_xml(indent: 1, indent_text: "\t", encoding: "UTF-8")
202
+ output = ctx.parse(source).to_xml(
203
+ indent: 1, declaration: true, expand_empty: false,
204
+ encoding: "UTF-8", indent_text: "\t"
205
+ )
206
+ expect(output).to eq(target), "tab parity failed for #{source}"
207
+ end
198
208
  end
199
209
 
200
- it "drops space-only text nodes with noblanks and matches Nokogiri (issue #153)" do
201
- source = %(<a><t>1</t> <n/></a>)
210
+ it "drops space-only text nodes with noblanks and matches Nokogiri (issues #153/#156)" do
211
+ sources = [
212
+ %(<a><t>1</t> <n/></a>), # blank filler between tags
213
+ %(<p> leading</p>), # boundary space must survive
214
+ %(<p><b>b</b> after</p>), # element-to-text space must survive
215
+ %(<p>trailing </p>), # trailing space must survive
216
+ %(<r>\n <a>x <b>y</b> z</a>\n <c/>\n</r>),
217
+ ]
202
218
  recipe = {
203
219
  indent: 2, declaration: true, expand_empty: false, encoding: "UTF-8"
204
220
  }
205
- target = Nokogiri::XML(source, &:noblanks).to_xml(indent: 2, encoding: "UTF-8")
206
221
 
207
- doc = ctx.parse(source, noblanks: true)
222
+ sources.each do |source|
223
+ target = Nokogiri::XML(source, &:noblanks).to_xml(indent: 2, encoding: "UTF-8")
224
+
225
+ doc = ctx.parse(source, noblanks: true)
226
+ expect(doc.to_xml(recipe)).to eq(target), "noblanks parity failed for #{source}"
227
+
228
+ via_nokogiri = Moxml.new(:nokogiri).parse(source, noblanks: true).to_xml(recipe)
229
+ expect(via_nokogiri).to eq(target)
230
+ end
231
+ end
232
+
233
+ it "leaves no text nodes on a noblanks tree of blank filler" do
234
+ doc = ctx.parse(%(<a><t>1</t> <n/></a>), noblanks: true)
208
235
  expect(doc.root.children.to_a.select(&:text?)).to be_empty
209
- expect(doc.to_xml(recipe)).to eq(target)
236
+ end
210
237
 
211
- via_nokogiri = Moxml.new(:nokogiri).parse(source, noblanks: true).to_xml(recipe)
212
- expect(via_nokogiri).to eq(target)
238
+ it "refuses noblanks on readonly parses" do
239
+ # The strip mutates the tree; readonly freezes it at parse.
240
+ expect { ctx.parse("<a/>", noblanks: true, readonly: true) }
241
+ .to raise_error(ArgumentError, /noblanks.*mutable/)
213
242
  end
214
243
 
215
244
  it "reports the tracked native for a re-added document PI" do
@@ -13,12 +13,13 @@ RSpec.describe Moxml::Adapter::Oga do
13
13
  it_behaves_like "xml adapter"
14
14
 
15
15
  describe "serialization" do
16
- it "does not duplicate XML declarations when declaration nodes repeat" do
16
+ it "rejects a repeated XML declaration instead of duplicating it (issue #23)" do
17
17
  context = Moxml::Context.new(:oga)
18
18
  doc = context.create_document
19
19
 
20
20
  doc.add_child(doc.create_declaration("1.0", "UTF-8"))
21
- doc.add_child(doc.create_declaration("1.0", "UTF-8"))
21
+ expect { doc.add_child(doc.create_declaration("1.0", "UTF-8")) }
22
+ .to raise_error(Moxml::ValidationError)
22
23
  doc.add_child(doc.create_element("root"))
23
24
 
24
25
  serialized = doc.to_xml
@@ -36,4 +36,16 @@ RSpec.describe Moxml::NodeSet do
36
36
  expect(nodes).to be_empty
37
37
  end
38
38
  end
39
+
40
+ describe "public surface (issue #26)" do
41
+ it "exposes raw natives only through native_nodes" do
42
+ children = doc.root.children
43
+ expect(children.native_nodes).not_to include(a_kind_of(Moxml::Node))
44
+ expect(children).not_to respond_to(:nodes)
45
+ end
46
+
47
+ it "wraps through the Enumerable surface" do
48
+ expect(doc.root.children.map(&:class).uniq).to eq([Moxml::Element])
49
+ end
50
+ end
39
51
  end
@@ -4,9 +4,13 @@ require "benchmark"
4
4
  require "benchmark/ips"
5
5
 
6
6
  RSpec.shared_examples "Performance Examples" do
7
- if ENV["SKIP_BENCHMARKS"]
8
- it "skips benchmarks when SKIP_BENCHMARKS is set" do
9
- skip "Benchmarks skipped. To run benchmarks, unset SKIP_BENCHMARKS"
7
+ # Issue #45: benchmarks never run inside the default suites - only
8
+ # under RUN_PERFORMANCE=1 (the CI perf step and
9
+ # rake spec:performance). RSpec tag exclusion proved unreliable
10
+ # through the shared-example indirection, so this is a hard env gate.
11
+ if ENV["SKIP_BENCHMARKS"] || !ENV["RUN_PERFORMANCE"]
12
+ it "skips benchmarks unless RUN_PERFORMANCE is set" do
13
+ skip "Benchmarks run only with RUN_PERFORMANCE=1 (rake spec:performance)"
10
14
  end
11
15
  else
12
16
  let(:context) { Moxml.new }
@@ -41,7 +45,7 @@ RSpec.shared_examples "Performance Examples" do
41
45
  }
42
46
  end
43
47
 
44
- it "meets Parser performance threshold" do
48
+ it "meets Parser performance threshold", :performance do
45
49
  result = nil
46
50
  report = Benchmark.ips do |x|
47
51
  x.config(time: 5, warmup: 2)
@@ -54,7 +58,7 @@ RSpec.shared_examples "Performance Examples" do
54
58
  expect(ips).to be >= threshold, message
55
59
  end
56
60
 
57
- it "meets Serializer performance threshold" do
61
+ it "meets Serializer performance threshold", :performance do
58
62
  report = Benchmark.ips do |x|
59
63
  x.config(time: 5, warmup: 2)
60
64
  x.report("Serializer") { _ = doc.to_xml }
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.18
4
+ version: 0.5.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.