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.
- checksums.yaml +4 -4
- data/lib/lutaml/lml/cli.rb +8 -1
- data/lib/lutaml/lml/document_builder.rb +72 -3
- data/lib/lutaml/lml/format/adapter/standard_adapter.rb +17 -4
- data/lib/lutaml/lml/formatter/elk.rb +42 -0
- data/lib/lutaml/lml/formatter/graphviz/node_formatter.rb +2 -1
- data/lib/lutaml/lml/formatter.rb +1 -0
- data/lib/lutaml/lml/grammar/lml.artifact.json +6982 -4538
- data/lib/lutaml/lml/grammar/lml.parg +209 -110
- data/lib/lutaml/lml/layout/elk_engine.rb +15 -0
- data/lib/lutaml/lml/layout/elk_graph_builder.rb +129 -0
- data/lib/lutaml/lml/layout/elk_svg_markers.rb +45 -0
- data/lib/lutaml/lml/layout/elk_svg_renderer.rb +119 -0
- data/lib/lutaml/lml/layout/graph_viz_engine.rb +1 -1
- data/lib/lutaml/lml/layout.rb +4 -0
- data/lib/lutaml/lml/model_compiler.rb +24 -3
- data/lib/lutaml/lml/models/document.rb +29 -0
- data/lib/lutaml/lml/models/operation_parameter.rb +1 -0
- data/lib/lutaml/lml/models/reference.rb +23 -0
- data/lib/lutaml/lml/models/top_element_attribute.rb +12 -0
- data/lib/lutaml/lml/models/uml_class.rb +1 -1
- data/lib/lutaml/lml/preprocessor.rb +44 -79
- data/lib/lutaml/lml/transform.rb +1 -1
- data/lib/lutaml/lml/validator.rb +86 -18
- data/lib/lutaml/lml/version.rb +1 -1
- data/lib/lutaml/lml.rb +1 -0
- metadata +10 -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('&', '&').gsub('<', '<').gsub('>', '>')
|
|
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
|
data/lib/lutaml/lml/layout.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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"
|
|
@@ -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
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
data/lib/lutaml/lml/transform.rb
CHANGED
data/lib/lutaml/lml/validator.rb
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
next unless
|
|
285
|
+
Array(instance.attributes).filter_map do |attr|
|
|
286
|
+
ref = attr.reference
|
|
287
|
+
next unless ref
|
|
220
288
|
|
|
221
|
-
[name,
|
|
289
|
+
[attr.name.to_s, ref.path]
|
|
222
290
|
end
|
|
223
291
|
end
|
|
224
292
|
|