rng 0.3.7 → 0.3.9

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.
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'parslet'
4
- require 'nokogiri'
5
4
  require 'set'
6
5
 
7
6
  module Rng
@@ -261,6 +260,23 @@ module Rng
261
260
  (whitespace >> annotation).repeat(1)
262
261
  end
263
262
 
263
+ # Match a `[ ... ]` annotation WITHOUT capturing anything (produces a raw
264
+ # slice that is discarded in a sequence). Strings and nested brackets are
265
+ # matched as raw characters so no `.as` capture turns the surrounding
266
+ # content item into an array. Used to drop a content-position annotation.
267
+ rule(:annotation_skip) do
268
+ str('[') >> annotation_skip_inner.repeat >> str(']')
269
+ end
270
+
271
+ rule(:annotation_skip_inner) do
272
+ (str('"""') >> (str('"""').absent? >> any).repeat >> str('"""')) |
273
+ (str("'''") >> (str("'''").absent? >> any).repeat >> str("'''")) |
274
+ (str('"') >> ((str('\\') >> any) | (str('"').absent? >> match('[\u0000-\u001F\u007F]').absent? >> any)).repeat >> str('"')) |
275
+ (str("'") >> ((str('\\') >> any) | (str("'").absent? >> match('[\u0000-\u001F\u007F]').absent? >> any)).repeat >> str("'")) |
276
+ (str('[') >> annotation_skip_inner.repeat >> str(']')) |
277
+ (str('[').absent? >> str(']').absent? >> str('"').absent? >> str("'").absent? >> any)
278
+ end
279
+
264
280
  # Notation/annotation: [ key = "value" ] or just [ ... ]
265
281
  # Notations are only valid when attached to patterns using >>, not as standalone preamble items
266
282
  rule(:notation) do
@@ -312,14 +328,18 @@ module Rng
312
328
  value_literal >>
313
329
  (whitespace >> str('|') >> whitespace >> value_literal).repeat(1) >>
314
330
  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)) |
331
+ # Non-parenthesized choice of items: "a" | "b", text | "a", "a" | ref, text | ref
332
+ (attribute_choice_item >> (whitespace >> str('|') >> whitespace >> attribute_choice_item).repeat(1).as(:attribute_choice)) |
317
333
  value_literal |
318
334
  datatype_ref |
319
335
  keyword('text').as(:text_type) |
320
336
  identifier.as(:ref)
321
337
  end
322
338
 
339
+ rule(:attribute_choice_item) do
340
+ keyword('text').as(:text_type) | value_literal | identifier.as(:ref)
341
+ end
342
+
323
343
  rule(:datatype_ref) do
324
344
  identifier.as(:prefix) >> str(':') >> identifier.as(:type) >>
325
345
  (whitespace >> str('{') >> whitespace >>
@@ -381,7 +401,7 @@ module Rng
381
401
  # Named pattern definition (e.g., "myPattern = element foo { text }")
382
402
  rule(:named_pattern) do
383
403
  (doc_comments >> whitespace).maybe.as(:docs) >>
384
- annotations.maybe >>
404
+ (annotation_skip >> whitespace).repeat >>
385
405
  identifier.as(:name) >> whitespace >>
386
406
  (str('|=') | str('&=') | str('=')).as(:operator) >> whitespace >>
387
407
  pattern_list.as(:pattern)
@@ -402,14 +422,18 @@ module Rng
402
422
  (
403
423
  (whitespace >> str('&') >> whitespace >> content_item).repeat(1).as(:interleave_items) |
404
424
  (whitespace >> str('|') >> whitespace >> content_item).repeat(1).as(:choice_items) |
405
- (comma? >> content_item).repeat(1).as(:sequence_items)
425
+ # A comma is required between top-level sequence items; a comma-less
426
+ # continuation would greedily swallow the following definition.
427
+ (whitespace >> comma >> whitespace >> content_item).repeat(1).as(:sequence_items)
406
428
  ).maybe
407
429
  end
408
430
 
409
431
  # Choice is handled at content level, not as separate pattern
410
432
  rule(:content_item) do
411
433
  (doc_comments >> whitespace).maybe >>
412
- annotations.maybe >>
434
+ # Drop any content-position bracket annotation(s) before the item so the
435
+ # following pattern still matches (annotations are not modelled here).
436
+ (annotation_skip >> whitespace).repeat >>
413
437
  (element_def | attribute_def |
414
438
  # Datatype subtraction: identifier - ( value|identifier|choice|annotated )
415
439
  (identifier.as(:datatype_name) >>
@@ -668,18 +692,13 @@ module Rng
668
692
  result
669
693
  end
670
694
 
695
+ # Parse RNC content into a Grammar.
696
+ #
697
+ # Delegates to {IncludeProcessor#parse_content} so that this entry point
698
+ # and {Rng.parse_rnc} share a single canonical pipeline. With no location,
699
+ # include directives are not resolved.
671
700
  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)
701
+ IncludeProcessor.new.parse_content(input)
683
702
  end
684
703
 
685
704
  # Convert RNG schema to RNC
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'nokogiri'
3
+ require 'moxml'
4
4
  require 'set'
5
5
 
6
6
  module Rng
7
7
  # RncToRngConverter converts RNC parse trees to RNG XML format.
8
8
  #
9
9
  # This class takes the parse tree output from the Parslet RNC parser and
10
- # converts it to RNG XML using Nokogiri's XML builder. The resulting XML
10
+ # converts it to RNG XML using Moxml's Nokogiri-compatible XML builder.
11
+ # The resulting XML
11
12
  # can then be deserialized into Grammar objects using Lutaml::Model.
12
13
  #
13
14
  # @example Convert a parse tree to RNG XML
@@ -17,6 +18,8 @@ module Rng
17
18
  # grammar = Rng::Grammar.from_xml(rng_xml)
18
19
  class RncToRngConverter
19
20
  RNG_NAMESPACE = 'http://relaxng.org/ns/structure/1.0'
21
+ OCCURRENCE_TAGS = { '*' => 'zeroOrMore', '+' => 'oneOrMore', '?' => 'optional' }.freeze
22
+ private_constant :OCCURRENCE_TAGS
20
23
 
21
24
  # Convert a parse tree to RNG XML
22
25
  #
@@ -36,65 +39,37 @@ module Rng
36
39
  # Validate that element notations in preamble annotations are only used with element/attribute patterns
37
40
  validate_preamble_element_notation_usage(tree[:preamble_items], tree[:definitions])
38
41
 
39
- builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
42
+ # Build without Context#build: its block is instance_eval'd, which
43
+ # would re-bind self and turn every converter helper call into a
44
+ # builder method_missing tag. A lambda preserves self; Moxml's tag
45
+ # blocks use block.call, so caller context survives down the tree.
46
+ builder = Moxml::Builder.new(Moxml::Context.new)
47
+ emit = lambda do |xml|
48
+ xml.declaration
40
49
  grammar_attrs = { xmlns: 'http://relaxng.org/ns/structure/1.0' }
41
50
  if @has_documentation
42
51
  grammar_attrs[:'xmlns:a'] =
43
52
  'http://relaxng.org/ns/compatibility/annotations/1.0'
44
53
  end
54
+ if tree[:namespace]
55
+ grammar_attrs[:ns] =
56
+ process_string_literal(tree[:namespace][:namespace_uri])
57
+ end
58
+ if tree[:datatype_library]
59
+ grammar_attrs[:datatypeLibrary] =
60
+ process_string_literal(tree[:datatype_library][:uri])
61
+ elsif tree[:datatype_map] && !tree[:datatype_map].empty?
62
+ # From ParseTreeProcessor - datatype_map is {prefix => uri}
63
+ # Use the first (typically only) datatype library
64
+ grammar_attrs[:datatypeLibrary] = tree[:datatype_map].values.first
65
+ end
45
66
 
46
67
  xml.grammar(grammar_attrs) do
47
- # Add namespace if present
48
- if tree[:namespace]
49
- xml.parent[:ns] =
50
- process_string_literal(tree[:namespace][:namespace_uri])
51
- end
52
-
53
- # Add datatype library if present
54
- if tree[:datatype_library]
55
- xml.parent[:datatypeLibrary] =
56
- process_string_literal(tree[:datatype_library][:uri])
57
- elsif tree[:datatype_map] && !tree[:datatype_map].empty?
58
- # From ParseTreeProcessor - datatype_map is {prefix => uri}
59
- # Use the first (typically only) datatype library
60
- xml.parent[:datatypeLibrary] = tree[:datatype_map].values.first
61
- end
62
-
63
- # If no explicit start but we have top-level elements, wrap them in start
64
- has_explicit_start = tree[:start] && tree[:start][:start_pattern]
65
- has_top_elements = tree[:definitions]&.any? do |d|
66
- d.key?(:top_element)
67
- end
68
- has_top_choice = tree[:definitions]&.any? do |d|
69
- d.key?(:top_choice)
70
- end
71
-
72
- if has_explicit_start
73
- # Process explicit start pattern
74
- xml.start do
75
- add_documentation(xml, tree[:start]) if tree[:start][:docs]
76
- process_pattern_list(xml, tree[:start][:start_pattern])
77
- end
78
- elsif has_top_choice
79
- # No explicit start, but has top-level choice - wrap in start with choice
80
- first_choice = tree[:definitions].find { |d| d.key?(:top_choice) }
81
- xml.start do
82
- xml.choice do
83
- process_content_item(xml, first_choice[:top_choice][:first])
84
- first_choice[:top_choice][:choice_items]&.each do |item|
85
- process_content_item(xml, item)
86
- end
87
- end
88
- end
89
- elsif has_top_elements
90
- # No explicit start, but has top-level elements - wrap first one in start
91
- xml.start do
92
- first_element = tree[:definitions].find do |d|
93
- d.key?(:top_element)
94
- end
95
- process_content_item(xml, first_element[:top_element])
96
- end
97
- end
68
+ # A leading `start` (tree[:start]) or a non-leading one (emitted as
69
+ # <start> in the definitions loop) both suppress auto-wrapping.
70
+ has_explicit_start =
71
+ (tree[:start] && tree[:start][:start_pattern]) || nonleading_start?(tree)
72
+ emit_grammar_start(xml, tree)
98
73
 
99
74
  # Process named patterns and remaining top-level elements
100
75
  tree[:definitions]&.each_with_index do |def_item, idx|
@@ -162,6 +137,8 @@ module Rng
162
137
  # Include without override block
163
138
  xml.include(href: href)
164
139
  end
140
+ elsif def_item.key?(:name) && keyword_start_name?(def_item[:name])
141
+ emit_nonleading_start(xml, def_item)
165
142
  elsif def_item.key?(:name)
166
143
  # Named pattern - handle augmentation operators
167
144
  name = process_identifier(def_item[:name])
@@ -204,7 +181,8 @@ module Rng
204
181
  end
205
182
  end
206
183
 
207
- builder.to_xml
184
+ emit.call(builder)
185
+ builder.document.to_xml
208
186
  end
209
187
 
210
188
  private
@@ -256,6 +234,11 @@ module Rng
256
234
  end
257
235
 
258
236
  # Add annotations (foreign attributes and elements) if present
237
+ #
238
+ # NOTE: currently uncalled; kept for upcoming annotation emission. The
239
+ # foreign-attribute branch below needs the enclosing element, which the
240
+ # Moxml builder does not expose yet — port it together with that hook
241
+ # rather than reintroducing Nokogiri-style parent mutation.
259
242
  def add_annotations(xml, item, processor = nil)
260
243
  return unless item[:annotations]
261
244
 
@@ -440,6 +423,66 @@ module Rng
440
423
  end
441
424
  end
442
425
 
426
+ # Emit the grammar's <start>: an explicit leading start, or (when there is
427
+ # none) auto-wrap the first top-level choice/element. A non-leading start is
428
+ # emitted by the definitions loop, so here it only suppresses auto-wrapping.
429
+ def emit_grammar_start(xml, tree)
430
+ if tree[:start] && tree[:start][:start_pattern]
431
+ xml.start do
432
+ add_documentation(xml, tree[:start]) if tree[:start][:docs]
433
+ process_pattern_list(xml, tree[:start][:start_pattern])
434
+ end
435
+ elsif nonleading_start?(tree)
436
+ # Emitted as <start> by the definitions loop.
437
+ elsif (choice = tree[:definitions]&.find { |d| d.key?(:top_choice) })
438
+ emit_start_from_top_choice(xml, choice)
439
+ elsif (element = tree[:definitions]&.find { |d| d.key?(:top_element) })
440
+ xml.start { process_content_item(xml, element[:top_element]) }
441
+ end
442
+ end
443
+
444
+ def emit_start_from_top_choice(xml, first_choice)
445
+ xml.start do
446
+ xml.choice do
447
+ process_content_item(xml, first_choice[:top_choice][:first])
448
+ first_choice[:top_choice][:choice_items]&.each do |item|
449
+ process_content_item(xml, item)
450
+ end
451
+ end
452
+ end
453
+ end
454
+
455
+ # True when a `start = X` / `start |= X` appears outside the leading
456
+ # position and was parsed as a named pattern named "start".
457
+ def nonleading_start?(tree)
458
+ tree[:definitions]&.any? do |d|
459
+ d.key?(:name) && keyword_start_name?(d[:name])
460
+ end || false
461
+ end
462
+
463
+ # True when a named-pattern name is the `start` keyword, and NOT an escaped
464
+ # identifier `\start` (a define literally named "start", which must stay a
465
+ # <define>). The escape survives as a backslash_escape identifier part.
466
+ def keyword_start_name?(name_node)
467
+ process_identifier(name_node) == 'start' && !escaped_identifier?(name_node)
468
+ end
469
+
470
+ def escaped_identifier?(name_node)
471
+ parts = name_node.is_a?(Hash) ? name_node[:identifier_parts] : nil
472
+ Array(parts).any? { |p| p.is_a?(Hash) && p.key?(:backslash_escape) }
473
+ end
474
+
475
+ # Emit a real <start> for a non-leading `start = X` / `start |= X` that was
476
+ # parsed as a named pattern (a <define name="start"> would be wrong).
477
+ def emit_nonleading_start(xml, def_item)
478
+ op = def_item[:operator] ? extract_string(def_item[:operator]) : '='
479
+ combine = { '|=' => 'choice', '&=' => 'interleave' }[op]
480
+ xml.start(combine ? { combine: combine } : {}) do
481
+ add_documentation(xml, def_item) if def_item[:docs]
482
+ process_pattern_list(xml, def_item[:pattern])
483
+ end
484
+ end
485
+
443
486
  # Collect prefixed namespace declarations from preamble items
444
487
  def collect_namespace_prefixes(preamble_items)
445
488
  return unless preamble_items
@@ -707,7 +750,7 @@ module Rng
707
750
  # Bare anyName wildcard: *
708
751
  any_name_info = item[:bare_any_name]
709
752
  except_clause = any_name_info[:any_name_except]
710
- xml.element do
753
+ xml.element({}) do
711
754
  xml.anyName do
712
755
  if except_clause
713
756
  xml.except_ do
@@ -722,6 +765,13 @@ module Rng
722
765
  end
723
766
  end
724
767
 
768
+ def wrap_with_occurrence(xml, occurrence, &block)
769
+ tag = OCCURRENCE_TAGS[occurrence.to_s] if occurrence
770
+ return yield unless tag
771
+
772
+ xml.send(tag, &block)
773
+ end
774
+
725
775
  def process_content_item(xml, item)
726
776
  if item.key?(:type) && item.key?(:name)
727
777
  # Attribute definition (has both :type and :name keys)
@@ -821,19 +871,7 @@ module Rng
821
871
  end
822
872
  end
823
873
 
824
- if occurrence
825
- # Wrap in occurrence element
826
- occurrence_tag = case occurrence.to_s
827
- when '*' then 'zeroOrMore'
828
- when '+' then 'oneOrMore'
829
- when '?' then 'optional'
830
- end
831
- xml.send(occurrence_tag) do
832
- attribute_block.call(xml)
833
- end
834
- else
835
- attribute_block.call(xml)
836
- end
874
+ wrap_with_occurrence(xml, occurrence) { attribute_block.call(xml) }
837
875
  elsif item.key?(:name)
838
876
  # Element definition
839
877
  attrs = {}
@@ -886,7 +924,7 @@ module Rng
886
924
  case element_name_type
887
925
  when :any_name
888
926
  # Generate <anyName> with optional <except>
889
- xml_ctx.element do
927
+ xml_ctx.element({}) do
890
928
  add_documentation(xml_ctx, item) if item[:docs]
891
929
  xml_ctx.anyName do
892
930
  if except_clause
@@ -899,7 +937,7 @@ module Rng
899
937
  end
900
938
  when :ns_name
901
939
  # Generate <nsName> with ns attribute and optional <except>
902
- xml_ctx.element do
940
+ xml_ctx.element({}) do
903
941
  add_documentation(xml_ctx, item) if item[:docs]
904
942
  xml_ctx.nsName(ns: ns_prefix) do
905
943
  if except_clause
@@ -912,7 +950,7 @@ module Rng
912
950
  end
913
951
  when :name_choice
914
952
  # Element with choice of names (e.g., element foo|bar { ... })
915
- xml_ctx.element do
953
+ xml_ctx.element({}) do
916
954
  add_documentation(xml_ctx, item) if item[:docs]
917
955
  xml_ctx.choice do
918
956
  choice_names.each do |name_part|
@@ -939,26 +977,19 @@ module Rng
939
977
  end
940
978
  end
941
979
 
942
- if occurrence
943
- # Wrap in occurrence element
944
- occurrence_tag = case occurrence.to_s
945
- when '*' then 'zeroOrMore'
946
- when '+' then 'oneOrMore'
947
- when '?' then 'optional'
948
- end
949
-
950
- xml.send(occurrence_tag) do
951
- element_block.call(xml)
952
- end
953
- else
954
- element_block.call(xml)
955
- end
980
+ wrap_with_occurrence(xml, occurrence) { element_block.call(xml) }
956
981
  elsif item.key?(:text)
957
- xml.parent << Nokogiri::XML::Node.new('text', xml.doc)
982
+ wrap_with_occurrence(xml, item[:occurrence]) do
983
+ xml.text_
984
+ end
958
985
  elsif item.key?(:empty)
959
- xml.parent << Nokogiri::XML::Node.new('empty', xml.doc)
986
+ wrap_with_occurrence(xml, item[:occurrence]) do
987
+ xml.empty_
988
+ end
960
989
  elsif item.key?(:not_allowed)
961
- xml.parent << Nokogiri::XML::Node.new('notAllowed', xml.doc)
990
+ wrap_with_occurrence(xml, item[:occurrence]) do
991
+ xml.notAllowed_
992
+ end
962
993
  elsif item.key?(:list_content)
963
994
  xml.list do
964
995
  process_list_content(xml, item[:list_content])
@@ -968,21 +999,7 @@ module Rng
968
999
  elsif item.key?(:external_href)
969
1000
  xml.externalRef(href: process_string_literal(item[:external_href]))
970
1001
  elsif item.key?(:group)
971
- occurrence = item[:occurrence]
972
-
973
- if occurrence
974
- occurrence_tag = case occurrence.to_s
975
- when '*' then 'zeroOrMore'
976
- when '+' then 'oneOrMore'
977
- when '?' then 'optional'
978
- end
979
-
980
- xml.send(occurrence_tag) do
981
- xml.group do
982
- process_element_content(xml, item[:group])
983
- end
984
- end
985
- else
1002
+ wrap_with_occurrence(xml, item[:occurrence]) do
986
1003
  xml.group do
987
1004
  process_element_content(xml, item[:group])
988
1005
  end
@@ -997,28 +1014,34 @@ module Rng
997
1014
  ref_name = process_identifier(item[:ref])
998
1015
  raise StandardError, "subtraction operator '-' cannot be used as a pattern" if ref_name == '-'
999
1016
 
1000
- xml.ref(name: ref_name)
1017
+ wrap_with_occurrence(xml, item[:occurrence]) do
1018
+ xml.ref(name: ref_name)
1019
+ end
1001
1020
  elsif item.key?(:prefix) && item.key?(:type)
1002
1021
  # Datatype reference (e.g., xsd:string { maxLength = "100" })
1003
1022
  data_attrs = {
1004
1023
  type: process_identifier(item[:type]),
1005
1024
  datatypeLibrary: 'http://www.w3.org/2001/XMLSchema-datatypes'
1006
1025
  }
1007
- if item[:params]
1008
- xml.data(data_attrs) do
1009
- params = item[:params].is_a?(Array) ? item[:params] : [item[:params]]
1010
- params.each do |param|
1011
- param_name = process_identifier(param[:param_name])
1012
- param_value = process_string_literal(param[:param_value])
1013
- xml.param(param_value, name: param_name)
1026
+ wrap_with_occurrence(xml, item[:occurrence]) do
1027
+ if item[:params]
1028
+ xml.data(data_attrs) do
1029
+ params = item[:params].is_a?(Array) ? item[:params] : [item[:params]]
1030
+ params.each do |param|
1031
+ param_name = process_identifier(param[:param_name])
1032
+ param_value = process_string_literal(param[:param_value])
1033
+ xml.param(param_value, name: param_name)
1034
+ end
1014
1035
  end
1036
+ else
1037
+ xml.data(data_attrs)
1015
1038
  end
1016
- else
1017
- xml.data(data_attrs)
1018
1039
  end
1019
1040
  elsif item.key?(:value)
1020
1041
  # Value literal (string) in element content
1021
- xml.value(process_string_literal(item[:value]))
1042
+ wrap_with_occurrence(xml, item[:occurrence]) do
1043
+ xml.value(process_string_literal(item[:value]))
1044
+ end
1022
1045
  elsif item.key?(:grammar_block)
1023
1046
  # Inline grammar block
1024
1047
  grammar_data = item[:grammar_block]
@@ -1158,11 +1181,9 @@ module Rng
1158
1181
 
1159
1182
  # Process a single list content item (datatype or text with optional occurrence)
1160
1183
  def process_list_item(xml, item)
1161
- occurrence = item[:occurrence]&.to_s
1162
-
1163
1184
  item_block = lambda do |xml_ctx|
1164
1185
  if item.key?(:text)
1165
- xml_ctx.parent << Nokogiri::XML::Node.new('text', xml_ctx.doc)
1186
+ xml_ctx.text_
1166
1187
  elsif item.key?(:prefix)
1167
1188
  # Datatype reference
1168
1189
  data_attrs = {
@@ -1179,18 +1200,7 @@ module Rng
1179
1200
  end
1180
1201
  end
1181
1202
 
1182
- if occurrence
1183
- occurrence_tag = case occurrence
1184
- when '*' then 'zeroOrMore'
1185
- when '+' then 'oneOrMore'
1186
- when '?' then 'optional'
1187
- end
1188
- xml.send(occurrence_tag) do
1189
- item_block.call(xml)
1190
- end
1191
- else
1192
- item_block.call(xml)
1193
- end
1203
+ wrap_with_occurrence(xml, item[:occurrence]) { item_block.call(xml) }
1194
1204
  end
1195
1205
 
1196
1206
  # Process name except clause for wildcards
@@ -1243,18 +1253,17 @@ module Rng
1243
1253
 
1244
1254
  # Process attribute type content (factored out for wildcard support)
1245
1255
  def process_attribute_type(xml, type_info)
1246
- if type_info == 'text' || (type_info.is_a?(Hash) && type_info.key?(:text_type))
1247
- xml.parent << Nokogiri::XML::Node.new('text', xml.doc)
1248
- elsif type_info.is_a?(Hash) && type_info.key?(:value_choice)
1249
- # Choice of value literals
1256
+ if type_info.is_a?(Hash) && type_info.key?(:attribute_choice)
1257
+ # Choice of attribute items (text / value literals / refs)
1250
1258
  xml.choice do
1251
- # First value (before the choice operator)
1252
- xml.value(process_string_literal(type_info[:value]))
1253
- # Remaining values (after | operators)
1254
- type_info[:value_choice].each do |val|
1255
- xml.value(process_string_literal(val[:value]))
1259
+ # The first item is hoisted alongside the rest of the choice
1260
+ emit_attribute_choice_item(xml, type_info)
1261
+ type_info[:attribute_choice].each do |item|
1262
+ emit_attribute_choice_item(xml, item)
1256
1263
  end
1257
1264
  end
1265
+ elsif type_info == 'text' || (type_info.is_a?(Hash) && type_info.key?(:text_type))
1266
+ xml.text_
1258
1267
  elsif type_info.is_a?(Hash) && type_info.key?(:value)
1259
1268
  # Single value literal
1260
1269
  xml.value(process_string_literal(type_info[:value]))
@@ -1282,6 +1291,16 @@ module Rng
1282
1291
  end
1283
1292
  end
1284
1293
 
1294
+ def emit_attribute_choice_item(xml, item)
1295
+ if item.key?(:text_type)
1296
+ xml.text_
1297
+ elsif item.key?(:value)
1298
+ xml.value(process_string_literal(item[:value]))
1299
+ elsif item.key?(:ref)
1300
+ xml.ref(name: process_identifier(item[:ref]))
1301
+ end
1302
+ end
1303
+
1285
1304
  # Process data parameters (e.g., maxLength = "100")
1286
1305
  def process_data_params(xml, params)
1287
1306
  params = [params] unless params.is_a?(Array)
@@ -1402,7 +1421,7 @@ module Rng
1402
1421
  end
1403
1422
 
1404
1423
  # Emit as an empty foreign element (content is annotation-only, ignored in RNG)
1405
- xml.parent.add_child("<#{element_name}/>")
1424
+ xml.create_tag(element_name)
1406
1425
  end
1407
1426
  end
1408
1427
  end