moxml 0.5.126 → 0.5.128

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: 71f2ce65d604babc4d29753cb99b353f6f9fafa4a8379f913d109aa4e3cb1aba
4
- data.tar.gz: bb11508d1a28d87de8b3bcca4a6913ce40d29ac7152dfbee4311db797873ea14
3
+ metadata.gz: 5b80e9601eb4a227d342aa484359a4724f3273a561c9f74cd8d375ffd5321186
4
+ data.tar.gz: 987e9faa827418fe3471456b1b939ced916467c895454670c3ba1273ee5fd616
5
5
  SHA512:
6
- metadata.gz: bca5457e68b0d51547a72f440097d5203fc469654f9360d6dfb6c3d0f8f8d8a6cf921e3e057e3bd9ef8c6ad86823c80057d5cb171474d27f4ca46e5de329a715
7
- data.tar.gz: c382a8f7eb2e23a116021b47d785e6757e04961576b12f2f452c57d01e946ee121da96ff2b77e77c6d5539e49110dc5af32fcf0a4db208906d5852251ffa978e
6
+ metadata.gz: 0f059f790d6a76ba2213d83ced97289d837ab0ad1b4448dd1f026bb991a5e90b8131fabc31d6f2f0eeb948564693231ea89c3899a542618d65de153db687bde7
7
+ data.tar.gz: edae98439cf51d03ea590709a22fa05848f9e7b62fa7f274bf6e1ffdd34f0115eca0622b165e3f1a884d8f9a73ba4047338061d6281c4c88161ce13bbd44a7ae
data/Gemfile CHANGED
@@ -9,7 +9,7 @@ gemspec
9
9
  gem "benchmark"
10
10
  gem "benchmark-ips"
11
11
  gem "get_process_mem"
12
- gem "leptris", "~> 1.9.341"
12
+ gem "leptris", "~> 1.9.343"
13
13
  gem "libxml-ruby", ">= 5.0"
14
14
  gem "nokogiri", "~> 1.18"
15
15
  gem "openssl", "~> 3.0"
@@ -327,6 +327,15 @@ module Moxml
327
327
 
328
328
  def xsd_compile_error(_xsd_handle); end
329
329
 
330
+ # Deterministic drop (#362.3): true when the engine-side
331
+ # schema was freed NOW, false when the adapter can only
332
+ # wait for GC (the default — document the capability per
333
+ # adapter). moxml's Schema#release surfaces this. The name
334
+ # is the contract (release), not a predicate.
335
+ def xsd_release(_xsd_handle) # rubocop:disable Naming/PredicateMethod
336
+ false
337
+ end
338
+
330
339
  def xsd_simple_valid?(_xsd_handle, _type_name, _lexical)
331
340
  raise Moxml::NotImplementedError,
332
341
  "XSD validation requires an adapter with an XSD engine"
@@ -198,6 +198,15 @@ module Moxml
198
198
 
199
199
  XSD_SUPPORTED = NATIVE_XSD
200
200
 
201
+ # Schema#release (moxml#362.3, binding 1.9.342+): the
202
+ # deterministic drop face — disarms the AutoPointer's GC
203
+ # finalizer and frees now. Face-probed with the version
204
+ # floor (the NATIVE_DOC_ABSORB pairing).
205
+ XSD_RELEASE =
206
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.342.0") &&
207
+ defined?(::Leptris::XML::XSD::Schema) &&
208
+ ::Leptris::XML::XSD::Schema.method_defined?(:release)
209
+
201
210
  NATIVE_BUILDER =
202
211
  defined?(::Leptris::XML::Builder) &&
203
212
  defined?(::Leptris::XML::Document) &&
@@ -282,6 +291,12 @@ module Moxml
282
291
  xsd_handle.declaration_count
283
292
  end
284
293
 
294
+ def xsd_release(xsd_handle)
295
+ return false unless XSD_RELEASE
296
+
297
+ xsd_handle.release
298
+ end
299
+
285
300
  def xsd_compile_error(xsd_handle)
286
301
  xsd_handle.error
287
302
  end
@@ -300,17 +315,28 @@ module Moxml
300
315
 
301
316
  def xsd_validate(xsd_handle, native_document)
302
317
  # +xsd_handle+ is the binding's Leptris::XML::XSD::Schema;
303
- # validate_errors takes the binding document. Entries
304
- # normalize to Strings: the moxml contract is []-of-
305
- # messages, and the binding may enrich entries into
306
- # structured objects (element/line/column —
307
- # leptris-ruby#419) without breaking consumers.
318
+ # validate_errors takes the binding document. Entries are
319
+ # the #362.2 ValidationError contract; the engine payload
320
+ # is message strings today (line/column/path ride
321
+ # leptris-ruby#419 — the mapping fills them the day the
322
+ # binding exposes them).
308
323
  doc = if native_document.is_a?(::Leptris::XML::Document)
309
324
  native_document
310
325
  else
311
326
  to_binding(native_document)
312
327
  end
313
- xsd_handle.validate_errors(doc).map(&:to_s)
328
+ structured = defined?(::Leptris::XML::XSD::ValidationError)
329
+ xsd_handle.validate_errors(doc).map do |entry|
330
+ if structured &&
331
+ entry.is_a?(::Leptris::XML::XSD::ValidationError)
332
+ Moxml::XSD::ValidationError.new(
333
+ message: entry.message, line: entry.line,
334
+ column: entry.column, path: entry.path
335
+ )
336
+ else
337
+ Moxml::XSD::ValidationError.new(message: entry.to_s)
338
+ end
339
+ end
314
340
  end
315
341
 
316
342
  def builder_face(native_document)
@@ -10,6 +10,11 @@ module Moxml
10
10
  # libxslt-backed XSLT 1.0 (Moxml::XSLT contract).
11
11
  XSLT_SUPPORTED = true
12
12
 
13
+ # XSD over libxml2 (issue #362.2): structured #362
14
+ # ValidationError entries — libxml2 carries line/column in
15
+ # the message ("L:C: SEV: ..."), which the mapping lifts.
16
+ XSD_SUPPORTED = true
17
+
13
18
  class << self
14
19
  def attachments
15
20
  @attachments ||= Moxml::NativeAttachment.new
@@ -193,6 +198,57 @@ module Moxml
193
198
  native_fragment.children.map(&:to_html).join
194
199
  end
195
200
 
201
+ def xsd_compile(schema_source)
202
+ ::Nokogiri::XML::Schema.new(schema_source.to_s)
203
+ end
204
+
205
+ def xsd_compile_file(path)
206
+ path = path.to_s
207
+ # libxml2 resolves schemaLocation hrefs against the
208
+ # process CWD (the reader form carries no base) — chdir
209
+ # to the file's directory so relative hrefs resolve like
210
+ # leptris's engine-side base_dir.
211
+ dir = File.dirname(File.expand_path(path))
212
+ Dir.chdir(dir) { ::Nokogiri::XML::Schema(File.read(path)) }
213
+ end
214
+
215
+ # Compile failures RAISE from Schema.new; a constructed
216
+ # handle carries any pooled errors here (empty = none).
217
+ # Nokogiri/libxml2's schema object does not expose a
218
+ # declaration census — the base's nil default documents
219
+ # the capability gap rather than guessing.
220
+ def xsd_declarations(_xsd_handle)
221
+ nil
222
+ end
223
+
224
+ def xsd_compile_error(xsd_handle)
225
+ msgs = xsd_handle.errors.map(&:to_s)
226
+ msgs.empty? ? nil : msgs.join("; ")
227
+ end
228
+
229
+ def xsd_validate(xsd_handle, native_document)
230
+ xsd_handle.validate(native_document).map do |entry|
231
+ line = entry.line
232
+ line = line.to_i if line.is_a?(String)
233
+ Moxml::XSD::ValidationError.new(
234
+ message: strip_libxml2_position(entry.message),
235
+ line: line&.positive? ? line : nil,
236
+ column: nil,
237
+ )
238
+ end
239
+ end
240
+
241
+ # libxml2's nokogiri face prefixes "L:C: SEVERITY: " onto
242
+ # the message; the position rides the structured fields.
243
+ def strip_libxml2_position(message)
244
+ msg = message.to_s
245
+ if (m = msg.match(/\A\d+:\d+: (?:ERROR|WARNING|FATAL): (.*)\z/m))
246
+ m[1]
247
+ else
248
+ msg
249
+ end
250
+ end
251
+
196
252
  # Nokogiri parses with `config.recover` unless strict, so
197
253
  # recoverable syntax errors land on `doc.errors` instead of
198
254
  # raising (issue #147).
data/lib/moxml/css.rb ADDED
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ # CSS selector -> XPath 1.0 translation (#362.1): the shared
5
+ # implementation behind Node#css/at_css. One translator for
6
+ # every adapter — CSS semantics converge by construction instead
7
+ # of per-engine differences. Supported: type/universal, #id,
8
+ # .class, attribute predicates ([a], [a=v], ^=, $=, *=, ~=, |=),
9
+ # the descendant/child/adjacent-sibling/following-sibling
10
+ # combinators, :first-child/:last-child/:nth-child(n|odd|even),
11
+ # :not(simple), and comma groups (union NodeSets).
12
+ module Css
13
+ class Error < Moxml::Error; end
14
+
15
+ SIMPLE_PART = /
16
+ (?<tag>^[A-Za-z][\w-]*|\*)
17
+ | \.(?<class>[\w-]+)
18
+ | \#(?<id>[\w-]+)
19
+ | :(?<pseudo>[\w-]+(?:\([^)]*\))?)
20
+ | \[(?<attr>[^\]]+)\]
21
+ /x
22
+
23
+ module_function
24
+
25
+ # absolute: true when the context node is a DOCUMENT — the
26
+ # "//" anchor addresses the whole tree (and avoids engines
27
+ # that resolve "." at a document node to the root element);
28
+ # false (an element) scopes to the subtree with ".//".
29
+ def to_xpath(selector, absolute: false)
30
+ selectors = split_top_level(selector.to_s)
31
+ raise Error, "empty selector" if selectors.empty?
32
+
33
+ anchor = absolute ? "" : "."
34
+ selectors.map { |s| "#{anchor}#{to_xpath_one(s.strip)}" }
35
+ .uniq.join(" | ")
36
+ end
37
+
38
+ # Bracket- and quote-aware tokenization: combinator characters
39
+ # only split OUTSIDE [...] (a ~= inside an attribute selector
40
+ # is not the sibling combinator).
41
+ def tokenize(selector)
42
+ tokens = []
43
+ current = +""
44
+ depth = 0
45
+ quote = nil
46
+ flush = lambda do
47
+ unless current.empty?
48
+ tokens << [:simple, current]
49
+ current = +""
50
+ end
51
+ end
52
+ selector.each_char do |c|
53
+ if quote
54
+ current << c
55
+ quote = nil if c == quote
56
+ elsif ["'", '"'].include?(c)
57
+ quote = c
58
+ current << c
59
+ elsif c == "[" || c == "]" || depth.positive?
60
+ depth += 1 if c == "["
61
+ depth -= 1 if c == "]"
62
+ current << c
63
+ elsif c.match?(/[\s>+~]/)
64
+ flush.call
65
+ if tokens.last && tokens.last[0] == :comb
66
+ # a concrete combinator replaces the whitespace run;
67
+ # extra whitespace after one collapses
68
+ if c.match?(/[>+~]/)
69
+ tokens[-1] = [:comb, c]
70
+ end
71
+ next
72
+ end
73
+ tokens << [:comb, c.strip.empty? ? " " : c]
74
+ elsif c == ","
75
+ flush.call
76
+ tokens << [:comma, ","]
77
+ else
78
+ current << c
79
+ end
80
+ end
81
+ flush.call
82
+ tokens
83
+ end
84
+
85
+ def to_xpath_one(selector)
86
+ xpath = ""
87
+ seen_step = false
88
+ pending_combinator = :descendant
89
+
90
+ tokenize(selector).each do |kind, value|
91
+ if kind == :comb
92
+ raise Error, "selector starts with a combinator" unless seen_step
93
+
94
+ pending_combinator =
95
+ case value.strip
96
+ when ">" then :child
97
+ when "+" then :adjacent
98
+ when "~" then :sibling
99
+ else :descendant
100
+ end
101
+ elsif kind == :comma
102
+ raise Error, "trailing comma"
103
+ else
104
+ step = simple_selector_to_xpath(value, pending_combinator)
105
+ sep = if !seen_step
106
+ pending_combinator == :descendant ? "//" : "/"
107
+ elsif pending_combinator == :descendant
108
+ "//"
109
+ else
110
+ "/"
111
+ end
112
+ xpath = "#{xpath}#{sep}#{step}"
113
+ seen_step = true
114
+ pending_combinator = :descendant
115
+ end
116
+ end
117
+
118
+ raise Error, "selector ends with a combinator" if
119
+ seen_step && pending_combinator != :descendant
120
+ raise Error, "empty selector" unless seen_step
121
+
122
+ xpath
123
+ end
124
+
125
+ def simple_selector_to_xpath(simple, combinator)
126
+ tag, preds = parse_simple(simple)
127
+
128
+ predicate = preds.empty? ? "" : "[#{preds.join(' and ')}]"
129
+
130
+ case combinator
131
+ when :adjacent
132
+ # The FIRST following sibling, constrained to the test +
133
+ # predicates (the CSS + semantics) — one combined
134
+ # predicate, which oga's XPath subset parses (chained
135
+ # [1][self::x] it does not).
136
+ combined = (["position() = 1", "name() = '#{tag}'"] +
137
+ (preds.empty? ? [] : [preds.join(" and ")]))
138
+ .join(" and ")
139
+ "following-sibling::*[#{combined}]"
140
+ when :sibling
141
+ "following-sibling::#{tag}#{predicate}"
142
+ else
143
+ "#{tag}#{predicate}"
144
+ end
145
+ end
146
+
147
+ def parse_simple(simple)
148
+ tag = "*"
149
+ preds = []
150
+
151
+ simple.scan(SIMPLE_PART) do |tag_m, klass, id, pseudo, attr|
152
+ if tag_m
153
+ tag = tag_m
154
+ elsif klass
155
+ preds << "contains(concat(' ', normalize-space(@class), ' '), ' #{klass} ')"
156
+ elsif id
157
+ preds << "@id = '#{id}'"
158
+ elsif pseudo
159
+ preds << pseudo_predicate(pseudo)
160
+ elsif attr
161
+ preds << attr_predicate(attr)
162
+ end
163
+ end
164
+
165
+ [tag, preds]
166
+ end
167
+
168
+ def pseudo_predicate(pseudo)
169
+ case pseudo
170
+ when "first-child"
171
+ "count(preceding-sibling::*) = 0"
172
+ when "last-child"
173
+ "count(following-sibling::*) = 0"
174
+ when /\Anth-child\((\d+)\)\z/
175
+ "count(preceding-sibling::*) = #{Regexp.last_match(1).to_i - 1}"
176
+ when /\Anth-child\(odd\)\z/i
177
+ "count(preceding-sibling::*) mod 2 = 0"
178
+ when /\Anth-child\(even\)\z/i
179
+ "count(preceding-sibling::*) mod 2 = 1"
180
+ when /\Anot\((.+)\)\z/
181
+ inner = Regexp.last_match(1).strip
182
+ negate(inner)
183
+ else
184
+ raise Error, "unsupported pseudo :#{pseudo}"
185
+ end
186
+ end
187
+
188
+ def negate(inner_selector)
189
+ raise Error, ":not supports a single simple selector" if
190
+ inner_selector.match?(/[\s>+~,]/)
191
+
192
+ tag, preds = parse_simple(inner_selector)
193
+ return "false()" if tag == "*" && preds.empty?
194
+
195
+ checks = []
196
+ checks << "not(self::#{tag})" unless tag == "*"
197
+ checks.concat(preds.map { |p| "not(#{p})" })
198
+ "(#{checks.join(' and ')})"
199
+ end
200
+
201
+ def attr_predicate(expr)
202
+ # Linear string parsing — CodeQL rb/polynomial-redos flags
203
+ # the equivalent greedy regex (optional groups + .*\z
204
+ # backtrack polynomially on adversarial input).
205
+ expr = expr.strip
206
+ name = expr[/\A[\w:-]+/]
207
+ raise Error, "bad attribute selector: [#{expr}]" unless name
208
+
209
+ rest = expr[name.length..].to_s.strip
210
+ op = rest[/\A[~^$*|]?=/]
211
+ value = (op ? rest[op.length..] : "").to_s.strip
212
+ # No-backreference unquote (CodeQL rb/polynomial-redos: the
213
+ # ('(["']).*\1\z shape backtracks polynomially).
214
+ if value.length > 1 &&
215
+ ((value.start_with?("'") && value.end_with?("'")) ||
216
+ (value.start_with?('"') && value.end_with?('"')))
217
+ value = value[1..-2]
218
+ end
219
+ literal = xpath_string_literal(value)
220
+
221
+ case op
222
+ when nil, "" then "@#{name}"
223
+ when "=" then "@#{name} = #{literal}"
224
+ when "^=" then "starts-with(@#{name}, #{literal})"
225
+ when "$=" then "substring(@#{name}, string-length(@#{name}) - string-length(#{literal}) + 1) = #{literal}"
226
+ when "*=" then "contains(@#{name}, #{literal})"
227
+ when "~=" then "contains(concat(' ', normalize-space(@#{name}), ' '), concat(' ', #{literal}, ' '))"
228
+ when "|=" then "@#{name} = #{literal} or starts-with(@#{name}, concat(#{literal}, '-'))"
229
+ else raise Error, "unsupported attribute operator #{op}"
230
+ end
231
+ end
232
+
233
+ # An XPath 1.0 string literal: values containing apostrophes
234
+ # cannot escape the delimiter — they go through concat().
235
+ def xpath_string_literal(value)
236
+ return "'#{value}'" unless value.include?("'")
237
+
238
+ parts = value.split("'", -1).map { |seg| "'#{seg}'" }
239
+ "concat(#{parts.join(%(, "'", ))})"
240
+ end
241
+
242
+ def split_top_level(selector)
243
+ out = []
244
+ depth = 0
245
+ quote = nil
246
+ current = +""
247
+ selector.each_char do |c|
248
+ if quote
249
+ current << c
250
+ quote = nil if c == quote
251
+ elsif ["'", '"'].include?(c)
252
+ quote = c
253
+ current << c
254
+ elsif c == "["
255
+ depth += 1
256
+ current << c
257
+ elsif c == "]"
258
+ depth -= 1
259
+ raise Error, "unbalanced ]" if depth.negative?
260
+
261
+ current << c
262
+ elsif c == "," && depth.zero?
263
+ out << current
264
+ current = +""
265
+ else
266
+ current << c
267
+ end
268
+ end
269
+ raise Error, "unbalanced [" if depth.positive?
270
+
271
+ out << current
272
+ out.map(&:strip).reject(&:empty?)
273
+ end
274
+ end
275
+ end
data/lib/moxml/node.rb CHANGED
@@ -384,14 +384,25 @@ module Moxml
384
384
  xpath(expression, namespaces)
385
385
  end
386
386
 
387
- def css(_expression)
388
- raise Moxml::NotImplementedError,
389
- "CSS selectors are not supported; use search with XPath"
387
+ # CSS through the shared Moxml::Css translator (#362.1): the
388
+ # SAME selector semantics on every adapter — the translation
389
+ # happens once, inside Moxml, so engines cannot diverge.
390
+ # NodeSet results document-order (the engines' native order).
391
+ def css(expression)
392
+ NodeSet.new(
393
+ adapter.xpath(@native, Css.to_xpath(expression,
394
+ absolute: document?)),
395
+ context,
396
+ )
390
397
  end
391
398
 
392
- def at_css(_expression)
393
- raise Moxml::NotImplementedError,
394
- "CSS selectors are not supported; use at with XPath"
399
+ def at_css(expression)
400
+ Node.wrap(
401
+ adapter.at_xpath(@native,
402
+ Css.to_xpath(expression,
403
+ absolute: document?)),
404
+ context,
405
+ )
395
406
  end
396
407
 
397
408
  def at_css_or_xpath(expression, namespaces = {})
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.126"
4
+ VERSION = "0.5.128"
5
5
  end
data/lib/moxml/xsd.rb CHANGED
@@ -19,14 +19,45 @@ module Moxml
19
19
  # </xs:schema>
20
20
  # XSD
21
21
  # schema.valid?(doc) # => true/false
22
- # schema.validate(doc) # => [] or ["1:0: ...", ...]
22
+ # schema.validate(doc) # => [] or [ValidationError(...)]
23
23
  #
24
24
  # Adapters without an engine raise Moxml::NotImplementedError at
25
25
  # compile; Moxml::XSD.supported?(context) answers without
26
26
  # raising. The tier-1 scope is the engine's: documents outside it
27
27
  # validate laxly (see the binding's xsd_validate_spec).
28
28
  module XSD
29
- SUPPORTED_ADAPTERS = %i[leptris].freeze
29
+ SUPPORTED_ADAPTERS = %i[leptris nokogiri].freeze
30
+
31
+ # One validation finding, harmonized across adapters (#362.2):
32
+ # +message+ always present; +line+ / +column+ when the engine
33
+ # reports them; +path+ (the element path) when the engine
34
+ # reports it — nil where the payload lacks the datum rather
35
+ # than a guessed placeholder. #to_s preserves the plain-string
36
+ # consumer flows the pre-#362 string contract fed.
37
+ class ValidationError
38
+ attr_reader :message, :line, :column, :path
39
+
40
+ def initialize(message:, line: nil, column: nil, path: nil)
41
+ @message = message.to_s
42
+ @line = line
43
+ @column = column
44
+ @path = path
45
+ end
46
+
47
+ def to_s
48
+ if line
49
+ "#{line}: #{message}"
50
+ else
51
+ message
52
+ end
53
+ end
54
+
55
+ def ==(other)
56
+ other.is_a?(ValidationError) &&
57
+ message == other.message && line == other.line &&
58
+ column == other.column && path == other.path
59
+ end
60
+ end
30
61
 
31
62
  class << self
32
63
  # Whether the context's adapter ships an XSD validator.
@@ -51,6 +82,13 @@ module Moxml
51
82
  # A compiled schema (adapter-owned handle). Validation results
52
83
  # are the engine's accumulated messages; [] means valid.
53
84
  class Schema
85
+ def check_released!
86
+ return unless released?
87
+
88
+ raise Moxml::AdapterError,
89
+ "schema released (deterministic drop); compile a new one"
90
+ end
91
+
54
92
  def initialize(handle, context)
55
93
  @handle = handle
56
94
  @context = context
@@ -65,6 +103,7 @@ module Moxml
65
103
  # visible); pass a Moxml::Document to control the parse
66
104
  # yourself.
67
105
  def validate(node_or_xml)
106
+ check_released!
68
107
  doc = case node_or_xml
69
108
  when Moxml::Document then node_or_xml
70
109
  when Moxml::Node then node_or_xml.document
@@ -85,18 +124,35 @@ module Moxml
85
124
  end
86
125
 
87
126
  def valid?(node_or_xml)
127
+ check_released!
88
128
  validate(node_or_xml).empty?
89
129
  end
90
130
 
91
131
  # Number of top-level schema declarations the compiler
92
132
  # recognized.
93
133
  def declarations
134
+ check_released!
94
135
  @context.config.adapter.xsd_declarations(@handle)
95
136
  end
96
137
 
138
+ # Deterministic drop (#362.3): true when the adapter freed
139
+ # the engine-side schema NOW; false when the adapter can only
140
+ # wait for GC (documented per adapter). After a true release
141
+ # the faces raise AdapterError — the handle is gone.
142
+ def release
143
+ dropped = @context.config.adapter.xsd_release(@handle)
144
+ @released = true if dropped
145
+ dropped
146
+ end
147
+
148
+ def released?
149
+ @released == true
150
+ end
151
+
97
152
  # The engine's schema-level compile error, or nil when the
98
153
  # schema compiled cleanly.
99
154
  def compile_error
155
+ check_released!
100
156
  @context.config.adapter.xsd_compile_error(@handle)
101
157
  end
102
158
 
data/lib/moxml.rb CHANGED
@@ -84,6 +84,7 @@ module Moxml
84
84
  autoload :EntityReference, "moxml/entity_reference"
85
85
  autoload :DocumentBuilder, "moxml/document_builder"
86
86
  autoload :HtmlFragment, "moxml/html_fragment"
87
+ autoload :Css, "moxml/css"
87
88
  autoload :Builder, "moxml/builder"
88
89
  autoload :Entity, "moxml/entity"
89
90
  autoload :EntityRegistry, "moxml/entity_registry"
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # #362.1: CSS selection through the shared Moxml::Css translator —
6
+ # one implementation for every adapter, so selector semantics
7
+ # converge by construction. The correctness oracle is
8
+ # nokogiri-native CSS: the translated XPath must return the same
9
+ # node sets for the corpus.
10
+ RSpec.describe "Moxml CSS selection (#362.1)" do
11
+ let(:source) do
12
+ <<~HTML
13
+ <doc>
14
+ <p id="first" class="lead intro">one</p>
15
+ <p class="lead">two <b>bold</b></p>
16
+ <div class="wrap">
17
+ <p data-k="v">three</p>
18
+ <span>four</span>
19
+ <p>five</p>
20
+ </div>
21
+ <section><article>deep</article></section>
22
+ </doc>
23
+ HTML
24
+ end
25
+
26
+ describe "Moxml::Css.to_xpath" do
27
+ def xp(sel, absolute: false)
28
+ Moxml::Css.to_xpath(sel, absolute: absolute)
29
+ end
30
+
31
+ it "translates the core shapes" do
32
+ expect(xp("p", absolute: true)).to eq("//p")
33
+ expect(xp(".lead", absolute: true))
34
+ .to include("contains(concat(' ', normalize-space(@class)")
35
+ expect(xp("#first", absolute: true)).to eq("//*[@id = 'first']")
36
+ expect(xp("[data-k]", absolute: true)).to eq("//p/*[@data-k]").or include("[@data-k]")
37
+ expect(xp("div > p", absolute: true)).to eq("//div/p")
38
+ expect(xp("div p", absolute: true)).to eq("//div//p")
39
+ expect(xp("span + p", absolute: true))
40
+ .to eq("//span/following-sibling::*[position() = 1 and name() = 'p']")
41
+ expect(xp("p ~ span", absolute: true))
42
+ .to eq("//p/following-sibling::span")
43
+ end
44
+
45
+ it "supports attribute operators" do
46
+ expect(xp("[href^='h']", absolute: true))
47
+ .to include("starts-with(@href, 'h')")
48
+ expect(xp("[href$='z']", absolute: true)).to include("substring(@href")
49
+ expect(xp("[href*='mid']", absolute: true)).to include("contains(@href, 'mid')")
50
+ expect(xp("[lang|=en]", absolute: true))
51
+ .to include("starts-with(@lang, concat('en', '-')")
52
+ end
53
+
54
+ it "supports positional pseudos" do
55
+ expect(xp("p:first-child", absolute: true))
56
+ .to include("count(preceding-sibling::*) = 0")
57
+ expect(xp("p:nth-child(3)", absolute: true))
58
+ .to include("count(preceding-sibling::*) = 2")
59
+ expect(xp("p:nth-child(odd)", absolute: true))
60
+ .to include("mod 2 = 0")
61
+ end
62
+
63
+ it "negates simple selectors with :not" do
64
+ expect(xp("p:not(.lead)", absolute: true))
65
+ .to include("not(contains(")
66
+ expect(xp(":not(p)", absolute: true)).to include("not(self::p)")
67
+ end
68
+
69
+ it "groups comma selectors into a union" do
70
+ expect(xp("div, section", absolute: true)).to include(" | ")
71
+ end
72
+
73
+ it "raises Css::Error on unsupported shapes" do
74
+ expect { xp("p:hover") }.to raise_error(Moxml::Css::Error, /pseudo/)
75
+ expect { xp("> p") }.to raise_error(Moxml::Css::Error)
76
+ expect { xp("p >>") }.to raise_error(Moxml::Css::Error)
77
+ expect { xp("") }.to raise_error(Moxml::Css::Error)
78
+ end
79
+ end
80
+
81
+ describe "Node#css / at_css" do
82
+ let(:expected) do
83
+ {
84
+ "p" => 4, "#first" => 1, ".lead" => 2, "p.lead" => 2,
85
+ "div p" => 2, "div > p" => 2, "[data-k]" => 1,
86
+ "[data-k=v]" => 1, "p:not(.lead)" => 2,
87
+ "p:first-child" => 2, "p:nth-child(3)" => 1,
88
+ "section article" => 1, "div, section" => 2,
89
+ "span + p" => 1, "p ~ span" => 1
90
+ }
91
+ end
92
+
93
+ %i[nokogiri leptris oga ox rexml].each do |adapter_name|
94
+ describe "on #{adapter_name}" do
95
+ let(:ctx) { Moxml.new(adapter_name) }
96
+ let(:doc) { ctx.parse(source) }
97
+
98
+ it "returns the nokogiri-native CSS node sets" do
99
+ expected.each do |sel, want|
100
+ expect(doc.css(sel).size).to eq(want),
101
+ "#{sel} => #{doc.css(sel).size}, want #{want}"
102
+ end
103
+ end
104
+
105
+ it "at_css returns the first match" do
106
+ expect(doc.at_css("p").text).to eq("one")
107
+ expect(doc.at_css("div p").text).to eq("three")
108
+ expect(doc.at_css("nosuch")).to be_nil
109
+ end
110
+ end
111
+ end
112
+
113
+ it "scopes element-subtree selection" do
114
+ ctx = Moxml.new(:leptris)
115
+ doc = ctx.parse(source)
116
+ wrap = doc.at_css("div")
117
+ expect(wrap.css("p").size).to eq(2)
118
+ expect(doc.at_css("doc").css("section article").size).to eq(1)
119
+ end
120
+ end
121
+ end
@@ -65,6 +65,26 @@ RSpec.describe "Moxml::Document#to_html" do
65
65
  "<body>\n\t\t<div id=\"a\">\n\t\t\t<p>x</p>",
66
66
  )
67
67
  end
68
+
69
+ it "nests a LAST block child (engine 1.9.341, leptris#1647)" do
70
+ doc2 = Moxml.new(:leptris).parse_html(
71
+ "<html><head><title>T</title></head><body>" \
72
+ "<div id=\"a\"><p>x</p><span>y</span></div></body></html>",
73
+ )
74
+ out = doc2.to_html(indent: 2)
75
+ skip "engine predates leptris#1647" unless out.include?("<body>\n <div")
76
+ expect(out).to eq(
77
+ "<html>\n " \
78
+ "<head><title>T</title></head>\n " \
79
+ "<body>\n " \
80
+ "<div id=\"a\">\n " \
81
+ "<p>x</p>\n " \
82
+ "<span>y</span>\n " \
83
+ "</div>\n " \
84
+ "</body>\n" \
85
+ "</html>",
86
+ )
87
+ end
68
88
  end
69
89
 
70
90
  context "when the binding predates the ride" do
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # #362.2: Schema#validate returns one error shape on every
6
+ # adapter — Moxml::XSD::ValidationError(message:, line:, column:,
7
+ # path:) — so consumers present findings uniformly. Each adapter
8
+ # fills what its engine reports; missing data is nil, never a
9
+ # guessed placeholder.
10
+ RSpec.describe "Moxml::XSD::ValidationError contract (#362.2)" do
11
+ let(:schema_text) do
12
+ <<~XSD
13
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
14
+ <xs:element name="r">
15
+ <xs:complexType>
16
+ <xs:sequence>
17
+ <xs:element name="a"/>
18
+ <xs:element name="b"/>
19
+ </xs:sequence>
20
+ </xs:complexType>
21
+ </xs:element>
22
+ </xs:schema>
23
+ XSD
24
+ end
25
+ let(:bad_doc) { "<r><a/></r>" }
26
+
27
+ %i[nokogiri leptris].each do |adapter_name|
28
+ describe "on #{adapter_name}" do
29
+ let(:ctx) { Moxml.new(adapter_name) }
30
+ let(:schema) { ctx.xsd(schema_text) }
31
+ let(:errors) { schema.validate(ctx.parse(bad_doc)) }
32
+
33
+ it "returns the structured contract" do
34
+ expect(errors).not_to be_empty
35
+ expect(errors).to all(be_a(Moxml::XSD::ValidationError))
36
+ expect(errors.first.message).not_to be_empty
37
+ end
38
+
39
+ it "to_s preserves plain-string flows" do
40
+ expect(errors.map(&:to_s)).to all(be_a(String))
41
+ end
42
+ end
43
+ end
44
+
45
+ describe "adapter payloads" do
46
+ it "nokogiri carries the line (libxml2 structured errors)" do
47
+ ctx = Moxml.new(:nokogiri)
48
+ errors = ctx.xsd(schema_text).validate(ctx.parse(bad_doc))
49
+ expect(errors.first.line).to eq(1)
50
+ end
51
+
52
+ it "leptris reports message only (line/path ride leptris-ruby#419)" do
53
+ ctx = Moxml.new(:leptris)
54
+ errors = ctx.xsd(schema_text).validate(ctx.parse(bad_doc))
55
+ expect(errors.first.line).to be_nil
56
+ expect(errors.first.path).to be_nil
57
+ end
58
+ end
59
+
60
+ describe "the ValidationError object" do
61
+ it "compares fieldwise and formats with the line" do
62
+ a = Moxml::XSD::ValidationError.new(message: "boom", line: 3)
63
+ b = Moxml::XSD::ValidationError.new(message: "boom", line: 3)
64
+ c = Moxml::XSD::ValidationError.new(message: "other", line: 3)
65
+ expect(a).to eq(b)
66
+ expect(a).not_to eq(c)
67
+ expect(a.to_s).to eq("3: boom")
68
+ expect(Moxml::XSD::ValidationError.new(message: "bare").to_s)
69
+ .to eq("bare")
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # #362.3: Schema#release — deterministic drop where the engine
6
+ # supports it, an explicit no-op-with-false where it cannot, and
7
+ # a documented contract for long-lived validation services.
8
+ RSpec.describe "Moxml::XSD::Schema#release (#362.3)" do
9
+ let(:schema_text) do
10
+ <<~XSD
11
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
12
+ <xs:element name="a" type="xs:string"/>
13
+ </xs:schema>
14
+ XSD
15
+ end
16
+
17
+ describe "on leptris" do
18
+ let(:ctx) { Moxml.new(:leptris) }
19
+
20
+ context "with the release ride armed (binding >= 1.9.342.0)" do
21
+ before do
22
+ unless Moxml::Adapter::Leptris::XSD_RELEASE
23
+ skip "binding #{Leptris::VERSION} predates Schema#release"
24
+ end
25
+ end
26
+
27
+ it "drops the engine schema deterministically" do
28
+ schema = ctx.xsd(schema_text)
29
+ expect(schema.valid?(ctx.parse("<a/>"))).to be(true)
30
+ expect(schema.release).to be(true)
31
+ expect(schema.released?).to be(true)
32
+ end
33
+
34
+ it "raises on faces after release" do
35
+ schema = ctx.xsd(schema_text)
36
+ schema.release
37
+ expect { schema.valid?(ctx.parse("<a/>")) }
38
+ .to raise_error(Moxml::AdapterError, /released/)
39
+ expect { schema.declarations }
40
+ .to raise_error(Moxml::AdapterError, /released/)
41
+ end
42
+
43
+ it "frees without a double free at GC" do
44
+ schema = ctx.xsd(schema_text)
45
+ schema.release
46
+ GC.start
47
+ expect(schema.released?).to be(true)
48
+ end
49
+ end
50
+
51
+ context "when the binding predates the ride" do
52
+ before do
53
+ if Moxml::Adapter::Leptris::XSD_RELEASE
54
+ skip "binding #{Leptris::VERSION} carries Schema#release"
55
+ end
56
+ end
57
+
58
+ it "answers false (GC-only) and keeps the schema usable" do
59
+ schema = ctx.xsd(schema_text)
60
+ expect(schema.release).to be(false)
61
+ expect(schema).not_to be_released
62
+ expect(schema.valid?(ctx.parse("<a/>"))).to be(true)
63
+ end
64
+ end
65
+ end
66
+
67
+ describe "on nokogiri" do
68
+ it "answers false — libxml2 frees at GC, documented" do
69
+ ctx = Moxml.new(:nokogiri)
70
+ schema = ctx.xsd(schema_text)
71
+ expect(schema.release).to be(false)
72
+ expect(schema).not_to be_released
73
+ expect(schema.valid?(ctx.parse("<a/>"))).to be(true)
74
+ end
75
+ end
76
+ end
@@ -123,11 +123,14 @@ RSpec.describe Moxml::XSD do
123
123
  expect(schema.instance_variable_get(:@parsed_source)).to be_nil
124
124
  end
125
125
 
126
- it "normalizes validate entries to strings (moxml contract)" do
126
+ it "normalizes validate entries to the #362.2 ValidationError contract" do
127
127
  schema = ctx.xsd(schema_text)
128
128
  errors = schema.validate("<note><wrong/></note>")
129
129
  expect(errors).not_to be_empty
130
- expect(errors).to all(be_a(String))
130
+ expect(errors).to all(be_a(Moxml::XSD::ValidationError))
131
+ # to_s preserves the plain-string consumer flows.
132
+ expect(errors.map(&:to_s)).to all(be_a(String))
133
+ expect(errors.first.message).not_to be_empty
131
134
  end
132
135
 
133
136
  it "derives XSD_SUPPORTED from the engine face gate" do
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.126
4
+ version: 0.5.128
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-10-11 00:00:00.000000000 Z
11
+ date: 2026-10-12 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
@@ -202,6 +202,7 @@ files:
202
202
  - lib/moxml/comment.rb
203
203
  - lib/moxml/config.rb
204
204
  - lib/moxml/context.rb
205
+ - lib/moxml/css.rb
205
206
  - lib/moxml/declaration.rb
206
207
  - lib/moxml/doctype.rb
207
208
  - lib/moxml/document.rb
@@ -487,6 +488,7 @@ files:
487
488
  - spec/moxml/comment_spec.rb
488
489
  - spec/moxml/config_spec.rb
489
490
  - spec/moxml/context_spec.rb
491
+ - spec/moxml/css_spec.rb
490
492
  - spec/moxml/cycle_rejection_spec.rb
491
493
  - spec/moxml/declaration_preservation_spec.rb
492
494
  - spec/moxml/declaration_spec.rb
@@ -575,6 +577,8 @@ files:
575
577
  - spec/moxml/xpath/union_spec.rb
576
578
  - spec/moxml/xpath_capabilities_spec.rb
577
579
  - spec/moxml/xpath_spec.rb
580
+ - spec/moxml/xsd_error_contract_spec.rb
581
+ - spec/moxml/xsd_release_spec.rb
578
582
  - spec/moxml/xsd_spec.rb
579
583
  - spec/moxml/xslt/stylesheet_spec.rb
580
584
  - spec/performance/README.md