rng 0.3.7 → 0.3.8

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.
@@ -17,6 +17,8 @@ module Rng
17
17
  # grammar = Rng::Grammar.from_xml(rng_xml)
18
18
  class RncToRngConverter
19
19
  RNG_NAMESPACE = 'http://relaxng.org/ns/structure/1.0'
20
+ OCCURRENCE_TAGS = { '*' => 'zeroOrMore', '+' => 'oneOrMore', '?' => 'optional' }.freeze
21
+ private_constant :OCCURRENCE_TAGS
20
22
 
21
23
  # Convert a parse tree to RNG XML
22
24
  #
@@ -60,41 +62,11 @@ module Rng
60
62
  xml.parent[:datatypeLibrary] = tree[:datatype_map].values.first
61
63
  end
62
64
 
63
- # If no explicit start but we have top-level elements, wrap them in start
64
- has_explicit_start = tree[:start] && tree[:start][:start_pattern]
65
- has_top_elements = tree[:definitions]&.any? do |d|
66
- d.key?(:top_element)
67
- end
68
- has_top_choice = tree[:definitions]&.any? do |d|
69
- d.key?(:top_choice)
70
- end
71
-
72
- if has_explicit_start
73
- # Process explicit start pattern
74
- xml.start do
75
- add_documentation(xml, tree[:start]) if tree[:start][:docs]
76
- process_pattern_list(xml, tree[:start][:start_pattern])
77
- end
78
- elsif has_top_choice
79
- # No explicit start, but has top-level choice - wrap in start with choice
80
- first_choice = tree[:definitions].find { |d| d.key?(:top_choice) }
81
- xml.start do
82
- xml.choice do
83
- process_content_item(xml, first_choice[:top_choice][:first])
84
- first_choice[:top_choice][:choice_items]&.each do |item|
85
- process_content_item(xml, item)
86
- end
87
- end
88
- end
89
- elsif has_top_elements
90
- # No explicit start, but has top-level elements - wrap first one in start
91
- xml.start do
92
- first_element = tree[:definitions].find do |d|
93
- d.key?(:top_element)
94
- end
95
- process_content_item(xml, first_element[:top_element])
96
- end
97
- end
65
+ # A leading `start` (tree[:start]) or a non-leading one (emitted as
66
+ # <start> in the definitions loop) both suppress auto-wrapping.
67
+ has_explicit_start =
68
+ (tree[:start] && tree[:start][:start_pattern]) || nonleading_start?(tree)
69
+ emit_grammar_start(xml, tree)
98
70
 
99
71
  # Process named patterns and remaining top-level elements
100
72
  tree[:definitions]&.each_with_index do |def_item, idx|
@@ -162,6 +134,8 @@ module Rng
162
134
  # Include without override block
163
135
  xml.include(href: href)
164
136
  end
137
+ elsif def_item.key?(:name) && keyword_start_name?(def_item[:name])
138
+ emit_nonleading_start(xml, def_item)
165
139
  elsif def_item.key?(:name)
166
140
  # Named pattern - handle augmentation operators
167
141
  name = process_identifier(def_item[:name])
@@ -440,6 +414,66 @@ module Rng
440
414
  end
441
415
  end
442
416
 
417
+ # Emit the grammar's <start>: an explicit leading start, or (when there is
418
+ # none) auto-wrap the first top-level choice/element. A non-leading start is
419
+ # emitted by the definitions loop, so here it only suppresses auto-wrapping.
420
+ def emit_grammar_start(xml, tree)
421
+ if tree[:start] && tree[:start][:start_pattern]
422
+ xml.start do
423
+ add_documentation(xml, tree[:start]) if tree[:start][:docs]
424
+ process_pattern_list(xml, tree[:start][:start_pattern])
425
+ end
426
+ elsif nonleading_start?(tree)
427
+ # Emitted as <start> by the definitions loop.
428
+ elsif (choice = tree[:definitions]&.find { |d| d.key?(:top_choice) })
429
+ emit_start_from_top_choice(xml, choice)
430
+ elsif (element = tree[:definitions]&.find { |d| d.key?(:top_element) })
431
+ xml.start { process_content_item(xml, element[:top_element]) }
432
+ end
433
+ end
434
+
435
+ def emit_start_from_top_choice(xml, first_choice)
436
+ xml.start do
437
+ xml.choice do
438
+ process_content_item(xml, first_choice[:top_choice][:first])
439
+ first_choice[:top_choice][:choice_items]&.each do |item|
440
+ process_content_item(xml, item)
441
+ end
442
+ end
443
+ end
444
+ end
445
+
446
+ # True when a `start = X` / `start |= X` appears outside the leading
447
+ # position and was parsed as a named pattern named "start".
448
+ def nonleading_start?(tree)
449
+ tree[:definitions]&.any? do |d|
450
+ d.key?(:name) && keyword_start_name?(d[:name])
451
+ end || false
452
+ end
453
+
454
+ # True when a named-pattern name is the `start` keyword, and NOT an escaped
455
+ # identifier `\start` (a define literally named "start", which must stay a
456
+ # <define>). The escape survives as a backslash_escape identifier part.
457
+ def keyword_start_name?(name_node)
458
+ process_identifier(name_node) == 'start' && !escaped_identifier?(name_node)
459
+ end
460
+
461
+ def escaped_identifier?(name_node)
462
+ parts = name_node.is_a?(Hash) ? name_node[:identifier_parts] : nil
463
+ Array(parts).any? { |p| p.is_a?(Hash) && p.key?(:backslash_escape) }
464
+ end
465
+
466
+ # Emit a real <start> for a non-leading `start = X` / `start |= X` that was
467
+ # parsed as a named pattern (a <define name="start"> would be wrong).
468
+ def emit_nonleading_start(xml, def_item)
469
+ op = def_item[:operator] ? extract_string(def_item[:operator]) : '='
470
+ combine = { '|=' => 'choice', '&=' => 'interleave' }[op]
471
+ xml.start(combine ? { combine: combine } : {}) do
472
+ add_documentation(xml, def_item) if def_item[:docs]
473
+ process_pattern_list(xml, def_item[:pattern])
474
+ end
475
+ end
476
+
443
477
  # Collect prefixed namespace declarations from preamble items
444
478
  def collect_namespace_prefixes(preamble_items)
445
479
  return unless preamble_items
@@ -722,6 +756,13 @@ module Rng
722
756
  end
723
757
  end
724
758
 
759
+ def wrap_with_occurrence(xml, occurrence, &block)
760
+ tag = OCCURRENCE_TAGS[occurrence.to_s] if occurrence
761
+ return yield unless tag
762
+
763
+ xml.send(tag, &block)
764
+ end
765
+
725
766
  def process_content_item(xml, item)
726
767
  if item.key?(:type) && item.key?(:name)
727
768
  # Attribute definition (has both :type and :name keys)
@@ -821,19 +862,7 @@ module Rng
821
862
  end
822
863
  end
823
864
 
824
- if occurrence
825
- # Wrap in occurrence element
826
- occurrence_tag = case occurrence.to_s
827
- when '*' then 'zeroOrMore'
828
- when '+' then 'oneOrMore'
829
- when '?' then 'optional'
830
- end
831
- xml.send(occurrence_tag) do
832
- attribute_block.call(xml)
833
- end
834
- else
835
- attribute_block.call(xml)
836
- end
865
+ wrap_with_occurrence(xml, occurrence) { attribute_block.call(xml) }
837
866
  elsif item.key?(:name)
838
867
  # Element definition
839
868
  attrs = {}
@@ -939,26 +968,19 @@ module Rng
939
968
  end
940
969
  end
941
970
 
942
- if occurrence
943
- # Wrap in occurrence element
944
- occurrence_tag = case occurrence.to_s
945
- when '*' then 'zeroOrMore'
946
- when '+' then 'oneOrMore'
947
- when '?' then 'optional'
948
- end
949
-
950
- xml.send(occurrence_tag) do
951
- element_block.call(xml)
952
- end
953
- else
954
- element_block.call(xml)
955
- end
971
+ wrap_with_occurrence(xml, occurrence) { element_block.call(xml) }
956
972
  elsif item.key?(:text)
957
- xml.parent << Nokogiri::XML::Node.new('text', xml.doc)
973
+ wrap_with_occurrence(xml, item[:occurrence]) do
974
+ xml.parent << Nokogiri::XML::Node.new('text', xml.doc)
975
+ end
958
976
  elsif item.key?(:empty)
959
- xml.parent << Nokogiri::XML::Node.new('empty', xml.doc)
977
+ wrap_with_occurrence(xml, item[:occurrence]) do
978
+ xml.parent << Nokogiri::XML::Node.new('empty', xml.doc)
979
+ end
960
980
  elsif item.key?(:not_allowed)
961
- xml.parent << Nokogiri::XML::Node.new('notAllowed', xml.doc)
981
+ wrap_with_occurrence(xml, item[:occurrence]) do
982
+ xml.parent << Nokogiri::XML::Node.new('notAllowed', xml.doc)
983
+ end
962
984
  elsif item.key?(:list_content)
963
985
  xml.list do
964
986
  process_list_content(xml, item[:list_content])
@@ -968,21 +990,7 @@ module Rng
968
990
  elsif item.key?(:external_href)
969
991
  xml.externalRef(href: process_string_literal(item[:external_href]))
970
992
  elsif item.key?(:group)
971
- occurrence = item[:occurrence]
972
-
973
- if occurrence
974
- occurrence_tag = case occurrence.to_s
975
- when '*' then 'zeroOrMore'
976
- when '+' then 'oneOrMore'
977
- when '?' then 'optional'
978
- end
979
-
980
- xml.send(occurrence_tag) do
981
- xml.group do
982
- process_element_content(xml, item[:group])
983
- end
984
- end
985
- else
993
+ wrap_with_occurrence(xml, item[:occurrence]) do
986
994
  xml.group do
987
995
  process_element_content(xml, item[:group])
988
996
  end
@@ -997,28 +1005,34 @@ module Rng
997
1005
  ref_name = process_identifier(item[:ref])
998
1006
  raise StandardError, "subtraction operator '-' cannot be used as a pattern" if ref_name == '-'
999
1007
 
1000
- xml.ref(name: ref_name)
1008
+ wrap_with_occurrence(xml, item[:occurrence]) do
1009
+ xml.ref(name: ref_name)
1010
+ end
1001
1011
  elsif item.key?(:prefix) && item.key?(:type)
1002
1012
  # Datatype reference (e.g., xsd:string { maxLength = "100" })
1003
1013
  data_attrs = {
1004
1014
  type: process_identifier(item[:type]),
1005
1015
  datatypeLibrary: 'http://www.w3.org/2001/XMLSchema-datatypes'
1006
1016
  }
1007
- if item[:params]
1008
- xml.data(data_attrs) do
1009
- params = item[:params].is_a?(Array) ? item[:params] : [item[:params]]
1010
- params.each do |param|
1011
- param_name = process_identifier(param[:param_name])
1012
- param_value = process_string_literal(param[:param_value])
1013
- xml.param(param_value, name: param_name)
1017
+ wrap_with_occurrence(xml, item[:occurrence]) do
1018
+ if item[:params]
1019
+ xml.data(data_attrs) do
1020
+ params = item[:params].is_a?(Array) ? item[:params] : [item[:params]]
1021
+ params.each do |param|
1022
+ param_name = process_identifier(param[:param_name])
1023
+ param_value = process_string_literal(param[:param_value])
1024
+ xml.param(param_value, name: param_name)
1025
+ end
1014
1026
  end
1027
+ else
1028
+ xml.data(data_attrs)
1015
1029
  end
1016
- else
1017
- xml.data(data_attrs)
1018
1030
  end
1019
1031
  elsif item.key?(:value)
1020
1032
  # Value literal (string) in element content
1021
- xml.value(process_string_literal(item[:value]))
1033
+ wrap_with_occurrence(xml, item[:occurrence]) do
1034
+ xml.value(process_string_literal(item[:value]))
1035
+ end
1022
1036
  elsif item.key?(:grammar_block)
1023
1037
  # Inline grammar block
1024
1038
  grammar_data = item[:grammar_block]
@@ -1158,8 +1172,6 @@ module Rng
1158
1172
 
1159
1173
  # Process a single list content item (datatype or text with optional occurrence)
1160
1174
  def process_list_item(xml, item)
1161
- occurrence = item[:occurrence]&.to_s
1162
-
1163
1175
  item_block = lambda do |xml_ctx|
1164
1176
  if item.key?(:text)
1165
1177
  xml_ctx.parent << Nokogiri::XML::Node.new('text', xml_ctx.doc)
@@ -1179,18 +1191,7 @@ module Rng
1179
1191
  end
1180
1192
  end
1181
1193
 
1182
- if occurrence
1183
- occurrence_tag = case occurrence
1184
- when '*' then 'zeroOrMore'
1185
- when '+' then 'oneOrMore'
1186
- when '?' then 'optional'
1187
- end
1188
- xml.send(occurrence_tag) do
1189
- item_block.call(xml)
1190
- end
1191
- else
1192
- item_block.call(xml)
1193
- end
1194
+ wrap_with_occurrence(xml, item[:occurrence]) { item_block.call(xml) }
1194
1195
  end
1195
1196
 
1196
1197
  # Process name except clause for wildcards
@@ -1243,18 +1244,17 @@ module Rng
1243
1244
 
1244
1245
  # Process attribute type content (factored out for wildcard support)
1245
1246
  def process_attribute_type(xml, type_info)
1246
- if type_info == 'text' || (type_info.is_a?(Hash) && type_info.key?(:text_type))
1247
- xml.parent << Nokogiri::XML::Node.new('text', xml.doc)
1248
- elsif type_info.is_a?(Hash) && type_info.key?(:value_choice)
1249
- # Choice of value literals
1247
+ if type_info.is_a?(Hash) && type_info.key?(:attribute_choice)
1248
+ # Choice of attribute items (text / value literals / refs)
1250
1249
  xml.choice do
1251
- # First value (before the choice operator)
1252
- xml.value(process_string_literal(type_info[:value]))
1253
- # Remaining values (after | operators)
1254
- type_info[:value_choice].each do |val|
1255
- xml.value(process_string_literal(val[:value]))
1250
+ # The first item is hoisted alongside the rest of the choice
1251
+ emit_attribute_choice_item(xml, type_info)
1252
+ type_info[:attribute_choice].each do |item|
1253
+ emit_attribute_choice_item(xml, item)
1256
1254
  end
1257
1255
  end
1256
+ elsif type_info == 'text' || (type_info.is_a?(Hash) && type_info.key?(:text_type))
1257
+ xml.parent << Nokogiri::XML::Node.new('text', xml.doc)
1258
1258
  elsif type_info.is_a?(Hash) && type_info.key?(:value)
1259
1259
  # Single value literal
1260
1260
  xml.value(process_string_literal(type_info[:value]))
@@ -1282,6 +1282,16 @@ module Rng
1282
1282
  end
1283
1283
  end
1284
1284
 
1285
+ def emit_attribute_choice_item(xml, item)
1286
+ if item.key?(:text_type)
1287
+ xml.parent << Nokogiri::XML::Node.new('text', xml.doc)
1288
+ elsif item.key?(:value)
1289
+ xml.value(process_string_literal(item[:value]))
1290
+ elsif item.key?(:ref)
1291
+ xml.ref(name: process_identifier(item[:ref]))
1292
+ end
1293
+ end
1294
+
1285
1295
  # Process data parameters (e.g., maxLength = "100")
1286
1296
  def process_data_params(xml, params)
1287
1297
  params = [params] unless params.is_a?(Array)
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.8'
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,7 +29,7 @@ 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'
32
+ spec.add_dependency 'lutaml-model', '~> 0.8.114'
33
33
  spec.add_dependency 'nokogiri'
34
34
  spec.add_dependency 'parslet'
35
35
 
@@ -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,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