lutaml-lml 0.1.8 → 0.2.1

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/lib/lutaml/lml/cli.rb +20 -4
  3. data/lib/lutaml/lml/data_processor/attribute_processing.rb +49 -9
  4. data/lib/lutaml/lml/data_processor/value_declaration_processing.rb +62 -0
  5. data/lib/lutaml/lml/data_processor.rb +3 -0
  6. data/lib/lutaml/lml/document_builder.rb +230 -6
  7. data/lib/lutaml/lml/format/adapter/standard_adapter.rb +17 -4
  8. data/lib/lutaml/lml/formatter/elk.rb +42 -0
  9. data/lib/lutaml/lml/formatter/graphviz/node_formatter.rb +2 -1
  10. data/lib/lutaml/lml/formatter.rb +1 -0
  11. data/lib/lutaml/lml/grammar/lml.artifact.json +8659 -3193
  12. data/lib/lutaml/lml/grammar/lml.parg +236 -90
  13. data/lib/lutaml/lml/layout/elk_engine.rb +15 -0
  14. data/lib/lutaml/lml/layout/elk_graph_builder.rb +129 -0
  15. data/lib/lutaml/lml/layout/elk_svg_markers.rb +45 -0
  16. data/lib/lutaml/lml/layout/elk_svg_renderer.rb +119 -0
  17. data/lib/lutaml/lml/layout/graph_viz_engine.rb +1 -1
  18. data/lib/lutaml/lml/layout.rb +4 -0
  19. data/lib/lutaml/lml/model_compiler.rb +231 -19
  20. data/lib/lutaml/lml/models/data_type.rb +1 -0
  21. data/lib/lutaml/lml/models/document.rb +31 -0
  22. data/lib/lutaml/lml/models/enum.rb +2 -0
  23. data/lib/lutaml/lml/models/instance.rb +1 -0
  24. data/lib/lutaml/lml/models/instance_collection.rb +1 -0
  25. data/lib/lutaml/lml/models/mapping_rule.rb +16 -0
  26. data/lib/lutaml/lml/models/namespace.rb +14 -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/serialization_mapping.rb +17 -0
  30. data/lib/lutaml/lml/models/top_element_attribute.rb +16 -0
  31. data/lib/lutaml/lml/models/uml_class.rb +2 -1
  32. data/lib/lutaml/lml/preprocessor.rb +49 -8
  33. data/lib/lutaml/lml/transform.rb +1 -1
  34. data/lib/lutaml/lml/validator.rb +344 -0
  35. data/lib/lutaml/lml/version.rb +1 -1
  36. data/lib/lutaml/lml.rb +5 -0
  37. metadata +15 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d249342f8fa771fc6acc926a4a4fa75eb2f2532ccd4461ea0452c7930d6a0372
4
- data.tar.gz: 7a852a63a20c02124a7c34c66501f749801711968239ef8e7fd7d92df4b96abb
3
+ metadata.gz: 41662922310b1ec54e30485a3ec783c99f385f5e80eb0e4a98277b72ac5a71dc
4
+ data.tar.gz: 2b6030ded9c8bb82941ceefdf5e1df9a8531af5a61888faee4ecbc23df0ca961
5
5
  SHA512:
6
- metadata.gz: 2acbed3dc1c0da892d6344ec7ff87147d13d24de03b44ae8c8ff3ba136a3941a20ac6b95e4d1e4445c52b91ca0997e1d7c9a7425fd92713b954874ce2dd0abf3
7
- data.tar.gz: a9f5844e875eba16ef0f3bdcb2f10ff62fe377935cce06f35c7e92571a8e7cfa9a43782919b2661f161cb4be6fffe73b67fc10bbcb9c56a73ab7ec36b2daec48
6
+ metadata.gz: 94f5275ce72d247c2842a60b265bdc8980cf82b49ce45d1970b606a0a9329505776235efb2df3247165b40cd5aaccc977b9aadcf77eb4ad044119ec71e4ea0a7
7
+ data.tar.gz: bfe8561322f20e76736e00a6c67ab862207e344637097f270a1111c7a5edbc540f8137989858e8fe3584dbaded200fe150c35da83523a637d26523f9cc64f8e6
@@ -37,7 +37,12 @@ module Lutaml
37
37
 
38
38
  def initialize(*args)
39
39
  super
40
- @formatter = ::Lutaml::Formatter::Graphviz.new if defined?(::Lutaml::Formatter::Graphviz)
40
+ @formatter = case options[:layout]
41
+ when 'elk'
42
+ ::Lutaml::Formatter::Elk.new
43
+ else
44
+ ::Lutaml::Formatter::Graphviz.new
45
+ end
41
46
  @out_object = $stdout
42
47
  end
43
48
 
@@ -71,6 +76,8 @@ module Lutaml
71
76
  desc: 'Node attributes (key=value,key2=value2)'
72
77
  method_option :all, type: :string, aliases: '-a',
73
78
  desc: 'Set attributes for graph, edge, and node'
79
+ method_option :layout, type: :string, enum: %w[graphviz elk], default: 'graphviz',
80
+ desc: 'Layout engine (elk requires the elkrb gem)'
74
81
  def generate(*paths)
75
82
  assert_input_paths(paths)
76
83
 
@@ -186,13 +193,22 @@ module Lutaml
186
193
  input_path = Pathname.new(path_string)
187
194
  return report_file_error(input_path, 'File does not exist') unless input_path.exist?
188
195
 
189
- parse_document(input_path)
190
- say "✓ #{input_path}", :green
191
- nil
196
+ document = parse_document(input_path)
197
+ violations = Lutaml::Lml::Validator.violations(document)
198
+ if violations.any?
199
+ report_file_error(input_path, violations_message(violations))
200
+ else
201
+ say "✓ #{input_path}", :green
202
+ nil
203
+ end
192
204
  rescue StandardError => e
193
205
  report_file_error(input_path, e.message)
194
206
  end
195
207
 
208
+ def violations_message(violations)
209
+ violations.map { |v| "[#{v.rule}] #{v.message}" }.join('; ')
210
+ end
211
+
196
212
  def report_file_error(input_path, message)
197
213
  say "✗ #{input_path}: #{message}", :red
198
214
  "#{input_path}: #{message}"
@@ -15,18 +15,52 @@ module Lutaml
15
15
 
16
16
  def process_attributes_array(obj)
17
17
  return [] if obj.empty?
18
- return obj.map { |item| process_attributes(item) } unless single_key_hashes?(obj)
18
+ return obj.map { |item| process_attributes(item) } unless named_attribute_entries?(obj)
19
+ return merge_named_entries(obj) unless single_key_hashes?(obj)
19
20
 
21
+ obj.each_with_object({}) { |item, hash| append_uniform_entry(hash, item) }
22
+ end
23
+
24
+ def append_uniform_entry(hash, item)
25
+ hash[:properties] ||= []
26
+ if item.key?(:properties)
27
+ hash[:properties] << process_attributes(item[:properties])
28
+ else
29
+ hash.merge!(process_attributes(item))
30
+ end
31
+ end
32
+
33
+ # Attribute-definition groups split across capture depths (e.g. the
34
+ # comma form: {name, type} beside a nested {value_set}) combine into
35
+ # one attribute entry. Key/value pair lists (import/export maps)
36
+ # carry :key and are never merged.
37
+ def named_attribute_entries?(obj)
38
+ obj.any? { |e| e.is_a?(Hash) && e.key?(:name) } &&
39
+ obj.all? { |e| e.is_a?(Hash) && !e.key?(:key) }
40
+ end
41
+
42
+ COLLECTION_KEYS = %i[properties attributes instances].freeze
43
+
44
+ def merge_named_entries(obj)
20
45
  obj.each_with_object({}) do |item, hash|
21
- hash[:properties] ||= []
22
- if item.key?(:properties)
23
- hash[:properties] << process_attributes(item[:properties])
24
- else
25
- hash.merge!(process_attributes(item))
46
+ process_attributes(item).each do |key, value|
47
+ hash[key] = merge_attribute_value(hash[key], key, value)
26
48
  end
27
49
  end
28
50
  end
29
51
 
52
+ def merge_attribute_value(existing, key, value)
53
+ return merge_collection_value(existing, value) if COLLECTION_KEYS.include?(key)
54
+ return existing + value if existing.is_a?(Array) && value.is_a?(Array)
55
+
56
+ value
57
+ end
58
+
59
+ def merge_collection_value(existing, value)
60
+ current = existing.is_a?(Array) ? existing : (existing ? [existing] : [])
61
+ current + (value.is_a?(Array) ? value : [value])
62
+ end
63
+
30
64
  def process_attributes_hash(obj)
31
65
  result = extract_name_and_value(obj)
32
66
  convert_instance_type(result)
@@ -34,18 +68,17 @@ module Lutaml
34
68
  result[:literal] = true if obj.key?(:type_literal)
35
69
  apply_nested_attributes(result, obj)
36
70
  apply_remaining_keys(result, obj)
71
+ translate_values_argument(result)
37
72
  result
38
73
  end
39
74
 
40
- private
41
-
42
75
  def single_key_hashes?(arr)
43
76
  arr.all? { |e| e.is_a?(Hash) && e.keys.size == 1 }
44
77
  end
45
78
 
46
79
  def extract_name_and_value(obj)
47
80
  result = {}
48
- result[:name] = obj[:key] if obj.key?(:key)
81
+ result[:name] = strip_argument_colon(obj[:key]) if obj.key?(:key)
49
82
 
50
83
  if obj.key?(:comments)
51
84
  result[:name] = 'Comment'
@@ -74,9 +107,16 @@ module Lutaml
74
107
  obj.each do |key, value|
75
108
  next if EXCLUDED_PASS_THROUGH_KEYS.include?(key)
76
109
 
110
+ value = strip_argument_colon(value) if key == :name
77
111
  result[key] = value unless result.key?(key)
78
112
  end
79
113
  end
114
+
115
+ # `default: "Clear"` uses the colon separator (RS 3001 §Attribute);
116
+ # the colon lands in the captured name and must not.
117
+ def strip_argument_colon(name)
118
+ name.to_s.sub(/:\z/, "")
119
+ end
80
120
  end
81
121
  end
82
122
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ class DataProcessor
6
+ # RS 3001 §Value/§Attribute argument normalization: the canonical
7
+ # `value` construct and the attribute `values` argument.
8
+ module ValueDeclarationProcessing
9
+ # `value <name> { block }` is the canonical enum member; the bare
10
+ # identifier member is its documented shortcut. Normalize into the
11
+ # member shape the shortcut produces: the value block's `definition`
12
+ # lines become the member description and its attribute pairs become
13
+ # member payload properties.
14
+ def process_value_declaration(decl)
15
+ inner = { name: unwrap_string(decl[:value_name]) }
16
+ Array(decl[:members]).each do |member|
17
+ normalize_value_member(inner, member)
18
+ end
19
+ { attributes: inner }
20
+ end
21
+
22
+ # `values <enum>` types the attribute with the named enum;
23
+ # `values { v1, v2 }` carries an inline value set that the compiler
24
+ # expands into an anonymous enum.
25
+ def translate_values_argument(result)
26
+ if result.key?(:value_set_ref)
27
+ ref = unwrap_string(result.delete(:value_set_ref))
28
+ result[:type] = ref unless result[:type]
29
+ end
30
+ return unless result.key?(:value_set)
31
+
32
+ result[:value_set] = Array(result[:value_set][:items]).map { |item| unwrap_data_value(item[:item]) }
33
+ end
34
+
35
+ private
36
+
37
+ def normalize_value_member(inner, member)
38
+ if member.key?(:definition)
39
+ (inner[:attributes] ||= []) << { name: 'description', type: unwrap_string(member[:definition]) }
40
+ elsif member.key?(:instance)
41
+ (inner[:instances] ||= []).concat(Array(member[:instance]))
42
+ elsif member.key?(:attributes)
43
+ (inner[:properties] ||= []) << member[:attributes]
44
+ elsif member.key?(:key)
45
+ (inner[:properties] ||= []) << extract_name_and_value(member)
46
+ end
47
+ end
48
+
49
+ def unwrap_data_value(item)
50
+ return item unless item.is_a?(Hash)
51
+
52
+ item[:string] || item[:number] || item[:float] ||
53
+ item[:boolean] || item[:name] || item
54
+ end
55
+
56
+ def unwrap_string(value)
57
+ value.is_a?(Hash) && value.key?(:string) ? value[:string] : value
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -5,12 +5,14 @@ module Lutaml
5
5
  class DataProcessor
6
6
  autoload :ValueProcessing, "lutaml/lml/data_processor/value_processing"
7
7
  autoload :AttributeProcessing, "lutaml/lml/data_processor/attribute_processing"
8
+ autoload :ValueDeclarationProcessing, "lutaml/lml/data_processor/value_declaration_processing"
8
9
  autoload :InstanceProcessing, "lutaml/lml/data_processor/instance_processing"
9
10
  autoload :CollectionProcessing, "lutaml/lml/data_processor/collection_processing"
10
11
  autoload :ViewProcessing, "lutaml/lml/data_processor/view_processing"
11
12
 
12
13
  include ValueProcessing
13
14
  include AttributeProcessing
15
+ include ValueDeclarationProcessing
14
16
  include InstanceProcessing
15
17
  include CollectionProcessing
16
18
  include ViewProcessing
@@ -20,6 +22,7 @@ module Lutaml
20
22
  instances: :process_instances,
21
23
  instance: :process_instance,
22
24
  attributes: :process_attributes,
25
+ value_decl: :process_value_declaration,
23
26
  show_list: :process_show_list,
24
27
  hide_list: :process_hide_list
25
28
  }.freeze
@@ -24,7 +24,9 @@ module Lutaml
24
24
  mapping: ::Lutaml::Lml::Mapping,
25
25
  string_format: ::Lutaml::Lml::StringFormat,
26
26
  derived_field: ::Lutaml::Lml::DerivedField,
27
- member_field: ::Lutaml::Lml::MemberField
27
+ member_field: ::Lutaml::Lml::MemberField,
28
+ namespace: ::Lutaml::Lml::Namespace,
29
+ serialization_mapping: ::Lutaml::Lml::SerializationMapping
28
30
  }.freeze
29
31
 
30
32
  attr_reader :registry
@@ -36,7 +38,7 @@ module Lutaml
36
38
  FACTORY_KEYS = %i[
37
39
  document package class enum data_type diagram view_import view_filter
38
40
  attribute association operation constraint value cardinality mapping
39
- string_format derived_field
41
+ string_format derived_field namespace serialization_mapping
40
42
  ].freeze
41
43
 
42
44
  MEMBER_KEY_MAP = {
@@ -52,7 +54,9 @@ module Lutaml
52
54
  constraints: :constraint,
53
55
  values: :value,
54
56
  mappings: :mapping,
55
- member_fields: :member_field
57
+ member_fields: :member_field,
58
+ namespaces: :namespace,
59
+ serialization_mappings: :serialization_mapping
56
60
  }.freeze
57
61
 
58
62
  FACTORY_KEYS.each do |key|
@@ -68,6 +72,16 @@ module Lutaml
68
72
  def set_model(model, hash)
69
73
  hash = build_members(model, hash)
70
74
  set_model_attributes(model, hash)
75
+ expand_class_specializer(model, hash)
76
+ end
77
+
78
+ # The prefix specializer (`abstract class Foo`) expands to the
79
+ # canonical `abstract true` declaration (RS 3001 par. Declaration
80
+ # modifiers); the class-level modifier capture is not a block member.
81
+ def expand_class_specializer(model, hash)
82
+ return unless hash[:modifier].to_s == 'abstract' && model.class.attributes.key?(:is_abstract)
83
+
84
+ model.is_abstract = true
71
85
  end
72
86
 
73
87
  def set_model_attributes(model, hash)
@@ -79,12 +93,24 @@ module Lutaml
79
93
  def build_members(model, hash)
80
94
  members = hash.delete(:members)
81
95
  members.to_a.each do |member_hash|
96
+ record_model_version(model, member_hash)
82
97
  add_members(model, member_hash)
83
98
  set_model_attributes(model, member_hash)
84
99
  end
85
100
  hash
86
101
  end
87
102
 
103
+ # A `models <Name> version "x.y.z"` header travels with the block's
104
+ # member hash; the block itself flattens into the document, so the
105
+ # version is recorded on the document before the name is dropped.
106
+ def record_model_version(model, hash)
107
+ return unless model.class.attributes.key?(:model_versions)
108
+ return unless hash.key?(:version)
109
+
110
+ name = hash[:name].to_s
111
+ model.model_versions[name] = unquote(hash.delete(:version)).to_s
112
+ end
113
+
88
114
  def apply_attribute(model, key, value)
89
115
  return unless model.class.attributes.key?(key.to_sym)
90
116
 
@@ -102,9 +128,11 @@ module Lutaml
102
128
  end
103
129
 
104
130
  def add_members(model, hash)
105
- expand_mapping_section(model, hash)
106
- expand_class_declarations(model, hash)
107
- expand_enum_member_fields(model, hash)
131
+ expand_declared_members(model, hash)
132
+ normalize_namespace_declaration(hash)
133
+ normalize_serialization_mapping(hash)
134
+ expand_instances_collection_name(hash)
135
+ expand_nested_members(model, hash)
108
136
  MEMBER_KEY_MAP.each do |plural_key, singular_key|
109
137
  data = hash.delete(plural_key)
110
138
  next if data.nil?
@@ -116,6 +144,185 @@ module Lutaml
116
144
  remap_filter_keys(hash, model)
117
145
  end
118
146
 
147
+ # RS 3001 §Enums: an enum-level `values { v1, v2 }` shorthand
148
+ # expands into plain members.
149
+ def expand_value_set_declaration(model, hash)
150
+ decl = hash.delete(:value_set_decl) or return
151
+ Array(decl[:items]).each do |item|
152
+ name = unquote(item[:item])
153
+ model.attributes << TopElementAttribute.new(name: name)
154
+ end
155
+ end
156
+
157
+ # RS 3001 §Value: the canonical `value` construct normalizes into
158
+ # the same member shape the bare-identifier shortcut produces.
159
+ def expand_value_declaration(model, hash)
160
+ decl = hash.delete(:value_decl) or return
161
+ apply_attribute(model, :attributes, decl.fetch(:attributes))
162
+ end
163
+
164
+ # Namespace declaration bodies arrive as keyword-line members
165
+ # ({uri: …}, {prefix: …}); flatten them onto the namespace hash.
166
+ def normalize_namespace_declaration(hash)
167
+ ns = hash[:namespaces] or return
168
+ Array(ns.delete(:members)).each do |pair|
169
+ pair.each { |key, value| ns[key] = unquote(value) }
170
+ end
171
+ end
172
+
173
+ # Serialization mapping rules arrive keyed by their line construct
174
+ # ({map_element: {wire:, field:}}); normalize into plain MappingRule
175
+ # hashes so the model cast lands typed rules.
176
+ def normalize_serialization_mapping(hash)
177
+ mapping = hash[:serialization_mappings] or return
178
+ rules = Array(mapping[:rules])
179
+ return if rules.any? && rules.all? { |e| e.is_a?(Hash) && e.key?(:kind) }
180
+
181
+ rules = rules.filter_map do |entry|
182
+ next nil unless entry.is_a?(Hash)
183
+
184
+ next { kind: 'map', wire: unquote(entry[:wire]), field: unquote(entry[:to]) } if entry[:wire] && entry[:to]
185
+
186
+ first_key = entry.keys.first
187
+ kind = first_key.to_s
188
+ body = entry[first_key] || {}
189
+ unless body.is_a?(Hash)
190
+ kind_n = normalize_mapping_kind(kind)
191
+ next nil if body.to_s.empty?
192
+
193
+ term = unquote(body)
194
+ next { kind: kind_n, wire: term, field: term } if %w[element attribute map].include?(kind_n)
195
+
196
+ if kind_n == 'namespace'
197
+ mapping[:namespace_ref] = term
198
+ next nil
199
+ end
200
+
201
+ next { kind: kind_n, field: term }
202
+ end
203
+ if kind == 'element_name'
204
+ mapping[:element_name] = unquote(body[:element_name])
205
+ next nil
206
+ end
207
+ if kind == 'map_namespace'
208
+ mapping[:namespace_ref] = unquote(body[:namespace_ref])
209
+ next nil
210
+ end
211
+ normalized = { kind: normalize_mapping_kind(kind) }
212
+ wire = body[:wire] || body[:element_wire]
213
+ normalized[:wire] = unquote(wire) if wire
214
+ field = body[:field] || body[:to_field] || body[:element_field] ||
215
+ body[:attribute_field] || body[:to]
216
+ normalized[:field] = unquote(field) if field
217
+ normalized[:mode] = body[:mode] if body[:mode]
218
+ normalized[:namespace_ref] = unquote(body[:namespace_ref]) if body[:namespace_ref]
219
+ normalized
220
+ end
221
+ mapping[:rules] = rules
222
+ end
223
+
224
+ def normalize_mapping_kind(kind)
225
+ {
226
+ 'map_element' => 'element', 'map_attribute' => 'attribute',
227
+ 'map_content' => 'content', 'map_namespace' => 'namespace',
228
+ 'map_key' => 'key', 'map_value' => 'value', 'map' => 'map',
229
+ 'legacy_map' => 'map'
230
+ }.fetch(kind, kind)
231
+ end
232
+
233
+ # A named instances collection (`instances "Glazes" { ... }`) arrives
234
+ # with its name as a sibling of the collection body; move it inside
235
+ # so the InstanceCollection carries it.
236
+ def expand_instances_collection_name(hash)
237
+ return unless hash.key?(:instances) && hash.key?(:name)
238
+
239
+ name = unquote(hash.delete(:name))
240
+ body = hash[:instances].is_a?(Hash) ? hash[:instances] : {}
241
+ body[:name] = name unless body.key?(:name)
242
+ end
243
+
244
+ # The document root is a repetition of top-level constructs (RS 3001:
245
+ # documents interleave declarations), so models/packages blocks nest
246
+ # their members one level down; recurse into them.
247
+ def expand_nested_members(model, hash)
248
+ return unless hash.key?(:members)
249
+
250
+ members = hash.delete(:members)
251
+ members.to_a.each do |nested|
252
+ add_members(model, nested)
253
+ set_model_attributes(model, nested)
254
+ end
255
+ end
256
+
257
+ # Declaration-level member hooks: single-line definitions, canonical
258
+ # `value` members, mapping sections, class declarations, enum
259
+ # member_fields and from_table all arrive as bare member hashes.
260
+ def expand_declared_members(model, hash)
261
+ expand_abstract_declaration(model, hash)
262
+ expand_definition_text(model, hash)
263
+ expand_value_declaration(model, hash)
264
+ expand_value_set_declaration(model, hash)
265
+ expand_mapping_section(model, hash)
266
+ expand_class_declarations(model, hash)
267
+ expand_enum_member_fields(model, hash)
268
+ expand_from_table(model, hash)
269
+ expand_operation_members(model, hash)
270
+ end
271
+
272
+ # Operation members arrive with grammar capture names
273
+ # (`visibility_modifier`, `param_default`) and per-iteration
274
+ # stereotype hashes; normalize to the model's field names.
275
+ def expand_operation_members(model, hash)
276
+ ops = hash.delete(:operations)
277
+ return unless ops && model.class.attributes.key?(:operations)
278
+
279
+ Array(ops.is_a?(Hash) ? [ops] : ops).each do |op|
280
+ model.operations << build(:operation, normalize_operation_hash(op))
281
+ end
282
+ end
283
+
284
+ def normalize_operation_hash(op)
285
+ op = op.dup
286
+ if (vis = op.delete(:visibility_modifier))
287
+ op[:visibility] = Transform::VISIBILITY_MAP.fetch(vis.to_s, 'public')
288
+ end
289
+ stereo = op[:stereotype]
290
+ if stereo
291
+ items = stereo.is_a?(Array) ? stereo : [stereo]
292
+ op[:stereotype] = items.map { |x| x.is_a?(Hash) ? x[:stereotype].to_s : x.to_s }
293
+ end
294
+ params = op[:owned_parameter]
295
+ if params
296
+ list = params.is_a?(Array) ? params : [params]
297
+ op[:owned_parameter] = list.map { |param| normalize_operation_param(param) }
298
+ end
299
+ op
300
+ end
301
+
302
+ def normalize_operation_param(param)
303
+ param = param.dup
304
+ if (default = param.delete(:param_default))
305
+ param[:default] = default.is_a?(Hash) ? default[:string].to_s : default.to_s
306
+ end
307
+ param
308
+ end
309
+
310
+ # RS 3001 §Definition single-line form (`definition "text"`) arrives
311
+ # as a quoted-string capture; unwrap it for the entity's definition.
312
+ # RS 3001 declaration modifiers: `abstract true` maps onto the
313
+ # is_abstract field (the visibility declaration flows through the
314
+ # generic attribute path).
315
+ def expand_abstract_declaration(model, hash)
316
+ value = hash.delete(:abstract)
317
+ model.is_abstract = true if value.to_s == 'true' && model.class.attributes.key?(:is_abstract)
318
+ end
319
+
320
+ def expand_definition_text(model, hash)
321
+ return unless hash.key?(:definition) && model.class.attributes.key?(:definition)
322
+
323
+ model.definition = unquote(hash.delete(:definition))
324
+ end
325
+
119
326
  # A mapping section arrives as a bare member hash
120
327
  # { format:, maps: [{ wire:, to: }] }; expand to one Mapping entry
121
328
  # per map line, each carrying the format, and strip the section keys
@@ -158,6 +365,23 @@ module Lutaml
158
365
  type: unquote(derived[:type]))
159
366
  end
160
367
 
368
+ # L7a: from_table declares the enum's members as an artifact table
369
+ # { from_table_name:, from_table_artifact? } — expansion happens at
370
+ # compile time (deterministic materialization, no runtime data dep)
371
+ def expand_from_table(model, hash)
372
+ decl = hash.delete(:from_table_decl)
373
+ return unless decl && model.is_a?(Enum)
374
+
375
+ # the members repetition splits the declaration across iteration
376
+ # hashes (name part, artifact part) — merge the parts
377
+ parts = decl.is_a?(Array) ? decl : [decl]
378
+ decl = parts.each_with_object({}) do |part, acc|
379
+ acc.merge!(part) { |_key, _old, new_val| new_val }
380
+ end
381
+ model.from_table_name = unquote(decl[:from_table_name])
382
+ model.from_table_artifact = unquote(decl[:artifact_table]) if decl[:artifact_table]
383
+ end
384
+
161
385
  def expand_enum_member_fields(model, hash)
162
386
  decl = hash.delete(:member_fields_decl)
163
387
  return unless decl && model.is_a?(Enum)
@@ -70,6 +70,8 @@ module Lutaml
70
70
  end
71
71
 
72
72
  def format_value(prefix, key, value, indent)
73
+ return "#{prefix}#{key} = ref:(#{value[:reference]})" if reference_value?(value)
74
+
73
75
  case value
74
76
  when Array
75
77
  format_array(prefix, key, value, indent)
@@ -84,11 +86,15 @@ module Lutaml
84
86
 
85
87
  def format_array(prefix, key, items, indent)
86
88
  elements = items.map do |item|
87
- case item
88
- when Hash
89
- format_nested_instance(item, indent + 1)
89
+ if reference_value?(item)
90
+ " ref:(#{item[:reference]})"
90
91
  else
91
- " #{quote_value(item)}"
92
+ case item
93
+ when Hash
94
+ format_nested_instance(item, indent + 1)
95
+ else
96
+ " #{quote_value(item)}"
97
+ end
92
98
  end
93
99
  end
94
100
 
@@ -111,6 +117,13 @@ module Lutaml
111
117
  "#{prefix}instance#{type_clause} {\n#{inner}\n#{prefix}}"
112
118
  end
113
119
 
120
+ # RS 3001 par. Value type: a `reference` map is the wire form of a
121
+ # reference literal; it emits as `ref:(path)`, never as a nested
122
+ # instance.
123
+ def reference_value?(value)
124
+ value.is_a?(Hash) && value.keys == [:reference]
125
+ end
126
+
114
127
  def quote_value(val)
115
128
  return val.to_s if val.is_a?(Numeric) || val.is_a?(TrueClass) || val.is_a?(FalseClass)
116
129
  str = val.to_s
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Formatter
5
+ # ELK-layout diagram formatter: builds an Elkrb::Graph from the
6
+ # document, lays it out in-process (no external tools) and emits SVG,
7
+ # or converts the SVG to PS/EPS/EMF through vectory.
8
+ class Elk
9
+ VALID_TYPES = %i[svg ps eps emf].freeze
10
+
11
+ attr_reader :type
12
+
13
+ def initialize(_attributes = {})
14
+ @type = :svg
15
+ end
16
+
17
+ def type=(value)
18
+ @type = value.to_sym
19
+ return if VALID_TYPES.include?(@type)
20
+
21
+ raise ArgumentError,
22
+ "unsupported output type #{@type.inspect} (valid: #{VALID_TYPES.join(', ')})"
23
+ end
24
+
25
+ def format(document)
26
+ graph = Lutaml::Layout::ElkGraphBuilder.new.build(document)
27
+ svg = Lutaml::Layout::ElkEngine.new(input: graph).render(:svg)
28
+ @type == :svg ? svg : convert(svg)
29
+ end
30
+
31
+ private
32
+
33
+ def convert(svg)
34
+ require 'vectory'
35
+ Vectory::Svg.new(svg).public_send(:"to_#{@type}").content
36
+ rescue LoadError
37
+ raise Error, 'The vectory gem is required for PS/EPS/EMF output: ' \
38
+ 'add `gem "vectory"` to your Gemfile'
39
+ end
40
+ end
41
+ end
42
+ end
@@ -7,7 +7,8 @@ module Lutaml
7
7
  ACCESS_SYMBOLS = {
8
8
  'public' => '+',
9
9
  'protected' => '#',
10
- 'private' => '-'
10
+ 'private' => '-',
11
+ 'package' => '~'
11
12
  }.freeze
12
13
 
13
14
  def format_attribute(node)
@@ -4,5 +4,6 @@ module Lutaml
4
4
  module Formatter
5
5
  autoload :Base, "lutaml/lml/formatter/base"
6
6
  autoload :Graphviz, "lutaml/lml/formatter/graphviz"
7
+ autoload :Elk, "lutaml/lml/formatter/elk"
7
8
  end
8
9
  end