lutaml-lml 0.3.3 → 0.3.5

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/exe/lutaml-lml-lsp +11 -0
  3. data/lib/lutaml/lml/cli.rb +166 -17
  4. data/lib/lutaml/lml/data_processor/attribute_processing.rb +4 -2
  5. data/lib/lutaml/lml/data_processor/value_declaration_processing.rb +1 -0
  6. data/lib/lutaml/lml/data_processor.rb +8 -1
  7. data/lib/lutaml/lml/diagnostic.rb +149 -0
  8. data/lib/lutaml/lml/document_builder.rb +63 -21
  9. data/lib/lutaml/lml/format/adapter/standard_adapter.rb +22 -23
  10. data/lib/lutaml/lml/formatter/canonical.rb +736 -0
  11. data/lib/lutaml/lml/formatter.rb +2 -0
  12. data/lib/lutaml/lml/grammar/lml.artifact.json +2946 -2512
  13. data/lib/lutaml/lml/grammar/lml.parg +47 -9
  14. data/lib/lutaml/lml/grammar.rb +13 -0
  15. data/lib/lutaml/lml/import_resolver.rb +13 -4
  16. data/lib/lutaml/lml/lsp/conversions.rb +78 -0
  17. data/lib/lutaml/lml/lsp/keywords.rb +43 -0
  18. data/lib/lutaml/lml/lsp/server.rb +378 -0
  19. data/lib/lutaml/lml/lsp/store.rb +68 -0
  20. data/lib/lutaml/lml/lsp/transport.rb +53 -0
  21. data/lib/lutaml/lml/lsp.rb +16 -0
  22. data/lib/lutaml/lml/model_compiler/serialization_mappings.rb +36 -10
  23. data/lib/lutaml/lml/model_compiler.rb +25 -2
  24. data/lib/lutaml/lml/models/association.rb +3 -0
  25. data/lib/lutaml/lml/models/constraint.rb +3 -0
  26. data/lib/lutaml/lml/models/data_type.rb +3 -0
  27. data/lib/lutaml/lml/models/diagram.rb +3 -0
  28. data/lib/lutaml/lml/models/document.rb +10 -3
  29. data/lib/lutaml/lml/models/enum.rb +4 -1
  30. data/lib/lutaml/lml/models/instance.rb +3 -0
  31. data/lib/lutaml/lml/models/instance_collection.rb +3 -0
  32. data/lib/lutaml/lml/models/member_field.rb +2 -0
  33. data/lib/lutaml/lml/models/namespace.rb +2 -0
  34. data/lib/lutaml/lml/models/operation.rb +4 -1
  35. data/lib/lutaml/lml/models/package.rb +3 -0
  36. data/lib/lutaml/lml/models/primitive_type.rb +6 -3
  37. data/lib/lutaml/lml/models/serialization_mapping.rb +2 -0
  38. data/lib/lutaml/lml/models/top_element_attribute.rb +6 -0
  39. data/lib/lutaml/lml/models/uml_class.rb +6 -0
  40. data/lib/lutaml/lml/models/value.rb +3 -0
  41. data/lib/lutaml/lml/pipeline.rb +25 -8
  42. data/lib/lutaml/lml/positioned.rb +13 -0
  43. data/lib/lutaml/lml/preprocessor.rb +44 -13
  44. data/lib/lutaml/lml/quoting.rb +60 -0
  45. data/lib/lutaml/lml/source_position.rb +20 -0
  46. data/lib/lutaml/lml/symbol_index.rb +200 -0
  47. data/lib/lutaml/lml/transform.rb +49 -2
  48. data/lib/lutaml/lml/validator.rb +134 -21
  49. data/lib/lutaml/lml/version.rb +1 -1
  50. data/lib/lutaml/lml.rb +18 -1
  51. metadata +18 -4
@@ -0,0 +1,736 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Formatter
5
+ # The canonical LML emitter (RS 3001 informative "Canonical form"):
6
+ # a deterministic rendering of the document model. Canonicality is a
7
+ # formatting operation, not a validity constraint. The rules live in
8
+ # TODO.next/sota/canon.md.
9
+ #
10
+ # Laws: format(format(x)) == format(x) (idempotence), and
11
+ # parse(format(parse(x))) equals parse(x) under the document-model
12
+ # comparison.
13
+ class Canonical
14
+ include Lutaml::Lml::Quoting
15
+
16
+ GROUPS = %i[
17
+ namespaces packages classes enums data_types primitives
18
+ serialization_mappings associations diagrams
19
+ ].freeze
20
+
21
+ VISIBILITY_SYMBOLS = {
22
+ 'private' => '-', 'protected' => '#', 'package' => '~'
23
+ }.freeze
24
+
25
+ VISIBILITY_WORDS = %w[private protected package].freeze
26
+
27
+ def initialize(document)
28
+ @document = document
29
+ @lines = []
30
+ @level = 0
31
+ @bodies = []
32
+ end
33
+
34
+ def render
35
+ render_document(@document)
36
+ trim_trailing_blanks
37
+ return '' if @lines.empty?
38
+
39
+ "#{@lines.join("\n")}\n"
40
+ end
41
+
42
+ private
43
+
44
+ # ---- document ----
45
+
46
+ def render_document(doc)
47
+ unless doc.lml_version.to_s.empty?
48
+ line("lml #{quote_always(doc.lml_version)}")
49
+ blank_line
50
+ end
51
+ Array(doc.requires).each { |path| line("require #{quote_always(path)}") }
52
+ blank_line if Array(doc.requires).any?
53
+
54
+ Array(doc.comments).each { |comment| top { render_comment(comment) } }
55
+
56
+ render_scope(doc)
57
+ top { render_instance(doc.instance) } if doc.instance
58
+ top { render_instance_collection(doc.instances) } if doc.instances
59
+ render_views(doc)
60
+ end
61
+
62
+ def render_scope(doc)
63
+ return render_model_members(doc) if doc.name.to_s.empty?
64
+
65
+ top do
66
+ render_block("models #{quote_scalar(doc.name)}#{version_suffix(doc)}") do
67
+ render_model_members(doc)
68
+ end
69
+ end
70
+ end
71
+
72
+ def version_suffix(doc)
73
+ version = doc.model_versions[doc.name]
74
+ version.nil? ? '' : " version #{quote_always(version)}"
75
+ end
76
+
77
+ def render_model_members(doc)
78
+ %i[title caption fontname].each do |attribute|
79
+ value = doc.public_send(attribute)
80
+ next if value.to_s.empty?
81
+
82
+ top { line("#{attribute} #{quote_always(value)}") }
83
+ end
84
+
85
+ GROUPS.each do |collection|
86
+ Array(doc.public_send(collection)).each do |item|
87
+ top { render_group_member(collection, item) }
88
+ end
89
+ end
90
+ end
91
+
92
+ def render_group_member(collection, item)
93
+ case collection
94
+ when :namespaces then render_namespace(item)
95
+ when :packages then render_package(item)
96
+ when :classes then render_class(item)
97
+ when :enums then render_enum(item)
98
+ when :data_types then render_data_type(item)
99
+ when :primitives then render_primitive(item)
100
+ when :serialization_mappings then render_mapping(item)
101
+ when :associations then render_association(item)
102
+ when :diagrams then render_diagram(item)
103
+ end
104
+ end
105
+
106
+ def render_views(doc)
107
+ imports = Array(doc.view_imports)
108
+ show = doc.show_filter
109
+ hide = doc.hide_filter
110
+ return if imports.empty? && show.nil? && hide.nil?
111
+
112
+ top do
113
+ render_block("view #{quote_scalar(doc.name)}") do
114
+ imports.each { |entry| member { line("import #{quote_always(entry.path)}") } }
115
+ member { line("show #{show.entity_names.join(', ')}") } if show
116
+ member { line("hide #{hide.entity_names.join(', ')}") } if hide
117
+ end
118
+ end
119
+ end
120
+
121
+ # ---- declarations ----
122
+
123
+ def render_namespace(namespace)
124
+ render_block("namespace #{namespace.name}") do
125
+ line("uri #{quote_scalar(namespace.uri)}") if namespace.uri
126
+ line("prefix #{quote_scalar(namespace.prefix)}") if namespace.prefix
127
+ end
128
+ end
129
+
130
+ def render_package(package)
131
+ render_block("#{declaration_visibility(package.visibility)}package #{package.name}") do
132
+ render_collection(package, :classes) { |item| render_class(item) }
133
+ render_collection(package, :enums) { |item| render_enum(item) }
134
+ render_collection(package, :data_types) { |item| render_data_type(item) }
135
+ render_collection(package, :packages) { |item| render_package(item) }
136
+ end
137
+ end
138
+
139
+ def render_class(klass)
140
+ render_block(class_header(klass)) do
141
+ render_comments(klass.comments)
142
+ member { render_definition(klass.definition) } if definition_present?(klass.definition)
143
+ member { render_class_namespace(klass.xml_namespace) } if klass.xml_namespace&.uri
144
+ Array(klass.attributes).each { |attribute| member { render_attribute(attribute) } }
145
+ Array(klass.operations).each { |operation| member { render_operation(operation) } }
146
+ Array(klass.derived_fields).each do |field|
147
+ member { line("derive #{field.name}: #{field.type}") }
148
+ end
149
+ Array(klass.string_formats).each { |format| member { render_string_format(format) } }
150
+ Array(klass.serialization_mappings).each do |mapping|
151
+ member { render_mapping(mapping, with_target: false) }
152
+ end
153
+ end
154
+ end
155
+
156
+ def render_class_namespace(namespace)
157
+ text = "namespace #{quote_always(namespace.uri)}"
158
+ text += " prefix #{quote_always(namespace.prefix)}" if namespace.prefix
159
+ line(text)
160
+ end
161
+
162
+ def class_header(klass)
163
+ text = "#{declaration_visibility(klass.visibility)}#{class_modifier(klass)}"
164
+ text += "class #{klass.name}"
165
+ parent = klass.parent_class.to_s.empty? ? klass.type : klass.parent_class
166
+ text += " < #{parent}" if parent.to_s != ''
167
+ text + stereotype_suffix(klass.stereotype)
168
+ end
169
+
170
+ def class_modifier(klass)
171
+ return "#{klass.modifier} " if klass.modifier.to_s != ''
172
+
173
+ klass.is_abstract ? 'abstract ' : ''
174
+ end
175
+
176
+ def render_enum(enum)
177
+ render_block("#{declaration_visibility(enum.visibility)}enum #{enum.name}#{stereotype_suffix(enum.stereotype)}") do
178
+ member { render_definition(enum.definition) } if definition_present?(enum.definition)
179
+ member { line('abstract true') } if enum.is_abstract
180
+ member { render_member_fields(enum.member_fields) } if Array(enum.member_fields).any?
181
+ member { render_from_table(enum) } if enum.from_table_name
182
+ Array(enum.attributes).each { |value| member { render_enum_member(value) } }
183
+ end
184
+ end
185
+
186
+ def render_member_fields(fields)
187
+ render_block('member_fields') do
188
+ Array(fields).each { |field| line("#{field.name} #{field.type}") }
189
+ end
190
+ end
191
+
192
+ def render_from_table(enum)
193
+ header = "from_table #{quote_scalar(enum.from_table_name)}"
194
+ if enum.from_table_artifact.to_s.empty?
195
+ line(header)
196
+ else
197
+ render_block(header) do
198
+ line("artifact #{quote_scalar(enum.from_table_artifact)}")
199
+ end
200
+ end
201
+ end
202
+
203
+ def render_enum_member(value)
204
+ return render_attribute(value) if attribute_member?(value)
205
+
206
+ description = Array(value.attributes).find { |attribute| attribute.name == 'description' }
207
+ properties = Array(value.properties)
208
+ instances = Array(value.instances)
209
+ payload = description || properties.any? || instances.any?
210
+
211
+ unless payload
212
+ line("value #{quote_scalar(value.name)}")
213
+ return
214
+ end
215
+
216
+ render_block("value #{quote_scalar(value.name)}") do
217
+ member { render_definition(description.type) } if description
218
+ properties.each do |property|
219
+ member { line("#{property.name} = #{render_data_value(unwrap(property.value))}") }
220
+ end
221
+ instances.each { |instance| member { render_instance(instance) } }
222
+ end
223
+ end
224
+
225
+ # A member carrying the attribute shape (type, visibility, direct
226
+ # definition, cardinality, options) came from a bare attribute
227
+ # declaration, not from the `value` construct. Rendering it as a
228
+ # `value` block would move a direct definition into the nested
229
+ # description payload and change the model.
230
+ def attribute_member?(value)
231
+ value.type.to_s != '' ||
232
+ !public_visibility?(value.visibility) ||
233
+ definition_present?(value.definition) ||
234
+ value.cardinality || Array(value.value_set).any? ||
235
+ value.pattern || value.default ||
236
+ Array(value.stereotype).any? || value.literal
237
+ end
238
+
239
+ def render_data_type(data_type)
240
+ render_block("#{declaration_visibility(data_type.visibility)}data_type #{data_type.name}#{stereotype_suffix(data_type.stereotype)}") do
241
+ member { render_definition(data_type.definition) } if definition_present?(data_type.definition)
242
+ Array(data_type.attributes).each { |attribute| member { render_attribute(attribute) } }
243
+ end
244
+ end
245
+
246
+ def render_primitive(primitive)
247
+ line("#{declaration_visibility(primitive.visibility)}primitive #{primitive.name}")
248
+ end
249
+
250
+ def render_mapping(mapping, with_target: true)
251
+ target = with_target && mapping.target.to_s != '' ? " #{mapping.target}" : ''
252
+ render_block("mapping #{mapping.format}#{target}") do
253
+ line("element #{quote_always(mapping.element_name)}") if mapping.element_name
254
+ line("namespace #{mapping.namespace_ref}") if mapping.namespace_ref
255
+ line('mixed_content') if mapping.mixed_content
256
+ Array(mapping.rules).each do |rule|
257
+ text = mapping_rule(rule)
258
+ line(text) if text
259
+ end
260
+ end
261
+ end
262
+
263
+ def mapping_rule(rule)
264
+ case rule.kind.to_s
265
+ when 'element' then "map_element #{wire_and_field(rule)}"
266
+ when 'attribute' then "map_attribute #{wire_and_field(rule)}"
267
+ when 'content' then "map_content #{rule.field}"
268
+ when 'namespace' then "namespace #{rule.field || rule.wire}"
269
+ when 'key' then "map_key to_instance #{rule.field}"
270
+ when 'value'
271
+ mode = rule.mode.to_s.empty? ? 'to_instance' : rule.mode
272
+ "map_value #{mode} #{rule.field}"
273
+ else "map #{wire_and_field(rule)}"
274
+ end
275
+ end
276
+
277
+ def wire_and_field(rule)
278
+ parts = []
279
+ parts << quote_always(rule.wire) if rule.wire
280
+ parts << rule.field if rule.field
281
+ parts.join(' ')
282
+ end
283
+
284
+ def render_association(association)
285
+ name = association.name.to_s.empty? ? '' : " #{association.name}"
286
+ render_block("association#{name}") do
287
+ member { line("owner_type #{association.owner_end_type}") } if association.owner_end_type
288
+ member { line("member_type #{association.member_end_type}") } if association.member_end_type
289
+ member { line(association_end('owner', association.owner_end, association.owner_end_attribute_name, association.owner_end_cardinality)) } if association.owner_end
290
+ member { line(association_end('member', association.member_end, association.member_end_attribute_name, association.member_end_cardinality)) } if association.member_end
291
+ end
292
+ end
293
+
294
+ def association_end(keyword, name, attribute_name, cardinality)
295
+ text = "#{keyword} #{quote_scalar(name)}"
296
+ text += "##{attribute_name}" if attribute_name.to_s != ''
297
+ text += " [#{cardinality_token(cardinality)}]" if cardinality
298
+ text
299
+ end
300
+
301
+ def render_diagram(diagram)
302
+ return if diagram.name.to_s.empty?
303
+
304
+ render_block("diagram #{diagram.name}") do
305
+ member { render_definition(diagram.definition) } if definition_present?(diagram.definition)
306
+ end
307
+ end
308
+
309
+ # ---- attributes ----
310
+
311
+ def render_attribute(attribute)
312
+ return if attribute.name.to_s.empty?
313
+
314
+ return render_attribute_block(attribute) if attribute.xml_role && attribute_block_options?(attribute)
315
+
316
+ if terse_attribute?(attribute)
317
+ line("attribute #{attribute.name}, #{attribute.type}")
318
+ elsif naked_attribute?(attribute)
319
+ line("#{visibility_symbol(attribute.visibility)}#{attribute_name_text(attribute.name)}")
320
+ elsif bare_attribute?(attribute)
321
+ render_bare_attribute(attribute)
322
+ else
323
+ render_attribute_block(attribute)
324
+ end
325
+ end
326
+
327
+ def attribute_block_options?(attribute)
328
+ Array(attribute.value_set).any? || Array(attribute.properties).any? ||
329
+ !attribute.pattern.nil? || !attribute.default.nil?
330
+ end
331
+
332
+ def terse_attribute?(attribute)
333
+ public_visibility?(attribute.visibility) &&
334
+ bare_name?(attribute.name) &&
335
+ attribute.type.to_s != '' &&
336
+ attribute_cardinality(attribute).nil? &&
337
+ Array(attribute.value_set).empty? &&
338
+ Array(attribute.properties).empty? &&
339
+ attribute.pattern.nil? && attribute.default.nil? &&
340
+ Array(attribute.stereotype).empty? && !attribute.literal &&
341
+ attribute.xml_role.nil? && !definition_present?(attribute.definition)
342
+ end
343
+
344
+ def naked_attribute?(attribute)
345
+ public_visibility?(attribute.visibility) &&
346
+ attribute.type.to_s.empty? &&
347
+ attribute_cardinality(attribute).nil? &&
348
+ Array(attribute.value_set).empty? &&
349
+ Array(attribute.properties).empty? &&
350
+ attribute.pattern.nil? && attribute.default.nil? &&
351
+ Array(attribute.stereotype).empty? && !attribute.literal &&
352
+ attribute.xml_role.nil? && !definition_present?(attribute.definition)
353
+ end
354
+
355
+ def bare_attribute?(attribute)
356
+ !public_visibility?(attribute.visibility) ||
357
+ !bare_name?(attribute.name) ||
358
+ Array(attribute.stereotype).any? || attribute.literal ||
359
+ !attribute.xml_role.nil? || definition_present?(attribute.definition)
360
+ end
361
+
362
+ def render_bare_attribute(attribute)
363
+ header = "#{visibility_symbol(attribute.visibility)}#{attribute_name_text(attribute.name)}"
364
+ type = attribute_type_text(attribute)
365
+ header += ": #{type}" if type != ''
366
+ cardinality = attribute_cardinality(attribute)
367
+ header += " [#{cardinality}]" if cardinality
368
+ header += " as :#{attribute.xml_role}" if attribute.xml_role
369
+
370
+ if definition_present?(attribute.definition)
371
+ render_block(header) { render_definition(attribute.definition) }
372
+ else
373
+ line(header)
374
+ end
375
+ end
376
+
377
+ def bare_name?(name)
378
+ name.to_s.match?(%r{\A[A-Za-z0-9_/+]+\z})
379
+ end
380
+
381
+ def attribute_name_text(name)
382
+ bare_name?(name) ? name.to_s : quote_always(name)
383
+ end
384
+
385
+ def attribute_type_text(attribute)
386
+ return '' if attribute.type.to_s.empty?
387
+
388
+ stereotypes = Array(attribute.stereotype)
389
+ type = attribute.literal ? quote_always(attribute.type) : attribute.type.to_s
390
+ stereotypes.any? ? "<<#{stereotypes.first}>> #{type}" : type
391
+ end
392
+
393
+ def render_attribute_block(attribute)
394
+ header = "attribute #{attribute_name_text(attribute.name)}"
395
+ header += " as :#{attribute.xml_role}" if attribute.xml_role
396
+ render_block(header) do
397
+ line("type #{attribute.type}") if attribute.type.to_s != ''
398
+ cardinality = attribute_cardinality(attribute)
399
+ line("cardinality #{cardinality}") if cardinality
400
+ if Array(attribute.value_set).any?
401
+ values = attribute.value_set.map { |value| quote_scalar(value) }.join(', ')
402
+ line("values { #{values} }")
403
+ end
404
+ line("default #{render_data_value(attribute.default)}") if attribute.default
405
+
406
+ properties = Array(attribute.properties)
407
+ defaults, others = properties.partition { |property| property.name == 'default' }
408
+ defaults.each { |property| line(property_line(property)) }
409
+ line("pattern /#{attribute.pattern}/") if attribute.pattern
410
+ others.sort_by { |property| property.name.to_s }
411
+ .each { |property| line(property_line(property)) }
412
+ end
413
+ end
414
+
415
+ def property_line(property)
416
+ "#{property.name} = #{render_data_value(unwrap(property.value))}"
417
+ end
418
+
419
+ def render_operation(operation)
420
+ parameters = Array(operation.owned_parameter).map do |parameter|
421
+ operation_parameter(parameter)
422
+ end.join(', ')
423
+ text = "#{visibility_symbol(operation.visibility)}#{operation.name}(#{parameters})"
424
+ text += ": #{operation.return_type}" if operation.return_type.to_s != ''
425
+ stereotypes = Array(operation.stereotype)
426
+ text += " { #{stereotypes.join(', ')} }" if stereotypes.any?
427
+ line(text)
428
+ end
429
+
430
+ def operation_parameter(parameter)
431
+ parts = []
432
+ direction = parameter.direction.to_s
433
+ parts << direction if !direction.empty? && direction != 'in'
434
+ parts << parameter.name
435
+ parts << ": #{parameter.type}" if parameter.type.to_s != ''
436
+ parts << "= #{render_data_value(parameter.default)}" unless parameter.default.nil?
437
+ parts.join(' ')
438
+ end
439
+
440
+ def render_string_format(format)
441
+ render_block("string_format #{quote_scalar(format.format)}") do
442
+ line("artifact #{quote_scalar(format.artifact)}") if format.artifact
443
+ line("root #{quote_scalar(format.root)}") if format.root
444
+ end
445
+ end
446
+
447
+ # ---- instances ----
448
+
449
+ def render_instance_collection(collection)
450
+ name = collection.name.to_s.empty? ? '' : " #{quote_scalar(collection.name)}"
451
+ render_block("instances#{name}") do
452
+ Array(collection.imports).each { |entry| member { render_import(entry) } }
453
+ Array(collection.collections).each { |entry| member { render_collection_decl(entry) } }
454
+ Array(collection.exports).each { |entry| member { render_export(entry) } }
455
+ Array(collection.instances).each { |entry| member { render_instance(entry) } }
456
+ end
457
+ end
458
+
459
+ def render_import(entry)
460
+ render_block('import') do
461
+ member do
462
+ render_block("#{entry.format_type} #{quote_always(entry.file)}") do
463
+ Array(entry.attributes).each do |attribute|
464
+ member { render_instance_attribute(attribute) }
465
+ end
466
+ end
467
+ end
468
+ end
469
+ end
470
+
471
+ def render_export(entry)
472
+ render_block('export') do
473
+ member do
474
+ render_block("format #{entry.format_type}") do
475
+ Array(entry.attributes).each do |attribute|
476
+ member { render_instance_attribute(attribute) }
477
+ end
478
+ end
479
+ end
480
+ end
481
+ end
482
+
483
+ def render_collection_decl(collection)
484
+ render_block("collection #{quote_always(collection.name)}") do
485
+ if Array(collection.includes).any?
486
+ values = collection.includes.map { |value| quote_always(value) }.join(', ')
487
+ member { line("includes [#{values}]") }
488
+ end
489
+ next if Array(collection.validations).empty?
490
+
491
+ member do
492
+ render_block('validation') do
493
+ Array(collection.validations).each do |condition|
494
+ line("condition #{quote_always(condition)}")
495
+ end
496
+ end
497
+ end
498
+ end
499
+ end
500
+
501
+ def render_instance(instance, prefix = '')
502
+ render_block("#{prefix}#{instance_header(instance)}") do
503
+ member { line("isa #{quote_scalar(instance.isa)}") } if instance.isa.to_s != ''
504
+ if Array(instance.template).any?
505
+ member { render_template(instance.template) }
506
+ else
507
+ Array(instance.attributes).each do |attribute|
508
+ member { render_instance_attribute(attribute) }
509
+ end
510
+ end
511
+ member { render_instance(instance.instance) } if instance.instance
512
+ end
513
+ end
514
+
515
+ def instance_header(instance)
516
+ label =
517
+ if instance.name.to_s.empty?
518
+ instance.type.to_s.empty? ? 'instance' : "instance #{instance.type}"
519
+ elsif instance.type.to_s.empty?
520
+ "instance #{quote_always(instance.name)}"
521
+ else
522
+ "#{instance.type} #{quote_always(instance.name)}"
523
+ end
524
+ label += " extends #{quote_always(instance.parent)}" if instance.parent.to_s != ''
525
+ label
526
+ end
527
+
528
+ def render_instance_attribute(attribute)
529
+ if attribute.name.to_s.empty?
530
+ render_definition(attribute.definition, force_block: true) if definition_present?(attribute.definition)
531
+ return
532
+ end
533
+
534
+ # `# text` inside an instance body parses as an attribute named
535
+ # Comment with no type. Emitting it as a normal assignment would
536
+ # reintroduce a String type on the next parse.
537
+ if attribute.name == 'Comment' && attribute.type.to_s.empty?
538
+ line("# #{unwrap(attribute.value)}")
539
+ return
540
+ end
541
+
542
+ if Array(attribute.instances).any?
543
+ render_instance_values(attribute)
544
+ else
545
+ line("#{assignment_prefix(attribute)}#{render_data_value(unwrap(attribute.value))}")
546
+ end
547
+ end
548
+
549
+ # `name += value` extends an inherited value; the grammar stores
550
+ # that as the `extended` flag.
551
+ def assignment_prefix(attribute)
552
+ marker = attribute.extended ? '+' : ''
553
+ "#{quote_scalar(attribute.name)} #{marker}= "
554
+ end
555
+
556
+ def render_instance_values(attribute)
557
+ values = Array(attribute.instances)
558
+ if values.one?
559
+ render_instance(values.first, assignment_prefix(attribute))
560
+ return
561
+ end
562
+
563
+ line("#{assignment_prefix(attribute)}[")
564
+ @level += 1
565
+ values.each { |instance| render_instance(instance) }
566
+ @level -= 1
567
+ line(']')
568
+ end
569
+
570
+ def render_template(entries)
571
+ render_block('template') do
572
+ Array(entries).each do |entry|
573
+ member { render_instance_attribute(entry) }
574
+ end
575
+ end
576
+ end
577
+
578
+ # ---- values ----
579
+
580
+ def render_data_value(value)
581
+ value = unwrap(value)
582
+ case value
583
+ when nil then 'null'
584
+ when Float then number_scalar(value)
585
+ when true, false, Integer then value.to_s
586
+ when String then quote_always(value)
587
+ when Array
588
+ "[#{value.map { |item| render_data_value(item) }.join(', ')}]"
589
+ when Hash
590
+ reference = value[:reference] || value['reference']
591
+ return "ref:(#{reference})" if reference
592
+
593
+ pairs = value.map do |key, item|
594
+ "#{quote_scalar(key)} = #{render_data_value(item)}"
595
+ end
596
+ "{ #{pairs.join(' ')} }"
597
+ else
598
+ quote_always(value.to_s)
599
+ end
600
+ end
601
+
602
+ def unwrap(value)
603
+ value = value.value while value.is_a?(Lutaml::Lml::LiteralValue)
604
+ value
605
+ end
606
+
607
+ def cardinality_token(cardinality)
608
+ min = cardinality.min.to_s
609
+ max = cardinality.max.to_s
610
+ max = 'n' if max == '*'
611
+ max.empty? ? min : "#{min}..#{max}"
612
+ end
613
+
614
+ def attribute_cardinality(attribute)
615
+ cardinality = attribute.cardinality
616
+ return nil if cardinality.nil?
617
+
618
+ token = cardinality_token(cardinality)
619
+ token == '0..1' ? nil : token
620
+ end
621
+
622
+ # ---- definitions and comments ----
623
+
624
+ def definition_present?(definition)
625
+ text = definition_text(definition)
626
+ !(text.nil? || text.empty?)
627
+ end
628
+
629
+ def definition_text(definition)
630
+ definition&.to_s
631
+ end
632
+
633
+ def render_definition(definition, force_block: false)
634
+ text = definition_text(definition)
635
+ return if text.nil? || text.empty?
636
+
637
+ if force_block || text.include?("\n")
638
+ render_definition_block(text)
639
+ else
640
+ line("definition #{quote_always(text)}")
641
+ end
642
+ end
643
+
644
+ def render_definition_block(text)
645
+ line('definition {')
646
+ @level += 1
647
+ text.split("\n", -1).each do |part|
648
+ part.empty? ? (@lines << '') : line(escape_braces(part))
649
+ end
650
+ @level -= 1
651
+ trim_trailing_blanks
652
+ line('}')
653
+ end
654
+
655
+ def escape_braces(text)
656
+ text.gsub(/[{}]/) { |brace| "\\#{brace}" }
657
+ end
658
+
659
+ def render_comments(comments)
660
+ Array(comments).each { |comment| member { render_comment(comment) } }
661
+ end
662
+
663
+ def render_comment(comment)
664
+ text = comment.to_s
665
+ if text.include?("\n")
666
+ line("*|#{text}|*")
667
+ elsif text.empty?
668
+ line('**')
669
+ else
670
+ line("** #{text}")
671
+ end
672
+ end
673
+
674
+ # ---- headers ----
675
+
676
+ def declaration_visibility(visibility)
677
+ word = visibility.to_s
678
+ VISIBILITY_WORDS.include?(word) ? "#{word} " : ''
679
+ end
680
+
681
+ def visibility_symbol(visibility)
682
+ VISIBILITY_SYMBOLS.fetch(visibility.to_s, '')
683
+ end
684
+
685
+ def public_visibility?(visibility)
686
+ visibility.nil? || visibility.to_s.empty? || visibility.to_s == 'public'
687
+ end
688
+
689
+ def stereotype_suffix(stereotypes)
690
+ values = Array(stereotypes)
691
+ values.empty? ? '' : " <<#{values.first}>>"
692
+ end
693
+
694
+ # ---- layout ----
695
+
696
+ def render_collection(owner, collection)
697
+ Array(owner.public_send(collection)).each do |item|
698
+ member { yield item }
699
+ end
700
+ end
701
+
702
+ def render_block(header)
703
+ line("#{header} {")
704
+ @level += 1
705
+ @bodies.push(@lines.length)
706
+ yield
707
+ @bodies.pop
708
+ @level -= 1
709
+ trim_trailing_blanks
710
+ line('}')
711
+ end
712
+
713
+ def member
714
+ blank_line if @bodies.any? && @lines.length > @bodies.last
715
+ yield
716
+ end
717
+
718
+ def top
719
+ blank_line if @bodies.empty? || @lines.length > @bodies.last
720
+ yield
721
+ end
722
+
723
+ def line(text)
724
+ @lines << "#{' ' * @level}#{text}"
725
+ end
726
+
727
+ def blank_line
728
+ @lines << '' unless @lines.empty? || @lines.last == ''
729
+ end
730
+
731
+ def trim_trailing_blanks
732
+ @lines.pop while @lines.last == ''
733
+ end
734
+ end
735
+ end
736
+ end