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