Current section
Files
Jump to
Current section
Files
lib/mermaid_generator.ex
defmodule Cracker.MermaidGenerator do
@moduledoc """
Generates Mermaid flowchart markup from a list of function call edges.
Each edge is represented as [caller, callee] where caller and callee
are strings in the format "module.function/arity"
"""
@doc """
Generate Mermaid flowchart markup from a list of function call edges.
## Example
edges = [
{"MyApp.ModuleA.post/1", "MyApp.Client.post/2"},
{"MyApp.ModuleA.post/1", "MyApp.ModuleA.persist_data/2"},
{"MyApp.Client.post/2", "MyApp.Client.encode_query/2"},
{"MyApp.Client.post/2", "MyApp.Client.send_request/2"}
]
Cracker.MermaidGenerator.generate(edges)
"""
def generate(edges) do
# Convert edges to structured format
structured_edges = Enum.map(edges, &to_edge_tuple/1)
# Group by caller
grouped_edges = group_by_caller(structured_edges)
# Group by module and create module IDs
{module_grouped_edges, module_ids} = edges
|> Enum.flat_map(fn {from, to} -> [from, to] end)
|> Enum.map(&parse_function/1)
|> Enum.map(&elem(&1, 0))
|> Enum.uniq()
|> Enum.map(&{&1, "m#{System.unique_integer([:positive])}"})
|> then(fn module_ids ->
{
Enum.group_by(grouped_edges, fn {{module, _, _}, _} -> module end),
Map.new(module_ids)
}
end)
# Create function IDs
function_ids = edges
|> Enum.flat_map(fn {from, to} -> [from, to] end)
|> Enum.map(&parse_function/1)
|> Enum.uniq()
|> Enum.map(fn {m, f, a} -> {"#{m}.#{f}/#{a}", "f#{System.unique_integer([:positive])}"} end)
|> Map.new()
IO.iodata_to_binary([
"flowchart LR\n",
" %% Function call graph\n",
generate_caller_nodes(grouped_edges, function_ids),
"\n",
generate_module_subgraphs(module_grouped_edges, module_ids, function_ids),
"\n %% External connections\n",
generate_connections(grouped_edges, module_ids, function_ids)
])
end
defp to_edge_tuple({from, to}) do
{parse_function(from), parse_function(to)}
end
defp parse_function(function_str) do
parts = String.split(function_str, ".")
{module_parts, [function_with_arity]} = Enum.split(parts, -1)
module_path = Enum.join(module_parts, ".")
[function_name, arity] = String.split(function_with_arity, "/")
{module_path, function_name, String.to_integer(arity)}
end
defp group_by_caller(edges) do
Enum.group_by(edges, &elem(&1, 0))
end
defp generate_caller_nodes(grouped_edges, function_ids) do
# Find all functions that are called by others
called_functions =
grouped_edges
|> Enum.flat_map(fn {_caller, callees} ->
Enum.map(callees, fn {_caller, callee} -> callee end)
end)
|> MapSet.new()
grouped_edges
|> Enum.filter(fn {caller, _callees} -> not MapSet.member?(called_functions, caller) end)
|> Enum.map(fn {{module, func, arity}, _callees} ->
func_id = function_ids["#{module}.#{func}/#{arity}"]
" #{func_id}[\"#{module}.#{func}/#{arity}\"]\n"
end)
end
defp generate_module_subgraphs(module_grouped_edges, module_ids, function_ids) do
module_grouped_edges
|> Enum.map(fn {module, edges} ->
module_id = module_ids[module]
[
" subgraph #{module_id}[\"#{module}\"]\n",
" style #{module_id} stroke-dasharray: 5 5\n",
generate_function_subgraphs(edges, module, function_ids),
" end\n"
]
end)
end
defp generate_function_subgraphs(grouped_edges, current_module, function_ids) do
Enum.map(grouped_edges, fn {{_caller_module, caller_func, caller_arity}, callees} ->
func_id = function_ids["#{current_module}.#{caller_func}/#{caller_arity}"]
[
" subgraph #{func_id}_box[\"#{caller_func}/#{caller_arity}\"]\n",
# Generate all nodes
generate_callee_nodes(current_module, callees, func_id, function_ids),
# Generate internal connections
generate_callee_connections(callees, func_id, function_ids),
" end\n"
]
end)
end
defp generate_callee_nodes(caller_module, callees, container_id, function_ids) do
callees
|> Enum.with_index()
|> Enum.map(fn {{_caller, {callee_module, callee_func, callee_arity}}, _idx} ->
func_str = "#{callee_module}.#{callee_func}/#{callee_arity}"
func_id = "#{container_id}_box_#{function_ids[func_str]}"
label = if callee_module == caller_module do
"#{callee_func}/#{callee_arity}"
else
"#{callee_module}.#{callee_func}/#{callee_arity}"
end
" #{func_id}[\"#{label}\"]\n"
end)
end
defp generate_callee_connections(callees, container_id, function_ids) do
callees
|> Enum.with_index()
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [{{_caller1, callee1}, _idx1}, {{_caller2, callee2}, _idx2}] ->
source = "#{container_id}_box_#{function_ids["#{elem(callee1, 0)}.#{elem(callee1, 1)}/#{elem(callee1, 2)}"]}"
target = "#{container_id}_box_#{function_ids["#{elem(callee2, 0)}.#{elem(callee2, 1)}/#{elem(callee2, 2)}"]}"
" #{source} --> #{target}\n"
end)
end
defp generate_connections(grouped_edges, module_ids, function_ids) do
# Find all functions that are called by others
called_functions =
grouped_edges
|> Enum.flat_map(fn {_caller, callees} ->
Enum.map(callees, fn {_caller, callee} -> callee end)
end)
|> MapSet.new()
Enum.flat_map(grouped_edges, fn {{caller_module, caller_func, caller_arity} = caller, callees} ->
caller_str = "#{caller_module}.#{caller_func}/#{caller_arity}"
caller_id = function_ids[caller_str]
# Only create container connection if this function isn't called by others
container_connection =
if MapSet.member?(called_functions, caller) do
[]
else
[" #{caller_id} --> #{module_ids[caller_module]}\n"]
end
# Connections to other containers
external_connections =
callees
|> Enum.filter(fn {_caller, callee} ->
# Only create connections for calls to functions that are callers themselves
Enum.any?(grouped_edges, fn {key, _} -> key == callee end)
end)
|> Enum.map(fn {_caller, {callee_module, callee_func, callee_arity}} ->
callee_str = "#{callee_module}.#{callee_func}/#{callee_arity}"
callee_id = function_ids[callee_str]
source = "#{caller_id}_box_#{callee_id}"
if callee_module == caller_module && callee_func == caller_func && callee_arity == caller_arity do
# Self-referential connection within the same container
" #{source} --> #{source}\n"
else
target = "#{callee_id}_box"
" #{source} -.-> #{target}\n"
end
end)
container_connection ++ external_connections
end)
end
end