moxml 0.6.3 → 0.6.4
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 +4 -4
- data/docs/_pages/divergences.adoc +31 -0
- data/lib/moxml/adapter/leptris.rb +216 -21
- data/lib/moxml/context.rb +4 -5
- data/lib/moxml/version.rb +1 -1
- data/lib/moxml/xsd.rb +436 -2
- data/spec/moxml/adapter/leptris_children_address_spec.rb +171 -0
- data/spec/moxml/adapter/leptris_spec.rb +0 -6
- data/spec/moxml/xsd_composed_spec.rb +183 -0
- metadata +4 -3
- data/spec/moxml/adapter/leptris_children_columnar_spec.rb +0 -146
data/lib/moxml/xsd.rb
CHANGED
|
@@ -97,9 +97,10 @@ module Moxml
|
|
|
97
97
|
end
|
|
98
98
|
|
|
99
99
|
def initialize(handle, context, schema_source = nil,
|
|
100
|
-
self_contained: nil)
|
|
100
|
+
self_contained: nil, path: nil)
|
|
101
101
|
@handle = handle
|
|
102
102
|
@context = context
|
|
103
|
+
@schema_path = path
|
|
103
104
|
@rules_index =
|
|
104
105
|
if schema_source
|
|
105
106
|
SchemaRules.index(schema_source, context,
|
|
@@ -157,8 +158,9 @@ module Moxml
|
|
|
157
158
|
end
|
|
158
159
|
end
|
|
159
160
|
engine_errors = @context.config.adapter.xsd_validate(
|
|
160
|
-
|
|
161
|
+
validation_handle(doc), doc.native
|
|
161
162
|
)
|
|
163
|
+
engine_errors = dismiss_refuted_not_declared(engine_errors, doc)
|
|
162
164
|
return engine_errors unless @rules_index
|
|
163
165
|
|
|
164
166
|
# The #452 post-validation rules only ever add findings for
|
|
@@ -170,6 +172,438 @@ module Moxml
|
|
|
170
172
|
SchemaRules.findings(doc, @rules_index)).uniq
|
|
171
173
|
end
|
|
172
174
|
|
|
175
|
+
# Validations run against the compile-time handle. The
|
|
176
|
+
# composition fix (#522) is dismissal-side: the facade indexes
|
|
177
|
+
# every unit of the composition (the schema + the instance's
|
|
178
|
+
# xsi:schemaLocation hints) and drops engine strict-admission
|
|
179
|
+
# rows the index refutes — engines whose admission consults
|
|
180
|
+
# only the head unit's declarations never see the aux units,
|
|
181
|
+
# and recompiling a mixed-targetNamespace union would swap one
|
|
182
|
+
# false answer for another.
|
|
183
|
+
def validation_handle(_doc)
|
|
184
|
+
@handle
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
|
188
|
+
|
|
189
|
+
# xsi:schemaLocation (namespace/path pairs) and
|
|
190
|
+
# xsi:noNamespaceSchemaLocation paths from the validated
|
|
191
|
+
# instance's root, resolved against this schema's directory
|
|
192
|
+
# and filtered to files that exist. Empty when this schema was
|
|
193
|
+
# compiled from a string (no base to resolve against).
|
|
194
|
+
def instance_schema_hints(doc)
|
|
195
|
+
return [].freeze unless @schema_path
|
|
196
|
+
return [].freeze unless doc&.root
|
|
197
|
+
|
|
198
|
+
base = File.dirname(@schema_path)
|
|
199
|
+
root = doc.root
|
|
200
|
+
hints = []
|
|
201
|
+
root.attributes.each do |attribute|
|
|
202
|
+
next unless attribute.namespace&.uri == XSI_NS
|
|
203
|
+
|
|
204
|
+
value = attribute.value.to_s
|
|
205
|
+
case attribute.name
|
|
206
|
+
when "noNamespaceSchemaLocation"
|
|
207
|
+
hints << value
|
|
208
|
+
when "schemaLocation"
|
|
209
|
+
hints.concat(value.split(/\s+/).reject(&:empty?)
|
|
210
|
+
.each_slice(2).map(&:last))
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
hints.uniq
|
|
214
|
+
.map { |hint| File.expand_path(hint, base) }
|
|
215
|
+
.select do |p|
|
|
216
|
+
File.file?(p) && p != File.expand_path(@schema_path)
|
|
217
|
+
end.freeze
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Engine strict-admission rows the composed unit set REFUTES:
|
|
221
|
+
# engines whose wildcard admission consults only the head
|
|
222
|
+
# unit's declaration table report "attribute 'x': not declared
|
|
223
|
+
# (strict)" for attributes declared in another unit of the
|
|
224
|
+
# same compiled composition, and the mixed-tns fresh union
|
|
225
|
+
# compile itself can mis-admit a root element the same way
|
|
226
|
+
# (leptris#1964 family, moxml#522). The facade owns the
|
|
227
|
+
# composition, so it can refute a row when the named item, as
|
|
228
|
+
# it appears in THIS instance, is a global declaration of any
|
|
229
|
+
# unit (namespace + local name). Only exact "not declared
|
|
230
|
+
# (strict)" rows are ever dropped; every other engine finding
|
|
231
|
+
# passes through untouched.
|
|
232
|
+
def dismiss_refuted_not_declared(engine_errors, doc)
|
|
233
|
+
return engine_errors if engine_errors.empty? || !@schema_path
|
|
234
|
+
|
|
235
|
+
hints = instance_schema_hints(doc)
|
|
236
|
+
units = composed_unit_set([@schema_path, *hints])
|
|
237
|
+
declared = composed_declarations(units)
|
|
238
|
+
admissible = element_admissibility(units)
|
|
239
|
+
return engine_errors if declared.empty? && admissible.empty?
|
|
240
|
+
|
|
241
|
+
attr_index = {}
|
|
242
|
+
element_ns = {}
|
|
243
|
+
stack = [doc.root]
|
|
244
|
+
while (element = stack.pop)
|
|
245
|
+
element_ns[(element.namespace&.uri || "")] ||= true
|
|
246
|
+
stack.concat(element.children.grep(Moxml::Element))
|
|
247
|
+
element.attributes.each do |attribute|
|
|
248
|
+
attr_uri = attribute.namespace&.uri || ""
|
|
249
|
+
((attr_index[[attr_uri, attribute.name]] ||= []) <<
|
|
250
|
+
element.name)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
engine_errors.reject do |finding|
|
|
254
|
+
match = NOT_DECLARED_ROW.match(finding.message)
|
|
255
|
+
next false unless match
|
|
256
|
+
|
|
257
|
+
element_name = match[1]
|
|
258
|
+
attribute_name = match[2]
|
|
259
|
+
if attribute_name
|
|
260
|
+
attr_index.any? do |(uri, attr_name), holders|
|
|
261
|
+
next false unless attr_name == attribute_name &&
|
|
262
|
+
holders.include?(element_name)
|
|
263
|
+
|
|
264
|
+
# A global declaration of some unit, or a group member
|
|
265
|
+
# the element's type admits (moxml#522 attgC class —
|
|
266
|
+
# exact (element, attribute, namespace) match).
|
|
267
|
+
declared.key?([uri, attribute_name]) ||
|
|
268
|
+
admissible.dig(element_name, attribute_name) == uri
|
|
269
|
+
end
|
|
270
|
+
else
|
|
271
|
+
declared.key?(["", element_name]) ||
|
|
272
|
+
(element_ns[element_name] &&
|
|
273
|
+
declared.key?([element_ns[element_name], element_name]))
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# The composition closure of the unit set: every unit's
|
|
279
|
+
# xs:include / xs:import / xs:redefine schemaLocation targets
|
|
280
|
+
# (existing files), cycle-guarded, resolved relative to each
|
|
281
|
+
# unit. The engine compiles these documents as ONE composition;
|
|
282
|
+
# the facade's index must see the same set even when the
|
|
283
|
+
# instance carries no xsi hints (attgC035's redefined groups
|
|
284
|
+
# live in documents only the schema graph knows).
|
|
285
|
+
def composed_unit_set(units)
|
|
286
|
+
@composed_unit_set ||= {}
|
|
287
|
+
cached = @composed_unit_set[units]
|
|
288
|
+
return cached if cached
|
|
289
|
+
|
|
290
|
+
seen = {}
|
|
291
|
+
queue = units.dup
|
|
292
|
+
until queue.empty?
|
|
293
|
+
unit = queue.pop
|
|
294
|
+
next if seen.key?(unit)
|
|
295
|
+
|
|
296
|
+
seen[unit] = true
|
|
297
|
+
base = File.dirname(unit)
|
|
298
|
+
unit_schema_refs(unit).each do |ref|
|
|
299
|
+
target = File.expand_path(ref, base)
|
|
300
|
+
queue << target if File.file?(target) && !seen.key?(target)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
closure = seen.keys.sort
|
|
304
|
+
@composed_unit_set[units] = closure
|
|
305
|
+
closure
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# schemaLocation hrefs on the unit's own xs:include, xs:import
|
|
309
|
+
# and xs:redefine (poison-immune REXML read; the engine's
|
|
310
|
+
# component tables are process-global and a later parse can
|
|
311
|
+
# poison compiled handles, moxml#522).
|
|
312
|
+
def unit_schema_refs(unit)
|
|
313
|
+
require "rexml/document"
|
|
314
|
+
root = REXML::Document.new(File.read(unit)).root
|
|
315
|
+
return [].freeze unless root&.name == "schema"
|
|
316
|
+
|
|
317
|
+
refs = %w[include import redefine].map do |tag|
|
|
318
|
+
root.elements.each_with_object([]) do |node, out|
|
|
319
|
+
next unless node.name == tag
|
|
320
|
+
|
|
321
|
+
loc = node.attributes["schemaLocation"]
|
|
322
|
+
out << loc if loc.to_s.match?(/\S/)
|
|
323
|
+
end
|
|
324
|
+
end.flatten
|
|
325
|
+
refs.freeze
|
|
326
|
+
rescue StandardError
|
|
327
|
+
[].freeze
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# {element name => {attribute name => namespace}}: every
|
|
331
|
+
# attribute the composition admits for each element — the
|
|
332
|
+
# element's type's attributeGroup members resolved transitively
|
|
333
|
+
# across units (import groups; an xs:redefine body's self-ref
|
|
334
|
+
# is the 4.2.2 hook to the ORIGINAL definition, so the
|
|
335
|
+
# effective group is body UNION original). Only exact members
|
|
336
|
+
# of the real composition land here, so a genuinely undeclared
|
|
337
|
+
# attribute never becomes refutable.
|
|
338
|
+
def element_admissibility(units)
|
|
339
|
+
@element_admissibility ||= {}
|
|
340
|
+
cached = @element_admissibility[units]
|
|
341
|
+
return cached if cached
|
|
342
|
+
|
|
343
|
+
facts = {}
|
|
344
|
+
units.each do |unit|
|
|
345
|
+
unit_facts = schema_unit_facts(unit)
|
|
346
|
+
facts[unit] = unit_facts if unit_facts
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Effective group definitions: redefining bodies merge the
|
|
350
|
+
# target unit's original when (and only when) the body hooks
|
|
351
|
+
# it with a self-ref. Keys: bare name AND {uri}name (refs
|
|
352
|
+
# resolve with the referencing unit's xmlns, bare-name
|
|
353
|
+
# fallback — same-name groups are composition-unique).
|
|
354
|
+
plain = {}
|
|
355
|
+
bodies = {}
|
|
356
|
+
facts.each_value do |unit|
|
|
357
|
+
unit[:redefines].each do |name, target_path|
|
|
358
|
+
body = unit[:groups][name]
|
|
359
|
+
original = facts[target_path]&.dig(:groups, name)
|
|
360
|
+
bodies[name] = merge_group_def(body, original, name)
|
|
361
|
+
end
|
|
362
|
+
unit[:groups].each do |name, defn|
|
|
363
|
+
plain[name] = defn unless unit[:redefines].key?(name)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
# Redefine bodies are the EFFECTIVE definitions — they win
|
|
367
|
+
# over the plain originals.
|
|
368
|
+
groups = plain.merge(bodies)
|
|
369
|
+
|
|
370
|
+
resolved = {}
|
|
371
|
+
facts.each_value do |unit|
|
|
372
|
+
unit[:elements].each do |element_name, element|
|
|
373
|
+
attrs = {}
|
|
374
|
+
element[:groups].each do |qref|
|
|
375
|
+
resolve_group_ref(qref, unit, groups, attrs, {})
|
|
376
|
+
end
|
|
377
|
+
element[:attrs].each do |name, uri|
|
|
378
|
+
attrs[name] = uri
|
|
379
|
+
end
|
|
380
|
+
resolved[element_name] = attrs unless attrs.empty?
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
@element_admissibility[units] = resolved.freeze
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# body UNION original when the body hooks the original with a
|
|
387
|
+
# self-ref; the body alone otherwise.
|
|
388
|
+
def merge_group_def(body, original, name)
|
|
389
|
+
return body unless body && original.is_a?(Hash) &&
|
|
390
|
+
body[:refs].include?(name)
|
|
391
|
+
|
|
392
|
+
{
|
|
393
|
+
attrs: body[:attrs] + original[:attrs],
|
|
394
|
+
refs: body[:refs] + original[:refs],
|
|
395
|
+
}
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Resolve one attributeGroup ref (raw QName string) into attrs
|
|
399
|
+
# {name => uri}, transitively; cycle-guarded by +seen+. Keys
|
|
400
|
+
# are bare names with qualified "{uri}name" fallbacks.
|
|
401
|
+
def resolve_group_ref(qref, from_unit, groups, attrs, seen)
|
|
402
|
+
return if seen.key?(qref)
|
|
403
|
+
|
|
404
|
+
seen[qref] = true
|
|
405
|
+
prefix, _, local = qref.rpartition(":")
|
|
406
|
+
xmlns = from_unit[:xmlns]
|
|
407
|
+
uri = if prefix.empty?
|
|
408
|
+
xmlns["xmlns"].to_s
|
|
409
|
+
else
|
|
410
|
+
xmlns["xmlns:#{prefix}"].to_s
|
|
411
|
+
end
|
|
412
|
+
defn = groups["{#{uri}}#{local}"] || groups[local]
|
|
413
|
+
return unless defn.is_a?(Hash)
|
|
414
|
+
|
|
415
|
+
defn[:attrs].each do |name, attr_uri|
|
|
416
|
+
attrs[name] = attr_uri
|
|
417
|
+
end
|
|
418
|
+
defn[:refs].each do |ref|
|
|
419
|
+
resolve_group_ref(ref, from_unit, groups, attrs, seen)
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Per-unit schema facts for the admissibility index:
|
|
424
|
+
# {tns, afd (attributeFormDefault qualified?), xmlns,
|
|
425
|
+
# groups: {name => {attrs, refs}}, redefines: {name => target},
|
|
426
|
+
# elements: {name => {groups, attrs}}} — poison-immune REXML.
|
|
427
|
+
# Attribute member URIs use the DEFINING unit's form rules;
|
|
428
|
+
# group refs stay raw QNames (resolved at chase time).
|
|
429
|
+
def schema_unit_facts(unit)
|
|
430
|
+
require "rexml/document"
|
|
431
|
+
root = REXML::Document.new(File.read(unit)).root
|
|
432
|
+
return nil unless root&.name == "schema"
|
|
433
|
+
|
|
434
|
+
xmlns = {}
|
|
435
|
+
root.attributes.each do |name, value|
|
|
436
|
+
xmlns[name] = value.to_s if name.start_with?("xmlns")
|
|
437
|
+
end
|
|
438
|
+
tns = root.attributes["targetNamespace"].to_s
|
|
439
|
+
afd = root.attributes["attributeFormDefault"] == "qualified"
|
|
440
|
+
|
|
441
|
+
member_uri = lambda do |node|
|
|
442
|
+
qualified = node.attributes["form"] == "qualified" || afd
|
|
443
|
+
qualified ? tns : ""
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
groups = {}
|
|
447
|
+
redefines = {}
|
|
448
|
+
root.elements.each do |node|
|
|
449
|
+
case node.name
|
|
450
|
+
when "attributeGroup"
|
|
451
|
+
name = node.attributes["name"]
|
|
452
|
+
next unless name
|
|
453
|
+
|
|
454
|
+
groups[name] = walk_group_members(node, member_uri)
|
|
455
|
+
when "redefine"
|
|
456
|
+
loc = node.attributes["schemaLocation"]
|
|
457
|
+
node.elements.each do |body|
|
|
458
|
+
next unless body.name == "attributeGroup"
|
|
459
|
+
|
|
460
|
+
name = body.attributes["name"]
|
|
461
|
+
next unless name
|
|
462
|
+
|
|
463
|
+
groups[name] = walk_group_members(body, member_uri)
|
|
464
|
+
redefines[name] = File.expand_path(loc, File.dirname(unit)) if
|
|
465
|
+
loc.to_s.match?(/\S/)
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
elements = {}
|
|
471
|
+
root.elements.each do |node|
|
|
472
|
+
next unless node.name == "element"
|
|
473
|
+
next unless (name = node.attributes["name"])
|
|
474
|
+
|
|
475
|
+
container = node.elements.find { |child| child.name == "complexType" }
|
|
476
|
+
type_name = node.attributes["type"].to_s
|
|
477
|
+
container ||= root.elements.find do |child|
|
|
478
|
+
child.name == "complexType" && child.attributes["name"] == type_name
|
|
479
|
+
end
|
|
480
|
+
next unless container.is_a?(REXML::Element)
|
|
481
|
+
|
|
482
|
+
groups_seen, attrs = walk_type_attributes(container, member_uri)
|
|
483
|
+
elements[name] = { groups: groups_seen, attrs: attrs }
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
{
|
|
487
|
+
tns: tns, afd: afd, xmlns: xmlns, groups: groups,
|
|
488
|
+
redefines: redefines, elements: elements
|
|
489
|
+
}
|
|
490
|
+
rescue StandardError
|
|
491
|
+
nil
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Refs and attribute members of one attributeGroup definition.
|
|
495
|
+
# Refs stay raw QNames — the chase resolves prefixes through
|
|
496
|
+
# the referencing unit's xmlns.
|
|
497
|
+
def walk_group_members(node, member_uri)
|
|
498
|
+
refs = []
|
|
499
|
+
attrs = []
|
|
500
|
+
node.elements.each do |child|
|
|
501
|
+
case child.name
|
|
502
|
+
when "attribute"
|
|
503
|
+
name = child.attributes["name"]
|
|
504
|
+
attrs << [name, member_uri.call(child)] if name
|
|
505
|
+
when "attributeGroup"
|
|
506
|
+
ref = child.attributes["ref"]
|
|
507
|
+
refs << ref if ref.to_s.match?(/\S/)
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
{ attrs: attrs, refs: refs }
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# attributeGroup refs + own attribute members of a complexType
|
|
514
|
+
# (one level; nested group-through-group chains resolve at
|
|
515
|
+
# chase time). Returns [raw refs, attrs].
|
|
516
|
+
def walk_type_attributes(container, member_uri)
|
|
517
|
+
refs = []
|
|
518
|
+
attrs = []
|
|
519
|
+
container.elements.each do |child|
|
|
520
|
+
case child.name
|
|
521
|
+
when "attributeGroup"
|
|
522
|
+
ref = child.attributes["ref"]
|
|
523
|
+
refs << ref if ref.to_s.match?(/\S/)
|
|
524
|
+
when "attribute"
|
|
525
|
+
name = child.attributes["name"]
|
|
526
|
+
attrs << [name, member_uri.call(child)] if name
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
[refs, attrs]
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# {uri, local} => true for every global xs:attribute and
|
|
533
|
+
# xs:element declared across the unit set: a global
|
|
534
|
+
# declaration always takes its unit's targetNamespace.
|
|
535
|
+
# Parsed with REXML when available: the engine's component
|
|
536
|
+
# tables are process-global and can be poisoned by later
|
|
537
|
+
# parses between compile and validate (moxml#522) — a
|
|
538
|
+
# declaration index must be poison-immune. Falls back to the
|
|
539
|
+
# context parse when REXML is unavailable.
|
|
540
|
+
def composed_declarations(units)
|
|
541
|
+
@composed_declarations ||= {}
|
|
542
|
+
cached = @composed_declarations[units]
|
|
543
|
+
return cached if cached
|
|
544
|
+
|
|
545
|
+
declared = {}
|
|
546
|
+
parser = begin
|
|
547
|
+
require "rexml"
|
|
548
|
+
:rexml
|
|
549
|
+
rescue LoadError
|
|
550
|
+
:context
|
|
551
|
+
end
|
|
552
|
+
units.each do |unit|
|
|
553
|
+
tns, decls = unit_declarations(unit, parser)
|
|
554
|
+
next unless decls
|
|
555
|
+
|
|
556
|
+
decls.each do |name|
|
|
557
|
+
declared[[tns, name]] = true
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
@composed_declarations[units] = declared
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# [targetNamespace, [global attribute/element names]] for one
|
|
564
|
+
# schema unit, per the parser flavor. REXML's read is
|
|
565
|
+
# poison-immune; the context parse is the fallback.
|
|
566
|
+
def unit_declarations(unit, parser)
|
|
567
|
+
text = File.read(unit)
|
|
568
|
+
if parser == :rexml
|
|
569
|
+
require "rexml/document"
|
|
570
|
+
root = REXML::Document.new(text).root
|
|
571
|
+
return nil unless root&.name == "schema"
|
|
572
|
+
|
|
573
|
+
tns = root.attributes["targetNamespace"].to_s
|
|
574
|
+
names = %w[attribute element].freeze
|
|
575
|
+
decls = root.elements.filter_map do |decl|
|
|
576
|
+
next unless names.include?(decl.name)
|
|
577
|
+
|
|
578
|
+
decl.attributes["name"]
|
|
579
|
+
end
|
|
580
|
+
else
|
|
581
|
+
root = @context.parse(text).root
|
|
582
|
+
return nil unless root&.name == "schema"
|
|
583
|
+
|
|
584
|
+
tns = root.attributes.find { |a| a.name == "targetNamespace" }
|
|
585
|
+
&.value.to_s
|
|
586
|
+
names = %w[attribute element].freeze
|
|
587
|
+
decls = root.children
|
|
588
|
+
.grep(Moxml::Element)
|
|
589
|
+
.filter_map do |decl|
|
|
590
|
+
next unless names.include?(decl.name)
|
|
591
|
+
|
|
592
|
+
decl.attributes.find { |a| a.name == "name" }&.value
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
[tns, decls]
|
|
596
|
+
rescue StandardError
|
|
597
|
+
nil
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Matches both row shapes the strict admission emits — with and
|
|
601
|
+
# without the attribute segment:
|
|
602
|
+
# element 'doc' @2:144: element 'doc': not declared (strict)
|
|
603
|
+
# element 'elem' @3:46: attribute 'foo': not declared (strict)
|
|
604
|
+
NOT_DECLARED_ROW =
|
|
605
|
+
/\Aelement '([^']+)'(?: @\S+)? (?:(?:element|attribute) '([^']+)': )?not declared \(strict\)\z/
|
|
606
|
+
|
|
173
607
|
def valid?(node_or_xml)
|
|
174
608
|
check_released!
|
|
175
609
|
validate(node_or_xml).empty?
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "leptris"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
# Leptris gem not available - skip all specs in this file
|
|
7
|
+
return
|
|
8
|
+
end
|
|
9
|
+
return unless Leptris::XML::Document.respond_to?(:create)
|
|
10
|
+
# The address-native lane rides the binding's shadow faces
|
|
11
|
+
# (children_addrs / walk_addresses, leptris-ruby#587/#589/#591);
|
|
12
|
+
# older bindings keep the columnar per-subtree crossing, so the
|
|
13
|
+
# routing cannot be pinned there.
|
|
14
|
+
return unless defined?(Leptris::XML::Native) &&
|
|
15
|
+
Leptris::XML::Native.respond_to?(:children_addrs)
|
|
16
|
+
|
|
17
|
+
require "moxml/adapter/leptris"
|
|
18
|
+
|
|
19
|
+
RSpec.describe "Moxml::Adapter::Leptris address-native children (shadow lane)" do
|
|
20
|
+
around do |example|
|
|
21
|
+
Moxml.with_config(:leptris, true, "UTF-8") do
|
|
22
|
+
example.run
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
let(:xml) do
|
|
27
|
+
<<~XML
|
|
28
|
+
<?xml version="1.0"?><r><a x="1">t</a><b/><c><d/></c><tail>z</tail></r>
|
|
29
|
+
XML
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
let(:doc) { Moxml.parse(xml) }
|
|
33
|
+
let(:root) { doc.root }
|
|
34
|
+
let(:root_native) { root.native }
|
|
35
|
+
|
|
36
|
+
# Counts real Native.children_addrs C crossings inside the block —
|
|
37
|
+
# no stubs, the actual boundary traffic the address lane pays.
|
|
38
|
+
def with_crossing_count(method_id = :children_addrs)
|
|
39
|
+
calls = 0
|
|
40
|
+
tp = TracePoint.new(:c_call) do |t|
|
|
41
|
+
calls += 1 if t.method_id == method_id
|
|
42
|
+
end
|
|
43
|
+
result = nil
|
|
44
|
+
tp.enable { result = yield }
|
|
45
|
+
[calls, result]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe "the address gate" do
|
|
49
|
+
it "face-probes the shadow faces on the binding" do
|
|
50
|
+
expect(Moxml::Adapter::Leptris::ADDRESS_NATIVE).to be(true)
|
|
51
|
+
expect(Leptris::XML::Native.respond_to?(:children_addrs)).to be(true)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe "plain children reads" do
|
|
56
|
+
it "answer address tokens matching the native children face" do
|
|
57
|
+
got = Moxml::Adapter::Leptris.children(root_native)
|
|
58
|
+
expected = Leptris::XML::NativeNode
|
|
59
|
+
.instance_method(:children).bind_call(root_native).to_a
|
|
60
|
+
expect(got).to all(be_a(Moxml::Adapter::Leptris::AddressNode))
|
|
61
|
+
expect(got.size).to eq(expected.size)
|
|
62
|
+
expect(got.map { |n| Moxml::Adapter::Leptris.node_name(n) })
|
|
63
|
+
.to eq(expected.map(&:name))
|
|
64
|
+
got.each_with_index do |token, i|
|
|
65
|
+
# Address equality: the token stands for the same C node.
|
|
66
|
+
expect(token.address).to eq(expected[i].address)
|
|
67
|
+
expect(Moxml::Adapter::Leptris.node_type(token))
|
|
68
|
+
.to eq(Moxml::Adapter::Leptris.node_type(expected[i]))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "hand back the SAME token per address across traversals" do
|
|
73
|
+
first = Moxml::Adapter::Leptris.children(root_native)
|
|
74
|
+
second = Moxml::Adapter::Leptris.children(root_native)
|
|
75
|
+
expect(second).to eq(first)
|
|
76
|
+
expect(second.first).to equal(first.first)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Parsed-document wrappers mint with Identity::ChildReads above
|
|
80
|
+
# Moxml::Node, so consumer iteration on those wrappers dispatches
|
|
81
|
+
# to the binding's C read layer. The address face pays one
|
|
82
|
+
# children_addrs crossing per element receiver — the token mint
|
|
83
|
+
# replaces the TypedData wrap + identity-cache write the columnar
|
|
84
|
+
# lane pays per node in C.
|
|
85
|
+
it "pay one crossing per element receiver, none on repeat reads" do
|
|
86
|
+
calls, = with_crossing_count do
|
|
87
|
+
walk_face = lambda do |token|
|
|
88
|
+
Moxml::Adapter::Leptris.children(token).each do |child|
|
|
89
|
+
walk_face.call(child)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
walk_face.call(root_native)
|
|
93
|
+
end
|
|
94
|
+
# The subtree has 6 elements (r, a, b, c, d, tail): one crossing
|
|
95
|
+
# each, no walk_columnar at all.
|
|
96
|
+
expect(calls).to eq(6)
|
|
97
|
+
calls2, = with_crossing_count do
|
|
98
|
+
Moxml::Adapter::Leptris.children(root_native)
|
|
99
|
+
end
|
|
100
|
+
expect(calls2).to eq(1)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "serve element reads through the address-keyed faces" do
|
|
104
|
+
target = Moxml::Adapter::Leptris.children(root_native)
|
|
105
|
+
.find { |t| Moxml::Adapter::Leptris.node_name(t) == "a" }
|
|
106
|
+
expect(Moxml::Adapter::Leptris.bare_attr_value(target, "x")).to eq("1")
|
|
107
|
+
expect(Moxml::Adapter::Leptris.text_content(target)).to eq("t")
|
|
108
|
+
expect(Moxml::Adapter::Leptris.attributes(target).map(&:value))
|
|
109
|
+
.to eq(["1"])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "keep the document assembly path on document receivers" do
|
|
113
|
+
calls, doc_children = with_crossing_count do
|
|
114
|
+
Moxml::Adapter::Leptris.children(doc.native)
|
|
115
|
+
end
|
|
116
|
+
# No token crossings: the document's child list rides the
|
|
117
|
+
# assembly path (declaration/doctype kinds are outside the
|
|
118
|
+
# address lane's WS enum).
|
|
119
|
+
expect(calls).to eq(0)
|
|
120
|
+
expect(doc_children.map do |n|
|
|
121
|
+
Moxml::Adapter::Leptris.node_name(n)
|
|
122
|
+
end).to include("r")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
describe "mutation invalidation" do
|
|
127
|
+
# Read back through the adapter face: the token map must never
|
|
128
|
+
# serve a list a mutation has invalidated.
|
|
129
|
+
def face_children_names
|
|
130
|
+
Moxml::Adapter::Leptris.children(root_native)
|
|
131
|
+
.map { |n| Moxml::Adapter::Leptris.node_name(n) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "sees children added after the map was primed" do
|
|
135
|
+
face_children_names # prime the map
|
|
136
|
+
root.add_child(doc.create_element("late"))
|
|
137
|
+
expect(face_children_names).to include("late")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "sees children removed after the map was primed" do
|
|
141
|
+
face_children_names # prime the map
|
|
142
|
+
root.children.to_a.first.remove
|
|
143
|
+
expect(face_children_names).not_to include("a")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "sees sibling insertions after the map was primed" do
|
|
147
|
+
face_children_names # prime the map
|
|
148
|
+
anchor = root.children.to_a.first
|
|
149
|
+
anchor.add_previous_sibling(doc.create_element("before2"))
|
|
150
|
+
expect(face_children_names).to include("before2")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "sees replace after the map was primed" do
|
|
154
|
+
face_children_names # prime the map
|
|
155
|
+
victim = root.children.to_a.find { |n| n.name == "b" }
|
|
156
|
+
victim.replace(doc.create_element("swapped"))
|
|
157
|
+
expect(face_children_names).to include("swapped")
|
|
158
|
+
expect(face_children_names).not_to include("b")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "sees content rewrites after the map was primed" do
|
|
162
|
+
face_children_names # prime the map
|
|
163
|
+
target = root.children.to_a.find { |n| n.name == "a" }
|
|
164
|
+
target.content = "rewritten"
|
|
165
|
+
names = face_children_names
|
|
166
|
+
expect(names).to include("a")
|
|
167
|
+
expect(root.children.to_a.find { |n| n.name == "a" }.text)
|
|
168
|
+
.to eq("rewritten")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -53,10 +53,6 @@ RSpec.describe Moxml::Adapter::Leptris do
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
it "evaluates attribute-node queries with proper wrappers" do
|
|
56
|
-
# Native since 1.9.105 (leptris-ruby#153: ResultAttr with
|
|
57
|
-
# name/value); earlier bindings returned generic nodes whose
|
|
58
|
-
# #name raised, so the Ruby engine owned them.
|
|
59
|
-
skip "requires native attr results (leptris 1.9.105+)" unless described_class::ATTR_RESULT_NATIVE
|
|
60
56
|
attrs = doc.xpath("//item/@id")
|
|
61
57
|
expect(attrs.map(&:name)).to eq(%w[id id])
|
|
62
58
|
expect(attrs.map(&:value)).to eq(%w[1 2])
|
|
@@ -759,8 +755,6 @@ RSpec.describe Moxml::Adapter::Leptris do
|
|
|
759
755
|
let(:ctx) { Moxml.new(:leptris) }
|
|
760
756
|
|
|
761
757
|
it "answers engine source positions for parsed nodes" do
|
|
762
|
-
skip "requires leptris 1.9.181" unless described_class::NATIVE_SOURCE_POSITION
|
|
763
|
-
|
|
764
758
|
doc = ctx.parse("<root>\n <child>text</child>\n</root>")
|
|
765
759
|
expect(doc.root.source_position).to include(line: 1)
|
|
766
760
|
expect(doc.root.source_position).to be_a(Hash)
|