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
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Processing model §Names and namespaces (normative): pinned here.
4
+ # Processing model §XPath evaluation (normative): pinned here.
5
+
3
6
  require "spec_helper"
4
7
 
5
8
  # Cross-adapter XPath idioms that nokogiri consumers rely on:
@@ -107,6 +110,14 @@ RSpec.describe "XPath idioms across adapters" do
107
110
  expect(doc.at_xpath("/*")&.name).to eq("r")
108
111
  expect(doc.at_xpath("/r")&.name).to eq("r")
109
112
  end
113
+
114
+ it "answers scalar XPath functions unwrapped and uniform" do
115
+ doc = ctx.parse(plain_xml)
116
+ expect(doc.xpath("count(//a)")).to eq(1.0)
117
+ expect(doc.xpath("string(//a/@id)")).to eq("x")
118
+ expect(doc.xpath("boolean(//b)")).to be(true)
119
+ expect(doc.at_xpath("count(//a)")).to eq(1.0)
120
+ end
110
121
  end
111
122
  end
112
123
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Processing model §XSLT (normative): pinned here.
4
+
3
5
  require "spec_helper"
4
6
  require "moxml/c14n"
5
7
 
@@ -5,10 +5,13 @@ require "spec_helper"
5
5
  # moxml#375 — element hydration through the binding's
6
6
  # attributes_snapshot face (libleptris 1.9.353, engine
7
7
  # leptris#1689): one crossing for every attribute instead of one
8
- # per attribute. The output contract is stride-identical to the
9
- # binding's per-attribute face: qualified names, document order,
10
- # fresh values.
8
+ # per attribute. The facade listing face is bare names (uniform with
9
+ # every adapter, and with Attribute#name), document order, fresh
10
+ # values.
11
11
  RSpec.describe "leptris element-attribute snapshot hydration" do
12
+ def bare(pairs)
13
+ pairs.map { |(name, value)| [name.split(":", 2).last, value] }
14
+ end
12
15
  let(:ctx) { Moxml.new(:leptris) }
13
16
 
14
17
  context "when the snapshot face is armed (>= 1.9.353.0)" do
@@ -23,11 +26,11 @@ RSpec.describe "leptris element-attribute snapshot hydration" do
23
26
  '<root a="1" xml:lang="en" xmlns:ns="urn:u" ns:p="v"/>',
24
27
  )
25
28
  binding_element = Moxml::Adapter::Leptris.to_binding(doc.root)
26
- expected = binding_element.attribute_pairs
29
+ expected = bare(binding_element.attribute_pairs)
27
30
 
28
31
  expect(doc.root.attribute_pairs).to eq(expected)
29
32
  expect(doc.root.attribute_pairs).to eq(
30
- [["a", "1"], ["xml:lang", "en"], ["ns:p", "v"]],
33
+ [["a", "1"], ["lang", "en"], ["p", "v"]],
31
34
  )
32
35
  end
33
36
 
@@ -36,7 +39,7 @@ RSpec.describe "leptris element-attribute snapshot hydration" do
36
39
  '<root z="9" xmlns:m="urn:m" m:x="8" k="7"/>',
37
40
  )
38
41
  expect(doc.root.attribute_pairs.map(&:first)).to eq(
39
- %w[z m:x k],
42
+ %w[z x k],
40
43
  )
41
44
  end
42
45
 
@@ -71,7 +74,7 @@ RSpec.describe "leptris element-attribute snapshot hydration" do
71
74
  )
72
75
  binding_element = Moxml::Adapter::Leptris.to_binding(doc.root)
73
76
  expect(doc.root.attribute_pairs).to eq(
74
- binding_element.attribute_pairs,
77
+ bare(binding_element.attribute_pairs),
75
78
  )
76
79
  end
77
80
  end
@@ -94,7 +94,7 @@ RSpec.describe Moxml::Adapter::Ox do
94
94
 
95
95
  it "fails loudly on ox_mode: :hash (the adapter walks a tree, not a Hash)" do
96
96
  expect { described_class.parse("<r><t> x </t></r>", ox_mode: :hash) }
97
- .to raise_error(RuntimeError, /must be a String or Ox::Node/)
97
+ .to raise_error(Moxml::ParseError, /must be a String or Ox::Node/)
98
98
  end
99
99
 
100
100
  it "surfaces an invalid ox_skip as Moxml::ParseError" do
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Processing model §Access semantics (normative): pinned here.
4
+
3
5
  require "spec_helper"
4
6
 
5
7
  RSpec.describe Moxml::AttributeResolver do
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Processing model §Canonicalization (normative): pinned here.
4
+
3
5
  require "spec_helper"
4
6
  require "moxml/c14n"
5
7
 
@@ -84,6 +84,13 @@ RSpec.describe Moxml::Doctype do
84
84
  expect(doctype.external_id).to be_nil
85
85
  expect(doctype.system_id).to be_nil
86
86
  end
87
+
88
+ it "preserves a DOCTYPE internal subset on serialization" do
89
+ xml = '<!DOCTYPE r [<!ENTITY x "v">]><r>&x;</r>'
90
+ parsed = ctx.parse(xml)
91
+
92
+ expect(parsed.to_xml(indent: 0)).to include('<!ENTITY x "v">')
93
+ end
87
94
  end
88
95
  end
89
96
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Processing model §Parsing (normative): pinned here.
4
+
3
5
  require "spec_helper"
4
6
 
5
7
  # Document#parse_diagnostics (issue #271, option A — additive):
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Processing model §Parsing (normative): pinned here.
4
+
3
5
  require "spec_helper"
4
6
 
5
7
  RSpec.describe Moxml::Entity do
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Processing model §Materialization (normative): pinned here.
4
+
3
5
  require "spec_helper"
4
6
 
5
7
  # Moxml::Plan adapter-contract conformance (leptris follow-up to
@@ -21,6 +21,15 @@ RSpec.describe Moxml::ProcessingInstruction do
21
21
  expect(pi.content).to include("type=")
22
22
  expect(pi.content).to include("href=")
23
23
  end
24
+
25
+ it "answers the empty string for a PI with no data on every adapter" do
26
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |name|
27
+ parsed = Moxml.new(name).parse("<r><?pi?></r>")
28
+ pi = parsed.children.first.children
29
+ .find { |n| n.is_a?(described_class) }
30
+ expect(pi.content).to eq(""), "empty PI content on #{name}"
31
+ end
32
+ end
24
33
  end
25
34
 
26
35
  describe "creation" do
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # The engine-free lane (TODO.sota/03): the reference is pure Ruby and
6
+ # answers the model where no native engine exists. This spec proves it
7
+ # transpiles and runs under Opal.
8
+ RSpec.describe "Moxml::Reference under Opal", if: RUBY_ENGINE == "opal" do
9
+ it "parses, answers faces, and writes without a native engine" do
10
+ document = Moxml::Reference.parse(
11
+ '<r a="1"><b>t&amp;u</b><![CDATA[c]]><?pi d?></r>',
12
+ )
13
+ expect(Moxml::Reference.element_names(document)).to eq(%w[r b])
14
+ expect(Moxml::Reference.child_element_names(document)).to eq([["b"], []])
15
+ element = document.root.children.find(&:element?)
16
+ expect(element.children.map(&:kind)).to eq([:text])
17
+ expect(element.children.first.data).to eq("t&u")
18
+ expect(document.root.text).to eq("t&uc")
19
+ expect(Moxml::Reference.processing_instructions(document))
20
+ .to eq([["pi", "d"]])
21
+ expect(Moxml::Reference.to_xml(document))
22
+ .to include("<r a=\"1\">", "<b>t&amp;u</b>", "<![CDATA[c]]>")
23
+ end
24
+
25
+ it "raises a named ParseError for malformed input" do
26
+ expect { Moxml::Reference.parse("<r><c></r>") }
27
+ .to raise_error(Moxml::ParseError)
28
+ end
29
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "support/input_taxonomy"
5
+ require "support/xml_generator"
6
+
7
+ # The executable reference (TODO.sota/03): a pure-Ruby reader,
8
+ # writer, and normalizer that answer the processing model with no
9
+ # engine involved. The differential suite tests adapters against it;
10
+ # these specs pin the reference itself.
11
+ RSpec.describe Moxml::Reference do
12
+ describe "parsing" do
13
+ it "reads the object model, declaration, and document parts" do
14
+ doc = described_class.parse(
15
+ '<?xml version="1.0" encoding="UTF-8"?><!--lead--><r a="1"><b>t</b></r>',
16
+ )
17
+ expect(doc.declaration[:version]).to eq("1.0")
18
+ expect(doc.declaration[:encoding]).to eq("UTF-8")
19
+ expect(described_class.element_names(doc)).to eq(%w[r b])
20
+ expect(doc.root.attribute_pairs).to eq([["a", "1"]])
21
+ expect(described_class.comments(doc)).to eq(["lead"])
22
+ end
23
+
24
+ it "answers bare local names for prefixed sources" do
25
+ doc = described_class.parse('<r xmlns:p="urn:a"><p:c p:x="1"/></r>')
26
+ expect(described_class.element_names(doc)).to eq(%w[r c])
27
+ expect(described_class.child_element_names(doc)).to eq([["c"], []])
28
+ expect(doc.root.children.first.attribute_pairs).to eq([["x", "1"]])
29
+ end
30
+
31
+ it "expands built-in and numeric references, never comments or PI data" do
32
+ doc = described_class.parse(
33
+ "<r>&amp;&#65;&#x42;<![CDATA[<raw>]]><!-- c --><?pi d?></r>",
34
+ )
35
+ expect(doc.root.text).to eq("&AB<raw>")
36
+ expect(described_class.processing_instructions(doc)).to eq([["pi", "d"]])
37
+ end
38
+
39
+ it "preserves DTD-declared general entities lexically (uniform lane)" do
40
+ doc = described_class.parse(
41
+ '<!DOCTYPE r [<!ENTITY x "expanded">]><r>&x;</r>',
42
+ )
43
+ expect(doc.root.text).to eq("&x;")
44
+ end
45
+
46
+ it "normalizes literal line endings but not character references" do
47
+ doc = described_class.parse("<r>a\r\nb\rc&#xD;d</r>")
48
+ expect(doc.root.text).to eq("a\nb\nc\rd")
49
+ end
50
+
51
+ it "normalizes literal attribute whitespace" do
52
+ doc = described_class.parse(%(<r a="x\ty\nz"/>))
53
+ expect(doc.root.attribute_pairs).to eq([["a", "x y z"]])
54
+ end
55
+
56
+ it "drops the PI separator from PI data" do
57
+ doc = described_class.parse("<r><?pi data ?></r>")
58
+ expect(described_class.processing_instructions(doc))
59
+ .to eq([["pi", "data "]])
60
+ end
61
+
62
+ it "answers the empty string for PI with no data" do
63
+ doc = described_class.parse("<r><?pi?></r>")
64
+ expect(described_class.processing_instructions(doc)).to eq([["pi", ""]])
65
+ end
66
+ end
67
+
68
+ describe "totality over the input taxonomy" do
69
+ it "parses every well-formed taxonomy fixture" do
70
+ InputTaxonomy.well_formed.each do |input|
71
+ expect { described_class.parse(input.xml) }
72
+ .not_to raise_error, "#{input.id} must parse in the reference"
73
+ end
74
+ end
75
+
76
+ it "raises a named ParseError for every malformed taxonomy fixture" do
77
+ InputTaxonomy.malformed.each do |input|
78
+ expect { described_class.parse(input.xml) }
79
+ .to raise_error(Moxml::ParseError),
80
+ "#{input.id} must raise in the reference"
81
+ end
82
+ end
83
+
84
+ it "parses every generated document" do
85
+ (1..10).each do |seed|
86
+ expect { described_class.parse(XmlGenerator.generate(seed)) }
87
+ .not_to raise_error
88
+ end
89
+ end
90
+ end
91
+
92
+ describe "writers" do
93
+ let(:doc) do
94
+ described_class.parse(
95
+ '<r a="&lt;&amp;&quot;"><b/>t<!--c--><![CDATA[x<y]]></r>',
96
+ )
97
+ end
98
+
99
+ it "emits XML that re-parses to the same information set" do
100
+ xml = described_class.to_xml(doc)
101
+ again = described_class.parse(xml)
102
+ expect(described_class.element_names(again))
103
+ .to eq(described_class.element_names(doc))
104
+ expect(again.root.attribute_pairs).to eq(doc.root.attribute_pairs)
105
+ expect(again.root.text).to eq(doc.root.text)
106
+ end
107
+
108
+ it "emits the canonical facade escaping" do
109
+ expect(described_class.to_xml(doc))
110
+ .to include('<r a="&lt;&amp;&quot;">')
111
+ expect(described_class.to_xml(doc)).to include("<![CDATA[x<y]]>")
112
+ expect(described_class.to_xml(doc)).to include("<b/>")
113
+ end
114
+
115
+ it "splits a CDATA end sequence" do
116
+ document = Moxml::Reference::Document.new
117
+ root = Moxml::Reference::Node.new(kind: :element, name: "r")
118
+ root.children << Moxml::Reference::Node.new(kind: :cdata, data: "a]]>b")
119
+ document.children << root
120
+ expect(described_class.to_xml(document)).to include("]]]]><![CDATA[>")
121
+ end
122
+
123
+ it "leaves void elements unclosed in HTML and self-closed in XHTML" do
124
+ html_doc = described_class.parse("<html><body>a<br/>b</body></html>")
125
+ expect(described_class.to_html(html_doc)).to include("<br>")
126
+ expect(described_class.to_xhtml(html_doc)).to include("<br/>")
127
+ end
128
+
129
+ it "emits raw text inside HTML script and style, escaped in XHTML" do
130
+ html_doc = described_class.parse("<html><script>a &lt; b</script></html>")
131
+ expect(described_class.to_html(html_doc))
132
+ .to include("<script>a < b</script>")
133
+ expect(described_class.to_xhtml(html_doc))
134
+ .to include("<script>a &lt; b</script>")
135
+ end
136
+
137
+ it "emits no XML declaration unless the source had one" do
138
+ expect(described_class.to_html(described_class.parse("<r/>")))
139
+ .to eq("<r></r>")
140
+ with_declaration = described_class.parse('<?xml version="1.0"?><r/>')
141
+ expect(described_class.to_xml(with_declaration))
142
+ .to start_with('<?xml version="1.0"?>')
143
+ end
144
+ end
145
+
146
+ describe "normalizer" do
147
+ it "normalizes CRLF and bare CR to LF" do
148
+ expect(Moxml::Reference::Normalizer.normalize_eol("a\r\nb\rc"))
149
+ .to eq("a\nb\nc")
150
+ end
151
+
152
+ it "expands built-ins and numeric references" do
153
+ expect(Moxml::Reference::Normalizer.expand_entities("&lt;&#65;"))
154
+ .to eq("<A")
155
+ end
156
+
157
+ it "raises for an undefined reference" do
158
+ expect { Moxml::Reference::Normalizer.expand_entities("&nope;") }
159
+ .to raise_error(Moxml::ParseError)
160
+ end
161
+ end
162
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Processing model §SAX (normative): pinned here.
4
+
3
5
  require "spec_helper"
4
6
 
5
7
  RSpec.describe Moxml::SAX do
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Processing model §Access semantics (normative): pinned here.
4
+
3
5
  require "spec_helper"
4
6
 
5
7
  RSpec.describe Moxml::Text do
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Processing model §Serialization (normative): pinned here.
4
+
3
5
  require "spec_helper"
4
6
 
5
7
  # Document#to_html (issue #354): HTML-method serialization with the
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Processing model §Serialization (normative): pinned here.
4
+
3
5
  require "spec_helper"
4
6
 
5
7
  # Issue #390: XHTML-shaped serialization — void elements self-closed
@@ -217,11 +217,11 @@ RSpec.describe "XPath Axes" do
217
217
  end
218
218
 
219
219
  it "selects ancestors with ancestor::" do
220
- expect(select("//c/ancestor::*")).to eq(%w[parent root])
220
+ expect(select("//c/ancestor::*")).to eq(%w[root parent])
221
221
  end
222
222
 
223
223
  it "includes the context node with ancestor-or-self::" do
224
- expect(select("//c/ancestor-or-self::*")).to eq(%w[c parent root])
224
+ expect(select("//c/ancestor-or-self::*")).to eq(%w[root parent c])
225
225
  end
226
226
 
227
227
  it "selects following siblings" do
@@ -141,4 +141,42 @@ RSpec.describe Moxml::XSD do
141
141
  .to eq(Moxml::Adapter::Leptris::NATIVE_XSD)
142
142
  end
143
143
  end
144
+
145
+ describe "content_valid? with C-minted identity children (moxml#410)",
146
+ if: Moxml::Adapter::Leptris::XSD_SUPPORTED do
147
+ let(:ctx) { Moxml.new(:leptris) }
148
+ let(:schema_text) do
149
+ <<~XML
150
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
151
+ <xs:element name="person">
152
+ <xs:complexType>
153
+ <xs:sequence>
154
+ <xs:element name="name" type="xs:string"/>
155
+ </xs:sequence>
156
+ </xs:complexType>
157
+ </xs:element>
158
+ </xs:schema>
159
+ XML
160
+ end
161
+
162
+ it "answers for identity wrappers minted at create (1.9.374+)" do
163
+ schema = ctx.xsd(schema_text)
164
+ doc = ctx.create_document
165
+ root = doc.create_element("person")
166
+ doc.root = root
167
+ name = doc.create_element("name")
168
+ name.text = "A"
169
+ root.add_child(name)
170
+
171
+ expect(root.native.class.name).to include("Identity")
172
+ expect(schema.content_valid?("person", root.children.to_a)).to be(true)
173
+ end
174
+
175
+ it "answers for identity children from parse" do
176
+ schema = ctx.xsd(schema_text)
177
+ root = ctx.parse("<person><name>A</name></person>").root
178
+ expect(schema.content_valid?("person", root.children.to_a)).to be(true)
179
+ expect(schema.content_valid?("person", [])).to be(false)
180
+ end
181
+ end
144
182
  end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Seeded XML input generator (TODO.sota/02): composes the 01
4
+ # taxonomy's vocabulary into fresh inputs, deterministically from a
5
+ # seed. CI legs run a fixed seed — identical cases every run; the
6
+ # nightly fuzz leg passes a random one (MOXML_FUZZ_SEED). Every case
7
+ # id carries its provenance ("gen <seed>/<i> <malform>"), so a
8
+ # failure reproduces at that seed and index.
9
+ module InputGenerator
10
+ # Generated inputs record the divergence-relevant features they use
11
+ # so the differential exempts exactly those cells, never silently —
12
+ # every value is a register ID (docs/_pages/divergences.adoc).
13
+ FEATURE_EXEMPT = {
14
+ inter_element_ws: {
15
+ nodes: { ox: "D-13", headed_ox: "D-13" },
16
+ text: { ox: "D-13", headed_ox: "D-13" },
17
+ },
18
+ pi_in_content: { text: { oga: "D-14" } },
19
+ pre_root_ws: { nodes: { rexml: "D-15" }, text: { rexml: "D-15" } },
20
+ adjacent_cdata: {
21
+ nodes: { oga: "D-19", rexml: "D-19", ox: "D-19", headed_ox: "D-19",
22
+ leptris: "D-19" },
23
+ },
24
+ }.freeze
25
+
26
+ # malform is one of the three guaranteed-breaking structural
27
+ # mutations; oga's strict lane is lexer-strict (D-17) and is
28
+ # exempted for all of them in the strict-raise property.
29
+ Case = Struct.new(:id, :xml, :well_formed, :features, :voids, :malform)
30
+
31
+ ELEMENTS = %w[a b sec p].freeze
32
+ VOIDS = %w[br img hr].freeze
33
+ ATTR_VALUES = [
34
+ "1", "x y", "a &amp; b", "&lt;tag&gt;", "&#65;&#x42;", "taillé"
35
+ ].freeze
36
+ TEXTS = [
37
+ "plain text", " spaced ", "a &amp; b &lt; c", "&#65;&#x42;",
38
+ "café &#160; NBSP", "word"
39
+ ].freeze
40
+ NAMESPACES = [
41
+ 'xmlns="urn:t"', 'xmlns:ns="urn:n"', 'xmlns="urn:r"'
42
+ ].freeze
43
+ XML_ATTRS = ['xml:space="preserve"', 'xml:lang="en"'].freeze
44
+ MALFORMS = %i[unclosed stray attr_quote].freeze
45
+
46
+ module_function
47
+
48
+ # mode: :fuzz mixes the three structural malformations into roughly
49
+ # a third of the cases (the strict-raise property consumes those);
50
+ # :well_formed answers pure compositions.
51
+ def cases(seed:, count:, mode: :fuzz)
52
+ rng = Random.new(seed)
53
+ Array.new(count) do |i|
54
+ body, features, voids = compose(rng)
55
+ id = "gen #{seed}/#{i}"
56
+ if mode == :fuzz && rng.rand(3).zero?
57
+ malform = MALFORMS[rng.rand(MALFORMS.length)]
58
+ Case.new("#{id} #{malform}", mutate(body, malform), false,
59
+ features, voids, malform)
60
+ else
61
+ Case.new(id, body, true, features, voids, nil)
62
+ end
63
+ end
64
+ end
65
+
66
+ def compose(rng)
67
+ features = []
68
+ voids = []
69
+ pre = +""
70
+ draw = rng.rand(6)
71
+ if draw.zero?
72
+ pre << "<!--pre-root-->"
73
+ elsif draw == 1
74
+ pre << "<?prep root?>"
75
+ elsif draw == 2
76
+ pre << " "
77
+ features << :pre_root_ws
78
+ end
79
+
80
+ attrs = +'<r id="root"'
81
+ attrs << " #{NAMESPACES[rng.rand(NAMESPACES.length)]}" if rng.rand(2).zero?
82
+ attrs << " #{XML_ATTRS[rng.rand(XML_ATTRS.length)]}" if rng.rand(3).zero?
83
+ attrs << ' ns:x="v"' if rng.rand(3).zero?
84
+ attrs << ">"
85
+
86
+ body = +""
87
+ depth = 1 + rng.rand(4)
88
+ (1 + rng.rand(4)).times { body << child(rng, 0, depth, features, voids) }
89
+ # a whitespace-only run between two constructs is a dropped-text
90
+ # divergence cell (D-13 on ox/headed_ox)
91
+ features << :inter_element_ws if body.match?(%r{>\s+<})
92
+
93
+ xml = "#{pre}#{attrs}#{body}</r>"
94
+ # a default-namespace declaration scopes XPath name tests away
95
+ # from generated a/b elements (XPath 1.0, processing model
96
+ # "Names and namespaces")
97
+ features << :default_ns if xml.include?('xmlns="')
98
+ # adjacent text-like leaves — CDATA next to CDATA/text — coalesce
99
+ # on the libxml2 lane (D-19)
100
+ if xml.match?(/]\]>\s*<!\[CDATA\[/) || xml.match?(/]\]>\s*[^<\s]/) ||
101
+ xml.match?(/[^<\s]\s*<!\[CDATA\[/)
102
+ features << :adjacent_cdata
103
+ end
104
+ [xml, features.uniq, voids.uniq]
105
+ end
106
+
107
+ def child(rng, level, max_depth, features, voids)
108
+ if level < max_depth && rng.rand(8) < 5
109
+ inner = +""
110
+ (1 + rng.rand(3)).times do
111
+ inner << child(rng, level + 1, max_depth, features, voids)
112
+ end
113
+ name = ELEMENTS[rng.rand(ELEMENTS.length)]
114
+ extra = +""
115
+ extra << " #{NAMESPACES[rng.rand(NAMESPACES.length)]}" if rng.rand(4).zero?
116
+ extra << " #{XML_ATTRS[rng.rand(XML_ATTRS.length)]}" if rng.rand(4).zero?
117
+ extra << " ns:x=\"#{ATTR_VALUES[rng.rand(ATTR_VALUES.length)]}\"" if rng.rand(3).zero?
118
+ "<#{name}#{extra}>#{inner}</#{name}>"
119
+ else
120
+ leaf(rng, features, voids)
121
+ end
122
+ end
123
+
124
+ def leaf(rng, features, voids)
125
+ case rng.rand(6)
126
+ when 0, 1
127
+ TEXTS[rng.rand(TEXTS.length)]
128
+ when 2
129
+ "<!--comment #{rng.rand(9)}-->"
130
+ when 3
131
+ features << :pi_in_content
132
+ "<?inst data-#{rng.rand(9)}?>"
133
+ when 4
134
+ "<![CDATA[x < #{rng.rand(9)} & more]]>"
135
+ else
136
+ v = VOIDS[rng.rand(VOIDS.length)]
137
+ voids << v
138
+ "<#{v}/>"
139
+ end
140
+ end
141
+
142
+ # Each mutation is guaranteed to break well-formedness (the root
143
+ # close and the root id attribute always exist in composed bodies).
144
+ def mutate(xml, malform)
145
+ case malform
146
+ when :unclosed
147
+ xml.sub(%r{</[a-z]+>}, "")
148
+ when :stray
149
+ "#{xml}</r>"
150
+ when :attr_quote
151
+ xml.sub('id="root"', 'id="root')
152
+ end
153
+ end
154
+ end