# frozen_string_literal: true require "open3" require "lutaml/formatter/base" require "lutaml/layout/graph_viz_engine" module Lutaml module Formatter class Graphviz < Base class Attributes < Hash def to_s to_a .reject { |(_k, val)| val.nil? } .map { |(a, b)| "#{a}=#{b.inspect}" } .join(" ") end end ACCESS_SYMBOLS = { "public" => "+", "protected" => "#", "private" => "-", }.freeze DEFAULT_CLASS_FONT = "Helvetica".freeze VALID_TYPES = %i[ dot xdot ps pdf svg svgz fig png gif jpg jpeg json imap cmapx ].freeze def initialize(attributes = {}) super @graph = Attributes.new # Associations lines style, `true` gives curved lines # https://graphviz.org/doc/info/attrs.html#d:splines @graph["splines"] = "ortho" # Padding between outside of picture and nodes @graph["pad"] = 0.5 # Padding between levels @graph["ranksep"] = "1.2.equally" # Padding between nodes @graph["nodesep"] = "1.2.equally" # TODO: set rankdir # @graph['rankdir'] = 'BT' @edge = Attributes.new @edge["color"] = "gray50" @node = Attributes.new @node["shape"] = "box" @type = :dot end attr_reader :graph attr_reader :edge attr_reader :node def type=(value) super @type = :dot unless VALID_TYPES.include?(@type) end def format(node) dot = super.lines.map(&:rstrip).join("\n") generate_from_dot(dot) end def escape_html_chars(text) text .gsub(//, ">") .gsub(/\[/, "[") .gsub(/\]/, "]") end def format_field(node) symbol = ACCESS_SYMBOLS[node.visibility] result = "#{symbol}#{node.name}" if node.type keyword = node.keyword ? "«#{node.keyword}»" : "" result += " : #{keyword}#{node.type}" end if node.cardinality result += "[#{node.cardinality[:min]}..#{node.cardinality[:max]}]" end result = escape_html_chars(result) result = "#{result}" if node.static result end def format_method(node) symbol = ACCESS_SYMBOLS[node.access] result = "#{symbol} #{node.name}" if node.arguments arguments = node.arguments.map do |argument| "#{argument.name}#{" : #{argument.type}" if argument.type}" end.join(", ") end result << "(#{arguments})" result << " : #{node.type}" if node.type result = "#{result}" if node.static result = "#{result}" if node.abstract result end def format_relationship(node) graph_parent_name = generate_graph_name(node.owner_end) graph_node_name = generate_graph_name(node.member_end) attributes = generate_graph_relationship_attributes(node) graph_attributes = " [#{attributes}]" unless attributes.empty? %{#{graph_parent_name} -> #{graph_node_name}#{graph_attributes}} end def generate_graph_relationship_attributes(node) attributes = Attributes.new if %w[dependency realizes].include?(node.member_end_type) attributes["style"] = "dashed" end attributes["dir"] = if node.owner_end_type && node.member_end_type "both" elsif node.owner_end_type "back" else "direct" end attributes["label"] = node.action if node.action if node.owner_end_attribute_name attributes["headlabel"] = format_label( node.owner_end_attribute_name, node.owner_end_cardinality ) end if node.member_end_attribute_name attributes["taillabel"] = format_label( node.member_end_attribute_name, node.member_end_cardinality ) end attributes["arrowtail"] = case node.owner_end_type when "composition" "diamond" when "aggregation" "odiamond" when "direct" "vee" else "onormal" end attributes["arrowhead"] = case node.member_end_type when "composition" "diamond" when "aggregation" "odiamond" when "direct" "vee" else "onormal" end # swap labels and arrows if `dir` eq to `back` if attributes["dir"] == "back" attributes["arrowhead"], attributes["arrowtail"] = [attributes["arrowtail"], attributes["arrowhead"]] attributes["headlabel"], attributes["taillabel"] = [attributes["taillabel"], attributes["headlabel"]] end attributes end def format_label(name, cardinality = {}) res = "+#{name}" if cardinality.nil? || (cardinality["min"].nil? || cardinality["max"].nil?) return res end "#{res} #{cardinality['min']}..#{cardinality['max']}" end def format_member_rows(members, hide_members) unless !hide_members && members && members.length.positive? return <<~HEREDOC.chomp
HEREDOC end field_rows = members.map do |field| %{#{format_field(field)}} end field_table = <<~HEREDOC.chomp #{field_rows.map { |row| ' ' * 10 + row }.join("\n")}
HEREDOC field_table << "\n" << " " * 6 field_table end def format_class(node, hide_members) name = ["#{node.name}"] name.unshift("«#{node.keyword}»") if node.keyword name_html = <<~HEREDOC #{name.map { |n| %() }.join('\n')}
#{n}
HEREDOC field_table = format_member_rows(node.attributes, hide_members) method_table = format_member_rows(node.methods, hide_members) table_body = [name_html, field_table, method_table].map do |type| next if type.nil? <<~TEXT #{type} TEXT end <<~HEREDOC.chomp #{table_body.compact.join("\n")}
HEREDOC end def format_document(node) @fontname = node.fontname || DEFAULT_CLASS_FONT @node["fontname"] = "#{@fontname}-bold" if node.fidelity hide_members = node.fidelity["hideMembers"] hide_other_classes = node.fidelity["hideOtherClasses"] end classes = (node.classes + node.enums + node.data_types + node.primitives).map do |class_node| graph_node_name = generate_graph_name(class_node.name) <<~HEREDOC #{graph_node_name} [ shape="plain" fontname="#{@fontname || DEFAULT_CLASS_FONT}" label=<#{format_class(class_node, hide_members)}>] HEREDOC end.join("\n") associations = node.classes.map(&:associations).compact.flatten + node.associations if node.groups associations = sort_by_document_groupping(node.groups, associations) end classes_names = node.classes.map(&:name) associations = associations.map do |assoc_node| if hide_other_classes && !classes_names.include?(assoc_node.member_end) next end format_relationship(assoc_node) end.join("\n") classes = classes.lines.map { |line| " #{line}" }.join.chomp associations = associations .lines.map { |line| " #{line}" }.join.chomp <<~HEREDOC digraph G { graph [#{@graph}] edge [#{@edge}] node [#{@node}] #{classes} #{associations} } HEREDOC end protected def sort_by_document_groupping(groups, associations) result = [] groups.each do |batch| batch.each do |group_name| associations .select { |assc| assc.owner_end == group_name } .each do |association| result.push(association) unless result.include?(association) end end end associations.each do |association| result.push(association) unless result.include?(association) end result end def generate_from_dot(input) Lutaml::Layout::GraphVizEngine.new(input: input).render(@type) end def generate_graph_name(name) name.gsub(/[^0-9a-zA-Z]/i, "") end end end end