lutaml-lml 0.3.3 → 0.3.4

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