moxml 0.5.146 → 0.5.148

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +35 -0
  3. data/README.adoc +19 -1
  4. data/docs/_pages/adapter-compatibility.adoc +7 -0
  5. data/docs/_pages/divergences.adoc +90 -0
  6. data/docs/_references/index.adoc +9 -0
  7. data/docs/_references/processing-model.adoc +211 -0
  8. data/docs/index.adoc +9 -0
  9. data/lib/moxml/adapter/leptris.rb +100 -3
  10. data/lib/moxml/adapter/libxml.rb +5 -0
  11. data/lib/moxml/adapter/oga.rb +10 -0
  12. data/lib/moxml/document.rb +27 -4
  13. data/lib/moxml/node.rb +12 -0
  14. data/lib/moxml/version.rb +1 -1
  15. data/spec/consistency/divergence_register_spec.rb +109 -0
  16. data/spec/consistency/document_text_spec.rb +30 -0
  17. data/spec/consistency/eol_normalization_spec.rb +8 -8
  18. data/spec/consistency/loud_degradation_spec.rb +44 -0
  19. data/spec/consistency/model_coverage_audit_spec.rb +113 -0
  20. data/spec/consistency/spec_constant_uniqueness_spec.rb +29 -0
  21. data/spec/consistency/xpath_idioms_spec.rb +1 -1
  22. data/spec/moxml/adapter/leptris_contract_reads_spec.rb +29 -0
  23. data/spec/moxml/adapter/leptris_detached_text_spec.rb +41 -0
  24. data/spec/moxml/adapter/leptris_parse_lint_spec.rb +47 -0
  25. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +3 -3
  26. data/spec/moxml/c14n/adapter_delegation_spec.rb +3 -3
  27. data/spec/moxml/c14n/comments_pis_spec.rb +2 -2
  28. data/spec/moxml/c14n/subset_spec.rb +4 -4
  29. data/spec/moxml/c14n/w3c_examples_spec.rb +3 -1
  30. data/spec/moxml/declaration_preservation_spec.rb +2 -2
  31. data/spec/moxml/element_add_element_spec.rb +3 -3
  32. data/spec/moxml/element_attribute_pairs_spec.rb +2 -2
  33. data/spec/moxml/node_spec.rb +1 -1
  34. data/spec/moxml/plan_conformance_spec.rb +3 -3
  35. data/spec/moxml/sax_entity_parity_spec.rb +3 -3
  36. data/spec/moxml/sax_spec.rb +7 -7
  37. data/spec/moxml/struct_plan_spec.rb +2 -2
  38. data/spec/moxml/xpath_capabilities_spec.rb +13 -13
  39. metadata +11 -2
data/lib/moxml/node.rb CHANGED
@@ -847,6 +847,18 @@ module Moxml
847
847
  def self.wrap_with(node, context, adapter)
848
848
  return nil if node.nil?
849
849
 
850
+ # Identity natives ARE the canonical wrapper (leptris C-mint):
851
+ # @native is self, so canonicalization, the cache probes and
852
+ # the re-mint below all answer the receiver. One native ivar
853
+ # read decides; shells (@native != self) take the full path.
854
+ # The C mint leaves the node un-primed (@context unset) — prime
855
+ # on first wrap. Conditionally: an already-primed identity (a
856
+ # walked node) must not lose @parent_node to a re-prime.
857
+ if node.is_a?(Node) && node.instance_variable_get(:@native).equal?(node)
858
+ node.prime_contract(node, context, adapter) unless node.instance_variable_defined?(:@context)
859
+ return node
860
+ end
861
+
850
862
  # One C node may have several Ruby representations (leptris
851
863
  # native/binding duality, moxml#311); every wrap resolves to
852
864
  # the canonical one so memo holders share one invalidation
data/lib/moxml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.146"
4
+ VERSION = "0.5.148"
5
5
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Divergence register linkage (TODO.model/02): the register
6
+ # (docs/_pages/divergences.adoc) and the consistency suite must stay
7
+ # mechanically consistent — every capability-related skip cites a
8
+ # register ID, every register entry is cited by at least one spec, and
9
+ # no spec cites an unregistered ID.
10
+ RSpec.describe "divergence register linkage" do
11
+ REGISTER_PATH = "docs/_pages/divergences.adoc" # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
12
+ SCAN_GLOBS = %w[spec/consistency/**/*.rb spec/moxml/**/*.rb].freeze # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
13
+ NEXT_GATE = [ # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
14
+ /same engine as/, # engine dedup, not a divergence
15
+ /source_adapter == target_adapter/, # self round-trip
16
+ /MAX_SIZE/, # size guard
17
+ /AVAILABLE_ADAPTERS\.include/, # availability filter
18
+ /skip_shapes/, # shape routing (IDs cited above)
19
+ /leptris_fixed\?/, # version gate
20
+ /source_elements/, # data-presence guard
21
+ /next unless match/, # scanner-internal guard
22
+ ].freeze
23
+
24
+ # Version/availability gates are not divergences: a binding too old
25
+ # or an adapter not installed is a deployment fact, not an
26
+ # engine-vs-model difference at equal versions.
27
+ GATE = [ # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
28
+ /unavailable/i, /not available/, /predates/, /not installed/i,
29
+ /not present/i, /requires/i, /required\z/, /has no \S+ face/,
30
+ /lacks the /, /carries /, /degraded below/, /lockstep/i,
31
+ /unreleased/i, /stood down/, /recovered without/,
32
+ /Only \S+ available in CI/i, /adapter-specific/i, /noblanks/,
33
+ /SKIP_ parse flags/, /engine < \d/
34
+ ].freeze
35
+
36
+ def register_ids
37
+ File.readlines(REGISTER_PATH)
38
+ .filter_map { |line| line[/\A\|\s*(D-\d+)\s*$/, 1] }
39
+ .uniq
40
+ end
41
+
42
+ def spec_files
43
+ SCAN_GLOBS.flat_map { |g| Dir[g] }.uniq
44
+ end
45
+
46
+ def cited_ids
47
+ spec_files.flat_map { |path| File.readlines(path) }
48
+ .flat_map { |line| line.scan(/D-\d+/) }
49
+ .uniq
50
+ end
51
+
52
+ it "has a register with entries" do
53
+ expect(register_ids).not_to be_empty
54
+ end
55
+
56
+ it "requires capability-related skips to cite a register ID" do
57
+ offenders = []
58
+ spec_files.each do |path|
59
+ File.readlines(path).each_with_index do |line, i|
60
+ next unless (msg = line[/skip\s+[&(]?\s*["'](.*?)["']/, 1])
61
+
62
+ next if GATE.any? { |re| re.match?(msg) }
63
+ next if msg.match?(/D-\d+/)
64
+
65
+ offenders << "#{path}:#{i + 1} #{msg.strip[0, 60]}"
66
+ end
67
+ end
68
+ expect(offenders).to be_empty,
69
+ "capability skips must cite a divergence-register " \
70
+ "ID (D-NN): #{offenders.join('; ')}"
71
+ end
72
+
73
+ it "requires adapter-exempting next-guards to cite a nearby register ID" do
74
+ offenders = []
75
+ spec_files.each do |path|
76
+ next if path.end_with?("divergence_register_spec.rb")
77
+
78
+ lines = File.readlines(path)
79
+ lines.each_with_index do |line, i|
80
+ next unless line.match?(/^\s*next\s+(if|unless)\s/)
81
+ next if NEXT_GATE.any? { |re| re.match?(line) }
82
+ next if line.match?(/D-\d+/)
83
+
84
+ context = lines[[i - 4, 0].max...i] + lines[(i + 1)..(i + 4)].to_a
85
+ next if context.any? { |l| l.match?(/D-\d+/) }
86
+
87
+ offenders << "#{path}:#{i + 1} #{line.strip[0, 60]}"
88
+ end
89
+ end
90
+ expect(offenders).to be_empty,
91
+ "adapter-exempting next-guards must cite a " \
92
+ "divergence-register ID (D-NN) on or above the " \
93
+ "line: #{offenders.join('; ')}"
94
+ end
95
+
96
+ it "cites every register entry from at least one spec" do
97
+ missing = register_ids - cited_ids
98
+ expect(missing).to be_empty,
99
+ "register entries never cited by a spec: " \
100
+ "#{missing.join(', ')}"
101
+ end
102
+
103
+ it "never cites an unregistered divergence ID" do
104
+ orphans = cited_ids - register_ids
105
+ expect(orphans).to be_empty,
106
+ "specs cite IDs missing from the register: " \
107
+ "#{orphans.join(', ')}"
108
+ end
109
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Issue #400: documents are container nodes — Document#text
6
+ # concatenates descendant text (Nokogiri's Document#text contract).
7
+ # The base Node#text answered "" and silently emptied consumers'
8
+ # content-preservation checks (canon's formatter asserted through
9
+ # doc.text).
10
+ RSpec.describe "Document#text concatenates descendant text" do
11
+ Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
12
+ context "with #{adapter_name}" do
13
+ let(:ctx) { Moxml.new(adapter_name) }
14
+
15
+ it "answers the concatenated descendant text" do
16
+ doc = ctx.parse("<r><a>x</a><b>y</b></r>")
17
+ expect(doc.text).to eq("xy")
18
+ end
19
+
20
+ it "matches root.text (both containers recurse)" do
21
+ doc = ctx.parse("<r><a>x</a><b>y</b></r>")
22
+ expect(doc.text).to eq(doc.root.text)
23
+ end
24
+
25
+ it "answers an empty string for element-free documents" do
26
+ expect(ctx.parse("<r/>").text).to eq("")
27
+ end
28
+ end
29
+ end
30
+ end
@@ -20,7 +20,7 @@ require "spec_helper"
20
20
  # binding release vending that engine rides
21
21
  # (gate: binding 1.9.240).
22
22
  RSpec.describe "XML line-ending normalization" do
23
- ADAPTERS = %i[leptris nokogiri ox oga rexml libxml].freeze # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
23
+ EOL_NORMALIZATION_ADAPTERS = %i[leptris nokogiri ox oga rexml libxml].freeze # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
24
24
 
25
25
  def with_adapter(name)
26
26
  ctx = Moxml.new(name)
@@ -37,10 +37,10 @@ RSpec.describe "XML line-ending normalization" do
37
37
  end
38
38
 
39
39
  it "normalizes CRLF and lone CR to LF in text content" do
40
- ADAPTERS.each do |name|
40
+ EOL_NORMALIZATION_ADAPTERS.each do |name|
41
41
  next unless leptris_fixed?(name)
42
42
 
43
- skip "oga preserves raw CR (upstream)" if name == :oga
43
+ skip "D-02: oga preserves raw CR (upstream)" if name == :oga
44
44
 
45
45
  with_adapter(name) do |ctx|
46
46
  text = ctx.parse("<r>a\r\nb\rc</r>").root.children.first
@@ -50,10 +50,10 @@ RSpec.describe "XML line-ending normalization" do
50
50
  end
51
51
 
52
52
  it "normalizes EOL inside CDATA content" do
53
- ADAPTERS.each do |name|
53
+ EOL_NORMALIZATION_ADAPTERS.each do |name|
54
54
  next unless leptris_fixed?(name)
55
55
 
56
- skip "oga preserves raw CR (upstream)" if name == :oga
56
+ skip "D-02: oga preserves raw CR (upstream)" if name == :oga
57
57
 
58
58
  with_adapter(name) do |ctx|
59
59
  cdata = ctx.parse("<r><![CDATA[a\r\nb]]></r>").root.children.first
@@ -63,11 +63,11 @@ RSpec.describe "XML line-ending normalization" do
63
63
  end
64
64
 
65
65
  it "preserves CR from character references" do
66
- ADAPTERS.each do |name|
66
+ EOL_NORMALIZATION_ADAPTERS.each do |name|
67
67
  next unless leptris_fixed?(name)
68
68
 
69
- skip "ox collapses &#13; to LF (upstream)" if name == :ox
70
- skip "oga preserves raw CR (upstream)" if name == :oga
69
+ skip "D-03: ox collapses &#13; to LF (upstream)" if name == :ox
70
+ skip "D-02: oga preserves raw CR (upstream)" if name == :oga
71
71
 
72
72
  with_adapter(name) do |ctx|
73
73
  text = ctx.parse("<r>a&#13;b</r>").root.children.first
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Loud degradation (processing model, "Errors and loud degradation"):
6
+ # a capability an engine cannot deliver must answer the contract or
7
+ # raise the named error — never a divergent answer. Probed across all
8
+ # available adapters; the capability lists mirror the adapters'
9
+ # actual engine backing.
10
+ RSpec.describe "loud capability contracts" do
11
+ available = Moxml::Adapter::AVAILABLE_ADAPTERS
12
+ xslt_capable = %i[nokogiri leptris] & available
13
+ html_capable = %i[nokogiri leptris] & available
14
+
15
+ let(:sheet) do
16
+ %(<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">) \
17
+ "<xsl:template match='/'><o/></xsl:template></xsl:stylesheet>"
18
+ end
19
+
20
+ (available - xslt_capable).each do |name|
21
+ it "raises NotImplementedError for XSLT on #{name}" do
22
+ ctx = Moxml.new(name)
23
+ expect { ctx.xslt(sheet) }.to raise_error(Moxml::NotImplementedError)
24
+ end
25
+ end
26
+
27
+ (available - html_capable).each do |name|
28
+ it "raises NotImplementedError for to_xhtml on #{name}" do
29
+ doc = Moxml.new(name).parse("<r><a/></r>")
30
+ expect { doc.to_xhtml }.to raise_error(Moxml::NotImplementedError)
31
+ end
32
+
33
+ it "raises AdapterError for to_html on #{name}" do
34
+ doc = Moxml.new(name).parse("<r><a/></r>")
35
+ expect { doc.to_html }.to raise_error(Moxml::AdapterError)
36
+ end
37
+ end
38
+
39
+ xslt_capable.each do |name|
40
+ it "compiles a stylesheet on #{name}" do
41
+ expect(Moxml.new(name).xslt(sheet)).to be_a(Moxml::XSLT::Stylesheet)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Model coverage matrix (TODO.model/03): every face family of the
6
+ # processing model is governed for every adapter — covered by a pin
7
+ # (the consistency/contract spec that asserts equivalence), exempt via
8
+ # a registered divergence (adapter => D-ID), or loud (adapter =>
9
+ # the named error class). An unpinned family is an ungoverned cell:
10
+ # this audit fails until it has one of the three.
11
+ # See docs/_references/processing-model.adoc for the model itself.
12
+ RSpec.describe "model coverage matrix" do
13
+ MODEL_MATRIX = { # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
14
+ "document order and children listing" => {
15
+ pin: "spec/consistency/adapter_parity_spec.rb",
16
+ },
17
+ "name resolution" => {
18
+ pin: "spec/consistency/xpath_idioms_spec.rb",
19
+ },
20
+ "namespace resolution and bindings normalization" => {
21
+ pin: "spec/consistency/xpath_idioms_spec.rb",
22
+ },
23
+ "attribute access" => {
24
+ pin: "spec/moxml/attribute_resolver_spec.rb",
25
+ },
26
+ "text/content/raw_content" => {
27
+ pin: "spec/moxml/text_spec.rb",
28
+ },
29
+ "parsing modes and diagnostics" => {
30
+ pin: "spec/moxml/document_parse_diagnostics_spec.rb",
31
+ },
32
+ "EOL normalization" => {
33
+ pin: "spec/consistency/eol_normalization_spec.rb",
34
+ exempt: { oga: "D-02", ox: "D-03" },
35
+ },
36
+ "entity modes" => {
37
+ pin: "spec/moxml/entity_spec.rb",
38
+ },
39
+ "XPath evaluation and declared idioms" => {
40
+ pin: "spec/consistency/xpath_idioms_spec.rb",
41
+ exempt: { oga: "D-01" },
42
+ },
43
+ "serialization shapes (xml/html/xhtml)" => {
44
+ pin: "spec/moxml/xhtml_output_spec.rb",
45
+ },
46
+ "indentation knobs" => {
47
+ pin: "spec/moxml/to_html_spec.rb",
48
+ exempt: { oga: "D-09", ox: "D-09", headed_ox: "D-09",
49
+ libxml: "D-09", leptris: "D-09" },
50
+ },
51
+ "canonicalization" => {
52
+ pin: "spec/moxml/c14n/adapter_delegation_spec.rb",
53
+ exempt: { oga: "D-04", ox: "D-05", headed_ox: "D-05" },
54
+ },
55
+ "XSLT" => {
56
+ pin: "spec/consistency/xslt_spec.rb",
57
+ loud: { oga: "Moxml::NotImplementedError",
58
+ ox: "Moxml::NotImplementedError",
59
+ headed_ox: "Moxml::NotImplementedError",
60
+ rexml: "Moxml::NotImplementedError",
61
+ libxml: "Moxml::NotImplementedError" },
62
+ },
63
+ "SAX" => {
64
+ pin: "spec/moxml/sax_spec.rb",
65
+ },
66
+ "materialization" => {
67
+ pin: "spec/moxml/plan_conformance_spec.rb",
68
+ },
69
+ "loud capability contracts" => {
70
+ pin: "spec/consistency/loud_degradation_spec.rb",
71
+ },
72
+ }.freeze
73
+
74
+ def register_ids
75
+ File.readlines("docs/_pages/divergences.adoc")
76
+ .filter_map { |line| line[/\A\|\s*(D-\d+)\s*$/, 1] }
77
+ .uniq
78
+ end
79
+
80
+ it "governs every face family with a pin" do
81
+ unpinned = MODEL_MATRIX.reject { |_, cell| cell[:pin] }.keys
82
+ expect(unpinned).to be_empty,
83
+ "ungoverned face families (no pin): " \
84
+ "#{unpinned.join(', ')}"
85
+ end
86
+
87
+ it "points every pin at an existing spec" do
88
+ missing = MODEL_MATRIX.filter_map do |family, cell|
89
+ family unless cell[:pin] && File.exist?(cell[:pin])
90
+ end
91
+ expect(missing).to be_empty, "pins missing on disk: #{missing.join(', ')}"
92
+ end
93
+
94
+ it "exempts adapters only via registered divergences" do
95
+ unknown = MODEL_MATRIX.flat_map do |family, cell|
96
+ (cell[:exempt] || {}).filter_map do |adapter, id|
97
+ "#{family}: #{adapter} -> #{id}" unless register_ids.include?(id)
98
+ end
99
+ end
100
+ expect(unknown).to be_empty,
101
+ "exemptions cite unregistered IDs: #{unknown.join('; ')}"
102
+ end
103
+
104
+ it "declares loud cells only with named Moxml errors" do
105
+ bad = MODEL_MATRIX.flat_map do |family, cell|
106
+ (cell[:loud] || {}).filter_map do |adapter, error|
107
+ "#{family}: #{adapter} -> #{error}" unless error.start_with?("Moxml::")
108
+ end
109
+ end
110
+ expect(bad).to be_empty,
111
+ "loud cells must name Moxml errors: #{bad.join('; ')}"
112
+ end
113
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Checker integrity (TODO.model/06): top-level constant names in spec
6
+ # files all resolve through Object inside example blocks, so the same
7
+ # name defined by two files makes the last-loaded file win — the 0.5.143
8
+ # CORPUS collision made the battery fail ~50% of runs with a foreign
9
+ # corpus. This guard kills the class.
10
+ RSpec.describe "spec-file top-level constant uniqueness" do
11
+ it "defines each constant name in at most one spec/support file" do
12
+ seen = {}
13
+
14
+ Dir["spec/**/*.rb"].each do |path|
15
+ File.readlines(path).each_with_index do |line, i|
16
+ match = line.match(/\A\s*([A-Z][A-Z0-9_]*)\s*=[^=]/)
17
+ next unless match
18
+
19
+ name = match[1]
20
+ expect(seen)
21
+ .not_to have_key(name),
22
+ "#{name} defined at #{seen[name]} and #{path}:#{i + 1} " \
23
+ "— constants inside example blocks collide through " \
24
+ "Object; give one file a unique name"
25
+ seen[name] = "#{path}:#{i + 1}"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -42,7 +42,7 @@ RSpec.describe "XPath idioms across adapters" do
42
42
  if adapter_name == :oga
43
43
  # oga's own Ruby XPath engine has no xmlns: default-ns
44
44
  # support (third-party; explicit bindings work).
45
- skip "oga's XPath engine does not implement the xmlns: idiom"
45
+ skip "D-01: oga's XPath engine does not implement the xmlns: idiom"
46
46
  end
47
47
  end
48
48
 
@@ -130,4 +130,33 @@ RSpec.describe "leptris Identity contract reads" do
130
130
  expect(el.text).to eq("v")
131
131
  end
132
132
  end
133
+
134
+ describe "children" do
135
+ it "answers without the adapter children crossing (children_read)" do
136
+ unless Leptris::XML::NativeNode.method_defined?(:children_read)
137
+ skip "binding #{Leptris::VERSION} has no children_read face"
138
+ end
139
+ doc = ctx.parse("<root><item>v</item></root>")
140
+ el = doc.root.children.first
141
+
142
+ singleton = Moxml::Adapter::Leptris.singleton_class
143
+ singleton.send(:alias_method, :children_without_walk, :children)
144
+ singleton.send(:define_method, :children) { |*| raise "adapter children crossing" }
145
+ begin
146
+ kids = el.children
147
+ expect(kids.to_a.map(&:name)).to eq(["text"])
148
+ ensure
149
+ singleton.send(:alias_method, :children, :children_without_walk)
150
+ singleton.send(:remove_method, :children_without_walk)
151
+ end
152
+ end
153
+
154
+ it "returns a NodeSet carrying the contract wrappers" do
155
+ doc = ctx.parse("<root><item>v</item></root>")
156
+ kids = doc.root.children.first.children
157
+ expect(kids).to be_a(Moxml::NodeSet)
158
+ expect(kids.to_a.first.name).to eq("text")
159
+ expect(kids.to_a.first.content).to eq("v")
160
+ end
161
+ end
133
162
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Issue #401: Text#content= on a detached (parentless) node answered
6
+ # the engine's bare "Invalid argument" with no indication of the cause.
7
+ # The leptris adapter now re-raises it as a named Moxml error carrying
8
+ # the detachment. Created-unattached nodes work (probe), so only the
9
+ # failing detachment is re-raised.
10
+ RSpec.describe "detached text content= names the cause" do
11
+ let(:ctx) { Moxml.new(:leptris) }
12
+
13
+ it "answers removed text nodes: mutable on fixed engines (1.9.374+), named error before" do
14
+ doc = ctx.parse("<r><a>x</a><b>y</b></r>")
15
+ text = doc.root.children[0].children[0]
16
+ text.remove
17
+
18
+ if Gem::Version.new(Leptris::VERSION) >= Gem::Version.new("1.9.374")
19
+ # leptris/leptris#1785 fixed the engine root cause: detached
20
+ # text resolves its owning document, so the mutation succeeds.
21
+ text.content = "z"
22
+ expect(text.content).to eq("z")
23
+ else
24
+ expect { text.content = "z" }
25
+ .to raise_error(Moxml::Error, /detached \(parentless\).*issue #401/)
26
+ end
27
+ end
28
+
29
+ it "keeps created-unattached nodes mutable" do
30
+ text = ctx.create_document.create_text("x")
31
+ text.content = "y"
32
+ expect(text.content).to eq("y")
33
+ end
34
+
35
+ it "keeps attached nodes mutable" do
36
+ doc = ctx.parse("<r><a>x</a></r>")
37
+ text = doc.root.children[0].children[0]
38
+ text.content = "z"
39
+ expect(text.content).to eq("z")
40
+ end
41
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Issue #404: Context#parse_html forwards collect_errors (and mode) to
6
+ # the leptris flags entry (leptris#1771's §13.2.5 parse-lint) — the
7
+ # recorded error tokens surface through parse_diagnostics. Face-probed:
8
+ # bindings predating the kwarg skip.
9
+ RSpec.describe "parse_html collect_errors forwarding" do
10
+ let(:ctx) { Moxml.new(:leptris) }
11
+ let(:src) { %(<p class="x>unclosed</p><a href=><p>a<b</p>) }
12
+
13
+ before do
14
+ adapter = ctx.config.adapter
15
+ supported = adapter.singleton_class.const_get(:COLLECT_ERRORS_NATIVE)
16
+ skip "binding lacks the collect_errors kwarg (leptris#1771)" unless supported
17
+ end
18
+
19
+ it "surfaces parse-lint errors through parse_diagnostics" do
20
+ doc = ctx.parse_html(src, collect_errors: true)
21
+ diags = doc.parse_diagnostics
22
+ expect(diags).not_to be_empty
23
+ expect(diags.first).to have_key(:message)
24
+ end
25
+
26
+ # Issue #408: the #1771 reader fields ride the diag hash — the
27
+ # token code and position canon's HtmlValidator maps onto
28
+ # validation errors (nil where the engine has no token code).
29
+ it "carries the token code and position (issue #408)" do
30
+ doc = ctx.parse_html(src, collect_errors: true)
31
+ eof = doc.parse_diagnostics.find { |d| d[:message].include?("EOF") }
32
+ expect(eof).not_to be_nil
33
+ expect(eof[:code]).to eq("eof-in-tag")
34
+ expect(eof[:line]).to eq(1)
35
+ expect(eof[:column]).to eq(1)
36
+ end
37
+
38
+ it "keeps the default lane silent (no option, no diagnostics)" do
39
+ doc = ctx.parse_html(src)
40
+ expect(doc.parse_diagnostics).to be_empty
41
+ end
42
+
43
+ it "forwards through Context#parse_html options" do
44
+ doc = ctx.parse_html(src, { collect_errors: true })
45
+ expect(doc.parse_diagnostics).not_to be_empty
46
+ end
47
+ end
@@ -343,13 +343,13 @@ RSpec.shared_examples "xml adapter" do
343
343
  it "respects indentation settings" do
344
344
  if described_class.name.include?("Oga") &&
345
345
  !described_class.indents_output?
346
- pending("Oga does not support indentation settings")
346
+ pending("D-09: Oga does not support indentation settings")
347
347
  end
348
348
  if described_class.name.include?("Libxml")
349
- skip("LibXML serialization does not support indentation (documented limitation)")
349
+ skip("D-09: LibXML serialization does not support indentation (documented limitation)")
350
350
  end
351
351
  if described_class.name.include?("Ox")
352
- skip("Ox does not support indentation settings (documented limitation)")
352
+ skip("D-09: Ox does not support indentation settings (documented limitation)")
353
353
  end
354
354
 
355
355
  unindented = described_class.serialize(doc, indent: 0)
@@ -58,9 +58,9 @@ RSpec.describe "Moxml::C14n cross-adapter canonicalization" do
58
58
 
59
59
  describe "document-level nodes keep document order" do
60
60
  # oga surfaces the XML declaration as a <?xml?> PI and keeps the
61
- # separator space in PI content; ox/headed_ox drop PI content on
62
- # parse. Both are pre-existing parse-layer divergences, out of
63
- # scope here.
61
+ # separator space in PI content (D-04); ox/headed_ox drop PI
62
+ # content on parse (D-05). Both are pre-existing parse-layer
63
+ # divergences, out of scope here.
64
64
  let(:adapters) { Moxml::Adapter::AVAILABLE_ADAPTERS - %i[oga ox headed_ox] }
65
65
 
66
66
  it "canonicalizes a leading PI before the root element" do
@@ -53,13 +53,13 @@ RSpec.describe "C14N comments and PIs" do
53
53
  # PIs, and the DataModel.from_document path drops them. Fixing requires
54
54
  # adapter-level work and is tracked separately.
55
55
  it "renders PIs outside the document element (KNOWN LIMITATION)" do
56
- skip "document-level PI rendering not yet implemented"
56
+ skip "D-08: document-level PI rendering not yet implemented"
57
57
  end
58
58
  end
59
59
 
60
60
  describe "comments at document level" do
61
61
  it "renders document-level comments when with_comments: true (KNOWN LIMITATION)" do
62
- skip "document-level comment rendering not yet implemented"
62
+ skip "D-08: document-level comment rendering not yet implemented"
63
63
  end
64
64
  end
65
65
  end
@@ -63,7 +63,7 @@ RSpec.describe "Moxml::C14n subset canonicalization" do
63
63
  next if label == "text selection" && adapter_name == :rexml
64
64
 
65
65
  # rexml's adapter xpath does not return text nodes for
66
- # text() node tests (pre-existing capability gap).
66
+ # text() node tests (D-07, pre-existing capability gap).
67
67
 
68
68
  it "canonicalizes #{label}" do
69
69
  doc = ctx.parse(xml)
@@ -72,9 +72,9 @@ RSpec.describe "Moxml::C14n subset canonicalization" do
72
72
  end
73
73
 
74
74
  # ox/headed_ox strip comment content padding at the parse
75
- # layer (pre-existing, same family as their PI content loss);
76
- # only the rendered-comment case depends on it. rexml's
77
- # comment() selection gap also only affects this case.
75
+ # layer (D-06, pre-existing, same family as their PI content
76
+ # loss); only the rendered-comment case depends on it. rexml's
77
+ # comment() selection gap (D-07) also only affects this case.
78
78
  unless COMMENTS_PADDING_LOSS.include?(adapter_name) ||
79
79
  COMMENT_SELECTION_GAP.include?(adapter_name)
80
80
  it COMMENTS_CASE[0] do
@@ -55,7 +55,8 @@ RSpec.describe "Moxml::C14n W3C REC-xml-c14n examples" do
55
55
 
56
56
  W3C_FULL_FORMS.each_with_index do |(label, xml, expected), idx|
57
57
  # ox/headed_ox strip inter-element whitespace, expand &#xD;
58
- # and pad comments at parse; oga keeps the PI separator space.
58
+ # and pad comments at parse (D-05, D-06); oga keeps the PI
59
+ # separator space (D-04).
59
60
  skip_shapes = { ox: [0, CR_SHAPE, PI_SHAPE],
60
61
  headed_ox: [0, CR_SHAPE, PI_SHAPE],
61
62
  oga: [PI_SHAPE] }[adapter_name] || []
@@ -72,6 +73,7 @@ RSpec.describe "Moxml::C14n W3C REC-xml-c14n examples" do
72
73
 
73
74
  describe "W3C §3 document-subset whitespace" do
74
75
  Moxml::Adapter::AVAILABLE_ADAPTERS.each do |adapter_name|
76
+ # ox/headed_ox strip inter-element whitespace at parse (D-06)
75
77
  next if WHITESPACE_SHAPES.include?(adapter_name)
76
78
 
77
79
  it "renders selected nodes only, whitespace runs in place (#{adapter_name})" do
@@ -4,9 +4,9 @@ require "spec_helper"
4
4
 
5
5
  RSpec.describe "XML Declaration Preservation" do
6
6
  # Test with all available adapters
7
- ADAPTERS = %i[nokogiri oga rexml ox libxml headed_ox].freeze
7
+ DECLARATION_SPECS_ADAPTERS = %i[nokogiri oga rexml ox libxml headed_ox].freeze
8
8
 
9
- ADAPTERS.each do |adapter_name|
9
+ DECLARATION_SPECS_ADAPTERS.each do |adapter_name|
10
10
  context "with #{adapter_name} adapter" do
11
11
  let(:context) { Moxml.new(adapter_name) }
12
12