aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/cli/internal/graphvisualizer/graphvisualizer.go
diff options
context:
space:
mode:
Diffstat (limited to 'cli/internal/graphvisualizer/graphvisualizer.go')
-rw-r--r--cli/internal/graphvisualizer/graphvisualizer.go205
1 files changed, 205 insertions, 0 deletions
diff --git a/cli/internal/graphvisualizer/graphvisualizer.go b/cli/internal/graphvisualizer/graphvisualizer.go
new file mode 100644
index 0000000..4e134b2
--- /dev/null
+++ b/cli/internal/graphvisualizer/graphvisualizer.go
@@ -0,0 +1,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
+}