moxml 0.5.165 → 0.5.167

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.
@@ -105,7 +105,9 @@ module Moxml
105
105
  when "##defined" then :defined
106
106
  when "##definedSibling" then :sibling
107
107
  when /\A([^:]+):(.+)\z/
108
- uri = prefix_uri(Regexp.last_match(1), element)
108
+ uri = ::Moxml::XSD::SchemaRules.prefix_uri(
109
+ Regexp.last_match(1), element
110
+ )
109
111
  "{#{uri}}#{Regexp.last_match(2)}" unless uri.empty?
110
112
  else
111
113
  "{}#{token}"
@@ -113,23 +115,6 @@ module Moxml
113
115
  end
114
116
  end
115
117
 
116
- # node.namespaces is adapter-divergent (some engines return
117
- # in-scope bindings, others own-node declarations only), so
118
- # the in-scope map is walked out by hand — per-node
119
- # definitions merged along the ancestor axis, deeper
120
- # overriding shallower, identically on every adapter.
121
- def prefix_uri(prefix, element)
122
- bindings = {}
123
- node = element
124
- while node
125
- node.namespaces.each do |namespace|
126
- bindings[namespace.prefix] = namespace.uri
127
- end
128
- node = node.parent&.element? ? node.parent : nil
129
- end
130
- bindings[prefix].to_s
131
- end
132
-
133
118
  def entry_covers?(entry, namespace)
134
119
  namespace == case entry
135
120
  when "##targetNamespace" then :tns
@@ -140,11 +125,34 @@ module Moxml
140
125
  end
141
126
 
142
127
  ContentModel = Struct.new(:declared_children, :any,
143
- :declared_attributes, :any_attribute)
128
+ :declared_attributes, :any_attribute,
129
+ :particles, :pure_sequence)
144
130
  SchemaIndex = Struct.new(:target_namespace, :element_default,
145
131
  :attribute_default, :elements,
146
132
  :attributes, :contents, :version,
147
- :element_patterns, :attribute_patterns)
133
+ :element_patterns, :attribute_patterns,
134
+ :substitution_members)
135
+
136
+ # Resolves a QName token against an element's in-scope
137
+ # bindings. node.namespaces is adapter-divergent (some
138
+ # engines return in-scope bindings, others own-node
139
+ # declarations only), so the in-scope map is walked out by
140
+ # hand — per-node definitions merged along the ancestor
141
+ # axis, deeper overriding shallower, identically on every
142
+ # adapter. Unresolvable prefixes yield "".
143
+ module_function
144
+
145
+ def prefix_uri(prefix, element)
146
+ bindings = {}
147
+ node = element
148
+ while node
149
+ node.namespaces.each do |namespace|
150
+ bindings[namespace.prefix] = namespace.uri
151
+ end
152
+ node = node.parent&.element? ? node.parent : nil
153
+ end
154
+ bindings[prefix].to_s
155
+ end
148
156
 
149
157
  # The XSD 1.1 version-tagging namespace (Structures §C.3):
150
158
  # vc:minVersion / vc:maxVersion gate 1.1 constructs.
@@ -243,7 +251,7 @@ module Moxml
243
251
  root["targetNamespace"].to_s,
244
252
  root["elementFormDefault"] == "qualified",
245
253
  root["attributeFormDefault"] == "qualified",
246
- {}, {}, {}, schema_version(root), {}, {}
254
+ {}, {}, {}, schema_version(root), {}, {}, {}
247
255
  )
248
256
  child_elements(root).each do |decl|
249
257
  case decl.name
@@ -339,27 +347,36 @@ module Moxml
339
347
  sink[name] = sink.key?(name) ? nil : patterns
340
348
  end
341
349
 
342
- # Dismisses engine lexical findings the source's own
343
- # pattern facets refute (#464): an engine that cannot
344
- # evaluate a dialect construct rejects every occurrence of
345
- # the facet — including values the facet accepts. A
346
- # finding is dropped only when every same-named instance
347
- # in the document matches; one non-matching value keeps
348
- # the engine's answer, so a dismissal can only remove
349
- # false rejections, never create false acceptances.
350
+ # Dismisses engine findings the schema source refutes:
351
+ #
352
+ # * lexical findings pattern facets accept (#464) — dropped
353
+ # only when every same-named instance matches the facet;
354
+ # * content-model findings that reject a substitution-group
355
+ # member (#466 class 1) — dropped only when the parent's
356
+ # content model is a pure sequence, the child sequence
357
+ # matches it position-for-position with every particle
358
+ # exactly-once, and a substitution member resolves a
359
+ # position. One non-vouching instance — or any real
360
+ # mismatch — keeps the engine's answer, so a dismissal
361
+ # can only remove false rejections, never create false
362
+ # acceptances.
350
363
  def dismiss(engine_errors, doc, index)
351
364
  return engine_errors unless index
352
365
 
353
366
  vouched = vouch_table(doc, index)
354
367
  engine_errors.reject do |error|
355
- subject = lexical_subject(error.message)
356
- subject && vouched[subject]
368
+ if (subject = lexical_subject(error.message))
369
+ vouched[subject]
370
+ elsif (sequence = content_model_subject(error.message))
371
+ sequence_vouched?(doc, index, *sequence)
372
+ end
357
373
  end
358
374
  end
359
375
 
360
376
  LEXICAL_MESSAGE = /\A(?:element|attribute) '([^']+)' @\d+:\d+: (element|attribute) '([^']+)': lexical value does not match its type\z/
377
+ CONTENT_MODEL_MESSAGE = /\Aelement '([^']+)' @\d+:\d+: element '([^']+)': children do not match the content model \(at '([^']+)'\)\z/
361
378
 
362
- private_constant :LEXICAL_MESSAGE
379
+ private_constant :LEXICAL_MESSAGE, :CONTENT_MODEL_MESSAGE
363
380
 
364
381
  def lexical_subject(message)
365
382
  match = LEXICAL_MESSAGE.match(message.to_s)
@@ -368,6 +385,81 @@ module Moxml
368
385
  [match[2], match[3]]
369
386
  end
370
387
 
388
+ def content_model_subject(message)
389
+ match = CONTENT_MODEL_MESSAGE.match(message.to_s)
390
+ return unless match
391
+
392
+ [match[2], match[3]]
393
+ end
394
+
395
+ # Every same-named element in the document must vouch: a
396
+ # sequence the finding names could be valid while another
397
+ # same-named one is not. The root is included — the
398
+ # engine's finding can name it.
399
+ def sequence_vouched?(doc, index, name, offender)
400
+ root = doc.root
401
+ return false unless root
402
+
403
+ witnesses = []
404
+ yield_self = root.name == name
405
+ witnesses << sequence_valid?(root, index, offender) if yield_self
406
+ each_named_element(root, name) do |element|
407
+ witnesses << sequence_valid?(element, index, offender)
408
+ end
409
+ !witnesses.empty? && witnesses.all?
410
+ end
411
+
412
+ def each_named_element(node, name, &block)
413
+ child_elements(node).each do |child|
414
+ yield child if child.name == name
415
+ each_named_element(child, name, &block)
416
+ end
417
+ end
418
+
419
+ # The airtight witness: child count equals particle count,
420
+ # every position resolves to its particle's name or a
421
+ # registered member of it, every particle occurs exactly
422
+ # once, and the reported offender is a substitution member
423
+ # realized at some position. Anything else leaves the
424
+ # engine's answer standing.
425
+ def sequence_valid?(element, index, offender)
426
+ model = index.contents[qname(element)]
427
+ return false unless model&.pure_sequence
428
+
429
+ particles = model.particles
430
+ children = child_elements(element)
431
+ return false if children.empty? || children.size != particles.size
432
+
433
+ resolved = false
434
+ children.each_with_index do |child, position|
435
+ particle_name, min, max = particles[position]
436
+ child_qname = qname(child)
437
+ return false unless exactly_once?(min, max)
438
+
439
+ if child_qname == particle_name
440
+ next
441
+ elsif index.substitution_members.fetch(particle_name, [])
442
+ .include?(child_qname)
443
+ resolved = true
444
+ else
445
+ return false
446
+ end
447
+ end
448
+ resolved && offender_realized?(children, offender, index)
449
+ end
450
+
451
+ def exactly_once?(min, max)
452
+ min <= 1 && (max == :unbounded || max >= 1)
453
+ end
454
+
455
+ def offender_realized?(children, offender, index)
456
+ members = index.substitution_members.values.flatten
457
+ children.any? do |child|
458
+ child.name == offender &&
459
+ members.include?(qname(child))
460
+ end
461
+ end
462
+
371
463
  def vouch_table(doc, index)
372
464
  root = doc.root
373
465
  return {} unless root
@@ -434,6 +526,9 @@ module Moxml
434
526
  name = decl["name"] or return
435
527
  qname = "{#{index.target_namespace}}#{name}"
436
528
  index.elements[qname] = { nillable: decl["nillable"] == "true" }
529
+ if (head = decl["substitutionGroup"])
530
+ register_substitution(head, decl, qname, index)
531
+ end
437
532
  type_attr = decl.attribute("type")
438
533
  model = if type_attr.nil? || type_attr.value.empty?
439
534
  inline_model(decl, index)
@@ -443,6 +538,23 @@ module Moxml
443
538
  index.contents[qname] = model if model
444
539
  end
445
540
 
541
+ # substitutionGroup is a QName resolved against the global
542
+ # declaration's in-scope bindings (#466 class 1); the
543
+ # member's own qname is target-namespace-qualified — global
544
+ # declarations always are.
545
+ def register_substitution(head, decl, member_qname, index)
546
+ if head.include?(":")
547
+ prefix, _, local = head.partition(":")
548
+ uri = prefix_uri(prefix, decl)
549
+ return if uri.empty?
550
+
551
+ head_qname = "{#{uri}}#{local}"
552
+ else
553
+ head_qname = "{}#{head}"
554
+ end
555
+ (index.substitution_members[head_qname] ||= []) << member_qname
556
+ end
557
+
446
558
  def index_global_attribute(decl, index)
447
559
  name = decl["name"] or return
448
560
  index.attributes["{#{index.target_namespace}}#{name}"] = true
@@ -468,7 +580,7 @@ module Moxml
468
580
  end
469
581
 
470
582
  def content_model(type_node, index)
471
- model = ContentModel.new([], nil, [], nil)
583
+ model = ContentModel.new([], nil, [], nil, [], true)
472
584
  collect_model(type_node, index, model)
473
585
  model
474
586
  end
@@ -477,36 +589,58 @@ module Moxml
477
589
  # choice / all / complexContent wrappers flattened): we only
478
590
  # ADD findings for strict-wildcard matches, so flattening
479
591
  # can only under-report, never over-report. Cardinality and
480
- # order stay the engine's job.
592
+ # order stay the engine's job — EXCEPT for the substitution
593
+ # dismissal (#466 class 1), which vouches only for pure
594
+ # sequences: any choice/all marks the model impure and the
595
+ # flattened order is then not a validity witness.
481
596
  def collect_model(node, index, model)
482
597
  child_elements(node).each do |child|
483
598
  case child.name
484
599
  when "element"
485
- add_particle(child, index, model,
486
- model.declared_children)
600
+ if (qname = particle_qname(child, index))
601
+ model.declared_children << qname
602
+ model.particles << [
603
+ qname,
604
+ occur_value(child, "minOccurs", 1),
605
+ occur_value(child, "maxOccurs", 1),
606
+ ]
607
+ end
487
608
  when "attribute"
488
- add_particle(child, index, model,
489
- model.declared_attributes)
609
+ if (qname = particle_qname(child, index))
610
+ model.declared_attributes << qname
611
+ end
490
612
  when "any"
491
613
  model.any ||= wildcard_from(child)
492
614
  when "anyAttribute"
493
615
  model.any_attribute ||= wildcard_from(child)
616
+ when "choice", "all"
617
+ model.pure_sequence = false
618
+ collect_model(child, index, model)
494
619
  else
495
620
  collect_model(child, index, model)
496
621
  end
497
622
  end
498
623
  end
499
624
 
500
- def add_particle(particle, index, _model, sink)
625
+ def particle_qname(particle, index)
501
626
  ref = particle["ref"]
502
627
  name = ref&.split(":")&.last || particle["name"]
503
628
  return unless name
504
629
 
505
- sink << if ref
506
- "{#{index.target_namespace}}#{name}"
507
- else
508
- "{#{form_namespace(particle, index)}}#{name}"
509
- end
630
+ if ref
631
+ "{#{index.target_namespace}}#{name}"
632
+ else
633
+ "{#{form_namespace(particle, index)}}#{name}"
634
+ end
635
+ end
636
+
637
+ def occur_value(particle, attribute, default)
638
+ value = particle[attribute]
639
+ return :unbounded if value == "unbounded"
640
+
641
+ Integer(value || default)
642
+ rescue ArgumentError
643
+ default
510
644
  end
511
645
 
512
646
  # Particles declare with form="qualified"/"unqualified",
data/lib/moxml/xsd.rb CHANGED
@@ -177,15 +177,49 @@ module Moxml
177
177
  @context.config.adapter.xsd_declarations(@handle)
178
178
  end
179
179
 
180
- # The effective schema version the source declares (#460 item
181
- # 2): "1.1" when the root carries vc:minVersion="1.1", else
182
- # "1.0". Consumers (lutaml-xsd) scope 1.0-only checks with
183
- # it; the engine's application of 1.1 semantics is the
184
- # engine-side program.
180
+ # The effective schema version (#460 item 2): "1.1" or
181
+ # "1.0". The engine's answer (root @version) can only
182
+ # UPGRADE to 1.1 — it never downgrades the source-derived
183
+ # answer, because vc:minVersion="1.1" selects 1.1 semantics
184
+ # even when @version is absent, and @version="1.1" alongside
185
+ # vc:minVersion="1.0" is an authoring contradiction the
186
+ # source annotation wins.
185
187
  def version
188
+ adapter = @context.config.adapter
189
+ engine = if adapter.respond_to?(:xsd_version)
190
+ adapter.xsd_version(@handle)
191
+ end
192
+ return "1.1" if engine == "1.1"
193
+
186
194
  @rules_index ? @rules_index.version : "1.0"
187
195
  end
188
196
 
197
+ # One row per engine finding with the structured fields the
198
+ # #1843 report carries (moxml#460): kind, component, line,
199
+ # column, message. Nil when the binding predates the face —
200
+ # callers keep using #validate/#errors for the flat shape.
201
+ EngineFinding = Struct.new(:kind, :component, :line, :column,
202
+ :message)
203
+
204
+ def structured_errors(node_or_xml)
205
+ check_released!
206
+ doc = case node_or_xml
207
+ when Moxml::Document then node_or_xml
208
+ when Moxml::Node then node_or_xml.document
209
+ else @context.parse(node_or_xml.to_s)
210
+ end
211
+ adapter = @context.config.adapter
212
+ rows = if adapter.respond_to?(:xsd_structured_errors)
213
+ adapter.xsd_structured_errors(@handle, doc.native)
214
+ end
215
+ return nil unless rows
216
+
217
+ rows.map do |r|
218
+ EngineFinding.new(r.kind, r.component, r.line, r.column,
219
+ r.message)
220
+ end
221
+ end
222
+
189
223
  # Deterministic drop (#362.3): true when the adapter freed
190
224
  # the engine-side schema NOW; false when the adapter can only
191
225
  # wait for GC (documented per adapter). After a true release
@@ -122,6 +122,19 @@ RSpec.describe "XPath idioms across adapters" do
122
122
  expect(doc.at_xpath("count(//a)")).to eq(1.0)
123
123
  end
124
124
 
125
+ # The parent of an attribute node is its OWNER element
126
+ # (XPath 1.0 section 2.2) — the load-bearing arm of standoc's
127
+ # svg id disambiguation (#474).
128
+ it "answers the parent-of-attribute step with the owner element" do
129
+ doc = ctx.parse("<r><svg id='s'><use id='u'/></svg></r>")
130
+ svg = doc.xpath("//*[local-name()='svg']").first
131
+ expect(svg.xpath("./@id/..").map(&:name)).to eq(["svg"])
132
+ expect(svg.xpath("./@id/parent::*").map(&:name)).to eq(["svg"])
133
+ expect(
134
+ svg.xpath(".//*[@id] | ./@id/..").map(&:name).sort,
135
+ ).to eq(%w[svg use])
136
+ end
137
+
125
138
  # The live-tree contract: a sibling inserted after an XPath
126
139
  # evaluation must be visible to at_xpath and xpath. leptris's
127
140
  # node-set lane caches a document index that sibling insertions
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # leptris#1866: a cross-document replace used to hand back the
6
+ # SCRATCH document's wrapper — the adapter's insert deep-copies the
7
+ # subtree into the target, so the returned wrapper's native pointer
8
+ # belonged to the scratch arena. Callers stored it (svg cleanup
9
+ # keeps `new_svg` for id bookkeeping), the scratch was collected,
10
+ # and every later read of the stored wrapper walked freed memory:
11
+ # serializer segfaults and `<use ="">` empty-attribute names in
12
+ # the output.
13
+ RSpec.describe "leptris cross-document replace survival" do
14
+ let(:context) { Moxml.new(:leptris) }
15
+
16
+ it "keeps the installed wrapper valid after the scratch is collected" do
17
+ host = context.parse(
18
+ '<root xmlns:xlink="http://www.w3.org/1999/xlink">' \
19
+ '<svg xmlns="http://www.w3.org/2000/svg" id="s1">' \
20
+ '<use xlink:href="#a"/><use href="#b"/></svg></root>',
21
+ )
22
+ scratch = context.parse(
23
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" id=\"fresh\">" \
24
+ "<use xlink:href=\"#fx\"/><use href=\"#b\"/></svg>",
25
+ )
26
+ olds = host.root.children.select { |c| c.name == "svg" }
27
+ olds[0].replace(scratch.root)
28
+ GC.start(full_mark: true, immediate_sweep: true)
29
+ out = host.root.to_xml
30
+ expect(out).not_to include("<use =")
31
+ # the installed copy's OWN content survives the scratch GC
32
+ expect(out).to include("fx").and include("#b")
33
+ # NOTE: pre-existing divergence D-22 (materialized-wrapper
34
+ # lane) — the UNREPLACED siblings of a cross-doc replace are
35
+ # dropped; reproduced on the stock adapter before this fix.
36
+ end
37
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # The parent step answers the OWNER element natively on bindings
6
+ # serving the fixed engine (1.9.385: leptris#1869 parent-of-attribute
7
+ # names the owner; the cross-doc adopt keeps its attribute chain) —
8
+ # moxml#474: the reverse-axis gate used to route every parent-axis
9
+ # expression to the Ruby engine, so the fast consumers (standoc's
10
+ # svg id cleanup runs `.//*[@id] | ./@id/..` per styled svg) paid
11
+ # the interpreter. Other reverse axes keep the Ruby lane (their
12
+ # native results are reverse-ordered; document order is the
13
+ # model's contract).
14
+ RSpec.describe "leptris native parent-axis lane" do
15
+ let(:ctx) { Moxml.new(:leptris) }
16
+ let(:doc) do
17
+ ctx.parse('<root><svg id="s"><use id="u"/></svg></root>')
18
+ end
19
+ let(:svg) { doc.xpath("//*[local-name()='svg']").first }
20
+
21
+ it "routes parent-of-attribute expressions to the native lane" do
22
+ native = svg.adapter.native_xpath(
23
+ svg.native, "./@id/..", {}
24
+ )
25
+ expect(native).not_to be_nil
26
+ end
27
+
28
+ it "answers the owner element through the facade" do
29
+ expect(svg.xpath("./@id/..").map(&:name)).to eq(["svg"])
30
+ expect(svg.xpath("./@id/..").first).to equal(svg)
31
+ end
32
+
33
+ it "serves the two-arm union natively" do
34
+ expect(
35
+ svg.xpath(".//*[@id] | ./@id/..").map(&:name).sort,
36
+ ).to eq(%w[svg use])
37
+ end
38
+
39
+ it "keeps non-parent reverse axes on the Ruby lane" do
40
+ doc2 = ctx.parse('<r><e a="1" b="2" c="3"/></r>')
41
+ e = doc2.xpath("//e").first
42
+ expect(e.xpath("ancestor::*").map(&:name)).to eq(["r"])
43
+ native = e.adapter.native_xpath(e.native, "ancestor::*", {})
44
+ expect(native).to be_nil
45
+ end
46
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Processing model §Validation (normative): pinned here.
6
+ # moxml#460 items 1-2: the engine's metadata routes through the
7
+ # facade. On bindings predating the faces the probes answer nil and
8
+ # the facade degrades exactly as before (rules heuristic / flat
9
+ # errors) — the degradation is part of the contract.
10
+ RSpec.describe "Moxml::XSD engine metadata" do
11
+ let(:context) { Moxml.new(:leptris) }
12
+
13
+ describe "degradation on faces-less adapters" do
14
+ it "answers the source-derived version and nil structured rows" do
15
+ ctx = Moxml.new(:nokogiri)
16
+ skip "nokogiri requires an XSD-capable engine" unless
17
+ Moxml::XSD.supported?(ctx)
18
+ schema = ctx.xsd(
19
+ "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" version=\"1.1\"><xs:element name=\"e\" type=\"xs:string\"/></xs:schema>",
20
+ )
21
+ expect(schema.version).to eq("1.0")
22
+ expect(schema.structured_errors("<e/>")).to be_nil
23
+ end
24
+ end
25
+
26
+ describe "Schema#version" do
27
+ it "answers 1.1 for a 1.1-tagged schema when the binding serves it" do
28
+ schema = context.xsd(
29
+ '<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" ' \
30
+ 'version="1.1">' \
31
+ '<xs:element name="e" type="xs:string"/></xs:schema>',
32
+ )
33
+ v = schema.version
34
+ expect(v).to eq("1.1").or eq("1.0")
35
+ end
36
+
37
+ it "answers 1.0 for an untagged schema" do
38
+ schema = context.xsd(
39
+ '<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">' \
40
+ '<xs:element name="e" type="xs:string"/></xs:schema>',
41
+ )
42
+ expect(schema.version).to eq("1.0")
43
+ end
44
+ end
45
+
46
+ describe "Schema#structured_errors" do
47
+ it "returns rows with kind/component when the binding serves it" do
48
+ schema = context.xsd(
49
+ '<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">' \
50
+ '<xs:element name="note">' \
51
+ "<xs:complexType><xs:sequence>" \
52
+ '<xs:element name="to" type="xs:string"/>' \
53
+ "</xs:sequence></xs:complexType>" \
54
+ "</xs:element></xs:schema>",
55
+ )
56
+ # a WRONG child, not an empty instance: pre-1.9.383 engines
57
+ # false-validate empty instances (leptris#1859) — this keeps
58
+ # the spec meaningful across both engine generations
59
+ rows = schema.structured_errors("<note><from>x</from></note>")
60
+ skip "binding predates the structured report" if rows.nil?
61
+ expect(rows).not_to be_empty
62
+ expect(rows.first).to respond_to(:kind)
63
+ expect(rows.first).to respond_to(:component)
64
+ end
65
+ end
66
+ end