xref: /aosp_15_r20/external/go-cmp/cmp/report_text.go (revision 88d15eac089d7f20c739ff1001d56b91872b21a1)
1// Copyright 2019, The Go Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4
5package cmp
6
7import (
8	"bytes"
9	"fmt"
10	"math/rand"
11	"strings"
12	"time"
13	"unicode/utf8"
14
15	"github.com/google/go-cmp/cmp/internal/flags"
16)
17
18var randBool = rand.New(rand.NewSource(time.Now().Unix())).Intn(2) == 0
19
20const maxColumnLength = 80
21
22type indentMode int
23
24func (n indentMode) appendIndent(b []byte, d diffMode) []byte {
25	// The output of Diff is documented as being unstable to provide future
26	// flexibility in changing the output for more humanly readable reports.
27	// This logic intentionally introduces instability to the exact output
28	// so that users can detect accidental reliance on stability early on,
29	// rather than much later when an actual change to the format occurs.
30	if flags.Deterministic || randBool {
31		// Use regular spaces (U+0020).
32		switch d {
33		case diffUnknown, diffIdentical:
34			b = append(b, "  "...)
35		case diffRemoved:
36			b = append(b, "- "...)
37		case diffInserted:
38			b = append(b, "+ "...)
39		}
40	} else {
41		// Use non-breaking spaces (U+00a0).
42		switch d {
43		case diffUnknown, diffIdentical:
44			b = append(b, "  "...)
45		case diffRemoved:
46			b = append(b, "- "...)
47		case diffInserted:
48			b = append(b, "+ "...)
49		}
50	}
51	return repeatCount(n).appendChar(b, '\t')
52}
53
54type repeatCount int
55
56func (n repeatCount) appendChar(b []byte, c byte) []byte {
57	for ; n > 0; n-- {
58		b = append(b, c)
59	}
60	return b
61}
62
63// textNode is a simplified tree-based representation of structured text.
64// Possible node types are textWrap, textList, or textLine.
65type textNode interface {
66	// Len reports the length in bytes of a single-line version of the tree.
67	// Nested textRecord.Diff and textRecord.Comment fields are ignored.
68	Len() int
69	// Equal reports whether the two trees are structurally identical.
70	// Nested textRecord.Diff and textRecord.Comment fields are compared.
71	Equal(textNode) bool
72	// String returns the string representation of the text tree.
73	// It is not guaranteed that len(x.String()) == x.Len(),
74	// nor that x.String() == y.String() implies that x.Equal(y).
75	String() string
76
77	// formatCompactTo formats the contents of the tree as a single-line string
78	// to the provided buffer. Any nested textRecord.Diff and textRecord.Comment
79	// fields are ignored.
80	//
81	// However, not all nodes in the tree should be collapsed as a single-line.
82	// If a node can be collapsed as a single-line, it is replaced by a textLine
83	// node. Since the top-level node cannot replace itself, this also returns
84	// the current node itself.
85	//
86	// This does not mutate the receiver.
87	formatCompactTo([]byte, diffMode) ([]byte, textNode)
88	// formatExpandedTo formats the contents of the tree as a multi-line string
89	// to the provided buffer. In order for column alignment to operate well,
90	// formatCompactTo must be called before calling formatExpandedTo.
91	formatExpandedTo([]byte, diffMode, indentMode) []byte
92}
93
94// textWrap is a wrapper that concatenates a prefix and/or a suffix
95// to the underlying node.
96type textWrap struct {
97	Prefix   string      // e.g., "bytes.Buffer{"
98	Value    textNode    // textWrap | textList | textLine
99	Suffix   string      // e.g., "}"
100	Metadata interface{} // arbitrary metadata; has no effect on formatting
101}
102
103func (s *textWrap) Len() int {
104	return len(s.Prefix) + s.Value.Len() + len(s.Suffix)
105}
106func (s1 *textWrap) Equal(s2 textNode) bool {
107	if s2, ok := s2.(*textWrap); ok {
108		return s1.Prefix == s2.Prefix && s1.Value.Equal(s2.Value) && s1.Suffix == s2.Suffix
109	}
110	return false
111}
112func (s *textWrap) String() string {
113	var d diffMode
114	var n indentMode
115	_, s2 := s.formatCompactTo(nil, d)
116	b := n.appendIndent(nil, d)      // Leading indent
117	b = s2.formatExpandedTo(b, d, n) // Main body
118	b = append(b, '\n')              // Trailing newline
119	return string(b)
120}
121func (s *textWrap) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) {
122	n0 := len(b) // Original buffer length
123	b = append(b, s.Prefix...)
124	b, s.Value = s.Value.formatCompactTo(b, d)
125	b = append(b, s.Suffix...)
126	if _, ok := s.Value.(textLine); ok {
127		return b, textLine(b[n0:])
128	}
129	return b, s
130}
131func (s *textWrap) formatExpandedTo(b []byte, d diffMode, n indentMode) []byte {
132	b = append(b, s.Prefix...)
133	b = s.Value.formatExpandedTo(b, d, n)
134	b = append(b, s.Suffix...)
135	return b
136}
137
138// textList is a comma-separated list of textWrap or textLine nodes.
139// The list may be formatted as multi-lines or single-line at the discretion
140// of the textList.formatCompactTo method.
141type textList []textRecord
142type textRecord struct {
143	Diff       diffMode     // e.g., 0 or '-' or '+'
144	Key        string       // e.g., "MyField"
145	Value      textNode     // textWrap | textLine
146	ElideComma bool         // avoid trailing comma
147	Comment    fmt.Stringer // e.g., "6 identical fields"
148}
149
150// AppendEllipsis appends a new ellipsis node to the list if none already
151// exists at the end. If cs is non-zero it coalesces the statistics with the
152// previous diffStats.
153func (s *textList) AppendEllipsis(ds diffStats) {
154	hasStats := !ds.IsZero()
155	if len(*s) == 0 || !(*s)[len(*s)-1].Value.Equal(textEllipsis) {
156		if hasStats {
157			*s = append(*s, textRecord{Value: textEllipsis, ElideComma: true, Comment: ds})
158		} else {
159			*s = append(*s, textRecord{Value: textEllipsis, ElideComma: true})
160		}
161		return
162	}
163	if hasStats {
164		(*s)[len(*s)-1].Comment = (*s)[len(*s)-1].Comment.(diffStats).Append(ds)
165	}
166}
167
168func (s textList) Len() (n int) {
169	for i, r := range s {
170		n += len(r.Key)
171		if r.Key != "" {
172			n += len(": ")
173		}
174		n += r.Value.Len()
175		if i < len(s)-1 {
176			n += len(", ")
177		}
178	}
179	return n
180}
181
182func (s1 textList) Equal(s2 textNode) bool {
183	if s2, ok := s2.(textList); ok {
184		if len(s1) != len(s2) {
185			return false
186		}
187		for i := range s1 {
188			r1, r2 := s1[i], s2[i]
189			if !(r1.Diff == r2.Diff && r1.Key == r2.Key && r1.Value.Equal(r2.Value) && r1.Comment == r2.Comment) {
190				return false
191			}
192		}
193		return true
194	}
195	return false
196}
197
198func (s textList) String() string {
199	return (&textWrap{Prefix: "{", Value: s, Suffix: "}"}).String()
200}
201
202func (s textList) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) {
203	s = append(textList(nil), s...) // Avoid mutating original
204
205	// Determine whether we can collapse this list as a single line.
206	n0 := len(b) // Original buffer length
207	var multiLine bool
208	for i, r := range s {
209		if r.Diff == diffInserted || r.Diff == diffRemoved {
210			multiLine = true
211		}
212		b = append(b, r.Key...)
213		if r.Key != "" {
214			b = append(b, ": "...)
215		}
216		b, s[i].Value = r.Value.formatCompactTo(b, d|r.Diff)
217		if _, ok := s[i].Value.(textLine); !ok {
218			multiLine = true
219		}
220		if r.Comment != nil {
221			multiLine = true
222		}
223		if i < len(s)-1 {
224			b = append(b, ", "...)
225		}
226	}
227	// Force multi-lined output when printing a removed/inserted node that
228	// is sufficiently long.
229	if (d == diffInserted || d == diffRemoved) && len(b[n0:]) > maxColumnLength {
230		multiLine = true
231	}
232	if !multiLine {
233		return b, textLine(b[n0:])
234	}
235	return b, s
236}
237
238func (s textList) formatExpandedTo(b []byte, d diffMode, n indentMode) []byte {
239	alignKeyLens := s.alignLens(
240		func(r textRecord) bool {
241			_, isLine := r.Value.(textLine)
242			return r.Key == "" || !isLine
243		},
244		func(r textRecord) int { return utf8.RuneCountInString(r.Key) },
245	)
246	alignValueLens := s.alignLens(
247		func(r textRecord) bool {
248			_, isLine := r.Value.(textLine)
249			return !isLine || r.Value.Equal(textEllipsis) || r.Comment == nil
250		},
251		func(r textRecord) int { return utf8.RuneCount(r.Value.(textLine)) },
252	)
253
254	// Format lists of simple lists in a batched form.
255	// If the list is sequence of only textLine values,
256	// then batch multiple values on a single line.
257	var isSimple bool
258	for _, r := range s {
259		_, isLine := r.Value.(textLine)
260		isSimple = r.Diff == 0 && r.Key == "" && isLine && r.Comment == nil
261		if !isSimple {
262			break
263		}
264	}
265	if isSimple {
266		n++
267		var batch []byte
268		emitBatch := func() {
269			if len(batch) > 0 {
270				b = n.appendIndent(append(b, '\n'), d)
271				b = append(b, bytes.TrimRight(batch, " ")...)
272				batch = batch[:0]
273			}
274		}
275		for _, r := range s {
276			line := r.Value.(textLine)
277			if len(batch)+len(line)+len(", ") > maxColumnLength {
278				emitBatch()
279			}
280			batch = append(batch, line...)
281			batch = append(batch, ", "...)
282		}
283		emitBatch()
284		n--
285		return n.appendIndent(append(b, '\n'), d)
286	}
287
288	// Format the list as a multi-lined output.
289	n++
290	for i, r := range s {
291		b = n.appendIndent(append(b, '\n'), d|r.Diff)
292		if r.Key != "" {
293			b = append(b, r.Key+": "...)
294		}
295		b = alignKeyLens[i].appendChar(b, ' ')
296
297		b = r.Value.formatExpandedTo(b, d|r.Diff, n)
298		if !r.ElideComma {
299			b = append(b, ',')
300		}
301		b = alignValueLens[i].appendChar(b, ' ')
302
303		if r.Comment != nil {
304			b = append(b, " // "+r.Comment.String()...)
305		}
306	}
307	n--
308
309	return n.appendIndent(append(b, '\n'), d)
310}
311
312func (s textList) alignLens(
313	skipFunc func(textRecord) bool,
314	lenFunc func(textRecord) int,
315) []repeatCount {
316	var startIdx, endIdx, maxLen int
317	lens := make([]repeatCount, len(s))
318	for i, r := range s {
319		if skipFunc(r) {
320			for j := startIdx; j < endIdx && j < len(s); j++ {
321				lens[j] = repeatCount(maxLen - lenFunc(s[j]))
322			}
323			startIdx, endIdx, maxLen = i+1, i+1, 0
324		} else {
325			if maxLen < lenFunc(r) {
326				maxLen = lenFunc(r)
327			}
328			endIdx = i + 1
329		}
330	}
331	for j := startIdx; j < endIdx && j < len(s); j++ {
332		lens[j] = repeatCount(maxLen - lenFunc(s[j]))
333	}
334	return lens
335}
336
337// textLine is a single-line segment of text and is always a leaf node
338// in the textNode tree.
339type textLine []byte
340
341var (
342	textNil      = textLine("nil")
343	textEllipsis = textLine("...")
344)
345
346func (s textLine) Len() int {
347	return len(s)
348}
349func (s1 textLine) Equal(s2 textNode) bool {
350	if s2, ok := s2.(textLine); ok {
351		return bytes.Equal([]byte(s1), []byte(s2))
352	}
353	return false
354}
355func (s textLine) String() string {
356	return string(s)
357}
358func (s textLine) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) {
359	return append(b, s...), s
360}
361func (s textLine) formatExpandedTo(b []byte, _ diffMode, _ indentMode) []byte {
362	return append(b, s...)
363}
364
365type diffStats struct {
366	Name         string
367	NumIgnored   int
368	NumIdentical int
369	NumRemoved   int
370	NumInserted  int
371	NumModified  int
372}
373
374func (s diffStats) IsZero() bool {
375	s.Name = ""
376	return s == diffStats{}
377}
378
379func (s diffStats) NumDiff() int {
380	return s.NumRemoved + s.NumInserted + s.NumModified
381}
382
383func (s diffStats) Append(ds diffStats) diffStats {
384	assert(s.Name == ds.Name)
385	s.NumIgnored += ds.NumIgnored
386	s.NumIdentical += ds.NumIdentical
387	s.NumRemoved += ds.NumRemoved
388	s.NumInserted += ds.NumInserted
389	s.NumModified += ds.NumModified
390	return s
391}
392
393// String prints a humanly-readable summary of coalesced records.
394//
395// Example:
396//
397//	diffStats{Name: "Field", NumIgnored: 5}.String() => "5 ignored fields"
398func (s diffStats) String() string {
399	var ss []string
400	var sum int
401	labels := [...]string{"ignored", "identical", "removed", "inserted", "modified"}
402	counts := [...]int{s.NumIgnored, s.NumIdentical, s.NumRemoved, s.NumInserted, s.NumModified}
403	for i, n := range counts {
404		if n > 0 {
405			ss = append(ss, fmt.Sprintf("%d %v", n, labels[i]))
406		}
407		sum += n
408	}
409
410	// Pluralize the name (adjusting for some obscure English grammar rules).
411	name := s.Name
412	if sum > 1 {
413		name += "s"
414		if strings.HasSuffix(name, "ys") {
415			name = name[:len(name)-2] + "ies" // e.g., "entrys" => "entries"
416		}
417	}
418
419	// Format the list according to English grammar (with Oxford comma).
420	switch n := len(ss); n {
421	case 0:
422		return ""
423	case 1, 2:
424		return strings.Join(ss, " and ") + " " + name
425	default:
426		return strings.Join(ss[:n-1], ", ") + ", and " + ss[n-1] + " " + name
427	}
428}
429
430type commentString string
431
432func (s commentString) String() string { return string(s) }
433