diff options
| author | 2023-04-28 01:36:44 +0800 | |
|---|---|---|
| committer | 2023-04-28 01:36:44 +0800 | |
| commit | dd84b9d64fb98746a230cd24233ff50a562c39c9 (patch) | |
| tree | b583261ef00b3afe72ec4d6dacb31e57779a6faf /cli/internal/graphvisualizer | |
| parent | 0b46fcd72ac34382387b2bcf9095233efbcc52f4 (diff) | |
| download | HydroRoll-dd84b9d64fb98746a230cd24233ff50a562c39c9.tar.gz HydroRoll-dd84b9d64fb98746a230cd24233ff50a562c39c9.zip | |
Diffstat (limited to 'cli/internal/graphvisualizer')
| -rw-r--r-- | cli/internal/graphvisualizer/graphvisualizer.go | 205 |
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 +} |
