lutaml-lml 0.2.0 → 0.2.1

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.
@@ -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
@@ -145,6 +145,20 @@ module Lutaml
145
145
  klass.new(**attrs)
146
146
  end
147
147
 
148
+ # An inline map's cargo is {key_value_map: [{key:, value:}, ...]} with
149
+ # literal-shaped keys/values ({string: ..}, {number: ..}, {boolean: ..});
150
+ # materialize it as a plain Hash (issue #17: maps hydrate as data).
151
+ def hydrate_map_cargo(cargo)
152
+ cargo[:key_value_map].to_h do |pair|
153
+ [hydrate_scalar(pair[:key]).to_s, hydrate_scalar(pair[:value])]
154
+ end
155
+ end
156
+
157
+ def hydrate_scalar(literal)
158
+ literal = literal.value if literal.respond_to?(:value)
159
+ literal.is_a?(Hash) ? literal.values.first : literal
160
+ end
161
+
148
162
  def resolve_instance_type(instance)
149
163
  # L6: the explicit isa keyword is the type override; a `type`
150
164
  # attribute is ordinary data (pubid's identifier-kind field)
@@ -178,7 +192,14 @@ module Lutaml
178
192
  end
179
193
 
180
194
  def resolve_instance_value(value, nested, attr_def = nil)
195
+ value = value.value if value.respond_to?(:value)
196
+ return hydrate_map_cargo(value) if value.is_a?(Hash) && value.key?(:key_value_map)
197
+
181
198
  if nested.any?
199
+ # A scalar attribute with a single nested instance hydrates to
200
+ # the object itself; the declaration is the truth (issue #17).
201
+ return hydrate_instance(nested.first) if !attr_def&.collection && nested.one?
202
+
182
203
  nested.map { |i| hydrate_instance(i) }
183
204
  elsif value.is_a?(Array)
184
205
  value
@@ -337,7 +358,7 @@ module Lutaml
337
358
  return if attr_names.empty?
338
359
 
339
360
  klass.xml do |mapping|
340
- mapping.root(root_name)
361
+ mapping.element(root_name)
341
362
  attr_names.each do |attr_name|
342
363
  mapping.map_element(attr_name.to_s, to: attr_name)
343
364
  end
@@ -371,7 +392,7 @@ module Lutaml
371
392
  rule_ns = mapping.rules.select { |r| r.kind == 'namespace' }
372
393
  .to_h { |r| [r.field, namespace_class_for(r.field)] }
373
394
  klass.xml do |m|
374
- m.root(mapping.element_name) if mapping.element_name
395
+ m.element(mapping.element_name) if mapping.element_name
375
396
  m.namespace(ns_class) if ns_class
376
397
  m.mixed_content if mapping.mixed_content
377
398
  mapping.rules.each do |rule|
@@ -421,7 +442,7 @@ module Lutaml
421
442
  def namespace_class_for(ref)
422
443
  return nil if ref.nil?
423
444
 
424
- require "lutaml/xml/namespace" unless defined?(Lutaml::Xml::Namespace)
445
+ require 'lutaml/xml/namespace' unless defined?(Lutaml::Xml::Namespace)
425
446
 
426
447
  ns_class = @namespace_classes[ref]
427
448
  return ns_class if ns_class
@@ -17,6 +17,9 @@ module Lutaml
17
17
  attribute :enums, "Lutaml::Lml::Enum", collection: true, default: -> { [] }
18
18
  attribute :packages, "Lutaml::Lml::Package", collection: true, default: -> { [] }
19
19
  attribute :namespaces, "Lutaml::Lml::Namespace", collection: true, default: -> { [] }
20
+ # `models <Name> version "x.y.z"` headers: model name -> declared
21
+ # version (issue #16, the co-release identity line).
22
+ attribute :model_versions, :hash, default: -> { {} }
20
23
  attribute :serialization_mappings, "Lutaml::Lml::SerializationMapping", collection: true, default: -> { [] }
21
24
  attribute :primitives, "Lutaml::Lml::PrimitiveType", collection: true, default: -> { [] }
22
25
  attribute :associations, "Lutaml::Lml::Association", collection: true, default: -> { [] }
@@ -27,6 +30,32 @@ module Lutaml
27
30
  attribute :instance, "Lutaml::Lml::Instance"
28
31
  attribute :requires, :string, collection: true, default: -> { [] }
29
32
  attribute :instances, "Lutaml::Lml::InstanceCollection"
33
+
34
+ # Repeated `instances` blocks accumulate (issue #17): instances,
35
+ # imports, exports and collections concat; a duplicate collection
36
+ # name is an error (same name is a mistake, distinct names intent).
37
+ def instances=(collection)
38
+ collection = Lutaml::Lml::InstanceCollection.new(**collection) if collection.is_a?(Hash)
39
+ if @instances.nil? || (@instances.instances.empty? && @instances.imports.empty? &&
40
+ @instances.exports.empty? && @instances.collections.empty?)
41
+ @instances = collection
42
+ else
43
+ merge_instances(collection)
44
+ end
45
+ end
46
+
47
+ def merge_instances(incoming)
48
+ names = @instances.collections.map(&:name)
49
+ incoming.collections.each do |collection|
50
+ if names.include?(collection.name)
51
+ raise Lutaml::Lml::Error, "duplicate collection name '#{collection.name}'"
52
+ end
53
+ end
54
+ @instances.instances.concat(incoming.instances)
55
+ @instances.imports.concat(incoming.imports)
56
+ @instances.exports.concat(incoming.exports)
57
+ @instances.collections.concat(incoming.collections)
58
+ end
30
59
  attribute :view_imports, "Lutaml::Lml::ViewImport", collection: true, default: -> { [] }
31
60
  attribute :show_filter, "Lutaml::Lml::ViewFilter"
32
61
  attribute :hide_filter, "Lutaml::Lml::ViewFilter"
@@ -6,6 +6,7 @@ module Lutaml
6
6
  attribute :name, :string
7
7
  attribute :type, :string
8
8
  attribute :direction, :string, default: "in"
9
+ attribute :default, :string
9
10
  end
10
11
  end
11
12
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ # RS 3001 par. Value type: a reference to another instance
6
+ # (`reference:(path)` / `ref:(path)`). The attribute's value slot keeps
7
+ # its primitive wire shape (a `reference` hash); this is the typed
8
+ # domain view of it, built at one canonical point
9
+ # (TopElementAttribute#reference) so validation and serialization
10
+ # consume structured references instead of re-sniffing hashes.
11
+ class Reference < Lutaml::Model::Serializable
12
+ attribute :path, :string
13
+
14
+ def segments
15
+ @segments ||= path.to_s.split('.')
16
+ end
17
+
18
+ def to_s
19
+ path.to_s
20
+ end
21
+ end
22
+ end
23
+ end
@@ -83,6 +83,18 @@ module Lutaml
83
83
  model.value = value
84
84
  end
85
85
 
86
+ # RS 3001 par. Value type: the typed view of a reference value
87
+ # (`reference:(...)` / `ref:(...)`). Nil for every other value shape;
88
+ # for a list of references, the first reference.
89
+ def reference
90
+ literal = value.respond_to?(:value) ? value.value : value
91
+ cargo = literal.is_a?(::Array) ? literal.first : literal
92
+ cargo = cargo.value if cargo.is_a?(LiteralValue)
93
+ return nil unless cargo.is_a?(::Hash) && cargo[:reference]
94
+
95
+ Reference.new(path: cargo[:reference].to_s)
96
+ end
97
+
86
98
  private
87
99
 
88
100
  # A LiteralValue sits at the top level for a map literal, and inside the
@@ -20,7 +20,7 @@ module Lutaml
20
20
  attribute :attributes, "Lutaml::Lml::TopElementAttribute", collection: true
21
21
  attribute :modifier, :string
22
22
  attribute :constraints, "Lutaml::Lml::Constraint", collection: true
23
- attribute :operations, "Lutaml::Lml::Operation", collection: true
23
+ attribute :operations, "Lutaml::Lml::Operation", collection: true, default: -> { [] }
24
24
  attribute :data_types, "Lutaml::Lml::DataType", collection: true
25
25
  attribute :associations, "Lutaml::Lml::Association", collection: true
26
26
  attribute :wire_mappings, "Lutaml::Lml::Mapping", collection: true, default: -> { [] }
@@ -21,11 +21,12 @@ module Lutaml
21
21
 
22
22
  private
23
23
 
24
- # RS 3001 §Comment: `//` to end of line and `/* ... */` block
25
- # comments are stripped quote-aware before include expansion, so
26
- # string literals (e.g. URLs) survive intact.
24
+ # Comments (`//`, `/* */`) are consumed by the grammar's trivia
25
+ # skip (parsanol-ruby#134); only include directives are expanded
26
+ # here. Line structure is preserved so parse-error positions
27
+ # match the source.
27
28
  def expand_lines(text, base_dir, chain)
28
- text = strip_comments(text)
29
+ validate_block_comments(text)
29
30
  text.split(/\r?\n/)
30
31
  .flat_map { |line| expand_line(line, base_dir, chain) }
31
32
  .join("\n")
@@ -41,93 +42,57 @@ module Lutaml
41
42
  end
42
43
 
43
44
  def include_path(line, base_dir)
44
- match = line.match(/^\s*include\s+(.+)/)
45
+ match = line.match(/^\s*include\s+(.+)$/)
45
46
  return nil unless match
46
47
 
47
- File.expand_path(match[1].strip, base_dir)
48
+ File.expand_path(trim_trailing_comment(match[1].strip), base_dir)
49
+ end
50
+
51
+ def trim_trailing_comment(path)
52
+ path.sub(%r{\s+(//|/\*).*}, '')
48
53
  end
49
54
 
50
55
  def read_included(path)
51
- File.read(path, encoding: "UTF-8")
56
+ File.read(path, encoding: 'UTF-8')
52
57
  rescue Errno::ENOENT, Errno::EACCES => e
53
58
  raise Error, "cannot read include #{path}: #{e.message}"
54
59
  end
55
60
 
56
- def strip_comments(text)
57
- scanner = CommentScanner.new(text)
58
- out = +""
59
- until scanner.done?
60
- scanner.emit(out)
61
- end
62
- out
63
- end
64
-
65
- # Quote-aware scanner removing `//` and `/* */` comments while
66
- # preserving string literals and line structure.
67
- class CommentScanner
68
- def initialize(text)
69
- @text = text
70
- @pos = 0
71
- @quote = nil
72
- end
73
-
74
- def done?
75
- @pos >= @text.length
76
- end
77
-
78
- def emit(out)
79
- return emit_quoted(out) if @quote
80
- return emit_delimiter(out) if string_delimiter?(@text[@pos])
81
- return emit_block_comment(out) if block_comment_start?
82
- return skip_line_comment if line_comment_start?
83
-
84
- out << @text[@pos]
85
- @pos += 1
86
- end
87
-
88
- private
89
-
90
- def string_delimiter?(ch)
91
- ch == '"' || ch == "'"
92
- end
93
-
94
- def emit_delimiter(out)
95
- @quote = @text[@pos]
96
- out << @text[@pos]
97
- @pos += 1
98
- end
99
-
100
- def emit_quoted(out)
101
- ch = @text[@pos]
102
- out << ch
103
- if ch == "\\"
104
- out << @text[@pos + 1].to_s
105
- @pos += 2
106
- else
107
- @quote = nil if ch == @quote
108
- @pos += 1
61
+ # Quote-aware scan: a `/*` opened outside a string literal or a
62
+ # line comment must close before end of input. The grammar's
63
+ # trivia would otherwise reject the text with an unrelated
64
+ # expected-token error.
65
+ def validate_block_comments(text)
66
+ quote = nil
67
+ pos = 0
68
+ open_index = nil
69
+ while pos < text.length
70
+ ch = text[pos]
71
+ if quote
72
+ if ch == '\\'
73
+ pos += 2
74
+ next
75
+ end
76
+ quote = nil if ch == quote
77
+ elsif open_index
78
+ if text[pos, 2] == '*/'
79
+ open_index = nil
80
+ pos += 1
81
+ end
82
+ elsif ['"', "'"].include?(ch)
83
+ quote = ch
84
+ elsif text[pos, 2] == '//'
85
+ pos = text.index("\n", pos) || text.length
86
+ next
87
+ elsif text[pos, 2] == '/*'
88
+ open_index = pos
109
89
  end
90
+ pos += 1
110
91
  end
92
+ return unless open_index
111
93
 
112
- def block_comment_start?
113
- @text[@pos, 2] == "/*"
114
- end
115
-
116
- def emit_block_comment(out)
117
- close = @text.index("*/", @pos + 2)
118
- raise Lutaml::Lml::Error, "unterminated block comment" unless close
119
-
120
- out << "\n" * @text[@pos...close].count("\n") << " "
121
- @pos = close + 2
122
- end
123
-
124
- def line_comment_start?
125
- @text[@pos, 2] == "//"
126
- end
127
-
128
- def skip_line_comment
129
- @pos = @text.index("\n", @pos) || @text.length
130
- end
94
+ line = text[0...open_index].count("\n") + 1
95
+ raise Error, "unterminated block comment at line #{line}"
131
96
  end
132
97
  end
133
98
  end
@@ -8,7 +8,7 @@ module Lutaml
8
8
  VISIBILITY_MAP = {
9
9
  '-' => 'private',
10
10
  '#' => 'protected',
11
- '~' => 'friendly'
11
+ '~' => 'package'
12
12
  }.freeze
13
13
 
14
14
  rule(visibility_modifier: simple(:visibility_value)) do
@@ -34,6 +34,28 @@ module Lutaml
34
34
  circular_reference_violations
35
35
  end
36
36
 
37
+ # RS 3001 scopes definitions to packages: uniqueness is per scope
38
+ # (the document root and each package, recursively), not global.
39
+ def definition_scopes
40
+ scopes = [[nil, document_definitions]]
41
+ Array(@document.packages).each { |pkg| collect_package_scopes(pkg, scopes) }
42
+ scopes
43
+ end
44
+
45
+ def document_definitions
46
+ [
47
+ *Array(@document.classes),
48
+ *Array(@document.enums),
49
+ *Array(@document.data_types)
50
+ ]
51
+ end
52
+
53
+ def collect_package_scopes(pkg, scopes, parent = nil)
54
+ path = parent ? "#{parent}.#{pkg.name}" : pkg.name.to_s
55
+ scopes << [path, [*Array(pkg.classes), *Array(pkg.enums), *Array(pkg.data_types)]]
56
+ Array(pkg.packages).each { |nested| collect_package_scopes(nested, scopes, path) }
57
+ end
58
+
37
59
  private
38
60
 
39
61
  def all_definitions
@@ -59,14 +81,18 @@ module Lutaml
59
81
  end
60
82
 
61
83
  def unique_name_violations
62
- names = all_definitions.map(&:first)
63
- names.tally.filter_map do |name, count|
64
- next if count == 1
65
-
66
- Violation.new(
67
- rule: 'unique_names',
68
- message: "'#{name}' is defined #{count} times"
69
- )
84
+ definition_scopes.flat_map do |scope, definitions|
85
+ scope_label = scope ? "package '#{scope}'" : 'the document root'
86
+ definitions.map { |d| d.name.to_s }
87
+ .tally
88
+ .filter_map do |name, count|
89
+ next if count == 1
90
+
91
+ Violation.new(
92
+ rule: 'unique_names',
93
+ message: "'#{name}' is defined #{count} times in #{scope_label}"
94
+ )
95
+ end
70
96
  end
71
97
  end
72
98
 
@@ -91,10 +117,49 @@ module Lutaml
91
117
  instances.flat_map do |instance|
92
118
  mandatory_violations(instance) +
93
119
  type_conformity_violations(instance) +
120
+ values_set_violations(instance) +
121
+ unknown_attribute_violations(instance) +
94
122
  reference_violations(instance)
95
123
  end.compact
96
124
  end
97
125
 
126
+ # RS 3001 par. Attribute: a `values` declaration restricts an
127
+ # attribute to its declared set.
128
+ def values_set_violations(instance)
129
+ definition = type_definitions[definition_type(instance)]
130
+ return [] unless definition.is_a?(UmlClass) || definition.is_a?(DataType)
131
+
132
+ instance_attribute_pairs(instance).filter_map do |name, value|
133
+ attr = Array(definition.attributes).find { |a| a.name.to_s == name }
134
+ next unless attr && !attr.value_set.empty?
135
+
136
+ literal = value.respond_to?(:value) ? value.value : value
137
+ next if attr.value_set.map(&:to_s).include?(literal.to_s)
138
+
139
+ Violation.new(
140
+ rule: 'values_set_membership',
141
+ message: "instance '#{instance_name(instance)}': attribute '#{name}' " \
142
+ "must be one of #{attr.value_set.inspect}, got #{literal.to_s.inspect}"
143
+ )
144
+ end
145
+ end
146
+
147
+ def unknown_attribute_violations(instance)
148
+ definition = type_definitions[definition_type(instance)]
149
+ return [] unless definition.is_a?(UmlClass) || definition.is_a?(DataType)
150
+
151
+ declared = attribute_names(definition)
152
+ instance_attribute_pairs(instance)
153
+ .reject { |name, _value| declared.include?(name) }
154
+ .map do |name, _value|
155
+ Violation.new(
156
+ rule: 'unknown_attributes',
157
+ message: "instance '#{instance_name(instance)}' (#{definition_type(instance)}): " \
158
+ "unknown attribute '#{name}'"
159
+ )
160
+ end
161
+ end
162
+
98
163
  def instances
99
164
  Array(@document.instances&.instances)
100
165
  end
@@ -119,13 +184,7 @@ module Lutaml
119
184
  attr.cardinality && attr.cardinality.min == '1'
120
185
  end
121
186
 
122
- def populated?(instance, attr_name)
123
- pair = instance_attribute(instance, attr_name)
124
- return false unless pair
125
187
 
126
- value = pair[1]
127
- value.respond_to?(:value) ? !value.to_s.empty? : !value.to_s.empty?
128
- end
129
188
 
130
189
  def type_conformity_violations(instance)
131
190
  definition = type_definitions[definition_type(instance)]
@@ -213,12 +272,21 @@ module Lutaml
213
272
  literal
214
273
  end
215
274
 
275
+ def populated?(instance, attr_name)
276
+ pair = instance_attribute(instance, attr_name)
277
+ return false unless pair
278
+
279
+ value = pair[1]
280
+ literal = value.respond_to?(:value) ? value.value : value
281
+ !literal.to_s.empty?
282
+ end
283
+
216
284
  def reference_values(instance)
217
- instance_attribute_pairs(instance).filter_map do |name, value|
218
- literal = value.respond_to?(:value) ? value.value : value
219
- next unless literal.is_a?(Hash) && literal[:reference]
285
+ Array(instance.attributes).filter_map do |attr|
286
+ ref = attr.reference
287
+ next unless ref
220
288
 
221
- [name, literal[:reference]]
289
+ [attr.name.to_s, ref.path]
222
290
  end
223
291
  end
224
292
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end