# frozen_string_literal: true require 'json' require 'cgi' module DiverDown class Web class DefinitionToDot ATTRIBUTE_DELIMITER = ' ' MODULE_DELIMITER = '::' # Between modules is prominently distanced MODULE_MINLEN = 3 class MetadataStore Metadata = Data.define(:id, :type, :data, :module_store) do # @return [Hash] def to_h case type when :source source_to_h when :dependency dependency_to_h when :module module_to_h else raise NotImplementedError, "not implemented yet #{type}" end end private def source_to_h modules = module_store.get_modules(data.source_name).map do { module_name: _1, } end { id:, type: 'source', source_name: data.source_name, memo: module_store.get_memo(data.source_name), modules:, } end def dependency_to_h { id:, type: 'dependency', dependencies: data.map do |dependency| { source_name: dependency.source_name, method_ids: dependency.method_ids.sort.map do { name: _1.name, context: _1.context, } end, } end, } end def module_to_h { id:, type: 'module', modules: data.map do { module_name: _1, } end, } end end def initialize(module_store) @prefix = 'graph_' @module_store = module_store # Hash{ id => Metadata } @to_h = {} end # @param type [Symbol] # @param record [DiverDown::Definition::Source] # @return [String] def issue_source_id(source) build_metadata_and_return_id(:source, source) end # @param dependency [DiverDown::Definition::Dependency] # @return [String] def issue_dependency_id(dependency) build_metadata_and_return_id(:dependency, [dependency]) end # @param module_names [Array<String>] # @return [String] def issue_modules_id(module_names) issued_metadata = @to_h.values.find { _1.type == :module && _1.data == module_names } if issued_metadata issued_metadata.id else build_metadata_and_return_id(:module, module_names) end end # @param id [String] # @param dependency [DiverDown::Definition::Dependency] def append_dependency(id, dependency) metadata = @to_h.fetch(id) dependencies = metadata.data combined_dependencies = DiverDown::Definition::Dependency.combine(*dependencies, dependency) metadata.data.replace(combined_dependencies) end # @return [Array<Hash>] def to_a @to_h.values.map(&:to_h) end private def build_metadata_and_return_id(type, data) id = "#{@prefix}#{length + 1}" metadata = Metadata.new(id:, type:, data:, module_store: @module_store) @to_h[id] = metadata id end def length @to_h.length end end # @param definition [DiverDown::Definition] # @param module_store [DiverDown::ModuleStore] # @param compound [Boolean] # @param concentrate [Boolean] https://graphviz.org/docs/attrs/concentrate/ def initialize(definition, module_store, compound: false, concentrate: false, only_module: false) @definition = definition @module_store = module_store @io = DiverDown::Web::IndentedStringIo.new @indent = 0 @compound = compound || only_module # When only-module is enabled, dependencies between modules are displayed as compound. @compound_map = Hash.new { |h, k| h[k] = {} } # Hash{ ltail => Hash{ lhead => issued id } } @concentrate = concentrate @only_module = only_module @metadata_store = MetadataStore.new(module_store) end # @return [Array<Hash>] def metadata @metadata_store.to_a end # @return [String] def to_s io.puts %(strict digraph "#{escape_quote(definition.title)}" {) io.indented do io.puts('compound=true') if @compound io.puts('concentrate=true') if @concentrate if @only_module render_only_modules else render_sources end end io.puts '}' io.string end private attr_reader :definition, :module_store, :io def render_only_modules # Hash{ from_module => { to_module => Array<DiverDown::Definition::Dependency> } } dependency_map = Hash.new { |h, k| h[k] = Hash.new { |hi, ki| hi[ki] = [] } } definition.sources.sort_by(&:source_name).each do |source| source_modules = module_store.get_modules(source.source_name) next if source_modules.empty? source.dependencies.each do |dependency| dependency_modules = module_store.get_modules(dependency.source_name) next if dependency_modules.empty? dependency_map[source_modules][dependency_modules].push(dependency) end end # Remove duplicated prefix modules # from [["A"], ["A", "B"]] to [["A", "B"]] uniq_modules = [*dependency_map.keys, *dependency_map.values.map(&:keys).flatten(1)].uniq uniq_modules.reject! do |modules| modules.empty? || uniq_modules.any? { _1[0..modules.size - 1] == modules && _1.length > modules.size } end uniq_modules.each do |specific_module_names| buf = swap_io do indexes = (0..(specific_module_names.length - 1)).to_a chain_yield(indexes) do |index, next_proc| module_names = specific_module_names[0..index] module_name = specific_module_names[index] io.puts %(subgraph "#{escape_quote(module_label(module_names))}" {) io.indented do io.puts %(id="#{@metadata_store.issue_modules_id(module_names)}") io.puts %(label="#{escape_quote(module_name)}") io.puts %("#{escape_quote(module_name)}" #{build_attributes(label: module_name, id: @metadata_store.issue_modules_id(module_names))}) next_proc&.call end io.puts '}' end end io.write buf.string end dependency_map.each do |from_modules, h| h.each do |to_modules, all_dependencies| # Do not render standalone source # Do not render self-dependency next if from_modules.empty? || to_modules.empty? || from_modules == to_modules dependencies = DiverDown::Definition::Dependency.combine(*all_dependencies) dependencies.each do attributes = {} ltail = module_label(*from_modules) lhead = module_label(*to_modules) # Already rendered dependencies between modules # Add the dependency to the edge of the compound if @compound_map[ltail].include?(lhead) compound_id = @compound_map[ltail][lhead] @metadata_store.append_dependency(compound_id, _1) next end compound_id = @metadata_store.issue_dependency_id(_1) @compound_map[ltail][lhead] = compound_id attributes.merge!( id: compound_id, ltail:, lhead:, minlen: MODULE_MINLEN ) io.write(%("#{escape_quote(from_modules[-1])}" -> "#{escape_quote(to_modules[-1])}")) io.write(%( #{build_attributes(**attributes)}), indent: false) unless attributes.empty? io.write("\n") end end end end def render_sources by_modules = definition.sources.group_by do |source| module_store.get_modules(source.source_name) end # Remove duplicated prefix modules # from [["A"], ["A", "B"]] to [["A", "B"]] uniq_modules = by_modules.keys.uniq uniq_modules = uniq_modules.reject do |modules| uniq_modules.any? { _1[0..modules.size - 1] == modules && _1.length > modules.size } end uniq_modules.each do |full_modules| # Render module and source if full_modules.empty? sources = by_modules[full_modules].sort_by(&:source_name) sources.each do |source| insert_source(source) end else buf = swap_io do indexes = (0..(full_modules.length - 1)).to_a chain_yield(indexes) do |index, next_proc| module_names = full_modules[0..index] module_name = module_names[-1] io.puts %(subgraph "#{escape_quote(module_label(module_names))}" {) io.indented do io.puts %(id="#{@metadata_store.issue_modules_id(module_names)}") io.puts %(label="#{escape_quote(module_name)}") sources = (by_modules[module_names] || []).sort_by(&:source_name) sources.each do |source| insert_source(source) end next_proc&.call end io.puts '}' end end io.write buf.string end end definition.sources.sort_by(&:source_name).each do |source| insert_dependencies(source) end end def insert_source(source) io.puts %("#{escape_quote(source.source_name)}" #{build_attributes(label: source.source_name, id: @metadata_store.issue_source_id(source))}) end def insert_dependencies(source) source.dependencies.each do attributes = {} ltail = module_label(*module_store.get_modules(source.source_name)) lhead = module_label(*module_store.get_modules(_1.source_name)) if @compound && (ltail || lhead) # Rendering of dependencies between modules is done only once between_modules = ltail != lhead # Already rendered dependencies between modules # Add the dependency to the edge of the compound if between_modules && @compound_map[ltail].include?(lhead) compound_id = @compound_map[ltail][lhead] @metadata_store.append_dependency(compound_id, _1) next end compound_id = @metadata_store.issue_dependency_id(_1) @compound_map[ltail][lhead] = compound_id attributes.merge!( id: compound_id, ltail:, lhead:, minlen: MODULE_MINLEN ) else attributes.merge!( id: @metadata_store.issue_dependency_id(_1) ) end io.write(%("#{escape_quote(source.source_name)}" -> "#{escape_quote(_1.source_name)}")) io.write(%( #{build_attributes(**attributes)}), indent: false) unless attributes.empty? io.write("\n") end end def chain_yield(values, &block) *head, tail = values last_proc = proc do block.call(tail, nil) end chain_proc = head.inject(last_proc) do |next_proc, value| proc do block.call(value, next_proc) end end chain_proc.call end # rubocop:disable Lint/UnderscorePrefixedVariableName # attrsの参考 https://qiita.com/rubytomato@github/items/51779135bc4b77c8c20d def build_attributes(_wrap: '[]', **attrs) attrs = attrs.reject { _2.nil? || _2 == '' } return if attrs.empty? attrs_str = attrs.map { %(#{_1}="#{escape_quote(_2)}") }.join(ATTRIBUTE_DELIMITER) if _wrap "#{_wrap[0]}#{attrs_str}#{_wrap[1]}" else attrs_str end end # rubocop:enable Lint/UnderscorePrefixedVariableName def increase_indent @indent += 1 yield ensure @indent -= 1 end def swap_io old_io = @io @io = IndentedStringIo.new yield @io ensure @io = old_io end def module_label(*modules) return if modules.empty? "cluster_#{modules.join(MODULE_DELIMITER)}" end def escape_quote(string) string.to_s.gsub(/"/, '\"') end end end end