Skip to content
45 changes: 34 additions & 11 deletions internal/graph/dotgraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func (b *builder) addLegend() {
}
title := labels[0]
fmt.Fprintf(b, `subgraph cluster_L { "%s" [shape=box fontsize=16`, title)
fmt.Fprintf(b, ` label="%s\l"`, strings.Join(escapeForDot(labels), `\l`))
fmt.Fprintf(b, ` label="%s\l"`, strings.Join(escapeAllForDot(labels, leftJustify), `\l`))
if b.config.LegendURL != "" {
fmt.Fprintf(b, ` URL="%s" target="_blank"`, b.config.LegendURL)
}
Expand Down Expand Up @@ -187,7 +187,7 @@ func (b *builder) addNode(node *Node, nodeID int, maxFlat float64) {

// Create DOT attribute for node.
attr := fmt.Sprintf(`label="%s" id="node%d" fontsize=%d shape=%s tooltip="%s (%s)" color="%s" fillcolor="%s"`,
label, nodeID, fontSize, shape, node.Info.PrintableName(), cumValue,
label, nodeID, fontSize, shape, escapeForDot(node.Info.PrintableName(), centerJustify), cumValue,
dotColor(float64(node.CumValue())/float64(abs64(b.config.Total)), false),
dotColor(float64(node.CumValue())/float64(abs64(b.config.Total)), true))

Expand Down Expand Up @@ -247,7 +247,7 @@ func (b *builder) addNodelets(node *Node, nodeID int) bool {
continue
}
weight := b.config.FormatValue(w)
nodelets += fmt.Sprintf(`N%d_%d [label = "%s" id="N%d_%d" fontsize=8 shape=box3d tooltip="%s"]`+"\n", nodeID, i, t.Name, nodeID, i, weight)
nodelets += fmt.Sprintf(`N%d_%d [label = "%s" id="N%d_%d" fontsize=8 shape=box3d tooltip="%s"]`+"\n", nodeID, i, escapeForDot(t.Name, centerJustify), nodeID, i, weight)
nodelets += fmt.Sprintf(`N%d -> N%d_%d [label=" %s" weight=100 tooltip="%s" labeltooltip="%s"]`+"\n", nodeID, nodeID, i, weight, weight, weight)
if nts := lnts[t.Name]; nts != nil {
nodelets += b.numericNodelets(nts, maxNodelets, flatTags, fmt.Sprintf(`N%d_%d`, nodeID, i))
Expand All @@ -274,7 +274,7 @@ func (b *builder) numericNodelets(nts []*Tag, maxNumNodelets int, flatTags bool,
}
if w != 0 {
weight := b.config.FormatValue(w)
nodelets += fmt.Sprintf(`N%s_%d [label = "%s" id="N%s_%d" fontsize=8 shape=box3d tooltip="%s"]`+"\n", source, j, t.Name, source, j, weight)
nodelets += fmt.Sprintf(`N%s_%d [label = "%s" id="N%s_%d" fontsize=8 shape=box3d tooltip="%s"]`+"\n", source, j, escapeForDot(t.Name, centerJustify), source, j, weight)
nodelets += fmt.Sprintf(`%s -> N%s_%d [label=" %s" weight=100 tooltip="%s" labeltooltip="%s"%s]`+"\n", source, source, j, weight, weight, weight, attr)
}
}
Expand Down Expand Up @@ -305,7 +305,8 @@ func (b *builder) addEdge(edge *Edge, from, to int, hasNodelets bool) {
arrow = "..."
}
tooltip := fmt.Sprintf(`"%s %s %s (%s)"`,
edge.Src.Info.PrintableName(), arrow, edge.Dest.Info.PrintableName(), w)
escapeForDot(edge.Src.Info.PrintableName(), centerJustify), arrow,
escapeForDot(edge.Dest.Info.PrintableName(), centerJustify), w)
attr = fmt.Sprintf(`%s tooltip=%s labeltooltip=%s`, attr, tooltip, tooltip)

if edge.Residual {
Expand Down Expand Up @@ -382,7 +383,7 @@ func dotColor(score float64, isBackground bool) string {

func multilinePrintableName(info *NodeInfo) string {
infoCopy := *info
infoCopy.Name = ShortenFunctionName(infoCopy.Name)
infoCopy.Name = escapeForDot(ShortenFunctionName(infoCopy.Name), centerJustify)
infoCopy.Name = strings.Replace(infoCopy.Name, "::", `\n`, -1)
infoCopy.Name = strings.Replace(infoCopy.Name, ".", `\n`, -1)
if infoCopy.File != "" {
Expand Down Expand Up @@ -473,13 +474,35 @@ func min64(a, b int64) int64 {
return b
}

// escapeForDot escapes double quotes and backslashes, and replaces Graphviz's
// "center" character (\n) with a left-justified character.
// See https://graphviz.org/doc/info/attrs.html#k:escString for more info.
func escapeForDot(in []string) []string {
type dotJustifyType int64

const (
leftJustify dotJustifyType = 0
centerJustify dotJustifyType = 1
rightJustify dotJustifyType = 2
)

// Applies escapeForDot to all strings in the given slice.
func escapeAllForDot(in []string, justify dotJustifyType) []string {
var out = make([]string, len(in))
for i := range in {
out[i] = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(in[i], `\`, `\\`), `"`, `\"`), "\n", `\l`)
out[i] = escapeForDot(in[i], justify)
}
return out
}

// escapeForDot escapes double quotes and backslashes, and replaces Graphviz's
// "center" character (\n) with a left-justified character.
// See https://graphviz.org/doc/info/attrs.html#k:escString for more info.
func escapeForDot(str string, justify dotJustifyType) string {
var newline string
switch justify {
case leftJustify:
newline = `\l`
case centerJustify:
newline = `\n`
case rightJustify:
newline = `\r`
}
return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(str, `\`, `\\`), `"`, `\"`), "\n", newline)
}
47 changes: 45 additions & 2 deletions internal/graph/dotgraph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,30 @@ func TestComposeWithStandardGraphAndURL(t *testing.T) {
compareGraphs(t, buf.Bytes(), "compose6.dot")
}

func TestComposeWithNamesThatNeedEscaping(t *testing.T) {
g := baseGraph()
a, c := baseAttrsAndConfig()

// Change node names to have `"` in them, which need to be escaped for dot.
g.Nodes[0].Info = NodeInfo{Name: "var\"src\""}
g.Nodes[1].Info = NodeInfo{Name: "var\"#dest#\""}

// Add tag to Node 1 with `"` in name.
g.Nodes[0].LabelTags["a"] = &Tag{
Name: "var\"tag1\"",
Cum: 10,
Flat: 10,
}

// Set edge to be Residual.
g.Nodes[0].Out[g.Nodes[1]].Residual = true

var buf bytes.Buffer
ComposeDot(&buf, g, a, c)

compareGraphs(t, buf.Bytes(), "compose7.dot")
}

func baseGraph() *Graph {
src := &Node{
Info: NodeInfo{Name: "src"},
Expand Down Expand Up @@ -359,8 +383,27 @@ func TestEscapeForDot(t *testing.T) {
},
} {
t.Run(tc.desc, func(t *testing.T) {
if got := escapeForDot(tc.input); !reflect.DeepEqual(got, tc.want) {
t.Errorf("escapeForDot(%s) = %s, want %s", tc.input, got, tc.want)
if got := escapeAllForDot(tc.input, leftJustify); !reflect.DeepEqual(got, tc.want) {
t.Errorf("escapeAllForDot(%s) = %s, want %s", tc.input, got, tc.want)
}
})
}

// Test the different options for justifying text newlines in Dot
for _, justify := range []dotJustifyType{leftJustify, centerJustify, rightJustify} {
t.Run("Dot newline justification", func(t *testing.T) {
input := []string{"Line 1\nLine 2"}
var want []string
switch justify {
case leftJustify:
want = []string{`Line 1\lLine 2`}
case centerJustify:
want = []string{`Line 1\nLine 2`}
case rightJustify:
want = []string{`Line 1\rLine 2`}
}
if got := escapeAllForDot(input, justify); !reflect.DeepEqual(got, want) {
t.Errorf("escapeAllForDot(%s) = %s, want %s", input, got, want)
}
})
}
Expand Down
2 changes: 1 addition & 1 deletion internal/graph/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,7 @@ func joinLabels(s *profile.Sample) string {
}
}
sort.Strings(labels)
return strings.Join(labels, `\n`)
return strings.Join(labels, "\n") // This will be escaped downstream if needed.
}

// isNegative returns true if the node is considered as "negative" for the
Expand Down
9 changes: 9 additions & 0 deletions internal/graph/testdata/compose7.dot
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
digraph "testtitle" {
node [style=filled fillcolor="#f8f8f8"]
subgraph cluster_L { "label1" [shape=box fontsize=16 label="label1\llabel2\llabel3: \"foo\"\l" tooltip="testtitle"] }
N1 [label="var\"src\"\n10 (10.00%)\nof 25 (25.00%)" id="node1" fontsize=22 shape=box tooltip="var\"src\" (25)" color="#b23c00" fillcolor="#edddd5"]
N1_0 [label = "var\"tag1\"" id="N1_0" fontsize=8 shape=box3d tooltip="10"]
N1 -> N1_0 [label=" 10" weight=100 tooltip="10" labeltooltip="10"]
N2 [label="var\"#dest#\"\n15 (15.00%)\nof 25 (25.00%)" id="node2" fontsize=24 shape=box tooltip="var\"#dest#\" (25)" color="#b23c00" fillcolor="#edddd5"]
N1 -> N2 [label=" 10" weight=11 color="#b28559" tooltip="var\"src\" ... var\"#dest#\" (10)" labeltooltip="var\"src\" ... var\"#dest#\" (10)" style="dotted" minlen=2]
}