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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64d22a32278b59bf3f6913561453c616ac39c259f4ffded1b200efddc50461af
4
- data.tar.gz: 1fc2453376a3f7f65aa717beb3707e54ff1bdc751ae528ce8132e3f18720cf2b
3
+ metadata.gz: 8e689f7178c2c8a2e691e1327e99c1b74e7cea638ec80a12d5d557ad0c7a8003
4
+ data.tar.gz: 2d768848f695fab05f1f8808e44124178a5498b81bc12b277cc40b787fb862b4
5
5
  SHA512:
6
- metadata.gz: dc8735cc2a8a3ae46635100a30ee39e114c762b9bace21955e2d361ad8bcbf65e0a0f10102f81085fea19a13d377ad93e1ac5313c8ae56a3237171712de7f965
7
- data.tar.gz: 3ac117095690e1065a10921f4093072f151c84454cc0439fcd4a21764978654b8e5963f548969d92d76efb70c9ecca674984aa38fdc3a67cd3bb72c0924d394c
6
+ metadata.gz: b982725dbada931908431e146118e403f750ed9232d477491f568455a674b2f3d4efaeda73c3bbbd5b4fc0de49e1fab886f20fad6daf74dcdd283d20ff305569
7
+ data.tar.gz: 78f9da1bc7d6b3335af3e251954172e3d6d548f2a875c7d49b4f6c8cfe7a5cbd98dbe3f62d61b1effb2fd44088cd0f993a3b845fa014aaa87f2904f60be92e3e
@@ -22,6 +22,41 @@ module Rng
22
22
  end
23
23
  end
24
24
 
25
+ # Scan a grammar for unresolved external references.
26
+ #
27
+ # Walks the object graph forward-only via lutaml attribute reflection
28
+ # and collects a descriptive message for every Include, ExternalRef, and
29
+ # ParentRef found, including its href/name so callers can match on it.
30
+ #
31
+ # @param grammar [Grammar] The grammar to scan
32
+ # @return [Array<String>] Descriptive messages for unresolved references
33
+ def self.unresolved_ref_hrefs(grammar)
34
+ messages = []
35
+ collect_unresolved_refs(grammar, messages, [])
36
+ messages
37
+ end
38
+
39
+ # @api private
40
+ def self.collect_unresolved_refs(obj, messages, seen)
41
+ case obj
42
+ when Include
43
+ messages << "Unresolved include href: #{obj.href}" if obj.href
44
+ when ExternalRef
45
+ messages << "Unresolved externalRef href: #{obj.href}" if obj.href
46
+ when ParentRef
47
+ messages << "Unresolved parentRef name: #{obj.name}" if obj.name
48
+ end
49
+
50
+ return unless obj.is_a?(Lutaml::Model::Serializable)
51
+ return if seen.include?(obj.object_id)
52
+
53
+ seen << obj.object_id
54
+ obj.class.attributes.each_key do |attr_name|
55
+ value = obj.public_send(attr_name)
56
+ Array(value).each { |child| collect_unresolved_refs(child, messages, seen) }
57
+ end
58
+ end
59
+
25
60
  # Initialize the resolver
26
61
  #
27
62
  # @param grammar [Grammar] The grammar to resolve external refs in
@@ -83,18 +118,14 @@ module Rng
83
118
  # Copy start pattern
84
119
  if grammar.start && !grammar.start.empty?
85
120
  new_grammar.start = grammar.start.filter_map do |s|
86
- resolved = resolve_pattern(deep_dup(s), base_dir, visited_files)
87
- clear_element_order!(resolved)
88
- resolved
121
+ resolve_pattern(deep_dup(s), base_dir, visited_files)
89
122
  end
90
123
  end
91
124
 
92
125
  # Copy define patterns
93
126
  if grammar.define && !grammar.define.empty?
94
127
  new_grammar.define = grammar.define.filter_map do |d|
95
- resolved = resolve_pattern(deep_dup(d), base_dir, visited_files)
96
- clear_element_order!(resolved)
97
- resolved
128
+ resolve_pattern(deep_dup(d), base_dir, visited_files)
98
129
  end
99
130
  end
100
131
 
@@ -102,68 +133,10 @@ module Rng
102
133
  return unless grammar.div && !grammar.div.empty?
103
134
 
104
135
  new_grammar.div = grammar.div.filter_map do |div|
105
- resolved_div = resolve_div(deep_dup(div), base_dir, visited_files)
106
- clear_element_order!(resolved_div)
107
- resolved_div
136
+ resolve_div(deep_dup(div), base_dir, visited_files)
108
137
  end
109
138
  end
110
139
 
111
- # Recursively clear element_order on an object and its children
112
- # This forces to_xml to use Ruby attributes instead of stale XML nodes
113
- #
114
- # @param obj [Object] Object to clear element_order on
115
- def clear_element_order!(obj)
116
- return obj unless obj
117
-
118
- obj.instance_variable_set(:@element_order, nil)
119
-
120
- # Recursively clear on children based on type
121
- case obj
122
- when Start
123
- %i[element choice group interleave mixed optional zeroOrMore
124
- oneOrMore text empty value data list parentRef notAllowed grammar].each do |attr|
125
- children = obj.send(attr)
126
- next if children.nil? || (children.respond_to?(:empty?) && children.empty?)
127
-
128
- Array(children).each { |c| clear_element_order!(c) }
129
- end
130
- when Define
131
- %i[ref element choice group interleave mixed optional zeroOrMore
132
- oneOrMore text empty value data list notAllowed attribute grammar].each do |attr|
133
- children = obj.send(attr)
134
- next if children.nil? || (children.respond_to?(:empty?) && children.empty?)
135
-
136
- Array(children).each { |c| clear_element_order!(c) }
137
- end
138
- when Element
139
- %i[attribute ref choice group interleave mixed optional zeroOrMore
140
- oneOrMore anyName text empty value data list notAllowed element grammar].each do |attr|
141
- children = obj.send(attr)
142
- next if children.nil? || (children.respond_to?(:empty?) && children.empty?)
143
-
144
- Array(children).each { |c| clear_element_order!(c) }
145
- end
146
- when Group
147
- %i[attribute ref choice group interleave mixed optional zeroOrMore
148
- oneOrMore text empty value data list notAllowed].each do |attr|
149
- children = obj.send(attr)
150
- next if children.nil? || (children.respond_to?(:empty?) && children.empty?)
151
-
152
- Array(children).each { |c| clear_element_order!(c) }
153
- end
154
- when Div
155
- obj.div&.each { |d| clear_element_order!(d) }
156
- %i[start define].each do |attr|
157
- children = obj.send(attr)
158
- next if children.nil? || (children.respond_to?(:empty?) && children.empty?)
159
-
160
- Array(children).each { |c| clear_element_order!(c) }
161
- end
162
- end
163
-
164
- obj
165
- end
166
-
167
140
  # Resolve includes and return array of resolved grammars/content
168
141
  #
169
142
  # @param grammar [Grammar] Grammar containing includes
@@ -230,6 +203,7 @@ module Rng
230
203
  value = obj.public_send(attr_name)
231
204
  result.public_send(:"#{attr_name}=", deep_dup(value))
232
205
  end
206
+ copy_order_ivars!(obj, result)
233
207
  result
234
208
  when Array
235
209
  obj.map { |o| deep_dup(o) }
@@ -243,6 +217,21 @@ module Rng
243
217
  end
244
218
  end
245
219
 
220
+ # Carry lutaml's document-order ivars onto a deep-dup'd object so to_xml
221
+ # emits children in source order. These hold Lutaml::Xml::Element value
222
+ # objects (name/type), not live Nokogiri nodes.
223
+ #
224
+ # @param source [Object] Original object
225
+ # @param result [Object] Deep-dup'd copy
226
+ def copy_order_ivars!(source, result)
227
+ %i[@element_order @attribute_order].each do |ivar|
228
+ next unless source.instance_variable_defined?(ivar)
229
+
230
+ order = source.instance_variable_get(ivar)
231
+ result.instance_variable_set(ivar, order&.dup)
232
+ end
233
+ end
234
+
246
235
  # Resolve a single include directive
247
236
  #
248
237
  # @param include_directive [Include] Include element with href
@@ -310,32 +299,41 @@ module Rng
310
299
  def resolve_pattern(pattern, base_dir, visited_files)
311
300
  return unless pattern
312
301
 
313
- # Handle Element pattern
314
302
  case pattern
315
303
  when Element
316
304
  resolve_element_external_ref!(pattern, base_dir, visited_files)
317
-
318
- # Recursively resolve in Element's children
319
- resolve_element_children!(pattern, base_dir, visited_files)
320
- # Handle Group pattern
305
+ resolve_child_patterns!(pattern, base_dir, visited_files)
321
306
  when Group
322
307
  resolve_group_external_ref!(pattern, base_dir, visited_files)
323
-
324
- # Recursively resolve in Group's children
325
- resolve_group_children!(pattern, base_dir, visited_files)
326
- # Handle Define pattern
327
- when Define
328
- resolve_define_children!(pattern, base_dir, visited_files)
329
- # Handle Start pattern
330
- when Start
331
- resolve_start_children!(pattern, base_dir, visited_files)
332
- when Start
333
- resolve_pattern_children!(pattern, base_dir, visited_files)
308
+ resolve_child_patterns!(pattern, base_dir, visited_files)
309
+ when Define, Start
310
+ resolve_child_patterns!(pattern, base_dir, visited_files)
334
311
  end
335
312
 
336
313
  pattern
337
314
  end
338
315
 
316
+ # Recursively resolve external refs in a pattern's child patterns.
317
+ #
318
+ # Walks forward via lutaml attribute reflection (same approach as
319
+ # collect_unresolved_refs) instead of curated per-type attribute lists.
320
+ # Scalars are skipped by the Serializable guard. Only Element/Group/Define/
321
+ # Start dispatch into externalRef handling in resolve_pattern, so every
322
+ # other Serializable child is a no-op and the recursion stays bounded.
323
+ #
324
+ # @param pattern [Object] Pattern whose children to resolve
325
+ # @param base_dir [String] Base directory for href resolution
326
+ # @param visited_files [Set] Set of visited file paths
327
+ def resolve_child_patterns!(pattern, base_dir, visited_files)
328
+ pattern.class.attributes.each_key do |attr_name|
329
+ Array(pattern.public_send(attr_name)).each do |child|
330
+ next unless child.is_a?(Lutaml::Model::Serializable)
331
+
332
+ resolve_pattern(child, base_dir, visited_files)
333
+ end
334
+ end
335
+ end
336
+
339
337
  # Resolve external ref in an Element
340
338
  #
341
339
  # @param element [Element] Element with external_ref
@@ -386,9 +384,16 @@ module Rng
386
384
 
387
385
  # Copy pattern content from source to target
388
386
  #
387
+ # Rebuilds target's content from attributes, so its own document-order
388
+ # ivars no longer describe what it holds (e.g. an externalRef entry was
389
+ # replaced). Clear them on target only; children keep their own order.
390
+ #
389
391
  # @param target [Object] Target pattern (Element, Group, etc.)
390
392
  # @param source [Object] Source pattern
391
393
  def copy_pattern_content(target, source)
394
+ target.instance_variable_set(:@element_order, nil)
395
+ target.instance_variable_set(:@attribute_order, nil)
396
+
392
397
  case source
393
398
  when Start
394
399
  # Start pattern - copy its content (element, choice, group, etc.)
@@ -407,8 +412,6 @@ module Rng
407
412
  value data list notAllowed externalRef])
408
413
  when Choice
409
414
  target.choice = source.choice if source.choice
410
- when Group
411
- target.group = source.group if source.group
412
415
  when Interleave
413
416
  target.interleave = source.interleave if source.interleave
414
417
  when Optional
@@ -491,80 +494,6 @@ module Rng
491
494
  copy_pattern_content(group, start_pattern)
492
495
  end
493
496
 
494
- # Recursively resolve children of an Element
495
- #
496
- # @param element [Element] Element to resolve children in
497
- # @param base_dir [String] Base directory
498
- # @param visited_files [Set] Set of visited file paths
499
- def resolve_element_children!(element, base_dir, visited_files)
500
- %i[attribute ref choice group interleave mixed optional zeroOrMore
501
- oneOrMore anyName text empty value data list notAllowed element grammar].each do |attr|
502
- children = element.send(attr)
503
- next if children.nil? || (children.respond_to?(:empty?) && children.empty?)
504
-
505
- Array(children).each do |child|
506
- resolve_pattern(child, base_dir, visited_files)
507
- end
508
- end
509
- end
510
-
511
- # Recursively resolve children of a Group
512
- #
513
- # @param group [Group] Group to resolve children in
514
- # @param base_dir [String] Base directory
515
- # @param visited_files [Set] Set of visited file paths
516
- def resolve_group_children!(group, base_dir, visited_files)
517
- %i[attribute ref choice group interleave mixed optional zeroOrMore
518
- oneOrMore text empty value data list notAllowed].each do |attr|
519
- children = group.send(attr)
520
- next if children.nil? || (children.respond_to?(:empty?) && children.empty?)
521
-
522
- Array(children).each do |child|
523
- resolve_pattern(child, base_dir, visited_files)
524
- end
525
- end
526
- end
527
-
528
- # Recursively resolve children of a Start pattern
529
- #
530
- # @param start [Start] Start pattern to resolve children in
531
- # @param base_dir [String] Base directory
532
- # @param visited_files [Set] Set of visited file paths
533
- def resolve_start_children!(start, base_dir, visited_files)
534
- # Start has: element, choice, group, interleave, mixed, optional,
535
- # zeroOrMore, oneOrMore, text, empty, value, data, list,
536
- # parentRef, notAllowed, grammar
537
- %i[element choice group interleave mixed optional zeroOrMore
538
- oneOrMore text empty value data list parentRef notAllowed grammar].each do |attr|
539
- children = start.send(attr)
540
- next if children.nil? || (children.respond_to?(:empty?) && children.empty?)
541
-
542
- Array(children).each do |child|
543
- resolve_pattern(child, base_dir, visited_files)
544
- end
545
- end
546
- end
547
-
548
- # Recursively resolve children of a Define
549
- #
550
- # @param define [Define] Define to resolve children in
551
- # @param base_dir [String] Base directory
552
- # @param visited_files [Set] Set of visited file paths
553
- def resolve_define_children!(define, base_dir, visited_files)
554
- # Define has: ref, element, choice, group, interleave, mixed, optional,
555
- # zeroOrMore, oneOrMore, text, empty, value, data, list,
556
- # notAllowed, attribute, grammar
557
- %i[ref element choice group interleave mixed optional zeroOrMore
558
- oneOrMore text empty value data list notAllowed attribute grammar].each do |attr|
559
- children = define.send(attr)
560
- next if children.nil? || (children.respond_to?(:empty?) && children.empty?)
561
-
562
- Array(children).each do |child|
563
- resolve_pattern(child, base_dir, visited_files)
564
- end
565
- end
566
- end
567
-
568
497
  # Resolve href to absolute path with cycle detection
569
498
  #
570
499
  # @param href [String] Relative or absolute href
data/lib/rng/grammar.rb CHANGED
@@ -3,6 +3,14 @@
3
3
  module Rng
4
4
  # This represents the RNG schema
5
5
  class Grammar < Lutaml::Model::Serializable
6
+ # Non-serialized diagnostics, e.g. unresolved external-reference hrefs
7
+ # collected when parsing without resolve_external.
8
+ attr_writer :warnings
9
+
10
+ def warnings
11
+ @warnings ||= []
12
+ end
13
+
6
14
  attribute :id, :string
7
15
  attribute :ns, :string
8
16
  attribute :datatypeLibrary, :string
@@ -29,69 +29,74 @@ module Rng
29
29
  # @param visited_files [Set] Set of already visited file paths (for circular detection)
30
30
  # @return [Grammar] Parsed grammar object
31
31
  def parse_file(file_path, base_dir = nil, visited_files = Set.new)
32
- tree = parse_file_to_tree(file_path, base_dir, visited_files)
32
+ abs_path = track_visited_file(file_path, base_dir, visited_files)
33
33
 
34
- # Process raw_grammar/raw_override/raw_patterns first (before include resolution)
35
- # The include processor needs parsed content in the tree
36
- process_raw_nodes!(tree)
37
-
38
- # Process any includes in the tree (top-level or grammar-level)
39
- process_includes(tree, base_dir || File.dirname(File.expand_path(file_path)),
40
- visited_files)
41
-
42
- # Extract namespace from wrapper level if present
43
- namespace = tree[:namespace]
34
+ # Read file content
35
+ raise "Include file not found: #{abs_path}" unless File.exist?(abs_path)
44
36
 
45
- # Build grammar tree from different structures
46
- grammar_tree = build_grammar_tree(tree)
47
- grammar_tree[:namespace] = namespace if namespace
37
+ parse_tree_to_grammar(File.read(abs_path), File.dirname(abs_path), visited_files)
38
+ end
48
39
 
49
- # Convert to RNG XML and then to Grammar object
50
- rng_xml = @converter.convert(grammar_tree)
51
- Grammar.from_xml(rng_xml)
40
+ # Parse in-memory RNC content with optional include resolution.
41
+ #
42
+ # When +location+ is nil, includes are not resolved and the pipeline matches
43
+ # {RncParser.parse} exactly. When +location+ names an existing directory it
44
+ # is used directly as the base directory for relative +include+ directives.
45
+ # Otherwise +location+ is treated as a source file path and includes are
46
+ # resolved against that file's directory.
47
+ #
48
+ # @param content [String] RNC content
49
+ # @param location [String, nil] Base directory or source file path used to resolve relative includes
50
+ # @param visited_files [Set] Set of already visited file paths (for circular detection)
51
+ # @return [Grammar] Parsed grammar object
52
+ def parse_content(content, location: nil, visited_files: Set.new)
53
+ parse_tree_to_grammar(content, resolve_base_dir(location, visited_files), visited_files)
52
54
  end
53
55
 
54
56
  private
55
57
 
56
- # Parse file to parse tree (not Grammar object)
57
- #
58
- # @param file_path [String] Path to RNC file
59
- # @param base_dir [String, nil] Base directory for resolving relative paths
60
- # @param visited_files [Set] Set of already visited file paths
61
- # @return [Hash] Parse tree
62
- def parse_file_to_tree(file_path, base_dir = nil, visited_files = Set.new)
63
- # Resolve absolute path to prevent circular includes
64
- abs_path = File.expand_path(file_path, base_dir)
58
+ def resolve_base_dir(location, visited_files)
59
+ return nil if location.nil?
60
+ return location if File.directory?(location)
65
61
 
66
- # Check for circular includes
67
- raise "Circular include detected: #{abs_path}" if visited_files.include?(abs_path)
62
+ File.dirname(track_visited_file(location, nil, visited_files))
63
+ end
68
64
 
69
- # Mark file as visited
70
- visited_files.add(abs_path)
65
+ def parse_tree_to_grammar(content, base_dir, visited_files)
66
+ tree = parse_content_to_tree(content)
71
67
 
72
- # Read file content
73
- raise "Include file not found: #{abs_path}" unless File.exist?(abs_path)
68
+ if base_dir
69
+ # process_includes runs before normalize and needs raw_override /
70
+ # raw_grammar / raw_patterns expanded so it can walk the parsed
71
+ # subtrees. normalize would do this on the grammar_tree later, but
72
+ # by then process_includes has already executed.
73
+ process_raw_nodes!(tree)
74
+ process_includes(tree, base_dir, visited_files)
75
+ end
76
+
77
+ rng_xml = @converter.convert(build_grammar_tree(tree))
78
+ Grammar.from_xml(rng_xml)
79
+ end
74
80
 
75
- content = File.read(abs_path)
81
+ def track_visited_file(file_path, base_dir, visited_files)
82
+ abs_path = File.expand_path(file_path, base_dir)
83
+ raise "Circular include detected: #{abs_path}" if visited_files.include?(abs_path)
76
84
 
77
- # Parse with includes, passing the directory for relative path resolution
78
- parse_with_includes(content, File.dirname(abs_path), visited_files)
85
+ visited_files.add(abs_path)
86
+ abs_path
79
87
  end
80
88
 
81
- # Parse RNC content and resolve includes
89
+ # Parse RNC content into a raw parse tree.
90
+ #
91
+ # Applies hex-escape preprocessing so callers get the same parse tree
92
+ # they would from {RncParser.parse}. Preprocessing is idempotent on input
93
+ # that contains no hex escapes outside string literals, so it is safe to
94
+ # apply to file content as well.
82
95
  #
83
96
  # @param content [String] RNC content
84
- # @param base_dir [String, nil] Base directory for resolving relative paths
85
- # @param visited_files [Set] Set of already visited file paths
86
- # @return [Hash] Parse tree with includes resolved
87
- def parse_with_includes(content, base_dir = nil, visited_files = Set.new)
88
- parser = RncParser.new
89
- tree = parser.parse(content.strip)
90
-
91
- # Process includes in the tree
92
- process_includes(tree, base_dir, visited_files)
93
-
94
- tree
97
+ # @return [Hash] Raw parse tree
98
+ def parse_content_to_tree(content)
99
+ RncParser.new.parse(RncParser.preprocess_hex_escapes(content.strip))
95
100
  end
96
101
 
97
102
  # Process include directives by recursively parsing included files
@@ -140,27 +145,7 @@ module Rng
140
145
  href = extract_string_literal(include_item[:href])
141
146
  override = parse_override(include_item[:override])
142
147
 
143
- # Resolve file path relative to base_dir
144
- included_file_path = base_dir ? File.join(base_dir, href) : href
145
-
146
- # Parse included file recursively
147
- included_tree = parse_file_to_tree(included_file_path, base_dir,
148
- visited_files.dup)
149
-
150
- # Process raw grammar/override/patterns before extracting
151
- process_raw_nodes!(included_tree)
152
-
153
- # Extract grammar from included tree
154
- included_grammar = extract_grammar_tree(included_tree)
155
-
156
- # Recursively process includes in the included file
157
- if included_grammar[:includes] && !included_grammar[:includes].empty?
158
- process_includes(included_tree, File.dirname(included_file_path),
159
- visited_files.dup)
160
-
161
- # Re-extract grammar after processing its includes
162
- included_grammar = extract_grammar_tree(included_tree)
163
- end
148
+ included_grammar = resolve_included_grammar(href, base_dir, visited_files.dup)
164
149
 
165
150
  # Merge included definitions into temporary grammar_tree
166
151
  merge_included_grammar(grammar_tree, included_grammar, override)
@@ -191,28 +176,7 @@ module Rng
191
176
  href = extract_string_literal(include_item[:href])
192
177
  override = parse_override(include_item[:override])
193
178
 
194
- # Resolve file path relative to base_dir
195
- included_file_path = base_dir ? File.join(base_dir, href) : href
196
-
197
- # Parse included file recursively
198
- included_tree = parse_file_to_tree(included_file_path, base_dir,
199
- visited_files.dup)
200
-
201
- # Process raw grammar/override/patterns before extracting
202
- process_raw_nodes!(included_tree)
203
-
204
- # Extract grammar from included tree
205
- included_grammar = extract_grammar_tree(included_tree)
206
-
207
- # Recursively process includes in the included file
208
- if included_grammar[:includes] && !included_grammar[:includes].empty?
209
- # Process includes in the included file
210
- process_includes(included_tree, File.dirname(included_file_path),
211
- visited_files.dup)
212
-
213
- # Re-extract grammar after processing its includes
214
- included_grammar = extract_grammar_tree(included_tree)
215
- end
179
+ included_grammar = resolve_included_grammar(href, base_dir, visited_files.dup)
216
180
 
217
181
  # Merge included definitions into current tree
218
182
  merge_included_grammar(grammar_tree, included_grammar, override)
@@ -230,6 +194,16 @@ module Rng
230
194
  end
231
195
  end
232
196
 
197
+ def resolve_included_grammar(file_path, base_dir, visited_files)
198
+ abs_path = track_visited_file(file_path, base_dir, visited_files)
199
+ raise "Include file not found: #{abs_path}" unless File.exist?(abs_path)
200
+
201
+ tree = parse_content_to_tree(File.read(abs_path))
202
+ process_raw_nodes!(tree)
203
+ process_includes(tree, File.dirname(abs_path), visited_files)
204
+ build_grammar_tree(tree)
205
+ end
206
+
233
207
  # Process raw_grammar/raw_override/raw_patterns nodes in-place
234
208
  #
235
209
  # This is needed when included files contain grammar blocks or
@@ -426,21 +426,10 @@ module Rng
426
426
  if attr.value
427
427
  value_str = attr.value.is_a?(Value) ? attr.value.value : attr.value.to_s
428
428
  result += "\"#{value_str}\""
429
- # Check for choice of values
429
+ # Check for choice (values, text, refs, etc.) — delegate to the
430
+ # canonical choice builder so every member type is preserved
430
431
  elsif attr.choice
431
- # Check if choice contains Value objects
432
- choice_obj = attr.choice
433
- if choice_obj.value.is_a?(Array)
434
- # Choice with array of Value objects
435
- values = choice_obj.value.map do |v|
436
- v_str = v.is_a?(Value) ? v.value : v.to_s
437
- "\"#{escape_rnc_string(v_str)}\""
438
- end
439
- result += values.join(' | ')
440
- else
441
- # Other choice patterns
442
- result += build_pattern(attr.choice)
443
- end
432
+ result += build_choice(attr.choice)
444
433
  # Check for datatype
445
434
  elsif attr.data
446
435
  result += if attr.data.type
@@ -261,6 +261,23 @@ module Rng
261
261
  (whitespace >> annotation).repeat(1)
262
262
  end
263
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
+
264
281
  # Notation/annotation: [ key = "value" ] or just [ ... ]
265
282
  # Notations are only valid when attached to patterns using >>, not as standalone preamble items
266
283
  rule(:notation) do
@@ -312,14 +329,18 @@ module Rng
312
329
  value_literal >>
313
330
  (whitespace >> str('|') >> whitespace >> value_literal).repeat(1) >>
314
331
  whitespace >> str(')')).as(:datatype_except) |
315
- # Non-parenthesized choice of value literals: "a" | "b" | "c"
316
- (value_literal >> (whitespace >> str('|') >> whitespace >> value_literal).repeat(1).as(:value_choice)) |
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)) |
317
334
  value_literal |
318
335
  datatype_ref |
319
336
  keyword('text').as(:text_type) |
320
337
  identifier.as(:ref)
321
338
  end
322
339
 
340
+ rule(:attribute_choice_item) do
341
+ keyword('text').as(:text_type) | value_literal | identifier.as(:ref)
342
+ end
343
+
323
344
  rule(:datatype_ref) do
324
345
  identifier.as(:prefix) >> str(':') >> identifier.as(:type) >>
325
346
  (whitespace >> str('{') >> whitespace >>
@@ -381,7 +402,7 @@ module Rng
381
402
  # Named pattern definition (e.g., "myPattern = element foo { text }")
382
403
  rule(:named_pattern) do
383
404
  (doc_comments >> whitespace).maybe.as(:docs) >>
384
- annotations.maybe >>
405
+ (annotation_skip >> whitespace).repeat >>
385
406
  identifier.as(:name) >> whitespace >>
386
407
  (str('|=') | str('&=') | str('=')).as(:operator) >> whitespace >>
387
408
  pattern_list.as(:pattern)
@@ -402,14 +423,18 @@ module Rng
402
423
  (
403
424
  (whitespace >> str('&') >> whitespace >> content_item).repeat(1).as(:interleave_items) |
404
425
  (whitespace >> str('|') >> whitespace >> content_item).repeat(1).as(:choice_items) |
405
- (comma? >> content_item).repeat(1).as(:sequence_items)
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)
406
429
  ).maybe
407
430
  end
408
431
 
409
432
  # Choice is handled at content level, not as separate pattern
410
433
  rule(:content_item) do
411
434
  (doc_comments >> whitespace).maybe >>
412
- annotations.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 >>
413
438
  (element_def | attribute_def |
414
439
  # Datatype subtraction: identifier - ( value|identifier|choice|annotated )
415
440
  (identifier.as(:datatype_name) >>
@@ -668,18 +693,13 @@ module Rng
668
693
  result
669
694
  end
670
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.
671
701
  def self.parse(input)
672
- parser = new
673
- preprocessed = preprocess_hex_escapes(input.strip)
674
- tree = parser.parse(preprocessed)
675
-
676
- # Normalize parse tree
677
- processor = ParseTreeProcessor.new(tree)
678
- normalized = processor.normalize
679
-
680
- # Convert to RNG XML and Grammar object
681
- rng_xml = convert_to_rng(normalized.grammar_tree)
682
- Grammar.from_xml(rng_xml)
702
+ IncludeProcessor.new.parse_content(input)
683
703
  end
684
704
 
685
705
  # Convert RNG schema to RNC