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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f79d537da7c7fa27b8a630593350a988eecee947b4b32ed1621e02962df27e71
4
- data.tar.gz: ba8ba31cda8051fc5dee369e04e4548c62ea7f6f6e613689856fd231bb4be61c
3
+ metadata.gz: 30cbd708d890192c751f7fa14cc3eb87ba0e3ee344c636bb9581623de04ab639
4
+ data.tar.gz: 5a89263d3bedb5d4aaf587c9644f2479f135e6fd0b3aca923931dc24de001002
5
5
  SHA512:
6
- metadata.gz: 4cfee5710eb7c59e10c8ea3438e7ce65176a6a42ac296072da6ccd52cea2709f244acac691b45e1b9250bc7aa3c89a2e367e5719655575ece7b6425774d218e6
7
- data.tar.gz: c011827fc8554815cca6119741b597ad779e0966b6e5fadf0cf8c729fae28974151b18994a11d38a68ee544c3511e035042f6a6eaa3687dcf0070064605e25b9
6
+ metadata.gz: fa80e98551690a86dfd630e6ad09e5b2965508c8462f01aff53e06e5d8fee8affa0e50af39a31888c6231685638b965dead7390b4cc25b50f968dc8b5faf976e
7
+ data.tar.gz: 462a67af964daef324c0cacf8b4591aad300a7c923f74a747d756286e7a285b6c527872210f5df438510fe48ea6c467139fadc770924fecba2f83abd102d9377
@@ -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'
@@ -139,6 +167,7 @@ module Lutaml
139
167
  desc: 'Schema output path (file for one model, directory for many)'
140
168
  def compile(path)
141
169
  raise Thor::Error, "File does not exist: #{path}" unless File.exist?(path)
170
+ raise Thor::Error, "Expected a file, got directory: #{path}" if File.directory?(path)
142
171
 
143
172
  ns = options[:namespace] || (::Lutaml::Lml::ModelCompiler::DEFAULT_NAMESPACE if options[:schema])
144
173
  result = File.open(path) { |file| Lutaml::Lml::ModelCompiler.new(namespace: ns).compile(file) }
@@ -206,6 +235,84 @@ module Lutaml
206
235
  name.split('::').last.gsub(/[^a-zA-Z0-9]+/, '_').downcase
207
236
  end
208
237
 
238
+ def file_canonical?(path)
239
+ input = Pathname.new(path)
240
+ raise Thor::Error, "File does not exist: #{path}" unless input.exist?
241
+
242
+ source = input.read
243
+ canonical = canonical_source(source)
244
+ return true if source == canonical
245
+
246
+ if options[:check]
247
+ say diff_source(source, canonical, path)
248
+ say "#{path} is not canonical", :red
249
+ false
250
+ else
251
+ input.write(canonical)
252
+ say "Formatted: #{path}", :green
253
+ true
254
+ end
255
+ end
256
+
257
+ def canonical_source(source)
258
+ document = Lutaml::Lml.parse_document(source)
259
+ ::Lutaml::Formatter::Canonical.new(document).render
260
+ end
261
+
262
+ def diff_source(source, canonical, path)
263
+ old_lines = source.lines.map(&:chomp)
264
+ new_lines = canonical.lines.map(&:chomp)
265
+ out = ["--- #{path}", "+++ #{path} (canonical)"]
266
+ out.concat(diff_lines(old_lines, new_lines))
267
+ out.join("\n")
268
+ end
269
+
270
+ def diff_lines(old_lines, new_lines)
271
+ return ['(diff omitted: file too large)'] if old_lines.length * new_lines.length > 4_000_000
272
+
273
+ table = Array.new(old_lines.length + 1) do
274
+ Array.new(new_lines.length + 1, 0)
275
+ end
276
+ (old_lines.length - 1).downto(0) do |i|
277
+ (new_lines.length - 1).downto(0) do |j|
278
+ table[i][j] = if old_lines[i] == new_lines[j]
279
+ table[i + 1][j + 1] + 1
280
+ else
281
+ [table[i + 1][j], table[i][j + 1]].max
282
+ end
283
+ end
284
+ end
285
+ walk_diff(old_lines, new_lines, table)
286
+ end
287
+
288
+ def walk_diff(old_lines, new_lines, table)
289
+ result = []
290
+ i = 0
291
+ j = 0
292
+ while i < old_lines.length && j < new_lines.length
293
+ if old_lines[i] == new_lines[j]
294
+ result << " #{old_lines[i]}"
295
+ i += 1
296
+ j += 1
297
+ elsif table[i + 1][j] >= table[i][j + 1]
298
+ result << "- #{old_lines[i]}"
299
+ i += 1
300
+ else
301
+ result << "+ #{new_lines[j]}"
302
+ j += 1
303
+ end
304
+ end
305
+ while i < old_lines.length
306
+ result << "- #{old_lines[i]}"
307
+ i += 1
308
+ end
309
+ while j < new_lines.length
310
+ result << "+ #{new_lines[j]}"
311
+ j += 1
312
+ end
313
+ result
314
+ end
315
+
209
316
  def assert_input_paths(paths)
210
317
  return unless paths.empty?
211
318
 
@@ -250,33 +357,75 @@ module Lutaml
250
357
 
251
358
  def validate_single_file(path_string)
252
359
  input_path = Pathname.new(path_string)
253
- return report_file_error(input_path, 'File does not exist') unless input_path.exist?
360
+ return [missing_file_diagnostic(input_path)] unless input_path.exist?
254
361
 
255
362
  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
363
+ diagnostics = Lutaml::Lml::Validator.diagnostics(document)
364
+ if diagnostics.any?
365
+ if text_output?
366
+ diagnostics.each do |diagnostic|
367
+ say diagnostic_line(diagnostic),
368
+ (diagnostic.severity == 'warning' ? :yellow : :red)
369
+ end
370
+ end
371
+ elsif text_output?
260
372
  say "✓ #{input_path}", :green
261
- nil
262
373
  end
374
+ diagnostics
375
+ rescue Thor::Error => e
376
+ [contract_diagnostic('contract/LML-C02', e.message, input_path)]
377
+ rescue Lutaml::Lml::Error => e
378
+ [error_diagnostic(e, input_path)]
263
379
  rescue StandardError => e
264
- report_file_error(input_path, e.message)
380
+ [contract_diagnostic('contract/LML-C03', e.message, input_path)]
381
+ end
382
+
383
+ def missing_file_diagnostic(input_path)
384
+ contract_diagnostic('contract/LML-C01', 'File does not exist', input_path)
385
+ end
386
+
387
+ def contract_diagnostic(code, message, input_path)
388
+ diagnostic = Diagnostic.build(code, message: message, file: input_path.to_s)
389
+ say "✗ #{diagnostic}", :red if text_output?
390
+ diagnostic
391
+ end
392
+
393
+ def error_diagnostic(error, input_path)
394
+ diagnostic = error.diagnostic
395
+ return diagnostic if diagnostic&.file
396
+
397
+ Diagnostic.build(
398
+ diagnostic&.code || 'parse/LML-001',
399
+ message: diagnostic&.message || error.message.lines.first.to_s.strip,
400
+ file: input_path.to_s,
401
+ line: diagnostic&.line,
402
+ column: diagnostic&.column,
403
+ length: diagnostic&.length,
404
+ )
265
405
  end
266
406
 
267
- def violations_message(violations)
268
- violations.map { |v| "[#{v.rule}] #{v.message}" }.join('; ')
407
+ def text_output?
408
+ options[:format] != 'json'
269
409
  end
270
410
 
271
- def report_file_error(input_path, message)
272
- say "✗ #{input_path}: #{message}", :red
273
- "#{input_path}: #{message}"
411
+ def diagnostic_line(diagnostic)
412
+ marker = diagnostic.severity == 'warning' ? '⚠' : '✗'
413
+ "#{marker} #{diagnostic}"
274
414
  end
275
415
 
276
- def report_validation(errors)
277
- if errors.any?
278
- say "\nValidation failed with #{errors.size} error(s)", :red
416
+ def report_validation(diagnostics)
417
+ errors = diagnostics.count { |diagnostic| diagnostic.severity != 'warning' }
418
+ if options[:format] == 'json'
419
+ puts JSON.pretty_generate(diagnostics.map(&:to_hash))
420
+ exit 1 if errors.positive?
421
+ return
422
+ end
423
+
424
+ if errors.positive?
425
+ say "\nValidation failed with #{errors} error(s)", :red
279
426
  exit 1
427
+ elsif diagnostics.any?
428
+ say "\nAll files valid (#{diagnostics.size} warning(s))", :yellow
280
429
  else
281
430
  say "\nAll files valid!", :green
282
431
  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)
@@ -370,6 +402,7 @@ module Lutaml
370
402
  if (section = hash.delete(:default_string_formats))
371
403
  model.default_string_formats << build_string_format(section)
372
404
  end
405
+ expand_class_namespace(model, hash)
373
406
  return unless (derived = hash.delete(:derived_field))
374
407
 
375
408
  model.derived_fields << build(:derived_field,
@@ -377,6 +410,15 @@ module Lutaml
377
410
  type: unquote(derived[:type]))
378
411
  end
379
412
 
413
+ # Issue #49: an in-class `namespace "uri" [prefix "p"]` line
414
+ def expand_class_namespace(model, hash)
415
+ decl = hash.delete(:class_namespace) or return
416
+
417
+ model.xml_namespace = build(:namespace,
418
+ uri: unquote(decl[:uri]),
419
+ prefix: unquote(decl[:prefix]))
420
+ end
421
+
380
422
  # L7a: from_table declares the enum's members as an artifact table
381
423
  # { from_table_name:, from_table_artifact? } — expansion happens at
382
424
  # compile time (deterministic materialization, no runtime data dep)
@@ -406,8 +448,8 @@ module Lutaml
406
448
  end
407
449
 
408
450
  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])
451
+ items = section[:items].to_a.to_h do |item|
452
+ [item[:key].to_sym, unquote(item[:value])]
411
453
  end
412
454
  build(:string_format,
413
455
  format: unquote(section[:format]),