rng 0.3.6 → 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 +88 -158
- 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 +39 -17
- data/lib/rng/rnc_to_rng_converter.rb +125 -115
- 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 +145 -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
data/lib/rng/rnc_parser.rb
CHANGED
|
@@ -51,8 +51,9 @@ module Rng
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
# Character escapes for strings: \", \\, \n, \r, \t, and RELAX NG class escapes: \i, \c, \d, \w
|
|
54
|
+
# Any other \X sequence is preserved literally (e.g., \+, \-, \. in regex patterns)
|
|
54
55
|
rule(:char_escape) do
|
|
55
|
-
str('\\') >> match('["\\\\ntricdw]').as(:char)
|
|
56
|
+
str('\\') >> (match('["\\\\ntricdw]') | match('[^x]')).as(:char)
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
# Identifier can contain regular chars, dots, hex escapes, or backslash escapes
|
|
@@ -260,6 +261,23 @@ module Rng
|
|
|
260
261
|
(whitespace >> annotation).repeat(1)
|
|
261
262
|
end
|
|
262
263
|
|
|
264
|
+
# Match a `[ ... ]` annotation WITHOUT capturing anything (produces a raw
|
|
265
|
+
# slice that is discarded in a sequence). Strings and nested brackets are
|
|
266
|
+
# matched as raw characters so no `.as` capture turns the surrounding
|
|
267
|
+
# content item into an array. Used to drop a content-position annotation.
|
|
268
|
+
rule(:annotation_skip) do
|
|
269
|
+
str('[') >> annotation_skip_inner.repeat >> str(']')
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
rule(:annotation_skip_inner) do
|
|
273
|
+
(str('"""') >> (str('"""').absent? >> any).repeat >> str('"""')) |
|
|
274
|
+
(str("'''") >> (str("'''").absent? >> any).repeat >> str("'''")) |
|
|
275
|
+
(str('"') >> ((str('\\') >> any) | (str('"').absent? >> match('[\u0000-\u001F\u007F]').absent? >> any)).repeat >> str('"')) |
|
|
276
|
+
(str("'") >> ((str('\\') >> any) | (str("'").absent? >> match('[\u0000-\u001F\u007F]').absent? >> any)).repeat >> str("'")) |
|
|
277
|
+
(str('[') >> annotation_skip_inner.repeat >> str(']')) |
|
|
278
|
+
(str('[').absent? >> str(']').absent? >> str('"').absent? >> str("'").absent? >> any)
|
|
279
|
+
end
|
|
280
|
+
|
|
263
281
|
# Notation/annotation: [ key = "value" ] or just [ ... ]
|
|
264
282
|
# Notations are only valid when attached to patterns using >>, not as standalone preamble items
|
|
265
283
|
rule(:notation) do
|
|
@@ -311,14 +329,18 @@ module Rng
|
|
|
311
329
|
value_literal >>
|
|
312
330
|
(whitespace >> str('|') >> whitespace >> value_literal).repeat(1) >>
|
|
313
331
|
whitespace >> str(')')).as(:datatype_except) |
|
|
314
|
-
# Non-parenthesized choice of
|
|
315
|
-
(
|
|
332
|
+
# Non-parenthesized choice of items: "a" | "b", text | "a", "a" | ref, text | ref
|
|
333
|
+
(attribute_choice_item >> (whitespace >> str('|') >> whitespace >> attribute_choice_item).repeat(1).as(:attribute_choice)) |
|
|
316
334
|
value_literal |
|
|
317
335
|
datatype_ref |
|
|
318
336
|
keyword('text').as(:text_type) |
|
|
319
337
|
identifier.as(:ref)
|
|
320
338
|
end
|
|
321
339
|
|
|
340
|
+
rule(:attribute_choice_item) do
|
|
341
|
+
keyword('text').as(:text_type) | value_literal | identifier.as(:ref)
|
|
342
|
+
end
|
|
343
|
+
|
|
322
344
|
rule(:datatype_ref) do
|
|
323
345
|
identifier.as(:prefix) >> str(':') >> identifier.as(:type) >>
|
|
324
346
|
(whitespace >> str('{') >> whitespace >>
|
|
@@ -380,7 +402,7 @@ module Rng
|
|
|
380
402
|
# Named pattern definition (e.g., "myPattern = element foo { text }")
|
|
381
403
|
rule(:named_pattern) do
|
|
382
404
|
(doc_comments >> whitespace).maybe.as(:docs) >>
|
|
383
|
-
|
|
405
|
+
(annotation_skip >> whitespace).repeat >>
|
|
384
406
|
identifier.as(:name) >> whitespace >>
|
|
385
407
|
(str('|=') | str('&=') | str('=')).as(:operator) >> whitespace >>
|
|
386
408
|
pattern_list.as(:pattern)
|
|
@@ -401,13 +423,18 @@ module Rng
|
|
|
401
423
|
(
|
|
402
424
|
(whitespace >> str('&') >> whitespace >> content_item).repeat(1).as(:interleave_items) |
|
|
403
425
|
(whitespace >> str('|') >> whitespace >> content_item).repeat(1).as(:choice_items) |
|
|
404
|
-
|
|
426
|
+
# A comma is required between top-level sequence items; a comma-less
|
|
427
|
+
# continuation would greedily swallow the following definition.
|
|
428
|
+
(whitespace >> comma >> whitespace >> content_item).repeat(1).as(:sequence_items)
|
|
405
429
|
).maybe
|
|
406
430
|
end
|
|
407
431
|
|
|
408
432
|
# Choice is handled at content level, not as separate pattern
|
|
409
433
|
rule(:content_item) do
|
|
410
|
-
|
|
434
|
+
(doc_comments >> whitespace).maybe >>
|
|
435
|
+
# Drop any content-position bracket annotation(s) before the item so the
|
|
436
|
+
# following pattern still matches (annotations are not modelled here).
|
|
437
|
+
(annotation_skip >> whitespace).repeat >>
|
|
411
438
|
(element_def | attribute_def |
|
|
412
439
|
# Datatype subtraction: identifier - ( value|identifier|choice|annotated )
|
|
413
440
|
(identifier.as(:datatype_name) >>
|
|
@@ -666,18 +693,13 @@ module Rng
|
|
|
666
693
|
result
|
|
667
694
|
end
|
|
668
695
|
|
|
696
|
+
# Parse RNC content into a Grammar.
|
|
697
|
+
#
|
|
698
|
+
# Delegates to {IncludeProcessor#parse_content} so that this entry point
|
|
699
|
+
# and {Rng.parse_rnc} share a single canonical pipeline. With no location,
|
|
700
|
+
# include directives are not resolved.
|
|
669
701
|
def self.parse(input)
|
|
670
|
-
|
|
671
|
-
preprocessed = preprocess_hex_escapes(input.strip)
|
|
672
|
-
tree = parser.parse(preprocessed)
|
|
673
|
-
|
|
674
|
-
# Normalize parse tree
|
|
675
|
-
processor = ParseTreeProcessor.new(tree)
|
|
676
|
-
normalized = processor.normalize
|
|
677
|
-
|
|
678
|
-
# Convert to RNG XML and Grammar object
|
|
679
|
-
rng_xml = convert_to_rng(normalized.grammar_tree)
|
|
680
|
-
Grammar.from_xml(rng_xml)
|
|
702
|
+
IncludeProcessor.new.parse_content(input)
|
|
681
703
|
end
|
|
682
704
|
|
|
683
705
|
# Convert RNG schema to RNC
|
|
@@ -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])
|
|
@@ -394,7 +368,7 @@ module Rng
|
|
|
394
368
|
elsif part[:char_escape]
|
|
395
369
|
# Map character escape
|
|
396
370
|
char = extract_string(part[:char_escape][:char])
|
|
397
|
-
CHAR_ESCAPE_MAP[char] || char
|
|
371
|
+
CHAR_ESCAPE_MAP[char] || "\\#{char}"
|
|
398
372
|
elsif part[:backslash_escape]
|
|
399
373
|
# Backslash escape in identifier: \x -> x
|
|
400
374
|
if part[:backslash_escape][:escaped_backslash]
|
|
@@ -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
|