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.
- checksums.yaml +4 -4
- data/lib/rng/external_ref_resolver.rb +86 -157
- data/lib/rng/grammar.rb +8 -0
- data/lib/rng/include_processor.rb +64 -90
- data/lib/rng/rnc_builder.rb +3 -14
- data/lib/rng/rnc_parser.rb +36 -16
- data/lib/rng/rnc_to_rng_converter.rb +124 -114
- data/lib/rng/version.rb +1 -1
- data/lib/rng.rb +7 -5
- data/rng.gemspec +1 -1
- data/spec/fixtures/external/order_lib.rng +13 -0
- data/spec/fixtures/external/order_main.rng +3 -0
- data/spec/rng/advanced_rnc_spec.rb +123 -0
- data/spec/rng/external_ref_resolver_spec.rb +23 -0
- data/spec/rng/parse_rnc_spec.rb +75 -0
- data/spec/rng/rnc_parser_spec.rb +48 -0
- data/spec/rng/rnc_roundtrip_spec.rb +77 -0
- data/spec/rng/schema_spec.rb +18 -0
- metadata +7 -5
- data/lib/rng/parse_rnc.rb +0 -26
|
@@ -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
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1247
|
-
|
|
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
|
-
#
|
|
1252
|
-
xml
|
|
1253
|
-
|
|
1254
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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.
|
|
32
|
+
spec.add_dependency 'lutaml-model', '~> 0.8.114'
|
|
33
33
|
spec.add_dependency 'nokogiri'
|
|
34
34
|
spec.add_dependency 'parslet'
|
|
35
35
|
|
|
@@ -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
|