lutaml-graphviz 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2f8d38e9554f3ba105ab977a65c8fae46fa9bc9a75b66548dcd0cfa9bc8ee96d
4
+ data.tar.gz: 6734497fb339f562370efd5415af737ea9cf5bf8a5748efbbea36061f723eb32
5
+ SHA512:
6
+ metadata.gz: 6f382d5798769a1a90a5b70c71db72f278d1f9750a1227ae4084e9e435f64bfd53a1b701d8bab464d504e6a7bbfa20f416aeb167eba7f390dc0ae052baad6721
7
+ data.tar.gz: 63f8b3eeb80e2262110ed15a18a5b15a2dce97060519c8727a2657149b5db83c852f417a05ba5b6af0335e3080ae8bf413fd3a09eb140543fb5624a0ddc2e50e
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Formatter
5
+ class Graphviz < Base
6
+ module DocumentFormatter
7
+ def format_document(node)
8
+ @fontname = node.fontname || DEFAULT_CLASS_FONT
9
+ @node["fontname"] = "#{@fontname}-bold"
10
+
11
+ hide_members, hide_other_classes = extract_fidelity_options(node)
12
+ classes = format_all_classes(node, hide_members)
13
+ associations = build_associations(node, hide_other_classes)
14
+
15
+ build_digraph(classes, associations)
16
+ end
17
+
18
+ def extract_fidelity_options(node)
19
+ if node.fidelity
20
+ [node.fidelity.hideMembers, node.fidelity.hideOtherClasses]
21
+ else
22
+ [nil, nil]
23
+ end
24
+ end
25
+
26
+ def format_all_classes(node, hide_members)
27
+ node.all_classes.map do |class_node|
28
+ graph_node_name = generate_graph_name(class_node.name)
29
+ <<~HEREDOC
30
+ #{graph_node_name} [
31
+ shape="plain"
32
+ fontname="#{@fontname || DEFAULT_CLASS_FONT}"
33
+ label=<#{format_class(class_node, hide_members)}>]
34
+ HEREDOC
35
+ end.join("\n")
36
+ end
37
+
38
+ def build_associations(node, hide_other_classes)
39
+ associations = collect_all_associations(node)
40
+ associations = sort_by_document_grouping(node.groups, associations) if node.groups
41
+ classes_names = node.classes.map(&:name)
42
+ format_filtered_associations(associations, classes_names, hide_other_classes)
43
+ end
44
+
45
+ def collect_all_associations(node)
46
+ class_level = node.classifiable_classes
47
+ Set.new
48
+ all = class_level.filter_map(&:associations).flatten + node.associations
49
+ all.uniq { |a| association_key(a) }
50
+ end
51
+
52
+ def format_filtered_associations(associations, classes_names, hide_other_classes)
53
+ associations.filter_map do |assoc_node|
54
+ next if hide_other_classes && !classes_names.include?(assoc_node.member_end)
55
+
56
+ format_relationship(assoc_node)
57
+ end.join("\n")
58
+ end
59
+
60
+ def build_digraph(classes, associations)
61
+ indented_classes = indent_lines(classes)
62
+ indented_assocs = indent_lines(associations)
63
+
64
+ <<~HEREDOC
65
+ digraph G {
66
+ graph [#{@graph}]
67
+ edge [#{@edge}]
68
+ node [#{@node}]
69
+
70
+ #{indented_classes}
71
+
72
+ #{indented_assocs}
73
+ }
74
+ HEREDOC
75
+ end
76
+
77
+ def indent_lines(text)
78
+ text.lines.map { |line| " #{line}" }.join.chomp
79
+ end
80
+
81
+ private
82
+
83
+ def association_key(assoc)
84
+ [assoc.owner_end.to_s, assoc.member_end.to_s, assoc.name.to_s]
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Formatter
5
+ class Graphviz < Base
6
+ module HtmlBuilder
7
+ EMPTY_MEMBER_TABLE = <<~HEREDOC.chomp
8
+ <TABLE BORDER="0" CELLPADDING="0" CELLSPACING="0">
9
+ <TR><TD ALIGN="LEFT"></TD></TR>
10
+ </TABLE>
11
+ HEREDOC
12
+
13
+ def escape_html_chars(text)
14
+ text
15
+ .gsub("&", "&amp;")
16
+ .gsub("<", "&#60;")
17
+ .gsub(">", "&#62;")
18
+ .gsub("[", "&#91;")
19
+ .gsub("]", "&#93;")
20
+ end
21
+
22
+ def format_member_rows(members, hide_members, &formatter)
23
+ return EMPTY_MEMBER_TABLE if hide_members || !members&.any?
24
+
25
+ formatter ||= method(:dispatch_format)
26
+ field_rows = members.map do |member|
27
+ %(<TR><TD ALIGN="LEFT">#{formatter.call(member)}</TD></TR>)
28
+ end
29
+ build_member_table(field_rows)
30
+ end
31
+
32
+ def format_enum_member(member)
33
+ enum_literal?(member) ? format_enum_literal(member) : dispatch_format(member)
34
+ end
35
+
36
+ def enum_literal?(node)
37
+ node.is_a?(Lml::TopElementAttribute) &&
38
+ node.type.nil? && node.cardinality.nil?
39
+ end
40
+
41
+ def build_member_table(field_rows)
42
+ indented = field_rows.map { |row| (" " * 10) + row }.join("\n")
43
+ <<~HEREDOC.chomp
44
+ <TABLE BORDER="0" CELLPADDING="0" CELLSPACING="0">
45
+ #{indented}
46
+ </TABLE>
47
+ HEREDOC
48
+ .concat("\n")
49
+ .concat(" " * 6)
50
+ end
51
+
52
+ def build_name_table(name_parts)
53
+ <<~HEREDOC
54
+ <TABLE BORDER="0" CELLPADDING="0" CELLSPACING="0">
55
+ #{name_parts.map { |n| %(<TR><TD ALIGN="CENTER">#{n}</TD></TR>) }.join("\n")}
56
+ </TABLE>
57
+ HEREDOC
58
+ end
59
+
60
+ def build_table_body(name_html, field_table, method_table)
61
+ [name_html, field_table, method_table].compact.filter_map do |type|
62
+ <<~TEXT
63
+ <TR>
64
+ <TD>#{type}</TD>
65
+ </TR>
66
+ TEXT
67
+ end.join("\n")
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Formatter
5
+ class Graphviz < Base
6
+ module NodeFormatter
7
+ ACCESS_SYMBOLS = {
8
+ "public" => "+",
9
+ "protected" => "#",
10
+ "private" => "-",
11
+ "package" => "~"
12
+ }.freeze
13
+
14
+ def format_attribute(node)
15
+ symbol = ACCESS_SYMBOLS[node.visibility]
16
+ result = "#{symbol}#{node.name}"
17
+ if node.type
18
+ stereotype = node.stereotype.empty? ? "" : node.stereotype.map { |s| "«#{s}»" }.join
19
+ result += " : #{stereotype}#{node.type}"
20
+ end
21
+ result += format_cardinality_bounds(node.cardinality) if node.cardinality
22
+ result = escape_html_chars(result)
23
+ result = "<U>#{result}</U>" if node.static
24
+ result
25
+ end
26
+
27
+ def format_cardinality_bounds(cardinality)
28
+ min = cardinality.min || "*"
29
+ max = cardinality.max || "*"
30
+ "[#{min}..#{max}]"
31
+ end
32
+
33
+ def format_operation(node)
34
+ symbol = ACCESS_SYMBOLS[node.visibility]
35
+ params = format_operation_params(node.owned_parameter)
36
+ result = "#{symbol} #{node.name}(#{params})"
37
+ result << " : #{node.return_type}" if node.return_type
38
+ result = escape_html_chars(result)
39
+ result = "<U>#{result}</U>" if node.is_static
40
+ result = "<I>#{result}</I>" if node.is_abstract
41
+ result
42
+ end
43
+
44
+ def format_operation_params(params)
45
+ return "" unless params&.any?
46
+
47
+ params.map do |param|
48
+ param.type ? "#{param.name} : #{param.type}" : param.name.to_s
49
+ end.join(", ")
50
+ end
51
+
52
+ # Kind labels rendered above the name: guillemet stereotypes first,
53
+ # then the entity-kind label (enums/data types/primitives carry no
54
+ # user stereotypes of their own in the grammar).
55
+ KIND_LABELS = {
56
+ enums: "enumeration",
57
+ data_types: "dataType",
58
+ primitives: "primitive"
59
+ }.freeze
60
+
61
+ def format_class(node, hide_members = nil)
62
+ name = ["<B>#{escape_html_chars(node.name)}</B>"]
63
+ node.stereotype.each do |stereotype|
64
+ name.unshift("«#{escape_html_chars(stereotype)}»")
65
+ end
66
+ if (kind = KIND_LABELS[node.class.entity_type])
67
+ name.unshift("«#{kind}»")
68
+ end
69
+ name_html = build_name_table(name)
70
+
71
+ member_formatter = node.is_a?(Lml::Enum) ? method(:format_enum_member) : nil
72
+ field_table = format_member_rows(node.attributes, hide_members, &member_formatter)
73
+ method_table = format_member_rows(node.operations, hide_members) if node.operations&.any?
74
+ table_body = build_table_body(name_html, field_table, method_table)
75
+
76
+ <<~HEREDOC.chomp
77
+ <TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="10">
78
+ #{table_body}
79
+ </TABLE>
80
+ HEREDOC
81
+ end
82
+
83
+ def format_enum_literal(node)
84
+ "<I>#{escape_html_chars(node.name.to_s)}</I>"
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Formatter
5
+ class Graphviz < Base
6
+ module RelationshipFormatter
7
+ ARROW_TYPES = {
8
+ "composition" => "diamond",
9
+ "aggregation" => "odiamond",
10
+ "direct" => "vee"
11
+ }.freeze
12
+
13
+ DASHED_TYPES = %w[dependency realizes].freeze
14
+
15
+ DIRECTION_LABELS = {
16
+ "target" => "%s ▶",
17
+ "source" => "◀ %s"
18
+ }.freeze
19
+
20
+ def format_relationship(node)
21
+ graph_parent_name = generate_graph_name(node.owner_end)
22
+ graph_node_name = generate_graph_name(node.member_end)
23
+ attributes = build_edge_attributes(node)
24
+ graph_attributes = " [#{attributes}]" unless attributes.empty?
25
+
26
+ "#{graph_parent_name} -> #{graph_node_name}#{graph_attributes}"
27
+ end
28
+
29
+ def build_edge_attributes(node)
30
+ attrs = Attributes.new
31
+
32
+ apply_edge_style(attrs, node)
33
+ apply_edge_direction(attrs, node)
34
+ apply_edge_labels(attrs, node)
35
+ apply_arrow_types(attrs, node)
36
+ maybe_swap_labels(attrs)
37
+
38
+ attrs
39
+ end
40
+
41
+ def format_label(name, cardinality = nil)
42
+ res = "+#{name}"
43
+ return res if cardinality.nil? || cardinality.min.nil? || cardinality.max.nil?
44
+
45
+ "#{res} #{cardinality.min}..#{cardinality.max}"
46
+ end
47
+
48
+ private
49
+
50
+ def sort_by_document_grouping(groups, associations)
51
+ result = []
52
+ seen = Set.new
53
+
54
+ groups.each do |group|
55
+ group.values.each do |group_name| # rubocop:disable Style/HashEachMethods
56
+ matches = associations.select { |a| a.owner_end == group_name }
57
+ matches.each { |a| add_unseen(a, result, seen) }
58
+ end
59
+ end
60
+ associations.each { |a| add_unseen(a, result, seen) }
61
+ result
62
+ end
63
+
64
+ def add_unseen(association, result, seen)
65
+ return if seen.include?(association)
66
+
67
+ result.push(association)
68
+ seen.add(association)
69
+ end
70
+
71
+ def apply_edge_style(attrs, node)
72
+ attrs["style"] = "dashed" if DASHED_TYPES.include?(node.member_end_type)
73
+ end
74
+
75
+ def apply_edge_direction(attrs, node)
76
+ attrs["dir"] = if node.owner_end_type && node.member_end_type
77
+ "both"
78
+ elsif node.owner_end_type
79
+ "back"
80
+ else
81
+ "direct"
82
+ end
83
+ end
84
+
85
+ def apply_edge_labels(attrs, node)
86
+ apply_verb_label(attrs, node)
87
+ apply_direction_label(attrs, node)
88
+ apply_endpoint_labels(attrs, node)
89
+ end
90
+
91
+ def apply_verb_label(attrs, node)
92
+ attrs["label"] = node.action.verb if node&.action&.verb
93
+ end
94
+
95
+ def apply_direction_label(attrs, node)
96
+ return unless node&.action&.direction
97
+
98
+ template = DIRECTION_LABELS[node.action.direction]
99
+ attrs["label"] = template % attrs["label"] if template
100
+ end
101
+
102
+ def apply_endpoint_labels(attrs, node)
103
+ if node.owner_end_attribute_name
104
+ attrs["headlabel"] = format_label(
105
+ node.owner_end_attribute_name, node.owner_end_cardinality
106
+ )
107
+ end
108
+
109
+ return unless node.member_end_attribute_name
110
+
111
+ attrs["taillabel"] = format_label(
112
+ node.member_end_attribute_name, node.member_end_cardinality
113
+ )
114
+ end
115
+
116
+ def apply_arrow_types(attrs, node)
117
+ attrs["arrowtail"] = ARROW_TYPES.fetch(node.owner_end_type, "onormal")
118
+ attrs["arrowhead"] = ARROW_TYPES.fetch(node.member_end_type, "onormal")
119
+ end
120
+
121
+ def maybe_swap_labels(attrs)
122
+ return unless attrs["dir"] == "back" && attrs["arrowtail"] != "vee"
123
+
124
+ attrs["arrowhead"], attrs["arrowtail"] = [attrs["arrowtail"], attrs["arrowhead"]]
125
+ attrs["headlabel"], attrs["taillabel"] = [attrs["taillabel"], attrs["headlabel"]]
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "set"
5
+
6
+ module Lutaml
7
+ module Formatter
8
+ class Graphviz < Base
9
+ autoload :HtmlBuilder, "lutaml/graphviz/formatter/graphviz/html_builder"
10
+ autoload :NodeFormatter, "lutaml/graphviz/formatter/graphviz/node_formatter"
11
+ autoload :RelationshipFormatter, "lutaml/graphviz/formatter/graphviz/relationship_formatter"
12
+ autoload :DocumentFormatter, "lutaml/graphviz/formatter/graphviz/document_formatter"
13
+
14
+ include HtmlBuilder
15
+ include NodeFormatter
16
+ include RelationshipFormatter
17
+ include DocumentFormatter
18
+
19
+ class Attributes < Hash
20
+ def to_s
21
+ to_a
22
+ .reject { |(_k, val)| val.nil? }
23
+ .map { |(a, b)| "#{a}=#{b.inspect}" }
24
+ .join(" ")
25
+ end
26
+ end
27
+
28
+ DEFAULT_CLASS_FONT = "Helvetica"
29
+
30
+ VALID_TYPES = %i[
31
+ dot xdot ps pdf svg svgz fig png gif jpg jpeg json imap cmapx
32
+ ].freeze
33
+
34
+ def initialize(attributes = {})
35
+ super
36
+ setup_default_attributes
37
+ @type = :dot
38
+ end
39
+
40
+ attr_reader :graph, :edge, :node
41
+
42
+ def type=(value)
43
+ super
44
+ @type = :dot unless VALID_TYPES.include?(@type)
45
+ end
46
+
47
+ def format(node)
48
+ dot = super.lines.map(&:rstrip).join("\n")
49
+ generate_from_dot(dot)
50
+ end
51
+
52
+ def setup_default_attributes
53
+ @graph = build_graph_attrs
54
+ @edge = build_edge_attrs
55
+ @node = build_node_attrs
56
+ end
57
+
58
+ def build_graph_attrs
59
+ Attributes.new.tap do |g|
60
+ g["splines"] = "ortho"
61
+ g["pad"] = "0.5"
62
+ g["ranksep"] = "1.2"
63
+ g["nodesep"] = "1.2"
64
+ g["rankdir"] = "BT"
65
+ end
66
+ end
67
+
68
+ def build_edge_attrs
69
+ Attributes.new.tap { |e| e["color"] = "gray50" }
70
+ end
71
+
72
+ def build_node_attrs
73
+ Attributes.new.tap { |n| n["shape"] = "box" }
74
+ end
75
+
76
+ private :setup_default_attributes, :build_graph_attrs,
77
+ :build_edge_attrs, :build_node_attrs
78
+
79
+ protected
80
+
81
+ def generate_from_dot(input)
82
+ Lutaml::Layout::GraphVizEngine.new(input: input).render(@type)
83
+ end
84
+
85
+ def generate_graph_name(name)
86
+ name.gsub(/[^0-9a-zA-Z]/i, "")
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby-graphviz"
4
+ require "open3"
5
+
6
+ module Lutaml
7
+ module Layout
8
+ class GraphVizEngine < Engine
9
+ def render(type)
10
+ Open3.popen3("dot", "-T#{type}") do |stdin, stdout, _stderr, _wait|
11
+ stdout.binmode
12
+ stdin.puts(input)
13
+ stdin.close
14
+ stdout.read
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Graphviz
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/lml"
4
+ require_relative "graphviz/version"
5
+
6
+ module Lutaml
7
+ # Graphviz diagram rendering for LutaML models. The renderers register
8
+ # themselves against the model-driven formatter registry owned by
9
+ # lutaml (Lutaml::Formatter.all / .find_by_name), so requiring this file
10
+ # is all a consumer does to make the `graphviz` formatter available.
11
+ # ruby-graphviz and the `dot` toolchain live in this gem only.
12
+ module Graphviz
13
+ class Error < Lutaml::Error; end
14
+ end
15
+
16
+ module Formatter
17
+ autoload :Graphviz, "lutaml/graphviz/formatter/graphviz"
18
+ end
19
+
20
+ module Layout
21
+ autoload :GraphVizEngine, "lutaml/graphviz/layout/graph_viz_engine"
22
+ end
23
+ end
24
+
25
+ # Force the autoload so the formatter registers in Lutaml::Formatter.all
26
+ # as soon as the gem is required.
27
+ Lutaml::Formatter::Graphviz
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lutaml-graphviz
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ribose Inc.
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: lutaml
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ruby-graphviz
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Renders LutaML (LML) documents as Graphviz diagrams. Owns the ruby-graphviz
41
+ dependency so the lutaml core gemspecs never carry an optional render toolchain.
42
+ email:
43
+ - open.source@ribose.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - lib/lutaml/graphviz.rb
49
+ - lib/lutaml/graphviz/formatter/graphviz.rb
50
+ - lib/lutaml/graphviz/formatter/graphviz/document_formatter.rb
51
+ - lib/lutaml/graphviz/formatter/graphviz/html_builder.rb
52
+ - lib/lutaml/graphviz/formatter/graphviz/node_formatter.rb
53
+ - lib/lutaml/graphviz/formatter/graphviz/relationship_formatter.rb
54
+ - lib/lutaml/graphviz/layout/graph_viz_engine.rb
55
+ - lib/lutaml/graphviz/version.rb
56
+ homepage: https://github.com/lutaml/lutaml-graphviz
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ homepage_uri: https://github.com/lutaml/lutaml-graphviz
61
+ source_code_uri: https://github.com/lutaml/lutaml-graphviz
62
+ changelog_uri: https://github.com/lutaml/lutaml-graphviz/releases
63
+ rubygems_mfa_required: 'true'
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '3.0'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 4.0.23
79
+ specification_version: 4
80
+ summary: Graphviz diagram rendering for LutaML models
81
+ test_files: []