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