lutaml 0.2.0 → 0.4.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.
- checksums.yaml +4 -4
- data/.github/workflows/macos.yml +41 -0
- data/.github/workflows/ubuntu.yml +40 -0
- data/.github/workflows/windows.yml +48 -0
- data/Gemfile +0 -3
- data/README.adoc +72 -0
- data/exe/lutaml +22 -0
- data/lib/lutaml.rb +1 -3
- data/lib/lutaml/command_line.rb +261 -0
- data/lib/lutaml/express/lutaml_path/document_wrapper.rb +59 -0
- data/lib/lutaml/formatter.rb +19 -0
- data/lib/lutaml/formatter/base.rb +66 -0
- data/lib/lutaml/formatter/graphviz.rb +332 -0
- data/lib/lutaml/layout/engine.rb +15 -0
- data/lib/lutaml/layout/graph_viz_engine.rb +18 -0
- data/lib/lutaml/lutaml_path/document_wrapper.rb +25 -7
- data/lib/lutaml/parser.rb +53 -0
- data/lib/lutaml/uml/lutaml_path/document_wrapper.rb +15 -0
- data/lib/lutaml/version.rb +1 -1
- data/lutaml.gemspec +8 -6
- metadata +40 -12
- data/README.md +0 -40
@@ -0,0 +1,59 @@
|
|
1
|
+
require "lutaml/lutaml_path/document_wrapper"
|
2
|
+
require "expressir/express_exp/formatter"
|
3
|
+
|
4
|
+
module Lutaml
|
5
|
+
module Express
|
6
|
+
module LutamlPath
|
7
|
+
class DocumentWrapper < ::Lutaml::LutamlPath::DocumentWrapper
|
8
|
+
SCHEMA_ATTRIBUTES = %w[
|
9
|
+
id
|
10
|
+
constants
|
11
|
+
declarations
|
12
|
+
entities
|
13
|
+
functions
|
14
|
+
interfaces
|
15
|
+
procedures
|
16
|
+
remarks
|
17
|
+
rules
|
18
|
+
subtype_constraints
|
19
|
+
types
|
20
|
+
version
|
21
|
+
].freeze
|
22
|
+
SOURCE_CODE_ATTRIBUTE_NAME = "sourcecode".freeze
|
23
|
+
|
24
|
+
protected
|
25
|
+
|
26
|
+
def serialize_document(repository)
|
27
|
+
repository.schemas.each_with_object({}) do |schema, res|
|
28
|
+
res["schemas"] ||= []
|
29
|
+
serialized_schema = SCHEMA_ATTRIBUTES
|
30
|
+
.each_with_object({}) do |name, nested_res|
|
31
|
+
attr_value = schema.send(name)
|
32
|
+
nested_res[name] = serialize_value(attr_value)
|
33
|
+
if name == "entities"
|
34
|
+
nested_res[name] = merge_source_code_attr(nested_res[name],
|
35
|
+
attr_value)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
res[schema.id] = serialized_schema
|
39
|
+
serialized_schema = serialized_schema
|
40
|
+
.merge(SOURCE_CODE_ATTRIBUTE_NAME =>
|
41
|
+
entity_source_code(schema))
|
42
|
+
res["schemas"].push(serialized_schema)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def merge_source_code_attr(serialized_entries, entities)
|
47
|
+
serialized_entries.map do |serialized|
|
48
|
+
entity = entities.detect { |n| n.id == serialized["id"] }
|
49
|
+
serialized.merge(SOURCE_CODE_ATTRIBUTE_NAME => entity_source_code(entity))
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def entity_source_code(entity)
|
54
|
+
Expressir::ExpressExp::Formatter.format(entity)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Lutaml
|
4
|
+
module Formatter
|
5
|
+
class << self
|
6
|
+
def all
|
7
|
+
@all ||= []
|
8
|
+
end
|
9
|
+
|
10
|
+
def find_by_name(name)
|
11
|
+
name = name.to_sym
|
12
|
+
|
13
|
+
all.detect { |formatter_class| formatter_class.name == name }
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
require "lutaml/formatter/graphviz"
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "lutaml/formatter"
|
4
|
+
require "lutaml/uml/has_attributes"
|
5
|
+
|
6
|
+
module Lutaml
|
7
|
+
module Formatter
|
8
|
+
class Base
|
9
|
+
class << self
|
10
|
+
def inherited(subclass)
|
11
|
+
Formatter.all << subclass
|
12
|
+
end
|
13
|
+
|
14
|
+
def format(node, attributes = {})
|
15
|
+
new(attributes).format(node)
|
16
|
+
end
|
17
|
+
|
18
|
+
def name
|
19
|
+
to_s.split("::").last.downcase.to_sym
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
include ::Lutaml::Uml::HasAttributes
|
24
|
+
|
25
|
+
# rubocop:disable Rails/ActiveRecordAliases
|
26
|
+
def initialize(attributes = {})
|
27
|
+
update_attributes(attributes)
|
28
|
+
end
|
29
|
+
# rubocop:enable Rails/ActiveRecordAliases
|
30
|
+
|
31
|
+
def name
|
32
|
+
self.class.name
|
33
|
+
end
|
34
|
+
|
35
|
+
attr_reader :type
|
36
|
+
|
37
|
+
def type=(value)
|
38
|
+
@type = value.to_s.strip.downcase.to_sym
|
39
|
+
end
|
40
|
+
|
41
|
+
def format(node)
|
42
|
+
case node
|
43
|
+
when ::Lutaml::Uml::Node::Field then format_field(node)
|
44
|
+
when ::Lutaml::Uml::Node::Method then format_method(node)
|
45
|
+
when ::Lutaml::Uml::Node::Relationship then format_relationship(node)
|
46
|
+
when ::Lutaml::Uml::Node::ClassRelationship
|
47
|
+
then format_class_relationship(node)
|
48
|
+
when ::Lutaml::Uml::Node::ClassNode then format_class(node)
|
49
|
+
when Lutaml::Uml::Document then format_document(node)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def format_field(_node); raise NotImplementedError; end
|
54
|
+
|
55
|
+
def format_method(_node); raise NotImplementedError; end
|
56
|
+
|
57
|
+
def format_relationship(_node); raise NotImplementedError; end
|
58
|
+
|
59
|
+
def format_class_relationship(_node); raise NotImplementedError; end
|
60
|
+
|
61
|
+
def format_class(_node); raise NotImplementedError; end
|
62
|
+
|
63
|
+
def format_document(_node); raise NotImplementedError; end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,332 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
require "lutaml/formatter/base"
|
5
|
+
require "lutaml/layout/graph_viz_engine"
|
6
|
+
|
7
|
+
module Lutaml
|
8
|
+
module Formatter
|
9
|
+
class Graphviz < Base
|
10
|
+
class Attributes < Hash
|
11
|
+
def to_s
|
12
|
+
to_a
|
13
|
+
.reject { |(_k, val)| val.nil? }
|
14
|
+
.map { |(a, b)| "#{a}=#{b.inspect}" }
|
15
|
+
.join(" ")
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
ACCESS_SYMBOLS = {
|
20
|
+
"public" => "+",
|
21
|
+
"protected" => "#",
|
22
|
+
"private" => "-",
|
23
|
+
}.freeze
|
24
|
+
DEFAULT_CLASS_FONT = "Helvetica".freeze
|
25
|
+
|
26
|
+
VALID_TYPES = %i[
|
27
|
+
dot
|
28
|
+
xdot
|
29
|
+
ps
|
30
|
+
pdf
|
31
|
+
svg
|
32
|
+
svgz
|
33
|
+
fig
|
34
|
+
png
|
35
|
+
gif
|
36
|
+
jpg
|
37
|
+
jpeg
|
38
|
+
json
|
39
|
+
imap
|
40
|
+
cmapx
|
41
|
+
].freeze
|
42
|
+
|
43
|
+
def initialize(attributes = {})
|
44
|
+
super
|
45
|
+
|
46
|
+
@graph = Attributes.new
|
47
|
+
# Associations lines style, `true` gives curved lines
|
48
|
+
# https://graphviz.org/doc/info/attrs.html#d:splines
|
49
|
+
@graph["splines"] = "ortho"
|
50
|
+
# Padding between outside of picture and nodes
|
51
|
+
@graph["pad"] = 0.5
|
52
|
+
# Padding between levels
|
53
|
+
@graph["ranksep"] = "1.2.equally"
|
54
|
+
# Padding between nodes
|
55
|
+
@graph["nodesep"] = "1.2.equally"
|
56
|
+
# TODO: set rankdir
|
57
|
+
# @graph['rankdir'] = 'BT'
|
58
|
+
|
59
|
+
@edge = Attributes.new
|
60
|
+
@edge["color"] = "gray50"
|
61
|
+
|
62
|
+
@node = Attributes.new
|
63
|
+
@node["shape"] = "box"
|
64
|
+
|
65
|
+
@type = :dot
|
66
|
+
end
|
67
|
+
|
68
|
+
attr_reader :graph
|
69
|
+
attr_reader :edge
|
70
|
+
attr_reader :node
|
71
|
+
|
72
|
+
def type=(value)
|
73
|
+
super
|
74
|
+
|
75
|
+
@type = :dot unless VALID_TYPES.include?(@type)
|
76
|
+
end
|
77
|
+
|
78
|
+
def format(node)
|
79
|
+
dot = super.lines.map(&:rstrip).join("\n")
|
80
|
+
|
81
|
+
generate_from_dot(dot)
|
82
|
+
end
|
83
|
+
|
84
|
+
def escape_html_chars(text)
|
85
|
+
text
|
86
|
+
.gsub(/</, "<")
|
87
|
+
.gsub(/>/, ">")
|
88
|
+
.gsub(/\[/, "[")
|
89
|
+
.gsub(/\]/, "]")
|
90
|
+
end
|
91
|
+
|
92
|
+
def format_field(node)
|
93
|
+
symbol = ACCESS_SYMBOLS[node.visibility]
|
94
|
+
result = "#{symbol}#{node.name}"
|
95
|
+
if node.type
|
96
|
+
keyword = node.keyword ? "«#{node.keyword}»" : ""
|
97
|
+
result += " : #{keyword}#{node.type}"
|
98
|
+
end
|
99
|
+
if node.cardinality
|
100
|
+
result += "[#{node.cardinality[:min]}..#{node.cardinality[:max]}]"
|
101
|
+
end
|
102
|
+
result = escape_html_chars(result)
|
103
|
+
result = "<U>#{result}</U>" if node.static
|
104
|
+
|
105
|
+
result
|
106
|
+
end
|
107
|
+
|
108
|
+
def format_method(node)
|
109
|
+
symbol = ACCESS_SYMBOLS[node.access]
|
110
|
+
result = "#{symbol} #{node.name}"
|
111
|
+
if node.arguments
|
112
|
+
arguments = node.arguments.map do |argument|
|
113
|
+
"#{argument.name}#{" : #{argument.type}" if argument.type}"
|
114
|
+
end.join(", ")
|
115
|
+
end
|
116
|
+
|
117
|
+
result << "(#{arguments})"
|
118
|
+
result << " : #{node.type}" if node.type
|
119
|
+
result = "<U>#{result}</U>" if node.static
|
120
|
+
result = "<I>#{result}</I>" if node.abstract
|
121
|
+
|
122
|
+
result
|
123
|
+
end
|
124
|
+
|
125
|
+
def format_relationship(node)
|
126
|
+
graph_parent_name = generate_graph_name(node.owner_end)
|
127
|
+
graph_node_name = generate_graph_name(node.member_end)
|
128
|
+
attributes = generate_graph_relationship_attributes(node)
|
129
|
+
graph_attributes = " [#{attributes}]" unless attributes.empty?
|
130
|
+
|
131
|
+
%{#{graph_parent_name} -> #{graph_node_name}#{graph_attributes}}
|
132
|
+
end
|
133
|
+
|
134
|
+
def generate_graph_relationship_attributes(node)
|
135
|
+
attributes = Attributes.new
|
136
|
+
if %w[dependency realizes].include?(node.member_end_type)
|
137
|
+
attributes["style"] = "dashed"
|
138
|
+
end
|
139
|
+
attributes["dir"] = if node.owner_end_type && node.member_end_type
|
140
|
+
"both"
|
141
|
+
elsif node.owner_end_type
|
142
|
+
"back"
|
143
|
+
else
|
144
|
+
"direct"
|
145
|
+
end
|
146
|
+
attributes["label"] = node.action if node.action
|
147
|
+
if node.owner_end_attribute_name
|
148
|
+
attributes["headlabel"] = format_label(
|
149
|
+
node.owner_end_attribute_name,
|
150
|
+
node.owner_end_cardinality
|
151
|
+
)
|
152
|
+
end
|
153
|
+
if node.member_end_attribute_name
|
154
|
+
attributes["taillabel"] = format_label(
|
155
|
+
node.member_end_attribute_name,
|
156
|
+
node.member_end_cardinality
|
157
|
+
)
|
158
|
+
end
|
159
|
+
|
160
|
+
attributes["arrowtail"] = case node.owner_end_type
|
161
|
+
when "composition"
|
162
|
+
"diamond"
|
163
|
+
when "aggregation"
|
164
|
+
"odiamond"
|
165
|
+
when "direct"
|
166
|
+
"vee"
|
167
|
+
else
|
168
|
+
"onormal"
|
169
|
+
end
|
170
|
+
|
171
|
+
attributes["arrowhead"] = case node.member_end_type
|
172
|
+
when "composition"
|
173
|
+
"diamond"
|
174
|
+
when "aggregation"
|
175
|
+
"odiamond"
|
176
|
+
when "direct"
|
177
|
+
"vee"
|
178
|
+
else
|
179
|
+
"onormal"
|
180
|
+
end
|
181
|
+
# swap labels and arrows if `dir` eq to `back`
|
182
|
+
if attributes["dir"] == "back"
|
183
|
+
attributes["arrowhead"], attributes["arrowtail"] =
|
184
|
+
[attributes["arrowtail"], attributes["arrowhead"]]
|
185
|
+
attributes["headlabel"], attributes["taillabel"] =
|
186
|
+
[attributes["taillabel"], attributes["headlabel"]]
|
187
|
+
end
|
188
|
+
attributes
|
189
|
+
end
|
190
|
+
|
191
|
+
def format_label(name, cardinality = {})
|
192
|
+
res = "+#{name}"
|
193
|
+
if cardinality.nil? ||
|
194
|
+
(cardinality["min"].nil? || cardinality["max"].nil?)
|
195
|
+
return res
|
196
|
+
end
|
197
|
+
|
198
|
+
"#{res} #{cardinality['min']}..#{cardinality['max']}"
|
199
|
+
end
|
200
|
+
|
201
|
+
def format_member_rows(members, hide_members)
|
202
|
+
unless !hide_members && members && members.length.positive?
|
203
|
+
return <<~HEREDOC.chomp
|
204
|
+
<TABLE BORDER="0" CELLPADDING="0" CELLSPACING="0">
|
205
|
+
<TR><TD ALIGN="LEFT"></TD></TR>
|
206
|
+
</TABLE>
|
207
|
+
HEREDOC
|
208
|
+
end
|
209
|
+
|
210
|
+
field_rows = members.map do |field|
|
211
|
+
%{<TR><TD ALIGN="LEFT">#{format_field(field)}</TD></TR>}
|
212
|
+
end
|
213
|
+
field_table = <<~HEREDOC.chomp
|
214
|
+
<TABLE BORDER="0" CELLPADDING="0" CELLSPACING="0">
|
215
|
+
#{field_rows.map { |row| ' ' * 10 + row }.join("\n")}
|
216
|
+
</TABLE>
|
217
|
+
HEREDOC
|
218
|
+
field_table << "\n" << " " * 6
|
219
|
+
field_table
|
220
|
+
end
|
221
|
+
|
222
|
+
def format_class(node, hide_members)
|
223
|
+
name = ["<B>#{node.name}</B>"]
|
224
|
+
name.unshift("«#{node.keyword}»") if node.keyword
|
225
|
+
name_html = <<~HEREDOC
|
226
|
+
<TABLE BORDER="0" CELLPADDING="0" CELLSPACING="0">
|
227
|
+
#{name.map { |n| %(<TR><TD ALIGN="CENTER">#{n}</TD></TR>) }.join('\n')}
|
228
|
+
</TABLE>
|
229
|
+
HEREDOC
|
230
|
+
|
231
|
+
field_table = format_member_rows(node.attributes, hide_members)
|
232
|
+
method_table = format_member_rows(node.methods, hide_members)
|
233
|
+
table_body = [name_html, field_table, method_table].map do |type|
|
234
|
+
next if type.nil?
|
235
|
+
|
236
|
+
<<~TEXT
|
237
|
+
<TR>
|
238
|
+
<TD>#{type}</TD>
|
239
|
+
</TR>
|
240
|
+
TEXT
|
241
|
+
end
|
242
|
+
|
243
|
+
<<~HEREDOC.chomp
|
244
|
+
<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="10">
|
245
|
+
#{table_body.compact.join("\n")}
|
246
|
+
</TABLE>
|
247
|
+
HEREDOC
|
248
|
+
end
|
249
|
+
|
250
|
+
def format_document(node)
|
251
|
+
@fontname = node.fontname || DEFAULT_CLASS_FONT
|
252
|
+
@node["fontname"] = "#{@fontname}-bold"
|
253
|
+
|
254
|
+
if node.fidelity
|
255
|
+
hide_members = node.fidelity["hideMembers"]
|
256
|
+
hide_other_classes = node.fidelity["hideOtherClasses"]
|
257
|
+
end
|
258
|
+
classes = (node.classes +
|
259
|
+
node.enums +
|
260
|
+
node.data_types +
|
261
|
+
node.primitives).map do |class_node|
|
262
|
+
graph_node_name = generate_graph_name(class_node.name)
|
263
|
+
|
264
|
+
<<~HEREDOC
|
265
|
+
#{graph_node_name} [
|
266
|
+
shape="plain"
|
267
|
+
fontname="#{@fontname || DEFAULT_CLASS_FONT}"
|
268
|
+
label=<#{format_class(class_node, hide_members)}>]
|
269
|
+
HEREDOC
|
270
|
+
end.join("\n")
|
271
|
+
associations = node.classes.map(&:associations).compact.flatten +
|
272
|
+
node.associations
|
273
|
+
if node.groups
|
274
|
+
associations = sort_by_document_groupping(node.groups,
|
275
|
+
associations)
|
276
|
+
end
|
277
|
+
classes_names = node.classes.map(&:name)
|
278
|
+
associations = associations.map do |assoc_node|
|
279
|
+
if hide_other_classes &&
|
280
|
+
!classes_names.include?(assoc_node.member_end)
|
281
|
+
next
|
282
|
+
end
|
283
|
+
|
284
|
+
format_relationship(assoc_node)
|
285
|
+
end.join("\n")
|
286
|
+
|
287
|
+
classes = classes.lines.map { |line| " #{line}" }.join.chomp
|
288
|
+
associations = associations
|
289
|
+
.lines.map { |line| " #{line}" }.join.chomp
|
290
|
+
|
291
|
+
<<~HEREDOC
|
292
|
+
digraph G {
|
293
|
+
graph [#{@graph}]
|
294
|
+
edge [#{@edge}]
|
295
|
+
node [#{@node}]
|
296
|
+
|
297
|
+
#{classes}
|
298
|
+
|
299
|
+
#{associations}
|
300
|
+
}
|
301
|
+
HEREDOC
|
302
|
+
end
|
303
|
+
|
304
|
+
protected
|
305
|
+
|
306
|
+
def sort_by_document_groupping(groups, associations)
|
307
|
+
result = []
|
308
|
+
groups.each do |batch|
|
309
|
+
batch.each do |group_name|
|
310
|
+
associations
|
311
|
+
.select { |assc| assc.owner_end == group_name }
|
312
|
+
.each do |association|
|
313
|
+
result.push(association) unless result.include?(association)
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|
317
|
+
associations.each do |association|
|
318
|
+
result.push(association) unless result.include?(association)
|
319
|
+
end
|
320
|
+
result
|
321
|
+
end
|
322
|
+
|
323
|
+
def generate_from_dot(input)
|
324
|
+
Lutaml::Layout::GraphVizEngine.new(input: input).render(@type)
|
325
|
+
end
|
326
|
+
|
327
|
+
def generate_graph_name(name)
|
328
|
+
name.gsub(/[^0-9a-zA-Z]/i, "")
|
329
|
+
end
|
330
|
+
end
|
331
|
+
end
|
332
|
+
end
|