github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/command/format/diagnostic.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: MPL-2.0 3 4 package format 5 6 import ( 7 "bufio" 8 "bytes" 9 "fmt" 10 "sort" 11 "strings" 12 13 viewsjson "github.com/terramate-io/tf/command/views/json" 14 "github.com/terramate-io/tf/tfdiags" 15 16 "github.com/mitchellh/colorstring" 17 wordwrap "github.com/mitchellh/go-wordwrap" 18 ) 19 20 var disabledColorize = &colorstring.Colorize{ 21 Colors: colorstring.DefaultColors, 22 Disable: true, 23 } 24 25 // Diagnostic formats a single diagnostic message. 26 // 27 // The width argument specifies at what column the diagnostic messages will 28 // be wrapped. If set to zero, messages will not be wrapped by this function 29 // at all. Although the long-form text parts of the message are wrapped, 30 // not all aspects of the message are guaranteed to fit within the specified 31 // terminal width. 32 func Diagnostic(diag tfdiags.Diagnostic, sources map[string][]byte, color *colorstring.Colorize, width int) string { 33 return DiagnosticFromJSON(viewsjson.NewDiagnostic(diag, sources), color, width) 34 } 35 36 func DiagnosticFromJSON(diag *viewsjson.Diagnostic, color *colorstring.Colorize, width int) string { 37 if diag == nil { 38 // No good reason to pass a nil diagnostic in here... 39 return "" 40 } 41 42 var buf bytes.Buffer 43 44 // these leftRule* variables are markers for the beginning of the lines 45 // containing the diagnostic that are intended to help sighted users 46 // better understand the information hierarchy when diagnostics appear 47 // alongside other information or alongside other diagnostics. 48 // 49 // Without this, it seems (based on folks sharing incomplete messages when 50 // asking questions, or including extra content that's not part of the 51 // diagnostic) that some readers have trouble easily identifying which 52 // text belongs to the diagnostic and which does not. 53 var leftRuleLine, leftRuleStart, leftRuleEnd string 54 var leftRuleWidth int // in visual character cells 55 56 switch diag.Severity { 57 case viewsjson.DiagnosticSeverityError: 58 buf.WriteString(color.Color("[bold][red]Error: [reset]")) 59 leftRuleLine = color.Color("[red]│[reset] ") 60 leftRuleStart = color.Color("[red]╷[reset]") 61 leftRuleEnd = color.Color("[red]╵[reset]") 62 leftRuleWidth = 2 63 case viewsjson.DiagnosticSeverityWarning: 64 buf.WriteString(color.Color("[bold][yellow]Warning: [reset]")) 65 leftRuleLine = color.Color("[yellow]│[reset] ") 66 leftRuleStart = color.Color("[yellow]╷[reset]") 67 leftRuleEnd = color.Color("[yellow]╵[reset]") 68 leftRuleWidth = 2 69 default: 70 // Clear out any coloring that might be applied by Terraform's UI helper, 71 // so our result is not context-sensitive. 72 buf.WriteString(color.Color("\n[reset]")) 73 } 74 75 // We don't wrap the summary, since we expect it to be terse, and since 76 // this is where we put the text of a native Go error it may not always 77 // be pure text that lends itself well to word-wrapping. 78 fmt.Fprintf(&buf, color.Color("[bold]%s[reset]\n\n"), diag.Summary) 79 80 appendSourceSnippets(&buf, diag, color) 81 82 if diag.Detail != "" { 83 paraWidth := width - leftRuleWidth - 1 // leave room for the left rule 84 if paraWidth > 0 { 85 lines := strings.Split(diag.Detail, "\n") 86 for _, line := range lines { 87 if !strings.HasPrefix(line, " ") { 88 line = wordwrap.WrapString(line, uint(paraWidth)) 89 } 90 fmt.Fprintf(&buf, "%s\n", line) 91 } 92 } else { 93 fmt.Fprintf(&buf, "%s\n", diag.Detail) 94 } 95 } 96 97 // Before we return, we'll finally add the left rule prefixes to each 98 // line so that the overall message is visually delimited from what's 99 // around it. We'll do that by scanning over what we already generated 100 // and adding the prefix for each line. 101 var ruleBuf strings.Builder 102 sc := bufio.NewScanner(&buf) 103 ruleBuf.WriteString(leftRuleStart) 104 ruleBuf.WriteByte('\n') 105 for sc.Scan() { 106 line := sc.Text() 107 prefix := leftRuleLine 108 if line == "" { 109 // Don't print the space after the line if there would be nothing 110 // after it anyway. 111 prefix = strings.TrimSpace(prefix) 112 } 113 ruleBuf.WriteString(prefix) 114 ruleBuf.WriteString(line) 115 ruleBuf.WriteByte('\n') 116 } 117 ruleBuf.WriteString(leftRuleEnd) 118 ruleBuf.WriteByte('\n') 119 120 return ruleBuf.String() 121 } 122 123 // DiagnosticPlain is an alternative to Diagnostic which minimises the use of 124 // virtual terminal formatting sequences. 125 // 126 // It is intended for use in automation and other contexts in which diagnostic 127 // messages are parsed from the Terraform output. 128 func DiagnosticPlain(diag tfdiags.Diagnostic, sources map[string][]byte, width int) string { 129 return DiagnosticPlainFromJSON(viewsjson.NewDiagnostic(diag, sources), width) 130 } 131 132 func DiagnosticPlainFromJSON(diag *viewsjson.Diagnostic, width int) string { 133 if diag == nil { 134 // No good reason to pass a nil diagnostic in here... 135 return "" 136 } 137 138 var buf bytes.Buffer 139 140 switch diag.Severity { 141 case viewsjson.DiagnosticSeverityError: 142 buf.WriteString("\nError: ") 143 case viewsjson.DiagnosticSeverityWarning: 144 buf.WriteString("\nWarning: ") 145 default: 146 buf.WriteString("\n") 147 } 148 149 // We don't wrap the summary, since we expect it to be terse, and since 150 // this is where we put the text of a native Go error it may not always 151 // be pure text that lends itself well to word-wrapping. 152 fmt.Fprintf(&buf, "%s\n\n", diag.Summary) 153 154 appendSourceSnippets(&buf, diag, disabledColorize) 155 156 if diag.Detail != "" { 157 if width > 1 { 158 lines := strings.Split(diag.Detail, "\n") 159 for _, line := range lines { 160 if !strings.HasPrefix(line, " ") { 161 line = wordwrap.WrapString(line, uint(width-1)) 162 } 163 fmt.Fprintf(&buf, "%s\n", line) 164 } 165 } else { 166 fmt.Fprintf(&buf, "%s\n", diag.Detail) 167 } 168 } 169 170 return buf.String() 171 } 172 173 // DiagnosticWarningsCompact is an alternative to Diagnostic for when all of 174 // the given diagnostics are warnings and we want to show them compactly, 175 // with only two lines per warning and excluding all of the detail information. 176 // 177 // The caller may optionally pre-process the given diagnostics with 178 // ConsolidateWarnings, in which case this function will recognize consolidated 179 // messages and include an indication that they are consolidated. 180 // 181 // Do not pass non-warning diagnostics to this function, or the result will 182 // be nonsense. 183 func DiagnosticWarningsCompact(diags tfdiags.Diagnostics, color *colorstring.Colorize) string { 184 var b strings.Builder 185 b.WriteString(color.Color("[bold][yellow]Warnings:[reset]\n\n")) 186 for _, diag := range diags { 187 sources := tfdiags.WarningGroupSourceRanges(diag) 188 b.WriteString(fmt.Sprintf("- %s\n", diag.Description().Summary)) 189 if len(sources) > 0 { 190 mainSource := sources[0] 191 if mainSource.Subject != nil { 192 if len(sources) > 1 { 193 b.WriteString(fmt.Sprintf( 194 " on %s line %d (and %d more)\n", 195 mainSource.Subject.Filename, 196 mainSource.Subject.Start.Line, 197 len(sources)-1, 198 )) 199 } else { 200 b.WriteString(fmt.Sprintf( 201 " on %s line %d\n", 202 mainSource.Subject.Filename, 203 mainSource.Subject.Start.Line, 204 )) 205 } 206 } else if len(sources) > 1 { 207 b.WriteString(fmt.Sprintf( 208 " (%d occurences of this warning)\n", 209 len(sources), 210 )) 211 } 212 } 213 } 214 215 return b.String() 216 } 217 218 func appendSourceSnippets(buf *bytes.Buffer, diag *viewsjson.Diagnostic, color *colorstring.Colorize) { 219 if diag.Address != "" { 220 fmt.Fprintf(buf, " with %s,\n", diag.Address) 221 } 222 223 if diag.Range == nil { 224 return 225 } 226 227 if diag.Snippet == nil { 228 // This should generally not happen, as long as sources are always 229 // loaded through the main loader. We may load things in other 230 // ways in weird cases, so we'll tolerate it at the expense of 231 // a not-so-helpful error message. 232 fmt.Fprintf(buf, " on %s line %d:\n (source code not available)\n", diag.Range.Filename, diag.Range.Start.Line) 233 } else { 234 snippet := diag.Snippet 235 code := snippet.Code 236 237 var contextStr string 238 if snippet.Context != nil { 239 contextStr = fmt.Sprintf(", in %s", *snippet.Context) 240 } 241 fmt.Fprintf(buf, " on %s line %d%s:\n", diag.Range.Filename, diag.Range.Start.Line, contextStr) 242 243 // Split the snippet and render the highlighted section with underlines 244 start := snippet.HighlightStartOffset 245 end := snippet.HighlightEndOffset 246 247 // Only buggy diagnostics can have an end range before the start, but 248 // we need to ensure we don't crash here if that happens. 249 if end < start { 250 end = start + 1 251 if end > len(code) { 252 end = len(code) 253 } 254 } 255 256 // If either start or end is out of range for the code buffer then 257 // we'll cap them at the bounds just to avoid a panic, although 258 // this would happen only if there's a bug in the code generating 259 // the snippet objects. 260 if start < 0 { 261 start = 0 262 } else if start > len(code) { 263 start = len(code) 264 } 265 if end < 0 { 266 end = 0 267 } else if end > len(code) { 268 end = len(code) 269 } 270 271 before, highlight, after := code[0:start], code[start:end], code[end:] 272 code = fmt.Sprintf(color.Color("%s[underline]%s[reset]%s"), before, highlight, after) 273 274 // Split the snippet into lines and render one at a time 275 lines := strings.Split(code, "\n") 276 for i, line := range lines { 277 fmt.Fprintf( 278 buf, "%4d: %s\n", 279 snippet.StartLine+i, 280 line, 281 ) 282 } 283 284 if len(snippet.Values) > 0 || (snippet.FunctionCall != nil && snippet.FunctionCall.Signature != nil) { 285 // The diagnostic may also have information about the dynamic 286 // values of relevant variables at the point of evaluation. 287 // This is particularly useful for expressions that get evaluated 288 // multiple times with different values, such as blocks using 289 // "count" and "for_each", or within "for" expressions. 290 values := make([]viewsjson.DiagnosticExpressionValue, len(snippet.Values)) 291 copy(values, snippet.Values) 292 sort.Slice(values, func(i, j int) bool { 293 return values[i].Traversal < values[j].Traversal 294 }) 295 296 fmt.Fprint(buf, color.Color(" [dark_gray]├────────────────[reset]\n")) 297 if callInfo := snippet.FunctionCall; callInfo != nil && callInfo.Signature != nil { 298 299 fmt.Fprintf(buf, color.Color(" [dark_gray]│[reset] while calling [bold]%s[reset]("), callInfo.CalledAs) 300 for i, param := range callInfo.Signature.Params { 301 if i > 0 { 302 buf.WriteString(", ") 303 } 304 buf.WriteString(param.Name) 305 } 306 if param := callInfo.Signature.VariadicParam; param != nil { 307 if len(callInfo.Signature.Params) > 0 { 308 buf.WriteString(", ") 309 } 310 buf.WriteString(param.Name) 311 buf.WriteString("...") 312 } 313 buf.WriteString(")\n") 314 } 315 for _, value := range values { 316 fmt.Fprintf(buf, color.Color(" [dark_gray]│[reset] [bold]%s[reset] %s\n"), value.Traversal, value.Statement) 317 } 318 } 319 } 320 321 buf.WriteByte('\n') 322 }