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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f79d537da7c7fa27b8a630593350a988eecee947b4b32ed1621e02962df27e71
4
- data.tar.gz: ba8ba31cda8051fc5dee369e04e4548c62ea7f6f6e613689856fd231bb4be61c
3
+ metadata.gz: 3d2253a295aed1a2d3218c34818aa1fddf38ca68be9830695175a52ba777719b
4
+ data.tar.gz: 6a070753dfcf0acf67b5196c8f8fca550f8b7ae41f01a15ad969f8d3e423de4f
5
5
  SHA512:
6
- metadata.gz: 4cfee5710eb7c59e10c8ea3438e7ce65176a6a42ac296072da6ccd52cea2709f244acac691b45e1b9250bc7aa3c89a2e367e5719655575ece7b6425774d218e6
7
- data.tar.gz: c011827fc8554815cca6119741b597ad779e0966b6e5fadf0cf8c729fae28974151b18994a11d38a68ee544c3511e035042f6a6eaa3687dcf0070064605e25b9
6
+ metadata.gz: f1190e583a4ed9f86af817b45863ab26a40668dc38f4f1d17458f5df869e47e409c2454a7282d60ba2a601896e53697a852a263ab6148390f972c29519a8af61
7
+ data.tar.gz: ec32fdc80d72afa8a7173655c3f47115acbab0c1b14535126954e0edd0bd2c809b218884c8e98287beb53bd740f225ec552f741171f5fded67dadeaced305ca5
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "lutaml/lml"
5
+
6
+ # JSON-RPC framing is byte-oriented; text-mode newline translation would
7
+ # turn \r\n into \r\r\n and break Content-Length.
8
+ $stdin.binmode
9
+ $stdout.binmode
10
+
11
+ exit Lutaml::Lml::Lsp::Server.new.run
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'thor'
4
4
  require 'pathname'
5
+ require 'json'
5
6
  require 'lutaml/lml'
6
7
 
7
8
  module Lutaml
@@ -103,11 +104,38 @@ module Lutaml
103
104
  DESC
104
105
  method_option :input_format, type: :string, aliases: '-i',
105
106
  desc: 'Input format (lutaml, yaml, exp)'
107
+ method_option :format, type: :string, enum: %w[text json], default: 'text',
108
+ desc: 'Report format: text (default) or json'
106
109
  def validate(*paths)
107
110
  assert_input_paths(paths)
108
111
  @input_format = options[:input_format] || DEFAULT_INPUT_FORMAT
109
- errors = paths.map { |p| validate_single_file(p) }.compact
110
- report_validation(errors)
112
+ diagnostics = paths.flat_map { |path| validate_single_file(path) }
113
+ report_validation(diagnostics)
114
+ end
115
+
116
+ desc 'format [PATHS]', 'Render LML files in canonical form'
117
+ long_desc <<-DESC
118
+ Rewrite LML files into the RS 3001 canonical form (deterministic
119
+ member order, two-space layout, canonical attribute forms and
120
+ quoting).
121
+
122
+ With --check, files are not modified: each non-canonical file is
123
+ reported with a diff and the command exits 1.
124
+
125
+ Examples:
126
+ lutaml lml format model.lml
127
+
128
+ lutaml lml format model.lml --check
129
+ DESC
130
+ method_option :check, type: :boolean, default: false,
131
+ desc: 'Report non-canonical files instead of rewriting them'
132
+ def format(*paths)
133
+ assert_input_paths(paths)
134
+
135
+ noncanonical = paths.reject { |path| file_canonical?(path) }
136
+ return if noncanonical.empty?
137
+
138
+ exit 1
111
139
  end
112
140
 
113
141
  desc 'compile PATH', 'Compile LML model definitions into Ruby classes'
@@ -206,6 +234,84 @@ module Lutaml
206
234
  name.split('::').last.gsub(/[^a-zA-Z0-9]+/, '_').downcase
207
235
  end
208
236
 
237
+ def file_canonical?(path)
238
+ input = Pathname.new(path)
239
+ raise Thor::Error, "File does not exist: #{path}" unless input.exist?
240
+
241
+ source = input.read
242
+ canonical = canonical_source(source)
243
+ return true if source == canonical
244
+
245
+ if options[:check]
246
+ say diff_source(source, canonical, path)
247
+ say "#{path} is not canonical", :red
248
+ false
249
+ else
250
+ input.write(canonical)
251
+ say "Formatted: #{path}", :green
252
+ true
253
+ end
254
+ end
255
+
256
+ def canonical_source(source)
257
+ document = Lutaml::Lml.parse_document(source)
258
+ ::Lutaml::Formatter::Canonical.new(document).render
259
+ end
260
+
261
+ def diff_source(source, canonical, path)
262
+ old_lines = source.lines.map(&:chomp)
263
+ new_lines = canonical.lines.map(&:chomp)
264
+ out = ["--- #{path}", "+++ #{path} (canonical)"]
265
+ out.concat(diff_lines(old_lines, new_lines))
266
+ out.join("\n")
267
+ end
268
+
269
+ def diff_lines(old_lines, new_lines)
270
+ return ['(diff omitted: file too large)'] if old_lines.length * new_lines.length > 4_000_000
271
+
272
+ table = Array.new(old_lines.length + 1) do
273
+ Array.new(new_lines.length + 1, 0)
274
+ end
275
+ (old_lines.length - 1).downto(0) do |i|
276
+ (new_lines.length - 1).downto(0) do |j|
277
+ table[i][j] = if old_lines[i] == new_lines[j]
278
+ table[i + 1][j + 1] + 1
279
+ else
280
+ [table[i + 1][j], table[i][j + 1]].max
281
+ end
282
+ end
283
+ end
284
+ walk_diff(old_lines, new_lines, table)
285
+ end
286
+
287
+ def walk_diff(old_lines, new_lines, table)
288
+ result = []
289
+ i = 0
290
+ j = 0
291
+ while i < old_lines.length && j < new_lines.length
292
+ if old_lines[i] == new_lines[j]
293
+ result << " #{old_lines[i]}"
294
+ i += 1
295
+ j += 1
296
+ elsif table[i + 1][j] >= table[i][j + 1]
297
+ result << "- #{old_lines[i]}"
298
+ i += 1
299
+ else
300
+ result << "+ #{new_lines[j]}"
301
+ j += 1
302
+ end
303
+ end
304
+ while i < old_lines.length
305
+ result << "- #{old_lines[i]}"
306
+ i += 1
307
+ end
308
+ while j < new_lines.length
309
+ result << "+ #{new_lines[j]}"
310
+ j += 1
311
+ end
312
+ result
313
+ end
314
+
209
315
  def assert_input_paths(paths)
210
316
  return unless paths.empty?
211
317
 
@@ -250,33 +356,75 @@ module Lutaml
250
356
 
251
357
  def validate_single_file(path_string)
252
358
  input_path = Pathname.new(path_string)
253
- return report_file_error(input_path, 'File does not exist') unless input_path.exist?
359
+ return [missing_file_diagnostic(input_path)] unless input_path.exist?
254
360
 
255
361
  document = parse_document(input_path)
256
- violations = Lutaml::Lml::Validator.violations(document)
257
- if violations.any?
258
- report_file_error(input_path, violations_message(violations))
259
- else
362
+ diagnostics = Lutaml::Lml::Validator.diagnostics(document)
363
+ if diagnostics.any?
364
+ if text_output?
365
+ diagnostics.each do |diagnostic|
366
+ say diagnostic_line(diagnostic),
367
+ (diagnostic.severity == 'warning' ? :yellow : :red)
368
+ end
369
+ end
370
+ elsif text_output?
260
371
  say "✓ #{input_path}", :green
261
- nil
262
372
  end
373
+ diagnostics
374
+ rescue Thor::Error => e
375
+ [contract_diagnostic('contract/LML-C02', e.message, input_path)]
376
+ rescue Lutaml::Lml::Error => e
377
+ [error_diagnostic(e, input_path)]
263
378
  rescue StandardError => e
264
- report_file_error(input_path, e.message)
379
+ [contract_diagnostic('contract/LML-C03', e.message, input_path)]
380
+ end
381
+
382
+ def missing_file_diagnostic(input_path)
383
+ contract_diagnostic('contract/LML-C01', 'File does not exist', input_path)
384
+ end
385
+
386
+ def contract_diagnostic(code, message, input_path)
387
+ diagnostic = Diagnostic.build(code, message: message, file: input_path.to_s)
388
+ say "✗ #{diagnostic}", :red if text_output?
389
+ diagnostic
390
+ end
391
+
392
+ def error_diagnostic(error, input_path)
393
+ diagnostic = error.diagnostic
394
+ return diagnostic if diagnostic&.file
395
+
396
+ Diagnostic.build(
397
+ diagnostic&.code || 'parse/LML-001',
398
+ message: diagnostic&.message || error.message.lines.first.to_s.strip,
399
+ file: input_path.to_s,
400
+ line: diagnostic&.line,
401
+ column: diagnostic&.column,
402
+ length: diagnostic&.length,
403
+ )
265
404
  end
266
405
 
267
- def violations_message(violations)
268
- violations.map { |v| "[#{v.rule}] #{v.message}" }.join('; ')
406
+ def text_output?
407
+ options[:format] != 'json'
269
408
  end
270
409
 
271
- def report_file_error(input_path, message)
272
- say "✗ #{input_path}: #{message}", :red
273
- "#{input_path}: #{message}"
410
+ def diagnostic_line(diagnostic)
411
+ marker = diagnostic.severity == 'warning' ? '⚠' : '✗'
412
+ "#{marker} #{diagnostic}"
274
413
  end
275
414
 
276
- def report_validation(errors)
277
- if errors.any?
278
- say "\nValidation failed with #{errors.size} error(s)", :red
415
+ def report_validation(diagnostics)
416
+ errors = diagnostics.count { |diagnostic| diagnostic.severity != 'warning' }
417
+ if options[:format] == 'json'
418
+ puts JSON.pretty_generate(diagnostics.map(&:to_hash))
419
+ exit 1 if errors.positive?
420
+ return
421
+ end
422
+
423
+ if errors.positive?
424
+ say "\nValidation failed with #{errors} error(s)", :red
279
425
  exit 1
426
+ elsif diagnostics.any?
427
+ say "\nAll files valid (#{diagnostics.size} warning(s))", :yellow
280
428
  else
281
429
  say "\nAll files valid!", :green
282
430
  end
@@ -120,9 +120,11 @@ module Lutaml
120
120
  end
121
121
 
122
122
  # `default: "Clear"` uses the colon separator (RS 3001 §Attribute);
123
- # the colon lands in the captured name and must not.
123
+ # the colon lands in the captured name and must not. A quoted key
124
+ # is verbatim, so a trailing colon there is content.
124
125
  def strip_argument_colon(name)
125
- name = name[:string] if name.is_a?(Hash) && name.key?(:string)
126
+ return name[:string] if name.is_a?(Hash) && name.key?(:string)
127
+
126
128
  name.to_s.sub(/:\z/, "")
127
129
  end
128
130
  end
@@ -13,6 +13,7 @@ module Lutaml
13
13
  # member payload properties.
14
14
  def process_value_declaration(decl)
15
15
  inner = { name: unwrap_string(decl[:value_name]) }
16
+ inner[:position] = decl[:position] if decl[:position]
16
17
  Array(decl[:members]).each do |member|
17
18
  normalize_value_member(inner, member)
18
19
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # Post-transform normalization: turns parse captures into the flat
6
+ # member hashes the document builder consumes.
5
7
  class DataProcessor
6
8
  autoload :ValueProcessing, "lutaml/lml/data_processor/value_processing"
7
9
  autoload :AttributeProcessing, "lutaml/lml/data_processor/attribute_processing"
@@ -18,13 +20,14 @@ module Lutaml
18
20
  include ViewProcessing
19
21
 
20
22
  KEY_HANDLERS = {
23
+ lml_version: :process_lml_version,
21
24
  requires: :process_requires,
22
25
  instances: :process_instances,
23
26
  instance: :process_instance,
24
27
  attributes: :process_attributes,
25
28
  value_decl: :process_value_declaration,
26
29
  show_list: :process_show_list,
27
- hide_list: :process_hide_list
30
+ hide_list: :process_hide_list,
28
31
  }.freeze
29
32
 
30
33
  def self.process(obj)
@@ -47,6 +50,10 @@ module Lutaml
47
50
  def process_requires(obj)
48
51
  obj.map { |req| process_value(req).last }
49
52
  end
53
+
54
+ def process_lml_version(value)
55
+ value.is_a?(Hash) ? value[:string] : value
56
+ end
50
57
  end
51
58
  end
52
59
  end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Lutaml
6
+ module Lml
7
+ # One diagnostic record: a code, severity, source location, message,
8
+ # and a remedy. Codes are namespaced by stage (parse/validation/
9
+ # hydration/contract) and listed in CODES, the single source of
10
+ # truth also documented in the README.
11
+ class Diagnostic < Lutaml::Model::Serializable
12
+ CODES = {
13
+ 'parse/LML-001' => {
14
+ severity: 'error',
15
+ remedy: 'Fix the syntax at the reported position.',
16
+ },
17
+ 'parse/LML-002' => {
18
+ severity: 'error',
19
+ remedy: 'Terminate the block comment with */.',
20
+ },
21
+ 'parse/LML-003' => {
22
+ severity: 'error',
23
+ remedy: 'Check the include path and the included file.',
24
+ },
25
+ 'validation/LML-V01' => {
26
+ severity: 'error',
27
+ remedy: 'Rename or remove the duplicate definition.',
28
+ },
29
+ 'validation/LML-V02' => {
30
+ severity: 'error',
31
+ remedy: 'Rename or remove the duplicate attribute.',
32
+ },
33
+ 'validation/LML-V03' => {
34
+ severity: 'error',
35
+ remedy: 'Populate the mandatory attribute on the instance.',
36
+ },
37
+ 'validation/LML-V04' => {
38
+ severity: 'error',
39
+ remedy: 'Use a value matching the declared primitive type.',
40
+ },
41
+ 'validation/LML-V05' => {
42
+ severity: 'error',
43
+ remedy: 'Use one of the values declared for the attribute.',
44
+ },
45
+ 'validation/LML-V06' => {
46
+ severity: 'error',
47
+ remedy: 'Declare the attribute or remove it from the instance.',
48
+ },
49
+ 'validation/LML-V07' => {
50
+ severity: 'error',
51
+ remedy: 'Point the reference at an existing instance or attribute.',
52
+ },
53
+ 'validation/LML-V08' => {
54
+ severity: 'error',
55
+ remedy: 'Break the reference cycle.',
56
+ },
57
+ 'validation/LML-V09' => {
58
+ severity: 'error',
59
+ remedy: 'Use a value matching the attribute pattern.',
60
+ },
61
+ 'validation/LML-V10' => {
62
+ severity: 'error',
63
+ remedy: 'Fix the attribute pattern so that it compiles.',
64
+ },
65
+ 'hydration/LML-H01' => {
66
+ severity: 'error',
67
+ remedy: 'Check the instance data against the compiled model.',
68
+ },
69
+ 'contract/LML-C01' => {
70
+ severity: 'error',
71
+ remedy: 'Check the path of the input file.',
72
+ },
73
+ 'contract/LML-C02' => {
74
+ severity: 'error',
75
+ remedy: 'Use one of the supported input formats.',
76
+ },
77
+ 'contract/LML-C03' => {
78
+ severity: 'error',
79
+ remedy: 'Check the input file and its format.',
80
+ },
81
+ 'contract/LML-C04' => {
82
+ severity: 'error',
83
+ remedy: 'Use a cardinality token such as 1, 0..1, or 0..n.',
84
+ },
85
+ 'contract/LML-C05' => {
86
+ severity: 'error',
87
+ remedy: 'Check the imported view path and the imported files.',
88
+ },
89
+ 'contract/LML-C06' => {
90
+ severity: 'warning',
91
+ remedy: 'Use a supported language version or upgrade lutaml-lml.',
92
+ },
93
+ }.freeze
94
+
95
+ attribute :code, :string
96
+ attribute :severity, :string, default: 'error'
97
+ attribute :file, :string
98
+ attribute :line, :integer
99
+ attribute :column, :integer
100
+ attribute :length, :integer
101
+ attribute :message, :string
102
+ attribute :remedy, :string
103
+
104
+ key_value do
105
+ map 'code', to: :code
106
+ map 'severity', to: :severity
107
+ map 'file', to: :file
108
+ map 'line', to: :line
109
+ map 'column', to: :column
110
+ map 'length', to: :length
111
+ map 'message', to: :message
112
+ map 'remedy', to: :remedy
113
+ end
114
+
115
+ def self.build(code, message:, file: nil, line: nil, column: nil,
116
+ length: nil, severity: nil, remedy: nil)
117
+ entry = CODES.fetch(code)
118
+ new(
119
+ code: code,
120
+ severity: severity || entry[:severity],
121
+ file: file,
122
+ line: line,
123
+ column: column,
124
+ length: length,
125
+ message: message,
126
+ remedy: remedy || entry[:remedy],
127
+ )
128
+ end
129
+
130
+ # Human rendering: `path:line:col: [CODE] message`, then the
131
+ # remedy. Position segments are omitted when the parser could not
132
+ # attach them.
133
+ def to_s
134
+ "#{location}[#{code}] #{message}#{remedy_suffix}"
135
+ end
136
+
137
+ def location
138
+ parts = [file, line, column].compact
139
+ parts.empty? ? '' : "#{parts.join(':')}: "
140
+ end
141
+
142
+ private
143
+
144
+ def remedy_suffix
145
+ remedy.to_s.empty? ? '' : "\n remedy: #{remedy}"
146
+ end
147
+ end
148
+ end
149
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
+ # Builds domain model objects from processed parse hashes, applying
6
+ # source positions as non-serialized metadata.
5
7
  class DocumentBuilder
6
8
  # Default registry: builder keys → LML model classes.
7
9
  # Callers needing a different mapping can pass their own registry
@@ -19,6 +21,8 @@ module Lutaml
19
21
  operation: ::Lutaml::Lml::Operation,
20
22
  constraint: ::Lutaml::Lml::Constraint,
21
23
  value: ::Lutaml::Lml::Value,
24
+ instance: ::Lutaml::Lml::Instance,
25
+ instance_collection: ::Lutaml::Lml::InstanceCollection,
22
26
  view_import: ::Lutaml::Lml::ViewImport,
23
27
  view_filter: ::Lutaml::Lml::ViewFilter,
24
28
  mapping: ::Lutaml::Lml::Mapping,
@@ -26,7 +30,7 @@ module Lutaml
26
30
  derived_field: ::Lutaml::Lml::DerivedField,
27
31
  member_field: ::Lutaml::Lml::MemberField,
28
32
  namespace: ::Lutaml::Lml::Namespace,
29
- serialization_mapping: ::Lutaml::Lml::SerializationMapping
33
+ serialization_mapping: ::Lutaml::Lml::SerializationMapping,
30
34
  }.freeze
31
35
 
32
36
  attr_reader :registry
@@ -39,6 +43,7 @@ module Lutaml
39
43
  document package class enum data_type diagram view_import view_filter
40
44
  attribute association operation constraint value cardinality mapping
41
45
  string_format derived_field namespace serialization_mapping
46
+ instance instance_collection
42
47
  ].freeze
43
48
 
44
49
  MEMBER_KEY_MAP = {
@@ -56,7 +61,8 @@ module Lutaml
56
61
  mappings: :mapping,
57
62
  member_fields: :member_field,
58
63
  namespaces: :namespace,
59
- serialization_mappings: :serialization_mapping
64
+ serialization_mappings: :serialization_mapping,
65
+ instances: :instance,
60
66
  }.freeze
61
67
 
62
68
  FACTORY_KEYS.each do |key|
@@ -71,6 +77,7 @@ module Lutaml
71
77
 
72
78
  def set_model(model, hash)
73
79
  hash = build_members(model, hash)
80
+ apply_collection_members(model, hash)
74
81
  set_model_attributes(model, hash)
75
82
  expand_class_specializer(model, hash)
76
83
  end
@@ -86,10 +93,21 @@ module Lutaml
86
93
 
87
94
  def set_model_attributes(model, hash)
88
95
  hash.each do |key, value|
96
+ if key == :position
97
+ attach_position(model, value)
98
+ next
99
+ end
100
+
89
101
  apply_attribute(model, key, value)
90
102
  end
91
103
  end
92
104
 
105
+ def attach_position(model, value)
106
+ return unless model.is_a?(Positioned) && value.is_a?(Hash)
107
+
108
+ model.source_position = SourcePosition.new(**value)
109
+ end
110
+
93
111
  def build_members(model, hash)
94
112
  members = hash.delete(:members)
95
113
  members.to_a.each do |member_hash|
@@ -133,15 +151,26 @@ module Lutaml
133
151
  normalize_serialization_mapping(hash)
134
152
  expand_instances_collection_name(hash)
135
153
  expand_nested_members(model, hash)
154
+ apply_collection_members(model, hash)
155
+
156
+ remap_filter_keys(hash, model)
157
+ end
158
+
159
+ # Builds collection-shaped keys through the registry rather than
160
+ # letting lutaml-model cast raw hashes, so nested entities keep
161
+ # their non-serialized source metadata (positions).
162
+ def apply_collection_members(model, hash)
163
+ model.instances = build(:instance_collection, hash.delete(:instances)) if model.is_a?(Document) && hash.key?(:instances)
164
+
136
165
  MEMBER_KEY_MAP.each do |plural_key, singular_key|
137
166
  data = hash.delete(plural_key)
138
167
  next if data.nil?
139
168
 
140
- member = build(singular_key, data)
141
- ensure_collection(model, plural_key) << member
169
+ entries = data.is_a?(Array) ? data : [data]
170
+ entries.each do |entry|
171
+ ensure_collection(model, plural_key) << build(singular_key, entry)
172
+ end
142
173
  end
143
-
144
- remap_filter_keys(hash, model)
145
174
  end
146
175
 
147
176
  # RS 3001 §Enums: an enum-level `values { v1, v2 }` shorthand
@@ -165,12 +194,15 @@ module Lutaml
165
194
  # become the entry's instances (RS 3001 §Enums values carrying
166
195
  # instances).
167
196
  entry = { name: inner[:name] }
197
+ entry[:position] = inner[:position] if inner[:position]
168
198
  entry[:attributes] = inner[:attributes] if inner[:attributes]
169
- entry[:properties] = Array(inner[:properties]).flat_map do |pair|
170
- pair.is_a?(Hash) ? [pair] : Array(pair)
171
- end if inner[:properties]
199
+ if inner[:properties]
200
+ entry[:properties] = Array(inner[:properties]).flat_map do |pair|
201
+ pair.is_a?(Hash) ? [pair] : Array(pair)
202
+ end
203
+ end
172
204
  entry[:instances] = inner[:instances] if inner[:instances]
173
- apply_attribute(model, :attributes, entry)
205
+ append_collection(model, :attributes, build(:attribute, entry))
174
206
  end
175
207
 
176
208
  # Namespace declaration bodies arrive as keyword-line members
@@ -293,22 +325,22 @@ module Lutaml
293
325
  end
294
326
  end
295
327
 
296
- def normalize_operation_hash(op)
297
- op = op.dup
298
- if (vis = op.delete(:visibility_modifier))
299
- op[:visibility] = Transform::VISIBILITY_MAP.fetch(vis.to_s, 'public')
328
+ def normalize_operation_hash(operation)
329
+ operation = operation.dup
330
+ if (vis = operation.delete(:visibility_modifier))
331
+ operation[:visibility] = Transform::VISIBILITY_MAP.fetch(vis.to_s, 'public')
300
332
  end
301
- stereo = op[:stereotype]
333
+ stereo = operation[:stereotype]
302
334
  if stereo
303
335
  items = stereo.is_a?(Array) ? stereo : [stereo]
304
- op[:stereotype] = items.map { |x| x.is_a?(Hash) ? x[:stereotype].to_s : x.to_s }
336
+ operation[:stereotype] = items.map { |x| x.is_a?(Hash) ? x[:stereotype].to_s : x.to_s }
305
337
  end
306
- params = op[:owned_parameter]
338
+ params = operation[:owned_parameter]
307
339
  if params
308
340
  list = params.is_a?(Array) ? params : [params]
309
- op[:owned_parameter] = list.map { |param| normalize_operation_param(param) }
341
+ operation[:owned_parameter] = list.map { |param| normalize_operation_param(param) }
310
342
  end
311
- op
343
+ operation
312
344
  end
313
345
 
314
346
  def normalize_operation_param(param)
@@ -406,8 +438,8 @@ module Lutaml
406
438
  end
407
439
 
408
440
  def build_string_format(section)
409
- items = section[:items].to_a.each_with_object({}) do |item, acc|
410
- acc[item[:key].to_sym] = unquote(item[:value])
441
+ items = section[:items].to_a.to_h do |item|
442
+ [item[:key].to_sym, unquote(item[:value])]
411
443
  end
412
444
  build(:string_format,
413
445
  format: unquote(section[:format]),