lutaml-lml 0.2.0 → 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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/lutaml/lml/cli.rb +84 -18
  3. data/lib/lutaml/lml/data_processor/value_processing.rb +6 -1
  4. data/lib/lutaml/lml/document_builder.rb +72 -3
  5. data/lib/lutaml/lml/format/adapter/mapping.rb +12 -0
  6. data/lib/lutaml/lml/format/adapter/standard_adapter.rb +41 -12
  7. data/lib/lutaml/lml/formatter/elk.rb +45 -0
  8. data/lib/lutaml/lml/formatter/graphviz/node_formatter.rb +2 -1
  9. data/lib/lutaml/lml/formatter/svg_pdf/path_parser.rb +134 -0
  10. data/lib/lutaml/lml/formatter/svg_pdf.rb +281 -0
  11. data/lib/lutaml/lml/formatter.rb +2 -0
  12. data/lib/lutaml/lml/grammar/lml.artifact.json +7265 -4550
  13. data/lib/lutaml/lml/grammar/lml.parg +230 -115
  14. data/lib/lutaml/lml/layout/elk_engine.rb +15 -0
  15. data/lib/lutaml/lml/layout/elk_graph_builder.rb +129 -0
  16. data/lib/lutaml/lml/layout/elk_svg_markers.rb +45 -0
  17. data/lib/lutaml/lml/layout/elk_svg_renderer.rb +119 -0
  18. data/lib/lutaml/lml/layout/graph_viz_engine.rb +1 -1
  19. data/lib/lutaml/lml/layout.rb +4 -0
  20. data/lib/lutaml/lml/model_compiler/enums.rb +172 -0
  21. data/lib/lutaml/lml/model_compiler/hydration.rb +123 -0
  22. data/lib/lutaml/lml/model_compiler/serialization_mappings.rb +162 -0
  23. data/lib/lutaml/lml/model_compiler/string_formats.rb +42 -0
  24. data/lib/lutaml/lml/model_compiler/validation.rb +65 -0
  25. data/lib/lutaml/lml/model_compiler.rb +46 -453
  26. data/lib/lutaml/lml/models/document.rb +29 -0
  27. data/lib/lutaml/lml/models/operation_parameter.rb +1 -0
  28. data/lib/lutaml/lml/models/reference.rb +23 -0
  29. data/lib/lutaml/lml/models/top_element_attribute.rb +12 -0
  30. data/lib/lutaml/lml/models/uml_class.rb +1 -1
  31. data/lib/lutaml/lml/preprocessor.rb +44 -79
  32. data/lib/lutaml/lml/transform.rb +54 -6
  33. data/lib/lutaml/lml/validator.rb +88 -19
  34. data/lib/lutaml/lml/version.rb +1 -1
  35. data/lib/lutaml/lml.rb +19 -3
  36. metadata +17 -4
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ class ModelCompiler
6
+ # Evaluates hydrated instances against their compiled
7
+ # models (unknown attributes, enum membership, nesting).
8
+ module Validation
9
+ private
10
+
11
+ def validate_instance(instance, errors, path = 'root')
12
+ if instance.instance && Array(instance.attributes).empty?
13
+ validate_instance(instance.instance, errors, "#{path}.instance")
14
+ return
15
+ end
16
+
17
+ attrs = Array(instance.attributes)
18
+ type_attr = attrs.find { |a| a.name == 'type' }
19
+ type_name = type_attr ? type_attr.value.to_s : instance.type
20
+ klass = @compiled[demodulize(type_name)]
21
+ unless klass
22
+ errors << "#{path}: unknown type '#{type_name}'"
23
+ return
24
+ end
25
+
26
+ schema_attrs = klass.attributes.keys.map(&:to_s)
27
+ present_attrs = attrs.map(&:name)
28
+ schema_set = schema_attrs.to_set
29
+ present_set = present_attrs.to_set
30
+
31
+ unknown = present_set - schema_set - Set.new(%w[type])
32
+ unknown.each do |name|
33
+ errors << "#{path}.#{name}: attribute not defined on #{type_name}"
34
+ end
35
+
36
+ required = klass.attributes.values
37
+ .reject { |v| v.collection? || v.options.key?(:default) }
38
+ .to_set { |v| v.name.to_s }
39
+
40
+ missing = required - present_set
41
+ missing.each do |name|
42
+ errors << "#{path}.#{name}: required attribute missing (cardinality min >= 1)"
43
+ end
44
+
45
+ attrs.each do |attr|
46
+ next unless schema_attrs.include?(attr.name)
47
+
48
+ attr_def = klass.attributes[attr.name.to_sym]
49
+ next unless attr_def
50
+
51
+ next unless attr_def.collection? && attr.instances.any?
52
+
53
+ attr.instances.each_with_index do |nested, i|
54
+ validate_instance(nested, errors, "#{path}.#{attr.name}[#{i}]")
55
+ end
56
+ end
57
+
58
+ return unless instance.instance
59
+
60
+ validate_instance(instance.instance, errors, "#{path}.instance")
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -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,117 +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
- def resolve_instance_type(instance)
149
- # L6: the explicit isa keyword is the type override; a `type`
150
- # attribute is ordinary data (pubid's identifier-kind field)
151
- return demodulize(instance.isa.to_s) if instance.isa
152
-
153
- demodulize(instance.type.to_s)
154
- end
155
-
156
- def demodulize(name)
157
- name.split('::').last.to_s
158
- end
159
-
160
- def hydrate_as_untyped(instance, type_name)
161
- { _name: type_name, **extract_raw_attributes(instance) }
162
- end
163
-
164
- def extract_raw_attributes(instance)
165
- instance.each_attribute.each_with_object({}) do |(name, value, nested), hash|
166
- hash[name.to_sym] = resolve_instance_value(value, nested)
167
- end
168
- end
169
-
170
- def extract_instance_attributes(instance, klass)
171
- schema_keys = klass.attributes.keys.to_set
172
- instance.each_attribute.each_with_object({}) do |(name, value, nested), hash|
173
- key = name.to_sym
174
- next unless schema_keys.include?(key)
175
-
176
- hash[key] = resolve_instance_value(value, nested, klass.attributes[key])
177
- end
178
- end
179
-
180
- def resolve_instance_value(value, nested, attr_def = nil)
181
- if nested.any?
182
- nested.map { |i| hydrate_instance(i) }
183
- elsif value.is_a?(Array)
184
- value
185
- elsif (enum_class = attr_def && enum_class_for(attr_def.type))
186
- value_name = enum_value_name(value.to_s)
187
- description = enum_class.values
188
- .find { |v| v.name == value_name }&.description
189
- enum_class.new(value: value_name, description: description)
190
- elsif !value.nil?
191
- value
192
- end
193
- end
194
-
195
105
  def compile_document(doc)
196
106
  @declared_namespaces = Array(doc.namespaces)
197
107
  @serialization_mappings = Array(doc.serialization_mappings)
@@ -205,18 +115,6 @@ module Lutaml
205
115
  register_string_formats(doc)
206
116
  end
207
117
 
208
- # In-class `mapping <format> { … }` is the shortcut for a sibling
209
- # mapping targeting the enclosing class; hoist them so emission is
210
- # uniform.
211
- def hoist_class_serialization_mappings(doc)
212
- (Array(doc.classes) + Array(doc.data_types)).each do |klass_def|
213
- Array(klass_def.serialization_mappings).each do |mapping|
214
- mapping.target = klass_def.name.to_s
215
- @serialization_mappings << mapping
216
- end
217
- end
218
- end
219
-
220
118
  def resolve_forward_references
221
119
  @forward_refs.each do |class_name, deferred_attrs|
222
120
  klass = @compiled[class_name]
@@ -237,57 +135,7 @@ module Lutaml
237
135
  register(name, compiled_klass)
238
136
  end
239
137
 
240
- # Emit the lutaml-model mapping blocks declared in the LML source:
241
- # mapping yaml { map "full-name", to: "name" }
242
- # becomes klass.yml do |m| m.map "full-name", to: :name end
243
- def apply_declared_mappings(compiled_klass, def_obj)
244
- return unless def_obj.is_a?(UmlClass) || def_obj.is_a?(DataType)
245
-
246
- def_obj.wire_mappings.to_a.group_by(&:format).each do |format, entries|
247
- next unless format && Lutaml::Model::FormatRegistry.registered?(format.to_sym)
248
-
249
- compiled_klass.public_send(format) do |mapping|
250
- entries.each { |entry| mapping.map(entry.wire, to: entry.to.to_sym) }
251
- end
252
- end
253
- end
254
-
255
138
  alias compile_data_type compile_class
256
-
257
- EnumValue = Struct.new(:name, :description, :payload)
258
-
259
- def compile_enum(enum_def)
260
- name = enum_def.name.to_s
261
- expand_from_table(enum_def)
262
- values = extract_enum_values(enum_def)
263
-
264
- payload_fields = Array(enum_def.member_fields).map(&:name).map(&:to_s)
265
- compiled_klass = Class.new(Lutaml::Model::Serializable) do
266
- attribute :value, :string, default: values.first.name
267
- attribute :description, :string
268
- payload_fields.each do |field|
269
- attribute field.to_sym, :string
270
- end
271
-
272
- define_method(:to_s) { value }
273
- end
274
-
275
- values.each do |value|
276
- accessor = value.name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')
277
- compiled_klass.define_singleton_method(accessor) do
278
- new({ value: value.name, description: value.description }.merge(value.payload || {}))
279
- end
280
- end
281
- compiled_klass.define_singleton_method(:values) { values }
282
-
283
- @enum_names << name
284
- @enum_classes[name] = compiled_klass
285
- register(name, compiled_klass)
286
- end
287
-
288
- # Builds a compiled Serializable subclass from a class or data-type
289
- # definition: declares attributes and stashes forward-referenced
290
- # attributes for post-registration resolution.
291
139
  def build_compiled_class(def_obj)
292
140
  all_attrs = build_attributes(def_obj)
293
141
  immediate, deferred = all_attrs.partition do |(_, raw_type, type, _)|
@@ -318,125 +166,6 @@ module Lutaml
318
166
  compiled_klass
319
167
  end
320
168
 
321
- # Apply a lutaml-model XML mapping to each compiled class so they
322
- # support from_xml/to_xml. Runs after forward references are resolved
323
- # so all attributes (immediate + deferred) get element mappings.
324
- def apply_xml_mappings
325
- @compiled.each do |name, klass|
326
- next if @enum_names.include?(name)
327
-
328
- custom = serialization_mapping_for(name, 'xml')
329
- next apply_custom_xml_mapping(klass, custom) if custom
330
-
331
- apply_xml_mapping(klass, name)
332
- end
333
- end
334
-
335
- def apply_xml_mapping(klass, root_name)
336
- attr_names = klass.attributes.keys
337
- return if attr_names.empty?
338
-
339
- klass.xml do |mapping|
340
- mapping.root(root_name)
341
- attr_names.each do |attr_name|
342
- mapping.map_element(attr_name.to_s, to: attr_name)
343
- end
344
- end
345
- end
346
-
347
- # RS 3010 extension: key-value mapping blocks for the declared
348
- # formats (yaml/json/toml share lutaml-model's key_value model).
349
- def apply_key_value_mappings
350
- @compiled.each do |name, klass|
351
- next if @enum_names.include?(name)
352
-
353
- custom = serialization_mapping_for(name, 'key_value')
354
- apply_custom_key_value_mapping(klass, custom) if custom
355
- end
356
- end
357
-
358
- def serialization_mapping_for(target, format)
359
- @serialization_mappings.find do |m|
360
- m.target.to_s == target && serialization_format(m.format) == format
361
- end
362
- end
363
-
364
- def serialization_format(format)
365
- %w[yaml json toml].include?(format.to_s) ? 'key_value' : format.to_s
366
- end
367
-
368
- def apply_custom_xml_mapping(klass, mapping)
369
- check_namespace_ambiguity(mapping)
370
- ns_class = namespace_class_for(mapping.namespace_ref) if mapping.namespace_ref
371
- rule_ns = mapping.rules.select { |r| r.kind == 'namespace' }
372
- .to_h { |r| [r.field, namespace_class_for(r.field)] }
373
- klass.xml do |m|
374
- m.root(mapping.element_name) if mapping.element_name
375
- m.namespace(ns_class) if ns_class
376
- m.mixed_content if mapping.mixed_content
377
- mapping.rules.each do |rule|
378
- case rule.kind
379
- when 'element' then m.map_element(rule.wire, to: rule.field.to_sym)
380
- when 'attribute' then m.map_attribute(rule.wire, to: rule.field.to_sym)
381
- when 'content' then m.map_content(to: rule.field.to_sym)
382
- when 'namespace' then m.namespace(rule_ns[rule.field])
383
- end
384
- end
385
- end
386
- end
387
-
388
- def apply_custom_key_value_mapping(klass, mapping)
389
- formats = mapping.format.to_s == 'key_value' ? %w[yaml json toml] : [mapping.format.to_s]
390
- klass.instance_eval do
391
- formats.each do |format|
392
- public_send(format) do |m|
393
- mapping.rules.each do |rule|
394
- case rule.kind
395
- when 'map' then m.map(rule.wire, to: rule.field.to_sym)
396
- when 'key' then m.map_key(to_instance: rule.field.to_sym)
397
- when 'value'
398
- if rule.mode == 'as_attribute'
399
- m.map_value(as_attribute: rule.field.to_sym)
400
- else
401
- m.map_value(to_instance: rule.field.to_sym)
402
- end
403
- end
404
- end
405
- end
406
- end
407
- end
408
- end
409
-
410
- def check_namespace_ambiguity(mapping)
411
- return unless mapping.format.to_s == 'xml'
412
- return if mapping.namespace_ref || @declared_namespaces.length < 2
413
-
414
- raise ArgumentError,
415
- "mapping #{mapping.format} #{mapping.target}: ambiguous default " \
416
- "namespace — #{@declared_namespaces.length} namespaces in scope " \
417
- "(#{@declared_namespaces.map(&:name).join(', ')}); declare one with " \
418
- "'namespace <name>'"
419
- end
420
-
421
- def namespace_class_for(ref)
422
- return nil if ref.nil?
423
-
424
- require "lutaml/xml/namespace" unless defined?(Lutaml::Xml::Namespace)
425
-
426
- ns_class = @namespace_classes[ref]
427
- return ns_class if ns_class
428
-
429
- declared = @declared_namespaces.find { |ns| ns.name.to_s == ref }
430
- raise ArgumentError, "unknown namespace '#{ref}'" unless declared
431
-
432
- ns_class = Class.new(Lutaml::Xml::Namespace)
433
- uri = declared.uri
434
- prefix = declared.prefix.to_s
435
- ns_class.define_singleton_method(:uri) { uri }
436
- ns_class.define_singleton_method(:prefix_default) { prefix }
437
- @namespace_classes[ref] = ns_class
438
- end
439
-
440
169
  def build_attributes(klass_def)
441
170
  Array(klass_def.attributes).map do |attr|
442
171
  attr_name = attr.name.to_sym
@@ -447,33 +176,12 @@ module Lutaml
447
176
  end
448
177
  end
449
178
 
450
- # RS 3001 §Attribute: `values { v1, v2 }` expands into an anonymous
451
- # enum baked from the inline set; the attribute is typed with it.
452
- def resolve_values_type(klass_def, attr)
453
- return attr.type.to_s unless attr.value_set&.any?
454
-
455
- enum_name = anonymous_enum_name(klass_def.name, attr.name)
456
- members = attr.value_set.map { |v| ::Lutaml::Lml::TopElementAttribute.new(name: v.to_s) }
457
- compile_enum(::Lutaml::Lml::Enum.new(name: enum_name, attributes: members))
458
- enum_name
459
- end
460
-
461
- def anonymous_enum_name(class_name, attr_name)
462
- base = class_name.to_s.split('::').last
463
- attr_part = attr_name.to_s.split('_').map(&:capitalize).join
464
- "#{base}#{attr_part}Values"
465
- end
466
-
467
179
  def resolve_type(type_name)
468
- if TYPE_MAP.key?(type_name)
469
- TYPE_MAP[type_name]
470
- elsif type_name.start_with?('reference:(')
471
- :string
472
- elsif @compiled.key?(type_name)
473
- @compiled[type_name]
474
- else
475
- :string
476
- 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
477
185
  end
478
186
 
479
187
  def build_options(attr)
@@ -489,9 +197,9 @@ module Lutaml
489
197
  min = parse_cardinality_value(card.min)
490
198
  max = parse_cardinality_value(card.max)
491
199
 
492
- if max && (max > 1 || max == Float::INFINITY)
493
- options[:collection] = true
494
- elsif max.nil? && min && min > 1
200
+ collection = (max && (max > 1 || max == Float::INFINITY)) ||
201
+ (max.nil? && min && min > 1)
202
+ if collection
495
203
  options[:collection] = true
496
204
  elsif min.zero?
497
205
  options[:default] = nil
@@ -519,9 +227,8 @@ module Lutaml
519
227
  # render_nil is a lutaml-model MAPPING option, not an attribute
520
228
  # option: collect the declaration for the mapping passes
521
229
  def declared_render_nil(attr)
522
- Array(attr.properties).find do |prop|
523
- prop.name.to_s == 'render_nil'
524
- 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
525
232
  end
526
233
 
527
234
  def parse_cardinality_value(val)
@@ -532,104 +239,6 @@ module Lutaml
532
239
  raise ValidationError, "Unrecognized cardinality token: #{val.inspect}"
533
240
  end
534
241
 
535
- # L7a point 3: from_table members are baked at compile time — the
536
- # artifact table's rows become the values, validated against the
537
- # declared member_fields (unknown field = error; missing = nil).
538
- # The member name comes from the row's "name" column.
539
- def expand_from_table(enum_def)
540
- return unless enum_def.from_table_name
541
-
542
- rows = artifact_table_rows(enum_def)
543
- known = Array(enum_def.member_fields).map { |f| f.name.to_s }
544
- rows.each do |row|
545
- row = row.transform_keys(&:to_s)
546
- member_name = row.fetch('name')
547
- unknown = row.keys - known - ['name']
548
- if unknown.any?
549
- raise ArgumentError,
550
- "enum #{enum_def.name}: table row #{member_name.inspect} has " \
551
- "undeclared fields #{unknown.inspect} (declared: #{known.inspect})"
552
- end
553
- payload = row.slice(*known).compact
554
- enum_def.attributes << TopElementAttribute.new(
555
- name: member_name,
556
- properties: payload.map do |field, value|
557
- TopElementAttribute.new(name: field, value: value)
558
- end
559
- )
560
- end
561
- end
562
-
563
- def artifact_table_rows(enum_def)
564
- reference = enum_def.from_table_artifact || @default_artifact
565
- path = artifact_path_for(reference.to_s)
566
- unless path
567
- raise ArgumentError,
568
- "enum #{enum_def.name}: from_table artifact #{reference.inspect} not found"
569
- end
570
-
571
- envelope = JSON.parse(File.read(path))
572
- table = envelope['tables'].to_h[enum_def.from_table_name]
573
- unless table
574
- raise ArgumentError,
575
- "enum #{enum_def.name}: table #{enum_def.from_table_name.inspect} " \
576
- "not found in artifact #{reference.inspect}"
577
- end
578
-
579
- table['rows'].to_a
580
- end
581
-
582
- def extract_enum_values(enum_def)
583
- member_fields = Array(enum_def.member_fields).map do |f|
584
- [f.name.to_s, f.type.to_s]
585
- end
586
- enum_def.attributes.map do |attr|
587
- description = member_description(attr)
588
- payload = extract_member_payload(attr)
589
- validate_member_payload(enum_def, attr, payload, member_fields)
590
- EnumValue.new(attr.name.to_s, description, payload)
591
- end
592
- end
593
-
594
- # The member description rides a nested attribute named `description`
595
- # (bare-member form) or `definition` (RS 3001 `value` blocks); both
596
- # spellings are accepted.
597
- def member_description(attr)
598
- nested = Array(attr.attributes).find do |n|
599
- %w[description definition].include?(n.name.to_s)
600
- end
601
- text = nested&.type || nested&.definition || nested&.value
602
- text.is_a?(Hash) && text.key?(:string) ? text[:string] : text
603
- end
604
-
605
- # L7a: per-member payload lines ride the keyword-attribute
606
- # properties (`abbreviation = "WD"`)
607
- def extract_member_payload(attr)
608
- Array(attr.properties).to_h { |p| [p.name.to_s, p.value&.to_s] }
609
- end
610
-
611
- def validate_member_payload(enum_def, attr, payload, member_fields)
612
- known = member_fields.map(&:first)
613
- unknown = payload.keys - known
614
- return unless unknown.any?
615
-
616
- raise ArgumentError,
617
- "enum #{enum_def.name}: member #{attr.name} has undeclared " \
618
- "payload fields #{unknown.inspect} (declared: #{known.inspect})"
619
- end
620
-
621
- def enum_class_for(type)
622
- type.is_a?(Class) && @enum_classes.value?(type) and return type
623
- end
624
-
625
- def enum_value_name(raw)
626
- raw.split('::').last
627
- end
628
-
629
- # L7b: derived fields declare existence + type; computation is
630
- # bound by the binder from artifact derive specs. The attribute
631
- # participates in mappings like any other (relaton consumes URNs
632
- # from model JSON).
633
242
  def declare_derived_fields(compiled_klass, def_obj)
634
243
  return unless def_obj.is_a?(UmlClass) || def_obj.is_a?(DataType)
635
244
 
@@ -647,35 +256,6 @@ module Lutaml
647
256
  end
648
257
  end
649
258
 
650
- # L5: register grammar-backed string formats. Class-level
651
- # declarations win; a models-block default applies to classes
652
- # without their own. Artifact resolution: the compiler's
653
- # artifacts map (name => path), else the string as a path.
654
- def register_string_formats(doc)
655
- defaults = doc.packages.flat_map(&:default_string_formats)
656
- doc.classes.each do |class_def|
657
- declared = Array(class_def.string_formats)
658
- declared.each { |fmt| register_string_format(class_def.name.to_s, fmt) }
659
-
660
- declared_formats = declared.map(&:format)
661
- defaults.reject { |fmt| declared_formats.include?(fmt.format) }
662
- .each { |fmt| register_string_format(class_def.name.to_s, fmt) }
663
- end
664
- end
665
-
666
- def register_string_format(class_name, fmt)
667
- artifact_path = artifact_path_for(fmt.artifact.to_s)
668
- return unless artifact_path
669
-
670
- compiled = @compiled[class_name]
671
- return unless compiled
672
-
673
- Parsanol::PARG::Lutaml.register(compiled,
674
- format_name: fmt.format.to_sym,
675
- artifact: artifact_path,
676
- entry: fmt.root.to_s)
677
- end
678
-
679
259
  def artifact_path_for(reference)
680
260
  mapped = @artifact_paths && @artifact_paths[reference]
681
261
  return mapped if mapped && File.exist?(mapped.to_s)
@@ -700,7 +280,20 @@ module Lutaml
700
280
  @compiled[name] = klass
701
281
  return unless @namespace
702
282
 
703
- @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
704
297
  end
705
298
  end
706
299
  end
@@ -17,6 +17,9 @@ module Lutaml
17
17
  attribute :enums, "Lutaml::Lml::Enum", collection: true, default: -> { [] }
18
18
  attribute :packages, "Lutaml::Lml::Package", collection: true, default: -> { [] }
19
19
  attribute :namespaces, "Lutaml::Lml::Namespace", collection: true, default: -> { [] }
20
+ # `models <Name> version "x.y.z"` headers: model name -> declared
21
+ # version (issue #16, the co-release identity line).
22
+ attribute :model_versions, :hash, default: -> { {} }
20
23
  attribute :serialization_mappings, "Lutaml::Lml::SerializationMapping", collection: true, default: -> { [] }
21
24
  attribute :primitives, "Lutaml::Lml::PrimitiveType", collection: true, default: -> { [] }
22
25
  attribute :associations, "Lutaml::Lml::Association", collection: true, default: -> { [] }
@@ -27,6 +30,32 @@ module Lutaml
27
30
  attribute :instance, "Lutaml::Lml::Instance"
28
31
  attribute :requires, :string, collection: true, default: -> { [] }
29
32
  attribute :instances, "Lutaml::Lml::InstanceCollection"
33
+
34
+ # Repeated `instances` blocks accumulate (issue #17): instances,
35
+ # imports, exports and collections concat; a duplicate collection
36
+ # name is an error (same name is a mistake, distinct names intent).
37
+ def instances=(collection)
38
+ collection = Lutaml::Lml::InstanceCollection.new(**collection) if collection.is_a?(Hash)
39
+ if @instances.nil? || (@instances.instances.empty? && @instances.imports.empty? &&
40
+ @instances.exports.empty? && @instances.collections.empty?)
41
+ @instances = collection
42
+ else
43
+ merge_instances(collection)
44
+ end
45
+ end
46
+
47
+ def merge_instances(incoming)
48
+ names = @instances.collections.map(&:name)
49
+ incoming.collections.each do |collection|
50
+ if names.include?(collection.name)
51
+ raise Lutaml::Lml::Error, "duplicate collection name '#{collection.name}'"
52
+ end
53
+ end
54
+ @instances.instances.concat(incoming.instances)
55
+ @instances.imports.concat(incoming.imports)
56
+ @instances.exports.concat(incoming.exports)
57
+ @instances.collections.concat(incoming.collections)
58
+ end
30
59
  attribute :view_imports, "Lutaml::Lml::ViewImport", collection: true, default: -> { [] }
31
60
  attribute :show_filter, "Lutaml::Lml::ViewFilter"
32
61
  attribute :hide_filter, "Lutaml::Lml::ViewFilter"