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.
@@ -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(/</, "&#60;")
87
+ .gsub(/>/, "&#62;")
88
+ .gsub(/\[/, "&#91;")
89
+ .gsub(/\]/, "&#93;")
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