# 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(/\[/, "[") .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
#{n} |