lutaml-lml 0.1.8 → 0.2.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.
@@ -21,16 +21,19 @@ 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
27
  def expand_lines(text, base_dir, chain)
28
+ text = strip_comments(text)
25
29
  text.split(/\r?\n/)
26
30
  .flat_map { |line| expand_line(line, base_dir, chain) }
27
31
  .join("\n")
28
32
  end
29
33
 
30
34
  def expand_line(line, base_dir, chain)
31
- cleaned = strip_comment(line)
32
- path = include_path(cleaned, base_dir)
33
- return [cleaned] unless path
35
+ path = include_path(line, base_dir)
36
+ return [line] unless path
34
37
 
35
38
  raise Error, "circular include detected: #{path}" if chain.include?(path)
36
39
 
@@ -45,13 +48,86 @@ module Lutaml
45
48
  end
46
49
 
47
50
  def read_included(path)
48
- File.read(path)
51
+ File.read(path, encoding: "UTF-8")
49
52
  rescue Errno::ENOENT, Errno::EACCES => e
50
53
  raise Error, "cannot read include #{path}: #{e.message}"
51
54
  end
52
55
 
53
- def strip_comment(line)
54
- line.sub(%r{//.*}, "")
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
109
+ end
110
+ end
111
+
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
55
131
  end
56
132
  end
57
133
  end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Lml
5
+ # RS 3001 §Validation rules evaluated over a parsed document:
6
+ # class constraints (unique names, unique attribute names), attribute
7
+ # constraints (mandatory cardinality-1 attributes populated, values
8
+ # matching declared types) and reference validity (ref: paths resolve
9
+ # to instances and are acyclic).
10
+ class Validator
11
+ Violation = Struct.new(:rule, :message, keyword_init: true)
12
+
13
+ PRIMITIVE_CONFORMITY = {
14
+ 'Integer' => /\A-?\d+\z/,
15
+ 'Number' => /\A-?\d+\z/,
16
+ 'Float' => /\A-?\d+\.\d+\z/,
17
+ 'Boolean' => /\A(true|false)\z/
18
+ }.freeze
19
+
20
+ COLLECTION_KEYS = %i[properties attributes instances].freeze
21
+
22
+ def self.violations(document)
23
+ new(document).violations
24
+ end
25
+
26
+ def initialize(document)
27
+ @document = document
28
+ end
29
+
30
+ def violations
31
+ unique_name_violations +
32
+ unique_attribute_violations +
33
+ instance_violations +
34
+ circular_reference_violations
35
+ end
36
+
37
+ private
38
+
39
+ def all_definitions
40
+ @all_definitions ||= begin
41
+ pairs = []
42
+ Array(@document.classes).each { |c| pairs << [c.name.to_s, c] }
43
+ Array(@document.enums).each { |e| pairs << [e.name.to_s, e] }
44
+ Array(@document.data_types).each { |d| pairs << [d.name.to_s, d] }
45
+ Array(@document.packages).each { |pkg| collect_package_definitions(pkg, pairs) }
46
+ pairs
47
+ end
48
+ end
49
+
50
+ def collect_package_definitions(pkg, pairs)
51
+ Array(pkg.classes).each { |c| pairs << [c.name.to_s, c] }
52
+ Array(pkg.enums).each { |e| pairs << [e.name.to_s, e] }
53
+ Array(pkg.data_types).each { |d| pairs << [d.name.to_s, d] }
54
+ Array(pkg.packages).each { |nested| collect_package_definitions(nested, pairs) }
55
+ end
56
+
57
+ def type_definitions
58
+ @type_definitions ||= all_definitions.to_h
59
+ end
60
+
61
+ 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
+ )
70
+ end
71
+ end
72
+
73
+ def unique_attribute_violations
74
+ type_definitions.filter_map do |name, definition|
75
+ dupes = attribute_names(definition)
76
+ .tally.select { |_attr, count| count > 1 }.keys
77
+ next if dupes.empty?
78
+
79
+ Violation.new(
80
+ rule: 'unique_attribute_names',
81
+ message: "#{name}: duplicate attribute(s) #{dupes.inspect}"
82
+ )
83
+ end
84
+ end
85
+
86
+ def attribute_names(definition)
87
+ Array(definition.attributes).map(&:name).map(&:to_s)
88
+ end
89
+
90
+ def instance_violations
91
+ instances.flat_map do |instance|
92
+ mandatory_violations(instance) +
93
+ type_conformity_violations(instance) +
94
+ reference_violations(instance)
95
+ end.compact
96
+ end
97
+
98
+ def instances
99
+ Array(@document.instances&.instances)
100
+ end
101
+
102
+ def mandatory_violations(instance)
103
+ definition = type_definitions[definition_type(instance)]
104
+ return [] unless definition.is_a?(UmlClass) || definition.is_a?(DataType)
105
+
106
+ definition.attributes.filter_map do |attr|
107
+ next unless mandatory?(attr)
108
+ next if populated?(instance, attr.name.to_s)
109
+
110
+ Violation.new(
111
+ rule: 'mandatory_attributes',
112
+ message: "instance '#{instance_name(instance)}' (#{definition_type(instance)}): " \
113
+ "mandatory attribute '#{attr.name}' is not populated"
114
+ )
115
+ end
116
+ end
117
+
118
+ def mandatory?(attr)
119
+ attr.cardinality && attr.cardinality.min == '1'
120
+ end
121
+
122
+ def populated?(instance, attr_name)
123
+ pair = instance_attribute(instance, attr_name)
124
+ return false unless pair
125
+
126
+ value = pair[1]
127
+ value.respond_to?(:value) ? !value.to_s.empty? : !value.to_s.empty?
128
+ end
129
+
130
+ def type_conformity_violations(instance)
131
+ definition = type_definitions[definition_type(instance)]
132
+ return [] unless definition.respond_to?(:attributes)
133
+
134
+ instance_attribute_pairs(instance).filter_map do |name, value|
135
+ attr = Array(definition.attributes).find { |a| a.name.to_s == name }
136
+ next unless attr
137
+
138
+ conformity_violation(instance, name, attr, value)
139
+ end
140
+ end
141
+
142
+ def conformity_violation(instance, name, attr, value)
143
+ pattern = PRIMITIVE_CONFORMITY[attr.type.to_s]
144
+ return unless pattern
145
+ return if pattern.match?(value.to_s)
146
+
147
+ Violation.new(
148
+ rule: 'type_conformity',
149
+ message: "instance '#{instance_name(instance)}': attribute '#{name}' " \
150
+ "expects #{attr.type}, got #{value.to_s.inspect}"
151
+ )
152
+ end
153
+
154
+ def reference_violations(instance)
155
+ reference_values(instance).filter_map do |name, path|
156
+ segments = path.to_s.split('.')
157
+ unless resolvable_reference?(segments)
158
+ next Violation.new(
159
+ rule: 'reference_validity',
160
+ message: "instance '#{instance_name(instance)}': attribute '#{name}' " \
161
+ "references unknown path '#{path}'"
162
+ )
163
+ end
164
+
165
+ attribute_violation(instance, name, path, segments.first)
166
+ end
167
+ end
168
+
169
+ # RS 3001 reference paths: `instance` / `instance.attribute` /
170
+ # `collection.instance`.
171
+ def resolvable_reference?(segments)
172
+ case segments.length
173
+ when 1 then instance_names.include?(segments.first)
174
+ when 2 then instance_names.include?(segments.first) ||
175
+ collection_names.include?(segments.first)
176
+ else false
177
+ end
178
+ end
179
+
180
+ def collection_names
181
+ @collection_names ||= [@document.instances&.name].compact
182
+ end
183
+
184
+ def attribute_violation(instance, _name, path, target)
185
+ segment = path.to_s.split('.')[1]
186
+ target_instance = instances.find { |i| instance_name(i) == target }
187
+ return if segment.nil? || target_instance.nil? ||
188
+ instance_attribute(target_instance, segment)
189
+
190
+ Violation.new(
191
+ rule: 'reference_validity',
192
+ message: "instance '#{instance_name(instance)}': reference '#{path}' " \
193
+ "does not resolve (no attribute '#{segment}' on '#{target}')"
194
+ )
195
+ end
196
+
197
+ def instance_attribute_pairs(instance)
198
+ pairs = []
199
+ instance.each_attribute do |name, value, instances|
200
+ pairs << [name.to_s, value_for(value, instances)]
201
+ end
202
+ pairs
203
+ end
204
+
205
+ def instance_attribute(instance, attr_name)
206
+ instance_attribute_pairs(instance).find { |name, _value| name == attr_name }
207
+ end
208
+
209
+ def value_for(value, instances)
210
+ literal = value.respond_to?(:value) ? value.value : value
211
+ return instances.first if literal.is_a?(Hash) && literal[:instances]
212
+
213
+ literal
214
+ end
215
+
216
+ 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]
220
+
221
+ [name, literal[:reference]]
222
+ end
223
+ end
224
+
225
+ def instance_names
226
+ @instance_names ||= instances.map { |i| instance_name(i) }
227
+ end
228
+
229
+ # RS 3001: circular references are invalid.
230
+ def circular_reference_violations
231
+ graph = instances.to_h do |instance|
232
+ targets = reference_values(instance)
233
+ .map { |_name, path| path.to_s.split('.').first }
234
+ [instance_name(instance), targets]
235
+ end
236
+ cycles = cycles_in(graph)
237
+ cycles.map do |cycle|
238
+ Violation.new(
239
+ rule: 'reference_circularity',
240
+ message: "circular reference: #{cycle.join(' -> ')}"
241
+ )
242
+ end
243
+ end
244
+
245
+ def cycles_in(graph)
246
+ cycles = []
247
+ graph.each_key do |start|
248
+ path = walk_for_cycle(graph, start)
249
+ cycles << path if path
250
+ end
251
+ cycles.uniq { |cycle| cycle.sort }
252
+ end
253
+
254
+ def walk_for_cycle(graph, start)
255
+ path = [start]
256
+ node = start
257
+ loop do
258
+ nexts = graph[node] || []
259
+ node = nexts.first
260
+ return nil if node.nil? || node == '' || !graph.key?(node)
261
+ return path + [node] if path.include?(node)
262
+
263
+ path << node
264
+ end
265
+ end
266
+
267
+ def instance_name(instance)
268
+ instance.respond_to?(:name) ? instance.name.to_s : ''
269
+ end
270
+
271
+ def definition_type(instance)
272
+ (instance.isa || instance.type).to_s
273
+ end
274
+ end
275
+ end
276
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Lml
5
- VERSION = "0.1.8"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/lutaml/lml.rb CHANGED
@@ -44,6 +44,7 @@ module Lutaml
44
44
  autoload :ModelCompiler, "lutaml/lml/model_compiler"
45
45
  autoload :ImportResolver, "lutaml/lml/import_resolver"
46
46
  autoload :ViewResolver, "lutaml/lml/view_resolver"
47
+ autoload :Validator, "lutaml/lml/validator"
47
48
  autoload :ViewResolution, "lutaml/lml/view_resolution"
48
49
  autoload :EntityTypes, "lutaml/lml/entity_types"
49
50
  autoload :AssociationLabelResolver, "lutaml/lml/association_label_resolver"
@@ -84,6 +85,9 @@ module Lutaml
84
85
  autoload :Value, "lutaml/lml/models/value"
85
86
  autoload :ViewFilter, "lutaml/lml/models/view_filter"
86
87
  autoload :ViewImport, "lutaml/lml/models/view_import"
88
+ autoload :MappingRule, "lutaml/lml/models/mapping_rule"
89
+ autoload :Namespace, "lutaml/lml/models/namespace"
90
+ autoload :SerializationMapping, "lutaml/lml/models/serialization_mapping"
87
91
 
88
92
  # Namespaces with their own autoloads
89
93
  autoload :Grammar, "lutaml/lml/grammar"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-lml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -84,6 +84,7 @@ files:
84
84
  - lib/lutaml/lml/data_processor/attribute_processing.rb
85
85
  - lib/lutaml/lml/data_processor/collection_processing.rb
86
86
  - lib/lutaml/lml/data_processor/instance_processing.rb
87
+ - lib/lutaml/lml/data_processor/value_declaration_processing.rb
87
88
  - lib/lutaml/lml/data_processor/value_processing.rb
88
89
  - lib/lutaml/lml/data_processor/view_processing.rb
89
90
  - lib/lutaml/lml/document_builder.rb
@@ -133,11 +134,14 @@ files:
133
134
  - lib/lutaml/lml/models/instances_export.rb
134
135
  - lib/lutaml/lml/models/instances_import.rb
135
136
  - lib/lutaml/lml/models/mapping.rb
137
+ - lib/lutaml/lml/models/mapping_rule.rb
136
138
  - lib/lutaml/lml/models/member_field.rb
139
+ - lib/lutaml/lml/models/namespace.rb
137
140
  - lib/lutaml/lml/models/operation.rb
138
141
  - lib/lutaml/lml/models/operation_parameter.rb
139
142
  - lib/lutaml/lml/models/package.rb
140
143
  - lib/lutaml/lml/models/primitive_type.rb
144
+ - lib/lutaml/lml/models/serialization_mapping.rb
141
145
  - lib/lutaml/lml/models/string_format.rb
142
146
  - lib/lutaml/lml/models/top_element_attribute.rb
143
147
  - lib/lutaml/lml/models/uml_class.rb
@@ -151,6 +155,7 @@ files:
151
155
  - lib/lutaml/lml/transform.rb
152
156
  - lib/lutaml/lml/types.rb
153
157
  - lib/lutaml/lml/types/text_type.rb
158
+ - lib/lutaml/lml/validator.rb
154
159
  - lib/lutaml/lml/version.rb
155
160
  - lib/lutaml/lml/view_resolution.rb
156
161
  - lib/lutaml/lml/view_resolver.rb