lutaml-lml 0.2.0 → 0.3.0

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/lutaml/lml/cli.rb +84 -18
  3. data/lib/lutaml/lml/data_processor/value_processing.rb +6 -1
  4. data/lib/lutaml/lml/document_builder.rb +72 -3
  5. data/lib/lutaml/lml/format/adapter/mapping.rb +12 -0
  6. data/lib/lutaml/lml/format/adapter/standard_adapter.rb +41 -12
  7. data/lib/lutaml/lml/formatter/elk.rb +45 -0
  8. data/lib/lutaml/lml/formatter/graphviz/node_formatter.rb +2 -1
  9. data/lib/lutaml/lml/formatter/svg_pdf/path_parser.rb +134 -0
  10. data/lib/lutaml/lml/formatter/svg_pdf.rb +281 -0
  11. data/lib/lutaml/lml/formatter.rb +2 -0
  12. data/lib/lutaml/lml/grammar/lml.artifact.json +7265 -4550
  13. data/lib/lutaml/lml/grammar/lml.parg +230 -115
  14. data/lib/lutaml/lml/layout/elk_engine.rb +15 -0
  15. data/lib/lutaml/lml/layout/elk_graph_builder.rb +129 -0
  16. data/lib/lutaml/lml/layout/elk_svg_markers.rb +45 -0
  17. data/lib/lutaml/lml/layout/elk_svg_renderer.rb +119 -0
  18. data/lib/lutaml/lml/layout/graph_viz_engine.rb +1 -1
  19. data/lib/lutaml/lml/layout.rb +4 -0
  20. data/lib/lutaml/lml/model_compiler/enums.rb +172 -0
  21. data/lib/lutaml/lml/model_compiler/hydration.rb +123 -0
  22. data/lib/lutaml/lml/model_compiler/serialization_mappings.rb +162 -0
  23. data/lib/lutaml/lml/model_compiler/string_formats.rb +42 -0
  24. data/lib/lutaml/lml/model_compiler/validation.rb +65 -0
  25. data/lib/lutaml/lml/model_compiler.rb +46 -453
  26. data/lib/lutaml/lml/models/document.rb +29 -0
  27. data/lib/lutaml/lml/models/operation_parameter.rb +1 -0
  28. data/lib/lutaml/lml/models/reference.rb +23 -0
  29. data/lib/lutaml/lml/models/top_element_attribute.rb +12 -0
  30. data/lib/lutaml/lml/models/uml_class.rb +1 -1
  31. data/lib/lutaml/lml/preprocessor.rb +44 -79
  32. data/lib/lutaml/lml/transform.rb +54 -6
  33. data/lib/lutaml/lml/validator.rb +88 -19
  34. data/lib/lutaml/lml/version.rb +1 -1
  35. data/lib/lutaml/lml.rb +19 -3
  36. metadata +17 -4
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Layout
5
+ # SVG arrow-marker vocabulary and the <defs> block for ElkSvgRenderer.
6
+ # Marker ids are keyed by association member_end_type.
7
+ module ElkSvgMarkers
8
+ EDGE_STROKE = '#666666'
9
+
10
+ ARROW_MARKERS = {
11
+ 'association' => 'arrow',
12
+ 'aggregation' => 'diamond',
13
+ 'composition' => 'diamond_filled',
14
+ 'generalization' => 'triangle',
15
+ 'uses' => 'arrow',
16
+ 'direct' => 'arrow'
17
+ }.freeze
18
+
19
+ MARKER_SHAPES = {
20
+ 'arrow' => %(<path d="M 0 0 L 10 5 L 0 10 z" fill="#{EDGE_STROKE}"/>),
21
+ 'triangle' => %(<path d="M 0 0 L 12 5 L 0 10 z" fill="white" stroke="#{EDGE_STROKE}"/>),
22
+ 'diamond' => %(<path d="M 0 4 L 7 0 L 14 4 L 7 8 z" fill="white" stroke="#{EDGE_STROKE}"/>),
23
+ 'diamond_filled' => %(<path d="M 0 4 L 7 0 L 14 4 L 7 8 z" fill="#{EDGE_STROKE}"/>)
24
+ }.freeze
25
+
26
+ def arrow_marker(edge)
27
+ edge_type = edge.properties&.fetch('member_end_type', nil)
28
+ ARROW_MARKERS.fetch(edge_type.to_s, 'arrow')
29
+ end
30
+
31
+ def defs
32
+ MARKER_SHAPES.filter_map { |id, path| marker_def(id, path) }.join("\n")
33
+ end
34
+
35
+ private
36
+
37
+ def marker_def(id, path)
38
+ w, h = id.start_with?('diamond') ? %w[14 8] : %w[10 10]
39
+ %(<marker id="#{id}" viewBox="0 0 #{w} #{h}" refX="9" refY="4" ) +
40
+ %(markerWidth="#{w}" markerHeight="#{h}" ) +
41
+ %(orient="auto-start-reverse">#{path}</marker>)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Layout
5
+ # Renders a laid-out Elkrb::Graph (nodes positioned by the layout) as
6
+ # standalone SVG: rounded-rect nodes with text-line labels and
7
+ # polyline edges with per-type arrow markers.
8
+ class ElkSvgRenderer
9
+ include ElkSvgMarkers
10
+
11
+ EDGE_STROKE = ElkSvgMarkers::EDGE_STROKE
12
+
13
+ HEADER = <<~XML
14
+ <?xml version="1.0" encoding="UTF-8"?>
15
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%<width>s" height="%<height>s" viewBox="0 0 %<width>s %<height>s">
16
+ XML
17
+ NODE_FILL = '#f8f8f8'
18
+ NODE_STROKE = '#333333'
19
+
20
+ def render(graph)
21
+ @graph = graph
22
+ @out = +''
23
+ @out << format(HEADER, width: bounds_width, height: bounds_height)
24
+ @out << "<defs>\n#{defs}\n</defs>\n"
25
+ nodes
26
+ edges
27
+ @out << "</svg>\n"
28
+ @out
29
+ end
30
+
31
+ private
32
+
33
+ def nodes
34
+ @graph.children.each { |node| render_node(node) }
35
+ end
36
+
37
+ def render_node(node)
38
+ @out << %(<g transform="translate(#{node.x.to_i},#{node.y.to_i})">)
39
+ @out << node_rect(node)
40
+ node.labels.each_with_index { |label, i| @out << node_label(label, i) }
41
+ @out << '</g>'
42
+ end
43
+
44
+ def node_rect(node)
45
+ %(<rect width="#{node.width.to_i}" height="#{node.height.to_i}" rx="6" ) +
46
+ %(fill="#{NODE_FILL}" stroke="#{NODE_STROKE}"/>)
47
+ end
48
+
49
+ def node_label(label, index)
50
+ y = ((index + 0.7) * ElkGraphBuilder::LINE_HEIGHT).round(1)
51
+ weight = index.zero? ? ' font-weight="bold"' : ''
52
+ %(<text x="7" y="#{y}" font-family="Helvetica" font-size="12"#{weight}>#{escape(label.text)}</text>)
53
+ end
54
+
55
+ def edges
56
+ @graph.edges.each { |edge| render_edge(edge) }
57
+ end
58
+
59
+ def render_edge(edge)
60
+ points = edge_points(edge)
61
+ return if points.empty?
62
+
63
+ @out << edge_polyline(edge, points)
64
+ edge_label(edge, points)
65
+ end
66
+
67
+ def edge_polyline(edge, points)
68
+ pts = points.map { |p| "#{p.x.round(1)},#{p.y.round(1)}" }.join(' ')
69
+ %(<polyline fill="none" stroke="#{EDGE_STROKE}" marker-end="url(##{arrow_marker(edge)})" points="#{pts}"/>)
70
+ end
71
+
72
+ def edge_label(edge, points)
73
+ label = Array(edge.labels).first
74
+ return unless label&.text
75
+
76
+ @out << edge_label_text(label, edge_anchor(points))
77
+ end
78
+
79
+ def edge_anchor(points)
80
+ mid = points[points.size / 2]
81
+ { x: (mid.x + 4).round(1), y: (mid.y - 4).round(1) }
82
+ end
83
+
84
+ def edge_label_text(label, anchor)
85
+ %(<text x="#{anchor[:x]}" y="#{anchor[:y]}" font-family="Helvetica" ) +
86
+ %(font-size="10" fill="#{EDGE_STROKE}">#{escape(label.text)}</text>)
87
+ end
88
+
89
+ def edge_points(edge)
90
+ sections = Array(edge.sections)
91
+ return [] if sections.empty?
92
+
93
+ points = [sections.first.start_point]
94
+ sections.each do |section|
95
+ points.concat(Array(section.bend_points))
96
+ points << section.end_point
97
+ end
98
+ points.compact
99
+ end
100
+
101
+ def bounds_width
102
+ extent(:x, :width) + 20
103
+ end
104
+
105
+ def bounds_height
106
+ extent(:y, :height) + 20
107
+ end
108
+
109
+ def extent(pos, size)
110
+ values = @graph.children.map { |n| n.public_send(pos).to_f + n.public_send(size).to_f }
111
+ [values.max || 0, 0].max.round
112
+ end
113
+
114
+ def escape(text)
115
+ text.to_s.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
116
+ end
117
+ end
118
+ end
119
+ end
@@ -7,7 +7,7 @@ module Lutaml
7
7
  module Layout
8
8
  class GraphVizEngine < Engine
9
9
  def render(type)
10
- Open3.popen3("dot -T#{type}") do |stdin, stdout, _stderr, _wait|
10
+ Open3.popen3("dot", "-T#{type}") do |stdin, stdout, _stderr, _wait|
11
11
  stdout.binmode
12
12
  stdin.puts(input)
13
13
  stdin.close
@@ -4,5 +4,9 @@ module Lutaml
4
4
  module Layout
5
5
  autoload :Engine, "lutaml/lml/layout/engine"
6
6
  autoload :GraphVizEngine, "lutaml/lml/layout/graph_viz_engine"
7
+ autoload :ElkEngine, "lutaml/lml/layout/elk_engine"
8
+ autoload :ElkGraphBuilder, "lutaml/lml/layout/elk_graph_builder"
9
+ autoload :ElkSvgRenderer, "lutaml/lml/layout/elk_svg_renderer"
10
+ autoload :ElkSvgMarkers, "lutaml/lml/layout/elk_svg_markers"
7
11
  end
8
12
  end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ class ModelCompiler
6
+ # Compiles LML enums into Serializable subclasses with member
7
+ # payloads, table-driven expansion, and value accessors.
8
+ module Enums
9
+ EnumValue = Struct.new(:name, :description, :payload)
10
+
11
+ private
12
+
13
+ def compile_enum(enum_def)
14
+ name = enum_def.name.to_s
15
+ expand_from_table(enum_def)
16
+ values = extract_enum_values(enum_def)
17
+
18
+ payload_fields = Array(enum_def.member_fields).map(&:name).map(&:to_s)
19
+ value_names = values.map { |v| v.name.to_s }
20
+ compiled_klass = Class.new(Lutaml::Model::Serializable) do
21
+ attribute :value, :string, values: value_names, default: values.first.name
22
+ attribute :description, :string
23
+ payload_fields.each do |field|
24
+ attribute field.to_sym, :string
25
+ end
26
+
27
+ define_method(:to_s) { value }
28
+ end
29
+
30
+ values.each do |value|
31
+ accessor = value.name.to_s.gsub(/[^a-zA-Z0-9_]/, '_')
32
+ compiled_klass.define_singleton_method(accessor) do
33
+ new({ value: value.name, description: value.description }.merge(value.payload || {}))
34
+ end
35
+ end
36
+ compiled_klass.define_singleton_method(:values) { values }
37
+
38
+ @enum_names << name
39
+ @enum_classes[name] = compiled_klass
40
+ register(name, compiled_klass)
41
+ end
42
+
43
+ # Builds a compiled Serializable subclass from a class or data-type
44
+ # definition: declares attributes and stashes forward-referenced
45
+ # attributes for post-registration resolution.
46
+
47
+ # RS 3001 §Attribute: `values { v1, v2 }` expands into an anonymous
48
+ # enum baked from the inline set; the attribute is typed with it.
49
+ def resolve_values_type(klass_def, attr)
50
+ return attr.type.to_s unless attr.value_set&.any?
51
+
52
+ enum_name = anonymous_enum_name(klass_def.name, attr.name)
53
+ members = attr.value_set.map { |v| ::Lutaml::Lml::TopElementAttribute.new(name: v.to_s) }
54
+ compile_enum(::Lutaml::Lml::Enum.new(name: enum_name, attributes: members))
55
+ enum_name
56
+ end
57
+
58
+ def anonymous_enum_name(class_name, attr_name)
59
+ base = class_name.to_s.split('::').last
60
+ attr_part = attr_name.to_s.split('_').map(&:capitalize).join
61
+ "#{base}#{attr_part}Values"
62
+ end
63
+
64
+ # L7a point 3: from_table members are baked at compile time — the
65
+ # artifact table's rows become the values, validated against the
66
+ # declared member_fields (unknown field = error; missing = nil).
67
+ # The member name comes from the row's "name" column.
68
+ def expand_from_table(enum_def)
69
+ return unless enum_def.from_table_name
70
+
71
+ rows = artifact_table_rows(enum_def)
72
+ known = Array(enum_def.member_fields).map { |f| f.name.to_s }
73
+ rows.each do |row|
74
+ row = row.transform_keys(&:to_s)
75
+ member_name = row.fetch('name')
76
+ unknown = row.keys - known - ['name']
77
+ if unknown.any?
78
+ raise ArgumentError,
79
+ "enum #{enum_def.name}: table row #{member_name.inspect} has " \
80
+ "undeclared fields #{unknown.inspect} (declared: #{known.inspect})"
81
+ end
82
+ payload = row.slice(*known).compact
83
+ enum_def.attributes << TopElementAttribute.new(
84
+ name: member_name,
85
+ properties: payload.map do |field, value|
86
+ TopElementAttribute.new(name: field, value: value)
87
+ end,
88
+ )
89
+ end
90
+ end
91
+
92
+ def artifact_table_rows(enum_def)
93
+ reference = enum_def.from_table_artifact || @default_artifact
94
+ path = artifact_path_for(reference.to_s)
95
+ unless path
96
+ raise ArgumentError,
97
+ "enum #{enum_def.name}: from_table artifact #{reference.inspect} not found"
98
+ end
99
+
100
+ envelope = JSON.parse(File.read(path))
101
+ table = envelope['tables'].to_h[enum_def.from_table_name]
102
+ unless table
103
+ raise ArgumentError,
104
+ "enum #{enum_def.name}: table #{enum_def.from_table_name.inspect} " \
105
+ "not found in artifact #{reference.inspect}"
106
+ end
107
+
108
+ table['rows'].to_a
109
+ end
110
+
111
+ def extract_enum_values(enum_def)
112
+ member_fields = Array(enum_def.member_fields).map do |f|
113
+ [f.name.to_s, f.type.to_s]
114
+ end
115
+ enum_def.attributes.map do |attr|
116
+ description = member_description(attr)
117
+ payload = extract_member_payload(attr)
118
+ validate_member_payload(enum_def, attr, payload, member_fields)
119
+ EnumValue.new(attr.name.to_s, description, payload)
120
+ end
121
+ end
122
+
123
+ # The member description rides a nested attribute named `description`
124
+ # (bare-member form) or `definition` (RS 3001 `value` blocks); both
125
+ # spellings are accepted.
126
+
127
+ # The member description rides a nested attribute named `description`
128
+ # (bare-member form) or `definition` (RS 3001 `value` blocks); both
129
+ # spellings are accepted.
130
+ def member_description(attr)
131
+ nested = Array(attr.attributes).find do |n|
132
+ %w[description definition].include?(n.name.to_s)
133
+ end
134
+ text = nested&.type || nested&.definition || nested&.value
135
+ text.is_a?(Hash) && text.key?(:string) ? text[:string] : text
136
+ end
137
+
138
+ # L7a: per-member payload lines ride the keyword-attribute
139
+ # properties (`abbreviation = "WD"`)
140
+
141
+ # L7a: per-member payload lines ride the keyword-attribute
142
+ # properties (`abbreviation = "WD"`)
143
+ def extract_member_payload(attr)
144
+ Array(attr.properties).to_h { |p| [p.name.to_s, p.value&.to_s] }
145
+ end
146
+
147
+ def validate_member_payload(enum_def, attr, payload, member_fields)
148
+ known = member_fields.map(&:first)
149
+ unknown = payload.keys - known
150
+ return unless unknown.any?
151
+
152
+ raise ArgumentError,
153
+ "enum #{enum_def.name}: member #{attr.name} has undeclared " \
154
+ "payload fields #{unknown.inspect} (declared: #{known.inspect})"
155
+ end
156
+
157
+ def enum_class_for(type)
158
+ type.is_a?(Class) && @enum_classes.value?(type) and return type
159
+ end
160
+
161
+ def enum_value_name(raw)
162
+ raw.split('::').last
163
+ end
164
+
165
+ # L7b: derived fields declare existence + type; computation is
166
+ # bound by the binder from artifact derive specs. The attribute
167
+ # participates in mappings like any other (relaton consumes URNs
168
+ # from model JSON).
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ class ModelCompiler
6
+ # Builds typed instances from parsed instance documents
7
+ # against the compiled class set.
8
+ module Hydration
9
+ private
10
+
11
+ def hydrate_instance(instance)
12
+ inner = instance.instance
13
+ return hydrate_instance(inner) if inner && Array(instance.attributes).empty?
14
+
15
+ type_name = resolve_instance_type(instance)
16
+ klass = @compiled[type_name]
17
+ return hydrate_as_untyped(instance, type_name) unless klass
18
+
19
+ attrs = extract_instance_attributes(instance, klass)
20
+ klass.new(**attrs)
21
+ end
22
+
23
+ # An inline map's cargo is {key_value_map: [{key:, value:}, ...]} with
24
+ # literal-shaped keys/values ({string: ..}, {number: ..}, {boolean: ..});
25
+ # materialize it as a plain Hash (issue #17: maps hydrate as data).
26
+
27
+ # An inline map's cargo is {key_value_map: [{key:, value:}, ...]} with
28
+ # literal-shaped keys/values ({string: ..}, {number: ..}, {boolean: ..});
29
+ # materialize it as a plain Hash (issue #17: maps hydrate as data).
30
+ def hydrate_map_cargo(cargo)
31
+ cargo[:key_value_map].to_h do |pair|
32
+ [hydrate_scalar(pair[:key]).to_s, hydrate_scalar(pair[:value])]
33
+ end
34
+ end
35
+
36
+ def hydrate_scalar(literal)
37
+ literal = literal.value if literal.respond_to?(:value)
38
+ literal.is_a?(Hash) ? literal.values.first : literal
39
+ end
40
+
41
+ def resolve_instance_type(instance)
42
+ # L6: the explicit isa keyword is the type override; a `type`
43
+ # attribute is ordinary data (pubid's identifier-kind field)
44
+ return demodulize(instance.isa.to_s) if instance.isa
45
+
46
+ demodulize(instance.type.to_s)
47
+ end
48
+
49
+ def demodulize(name)
50
+ name.split('::').last.to_s
51
+ end
52
+
53
+ def hydrate_as_untyped(instance, type_name)
54
+ { _name: type_name, **extract_raw_attributes(instance) }
55
+ end
56
+
57
+ def extract_raw_attributes(instance)
58
+ instance.each_attribute.with_object({}) do |(name, value, nested), hash|
59
+ hash[name.to_sym] = resolve_instance_value(value, nested)
60
+ end
61
+ end
62
+
63
+ def extract_instance_attributes(instance, klass)
64
+ schema_keys = klass.attributes.keys.to_set
65
+ instance.each_attribute.with_object({}) do |(name, value, nested), hash|
66
+ key = name.to_sym
67
+ next unless schema_keys.include?(key)
68
+
69
+ hash[key] = resolve_instance_value(value, nested, klass.attributes[key])
70
+ end
71
+ end
72
+
73
+ def resolve_instance_value(value, nested, attr_def = nil)
74
+ value = value.value if value.respond_to?(:value)
75
+ return hydrate_map_cargo(value) if value.is_a?(Hash) && value.key?(:key_value_map)
76
+
77
+ if nested.any?
78
+ # A scalar attribute with a single nested instance hydrates to
79
+ # the object itself; the declaration is the truth (issue #17).
80
+ return hydrate_typed(nested.first, attr_def) if !attr_def&.collection && nested.one?
81
+
82
+ nested.map { |i| hydrate_typed(i, attr_def) }
83
+ # The Array guard must run before the enum branch: an array
84
+ # value under an enum-typed attribute stays a raw array.
85
+ # rubocop:disable Lint/DuplicateBranch
86
+ elsif value.is_a?(Array)
87
+ value
88
+ elsif (enum_class = attr_def && enum_class_for(attr_def.type))
89
+ value_name = enum_value_name(value.to_s)
90
+ description = enum_class.values
91
+ .find { |v| v.name == value_name }&.description
92
+ enum_class.new(value: value_name, description: description)
93
+ elsif !value.nil?
94
+ value
95
+ # rubocop:enable Lint/DuplicateBranch
96
+ end
97
+ end
98
+
99
+ # An untyped nested instance takes the enclosing attribute's type;
100
+ # hydrate_as_untyped would otherwise emit a raw hash polluted with
101
+ # a `_name` key, which also defeats union member key-coverage.
102
+
103
+ # An untyped nested instance takes the enclosing attribute's type;
104
+ # hydrate_as_untyped would otherwise emit a raw hash polluted with
105
+ # a `_name` key, which also defeats union member key-coverage.
106
+ def hydrate_typed(instance, attr_def)
107
+ return hydrate_instance(instance) if instance.isa || !instance.type.to_s.empty?
108
+
109
+ type = attr_def&.type
110
+ return hydrate_instance(instance) unless type.is_a?(Class)
111
+
112
+ if type.include?(Lutaml::Model::Serialize)
113
+ type.new(**extract_instance_attributes(instance, type))
114
+ elsif type == Lutaml::Model::Type::Union
115
+ extract_raw_attributes(instance)
116
+ else
117
+ hydrate_instance(instance)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ class ModelCompiler
6
+ # Applies declared LML serialization mappings
7
+ # (xml / key_value wire names, namespaces) onto compiled classes.
8
+ module SerializationMappings
9
+ private
10
+
11
+ # In-class `mapping <format> { … }` is the shortcut for a sibling
12
+ # mapping targeting the enclosing class; hoist them so emission is
13
+ # uniform.
14
+ def hoist_class_serialization_mappings(doc)
15
+ (Array(doc.classes) + Array(doc.data_types)).each do |klass_def|
16
+ Array(klass_def.serialization_mappings).each do |mapping|
17
+ mapping.target = klass_def.name.to_s
18
+ @serialization_mappings << mapping
19
+ end
20
+ end
21
+ end
22
+
23
+ # Emit the lutaml-model mapping blocks declared in the LML source:
24
+ # mapping yaml { map "full-name", to: "name" }
25
+ # becomes klass.yml do |m| m.map "full-name", to: :name end
26
+ def apply_declared_mappings(compiled_klass, def_obj)
27
+ return unless def_obj.is_a?(UmlClass) || def_obj.is_a?(DataType)
28
+
29
+ def_obj.wire_mappings.to_a.group_by(&:format).each do |format, entries|
30
+ next unless format && Lutaml::Model::FormatRegistry.registered?(format.to_sym)
31
+
32
+ compiled_klass.public_send(format) do |mapping|
33
+ entries.each { |entry| mapping.map(entry.wire, to: entry.to.to_sym) }
34
+ end
35
+ end
36
+ end
37
+
38
+ # Apply a lutaml-model XML mapping to each compiled class so they
39
+ # support from_xml/to_xml. Runs after forward references are resolved
40
+ # so all attributes (immediate + deferred) get element mappings.
41
+ def apply_xml_mappings
42
+ @compiled.each do |name, klass|
43
+ next if @enum_names.include?(name)
44
+
45
+ custom = serialization_mapping_for(name, 'xml')
46
+ next apply_custom_xml_mapping(klass, custom) if custom
47
+
48
+ apply_xml_mapping(klass, name)
49
+ end
50
+ end
51
+
52
+ def apply_xml_mapping(klass, root_name)
53
+ attr_names = klass.attributes.keys
54
+ return if attr_names.empty?
55
+
56
+ klass.xml do |mapping|
57
+ mapping.element(root_name)
58
+ attr_names.each do |attr_name|
59
+ mapping.map_element(attr_name.to_s, to: attr_name)
60
+ end
61
+ end
62
+ end
63
+
64
+ # RS 3010 extension: key-value mapping blocks for the declared
65
+ # formats (yaml/json/toml share lutaml-model's key_value model).
66
+
67
+ # RS 3010 extension: key-value mapping blocks for the declared
68
+ # formats (yaml/json/toml share lutaml-model's key_value model).
69
+ def apply_key_value_mappings
70
+ @compiled.each do |name, klass|
71
+ next if @enum_names.include?(name)
72
+
73
+ custom = serialization_mapping_for(name, 'key_value')
74
+ apply_custom_key_value_mapping(klass, custom) if custom
75
+ end
76
+ end
77
+
78
+ def serialization_mapping_for(target, format)
79
+ @serialization_mappings.find do |m|
80
+ m.target.to_s == target && serialization_format(m.format) == format
81
+ end
82
+ end
83
+
84
+ def serialization_format(format)
85
+ %w[yaml json toml].include?(format.to_s) ? 'key_value' : format.to_s
86
+ end
87
+
88
+ def apply_custom_xml_mapping(klass, mapping)
89
+ check_namespace_ambiguity(mapping)
90
+ ns_class = namespace_class_for(mapping.namespace_ref) if mapping.namespace_ref
91
+ rule_ns = mapping.rules.select { |r| r.kind == 'namespace' }
92
+ .to_h { |r| [r.field, namespace_class_for(r.field)] }
93
+ klass.xml do |m|
94
+ m.element(mapping.element_name) if mapping.element_name
95
+ m.namespace(ns_class) if ns_class
96
+ m.mixed_content if mapping.mixed_content
97
+ mapping.rules.each do |rule|
98
+ case rule.kind
99
+ when 'element' then m.map_element(rule.wire, to: rule.field.to_sym)
100
+ when 'attribute' then m.map_attribute(rule.wire, to: rule.field.to_sym)
101
+ when 'content' then m.map_content(to: rule.field.to_sym)
102
+ when 'namespace' then m.namespace(rule_ns[rule.field])
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ def apply_custom_key_value_mapping(klass, mapping)
109
+ formats = mapping.format.to_s == 'key_value' ? %w[yaml json toml] : [mapping.format.to_s]
110
+ klass.instance_eval do
111
+ formats.each do |format|
112
+ public_send(format) do |m|
113
+ mapping.rules.each do |rule|
114
+ case rule.kind
115
+ when 'map' then m.map(rule.wire, to: rule.field.to_sym)
116
+ when 'key' then m.map_key(to_instance: rule.field.to_sym)
117
+ when 'value'
118
+ if rule.mode == 'as_attribute'
119
+ m.map_value(as_attribute: rule.field.to_sym)
120
+ else
121
+ m.map_value(to_instance: rule.field.to_sym)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ def check_namespace_ambiguity(mapping)
131
+ return unless mapping.format.to_s == 'xml'
132
+ return if mapping.namespace_ref || @declared_namespaces.length < 2
133
+
134
+ raise ArgumentError,
135
+ "mapping #{mapping.format} #{mapping.target}: ambiguous default " \
136
+ "namespace — #{@declared_namespaces.length} namespaces in scope " \
137
+ "(#{@declared_namespaces.map(&:name).join(', ')}); declare one with " \
138
+ "'namespace <name>'"
139
+ end
140
+
141
+ def namespace_class_for(ref)
142
+ return nil if ref.nil?
143
+
144
+ require 'lutaml/xml/namespace' unless defined?(Lutaml::Xml::Namespace)
145
+
146
+ ns_class = @namespace_classes[ref]
147
+ return ns_class if ns_class
148
+
149
+ declared = @declared_namespaces.find { |ns| ns.name.to_s == ref }
150
+ raise ArgumentError, "unknown namespace '#{ref}'" unless declared
151
+
152
+ ns_class = Class.new(Lutaml::Xml::Namespace)
153
+ uri = declared.uri
154
+ prefix = declared.prefix.to_s
155
+ ns_class.define_singleton_method(:uri) { uri }
156
+ ns_class.define_singleton_method(:prefix_default) { prefix }
157
+ @namespace_classes[ref] = ns_class
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ class ModelCompiler
6
+ # Registers declared string_format handlers on
7
+ # compiled classes.
8
+ module StringFormats
9
+ private
10
+
11
+ # L5: register grammar-backed string formats. Class-level
12
+ # declarations win; a models-block default applies to classes
13
+ # without their own. Artifact resolution: the compiler's
14
+ # artifacts map (name => path), else the string as a path.
15
+ def register_string_formats(doc)
16
+ defaults = doc.packages.flat_map(&:default_string_formats)
17
+ doc.classes.each do |class_def|
18
+ declared = Array(class_def.string_formats)
19
+ declared.each { |fmt| register_string_format(class_def.name.to_s, fmt) }
20
+
21
+ declared_formats = declared.map(&:format)
22
+ defaults.reject { |fmt| declared_formats.include?(fmt.format) }
23
+ .each { |fmt| register_string_format(class_def.name.to_s, fmt) }
24
+ end
25
+ end
26
+
27
+ def register_string_format(class_name, fmt)
28
+ artifact_path = artifact_path_for(fmt.artifact.to_s)
29
+ return unless artifact_path
30
+
31
+ compiled = @compiled[class_name]
32
+ return unless compiled
33
+
34
+ Parsanol::PARG::Lutaml.register(compiled,
35
+ format_name: fmt.format.to_sym,
36
+ artifact: artifact_path,
37
+ entry: fmt.root.to_s)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end