rng 0.3.7 → 0.3.9

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.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'nokogiri'
3
+ require 'moxml'
4
4
  require 'uri'
5
5
 
6
6
  module Rng
@@ -22,7 +22,7 @@ module Rng
22
22
  end
23
23
 
24
24
  # Validates raw XML against RELAX NG structural rules.
25
- # Operates on the Nokogiri XML tree BEFORE Lutaml deserialization,
25
+ # Operates on the Moxml XML tree BEFORE Lutaml deserialization,
26
26
  # because Lutaml silently drops unmapped content.
27
27
  class SchemaValidator
28
28
  RNG_NS = 'http://relaxng.org/ns/structure/1.0'
@@ -31,6 +31,8 @@ module Rng
31
31
  LEAF_ELEMENTS = %w[empty text notAllowed ref parentRef value].freeze
32
32
  OBSOLETE_ELEMENTS = %w[not difference key keyRef].freeze
33
33
  OBSOLETE_ATTRS = %w[key keyRef global].freeze
34
+ OBSOLETE_ATTR_CONTEXTS = [%w[start name], %w[attribute global], %w[data key],
35
+ %w[data keyRef]].freeze
34
36
  NAME_REQUIRED = %w[element attribute].freeze
35
37
  CONTAINER_ELEMENTS = %w[group choice interleave optional zeroOrMore oneOrMore list mixed define start].freeze
36
38
  VALID_ROOT_ELEMENTS = %w[grammar element group choice interleave notAllowed externalRef data].freeze
@@ -66,7 +68,7 @@ module Rng
66
68
 
67
69
  class << self
68
70
  def validate(xml_input)
69
- doc = Nokogiri::XML(xml_input)
71
+ doc = Moxml::Context.new.parse(xml_input)
70
72
  root = doc.root
71
73
  report_error('Document has no root element') unless root
72
74
  validator = new
@@ -75,7 +77,7 @@ module Rng
75
77
  end
76
78
 
77
79
  def validate_all(xml_input)
78
- doc = Nokogiri::XML(xml_input)
80
+ doc = Moxml::Context.new.parse(xml_input)
79
81
  root = doc.root
80
82
  return [SchemaValidationError.new('Document has no root element')] unless root
81
83
 
@@ -89,7 +91,7 @@ module Rng
89
91
  end
90
92
 
91
93
  def valid?(xml_input)
92
- doc = Nokogiri::XML(xml_input)
94
+ doc = Moxml::Context.new.parse(xml_input)
93
95
  root = doc.root
94
96
  return false unless root
95
97
 
@@ -113,9 +115,9 @@ module Rng
113
115
  end
114
116
 
115
117
  def validate_node(node, parent_context: nil)
116
- return unless node.is_a?(Nokogiri::XML::Element)
118
+ return unless node.is_a?(Moxml::Element)
117
119
 
118
- ns = node.namespace&.href
120
+ ns = node.namespace&.uri
119
121
  local_name = node.name
120
122
  is_root = node.parent&.document&.root == node
121
123
  xpath = node.path.delete_prefix('/')
@@ -192,15 +194,15 @@ module Rng
192
194
  validate_oneOrMore_infinite_attribute_name(local_name, node, xpath)
193
195
 
194
196
  # Recurse
195
- node.element_children.each do |child|
196
- next if child.namespace&.href == ANNOTATIONS_NS
197
+ node.children.select(&:element?).each do |child|
198
+ next if child.namespace&.uri == ANNOTATIONS_NS
197
199
 
198
200
  validate_node(child, parent_context: context_for_child(local_name, node))
199
201
  end
200
202
  end
201
203
 
202
204
  def report_error(message, xpath: nil, node: nil)
203
- line = node&.line if node
205
+ line = node&.line_number
204
206
  error = SchemaValidationError.new(message, xpath: xpath, line: line)
205
207
  raise error unless @collect_all
206
208
 
@@ -211,7 +213,7 @@ module Rng
211
213
  private
212
214
 
213
215
  def rng_ns_children(node)
214
- node.element_children.select { |c| (c.namespace&.href == RNG_NS) || (c.namespace.nil? && !c.name.empty?) }
216
+ node.children.select(&:element?).select { |c| (c.namespace&.uri == RNG_NS) || (c.namespace.nil? && !c.name.empty?) }
215
217
  end
216
218
 
217
219
  def context_for_child(parent_name, _parent_node)
@@ -227,7 +229,7 @@ module Rng
227
229
  current = node.parent
228
230
  while current
229
231
  return current if current.name == name &&
230
- (current.namespace&.href == RNG_NS || (current.namespace.nil? && !current.name.empty?))
232
+ (current.namespace&.uri == RNG_NS || (current.namespace.nil? && !current.name.empty?))
231
233
 
232
234
  current = current.parent
233
235
  end
@@ -436,21 +438,13 @@ module Rng
436
438
  end
437
439
 
438
440
  def validate_obsolete_attrs(node, xpath)
439
- node.attributes.each do |name, attr|
440
- attr_ns = attr.namespace&.href
441
- if attr_ns == RNG_NS && OBSOLETE_ATTRS.include?(name)
442
- report_error("Attribute '#{name}' on '#{node.name}' is obsolete",
443
- xpath: xpath)
444
- end
441
+ node.attributes.each do |attr|
442
+ attr_ns = attr.namespace&.uri
443
+ report_error("Attribute '#{attr.name}' on '#{node.name}' is obsolete", xpath: xpath) if attr_ns == RNG_NS && OBSOLETE_ATTRS.include?(attr.name)
445
444
  next unless attr_ns.nil? && attr.namespace&.prefix.nil?
445
+ next unless OBSOLETE_ATTR_CONTEXTS.include?([node.name, attr.name])
446
446
 
447
- report_error("Attribute 'name' on 'start' is obsolete", xpath: xpath) if name == 'name' && node.name == 'start'
448
- if name == 'global' && node.name == 'attribute'
449
- report_error("Attribute 'global' on 'attribute' is obsolete",
450
- xpath: xpath)
451
- end
452
- report_error("Attribute '#{name}' on 'data' is obsolete", xpath: xpath) if %w[key
453
- keyRef].include?(name) && node.name == 'data'
447
+ report_error("Attribute '#{attr.name}' on '#{node.name}' is obsolete", xpath: xpath)
454
448
  end
455
449
  end
456
450
 
@@ -529,7 +523,7 @@ module Rng
529
523
  return false unless node
530
524
 
531
525
  current = node
532
- while current.is_a?(Nokogiri::XML::Element)
526
+ while current.is_a?(Moxml::Element)
533
527
  current.namespace_definitions.each do |ns|
534
528
  return true if ns.prefix == prefix
535
529
  end
@@ -758,7 +752,7 @@ module Rng
758
752
  def validate_name_value_purity(local_name, node, xpath)
759
753
  return unless %w[name value].include?(local_name)
760
754
 
761
- return unless node.element_children.any?
755
+ return unless node.children.any?(&:element?)
762
756
 
763
757
  report_error("'#{local_name}' must not contain child elements", xpath: xpath)
764
758
  end
@@ -827,8 +821,9 @@ module Rng
827
821
  def validate_leaf_no_attrs(local_name, node, xpath)
828
822
  return unless NO_ATTR_LEAF_ELEMENTS.include?(local_name)
829
823
 
830
- node.attributes.each do |attr_name, attr|
831
- attr_ns = attr.namespace&.href
824
+ node.attributes.each do |attr|
825
+ attr_name = attr.name
826
+ attr_ns = attr.namespace&.uri
832
827
  next if attr_ns && attr_ns != RNG_NS
833
828
  next if attr_name == 'xmlns' || attr_name.start_with?('xmlns:')
834
829
 
@@ -838,10 +833,11 @@ module Rng
838
833
  end
839
834
 
840
835
  def validate_unknown_attrs(node, xpath)
841
- node.attributes.each do |attr_name, attr|
836
+ node.attributes.each do |attr|
837
+ attr_name = attr.name
842
838
  next if attr_name == 'xmlns'
843
839
 
844
- attr_ns = attr.namespace&.href
840
+ attr_ns = attr.namespace&.uri
845
841
  if (attr_ns == RNG_NS) && !known_rng_attr?(
846
842
  node.name, attr_name
847
843
  )
@@ -1407,9 +1403,9 @@ module Rng
1407
1403
 
1408
1404
  def collect_all_refs(node)
1409
1405
  refs = []
1410
- return refs unless node.is_a?(Nokogiri::XML::Element)
1406
+ return refs unless node.is_a?(Moxml::Element)
1411
1407
 
1412
- ns = node.namespace&.href
1408
+ ns = node.namespace&.uri
1413
1409
  local = node.name
1414
1410
  if ns == RNG_NS || (ns.nil? && !local.empty?)
1415
1411
  case local
@@ -1418,7 +1414,7 @@ module Rng
1418
1414
  when 'parentRef'
1419
1415
  refs << { name: node['name'], type: 'parentRef' } if node['name']
1420
1416
  end
1421
- node.element_children.each do |child|
1417
+ node.children.select(&:element?).each do |child|
1422
1418
  refs.concat(collect_all_refs(child))
1423
1419
  end
1424
1420
  end
@@ -1444,9 +1440,9 @@ module Rng
1444
1440
 
1445
1441
  def collect_direct_refs(node)
1446
1442
  refs = []
1447
- return refs unless node.is_a?(Nokogiri::XML::Element)
1443
+ return refs unless node.is_a?(Moxml::Element)
1448
1444
 
1449
- ns = node.namespace&.href
1445
+ ns = node.namespace&.uri
1450
1446
  local = node.name
1451
1447
  if ns == RNG_NS || (ns.nil? && !local.empty?)
1452
1448
  case local
@@ -1455,7 +1451,7 @@ module Rng
1455
1451
  when 'parentRef'
1456
1452
  refs << node['name'] if node['name']
1457
1453
  end
1458
- node.element_children.each do |child|
1454
+ node.children.select(&:element?).each do |child|
1459
1455
  next if child.name == 'define' # Don't recurse into nested grammar's defines
1460
1456
 
1461
1457
  refs.concat(collect_direct_refs(child))
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'nokogiri'
3
+ require 'moxml'
4
4
 
5
5
  module Rng
6
6
  # Parses RELAX NG test suite XML files (like compacttest.xml)
@@ -53,7 +53,7 @@ module Rng
53
53
  attr_reader :test_cases
54
54
 
55
55
  def initialize(xml_content)
56
- @doc = Nokogiri::XML(xml_content)
56
+ @doc = Moxml::Context.new.parse(xml_content)
57
57
  @test_cases = []
58
58
  parse_test_cases
59
59
  end
@@ -138,7 +138,7 @@ module Rng
138
138
  xml_node.xpath('resource').each do |resource_node|
139
139
  name = resource_node['name']
140
140
  # Get the first element child as RNG content
141
- content_node = resource_node.elements.first
141
+ content_node = resource_node.children.find(&:element?)
142
142
  content = content_node ? content_node.to_xml : ''
143
143
  test_case.xml_resources[name] = content
144
144
  end
@@ -147,7 +147,7 @@ module Rng
147
147
  correct_node = xml_node.at_xpath('correct')
148
148
  if correct_node
149
149
  # Get the first element child as RNG content
150
- rng_node = correct_node.elements.first
150
+ rng_node = correct_node.children.find(&:element?)
151
151
  test_case.instance_variable_set(
152
152
  :@xml_correct,
153
153
  rng_node ? rng_node.to_xml : ''
@@ -158,7 +158,7 @@ module Rng
158
158
  incorrect_node = xml_node.at_xpath('incorrect')
159
159
  return unless incorrect_node
160
160
 
161
- rng_node = incorrect_node.elements.first
161
+ rng_node = incorrect_node.children.find(&:element?)
162
162
  test_case.instance_variable_set(
163
163
  :@xml_incorrect,
164
164
  rng_node ? rng_node.to_xml : ''
data/lib/rng/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rng
4
- VERSION = '0.3.7'
4
+ VERSION = '0.3.9'
5
5
  end
data/lib/rng.rb CHANGED
@@ -39,7 +39,6 @@ module Rng
39
39
  autoload :Grammar, File.expand_path('rng/grammar', __dir__)
40
40
  autoload :Documentation, File.expand_path('rng/documentation', __dir__)
41
41
  autoload :TestSuiteParser, File.expand_path('rng/test_suite_parser', __dir__)
42
- autoload :Value, File.expand_path('rng/value', __dir__)
43
42
 
44
43
  # Autoload parser support classes
45
44
  autoload :ParseTreeProcessor, File.expand_path('rng/parse_tree_processor', __dir__)
@@ -57,7 +56,6 @@ module Rng
57
56
 
58
57
  # Autoload parsers and builders
59
58
  autoload :RncParser, File.expand_path('rng/rnc_parser', __dir__)
60
- autoload :ParseRnc, File.expand_path('rng/parse_rnc', __dir__)
61
59
  autoload :ToRnc, File.expand_path('rng/to_rnc', __dir__)
62
60
  autoload :RncBuilder, File.expand_path('rng/rnc_builder', __dir__)
63
61
  autoload :IncludeProcessor, File.expand_path('rng/include_processor', __dir__)
@@ -67,13 +65,17 @@ module Rng
67
65
  def parse(rng, location: nil, nested_schema: false, validate: false, resolve_external: false)
68
66
  SchemaValidator.validate(rng) if validate && !nested_schema
69
67
  grammar = Grammar.from_xml(rng)
70
- return grammar unless resolve_external
68
+
69
+ unless resolve_external
70
+ grammar.warnings = ExternalRefResolver.unresolved_ref_hrefs(grammar)
71
+ return grammar
72
+ end
71
73
 
72
74
  ExternalRefResolver.new(grammar, location: location).resolve
73
75
  end
74
76
 
75
- def self.parse_rnc(rnc)
76
- ParseRnc.parse(rnc)
77
+ def self.parse_rnc(rnc, location: nil)
78
+ IncludeProcessor.new.parse_content(rnc, location: location)
77
79
  end
78
80
 
79
81
  def self.parse_file(file_path)
data/rng.gemspec CHANGED
@@ -29,8 +29,8 @@ Gem::Specification.new do |spec|
29
29
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
30
30
  spec.require_paths = ['lib']
31
31
 
32
- spec.add_dependency 'lutaml-model', '~> 0.8.0'
33
- spec.add_dependency 'nokogiri'
32
+ spec.add_dependency 'lutaml-model', '~> 0.8.114'
33
+ spec.add_dependency 'moxml'
34
34
  spec.add_dependency 'parslet'
35
35
 
36
36
  spec.add_dependency 'thor'
@@ -0,0 +1,13 @@
1
+ <grammar xmlns="http://relaxng.org/ns/structure/1.0">
2
+ <start>
3
+ <element name="root">
4
+ <element name="first">
5
+ <empty/>
6
+ </element>
7
+ <attribute name="mid"/>
8
+ <element name="last">
9
+ <empty/>
10
+ </element>
11
+ </element>
12
+ </start>
13
+ </grammar>
@@ -0,0 +1,3 @@
1
+ <grammar xmlns="http://relaxng.org/ns/structure/1.0">
2
+ <include href="order_lib.rng"/>
3
+ </grammar>
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'spec_helper'
4
4
  require 'rng'
5
+ require 'parslet'
5
6
 
6
7
  RSpec.describe 'Advanced RNC features' do
7
8
  describe 'Name wildcards' do
@@ -98,4 +99,126 @@ RSpec.describe 'Advanced RNC features' do
98
99
  expect { Rng.parse_rnc(rnc) }.not_to raise_error
99
100
  end
100
101
  end
102
+
103
+ describe 'Content-position annotations' do
104
+ it 'parses a bracket annotation before an attribute in content' do
105
+ rnc = 'start = element n { [ a:defaultValue = "x" ] attribute foo { text } }'
106
+ expect { Rng.parse_rnc(rnc) }.not_to raise_error
107
+ end
108
+
109
+ it 'parses a bracket annotation before a nested element in content' do
110
+ rnc = 'start = element n { [ a:documentation [ "hi" ] ] element c { text } }'
111
+ expect { Rng.parse_rnc(rnc) }.not_to raise_error
112
+ end
113
+
114
+ it 'parses an annotated attribute within a sequence' do
115
+ rnc = 'start = element n { [ a:x = "1" ] attribute foo { text }, ' \
116
+ 'attribute bar { text } }'
117
+ grammar = Rng.parse_rnc(rnc)
118
+ attrs = grammar.start.first.element.attribute.map(&:attr_name)
119
+ expect(attrs).to eq(%w[foo bar])
120
+ end
121
+
122
+ it 'leaves un-annotated content parsing unchanged' do
123
+ rnc = 'start = element n { attribute foo { text } }'
124
+ expect { Rng.parse_rnc(rnc) }.not_to raise_error
125
+ end
126
+
127
+ it 'does not let brackets inside an annotation string close it early' do
128
+ rnc = 'start = element n { [ a:x = "a]b[c" ] attribute foo { text }, ' \
129
+ 'attribute bar { text } }'
130
+ grammar = Rng.parse_rnc(rnc)
131
+ attrs = grammar.start.first.element.attribute.map(&:attr_name)
132
+ expect(attrs).to eq(%w[foo bar])
133
+ end
134
+
135
+ it 'ignores brackets inside a single-quoted annotation string' do
136
+ rnc = "start = element n { [ a:x = 'a]b[c' ] attribute foo { text }, " \
137
+ 'attribute bar { text } }'
138
+ grammar = Rng.parse_rnc(rnc)
139
+ attrs = grammar.start.first.element.attribute.map(&:attr_name)
140
+ expect(attrs).to eq(%w[foo bar])
141
+ end
142
+
143
+ it 'ignores brackets inside a triple-quoted annotation string' do
144
+ rnc = 'start = element n { [ a:doc [ """multi]line[x""" ] ] ' \
145
+ 'attribute foo { text }, attribute bar { text } }'
146
+ grammar = Rng.parse_rnc(rnc)
147
+ attrs = grammar.start.first.element.attribute.map(&:attr_name)
148
+ expect(attrs).to eq(%w[foo bar])
149
+ end
150
+
151
+ it 'drops an annotation before a non-leading definition without corrupting it' do
152
+ rnc = "foo = element foo { text }\n" \
153
+ "[ a:defaultValue = \"x\" ]\n" \
154
+ "bar = element bar { text }\n" \
155
+ 'start = foo'
156
+ grammar = Rng.parse_rnc(rnc)
157
+
158
+ expect(grammar.define.map(&:name)).to contain_exactly('foo', 'bar')
159
+ end
160
+
161
+ it 'rejects a control character inside an annotation string, like a value string' do
162
+ rnc = "start = element n { [ a:x = \"#{1.chr}\" ] " \
163
+ 'attribute foo { text } }'
164
+ expect { Rng.parse_rnc(rnc) }.to raise_error(Parslet::ParseFailed)
165
+ end
166
+ end
167
+
168
+ describe 'Sequence separators' do
169
+ it 'rejects comma-less sequence items so they cannot swallow the next definition' do
170
+ rnc = 'start = element n { attribute foo { text } attribute bar { text } }'
171
+ expect { Rng.parse_rnc(rnc) }.to raise_error(Parslet::ParseFailed)
172
+ end
173
+
174
+ it 'parses comma-separated sequence items in element content' do
175
+ rnc = 'start = element n { attribute foo { text }, attribute bar { text } }'
176
+ grammar = Rng.parse_rnc(rnc)
177
+ attrs = grammar.start.first.element.attribute.map(&:attr_name)
178
+ expect(attrs).to eq(%w[foo bar])
179
+ end
180
+ end
181
+
182
+ describe 'Non-leading start' do
183
+ it 'produces a real <start> for a start = element after other defines' do
184
+ rnc = "item = element item { text }\nstart = element root { item* }"
185
+ grammar = Rng.parse_rnc(rnc)
186
+
187
+ expect(grammar.start.first.element.attr_name).to eq('root')
188
+ expect(grammar.define.map(&:name)).to eq(['item'])
189
+ end
190
+
191
+ it 'produces a real <start> for a non-leading start = ref' do
192
+ rnc = "library = element library { text }\nstart = library"
193
+ grammar = Rng.parse_rnc(rnc)
194
+
195
+ expect(grammar.start.first.ref.name).to eq('library')
196
+ expect(grammar.define.map(&:name)).not_to include('start')
197
+ end
198
+
199
+ it 'collects multiple start |= definitions as combining starts' do
200
+ rnc = "a = element a { empty }\nb = element b { empty }\n" \
201
+ "start |= a\nstart |= b"
202
+ grammar = Rng.parse_rnc(rnc)
203
+
204
+ expect(grammar.start.map(&:combine)).to eq(%w[choice choice])
205
+ expect(grammar.start.map { |s| s.ref.name }).to contain_exactly('a', 'b')
206
+ end
207
+
208
+ it 'maps start &= to interleave-combining starts' do
209
+ rnc = "a = element a { empty }\nb = element b { empty }\n" \
210
+ "start &= a\nstart &= b"
211
+ grammar = Rng.parse_rnc(rnc)
212
+
213
+ expect(grammar.start.map(&:combine)).to eq(%w[interleave interleave])
214
+ end
215
+
216
+ it 'keeps an escaped \\start as a define, not the grammar start' do
217
+ rnc = "x = element x { text }\n\\start = element a { text }\nstart = x"
218
+ grammar = Rng.parse_rnc(rnc)
219
+
220
+ expect(grammar.define.map(&:name)).to include('start')
221
+ expect(grammar.start.first.ref.name).to eq('x')
222
+ end
223
+ end
101
224
  end
@@ -69,6 +69,22 @@ RSpec.describe Rng::ExternalRefResolver do
69
69
  end
70
70
  end
71
71
 
72
+ context 'with interleaved children reached through an include' do
73
+ let(:order_main) { File.read('spec/fixtures/external/order_main.rng') }
74
+
75
+ it 'preserves element_order through external ref resolution' do
76
+ grammar = Rng::Grammar.from_xml(order_main)
77
+ resolved = described_class.new(grammar, location: 'spec/fixtures/external/order_main.rng').resolve
78
+
79
+ xml = resolved.to_xml
80
+ expect(xml.index('first')).to be < xml.index('mid')
81
+ expect(xml.index('mid')).to be < xml.index('last')
82
+
83
+ root = resolved.start.first.element
84
+ expect(root.instance_variable_get(:@element_order)).not_to be_nil
85
+ end
86
+ end
87
+
72
88
  context 'with non-existent external file' do
73
89
  it 'does not raise error but warns when external file not found' do
74
90
  # Create a grammar with a non-existent include
@@ -110,6 +126,13 @@ RSpec.describe Rng::ExternalRefResolver do
110
126
  # Without resolve_external, the grammar is returned as-is
111
127
  expect(grammar).to be_a(Rng::Grammar)
112
128
  end
129
+
130
+ it 'warns about unresolved external references when not resolving' do
131
+ main_rng = File.read('spec/fixtures/external/include_main.rng')
132
+ grammar = Rng.parse(main_rng)
133
+
134
+ expect(grammar.warnings).to include(a_string_matching(/include_lib\.rng/))
135
+ end
113
136
  end
114
137
 
115
138
  describe Rng::ExternalRefResolver::ExternalRefResolutionError do
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe Rng::IncludeProcessor do
7
+ # A grammar-block RNC that includes another grammar and then defines its
8
+ # own named patterns after the include. The merged grammar must carry BOTH
9
+ # the included grammar's definitions and the including file's own patterns.
10
+ # ParseTreeProcessor used to overwrite :definitions (the include merge
11
+ # result) with only :includes + :patterns, dropping every included define.
12
+ it 'keeps included definitions and own patterns in a grammar block file' do
13
+ Dir.mktmpdir do |dir|
14
+ File.write(File.join(dir, 'base.rnc'), <<~RNC)
15
+ start = doc
16
+ doc = element doc { sections }
17
+ sections = element sections { element a { text } }
18
+ RNC
19
+
20
+ File.write(File.join(dir, 'derived.rnc'), <<~RNC)
21
+ include "base.rnc" {
22
+ doc = element doc { sections }
23
+ }
24
+
25
+ start = derived
26
+ derived = element derived { sections }
27
+ note = element note { text }
28
+ RNC
29
+
30
+ grammar = Rng.parse_file(File.join(dir, 'derived.rnc'))
31
+ names = grammar.define.map(&:name)
32
+
33
+ expect(names).to include('sections')
34
+ expect(names).to include('note')
35
+ expect(names).to include('derived')
36
+ end
37
+ end
38
+
39
+ it 'applies include-block overrides, replacing the base definition' do
40
+ Dir.mktmpdir do |dir|
41
+ File.write(File.join(dir, 'base.rnc'), <<~RNC)
42
+ start = doc
43
+ doc = element doc { sections }
44
+ sections = element sections { element a { text } }
45
+ RNC
46
+
47
+ File.write(File.join(dir, 'derived.rnc'), <<~RNC)
48
+ include "base.rnc" {
49
+ sections = element sections { element b { text } }
50
+ }
51
+ RNC
52
+
53
+ grammar = Rng.parse_file(File.join(dir, 'derived.rnc'))
54
+ sections = grammar.define.find { |d| d.name == 'sections' }
55
+
56
+ expect(sections).not_to be_nil
57
+ expect(sections.to_xml).to include('name="b"')
58
+ expect(sections.to_xml).not_to include('name="a"')
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'tmpdir'
5
+
6
+ RSpec.describe 'ParseRncSpec' do
7
+ describe '.parse_rnc' do
8
+ let(:simple_rnc) { 'element foo { text }' }
9
+
10
+ it 'returns a Grammar for include-free RNC without a location' do
11
+ parsed = Rng.parse_rnc(simple_rnc)
12
+
13
+ expect(parsed).to be_a(Rng::Grammar)
14
+ expect(parsed.start.first.element.attr_name).to eq('foo')
15
+ end
16
+
17
+ it 'produces equivalent output with and without location for hex-escaped RNC' do
18
+ # \x{72}oot decodes to "root". Hex preprocessing must run on the
19
+ # with-location path or the parse tree differs from the no-location path
20
+ # for the same input — which is the divergence this spec guards against.
21
+ rnc = 'element \\x{72}oot { text }'
22
+
23
+ Dir.mktmpdir do |dir|
24
+ scratch = File.join(dir, 'scratch.rnc')
25
+
26
+ without_location = Rng.parse_rnc(rnc)
27
+ with_location = Rng.parse_rnc(rnc, location: scratch)
28
+
29
+ expect(with_location.to_xml).to be_xml_equivalent_to(without_location.to_xml)
30
+ expect(with_location.start.first.element.attr_name).to eq('root')
31
+ end
32
+ end
33
+
34
+ it 'resolves relative includes when location is the source directory' do
35
+ Dir.mktmpdir do |dir|
36
+ File.write(File.join(dir, 'child.rnc'), <<~RNC)
37
+ ChildPattern = element child { text }
38
+ start = element wrapped { ChildPattern }
39
+ RNC
40
+
41
+ parent_path = File.join(dir, 'parent.rnc')
42
+ File.write(parent_path, <<~RNC)
43
+ include "child.rnc" {
44
+ start = element root { ChildPattern }
45
+ }
46
+ RNC
47
+
48
+ parsed = Rng.parse_rnc(File.read(parent_path), location: dir)
49
+
50
+ expect(parsed.define.map(&:name)).to include('ChildPattern')
51
+ expect(parsed.start.first.element.attr_name).to eq('root')
52
+ end
53
+ end
54
+
55
+ it 'also accepts a source file path as location' do
56
+ Dir.mktmpdir do |dir|
57
+ File.write(File.join(dir, 'child.rnc'), <<~RNC)
58
+ ChildPattern = element child { text }
59
+ RNC
60
+
61
+ parent_path = File.join(dir, 'parent.rnc')
62
+ File.write(parent_path, <<~RNC)
63
+ include "child.rnc" {
64
+ start = element root { ChildPattern }
65
+ }
66
+ RNC
67
+
68
+ parsed = Rng.parse_rnc(File.read(parent_path), location: parent_path)
69
+
70
+ expect(parsed.define.map(&:name)).to include('ChildPattern')
71
+ expect(parsed.start.first.element.attr_name).to eq('root')
72
+ end
73
+ end
74
+ end
75
+ end