1package hcl
2
3import (
4	"bufio"
5	"bytes"
6	"errors"
7	"fmt"
8	"io"
9	"sort"
10
11	wordwrap "github.com/mitchellh/go-wordwrap"
12	"github.com/zclconf/go-cty/cty"
13)
14
15type diagnosticTextWriter struct {
16	files map[string]*File
17	wr    io.Writer
18	width uint
19	color bool
20}
21
22// NewDiagnosticTextWriter creates a DiagnosticWriter that writes diagnostics
23// to the given writer as formatted text.
24//
25// It is designed to produce text appropriate to print in a monospaced font
26// in a terminal of a particular width, or optionally with no width limit.
27//
28// The given width may be zero to disable word-wrapping of the detail text
29// and truncation of source code snippets.
30//
31// If color is set to true, the output will include VT100 escape sequences to
32// color-code the severity indicators. It is suggested to turn this off if
33// the target writer is not a terminal.
34func NewDiagnosticTextWriter(wr io.Writer, files map[string]*File, width uint, color bool) DiagnosticWriter {
35	return &diagnosticTextWriter{
36		files: files,
37		wr:    wr,
38		width: width,
39		color: color,
40	}
41}
42
43func (w *diagnosticTextWriter) WriteDiagnostic(diag *Diagnostic) error {
44	if diag == nil {
45		return errors.New("nil diagnostic")
46	}
47
48	var colorCode, highlightCode, resetCode string
49	if w.color {
50		switch diag.Severity {
51		case DiagError:
52			colorCode = "\x1b[31m"
53		case DiagWarning:
54			colorCode = "\x1b[33m"
55		}
56		resetCode = "\x1b[0m"
57		highlightCode = "\x1b[1;4m"
58	}
59
60	var severityStr string
61	switch diag.Severity {
62	case DiagError:
63		severityStr = "Error"
64	case DiagWarning:
65		severityStr = "Warning"
66	default:
67		// should never happen
68		severityStr = "???????"
69	}
70
71	fmt.Fprintf(w.wr, "%s%s%s: %s\n\n", colorCode, severityStr, resetCode, diag.Summary)
72
73	if diag.Subject != nil {
74		snipRange := *diag.Subject
75		highlightRange := snipRange
76		if diag.Context != nil {
77			// Show enough of the source code to include both the subject
78			// and context ranges, which overlap in all reasonable
79			// situations.
80			snipRange = RangeOver(snipRange, *diag.Context)
81		}
82		// We can't illustrate an empty range, so we'll turn such ranges into
83		// single-character ranges, which might not be totally valid (may point
84		// off the end of a line, or off the end of the file) but are good
85		// enough for the bounds checks we do below.
86		if snipRange.Empty() {
87			snipRange.End.Byte++
88			snipRange.End.Column++
89		}
90		if highlightRange.Empty() {
91			highlightRange.End.Byte++
92			highlightRange.End.Column++
93		}
94
95		file := w.files[diag.Subject.Filename]
96		if file == nil || file.Bytes == nil {
97			fmt.Fprintf(w.wr, "  on %s line %d:\n  (source code not available)\n\n", diag.Subject.Filename, diag.Subject.Start.Line)
98		} else {
99
100			var contextLine string
101			if diag.Subject != nil {
102				contextLine = contextString(file, diag.Subject.Start.Byte)
103				if contextLine != "" {
104					contextLine = ", in " + contextLine
105				}
106			}
107
108			fmt.Fprintf(w.wr, "  on %s line %d%s:\n", diag.Subject.Filename, diag.Subject.Start.Line, contextLine)
109
110			src := file.Bytes
111			sc := NewRangeScanner(src, diag.Subject.Filename, bufio.ScanLines)
112
113			for sc.Scan() {
114				lineRange := sc.Range()
115				if !lineRange.Overlaps(snipRange) {
116					continue
117				}
118
119				beforeRange, highlightedRange, afterRange := lineRange.PartitionAround(highlightRange)
120				if highlightedRange.Empty() {
121					fmt.Fprintf(w.wr, "%4d: %s\n", lineRange.Start.Line, sc.Bytes())
122				} else {
123					before := beforeRange.SliceBytes(src)
124					highlighted := highlightedRange.SliceBytes(src)
125					after := afterRange.SliceBytes(src)
126					fmt.Fprintf(
127						w.wr, "%4d: %s%s%s%s%s\n",
128						lineRange.Start.Line,
129						before,
130						highlightCode, highlighted, resetCode,
131						after,
132					)
133				}
134
135			}
136
137			w.wr.Write([]byte{'\n'})
138		}
139
140		if diag.Expression != nil && diag.EvalContext != nil {
141			// We will attempt to render the values for any variables
142			// referenced in the given expression as additional context, for
143			// situations where the same expression is evaluated multiple
144			// times in different scopes.
145			expr := diag.Expression
146			ctx := diag.EvalContext
147
148			vars := expr.Variables()
149			stmts := make([]string, 0, len(vars))
150			seen := make(map[string]struct{}, len(vars))
151			for _, traversal := range vars {
152				val, diags := traversal.TraverseAbs(ctx)
153				if diags.HasErrors() {
154					// Skip anything that generates errors, since we probably
155					// already have the same error in our diagnostics set
156					// already.
157					continue
158				}
159
160				traversalStr := w.traversalStr(traversal)
161				if _, exists := seen[traversalStr]; exists {
162					continue // don't show duplicates when the same variable is referenced multiple times
163				}
164				switch {
165				case !val.IsKnown():
166					// Can't say anything about this yet, then.
167					continue
168				case val.IsNull():
169					stmts = append(stmts, fmt.Sprintf("%s set to null", traversalStr))
170				default:
171					stmts = append(stmts, fmt.Sprintf("%s as %s", traversalStr, w.valueStr(val)))
172				}
173				seen[traversalStr] = struct{}{}
174			}
175
176			sort.Strings(stmts) // FIXME: Should maybe use a traversal-aware sort that can sort numeric indexes properly?
177			last := len(stmts) - 1
178
179			for i, stmt := range stmts {
180				switch i {
181				case 0:
182					w.wr.Write([]byte{'w', 'i', 't', 'h', ' '})
183				default:
184					w.wr.Write([]byte{' ', ' ', ' ', ' ', ' '})
185				}
186				w.wr.Write([]byte(stmt))
187				switch i {
188				case last:
189					w.wr.Write([]byte{'.', '\n', '\n'})
190				default:
191					w.wr.Write([]byte{',', '\n'})
192				}
193			}
194		}
195	}
196
197	if diag.Detail != "" {
198		detail := diag.Detail
199		if w.width != 0 {
200			detail = wordwrap.WrapString(detail, w.width)
201		}
202		fmt.Fprintf(w.wr, "%s\n\n", detail)
203	}
204
205	return nil
206}
207
208func (w *diagnosticTextWriter) WriteDiagnostics(diags Diagnostics) error {
209	for _, diag := range diags {
210		err := w.WriteDiagnostic(diag)
211		if err != nil {
212			return err
213		}
214	}
215	return nil
216}
217
218func (w *diagnosticTextWriter) traversalStr(traversal Traversal) string {
219	// This is a specialized subset of traversal rendering tailored to
220	// producing helpful contextual messages in diagnostics. It is not
221	// comprehensive nor intended to be used for other purposes.
222
223	var buf bytes.Buffer
224	for _, step := range traversal {
225		switch tStep := step.(type) {
226		case TraverseRoot:
227			buf.WriteString(tStep.Name)
228		case TraverseAttr:
229			buf.WriteByte('.')
230			buf.WriteString(tStep.Name)
231		case TraverseIndex:
232			buf.WriteByte('[')
233			if keyTy := tStep.Key.Type(); keyTy.IsPrimitiveType() {
234				buf.WriteString(w.valueStr(tStep.Key))
235			} else {
236				// We'll just use a placeholder for more complex values,
237				// since otherwise our result could grow ridiculously long.
238				buf.WriteString("...")
239			}
240			buf.WriteByte(']')
241		}
242	}
243	return buf.String()
244}
245
246func (w *diagnosticTextWriter) valueStr(val cty.Value) string {
247	// This is a specialized subset of value rendering tailored to producing
248	// helpful but concise messages in diagnostics. It is not comprehensive
249	// nor intended to be used for other purposes.
250
251	ty := val.Type()
252	switch {
253	case val.IsNull():
254		return "null"
255	case !val.IsKnown():
256		// Should never happen here because we should filter before we get
257		// in here, but we'll do something reasonable rather than panic.
258		return "(not yet known)"
259	case ty == cty.Bool:
260		if val.True() {
261			return "true"
262		}
263		return "false"
264	case ty == cty.Number:
265		bf := val.AsBigFloat()
266		return bf.Text('g', 10)
267	case ty == cty.String:
268		// Go string syntax is not exactly the same as HCL native string syntax,
269		// but we'll accept the minor edge-cases where this is different here
270		// for now, just to get something reasonable here.
271		return fmt.Sprintf("%q", val.AsString())
272	case ty.IsCollectionType() || ty.IsTupleType():
273		l := val.LengthInt()
274		switch l {
275		case 0:
276			return "empty " + ty.FriendlyName()
277		case 1:
278			return ty.FriendlyName() + " with 1 element"
279		default:
280			return fmt.Sprintf("%s with %d elements", ty.FriendlyName(), l)
281		}
282	case ty.IsObjectType():
283		atys := ty.AttributeTypes()
284		l := len(atys)
285		switch l {
286		case 0:
287			return "object with no attributes"
288		case 1:
289			var name string
290			for k := range atys {
291				name = k
292			}
293			return fmt.Sprintf("object with 1 attribute %q", name)
294		default:
295			return fmt.Sprintf("object with %d attributes", l)
296		}
297	default:
298		return ty.FriendlyName()
299	}
300}
301
302func contextString(file *File, offset int) string {
303	type contextStringer interface {
304		ContextString(offset int) string
305	}
306
307	if cser, ok := file.Nav.(contextStringer); ok {
308		return cser.ContextString(offset)
309	}
310	return ""
311}
312