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.
@@ -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
@@ -182,6 +182,54 @@ RSpec.describe Rng::RncParser do
182
182
  end
183
183
  end
184
184
 
185
+ context 'with occurrence markers on leaf patterns' do
186
+ def root_element_for(form, extra = '')
187
+ rnc = "start = element root { #{form} }\n#{extra}"
188
+ described_class.parse(rnc).start.first.element
189
+ end
190
+
191
+ # On Element, :ref is a collection slot but :text/:empty/etc. are singular,
192
+ # so "leaf is empty" needs to handle both shapes uniformly.
193
+ def leaf_empty?(container, slot)
194
+ v = container.public_send(slot)
195
+ v.nil? || (v.respond_to?(:empty?) && v.empty?)
196
+ end
197
+
198
+ # form => { slot: model accessor on Element/wrapper, extra: extra RNC needed }
199
+ patterns = {
200
+ 'item' => { slot: :ref, extra: "item = element item { text }\n" },
201
+ 'text' => { slot: :text },
202
+ 'empty' => { slot: :empty },
203
+ 'notAllowed' => { slot: :notAllowed },
204
+ '"foo"' => { slot: :value },
205
+ 'xsd:string' => { slot: :data }
206
+ }
207
+ wrappers = { '*' => :zeroOrMore, '+' => :oneOrMore, '?' => :optional }
208
+
209
+ patterns.each do |form, config|
210
+ slot = config[:slot]
211
+ extra = config[:extra].to_s
212
+
213
+ it "emits a bare <#{slot}> when there is no occurrence marker on #{form}" do
214
+ element = root_element_for(form, extra)
215
+ expect(leaf_empty?(element, slot)).to be(false)
216
+ expect(element.zeroOrMore).to be_empty
217
+ expect(element.oneOrMore).to be_empty
218
+ expect(element.optional).to be_empty
219
+ end
220
+
221
+ wrappers.each do |marker, wrapper|
222
+ it "wraps #{form}#{marker} in <#{wrapper}>" do
223
+ element = root_element_for("#{form}#{marker}", extra)
224
+ wrapper_coll = element.public_send(wrapper)
225
+ expect(wrapper_coll.length).to eq(1)
226
+ expect(wrapper_coll.first.public_send(slot).length).to eq(1)
227
+ expect(leaf_empty?(element, slot)).to be(true)
228
+ end
229
+ end
230
+ end
231
+ end
232
+
185
233
  context 'with pattern references in choice' do
186
234
  let(:input) do
187
235
  <<~RNC
@@ -415,6 +463,103 @@ RSpec.describe Rng::RncParser do
415
463
  end
416
464
  end
417
465
 
466
+ context 'with named pattern after ## comment' do
467
+ let(:input) do
468
+ <<~RNC
469
+ document =
470
+ element document {
471
+ ## test 1
472
+ test
473
+ }
474
+
475
+ test =
476
+ element sections {
477
+ attribute test { text }?
478
+ }
479
+ RNC
480
+ end
481
+
482
+ let(:grammar) { described_class.parse(input) }
483
+
484
+ it 'parses without error' do
485
+ expect { grammar }.not_to raise_error
486
+ end
487
+
488
+ it 'contains the element document' do
489
+ expect(grammar.define.map(&:name)).to include('document')
490
+ document_def = grammar.define.find { |d| d.name == 'document' }
491
+ expect(document_def.element[0].attr_name).to eq('document')
492
+ end
493
+
494
+ it 'contains the element sections' do
495
+ expect(grammar.define.map(&:name)).to include('test')
496
+ test_def = grammar.define.find { |d| d.name == 'test' }
497
+ expect(test_def.element[0].attr_name).to eq('sections')
498
+ end
499
+ end
500
+
501
+ context 'with escape sequences in string literals' do
502
+ it 'parses known escapes (\\n, \\t, \\r, \\", \\\\)' do
503
+ input = <<~'RNC'
504
+ start = element value {
505
+ xsd:string { pattern = "hello\nworld\ttab\r\n" }
506
+ }
507
+ RNC
508
+ expect { described_class.parse(input) }.not_to raise_error
509
+ end
510
+
511
+ it 'parses RELAX NG class escapes (\\i, \\c, \\d, \\w)' do
512
+ input = <<~'RNC'
513
+ start = element value {
514
+ xsd:string { pattern = "\\i\\c\\d\\w" }
515
+ }
516
+ RNC
517
+ expect { described_class.parse(input) }.not_to raise_error
518
+ end
519
+
520
+ it 'parses unknown escape sequences (\\+, \\-, \\s, \\., \\,) preserving backslash' do
521
+ input = <<~'RNC'
522
+ start = element value {
523
+ xsd:string { pattern = "[\\+\\-]?\\d{4}" }
524
+ }
525
+ RNC
526
+ expect { described_class.parse(input) }.not_to raise_error
527
+ end
528
+
529
+ it 'parses ISO 8601 regex pattern with multiple escape sequences' do
530
+ input = <<~'RNC'
531
+ ISO8601DateTime = xsd:string { pattern = "([\\+\\-]?\\d{4})((-?)((0[1-9]|1[0-2])((-?)([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24:?00)([\\.,]\\d+)?)?((:?)[0-5]\\d([.,]\\d+)?)?([zZ]|([\\+\\-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?)?)?" }
532
+ elem = element elem { ISO8601DateTime }
533
+ RNC
534
+ expect { described_class.parse(input) }.not_to raise_error
535
+ end
536
+
537
+ it 'rejects invalid hex escape \\x{}' do
538
+ # Single-quoted heredoc: \x{} is literal \x{} (one backslash)
539
+ input = <<~'RNC'
540
+ start = element value {
541
+ xsd:string { pattern = "\x{}" }
542
+ }
543
+ RNC
544
+ expect do
545
+ described_class.parse(input)
546
+ end.to raise_error(Parslet::ParseFailed)
547
+ end
548
+
549
+ it 'round-trips unknown escape sequences preserving backslash' do
550
+ rnc_input = <<~'RNC'
551
+ start = element value {
552
+ xsd:string { pattern = "[\\+\\-]?\\d{4}" }
553
+ }
554
+ RNC
555
+ grammar = described_class.parse(rnc_input)
556
+ rnc_output = described_class.to_rnc(grammar)
557
+ expect(rnc_output).to include('\\+')
558
+ expect(rnc_output).to include('\\-')
559
+ expect(rnc_output).to include('\\d')
560
+ end
561
+ end
562
+
418
563
  context 'with except patterns in anyName' do
419
564
  let(:input) do
420
565
  <<~RNC
@@ -97,6 +97,83 @@ RSpec.describe 'RNG to RNC Round-trip Tests' do
97
97
  expect(xml).to include('<choice')
98
98
  end
99
99
 
100
+ it 'preserves a mixed attribute choice of text and value' do
101
+ rnc = <<~RNC
102
+ start = element doc {
103
+ attribute a { text | "x" }
104
+ }
105
+ RNC
106
+ xml = Rng.parse_rnc(rnc).to_xml
107
+ expect(xml).to include('<choice>')
108
+ expect(xml).to include('<text>')
109
+ expect(xml).to include('<value>x</value>')
110
+ end
111
+
112
+ it 'preserves a mixed attribute choice of value and ref' do
113
+ rnc = <<~RNC
114
+ start = element doc {
115
+ attribute a { "x" | foo }
116
+ }
117
+ foo = element foo { text }
118
+ RNC
119
+ xml = Rng.parse_rnc(rnc).to_xml
120
+ expect(xml).to include('<choice>')
121
+ expect(xml).to include('<value>x</value>')
122
+ expect(xml).to include('<ref name="foo"/>')
123
+ end
124
+
125
+ it 'preserves a mixed attribute choice of text and ref' do
126
+ rnc = <<~RNC
127
+ start = element doc {
128
+ attribute a { text | foo }
129
+ }
130
+ foo = element foo { text }
131
+ RNC
132
+ xml = Rng.parse_rnc(rnc).to_xml
133
+ expect(xml).to include('<choice>')
134
+ expect(xml).to include('<text>')
135
+ expect(xml).to include('<ref name="foo"/>')
136
+ end
137
+
138
+ it 'emits a mixed attribute choice of text and value back to RNC' do
139
+ rng = <<~RNG
140
+ <grammar xmlns="http://relaxng.org/ns/structure/1.0">
141
+ <start><element name="doc"><attribute name="indent">
142
+ <choice><text/><value>adaptive</value></choice>
143
+ </attribute></element></start>
144
+ </grammar>
145
+ RNG
146
+ rnc = Rng.to_rnc(Rng.parse(rng))
147
+ expect(rnc).to match(/attribute indent \{[^}]*text[^}]*\}/)
148
+ expect(rnc).to include('"adaptive"')
149
+ end
150
+
151
+ it 'emits a mixed attribute choice of value and ref back to RNC' do
152
+ rng = <<~RNG
153
+ <grammar xmlns="http://relaxng.org/ns/structure/1.0">
154
+ <start><element name="doc"><attribute name="a">
155
+ <choice><value>x</value><ref name="foo"/></choice>
156
+ </attribute></element></start>
157
+ <define name="foo"><text/></define>
158
+ </grammar>
159
+ RNG
160
+ rnc = Rng.to_rnc(Rng.parse(rng))
161
+ expect(rnc).to match(/attribute a \{[^}]*foo[^}]*\}/)
162
+ expect(rnc).to include('"x"')
163
+ end
164
+
165
+ it 'round-trips a mixed attribute choice of text and value through RNC and RNG' do
166
+ rnc = <<~RNC
167
+ start = element doc {
168
+ attribute indent { text | "adaptive" }
169
+ }
170
+ RNC
171
+ regenerated = Rng.to_rnc(Rng.parse(Rng.parse_rnc(rnc).to_xml))
172
+ # Both members of the choice must survive a full RNC -> RNG -> RNC trip
173
+ expect(regenerated).to match(/attribute indent \{[^}]*text[^}]*\}/)
174
+ expect(regenerated).to include('"adaptive"')
175
+ end
176
+
100
177
  it 'preserves interleave' do
101
178
  rnc = <<~RNC
102
179
  start = element foo {
@@ -81,6 +81,24 @@ RSpec.describe Rng::Grammar do
81
81
  expect(start_element).to be_a(Rng::Element)
82
82
  expect(start_element.attr_name).to eq('addressBook')
83
83
  end
84
+
85
+ it 'resolves includes for in-memory RNC when location is the source directory' do
86
+ main_path = File.expand_path('spec/fixtures/rnc/main_with_include.rnc')
87
+ parsed = Rng.parse_rnc(File.read(main_path), location: File.dirname(main_path))
88
+
89
+ expect(parsed).to be_a(described_class)
90
+ expect(parsed.define.map(&:name)).to include('BasePattern', 'ExtendedPattern')
91
+ expect(parsed.start.first.element.attr_name).to eq('doc')
92
+ end
93
+
94
+ it 'matches parse_file include resolution for in-memory RNC when location is the source directory' do
95
+ main_path = File.expand_path('spec/fixtures/rnc/main_include_trailing.rnc')
96
+ from_memory = Rng.parse_rnc(File.read(main_path), location: File.dirname(main_path))
97
+ from_file = Rng.parse_file(main_path)
98
+
99
+ expect(from_memory.start.first.element.attr_name).to eq(from_file.start.first.element.attr_name)
100
+ expect(from_memory.define.map(&:name).sort).to eq(from_file.define.map(&:name).sort)
101
+ end
84
102
  end
85
103
 
86
104
  describe 'RNG to RNC conversion' do
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rng
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.6
4
+ version: 0.3.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-15 00:00:00.000000000 Z
11
+ date: 2026-10-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 0.8.0
19
+ version: 0.8.114
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 0.8.0
26
+ version: 0.8.114
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: nokogiri
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -136,7 +136,6 @@ files:
136
136
  - lib/rng/optional.rb
137
137
  - lib/rng/param.rb
138
138
  - lib/rng/parent_ref.rb
139
- - lib/rng/parse_rnc.rb
140
139
  - lib/rng/parse_tree_processor.rb
141
140
  - lib/rng/pattern.rb
142
141
  - lib/rng/ref.rb
@@ -166,6 +165,8 @@ files:
166
165
  - spec/fixtures/external/nested_chain.rng
167
166
  - spec/fixtures/external/nested_leaf.rng
168
167
  - spec/fixtures/external/nested_mid.rng
168
+ - spec/fixtures/external/order_lib.rng
169
+ - spec/fixtures/external/order_main.rng
169
170
  - spec/fixtures/metanorma/3gpp.rnc
170
171
  - spec/fixtures/metanorma/3gpp.rng
171
172
  - spec/fixtures/metanorma/basicdoc.rnc
@@ -254,6 +255,7 @@ files:
254
255
  - spec/rng/metanorma_conversion_spec.rb
255
256
  - spec/rng/namespace_declaration_spec.rb
256
257
  - spec/rng/namespace_support_spec.rb
258
+ - spec/rng/parse_rnc_spec.rb
257
259
  - spec/rng/rnc_parser_spec.rb
258
260
  - spec/rng/rnc_roundtrip_spec.rb
259
261
  - spec/rng/rng_generation_spec.rb
data/lib/rng/parse_rnc.rb DELETED
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Rng
4
- # RNC Parser module
5
- # Provides functionality to parse RELAX NG Compact Syntax (RNC) files
6
- module ParseRnc
7
- class << self
8
- # Parse RNC syntax and return an RNG schema object
9
- # @param rnc_string [String] The RNC content to parse
10
- # @return [Rng::Grammar] The parsed schema object
11
- def parse(rnc_string)
12
- # This is a placeholder implementation
13
- # The actual parsing logic would need to be implemented
14
-
15
- # For now, we delegate to RncParser which is our internal implementation
16
- # In the future, this could be expanded with additional functionality
17
- RncParser.parse(rnc_string)
18
- end
19
- end
20
- end
21
-
22
- # Add class-level parsing method
23
- def self.parse_rnc(rnc_string)
24
- ParseRnc.parse(rnc_string)
25
- end
26
- end