moxml 0.5.17 → 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: 6de47d8ce88e56853b7523dfbcb2049e61d586878dca573d13bacd85138b4a4e
4
- data.tar.gz: 520a050794c1275e5bc519454319a34fd059b9bbcf3f0efd0f5d8e0876bb7e97
3
+ metadata.gz: 62f62c2c744354f3dc8c6436cd5404eac78738bc3da00509c1daed9e827d5195
4
+ data.tar.gz: 25cddfe9066c8ebda91925a47834fb3180aaefc2b7be5f74b32409a3bdd44ffb
5
5
  SHA512:
6
- metadata.gz: 6b197e6da24e0b40ead4fa25ee944b242ee3484475b2927a16e126faa208f8ff4bb7709795b1d2830b1394fbb0fad8d28f99ed32ec9dbf902710752286965e7c
7
- data.tar.gz: 89b1ae523cf06687e67e4d07a088cdef2f942a1b4dcff8ba56f468fb887b1ac3bba936b10145c057eb0f6cf767c9e7ba35e86fb753854ee391f29951f2e17485
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
@@ -18,7 +18,9 @@ code disagree.
18
18
  == Method groups
19
19
 
20
20
  Parse and document lifecycle::
21
- `parse(xml, options, context)`, `create_document`, `create` factories
21
+ `parse(xml, options, context)` `noblanks: true` drops
22
+ whitespace-only text nodes where the engine has the flag
23
+ (Nokogiri, leptris), `create_document`, `create` factories
22
24
  (`create_native_element`, `create_native_text`, `create_native_cdata`,
23
25
  `create_native_comment`, `create_native_processing_instruction`,
24
26
  `create_native_declaration`, `create_native_doctype`,
@@ -56,8 +58,9 @@ the tracked native; wrappers re-key through `refresh_native!`.
56
58
  Serialization::
57
59
  `serialize(node, options)`. Options: `indent`, `encoding`,
58
60
  `declaration`, `no_declaration`, `expand_empty`, `line_ending`,
59
- `indent_text` (the indent-unit string — honored where the engine
60
- has it, e.g. Nokogiri; leptris waits on leptris-ruby#109).
61
+ `indent_text` (the indent-unit string — Nokogiri and
62
+ leptris >= 1.9.45; its element unit path
63
+ drops comments — leptris-ruby#115).
61
64
  Entity-marker restoration is owned by the wrapper layer
62
65
  (`Node#to_xml`), guarded by `entity_bearing?`.
63
66
 
@@ -30,6 +30,13 @@ ctx.config.adapter_name # => :leptris / :nokogiri / :rexml (Opal)
30
30
 
31
31
  == Parse options
32
32
 
33
+ `noblanks: true`::
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.
39
+
33
40
  `readonly: true`::
34
41
  The binding memoizes every read and refuses mutations — the
35
42
  parse-and-read lifecycle for multi-pass consumers (comparison, diff,
@@ -62,13 +69,14 @@ implementation over one `leptris_node_traverse` call.
62
69
 
63
70
  == Known limitations (tracked upstream)
64
71
 
72
+ * Tab indentation honors the indent-unit string on leptris >= 1.9.45
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).
65
77
  * A PI preceding the DOCTYPE reorders on serialization — libleptris
66
78
  normalizes DOCTYPE-first and the document node does not record its
67
79
  relative position.
68
- * Tab indentation: the binding's `indent_text` is the display-form
69
- boolean, not Nokogiri's indent-unit string — the unit ask is
70
- leptris-ruby#109. moxml forwards `indent_text:` to Nokogiri
71
- meanwhile.
72
80
 
73
81
  Resolved with leptris-ruby 1.9.42 (leptris/leptris#636/#612): child
74
82
  PIs serialize on their own indented lines, DOCTYPE internal subsets
@@ -63,11 +63,15 @@ module Moxml
63
63
  include_decl = options.fetch(:declaration) do
64
64
  options[:no_declaration] ? false : document_has_declaration?(node)
65
65
  end
66
- xml = node.to_xml(
66
+ kwargs = {
67
67
  indent: options.fetch(:indent, 0),
68
68
  no_decl: !include_decl,
69
69
  encoding: options[:encoding],
70
- )
70
+ }
71
+ if INDENT_UNIT_SUPPORTED && options[:indent_text].is_a?(String)
72
+ kwargs[:indent_text] = options[:indent_text]
73
+ end
74
+ xml = node.to_xml(**kwargs)
71
75
  # Element output always ends with the close tag — but the
72
76
  # engine's serializer appends a stray trailing newline when
73
77
  # the element's last text child is non-ASCII (fixed engine
@@ -40,6 +40,15 @@ module Moxml
40
40
  LIBXML2_LAYOUT_PARITY =
41
41
  Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.42")
42
42
 
43
+ # leptris-ruby#109 (1.9.45): Element#to_xml takes the
44
+ # indent-unit string, so moxml's per-child document composition
45
+ # can carry `indent_text:` through. Text-bearing leaves still
46
+ # emit spaces until the engine fix (leptris/leptris#658); the
47
+ # boolean display form stays document-level and is never
48
+ # forwarded (the element face raises on it).
49
+ INDENT_UNIT_SUPPORTED =
50
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.45")
51
+
43
52
  NO_PARSE_ERRORS = [].freeze
44
53
  private_constant :NO_PARSE_ERRORS
45
54
 
@@ -81,7 +90,7 @@ module Moxml
81
90
  ::Leptris::XML::Document.parse(
82
91
  processed,
83
92
  readonly: options[:readonly] == true,
84
- options: dtdattr_parse_options(options),
93
+ options: parse_flags(options),
85
94
  )
86
95
  rescue ::Leptris::XML::ParseError => e
87
96
  # libleptris has no recovery mode that survives unclosed
@@ -97,6 +106,18 @@ module Moxml
97
106
  ctx = _context || Context.new(:leptris)
98
107
  doc = Document.new(native_doc, ctx)
99
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
+
100
121
  record_source_declaration(native_doc, processed)
101
122
  attachments.set(native_doc, :entity_markers, entity_markers)
102
123
  attachments.set(native_doc, :parse_errors, recover_errors) if recover_errors
@@ -104,11 +125,58 @@ module Moxml
104
125
  doc
105
126
  end
106
127
 
107
- # nil unless DTDATTR is requested — the binding treats a nil
108
- # options hash as plain defaults (defaults exclude ATTLIST
109
- # defaults, matching libxml2/Nokogiri semantics).
110
- def dtdattr_parse_options(options)
111
- options[:dtdattr] == true ? ::Leptris::XML::ParseOptions.dtdattr : nil
128
+ # nil when no parse flag is requested — the binding treats a
129
+ # nil options hash as plain defaults (matching libxml2/
130
+ # Nokogiri semantics: blanks kept, no ATTLIST defaults).
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!.
136
+ def parse_flags(options)
137
+ flags = 0
138
+ flags |= ::Leptris::XML::ParseOptions::DTDATTR if options[:dtdattr] == true
139
+ flags.zero? ? nil : ::Leptris::XML::ParseOptions.new(flags)
140
+ end
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
112
180
  end
113
181
 
114
182
  def parse_errors(native_doc)
@@ -26,11 +26,13 @@ module Moxml
26
26
  ::Nokogiri::XML::DocumentFragment.parse(processed_xml) do |config|
27
27
  config.strict.nonet
28
28
  config.recover unless options[:strict]
29
+ config.noblanks if options[:noblanks]
29
30
  end
30
31
  else
31
32
  ::Nokogiri::XML(processed_xml, nil, "UTF-8") do |config|
32
33
  config.strict.nonet
33
34
  config.recover unless options[:strict]
35
+ config.noblanks if options[:noblanks]
34
36
  end
35
37
  end
36
38
  rescue ::Nokogiri::XML::SyntaxError => e
@@ -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.17"
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,6 +185,62 @@ 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" do
189
+ skip "requires the indent unit (leptris 1.9.45+)" unless described_class::INDENT_UNIT_SUPPORTED
190
+
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
208
+ end
209
+
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
+ ]
218
+ recipe = {
219
+ indent: 2, declaration: true, expand_empty: false, encoding: "UTF-8"
220
+ }
221
+
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)
235
+ expect(doc.root.children.to_a.select(&:text?)).to be_empty
236
+ end
237
+
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/)
242
+ end
243
+
188
244
  it "reports the tracked native for a re-added document PI" do
189
245
  doc = ctx.parse("<?pi-src orig?><root/>")
190
246
  moved = doc.children.to_a[0]
@@ -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,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moxml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.17
4
+ version: 0.5.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-30 00:00:00.000000000 Z
11
+ date: 2026-08-31 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Moxml is a unified XML manipulation library that provides a common API