moxml 0.5.148 → 0.5.149

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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/nightly-fuzz.yml +80 -0
  3. data/docs/_pages/divergences.adoc +58 -0
  4. data/docs/_references/processing-model.adoc +30 -3
  5. data/lib/compat/opal/moxml_boot.rb +4 -0
  6. data/lib/moxml/adapter/leptris.rb +110 -19
  7. data/lib/moxml/adapter/libxml.rb +145 -23
  8. data/lib/moxml/adapter/oga.rb +33 -3
  9. data/lib/moxml/adapter/ox.rb +26 -2
  10. data/lib/moxml/adapter/rexml.rb +65 -15
  11. data/lib/moxml/context.rb +5 -1
  12. data/lib/moxml/document.rb +6 -6
  13. data/lib/moxml/node.rb +22 -6
  14. data/lib/moxml/node_set.rb +11 -0
  15. data/lib/moxml/reference/normalizer.rb +63 -0
  16. data/lib/moxml/reference/parser.rb +417 -0
  17. data/lib/moxml/reference/writer.rb +121 -0
  18. data/lib/moxml/reference.rb +205 -0
  19. data/lib/moxml/version.rb +1 -1
  20. data/lib/moxml/xpath/compiler.rb +17 -2
  21. data/lib/moxml/xpath/ordering.rb +97 -0
  22. data/lib/moxml/xpath.rb +1 -0
  23. data/lib/moxml.rb +1 -0
  24. data/spec/consistency/adapter_parity_spec.rb +2 -0
  25. data/spec/consistency/differential_taxonomy_spec.rb +117 -0
  26. data/spec/consistency/divergence_register_spec.rb +9 -1
  27. data/spec/consistency/eol_normalization_spec.rb +2 -0
  28. data/spec/consistency/loud_degradation_spec.rb +2 -0
  29. data/spec/consistency/metamorphic_properties_spec.rb +195 -0
  30. data/spec/consistency/metamorphic_spec.rb +138 -0
  31. data/spec/consistency/model_coverage_audit_spec.rb +108 -0
  32. data/spec/consistency/reference_differential_spec.rb +186 -0
  33. data/spec/consistency/xpath_idioms_spec.rb +11 -0
  34. data/spec/consistency/xslt_spec.rb +2 -0
  35. data/spec/moxml/adapter/leptris_attribute_snapshot_spec.rb +10 -7
  36. data/spec/moxml/adapter/ox_spec.rb +1 -1
  37. data/spec/moxml/attribute_resolver_spec.rb +2 -0
  38. data/spec/moxml/c14n/adapter_delegation_spec.rb +2 -0
  39. data/spec/moxml/doctype_spec.rb +7 -0
  40. data/spec/moxml/document_parse_diagnostics_spec.rb +2 -0
  41. data/spec/moxml/entity_spec.rb +2 -0
  42. data/spec/moxml/plan_conformance_spec.rb +2 -0
  43. data/spec/moxml/processing_instruction_spec.rb +9 -0
  44. data/spec/moxml/reference_opal_spec.rb +29 -0
  45. data/spec/moxml/reference_spec.rb +162 -0
  46. data/spec/moxml/sax_spec.rb +2 -0
  47. data/spec/moxml/text_spec.rb +2 -0
  48. data/spec/moxml/to_html_spec.rb +2 -0
  49. data/spec/moxml/xhtml_output_spec.rb +2 -0
  50. data/spec/moxml/xpath/axes_spec.rb +2 -2
  51. data/spec/moxml/xsd_spec.rb +38 -0
  52. data/spec/support/input_generator.rb +154 -0
  53. data/spec/support/input_taxonomy.rb +212 -0
  54. data/spec/support/xml_generator.rb +54 -0
  55. metadata +16 -1
@@ -0,0 +1,417 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Reference
5
+ # A small, total XML reader for the model's input taxonomy. No
6
+ # recovery: malformed input raises a named ParseError. The only
7
+ # dependencies are Ruby's String and Regexp, so the reference
8
+ # transpiles to the Opal lane.
9
+ class Parser
10
+ NAME = /[A-Za-z_:][-A-Za-z0-9_:.]*/
11
+ XML_WHITESPACE = /[ \t\n\r]/
12
+ ATTRIBUTE_LITERAL_WHITESPACE = /[\t\n\r]/
13
+ BUILTIN_ENTITIES = {
14
+ "amp" => "&", "lt" => "<", "gt" => ">",
15
+ "quot" => '"', "apos" => "'"
16
+ }.freeze
17
+ BOM = "\xEF\xBB\xBF"
18
+ QUOTES = ['"', "'"].freeze
19
+ DECLARATION_OPEN_SIZE = 5
20
+ DOCTYPE_OPEN_SIZE = 9
21
+ COMMENT_OPEN_SIZE = 4
22
+ COMMENT_CLOSE_SIZE = 3
23
+ CDATA_OPEN_SIZE = 9
24
+ CDATA_CLOSE_SIZE = 3
25
+ PI_OPEN_SIZE = 2
26
+ PI_CLOSE_SIZE = 2
27
+
28
+ def initialize(xml)
29
+ @source = ""
30
+ @pos = 0
31
+ @source = prepare_source(xml)
32
+ @length = @source.length
33
+ @entities = BUILTIN_ENTITIES.dup
34
+ end
35
+
36
+ def parse
37
+ document = Document.new
38
+ parse_prolog(document)
39
+ raise error("no root element") if at_end?
40
+
41
+ document.children << parse_element(nil)
42
+ parse_epilog(document)
43
+ document.entities = @entities
44
+ document
45
+ end
46
+
47
+ private
48
+
49
+ def prepare_source(xml)
50
+ source = xml.to_s
51
+ source = source.encode(Encoding::UTF_8) unless source.encoding == Encoding::UTF_8
52
+ raise error("source is not valid UTF-8") unless source.valid_encoding?
53
+
54
+ source = source.delete_prefix(BOM)
55
+ # XML 1.0 §2.11: literal CRLF and CR normalize to LF. Character
56
+ # references are expanded later and keep their value.
57
+ source.gsub("\r\n", "\n").gsub("\r", "\n")
58
+ rescue EncodingError => e
59
+ raise error("source cannot be read as UTF-8: #{e.message}")
60
+ end
61
+
62
+ def parse_prolog(document)
63
+ loop do
64
+ skip_whitespace
65
+ if declaration_start?
66
+ if document.children.empty? && document.declaration.nil?
67
+ document.declaration = parse_declaration
68
+ else
69
+ raise error("XML declaration is not first")
70
+ end
71
+ elsif start_with?("<!--")
72
+ document.children << parse_comment
73
+ elsif start_with?("<?")
74
+ document.children << parse_processing_instruction
75
+ elsif start_with?("<!DOCTYPE")
76
+ document.doctype = parse_doctype
77
+ else
78
+ return
79
+ end
80
+ end
81
+ end
82
+
83
+ def parse_epilog(document)
84
+ loop do
85
+ skip_whitespace
86
+ return if at_end?
87
+
88
+ if start_with?("<!--")
89
+ document.children << parse_comment
90
+ elsif start_with?("<?")
91
+ document.children << parse_processing_instruction
92
+ else
93
+ raise error("content after the root element")
94
+ end
95
+ end
96
+ end
97
+
98
+ def declaration_start?
99
+ return false unless start_with?("<?xml")
100
+
101
+ following = @source[@pos + 5]
102
+ following.nil? || following == "?" || XML_WHITESPACE.match?(following)
103
+ end
104
+
105
+ def parse_declaration
106
+ start = @pos
107
+ @pos += DECLARATION_OPEN_SIZE
108
+ attributes = parse_attributes_until("?>").to_h
109
+ {
110
+ version: attributes["version"],
111
+ encoding: attributes["encoding"],
112
+ standalone: attributes["standalone"],
113
+ raw: @source[start...@pos],
114
+ }
115
+ end
116
+
117
+ def parse_doctype
118
+ start = @pos
119
+ @pos += DOCTYPE_OPEN_SIZE
120
+ depth = 0
121
+ quote = nil
122
+ until at_end?
123
+ char = @source[@pos]
124
+ if quote
125
+ quote = nil if char == quote
126
+ elsif QUOTES.include?(char)
127
+ quote = char
128
+ elsif char == "["
129
+ depth += 1
130
+ elsif char == "]"
131
+ depth -= 1
132
+ elsif char == ">" && depth.zero?
133
+ @pos += 1
134
+ raw = @source[start...@pos]
135
+ collect_entities(raw)
136
+ return raw
137
+ end
138
+ @pos += 1
139
+ end
140
+ raise error("unterminated DOCTYPE")
141
+ end
142
+
143
+ def collect_entities(raw)
144
+ raw.scan(/<!ENTITY\s+([^\s%][^\s]*)\s+(?:"([^"]*)"|'([^']*)')/) do |name, dq, sq|
145
+ @entities[name] = dq || sq
146
+ end
147
+ end
148
+
149
+ def parse_element(parent, scope = {})
150
+ expect("<")
151
+ name = scan_name || raise(error("element name expected"))
152
+ attributes = parse_attributes_until(">", allow_empty_close: true)
153
+ node = Node.new(kind: :element, name: name, attributes: attributes)
154
+ node.parent = parent
155
+
156
+ element_scope = extend_scope(scope, attributes)
157
+ validate_prefixes(name, attributes, element_scope)
158
+
159
+ # parse_attributes_until consumed either ">" or "/>".
160
+ return node if @pos >= 2 && @source[@pos - 2, 2] == "/>"
161
+
162
+ parse_element_content(node, element_scope)
163
+ node
164
+ end
165
+
166
+ # Namespace bindings are parser state: xmlns declarations extend
167
+ # the in-scope table for the element and its descendants.
168
+ def extend_scope(scope, attributes)
169
+ extended = nil
170
+ attributes.each do |(name, value)|
171
+ if name == "xmlns"
172
+ (extended ||= scope.dup)[""] = value
173
+ elsif name.start_with?("xmlns:")
174
+ (extended ||= scope.dup)[name.delete_prefix("xmlns:")] = value
175
+ end
176
+ end
177
+ extended || scope
178
+ end
179
+
180
+ def validate_prefixes(name, attributes, scope)
181
+ check_prefix(name, scope, "element")
182
+ attributes.each do |(attribute_name, _)|
183
+ next if attribute_name == "xmlns" ||
184
+ attribute_name.start_with?("xmlns:")
185
+
186
+ check_prefix(attribute_name, scope, "attribute")
187
+ end
188
+ end
189
+
190
+ def check_prefix(name, scope, kind)
191
+ return unless name.include?(":")
192
+
193
+ prefix = name.split(":", 2).first
194
+ return if prefix == "xml"
195
+
196
+ return if scope.key?(prefix)
197
+
198
+ raise error("undeclared namespace prefix #{prefix.inspect} on #{kind} #{name}")
199
+ end
200
+
201
+ def parse_element_content(node, scope)
202
+ loop do
203
+ raise error("unclosed element <#{node.name}>") if at_end?
204
+
205
+ if start_with?("</")
206
+ @pos += 2
207
+ close_name = scan_name
208
+ skip_whitespace
209
+ expect(">")
210
+ unless close_name == node.name
211
+ raise error("mismatched closing tag </#{close_name}> for <#{node.name}>")
212
+ end
213
+
214
+ return
215
+
216
+ elsif start_with?("<!--")
217
+ node.children << parse_comment(node)
218
+ elsif start_with?("<![CDATA[")
219
+ node.children << parse_cdata(node)
220
+ elsif start_with?("<?")
221
+ node.children << parse_processing_instruction(node)
222
+ elsif start_with?("<")
223
+ node.children << parse_element(node, scope)
224
+ else
225
+ text = parse_text(node)
226
+ node.children << text if text
227
+ end
228
+ end
229
+ end
230
+
231
+ def parse_text(parent = nil)
232
+ stop = @source.index("<", @pos)
233
+ stop ||= @length
234
+ raw = @source[@pos...stop]
235
+ @pos = stop
236
+ return if raw.empty?
237
+
238
+ node = Node.new(kind: :text, data: expand_entities(raw))
239
+ node.parent = parent
240
+ node
241
+ end
242
+
243
+ def parse_comment(parent = nil)
244
+ @pos += COMMENT_OPEN_SIZE
245
+ stop = @source.index("-->", @pos)
246
+ raise error("unterminated comment") unless stop
247
+
248
+ data = @source[@pos...stop]
249
+ @pos = stop + COMMENT_CLOSE_SIZE
250
+ node = Node.new(kind: :comment, data: data)
251
+ node.parent = parent
252
+ node
253
+ end
254
+
255
+ def parse_cdata(parent = nil)
256
+ @pos += CDATA_OPEN_SIZE
257
+ stop = @source.index("]]>", @pos)
258
+ raise error("unterminated CDATA section") unless stop
259
+
260
+ data = @source[@pos...stop]
261
+ @pos = stop + CDATA_CLOSE_SIZE
262
+ node = Node.new(kind: :cdata, data: data)
263
+ node.parent = parent
264
+ node
265
+ end
266
+
267
+ def parse_processing_instruction(parent = nil)
268
+ @pos += PI_OPEN_SIZE
269
+ target = scan_name || raise(error("PI target expected"))
270
+ stop = @source.index("?>", @pos)
271
+ raise error("unterminated processing instruction") unless stop
272
+
273
+ data = @source[@pos...stop]
274
+ @pos = stop + PI_CLOSE_SIZE
275
+ # The separating whitespace after the target is parser syntax,
276
+ # not data (model, Access semantics).
277
+ data = data.sub(/\A[ \t\n\r]+/, "")
278
+ node = Node.new(kind: :processing_instruction, name: target, data: data)
279
+ node.parent = parent
280
+ node
281
+ end
282
+
283
+ # Parses attributes until the terminator. With allow_empty_close,
284
+ # a "/>" terminator is consumed and leaves the position after
285
+ # it; otherwise the exact terminator is expected.
286
+ def parse_attributes_until(terminator, allow_empty_close: false)
287
+ attributes = []
288
+ seen = {}
289
+ loop do
290
+ skip_whitespace
291
+ if allow_empty_close && start_with?("/>")
292
+ @pos += 2
293
+ return attributes
294
+ end
295
+ if start_with?(terminator)
296
+ @pos += terminator.length
297
+ return attributes
298
+ end
299
+ raise error("attribute or #{terminator} expected") if at_end?
300
+
301
+ name = scan_name || raise(error("attribute name expected"))
302
+ skip_whitespace
303
+ expect("=")
304
+ skip_whitespace
305
+ value = scan_attribute_value
306
+ raise error("duplicate attribute #{name}") if seen[name]
307
+
308
+ seen[name] = true
309
+ attributes << [name, value]
310
+ end
311
+ end
312
+
313
+ def scan_attribute_value
314
+ quote = @source[@pos]
315
+ raise error("quoted attribute value expected") unless QUOTES.include?(quote)
316
+
317
+ @pos += 1
318
+ stop = @source.index(quote, @pos)
319
+ raise error("unterminated attribute value") unless stop
320
+
321
+ raw = @source[@pos...stop]
322
+ @pos = stop + 1
323
+ expand_entities(raw.gsub(ATTRIBUTE_LITERAL_WHITESPACE, " "))
324
+ end
325
+
326
+ def expand_entities(raw)
327
+ return raw unless raw.include?("&")
328
+
329
+ parts = []
330
+ index = 0
331
+ while (amp = raw.index("&", index))
332
+ parts << raw[index...amp]
333
+ semi = raw.index(";", amp)
334
+ raise error("unterminated entity reference") unless semi
335
+
336
+ reference = raw[(amp + 1)...semi]
337
+ raise error("empty entity reference") if reference.empty?
338
+
339
+ parts << resolve_entity(reference)
340
+ index = semi + 1
341
+ end
342
+ parts << raw[index..]
343
+ parts.join
344
+ end
345
+
346
+ def resolve_entity(reference)
347
+ if reference.start_with?("#x")
348
+ codepoint(reference[2..], 16)
349
+ elsif reference.start_with?("#")
350
+ codepoint(reference[1..], 10)
351
+ elsif BUILTIN_ENTITIES.key?(reference)
352
+ BUILTIN_ENTITIES[reference]
353
+ else
354
+ # DTD-declared general entities are preserved lexically in
355
+ # the facade's default lane (every adapter answers the
356
+ # reference text). An undeclared reference is malformed.
357
+ raise error("undefined entity &#{reference};") unless @entities.key?(reference)
358
+
359
+ "&#{reference};"
360
+ end
361
+ end
362
+
363
+ def codepoint(digits, base)
364
+ unless digits.match?(base == 16 ? /\A[0-9A-Fa-f]+\z/ : /\A[0-9]+\z/)
365
+ raise error("malformed character reference")
366
+ end
367
+
368
+ code = digits.to_i(base)
369
+ unless valid_xml_char?(code)
370
+ raise error("character reference out of XML range: #{code}")
371
+ end
372
+
373
+ [code].pack("U")
374
+ end
375
+
376
+ def valid_xml_char?(code)
377
+ code == 0x9 || code == 0xA || code == 0xD ||
378
+ code.between?(0x20, 0xD7FF) ||
379
+ code.between?(0xE000, 0xFFFD) ||
380
+ code.between?(0x10000, 0x10FFFF)
381
+ end
382
+
383
+ def scan_name
384
+ match = NAME.match(@source, @pos)
385
+ return unless match && match.begin(0) == @pos
386
+
387
+ @pos = match.end(0)
388
+ match[0]
389
+ end
390
+
391
+ def skip_whitespace
392
+ @pos += 1 while !at_end? && XML_WHITESPACE.match?(@source[@pos])
393
+ end
394
+
395
+ def expect(token)
396
+ return @pos += token.length if start_with?(token)
397
+
398
+ raise error("expected #{token.inspect}")
399
+ end
400
+
401
+ def start_with?(token)
402
+ @source[@pos, token.length] == token
403
+ end
404
+
405
+ def at_end?
406
+ @pos >= @length
407
+ end
408
+
409
+ def error(message)
410
+ prefix = @source[0...@pos] || ""
411
+ line = prefix.count("\n") + 1
412
+ column = @pos - (prefix.rindex("\n") || -1)
413
+ ParseError.new(message, line: line, column: column)
414
+ end
415
+ end
416
+ end
417
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Reference
5
+ # Pure-Ruby serializers for the model's three shapes. The XML
6
+ # writer is the reference for the facade's canonical output at
7
+ # indent: 0; the HTML/XHTML writers pin the shape contracts (void
8
+ # elements unclosed vs self-closed, raw text nodes, no XML
9
+ # declaration).
10
+ #
11
+ # Output is assembled as an array of parts and joined: the
12
+ # reference must run under Opal, where mutable String#<< is
13
+ # unsupported.
14
+ class Writer
15
+ VOID_ELEMENTS = %w[
16
+ area base br col embed hr img input link meta param source
17
+ track wbr
18
+ ].freeze
19
+ RAW_TEXT_ELEMENTS = %w[script style].freeze
20
+
21
+ TEXT_ESCAPES = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;" }.freeze
22
+ ATTRIBUTE_ESCAPES = TEXT_ESCAPES.merge('"' => "&quot;").freeze
23
+ TEXT_ESCAPE_RE = /[&<>]/
24
+ ATTRIBUTE_ESCAPE_RE = /[&<>"]/
25
+ CDATA_END = "]]>"
26
+ CDATA_SPLIT = "]]]]><![CDATA[>"
27
+
28
+ def initialize(document)
29
+ @document = document
30
+ end
31
+
32
+ def to_xml
33
+ emit(:xml)
34
+ end
35
+
36
+ def to_html
37
+ emit(:html)
38
+ end
39
+
40
+ def to_xhtml
41
+ emit(:xhtml)
42
+ end
43
+
44
+ private
45
+
46
+ def emit(shape)
47
+ parts = []
48
+ parts << declaration if shape == :xml && @document.declaration
49
+ @document.children.each { |node| write(node, parts, shape) }
50
+ parts.join
51
+ end
52
+
53
+ def declaration
54
+ source = @document.declaration
55
+ attributes = []
56
+ attributes << %(version="#{escape_attribute(source[:version])}") if source[:version]
57
+ attributes << %(encoding="#{escape_attribute(source[:encoding])}") if source[:encoding]
58
+ attributes << %(standalone="#{escape_attribute(source[:standalone])}") if source[:standalone]
59
+ "<?xml #{attributes.join(' ')}?>"
60
+ end
61
+
62
+ def write(node, parts, shape)
63
+ case node.kind
64
+ when :element then write_element(node, parts, shape)
65
+ when :text then write_text(node, parts, shape)
66
+ when :comment then parts << "<!--#{node.data}-->"
67
+ when :cdata then parts << cdata(node.data)
68
+ when :processing_instruction then write_pi(node, parts)
69
+ end
70
+ end
71
+
72
+ def write_element(node, parts, shape)
73
+ parts << "<" << node.name
74
+ node.attributes.each do |(name, value)|
75
+ parts << " " << name << '="' << escape_attribute(value) << '"'
76
+ end
77
+
78
+ if shape == :html && VOID_ELEMENTS.include?(node.name)
79
+ parts << ">"
80
+ elsif shape == :xhtml && VOID_ELEMENTS.include?(node.name)
81
+ parts << "/>"
82
+ elsif node.children.empty?
83
+ if shape == :xml
84
+ parts << "/>"
85
+ else
86
+ parts << "></" << node.name << ">"
87
+ end
88
+ else
89
+ parts << ">"
90
+ node.children.each { |child| write(child, parts, shape) }
91
+ parts << "</" << node.name << ">"
92
+ end
93
+ end
94
+
95
+ def write_text(node, parts, shape)
96
+ parent = node.parent
97
+ raw = shape != :xhtml && parent &&
98
+ RAW_TEXT_ELEMENTS.include?(parent.name)
99
+ parts << (raw ? node.data : escape_text(node.data))
100
+ end
101
+
102
+ def write_pi(node, parts)
103
+ parts << "<?" << node.name
104
+ parts << " " << node.data unless node.data.nil? || node.data.empty?
105
+ parts << "?>"
106
+ end
107
+
108
+ def cdata(data)
109
+ "<![CDATA[#{data.gsub(CDATA_END, CDATA_SPLIT)}]]>"
110
+ end
111
+
112
+ def escape_text(text)
113
+ text.to_s.gsub(TEXT_ESCAPE_RE, TEXT_ESCAPES)
114
+ end
115
+
116
+ def escape_attribute(value)
117
+ value.to_s.gsub(ATTRIBUTE_ESCAPE_RE, ATTRIBUTE_ESCAPES)
118
+ end
119
+ end
120
+ end
121
+ end