# Copyright 2018 - 2022, Mathijs Saey, Vrije Universiteit Brussel
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
defmodule Skitter.Dot do
@moduledoc """
Export skitter workflows as [graphviz](https://graphviz.org/) dot graphs.
The main function in this module is the `to_dot/1` function, which accepts a skitter workflow
and returns its dot representation as a string. End users may prefer the `print_dot/1` function,
which immediately prints the returned string. If dot is installed on the system, the `export/3`
function can be used to export the generated graph in a variety of formats.
"""
alias Skitter.{Component, Workflow}
alias Skitter.Workflow.Node.Component, as: C
alias Skitter.Workflow.Node.Workflow, as: W
@doc """
Return the dot representation of a workflow as a string.
"""
@spec to_dot(Workflow.t()) :: String.t()
def to_dot(w = %Workflow{}), do: container(workflow: w)
@doc """
Renders the generated dot graph, requires dot to be installed on the system.
This function exports a given workflow to the dot language (using `to_dot/1`), after which it
calls `dot` on the generated dot representation. When `dot` returns successfully, `{:ok,
string}` is returned, where `string` is the output generated by `dot`.
A `format` should be specified. This format is passed to `dot` through the use of its `-T`
option. For a list of the options supported on your system, see `man dot`.
An optional list of options may be passed to further configure the use of `dot`. The following
options are supported:
- `dot_exe`: the path to the dot executable, by default, this function assumes dot is present in
your `$PATH`.
- `extra`: a list of extra arguments to pass to the dot executable.
## Examples
Save `workflow` as a svg file:
```
render(workflow, "svg")
```
"""
@spec render(Workflow.t(), String.t(), dot_exe: String.t(), extra: [String.t()]) ::
{:ok, binary()} | {:error, String.t()}
def render(w = %Workflow{}, format, opts \\ []) do
dotfile = System.tmp_dir!() |> Path.join("skitter_export.gv")
File.write!(dotfile, to_dot(w))
dot_exe = Keyword.get(opts, :dot_exe, "dot")
extra = Keyword.get(opts, :extra, [])
res =
case System.cmd(dot_exe, ["-T#{format}"] ++ extra ++ [dotfile]) do
{out, 0} -> {:ok, out}
{out, 1} -> {:error, out}
end
File.rm!(dotfile)
res
end
@doc """
Renders the generated dot graph and save it to `path`.
Renders the provided workflow (with `render/3`) and store the output to `path`. `format` and
`opts` are passed to `render/3`.
"""
@spec render_to_file(Workflow.t(), String.t(), String.t(),
dot_exe: String.t(),
extra: [String.t()]
) :: :ok | {:error, String.t()}
def render_to_file(w = %Workflow{}, format \\ "pdf", path \\ "dot.pdf", opts \\ []) do
opts = Keyword.merge(opts, [extra: ["-o", path]], fn _, l, r -> l ++ r end)
case render(w, format, opts) do
{:ok, _} -> :ok
{:error, err} -> {:error, err}
end
end
# Templates & Helpers
# -------------------
require EEx
# Load all templates
__DIR__
|> Path.join("dot/*.eex")
|> Path.wildcard()
|> Enum.map(fn file ->
fname = file |> Path.basename(".eex") |> String.to_atom()
EEx.function_from_file(:defp, fname, file, [:assigns], trim: true)
end)
# Path is used to avoid name conflicts in nested workflows
defp expand_path("", id), do: Atom.to_string(id)
defp expand_path(path, id), do: "#{path}_#{Atom.to_string(id)}"
# Ports are prefixed with path and their "type" (in or out)
defp port_path("", prefix, port), do: ~s/"#{prefix}_#{port}"/
defp port_path(path, prefix, port), do: ~s/"#{path}_#{prefix}_#{port}"/
# Pattern match to treat workflows and components differently
defp workflow_node(id, c = %C{}, path) do
component(id: id, component: c.component, path: path)
end
defp workflow_node(id, w = %W{}, path) do
workflow_nested(id: id, workflow: w.workflow, path: expand_path(path, id))
end
defp destination({name, port}, path, workflow) do
case workflow.nodes[name] do
%C{} -> ~s/"#{expand_path(path, name)}":in_#{port}/
%W{} -> path |> expand_path(name) |> port_path("in", port)
end
end
defp destination(port, path, _), do: port_path(path, "out", port)
defp source(name, %C{}, port, path), do: ~s/"#{expand_path(path, name)}":out_#{port}/
defp source(name, %W{}, port, path), do: path |> expand_path(name) |> port_path("out", port)
end