1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
|
package graphvisualizer
import (
"fmt"
"io"
"math/rand"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/fatih/color"
"github.com/mitchellh/cli"
"github.com/pyr-sh/dag"
"github.com/vercel/turbo/cli/internal/turbopath"
"github.com/vercel/turbo/cli/internal/ui"
"github.com/vercel/turbo/cli/internal/util"
"github.com/vercel/turbo/cli/internal/util/browser"
)
// GraphVisualizer requirements
type GraphVisualizer struct {
repoRoot turbopath.AbsoluteSystemPath
ui cli.Ui
TaskGraph *dag.AcyclicGraph
}
// hasGraphViz checks for the presence of https://graphviz.org/
func hasGraphViz() bool {
err := exec.Command("dot", "-V").Run()
return err == nil
}
func getRandChar() string {
i := rand.Intn(25) + 65
return string(rune(i))
}
func getRandID() string {
return getRandChar() + getRandChar() + getRandChar() + getRandChar()
}
// New creates an instance of ColorCache with helpers for adding colors to task outputs
func New(repoRoot turbopath.AbsoluteSystemPath, ui cli.Ui, TaskGraph *dag.AcyclicGraph) *GraphVisualizer {
return &GraphVisualizer{
repoRoot: repoRoot,
ui: ui,
TaskGraph: TaskGraph,
}
}
// Converts the TaskGraph dag into a string
func (g *GraphVisualizer) generateDotString() string {
return string(g.TaskGraph.Dot(&dag.DotOpts{
Verbose: true,
DrawCycles: true,
}))
}
// Outputs a warning when a file was requested, but graphviz is not available
func (g *GraphVisualizer) graphVizWarnUI() {
g.ui.Warn(color.New(color.FgYellow, color.Bold, color.ReverseVideo).Sprint(" WARNING ") + color.YellowString(" `turbo` uses Graphviz to generate an image of your\ngraph, but Graphviz isn't installed on this machine.\n\nYou can download Graphviz from https://graphviz.org/download.\n\nIn the meantime, you can use this string output with an\nonline Dot graph viewer."))
}
// RenderDotGraph renders a dot graph string for the current TaskGraph
func (g *GraphVisualizer) RenderDotGraph() {
g.ui.Output("")
g.ui.Output(g.generateDotString())
}
type nameCache map[string]string
func (nc nameCache) getName(in string) string {
if existing, ok := nc[in]; ok {
return existing
}
newName := getRandID()
nc[in] = newName
return newName
}
type sortableEdge dag.Edge
type sortableEdges []sortableEdge
// methods mostly copied from marshalEdges in the dag library
func (e sortableEdges) Less(i, j int) bool {
iSrc := dag.VertexName(e[i].Source())
jSrc := dag.VertexName(e[j].Source())
if iSrc < jSrc {
return true
} else if iSrc > jSrc {
return false
}
return dag.VertexName(e[i].Target()) < dag.VertexName(e[j].Target())
}
func (e sortableEdges) Len() int { return len(e) }
func (e sortableEdges) Swap(i, j int) { e[i], e[j] = e[j], e[i] }
func (g *GraphVisualizer) generateMermaid(out io.StringWriter) error {
if _, err := out.WriteString("graph TD\n"); err != nil {
return err
}
cache := make(nameCache)
// cast edges to our custom type so we can sort them
// this allows us to generate the same graph every time
var edges sortableEdges
for _, edge := range g.TaskGraph.Edges() {
edges = append(edges, sortableEdge(edge))
}
sort.Sort(edges)
for _, edge := range edges {
left := dag.VertexName(edge.Source())
right := dag.VertexName(edge.Target())
leftName := cache.getName(left)
rightName := cache.getName(right)
if _, err := out.WriteString(fmt.Sprintf("\t%v(\"%v\") --> %v(\"%v\")\n", leftName, left, rightName, right)); err != nil {
return err
}
}
return nil
}
// GenerateGraphFile saves a visualization of the TaskGraph to a file (or renders a DotGraph as a fallback))
func (g *GraphVisualizer) GenerateGraphFile(outputName string) error {
outputFilename := g.repoRoot.UntypedJoin(outputName)
ext := outputFilename.Ext()
// use .jpg as default extension if none is provided
if ext == "" {
ext = ".jpg"
outputFilename = g.repoRoot.UntypedJoin(outputName + ext)
}
if ext == ".mermaid" {
f, err := outputFilename.Create()
if err != nil {
return fmt.Errorf("error creating file: %w", err)
}
defer util.CloseAndIgnoreError(f)
if err := g.generateMermaid(f); err != nil {
return err
}
g.ui.Output(fmt.Sprintf("✔ Generated task graph in %s", ui.Bold(outputFilename.ToString())))
return nil
}
graphString := g.generateDotString()
if ext == ".html" {
f, err := outputFilename.Create()
if err != nil {
return fmt.Errorf("error creating file: %w", err)
}
defer f.Close() //nolint errcheck
_, writeErr1 := f.WriteString(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Graph</title>
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/viz.js@2.1.2-pre.1/viz.js"></script>
<script src="https://cdn.jsdelivr.net/npm/viz.js@2.1.2-pre.1/full.render.js"></script>
<script>`)
if writeErr1 != nil {
return fmt.Errorf("error writing graph contents: %w", writeErr1)
}
_, writeErr2 := f.WriteString("const s = `" + graphString + "`.replace(/\\_\\_\\_ROOT\\_\\_\\_/g, \"Root\").replace(/\\[root\\]/g, \"\");new Viz().renderSVGElement(s).then(el => document.body.appendChild(el)).catch(e => console.error(e));")
if writeErr2 != nil {
return fmt.Errorf("error creating file: %w", writeErr2)
}
_, writeErr3 := f.WriteString(`
</script>
</body>
</html>`)
if writeErr3 != nil {
return fmt.Errorf("error creating file: %w", writeErr3)
}
g.ui.Output("")
g.ui.Output(fmt.Sprintf("✔ Generated task graph in %s", ui.Bold(outputFilename.ToString())))
if ui.IsTTY {
if err := browser.OpenBrowser(outputFilename.ToString()); err != nil {
g.ui.Warn(color.New(color.FgYellow, color.Bold, color.ReverseVideo).Sprintf("failed to open browser. Please navigate to file://%v", filepath.ToSlash(outputFilename.ToString())))
}
}
return nil
}
hasDot := hasGraphViz()
if hasDot {
dotArgs := []string{"-T" + ext[1:], "-o", outputFilename.ToString()}
cmd := exec.Command("dot", dotArgs...)
cmd.Stdin = strings.NewReader(graphString)
if err := cmd.Run(); err != nil {
return fmt.Errorf("could not generate task graphfile %v: %w", outputFilename, err)
}
g.ui.Output("")
g.ui.Output(fmt.Sprintf("✔ Generated task graph in %s", ui.Bold(outputFilename.ToString())))
} else {
g.ui.Output("")
// User requested a file, but we're falling back to console here so warn about installing graphViz correctly
g.graphVizWarnUI()
g.RenderDotGraph()
}
return nil
}
|