moxml 0.5.148 → 0.5.150
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/.github/workflows/dependent-repos.json +7 -2
- data/.github/workflows/dependent-tests.yml +4 -1
- data/.github/workflows/nightly-fuzz.yml +80 -0
- data/.github/workflows/performance-release.yml +79 -0
- data/.github/workflows/performance.yml +59 -0
- data/Rakefile +23 -0
- data/benchmark/README.md +106 -0
- data/benchmark/scoreboard.json +115 -0
- data/benchmark/scoreboard.rb +556 -0
- data/bin/generate-model-coverage +21 -0
- data/docs/_pages/adapter-compatibility.adoc +3 -3
- data/docs/_pages/divergences.adoc +88 -39
- data/docs/_pages/ecosystem.adoc +62 -0
- data/docs/_pages/headed-ox-limitations.adoc +1 -1
- data/docs/_pages/headed-ox.adoc +2 -2
- data/docs/_pages/index.adoc +4 -0
- data/docs/_pages/model-coverage.adoc +57 -0
- data/docs/_pages/performance.adoc +29 -0
- data/docs/_pages/why-moxml.adoc +87 -0
- data/docs/_references/processing-model.adoc +69 -3
- data/lib/compat/opal/moxml_boot.rb +4 -0
- data/lib/moxml/adapter/base.rb +73 -0
- data/lib/moxml/adapter/leptris.rb +293 -23
- data/lib/moxml/adapter/libxml.rb +179 -25
- data/lib/moxml/adapter/nokogiri.rb +10 -0
- data/lib/moxml/adapter/oga.rb +71 -9
- data/lib/moxml/adapter/ox.rb +34 -2
- data/lib/moxml/adapter/rexml.rb +82 -17
- data/lib/moxml/c14n/data_model.rb +8 -0
- data/lib/moxml/c14n/processor.rb +17 -8
- data/lib/moxml/context.rb +33 -1
- data/lib/moxml/document.rb +18 -4
- data/lib/moxml/node.rb +75 -8
- data/lib/moxml/node_set.rb +42 -1
- data/lib/moxml/reference/normalizer.rb +63 -0
- data/lib/moxml/reference/parser.rb +417 -0
- data/lib/moxml/reference/writer.rb +121 -0
- data/lib/moxml/reference.rb +205 -0
- data/lib/moxml/relaxng.rb +62 -0
- data/lib/moxml/sax/block_handler.rb +7 -0
- data/lib/moxml/sax/handler.rb +20 -0
- data/lib/moxml/version.rb +1 -1
- data/lib/moxml/xpath/compiler.rb +17 -2
- data/lib/moxml/xpath/ordering.rb +97 -0
- data/lib/moxml/xpath.rb +29 -0
- data/lib/moxml.rb +2 -0
- data/spec/consistency/adapter_parity_spec.rb +24 -0
- data/spec/consistency/citation_hygiene_spec.rb +30 -0
- data/spec/consistency/differential_taxonomy_spec.rb +117 -0
- data/spec/consistency/divergence_register_spec.rb +9 -1
- data/spec/consistency/eol_normalization_spec.rb +2 -0
- data/spec/consistency/loud_degradation_spec.rb +2 -0
- data/spec/consistency/metamorphic_properties_spec.rb +195 -0
- data/spec/consistency/metamorphic_spec.rb +138 -0
- data/spec/consistency/model_coverage_audit_spec.rb +144 -61
- data/spec/consistency/published_docs_spec.rb +49 -0
- data/spec/consistency/reference_differential_spec.rb +186 -0
- data/spec/consistency/xpath_idioms_spec.rb +34 -5
- data/spec/consistency/xslt_spec.rb +2 -0
- data/spec/integration/shared_examples/edge_cases.rb +0 -13
- data/spec/moxml/adapter/leptris_attribute_snapshot_spec.rb +10 -7
- data/spec/moxml/adapter/leptris_spec.rb +23 -3
- data/spec/moxml/adapter/ox_spec.rb +1 -1
- data/spec/moxml/adapter/shared_examples/adapter_contract.rb +0 -8
- data/spec/moxml/attribute_resolver_spec.rb +35 -0
- data/spec/moxml/c14n/adapter_delegation_spec.rb +35 -6
- data/spec/moxml/c14n/character_encoding_spec.rb +7 -13
- data/spec/moxml/c14n/comments_pis_spec.rb +35 -8
- data/spec/moxml/c14n/subset_spec.rb +2 -12
- data/spec/moxml/c14n/w3c_examples_spec.rb +2 -5
- data/spec/moxml/doctype_spec.rb +7 -0
- data/spec/moxml/document_parse_diagnostics_spec.rb +2 -0
- data/spec/moxml/element_indent_spec.rb +34 -0
- data/spec/moxml/entity_spec.rb +2 -0
- data/spec/moxml/node_spec.rb +0 -3
- data/spec/moxml/plan_conformance_spec.rb +2 -0
- data/spec/moxml/processing_instruction_spec.rb +9 -0
- data/spec/moxml/reference_opal_spec.rb +29 -0
- data/spec/moxml/reference_spec.rb +162 -0
- data/spec/moxml/relax_ng_spec.rb +67 -0
- data/spec/moxml/sax_spec.rb +47 -74
- data/spec/moxml/text_spec.rb +2 -0
- data/spec/moxml/to_html_spec.rb +2 -0
- data/spec/moxml/xhtml_output_spec.rb +2 -0
- data/spec/moxml/xpath/axes_spec.rb +2 -2
- data/spec/moxml/xpath_capabilities_spec.rb +11 -38
- data/spec/moxml/xsd_spec.rb +38 -0
- data/spec/support/input_generator.rb +154 -0
- data/spec/support/input_taxonomy.rb +219 -0
- data/spec/support/model_coverage.rb +159 -0
- data/spec/support/xml_generator.rb +54 -0
- metadata +32 -2
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
# Executable reference semantics (TODO.sota/03): a pure-Ruby XML
|
|
5
|
+
# reader, writer, and normalizer that answer the processing model
|
|
6
|
+
# without any native engine. Adapters are differentially tested
|
|
7
|
+
# against these answers; the reference is also the lane for
|
|
8
|
+
# engine-free environments (the Opal line generalized).
|
|
9
|
+
#
|
|
10
|
+
# The reference is deliberately small and total over the model's
|
|
11
|
+
# declared input taxonomy — every well-formed document the model
|
|
12
|
+
# admits is parseable here. It is an oracle, not a production
|
|
13
|
+
# parser: no recovery, no diagnostics beyond a named ParseError.
|
|
14
|
+
module Reference
|
|
15
|
+
autoload :Parser, "moxml/reference/parser"
|
|
16
|
+
autoload :Writer, "moxml/reference/writer"
|
|
17
|
+
autoload :Normalizer, "moxml/reference/normalizer"
|
|
18
|
+
|
|
19
|
+
# A reference node: the information set of the processing model,
|
|
20
|
+
# with no engine-specific shape.
|
|
21
|
+
class Node
|
|
22
|
+
attr_accessor :kind, :name, :attributes, :children, :data, :parent
|
|
23
|
+
|
|
24
|
+
def initialize(kind:, name: nil, data: nil, attributes: [])
|
|
25
|
+
@kind = kind
|
|
26
|
+
@name = name
|
|
27
|
+
@data = data
|
|
28
|
+
@attributes = attributes
|
|
29
|
+
@children = []
|
|
30
|
+
@parent = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def element?
|
|
34
|
+
@kind == :element
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def text?
|
|
38
|
+
@kind == :text
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The facade's name face: a bare local name for prefixed sources
|
|
42
|
+
# (Element#name; attribute pairs list bare names too).
|
|
43
|
+
def local_name
|
|
44
|
+
return @name unless @name&.include?(":")
|
|
45
|
+
|
|
46
|
+
@name.split(":", 2)[1]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Concatenated descendant text (elements; text and CDATA only —
|
|
50
|
+
# comments and PI data are not text); own data for text-like
|
|
51
|
+
# nodes (the model's `text` face).
|
|
52
|
+
def text
|
|
53
|
+
case @kind
|
|
54
|
+
when :element
|
|
55
|
+
@children.select do |child|
|
|
56
|
+
child.kind == :text || child.kind == :cdata || child.element?
|
|
57
|
+
end.map(&:text).join
|
|
58
|
+
when :text, :cdata
|
|
59
|
+
@data
|
|
60
|
+
else
|
|
61
|
+
""
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Element content is its text face; text-like nodes answer their
|
|
66
|
+
# data (PI data excludes the separator, per the model).
|
|
67
|
+
def content
|
|
68
|
+
element? ? text : @data
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def attribute(name)
|
|
72
|
+
pair = @attributes.find do |(key, _)|
|
|
73
|
+
key == name || local_name_of(key) == name
|
|
74
|
+
end
|
|
75
|
+
pair && pair[1]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The facade's listing face: bare names, source order, duplicates
|
|
79
|
+
# included, namespace declarations excluded (they are Namespace
|
|
80
|
+
# nodes, not attributes — uniform with every adapter).
|
|
81
|
+
def attribute_pairs
|
|
82
|
+
@attributes.filter_map do |(key, value)|
|
|
83
|
+
next if key == "xmlns" || key.start_with?("xmlns:")
|
|
84
|
+
|
|
85
|
+
[local_name_of(key), value]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def local_name_of(name)
|
|
90
|
+
return name unless name&.include?(":")
|
|
91
|
+
|
|
92
|
+
name.split(":", 2)[1]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A reference document: declaration and doctype are state (the
|
|
97
|
+
# model's document-parts-are-state rule); children are the
|
|
98
|
+
# top-level nodes excluding them.
|
|
99
|
+
class Document
|
|
100
|
+
attr_accessor :children, :declaration, :doctype, :entities
|
|
101
|
+
|
|
102
|
+
def initialize
|
|
103
|
+
@children = []
|
|
104
|
+
@declaration = nil
|
|
105
|
+
@doctype = nil
|
|
106
|
+
@entities = {}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def root
|
|
110
|
+
@children.find(&:element?)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Depth-first pre-order, excluding the document itself.
|
|
114
|
+
def each_node(&block)
|
|
115
|
+
@children.each { |child| walk(child, &block) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def walk(node, &block)
|
|
119
|
+
yield node
|
|
120
|
+
node.children.each { |child| walk(child, &block) }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Encodings the reference accepts: UTF-8 and US-ASCII sources.
|
|
125
|
+
# Transcoding is the adapter's business; the reference reads the
|
|
126
|
+
# UTF-8 string the facade hands it.
|
|
127
|
+
module_function
|
|
128
|
+
|
|
129
|
+
# Parse an XML string into a reference Document.
|
|
130
|
+
def parse(xml)
|
|
131
|
+
Parser.new(xml).parse
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Document-order element names (the ordering face; bare local
|
|
135
|
+
# names, matching Element#name).
|
|
136
|
+
def element_names(document)
|
|
137
|
+
names = []
|
|
138
|
+
document.each_node { |node| names << node.local_name if node.element? }
|
|
139
|
+
names
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Element children names, keyed by each element's index in
|
|
143
|
+
# pre-order (the children-listing face; avoids text segmentation,
|
|
144
|
+
# which is a registered engine divergence family).
|
|
145
|
+
def child_element_names(document)
|
|
146
|
+
rows = []
|
|
147
|
+
document.each_node do |node|
|
|
148
|
+
next unless node.element?
|
|
149
|
+
|
|
150
|
+
rows << node.children.select(&:element?).map(&:local_name)
|
|
151
|
+
end
|
|
152
|
+
rows
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The access truth table, one row per element in pre-order:
|
|
156
|
+
# [name, text, content].
|
|
157
|
+
def element_access_rows(document)
|
|
158
|
+
rows = []
|
|
159
|
+
document.each_node do |node|
|
|
160
|
+
next unless node.element?
|
|
161
|
+
|
|
162
|
+
rows << [node.local_name, node.text, node.content]
|
|
163
|
+
end
|
|
164
|
+
rows
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# PI targets and data, in document order.
|
|
168
|
+
def processing_instructions(document)
|
|
169
|
+
rows = []
|
|
170
|
+
document.each_node do |node|
|
|
171
|
+
rows << [node.name, node.data] if node.kind == :processing_instruction
|
|
172
|
+
end
|
|
173
|
+
rows
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Comment data, in document order.
|
|
177
|
+
def comments(document)
|
|
178
|
+
rows = []
|
|
179
|
+
document.each_node { |node| rows << node.data if node.kind == :comment }
|
|
180
|
+
rows
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Serialize the reference document as XML (indent: 0 canonical
|
|
184
|
+
# facade shape: declaration if the source had one, escaped text,
|
|
185
|
+
# self-closing empty elements).
|
|
186
|
+
def to_xml(document)
|
|
187
|
+
Writer.new(document).to_xml
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Serialize as HTML (void elements unclosed, raw script/style).
|
|
191
|
+
def to_html(document)
|
|
192
|
+
Writer.new(document).to_html
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Serialize as XHTML (void elements self-closed).
|
|
196
|
+
def to_xhtml(document)
|
|
197
|
+
Writer.new(document).to_xhtml
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# EOL normalization per XML 1.0 §2.11.
|
|
201
|
+
def normalize_eol(text)
|
|
202
|
+
Normalizer.normalize_eol(text)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
# RELAX NG schema validation (TODO.sota/05): a facade for engines
|
|
5
|
+
# that ship an RNG validator. Engines without one raise the named
|
|
6
|
+
# Moxml::NotImplementedError — the face is never silently absent.
|
|
7
|
+
#
|
|
8
|
+
# ctx = Moxml.new(:leptris)
|
|
9
|
+
# schema = ctx.relaxng_file("schema.rng")
|
|
10
|
+
# schema.valid?(doc) # => true/false
|
|
11
|
+
# schema.validate_errors(doc) # => [] or [{line:, column:, message:}]
|
|
12
|
+
# schema.validate(doc) # => [] or ["line:col: error: message"]
|
|
13
|
+
#
|
|
14
|
+
# Adapters without an engine raise at compile; RelaxNG.supported?
|
|
15
|
+
# answers without raising (the XSD facade's pattern).
|
|
16
|
+
module RelaxNG
|
|
17
|
+
class << self
|
|
18
|
+
# Whether the context's adapter ships a RELAX NG validator.
|
|
19
|
+
def supported?(context)
|
|
20
|
+
adapter = context.config.adapter
|
|
21
|
+
adapter.const_defined?(:RELAXNG_SUPPORTED) &&
|
|
22
|
+
adapter.const_get(:RELAXNG_SUPPORTED)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A compiled schema (adapter-owned handle).
|
|
27
|
+
class Schema
|
|
28
|
+
def initialize(handle, context)
|
|
29
|
+
@handle = handle
|
|
30
|
+
@context = context
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def valid?(document)
|
|
34
|
+
@context.config.adapter.relaxng_valid?(@handle, native(document))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Structured report: [{line:, column:, message:}] (empty = valid).
|
|
38
|
+
def validate_errors(document)
|
|
39
|
+
@context.config.adapter
|
|
40
|
+
.relaxng_validate_errors(@handle, native(document))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Nokogiri shape: error strings (empty = valid).
|
|
44
|
+
def validate(document)
|
|
45
|
+
validate_errors(document).map do |error|
|
|
46
|
+
line = error[:line]
|
|
47
|
+
if line&.to_i&.positive?
|
|
48
|
+
"#{line}:#{error[:column]}: error: #{error[:message]}"
|
|
49
|
+
else
|
|
50
|
+
error[:message]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def native(document)
|
|
58
|
+
document.is_a?(Moxml::Document) ? document.native : document
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -191,6 +191,13 @@ module Moxml
|
|
|
191
191
|
def on_warning(message)
|
|
192
192
|
@handlers[:warning]&.call(message)
|
|
193
193
|
end
|
|
194
|
+
|
|
195
|
+
# A block handler subscribes to a family only when its DSL block
|
|
196
|
+
# was registered — it overrides every callback, so method
|
|
197
|
+
# ownership cannot answer interest here.
|
|
198
|
+
def handles?(family)
|
|
199
|
+
@handlers.key?(family)
|
|
200
|
+
end
|
|
194
201
|
end
|
|
195
202
|
end
|
|
196
203
|
end
|
data/lib/moxml/sax/handler.rb
CHANGED
|
@@ -98,6 +98,26 @@ module Moxml
|
|
|
98
98
|
raise error
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
+
# SAX event families that engines may or may not report as
|
|
102
|
+
# distinct events, with their handler methods. An engine that
|
|
103
|
+
# cannot report a family must raise the named error when the
|
|
104
|
+
# handler subscribes — never fold the events silently.
|
|
105
|
+
EVENT_METHODS = {
|
|
106
|
+
cdata: :on_cdata,
|
|
107
|
+
comment: :on_comment,
|
|
108
|
+
processing_instruction: :on_processing_instruction,
|
|
109
|
+
}.freeze
|
|
110
|
+
|
|
111
|
+
# Whether this handler subscribes to an event family: the
|
|
112
|
+
# callback was overridden (class handlers) or registered (block
|
|
113
|
+
# handlers).
|
|
114
|
+
#
|
|
115
|
+
# @param family [Symbol] one of EVENT_METHODS keys
|
|
116
|
+
# @return [Boolean]
|
|
117
|
+
def handles?(family)
|
|
118
|
+
method(EVENT_METHODS.fetch(family)).owner != Moxml::SAX::Handler
|
|
119
|
+
end
|
|
120
|
+
|
|
101
121
|
# Called when a non-fatal warning occurs
|
|
102
122
|
#
|
|
103
123
|
# Default implementation ignores warnings.
|
data/lib/moxml/version.rb
CHANGED
data/lib/moxml/xpath/compiler.rb
CHANGED
|
@@ -30,9 +30,12 @@ module Moxml
|
|
|
30
30
|
# Wildcard for node names/namespace prefixes
|
|
31
31
|
STAR = "*"
|
|
32
32
|
|
|
33
|
-
# Node types that require a NodeSet to push nodes into
|
|
33
|
+
# Node types that require a NodeSet to push nodes into.
|
|
34
|
+
# :union belongs here: a top-level union built its own NodeSet
|
|
35
|
+
# in on_pipe and bypassed the document-order wrapper — union
|
|
36
|
+
# results answered in expression order (M3).
|
|
34
37
|
RETURN_NODESET = %i[path absolute_path relative_path axis
|
|
35
|
-
predicate].freeze
|
|
38
|
+
predicate union].freeze
|
|
36
39
|
|
|
37
40
|
# Compiles and caches an AST
|
|
38
41
|
#
|
|
@@ -94,8 +97,14 @@ module Moxml
|
|
|
94
97
|
[nodeset_class, "new", empty_array,
|
|
95
98
|
context_var])
|
|
96
99
|
|
|
100
|
+
# Multi-step paths batch step N over every step-(N-1)
|
|
101
|
+
# node, concatenating subtree results after sibling
|
|
102
|
+
# results (//* answered a,c,b on <r><a><b/></a><c/></r>);
|
|
103
|
+
# XPath path results are conventionally document-ordered.
|
|
104
|
+
ordered = Ruby::Node.new(:send, [matched, "document_order"])
|
|
97
105
|
body = matched.assign(nodeset_new)
|
|
98
106
|
.followed_by(ruby_ast)
|
|
107
|
+
.followed_by(matched.assign(ordered))
|
|
99
108
|
.followed_by(matched)
|
|
100
109
|
else
|
|
101
110
|
body = ruby_ast
|
|
@@ -293,6 +302,12 @@ module Moxml
|
|
|
293
302
|
end
|
|
294
303
|
end
|
|
295
304
|
end
|
|
305
|
+
|
|
306
|
+
# Multi-step evaluation batches step N over every step-(N-1)
|
|
307
|
+
# node, so subtree results concatenate after sibling
|
|
308
|
+
# results (//* on <r><a><b/></a><c/></r> answered a,c,b).
|
|
309
|
+
# Path results are conventionally document-ordered: re-sort
|
|
310
|
+
# the union when the caller takes the node-set form.
|
|
296
311
|
end
|
|
297
312
|
end
|
|
298
313
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moxml
|
|
4
|
+
module XPath
|
|
5
|
+
# Document ordering for node-set results (TODO.sota/02, M3).
|
|
6
|
+
#
|
|
7
|
+
# XPath defines node-sets as unordered, but path-expression
|
|
8
|
+
# results are conventionally in document order (and consumers
|
|
9
|
+
# rely on it). Multi-step paths are evaluated by batching step N
|
|
10
|
+
# over every step-(N-1) node, which concatenates subtree results
|
|
11
|
+
# after sibling results — results must therefore be re-ordered.
|
|
12
|
+
#
|
|
13
|
+
# Ordering is a ONE-PASS position index per document, keyed by
|
|
14
|
+
# native identity: `traverse` yields nodes in document order, and
|
|
15
|
+
# the engines that re-order here (oga; the pure-Ruby XPath engine
|
|
16
|
+
# lanes) expose stable native objects per node. The ancestry-walk
|
|
17
|
+
# key (`key_for`) is quadratic on wide documents — every node
|
|
18
|
+
# compares its way through its siblings' structural equality —
|
|
19
|
+
# and is kept only as a fallback for natives the index does not
|
|
20
|
+
# know (attribute results, unstable identities).
|
|
21
|
+
module Ordering
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
INDEXED_ELEMENT_RANK = 1
|
|
25
|
+
ATTRIBUTE_RANK = 0
|
|
26
|
+
FALLBACK_RANK = 2
|
|
27
|
+
|
|
28
|
+
# Below this result size the ancestry walk is cheaper than one
|
|
29
|
+
# document traverse (the index only pays off on wide results).
|
|
30
|
+
INDEX_THRESHOLD = 16
|
|
31
|
+
|
|
32
|
+
# Sorts wrappers into document order. Attributes sort before
|
|
33
|
+
# the children of their element (XPath attribute-then-child
|
|
34
|
+
# ordering). Nodes the index cannot place keep their relative
|
|
35
|
+
# position at the end (stable sort).
|
|
36
|
+
def document_order(nodes)
|
|
37
|
+
return nodes if nodes.size < 2
|
|
38
|
+
|
|
39
|
+
indexed = if nodes.size < INDEX_THRESHOLD
|
|
40
|
+
nodes.each_with_index.map do |node, i|
|
|
41
|
+
[fallback_key(node), i, node]
|
|
42
|
+
end
|
|
43
|
+
else
|
|
44
|
+
positions = position_index(nodes.first)
|
|
45
|
+
nodes.each_with_index.map do |node, i|
|
|
46
|
+
[positions.fetch(node.native) { fallback_key(node) },
|
|
47
|
+
i, node]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
indexed.sort_by { |key, i, _| [key, i] }.map(&:last)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# native => [rank, position] for every node in document order.
|
|
54
|
+
# compare_by_identity: the natives are the engine's own objects,
|
|
55
|
+
# and native equality may be structural (Oga compares subtrees).
|
|
56
|
+
def position_index(node)
|
|
57
|
+
doc = node
|
|
58
|
+
doc = doc.parent while doc && !doc.is_a?(Moxml::Document)
|
|
59
|
+
return {}.compare_by_identity unless doc
|
|
60
|
+
|
|
61
|
+
index = {}.compare_by_identity
|
|
62
|
+
position = 0
|
|
63
|
+
doc.traverse do |n|
|
|
64
|
+
index[n.native] = [INDEXED_ELEMENT_RANK, position]
|
|
65
|
+
position += 1
|
|
66
|
+
end
|
|
67
|
+
index
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Documents with unstable native wrappers (a fresh object per
|
|
71
|
+
# access) fall back to the ancestry path: correct, and only the
|
|
72
|
+
# answer for lanes whose engine already orders natively.
|
|
73
|
+
def fallback_key(node)
|
|
74
|
+
kind_rank = node.is_a?(Moxml::Attribute) ? ATTRIBUTE_RANK : FALLBACK_RANK
|
|
75
|
+
[kind_rank, ancestry_path(node)]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def ancestry_path(node)
|
|
79
|
+
path = []
|
|
80
|
+
current = node
|
|
81
|
+
while current && !current.is_a?(Moxml::Document)
|
|
82
|
+
parent = current.parent
|
|
83
|
+
break unless parent
|
|
84
|
+
|
|
85
|
+
siblings = parent.children.to_a
|
|
86
|
+
idx = siblings.index { |s| s.equal?(current) } ||
|
|
87
|
+
siblings.index(current)
|
|
88
|
+
return [2_000_000] if idx.nil?
|
|
89
|
+
|
|
90
|
+
path.unshift(idx)
|
|
91
|
+
current = parent
|
|
92
|
+
end
|
|
93
|
+
path
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/moxml/xpath.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Moxml
|
|
|
9
9
|
autoload :Lexer, "moxml/xpath/lexer"
|
|
10
10
|
autoload :Parser, "moxml/xpath/parser"
|
|
11
11
|
autoload :Compiler, "moxml/xpath/compiler"
|
|
12
|
+
autoload :Ordering, "moxml/xpath/ordering"
|
|
12
13
|
|
|
13
14
|
autoload :Error, "moxml/xpath/errors"
|
|
14
15
|
autoload :SyntaxError, "moxml/xpath/errors"
|
|
@@ -25,5 +26,33 @@ module Moxml
|
|
|
25
26
|
autoload :Node, "moxml/xpath/ruby/node"
|
|
26
27
|
autoload :Generator, "moxml/xpath/ruby/generator"
|
|
27
28
|
end
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Evaluate an expression through the pure-Ruby XPath 1.0 engine —
|
|
33
|
+
# the model's reference lane. Adapters route shapes their native
|
|
34
|
+
# engine cannot answer here (the `xmlns:` idiom, `text()` node
|
|
35
|
+
# tests, ...). Returns the engine's answer: an Array of wrapped
|
|
36
|
+
# nodes in document order, a NodeSet, or a scalar.
|
|
37
|
+
def evaluate(node, expression, namespaces = {}, adapter: nil)
|
|
38
|
+
context = node.is_a?(Moxml::Node) ? node.context : ::Moxml::Context.new(adapter)
|
|
39
|
+
wrapped = node.is_a?(Moxml::Node) ? node : Moxml::Node.wrap(node, context)
|
|
40
|
+
compiled = Compiler.compile_with_cache(Parser.parse(expression),
|
|
41
|
+
namespaces: namespaces)
|
|
42
|
+
compiled.call(wrapped)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The adapter-contract form of #evaluate: Array<native> | scalar.
|
|
46
|
+
def evaluate_natives(node, expression, namespaces = {}, adapter: nil)
|
|
47
|
+
result = evaluate(node, expression, namespaces, adapter: adapter)
|
|
48
|
+
case result
|
|
49
|
+
when Array
|
|
50
|
+
result.map { |n| n.is_a?(Moxml::Node) ? n.native : n }
|
|
51
|
+
when NodeSet
|
|
52
|
+
result.to_a.map(&:native)
|
|
53
|
+
else
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
end
|
|
28
57
|
end
|
|
29
58
|
end
|
data/lib/moxml.rb
CHANGED
|
@@ -102,8 +102,10 @@ module Moxml
|
|
|
102
102
|
autoload :SAX, "moxml/sax"
|
|
103
103
|
autoload :Signature, "moxml/signature"
|
|
104
104
|
autoload :C14n, "moxml/c14n"
|
|
105
|
+
autoload :Reference, "moxml/reference"
|
|
105
106
|
autoload :XSLT, "moxml/xslt"
|
|
106
107
|
autoload :XSD, "moxml/xsd"
|
|
108
|
+
autoload :RelaxNG, "moxml/relaxng"
|
|
107
109
|
|
|
108
110
|
# Error hierarchy — each subclass autoloads from the same file
|
|
109
111
|
autoload :Error, "moxml/error"
|
|
@@ -1,8 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# Processing model §Document order and traversal (normative): pinned here.
|
|
4
|
+
|
|
3
5
|
RSpec.describe "Adapter Examples" do
|
|
4
6
|
let(:xml) { "<root><child>text</child></root>" }
|
|
5
7
|
|
|
8
|
+
describe "Document children listing" do
|
|
9
|
+
it "never lists the XML declaration as a child" do
|
|
10
|
+
Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter|
|
|
11
|
+
doc = Moxml.new(adapter)
|
|
12
|
+
.parse(%(<?xml version="1.0"?><r/>))
|
|
13
|
+
expect(doc.children.map(&:node_type)).to eq([:element]),
|
|
14
|
+
"#{adapter}: declaration leaked " \
|
|
15
|
+
"into children (model: state, not a node)"
|
|
16
|
+
expect(doc.has_xml_declaration).to be(true),
|
|
17
|
+
"#{adapter}: declaration state lost"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "lists the doctype per the Nokogiri contract" do
|
|
22
|
+
Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter|
|
|
23
|
+
doc = Moxml.new(adapter).parse(%(<!DOCTYPE r><r/>))
|
|
24
|
+
expect(doc.children.map(&:node_type)).to eq(%i[doctype element]),
|
|
25
|
+
"#{adapter}: doctype listing"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
6
30
|
describe "Serialization consistency" do
|
|
7
31
|
it "produces equivalent XML across adapters",
|
|
8
32
|
skip: "No easy way to exclude the declaration from Nokogiri documents" do
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# Citation hygiene (TODO.sota/07): a spec or doc that calls a
|
|
6
|
+
# divergence "pre-existing" or "documented" must resolve it to a
|
|
7
|
+
# register ID (D-NN) on the same line. The register is the only place
|
|
8
|
+
# such a claim is allowed to stand on its own.
|
|
9
|
+
CITATION_PHRASE = /(?:\bpre-existing\b|\bpreexisting\b|\bdocumented (?:divergence|limitation)\b)/i
|
|
10
|
+
|
|
11
|
+
RSpec.describe "divergence citations" do
|
|
12
|
+
def offenses(paths)
|
|
13
|
+
paths.flat_map do |path|
|
|
14
|
+
File.readlines(path).each_with_index.filter_map do |line, idx|
|
|
15
|
+
claim = line.match?(CITATION_PHRASE)
|
|
16
|
+
cited = line.match?(/D-\d+/)
|
|
17
|
+
"#{path}:#{idx + 1}: #{line.strip}" if claim && !cited
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "resolves every pre-existing/documented divergence claim to a register ID" do
|
|
23
|
+
paths = (Dir.glob("spec/**/*.rb") + Dir.glob("docs/**/*.adoc"))
|
|
24
|
+
.reject { |path| path.end_with?("citation_hygiene_spec.rb") }
|
|
25
|
+
found = offenses(paths)
|
|
26
|
+
expect(found).to be_empty,
|
|
27
|
+
"name a D-NN (docs/_pages/divergences.adoc) or drop " \
|
|
28
|
+
"the claim:\n#{found.join("\n")}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "support/input_taxonomy"
|
|
5
|
+
|
|
6
|
+
# Whole-class differential (TODO.sota/01): for every taxonomy input,
|
|
7
|
+
# every available adapter answers the same observable results — or the
|
|
8
|
+
# difference cites a registered divergence (D-NN). Faces: node count,
|
|
9
|
+
# root name, text, element count via XPath. Plus M1 (parse∘serialize
|
|
10
|
+
# round-trip) per adapter over every well-formed input. Nothing is
|
|
11
|
+
# exempt without a D-ID: this is the generated-evidence face of the
|
|
12
|
+
# conformance relation.
|
|
13
|
+
|
|
14
|
+
RSpec.describe "cross-adapter taxonomy differential" do
|
|
15
|
+
def snapshot(doc)
|
|
16
|
+
{
|
|
17
|
+
nodes: doc.traverse.to_a.grep_v(Moxml::Document).count,
|
|
18
|
+
root: doc.root&.name,
|
|
19
|
+
text: doc.text,
|
|
20
|
+
xpath: doc.xpath("//*").to_a.count,
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def answers(xml)
|
|
25
|
+
Moxml::Adapter::AVAILABLE_ADAPTERS.filter_map do |name|
|
|
26
|
+
doc = Moxml.new(name).parse(xml)
|
|
27
|
+
[name, snapshot(doc)]
|
|
28
|
+
rescue Moxml::Error, NotImplementedError, LoadError
|
|
29
|
+
nil # adapter unavailable in this environment
|
|
30
|
+
end.to_h
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
InputTaxonomy.well_formed.each do |input|
|
|
34
|
+
it "answers #{input.id} identically across adapters" do
|
|
35
|
+
per = answers(input.xml)
|
|
36
|
+
expect(per.keys.count).to be >= 2
|
|
37
|
+
|
|
38
|
+
base_name = per.keys.first
|
|
39
|
+
base = per[base_name]
|
|
40
|
+
per.each do |name, got|
|
|
41
|
+
base.except(:text).each_key do |face|
|
|
42
|
+
exempt = InputTaxonomy::EXEMPT.dig(input.id, face, name)
|
|
43
|
+
next if exempt
|
|
44
|
+
|
|
45
|
+
expect(got[face]).to eq(base[face]),
|
|
46
|
+
"#{input.id}/#{face} differs on #{name} vs " \
|
|
47
|
+
"#{base_name}: #{got[face].inspect} vs " \
|
|
48
|
+
"#{base[face].inspect} — fix it or register " \
|
|
49
|
+
"a divergence and exempt the cell"
|
|
50
|
+
end
|
|
51
|
+
text_exempt = InputTaxonomy::EXEMPT.dig(input.id, :text, name)
|
|
52
|
+
next if text_exempt
|
|
53
|
+
|
|
54
|
+
expect(got[:text]).to eq(base[:text]),
|
|
55
|
+
"#{input.id}/text differs on #{name} vs " \
|
|
56
|
+
"#{base_name}: #{got[:text].inspect} vs " \
|
|
57
|
+
"#{base[:text].inspect} — fix it or register " \
|
|
58
|
+
"a divergence and exempt the cell"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "keeps the round trip stable per adapter (M1: parse∘serialize)" do # rubocop:disable RSpec/NoExpectationExample
|
|
64
|
+
InputTaxonomy.well_formed.each do |input|
|
|
65
|
+
Moxml::Adapter::AVAILABLE_ADAPTERS.each do |name|
|
|
66
|
+
begin
|
|
67
|
+
ctx = Moxml.new(name)
|
|
68
|
+
doc = ctx.parse(input.xml)
|
|
69
|
+
first = snapshot(doc)
|
|
70
|
+
# indent: 0 — default indentation synthesizes whitespace text
|
|
71
|
+
# nodes on re-parse (formatting, not information; D-09
|
|
72
|
+
# adapters ignore the knob and may embed their own pretty
|
|
73
|
+
# newlines regardless). M1 therefore compares the information
|
|
74
|
+
# set: root, element count, whitespace-collapsed text. Node
|
|
75
|
+
# COUNT identity is the cross-adapter face's job (same parse),
|
|
76
|
+
# not the round-trip's.
|
|
77
|
+
second = snapshot(ctx.parse(doc.to_xml(indent: 0)))
|
|
78
|
+
rescue Moxml::Error, NotImplementedError, LoadError
|
|
79
|
+
# Named guards (e.g. D-16's read cap on rexml) legitimately
|
|
80
|
+
# excuse an adapter from M1 for an input.
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
compact = ->(snap) {
|
|
84
|
+
snap.except(:nodes).merge(text: snap[:text].gsub(/\s+/, ""))
|
|
85
|
+
}
|
|
86
|
+
expect(compact.call(second)).to eq(compact.call(first)),
|
|
87
|
+
"#{input.id} round-trip drifted on " \
|
|
88
|
+
"#{name}: #{first.inspect} vs " \
|
|
89
|
+
"#{second.inspect}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "raises the named parse error on strict malformed input, everywhere" do
|
|
95
|
+
# Entity/reference and namespace-validation classes are
|
|
96
|
+
# engine-variant by design — moxml governs them with config
|
|
97
|
+
# (entity modes, namespace validation), not with a registered
|
|
98
|
+
# divergence.
|
|
99
|
+
engine_variant = %w[undefined reference undeclared prefix].freeze
|
|
100
|
+
inputs = InputTaxonomy.malformed.reject do |input|
|
|
101
|
+
engine_variant.any? { |term| input.id.include?(term) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
inputs.each do |input|
|
|
105
|
+
Moxml::Adapter::AVAILABLE_ADAPTERS.each do |name|
|
|
106
|
+
next if InputTaxonomy::STRICT_EXEMPT.dig(input.id, name)
|
|
107
|
+
|
|
108
|
+
# The strict lane, named: the model's default posture for
|
|
109
|
+
# malformed input is a named raise on every adapter.
|
|
110
|
+
expect { Moxml.new(name).parse(input.xml, strict: true) }
|
|
111
|
+
.to raise_error(Moxml::ParseError),
|
|
112
|
+
"#{input.id}/#{name}: strict parse accepted malformed " \
|
|
113
|
+
"input or raised outside the named ParseError contract"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|