• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

code-shoily / choreo / 17b14252751eb2cfad891ce2a504c01050e94f0e

26 Apr 2026 08:17PM UTC coverage: 88.482% (-4.0%) from 92.503%
17b14252751eb2cfad891ce2a504c01050e94f0e

push

github

code-shoily
Add customization on rendering params

88 of 168 new or added lines in 8 files covered. (52.38%)

7 existing lines in 7 files now uncovered.

1498 of 1693 relevant lines covered (88.48%)

18.67 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

67.5
/lib/choreo/dependency/render/dot.ex
1
defmodule Choreo.Dependency.Render.DOT do
2
  @moduledoc """
3
  DOT (Graphviz) rendering for `Choreo.Dependency` graphs.
4

5
  Produces dependency-oriented visualisation:
6

7
    * **Applications** — 3D boxes
8
    * **Libraries** — cylinders
9
    * **Modules** — boxes
10
    * **Interfaces** — diamonds
11
    * **Tests** — note shapes
12

13
  Edge styles:
14
    * **uses** — solid grey
15
    * **imports** — dashed
16
    * **calls** — dotted
17
    * **inherits** — solid bold
18
    * **dev** — dashed grey
19

20
  Layout is top-to-bottom by default so dependencies point downward.
21
  """
22

23
  alias Choreo.Theme
24

25
  @doc """
26
  Renders a dependency graph to a DOT string.
27

28
  ## Options
29

30
    * `:theme` — `:default`, `:dark`, or a `Choreo.Theme` struct
31

32
  ## Examples
33

34
      iex> deps = Choreo.Dependency.new()
35
      iex> deps = deps
36
      ...>   |> Choreo.Dependency.add_application(:api, label: "API")
37
      ...>   |> Choreo.Dependency.add_module(:auth, label: "Auth")
38
      ...>   |> Choreo.Dependency.depends_on(:api, :auth)
39
      iex> dot = Choreo.Dependency.Render.DOT.to_dot(deps)
40
      iex> String.contains?(dot, "digraph")
41
      true
42
      iex> String.contains?(dot, "API")
43
      true
44
      iex> String.contains?(dot, "Auth")
45
      true
46
  """
47
  @spec to_dot(Choreo.Dependency.t(), keyword()) :: String.t()
48
  def to_dot(%Choreo.Dependency{} = deps, opts \\ []) do
49
    theme = resolve_theme(Keyword.get(opts, :theme, :default))
6✔
50
    subgraphs = Choreo.Internal.build_cluster_subgraphs(deps, theme)
6✔
51
    cycle_edges = cycle_edge_set(deps)
6✔
52

53
    base_opts =
6✔
54
      Yog.Render.DOT.default_options()
55
      |> Map.put(:rankdir, :tb)
56
      |> Map.put(:splines, :spline)
57
      |> Map.put(:nodesep, 0.5)
58
      |> Map.put(:ranksep, 1.0)
59
      |> Map.put(:node_shape, :box)
60
      |> Map.put(:node_style, :filled)
61
      |> Map.put(:node_color, "white")
62
      |> Map.put(:node_fontname, theme.node_fontname)
6✔
63
      |> Map.put(:node_fontsize, theme.node_fontsize)
6✔
64
      |> Map.put(:node_fontcolor, theme.node_fontcolor)
6✔
65
      |> Map.put(:edge_color, theme.edge_color)
6✔
66
      |> Map.put(:edge_fontname, theme.edge_fontname)
6✔
67
      |> Map.put(:edge_fontsize, theme.edge_fontsize)
6✔
68
      |> Map.put(:edge_penwidth, theme.edge_penwidth)
6✔
69
      |> Map.put(:arrowhead, :normal)
70
      |> Map.put(:node_label, &node_label/2)
71
      |> Map.put(:edge_label, fn _edge_id, label -> edge_label(label) end)
7✔
72
      |> Map.put(:node_attributes, node_attributes_fn(theme))
73
      |> Map.put(:edge_attributes, edge_attributes_fn(deps, cycle_edges))
74
      |> Map.merge(theme_graph_overrides(theme))
75
      |> Map.merge(Map.new(opts))
76

77
    base_opts = if subgraphs != [], do: Map.put(base_opts, :subgraphs, subgraphs), else: base_opts
6✔
78

79
    Yog.Multi.DOT.to_dot(deps.graph, base_opts)
6✔
80
  end
81

82
  # ============================================================================
83
  # Cycle highlighting
84
  # ============================================================================
85

86
  defp cycle_edge_set(deps) do
87
    cycles = Choreo.Dependency.Analysis.cyclic_dependencies(deps)
6✔
88

89
    cycles
90
    |> Enum.flat_map(fn path ->
91
      # path is [a, b, c, a]
92
      path
93
      |> Enum.chunk_every(2, 1, :discard)
94
      |> Enum.map(fn [from, to] -> {from, to} end)
1✔
95
    end)
96
    |> MapSet.new()
6✔
97
  end
98

99
  # ============================================================================
100
  # Theme helpers
101
  # ============================================================================
102

103
  defp resolve_theme(%Theme{} = theme), do: theme
×
104
  defp resolve_theme(:default), do: default_dependency_theme()
5✔
105
  defp resolve_theme(:dark), do: dark_dependency_theme()
1✔
NEW
106
  defp resolve_theme(:warm), do: warm_dependency_theme()
×
NEW
107
  defp resolve_theme(:forest), do: forest_dependency_theme()
×
NEW
108
  defp resolve_theme(:ocean), do: ocean_dependency_theme()
×
UNCOV
109
  defp resolve_theme(_), do: default_dependency_theme()
×
110

111
  defp warm_dependency_theme do
NEW
112
    %Theme{
×
113
      name: :dependency_warm,
114
      colors: %{
115
        application: "#ea580c",
116
        library: "#fbbf24",
117
        module: "#f43f5e",
118
        interface: "#db2777",
119
        test: "#78716c"
120
      },
121
      node_fontname: "Helvetica",
122
      node_fontsize: 12,
123
      node_fontcolor: "white",
124
      edge_color: "#78716c",
125
      edge_fontname: "Helvetica",
126
      edge_fontsize: 9,
127
      edge_penwidth: 1.0,
128
      graph_bgcolor: "#fef2f2",
129
      cluster_fillcolor: "#fee2e2",
130
      cluster_style: :rounded,
131
      cluster_color: "#fca5a5"
132
    }
133
  end
134

135
  defp forest_dependency_theme do
NEW
136
    %Theme{
×
137
      name: :dependency_forest,
138
      colors: %{
139
        application: "#047857",
140
        library: "#65a30d",
141
        module: "#15803d",
142
        interface: "#0f766e",
143
        test: "#4b5563"
144
      },
145
      node_fontname: "Helvetica",
146
      node_fontsize: 12,
147
      node_fontcolor: "white",
148
      edge_color: "#4b5563",
149
      edge_fontname: "Helvetica",
150
      edge_fontsize: 9,
151
      edge_penwidth: 1.0,
152
      graph_bgcolor: "#f0fdf4",
153
      cluster_fillcolor: "#dcfce7",
154
      cluster_style: :rounded,
155
      cluster_color: "#86efac"
156
    }
157
  end
158

159
  defp ocean_dependency_theme do
NEW
160
    %Theme{
×
161
      name: :dependency_ocean,
162
      colors: %{
163
        application: "#0e7490",
164
        library: "#0891b2",
165
        module: "#1d4ed8",
166
        interface: "#008080",
167
        test: "#64748b"
168
      },
169
      node_fontname: "Helvetica",
170
      node_fontsize: 12,
171
      node_fontcolor: "white",
172
      edge_color: "#64748b",
173
      edge_fontname: "Helvetica",
174
      edge_fontsize: 9,
175
      edge_penwidth: 1.0,
176
      graph_bgcolor: "#f0f9ff",
177
      cluster_fillcolor: "#e0f2fe",
178
      cluster_style: :rounded,
179
      cluster_color: "#7dd3fc"
180
    }
181
  end
182

183
  defp default_dependency_theme do
184
    %Theme{
5✔
185
      name: :dependency_default,
186
      colors: %{
187
        application: "#3b82f6",
188
        library: "#f59e0b",
189
        module: "#10b981",
190
        interface: "#8b5cf6",
191
        test: "#64748b"
192
      },
193
      node_fontname: "Helvetica",
194
      node_fontsize: 12,
195
      node_fontcolor: "white",
196
      edge_color: "#64748b",
197
      edge_fontname: "Helvetica",
198
      edge_fontsize: 9,
199
      edge_penwidth: 1.0,
200
      graph_bgcolor: nil,
201
      cluster_fillcolor: "#f8fafc",
202
      cluster_style: :rounded,
203
      cluster_color: "#cbd5e1"
204
    }
205
  end
206

207
  defp dark_dependency_theme do
208
    %Theme{
1✔
209
      name: :dependency_dark,
210
      colors: %{
211
        application: "#2563eb",
212
        library: "#d97706",
213
        module: "#059669",
214
        interface: "#7c3aed",
215
        test: "#475569"
216
      },
217
      node_fontname: "Helvetica",
218
      node_fontsize: 12,
219
      node_fontcolor: "#e2e8f0",
220
      edge_color: "#94a3b8",
221
      edge_fontname: "Helvetica",
222
      edge_fontsize: 9,
223
      edge_penwidth: 1.0,
224
      graph_bgcolor: "#0f172a",
225
      cluster_fillcolor: "#1e293b",
226
      cluster_style: :rounded,
227
      cluster_color: "#475569"
228
    }
229
  end
230

231
  defp dep_color(%Theme{colors: colors}, key) do
232
    Map.get(colors, key, "#64748b")
12✔
233
  end
234

235
  defp theme_graph_overrides(%Theme{graph_bgcolor: nil}), do: %{}
5✔
236
  defp theme_graph_overrides(%Theme{graph_bgcolor: bg}), do: %{bgcolor: bg}
1✔
237

238
  # ============================================================================
239
  # Node styling
240
  # ============================================================================
241

242
  defp node_attributes_fn(theme) do
243
    fn _id, data ->
6✔
244
      base =
12✔
245
        case Map.get(data, :node_type, :module) do
246
          :application ->
5✔
247
            [
248
              {:shape, :box3d},
249
              {:fillcolor, dep_color(theme, :application)},
250
              {:fontcolor, theme.node_fontcolor},
5✔
251
              {:style, "filled"}
252
            ]
253

254
          :library ->
×
255
            [
256
              {:shape, :cylinder},
257
              {:fillcolor, dep_color(theme, :library)},
NEW
258
              {:fontcolor, theme.node_fontcolor},
×
259
              {:style, "filled"}
260
            ]
261

262
          :module ->
7✔
263
            [
264
              {:shape, :box},
265
              {:fillcolor, dep_color(theme, :module)},
266
              {:fontcolor, theme.node_fontcolor},
7✔
267
              {:style, "filled"}
268
            ]
269

270
          :interface ->
×
271
            [
272
              {:shape, :diamond},
273
              {:fillcolor, dep_color(theme, :interface)},
NEW
274
              {:fontcolor, theme.node_fontcolor},
×
275
              {:style, "filled"}
276
            ]
277

278
          :test ->
×
279
            [
280
              {:shape, :note},
281
              {:fillcolor, dep_color(theme, :test)},
NEW
282
              {:fontcolor, theme.node_fontcolor},
×
283
              {:style, "filled"}
284
            ]
285

286
          _ ->
×
287
            [
288
              {:shape, :box},
289
              {:fillcolor, dep_color(theme, :module)},
NEW
290
              {:fontcolor, theme.node_fontcolor},
×
291
              {:style, "filled"}
292
            ]
293
        end
294

295
      base =
12✔
296
        if shape = data[:shape], do: [{:shape, shape} | Keyword.delete(base, :shape)], else: base
12✔
297

298
      base =
12✔
NEW
299
        if color = data[:fillcolor],
×
300
          do: [{:fillcolor, color} | Keyword.delete(base, :fillcolor)],
301
          else: base
12✔
302

303
      base =
12✔
NEW
304
        if fontcolor = data[:fontcolor],
×
305
          do: [{:fontcolor, fontcolor} | Keyword.delete(base, :fontcolor)],
306
          else: base
12✔
307

308
      base =
12✔
309
        if style = data[:style], do: [{:style, style} | Keyword.delete(base, :style)], else: base
12✔
310

311
      base =
12✔
NEW
312
        if penwidth = data[:penwidth],
×
313
          do: [{:penwidth, penwidth} | Keyword.delete(base, :penwidth)],
314
          else: base
12✔
315

316
      base =
12✔
317
        if desc = data[:description] do
×
318
          [{:tooltip, desc} | base]
319
        else
320
          base
12✔
321
        end
322

323
      base
12✔
324
    end
325
  end
326

327
  defp node_label(_id, data) do
328
    data[:label] || ""
12✔
329
  end
330

331
  # ============================================================================
332
  # Edge styling
333
  # ============================================================================
334

335
  defp edge_attributes_fn(deps, cycle_edges) do
336
    fn from, to, edge_id, _weight ->
6✔
337
      meta = Map.get(deps.edge_meta, edge_id, %{})
7✔
338

339
      base = dep_type_attrs(meta[:type] || :uses)
7✔
340

341
      # Highlight cycle edges in red
342
      base =
7✔
343
        if MapSet.member?(cycle_edges, {from, to}) do
2✔
344
          [{:color, "#ef4444"}, {:penwidth, 2.0} | Keyword.drop(base, [:color, :penwidth])]
345
        else
346
          base
5✔
347
        end
348

349
      if label = meta[:label] do
7✔
350
        if label != "" do
7✔
351
          [{:label, label} | base]
352
        else
353
          base
×
354
        end
355
      else
356
        base
×
357
      end
358
    end
359
  end
360

361
  defp dep_type_attrs(:imports), do: [{:style, :dashed}]
×
362
  defp dep_type_attrs(:calls), do: [{:style, :dotted}]
×
363
  defp dep_type_attrs(:inherits), do: [{:penwidth, 2.0}]
×
364
  defp dep_type_attrs(:dev), do: [{:style, :dashed}, {:color, "#9ca3af"}]
×
365
  defp dep_type_attrs(_), do: []
7✔
366

367
  defp edge_label(_), do: ""
7✔
368
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc