lutaml-lml 0.2.1 → 0.3.0

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.
@@ -4,9 +4,24 @@ require 'json'
4
4
 
5
5
  module Lutaml
6
6
  module Lml
7
+ # Compiles LML model definitions into anonymous
8
+ # Lutaml::Model::Serializable subclasses, hydrates instance data
9
+ # against them, and applies declared serialization mappings. The
10
+ # concern modules (Hydration, Validation, Enums,
11
+ # SerializationMappings, StringFormats) share this class's state.
7
12
  class ModelCompiler
13
+ autoload :Hydration, "lutaml/lml/model_compiler/hydration"
14
+ autoload :Validation, "lutaml/lml/model_compiler/validation"
15
+ autoload :Enums, "lutaml/lml/model_compiler/enums"
16
+ autoload :SerializationMappings, "lutaml/lml/model_compiler/serialization_mappings"
17
+ autoload :StringFormats, "lutaml/lml/model_compiler/string_formats"
18
+
8
19
  class ValidationError < Error; end
9
20
 
21
+ # The default generated namespace nests under Lutaml::Lml so
22
+ # compiled constants never collide with top-level constants.
23
+ DEFAULT_NAMESPACE = 'Lutaml::Lml::Generated'
24
+
10
25
  TYPE_MAP = {
11
26
  'String' => :string,
12
27
  'string' => :string,
@@ -20,13 +35,19 @@ module Lutaml
20
35
  'date_time' => :date_time,
21
36
  'DateTime' => :date_time,
22
37
  'Time' => :time,
23
- 'Uri' => :string
38
+ 'Uri' => :string,
24
39
  }.freeze
25
40
 
26
41
  # Tokens that denote unbounded cardinality in LML attribute
27
42
  # declarations. Anything else must parse as an Integer.
28
43
  UNBOUNDED_TOKENS = %w[* n N unbounded].freeze
29
44
 
45
+ include Hydration
46
+ include Validation
47
+ include Enums
48
+ include SerializationMappings
49
+ include StringFormats
50
+
30
51
  def initialize(namespace: nil, artifact_paths: {}, default_artifact: nil)
31
52
  @namespace = namespace
32
53
  @artifact_paths = artifact_paths
@@ -81,138 +102,6 @@ module Lutaml
81
102
 
82
103
  private
83
104
 
84
- def validate_instance(instance, errors, path = 'root')
85
- if instance.instance && Array(instance.attributes).empty?
86
- validate_instance(instance.instance, errors, "#{path}.instance")
87
- return
88
- end
89
-
90
- attrs = Array(instance.attributes)
91
- type_attr = attrs.find { |a| a.name == 'type' }
92
- type_name = type_attr ? type_attr.value.to_s : instance.type
93
- klass = @compiled[demodulize(type_name)]
94
- unless klass
95
- errors << "#{path}: unknown type '#{type_name}'"
96
- return
97
- end
98
-
99
- schema_attrs = klass.attributes.keys.map(&:to_s)
100
- present_attrs = attrs.map(&:name)
101
- schema_set = schema_attrs.to_set
102
- present_set = present_attrs.to_set
103
-
104
- unknown = present_set - schema_set - Set.new(%w[type])
105
- unknown.each do |name|
106
- errors << "#{path}.#{name}: attribute not defined on #{type_name}"
107
- end
108
-
109
- required = klass.attributes.select do |_k, v|
110
- !v.collection? && !v.options.key?(:default)
111
- end.keys.map(&:to_s).to_set
112
-
113
- missing = required - present_set
114
- missing.each do |name|
115
- errors << "#{path}.#{name}: required attribute missing (cardinality min >= 1)"
116
- end
117
-
118
- attrs.each do |attr|
119
- next unless schema_attrs.include?(attr.name)
120
-
121
- attr_def = klass.attributes[attr.name.to_sym]
122
- next unless attr_def
123
-
124
- next unless attr_def.collection? && attr.instances.any?
125
-
126
- attr.instances.each_with_index do |nested, i|
127
- validate_instance(nested, errors, "#{path}.#{attr.name}[#{i}]")
128
- end
129
- end
130
-
131
- return unless instance.instance
132
-
133
- validate_instance(instance.instance, errors, "#{path}.instance")
134
- end
135
-
136
- def hydrate_instance(instance)
137
- inner = instance.instance
138
- return hydrate_instance(inner) if inner && Array(instance.attributes).empty?
139
-
140
- type_name = resolve_instance_type(instance)
141
- klass = @compiled[type_name]
142
- return hydrate_as_untyped(instance, type_name) unless klass
143
-
144
- attrs = extract_instance_attributes(instance, klass)
145
- klass.new(**attrs)
146
- end
147
-
148
- # An inline map's cargo is {key_value_map: [{key:, value:}, ...]} with
149
- # literal-shaped keys/values ({string: ..}, {number: ..}, {boolean: ..});
150
- # materialize it as a plain Hash (issue #17: maps hydrate as data).
151
- def hydrate_map_cargo(cargo)
152
- cargo[:key_value_map].to_h do |pair|
153
- [hydrate_scalar(pair[:key]).to_s, hydrate_scalar(pair[:value])]
154
- end
155
- end
156
-
157
- def hydrate_scalar(literal)
158
- literal = literal.value if literal.respond_to?(:value)
159
- literal.is_a?(Hash) ? literal.values.first : literal
160
- end
161
-
162
- def resolve_instance_type(instance)
163
- # L6: the explicit isa keyword is the type override; a `type`
164
- # attribute is ordinary data (pubid's identifier-kind field)
165
- return demodulize(instance.isa.to_s) if instance.isa
166
-
167
- demodulize(instance.type.to_s)
168
- end
169
-
170
- def demodulize(name)
171
- name.split('::').last.to_s
172
- end
173
-
174
- def hydrate_as_untyped(instance, type_name)
175
- { _name: type_name, **extract_raw_attributes(instance) }
176
- end
177
-
178
- def extract_raw_attributes(instance)
179
- instance.each_attribute.each_with_object({}) do |(name, value, nested), hash|
180
- hash[name.to_sym] = resolve_instance_value(value, nested)
181
- end
182
- end
183
-
184
- def extract_instance_attributes(instance, klass)
185
- schema_keys = klass.attributes.keys.to_set
186
- instance.each_attribute.each_with_object({}) do |(name, value, nested), hash|
187
- key = name.to_sym
188
- next unless schema_keys.include?(key)
189
-
190
- hash[key] = resolve_instance_value(value, nested, klass.attributes[key])
191
- end
192
- end
193
-
194
- def resolve_instance_value(value, nested, attr_def = nil)
195
- value = value.value if value.respond_to?(:value)
196
- return hydrate_map_cargo(value) if value.is_a?(Hash) && value.key?(:key_value_map)
197
-
198
- if nested.any?
199
- # A scalar attribute with a single nested instance hydrates to
200
- # the object itself; the declaration is the truth (issue #17).
201
- return hydrate_instance(nested.first) if !attr_def&.collection && nested.one?
202
-
203
- nested.map { |i| hydrate_instance(i) }
204
- elsif value.is_a?(Array)
205
- value
206
- elsif (enum_class = attr_def && enum_class_for(attr_def.type))
207
- value_name = enum_value_name(value.to_s)
208
- description = enum_class.values
209
- .find { |v| v.name == value_name }&.description
210
- enum_class.new(value: value_name, description: description)
211
- elsif !value.nil?
212
- value
213
- end
214
- end
215
-
216
105
  def compile_document(doc)
217
106
  @declared_namespaces = Array(doc.namespaces)
218
107
  @serialization_mappings = Array(doc.serialization_mappings)
@@ -226,18 +115,6 @@ module Lutaml
226
115
  register_string_formats(doc)
227
116
  end
228
117
 
229
- # In-class `mapping <format> { … }` is the shortcut for a sibling
230
- # mapping targeting the enclosing class; hoist them so emission is
231
- # uniform.
232
- def hoist_class_serialization_mappings(doc)
233
- (Array(doc.classes) + Array(doc.data_types)).each do |klass_def|
234
- Array(klass_def.serialization_mappings).each do |mapping|
235
- mapping.target = klass_def.name.to_s
236
- @serialization_mappings << mapping
237
- end
238
- end
239
- end
240
-
241
118
  def resolve_forward_references
242
119
  @forward_refs.each do |class_name, deferred_attrs|
243
120
  klass = @compiled[class_name]
@@ -258,57 +135,7 @@ module Lutaml
258
135
  register(name, compiled_klass)
259
136
  end
260
137
 
261
- # Emit the lutaml-model mapping blocks declared in the LML source:
262
- # mapping yaml { map "full-name", to: "name" }
263
- # becomes klass.yml do |m| m.map "full-name", to: :name end
264
- def apply_declared_mappings(compiled_klass, def_obj)
265
- return unless def_obj.is_a?(UmlClass) || def_obj.is_a?(DataType)
266
-
267
- def_obj.wire_mappings.to_a.group_by(&:format).each do |format, entries|
268
- next unless format && Lutaml::Model::FormatRegistry.registered?(format.to_sym)
269
-
270
- compiled_klass.public_send(format) do |mapping|
271
- entries.each { |entry| mapping.map(entry.wire, to: entry.to.to_sym) }
272
- end
273
- end
274
- end
275
-
276
138
  alias compile_data_type compile_class
277
-
278
- EnumValue = Struct.new(:name, :description, :payload)
279
-
280
- def compile_enum(enum_def)
281
- name = enum_def.name.to_s
282
- expand_from_table(enum_def)
283
- values = extract_enum_values(enum_def)
284
-
285
- payload_fields = Array(enum_def.member_fields).map(&:name).map(&:to_s)
286
- compiled_klass = Class.new(Lutaml::Model::Serializable) do
287
- attribute :value, :string, default: values.first.name
288
- attribute :description, :string
289
- payload_fields.each do |field|
290
- attribute field.to_sym, :string
291
- end
292
-
293
- define_method(:to_s) { value }
294
- end
295
-
296
- values.each do |value|
297
- accessor = value.name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')
298
- compiled_klass.define_singleton_method(accessor) do
299
- new({ value: value.name, description: value.description }.merge(value.payload || {}))
300
- end
301
- end
302
- compiled_klass.define_singleton_method(:values) { values }
303
-
304
- @enum_names << name
305
- @enum_classes[name] = compiled_klass
306
- register(name, compiled_klass)
307
- end
308
-
309
- # Builds a compiled Serializable subclass from a class or data-type
310
- # definition: declares attributes and stashes forward-referenced
311
- # attributes for post-registration resolution.
312
139
  def build_compiled_class(def_obj)
313
140
  all_attrs = build_attributes(def_obj)
314
141
  immediate, deferred = all_attrs.partition do |(_, raw_type, type, _)|
@@ -339,125 +166,6 @@ module Lutaml
339
166
  compiled_klass
340
167
  end
341
168
 
342
- # Apply a lutaml-model XML mapping to each compiled class so they
343
- # support from_xml/to_xml. Runs after forward references are resolved
344
- # so all attributes (immediate + deferred) get element mappings.
345
- def apply_xml_mappings
346
- @compiled.each do |name, klass|
347
- next if @enum_names.include?(name)
348
-
349
- custom = serialization_mapping_for(name, 'xml')
350
- next apply_custom_xml_mapping(klass, custom) if custom
351
-
352
- apply_xml_mapping(klass, name)
353
- end
354
- end
355
-
356
- def apply_xml_mapping(klass, root_name)
357
- attr_names = klass.attributes.keys
358
- return if attr_names.empty?
359
-
360
- klass.xml do |mapping|
361
- mapping.element(root_name)
362
- attr_names.each do |attr_name|
363
- mapping.map_element(attr_name.to_s, to: attr_name)
364
- end
365
- end
366
- end
367
-
368
- # RS 3010 extension: key-value mapping blocks for the declared
369
- # formats (yaml/json/toml share lutaml-model's key_value model).
370
- def apply_key_value_mappings
371
- @compiled.each do |name, klass|
372
- next if @enum_names.include?(name)
373
-
374
- custom = serialization_mapping_for(name, 'key_value')
375
- apply_custom_key_value_mapping(klass, custom) if custom
376
- end
377
- end
378
-
379
- def serialization_mapping_for(target, format)
380
- @serialization_mappings.find do |m|
381
- m.target.to_s == target && serialization_format(m.format) == format
382
- end
383
- end
384
-
385
- def serialization_format(format)
386
- %w[yaml json toml].include?(format.to_s) ? 'key_value' : format.to_s
387
- end
388
-
389
- def apply_custom_xml_mapping(klass, mapping)
390
- check_namespace_ambiguity(mapping)
391
- ns_class = namespace_class_for(mapping.namespace_ref) if mapping.namespace_ref
392
- rule_ns = mapping.rules.select { |r| r.kind == 'namespace' }
393
- .to_h { |r| [r.field, namespace_class_for(r.field)] }
394
- klass.xml do |m|
395
- m.element(mapping.element_name) if mapping.element_name
396
- m.namespace(ns_class) if ns_class
397
- m.mixed_content if mapping.mixed_content
398
- mapping.rules.each do |rule|
399
- case rule.kind
400
- when 'element' then m.map_element(rule.wire, to: rule.field.to_sym)
401
- when 'attribute' then m.map_attribute(rule.wire, to: rule.field.to_sym)
402
- when 'content' then m.map_content(to: rule.field.to_sym)
403
- when 'namespace' then m.namespace(rule_ns[rule.field])
404
- end
405
- end
406
- end
407
- end
408
-
409
- def apply_custom_key_value_mapping(klass, mapping)
410
- formats = mapping.format.to_s == 'key_value' ? %w[yaml json toml] : [mapping.format.to_s]
411
- klass.instance_eval do
412
- formats.each do |format|
413
- public_send(format) do |m|
414
- mapping.rules.each do |rule|
415
- case rule.kind
416
- when 'map' then m.map(rule.wire, to: rule.field.to_sym)
417
- when 'key' then m.map_key(to_instance: rule.field.to_sym)
418
- when 'value'
419
- if rule.mode == 'as_attribute'
420
- m.map_value(as_attribute: rule.field.to_sym)
421
- else
422
- m.map_value(to_instance: rule.field.to_sym)
423
- end
424
- end
425
- end
426
- end
427
- end
428
- end
429
- end
430
-
431
- def check_namespace_ambiguity(mapping)
432
- return unless mapping.format.to_s == 'xml'
433
- return if mapping.namespace_ref || @declared_namespaces.length < 2
434
-
435
- raise ArgumentError,
436
- "mapping #{mapping.format} #{mapping.target}: ambiguous default " \
437
- "namespace — #{@declared_namespaces.length} namespaces in scope " \
438
- "(#{@declared_namespaces.map(&:name).join(', ')}); declare one with " \
439
- "'namespace <name>'"
440
- end
441
-
442
- def namespace_class_for(ref)
443
- return nil if ref.nil?
444
-
445
- require 'lutaml/xml/namespace' unless defined?(Lutaml::Xml::Namespace)
446
-
447
- ns_class = @namespace_classes[ref]
448
- return ns_class if ns_class
449
-
450
- declared = @declared_namespaces.find { |ns| ns.name.to_s == ref }
451
- raise ArgumentError, "unknown namespace '#{ref}'" unless declared
452
-
453
- ns_class = Class.new(Lutaml::Xml::Namespace)
454
- uri = declared.uri
455
- prefix = declared.prefix.to_s
456
- ns_class.define_singleton_method(:uri) { uri }
457
- ns_class.define_singleton_method(:prefix_default) { prefix }
458
- @namespace_classes[ref] = ns_class
459
- end
460
-
461
169
  def build_attributes(klass_def)
462
170
  Array(klass_def.attributes).map do |attr|
463
171
  attr_name = attr.name.to_sym
@@ -468,33 +176,12 @@ module Lutaml
468
176
  end
469
177
  end
470
178
 
471
- # RS 3001 §Attribute: `values { v1, v2 }` expands into an anonymous
472
- # enum baked from the inline set; the attribute is typed with it.
473
- def resolve_values_type(klass_def, attr)
474
- return attr.type.to_s unless attr.value_set&.any?
475
-
476
- enum_name = anonymous_enum_name(klass_def.name, attr.name)
477
- members = attr.value_set.map { |v| ::Lutaml::Lml::TopElementAttribute.new(name: v.to_s) }
478
- compile_enum(::Lutaml::Lml::Enum.new(name: enum_name, attributes: members))
479
- enum_name
480
- end
481
-
482
- def anonymous_enum_name(class_name, attr_name)
483
- base = class_name.to_s.split('::').last
484
- attr_part = attr_name.to_s.split('_').map(&:capitalize).join
485
- "#{base}#{attr_part}Values"
486
- end
487
-
488
179
  def resolve_type(type_name)
489
- if TYPE_MAP.key?(type_name)
490
- TYPE_MAP[type_name]
491
- elsif type_name.start_with?('reference:(')
492
- :string
493
- elsif @compiled.key?(type_name)
494
- @compiled[type_name]
495
- else
496
- :string
497
- end
180
+ return TYPE_MAP[type_name] if TYPE_MAP.key?(type_name)
181
+ return :string if type_name.start_with?('reference:(')
182
+ return @compiled[type_name] if @compiled.key?(type_name)
183
+
184
+ :string
498
185
  end
499
186
 
500
187
  def build_options(attr)
@@ -510,9 +197,9 @@ module Lutaml
510
197
  min = parse_cardinality_value(card.min)
511
198
  max = parse_cardinality_value(card.max)
512
199
 
513
- if max && (max > 1 || max == Float::INFINITY)
514
- options[:collection] = true
515
- elsif max.nil? && min && min > 1
200
+ collection = (max && (max > 1 || max == Float::INFINITY)) ||
201
+ (max.nil? && min && min > 1)
202
+ if collection
516
203
  options[:collection] = true
517
204
  elsif min.zero?
518
205
  options[:default] = nil
@@ -540,9 +227,8 @@ module Lutaml
540
227
  # render_nil is a lutaml-model MAPPING option, not an attribute
541
228
  # option: collect the declaration for the mapping passes
542
229
  def declared_render_nil(attr)
543
- Array(attr.properties).find do |prop|
544
- prop.name.to_s == 'render_nil'
545
- end&.then { |prop| prop.value.to_s == 'true' }
230
+ prop = Array(attr.properties).find { |prop| prop.name.to_s == 'render_nil' }
231
+ prop ? prop.value.to_s == 'true' : nil
546
232
  end
547
233
 
548
234
  def parse_cardinality_value(val)
@@ -553,104 +239,6 @@ module Lutaml
553
239
  raise ValidationError, "Unrecognized cardinality token: #{val.inspect}"
554
240
  end
555
241
 
556
- # L7a point 3: from_table members are baked at compile time — the
557
- # artifact table's rows become the values, validated against the
558
- # declared member_fields (unknown field = error; missing = nil).
559
- # The member name comes from the row's "name" column.
560
- def expand_from_table(enum_def)
561
- return unless enum_def.from_table_name
562
-
563
- rows = artifact_table_rows(enum_def)
564
- known = Array(enum_def.member_fields).map { |f| f.name.to_s }
565
- rows.each do |row|
566
- row = row.transform_keys(&:to_s)
567
- member_name = row.fetch('name')
568
- unknown = row.keys - known - ['name']
569
- if unknown.any?
570
- raise ArgumentError,
571
- "enum #{enum_def.name}: table row #{member_name.inspect} has " \
572
- "undeclared fields #{unknown.inspect} (declared: #{known.inspect})"
573
- end
574
- payload = row.slice(*known).compact
575
- enum_def.attributes << TopElementAttribute.new(
576
- name: member_name,
577
- properties: payload.map do |field, value|
578
- TopElementAttribute.new(name: field, value: value)
579
- end
580
- )
581
- end
582
- end
583
-
584
- def artifact_table_rows(enum_def)
585
- reference = enum_def.from_table_artifact || @default_artifact
586
- path = artifact_path_for(reference.to_s)
587
- unless path
588
- raise ArgumentError,
589
- "enum #{enum_def.name}: from_table artifact #{reference.inspect} not found"
590
- end
591
-
592
- envelope = JSON.parse(File.read(path))
593
- table = envelope['tables'].to_h[enum_def.from_table_name]
594
- unless table
595
- raise ArgumentError,
596
- "enum #{enum_def.name}: table #{enum_def.from_table_name.inspect} " \
597
- "not found in artifact #{reference.inspect}"
598
- end
599
-
600
- table['rows'].to_a
601
- end
602
-
603
- def extract_enum_values(enum_def)
604
- member_fields = Array(enum_def.member_fields).map do |f|
605
- [f.name.to_s, f.type.to_s]
606
- end
607
- enum_def.attributes.map do |attr|
608
- description = member_description(attr)
609
- payload = extract_member_payload(attr)
610
- validate_member_payload(enum_def, attr, payload, member_fields)
611
- EnumValue.new(attr.name.to_s, description, payload)
612
- end
613
- end
614
-
615
- # The member description rides a nested attribute named `description`
616
- # (bare-member form) or `definition` (RS 3001 `value` blocks); both
617
- # spellings are accepted.
618
- def member_description(attr)
619
- nested = Array(attr.attributes).find do |n|
620
- %w[description definition].include?(n.name.to_s)
621
- end
622
- text = nested&.type || nested&.definition || nested&.value
623
- text.is_a?(Hash) && text.key?(:string) ? text[:string] : text
624
- end
625
-
626
- # L7a: per-member payload lines ride the keyword-attribute
627
- # properties (`abbreviation = "WD"`)
628
- def extract_member_payload(attr)
629
- Array(attr.properties).to_h { |p| [p.name.to_s, p.value&.to_s] }
630
- end
631
-
632
- def validate_member_payload(enum_def, attr, payload, member_fields)
633
- known = member_fields.map(&:first)
634
- unknown = payload.keys - known
635
- return unless unknown.any?
636
-
637
- raise ArgumentError,
638
- "enum #{enum_def.name}: member #{attr.name} has undeclared " \
639
- "payload fields #{unknown.inspect} (declared: #{known.inspect})"
640
- end
641
-
642
- def enum_class_for(type)
643
- type.is_a?(Class) && @enum_classes.value?(type) and return type
644
- end
645
-
646
- def enum_value_name(raw)
647
- raw.split('::').last
648
- end
649
-
650
- # L7b: derived fields declare existence + type; computation is
651
- # bound by the binder from artifact derive specs. The attribute
652
- # participates in mappings like any other (relaton consumes URNs
653
- # from model JSON).
654
242
  def declare_derived_fields(compiled_klass, def_obj)
655
243
  return unless def_obj.is_a?(UmlClass) || def_obj.is_a?(DataType)
656
244
 
@@ -668,35 +256,6 @@ module Lutaml
668
256
  end
669
257
  end
670
258
 
671
- # L5: register grammar-backed string formats. Class-level
672
- # declarations win; a models-block default applies to classes
673
- # without their own. Artifact resolution: the compiler's
674
- # artifacts map (name => path), else the string as a path.
675
- def register_string_formats(doc)
676
- defaults = doc.packages.flat_map(&:default_string_formats)
677
- doc.classes.each do |class_def|
678
- declared = Array(class_def.string_formats)
679
- declared.each { |fmt| register_string_format(class_def.name.to_s, fmt) }
680
-
681
- declared_formats = declared.map(&:format)
682
- defaults.reject { |fmt| declared_formats.include?(fmt.format) }
683
- .each { |fmt| register_string_format(class_def.name.to_s, fmt) }
684
- end
685
- end
686
-
687
- def register_string_format(class_name, fmt)
688
- artifact_path = artifact_path_for(fmt.artifact.to_s)
689
- return unless artifact_path
690
-
691
- compiled = @compiled[class_name]
692
- return unless compiled
693
-
694
- Parsanol::PARG::Lutaml.register(compiled,
695
- format_name: fmt.format.to_sym,
696
- artifact: artifact_path,
697
- entry: fmt.root.to_s)
698
- end
699
-
700
259
  def artifact_path_for(reference)
701
260
  mapped = @artifact_paths && @artifact_paths[reference]
702
261
  return mapped if mapped && File.exist?(mapped.to_s)
@@ -721,7 +280,20 @@ module Lutaml
721
280
  @compiled[name] = klass
722
281
  return unless @namespace
723
282
 
724
- @namespace.const_set(name, klass)
283
+ namespace_module.const_set(name, klass)
284
+ end
285
+
286
+ def namespace_module
287
+ return @namespace if @namespace.is_a?(Module)
288
+
289
+ @namespace_module ||= begin
290
+ chain = @namespace.to_s.split('::')
291
+ chain.reduce(Object) do |parent, name|
292
+ parent.const_get(name)
293
+ rescue NameError
294
+ parent.const_set(name, Module.new)
295
+ end
296
+ end
725
297
  end
726
298
  end
727
299
  end
@@ -14,11 +14,59 @@ module Lutaml
14
14
  rule(visibility_modifier: simple(:visibility_value)) do
15
15
  VISIBILITY_MAP.fetch(visibility_value.to_s, 'public')
16
16
  end
17
- # Global scalar normalizer: applied to every leaf in the parse
18
- # tree (names may legally contain trailing spaces via
19
- # class_name_chars). Binding is named :value to make the breadth
20
- # explicit — it is not tied to any one grammar rule.
21
- rule(simple(:value)) { value.nil? ? value : value.to_s.strip }
17
+ # An empty `until` capture is an empty sequence, not an empty
18
+ # string: `""` parses the dq_string body as [] (parsanol
19
+ # repetition semantics). A present, zero-length string value is
20
+ # distinct from an absent one (RS 3001 §String values); only the
21
+ # :string capture is normalized, other empty sequences stay [].
22
+ rule(string: sequence(:empty_string)) { { string: '' } }
23
+
24
+ # RS 3001 §String values: the only two escapes are backslash-quote
25
+ # and backslash-backslash (the grammar rejects every other
26
+ # backslash), so unescaping is removing the backslash before
27
+ # those two characters.
28
+ rule(string: simple(:escaped)) { { string: Lutaml::Lml::Transform.unescape(escaped.to_s) } }
29
+
30
+ # Rule blocks evaluate in a Parsanol::Context, so this helper is
31
+ # a class method the block reaches through the class.
32
+ # @param body [String] raw string body as captured between quotes
33
+ # @return [String] the value with \" and \\ resolved
34
+ def self.unescape(body)
35
+ body.gsub(/\\(["'\\])/) { Regexp.last_match(1) }
36
+ end
37
+
38
+ # Global scalar normalizer: every leaf becomes a string (or stays
39
+ # nil). No stripping — name and type tokens are interior-bounded
40
+ # in the grammar (class_run/type_run/quoted_name_run), so quoted
41
+ # string values keep their padding verbatim (RS 3001 §String
42
+ # values).
43
+ rule(simple(:value)) { value.nil? ? value : value.to_s }
44
+
45
+ # Block captures (definition bodies, block comments) read their
46
+ # surrounding layout as content — a block's first line break and
47
+ # closing indentation are layout, not prose. parsanol only fires
48
+ # hash-keyed rules at selected tree positions, so the strip is a
49
+ # post-pass walk, not a rule.
50
+ BLOCK_KEYS = %i[definition comments].freeze
51
+
52
+ def apply(tree, context = nil)
53
+ strip_block_layout(super)
54
+ end
55
+
56
+ def strip_block_layout(node)
57
+ case node
58
+ when Hash
59
+ node.each_with_object({}) do |(key, value), result|
60
+ result[key] = if BLOCK_KEYS.include?(key) && value.is_a?(String)
61
+ value.strip
62
+ else
63
+ strip_block_layout(value)
64
+ end
65
+ end
66
+ when Array then node.map { |item| strip_block_layout(item) }
67
+ else node
68
+ end
69
+ end
22
70
  end
23
71
  end
24
72
  end
@@ -14,7 +14,8 @@ module Lutaml
14
14
  'Integer' => /\A-?\d+\z/,
15
15
  'Number' => /\A-?\d+\z/,
16
16
  'Float' => /\A-?\d+\.\d+\z/,
17
- 'Boolean' => /\A(true|false)\z/
17
+ 'Boolean' => /\A(true|false)\z/,
18
+ 'Null' => /\Anull\z/
18
19
  }.freeze
19
20
 
20
21
  COLLECTION_KEYS = %i[properties attributes instances].freeze
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
- VERSION = "0.2.1"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end