-
-
Notifications
You must be signed in to change notification settings - Fork 153
Expand file tree
/
Copy pathformatter.go
More file actions
553 lines (475 loc) · 17.4 KB
/
formatter.go
File metadata and controls
553 lines (475 loc) · 17.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
package errors
import (
"fmt"
"os"
"regexp"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/cockroachdb/errors"
"github.com/spf13/viper"
"golang.org/x/term"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/terminal"
"github.com/cloudposse/atmos/pkg/ui/markdown"
"github.com/cloudposse/atmos/pkg/ui/theme"
)
const (
// DefaultMaxLineLength is the default maximum line length before wrapping.
DefaultMaxLineLength = 80
// DefaultMarkdownWidth is the default width for markdown rendering when config is not available.
DefaultMarkdownWidth = 120
// Space is used for separating words.
space = " "
// Newline is used for line breaks.
newline = "\n"
)
// FormatterConfig controls error formatting behavior.
type FormatterConfig struct {
// Verbose enables detailed error chain output.
Verbose bool
// MaxLineLength is the maximum length before wrapping (default: 80).
// This controls both the width passed to the markdown renderer and the
// wrapping of text in explanation and hint sections.
MaxLineLength int
// Title is an optional custom title for the error message.
Title string
}
// DefaultFormatterConfig returns default formatting configuration.
func DefaultFormatterConfig() FormatterConfig {
return FormatterConfig{
Verbose: false,
MaxLineLength: DefaultMaxLineLength,
}
}
// formatContextTable creates a styled 2-column table for error context.
// Context is extracted from cockroachdb/errors safe details and displayed
// as key-value pairs in verbose mode.
func formatContextTable(err error, useColor bool) string {
details := errors.GetSafeDetails(err)
if len(details.SafeDetails) == 0 {
return ""
}
// Parse "component=vpc stack=prod" format into key-value pairs.
var rows [][]string
for _, detail := range details.SafeDetails {
str := fmt.Sprintf("%v", detail)
pairs := strings.Split(str, " ")
for _, pair := range pairs {
if parts := strings.SplitN(pair, "=", 2); len(parts) == 2 {
rows = append(rows, []string{parts[0], parts[1]})
}
}
}
if len(rows) == 0 {
return ""
}
// Create styled table with width constraint.
t := table.New().
Border(lipgloss.ThickBorder()).
Headers("Context", "Value").
Rows(rows...).
Width(DefaultMarkdownWidth)
if useColor {
t = t.
BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorBorder))).
StyleFunc(func(row, col int) lipgloss.Style {
style := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1)
if row == -1 {
// Header row - green and bold.
return style.Foreground(lipgloss.Color(theme.ColorGreen)).Bold(true)
}
if col == 0 {
// Key column - dimmed gray.
return style.Foreground(lipgloss.Color("#808080"))
}
// Value column - normal.
return style
})
}
return "\n" + t.String()
}
// Format formats an error for display with structured markdown sections.
func Format(err error, config FormatterConfig) string {
defer perf.Track(nil, "errors.Format")()
if err == nil {
return ""
}
// Determine color usage from terminal settings.
// This respects --no-color, --force-color, NO_COLOR env var, and terminal.color config.
useColor := shouldUseColor()
// Build structured markdown document with sections.
md := buildMarkdownSections(err, config, useColor)
// Render markdown through Glamour with configured width.
rendered := renderMarkdown(md, config.MaxLineLength)
// Strip ANSI codes if color is disabled.
if !useColor {
rendered = stripANSI(rendered)
}
return rendered
}
// buildMarkdownSections builds the complete markdown document with all sections.
func buildMarkdownSections(err error, config FormatterConfig, useColor bool) string {
var md strings.Builder
// Section 1: Error header + message.
// Prefer config.Title, then extract custom title, or use default.
title := config.Title
if title == "" {
title = extractCustomTitle(err)
}
if title == "" {
title = "Error"
}
md.WriteString("# " + title + newline + newline)
// Extract sentinel error and wrapped message.
sentinelMsg, wrappedMsg := extractSentinelAndWrappedMessage(err)
// Check for specific error types that need special formatting.
// Priority order: WorkflowStepError > ExecError > generic errors.
var workflowErr *WorkflowStepError
var execErr *ExecError
switch {
case errors.As(err, &workflowErr):
// Workflow orchestration failures - show workflow-specific message with exit code.
md.WriteString(fmt.Sprintf("**Error:** %s%s%s", workflowErr.WorkflowStepMessage(), newline, newline))
case errors.As(err, &execErr):
// External command execution failures - show command and exit code.
md.WriteString(fmt.Sprintf("**Error:** %s with exit code %d%s%s", sentinelMsg, execErr.ExitCode, newline, newline))
default:
// All other errors - just show the sentinel message without exit code.
md.WriteString("**Error:** " + sentinelMsg + newline + newline)
}
// Section 2: Explanation.
addExplanationSection(&md, err, wrappedMsg, config.MaxLineLength)
// Section 3 & 4: Examples and Hints.
addExampleAndHintsSection(&md, err, config.MaxLineLength)
// Section 4.5: Command Output (for ExecError with stderr).
addCommandOutputSection(&md, err)
// Section 5: Context.
addContextSection(&md, err, useColor)
// Section 6: Stack trace (verbose mode only).
if config.Verbose {
addStackTraceSection(&md, err, useColor)
}
return md.String()
}
// extractSentinelAndWrappedMessage extracts the root sentinel error message
// and any wrapped context message from the error chain.
// For example, given: fmt.Errorf("%w: The command has no steps", ErrInvalidArguments)
// Returns: ("invalid arguments", "The command has no steps").
func extractSentinelAndWrappedMessage(err error) (sentinelMsg string, wrappedMsg string) {
if err == nil {
return "", ""
}
// Get the full error message.
fullMsg := err.Error()
// Unwrap to find the root sentinel error.
current := err
for {
unwrapped := errors.Unwrap(current)
if unwrapped == nil {
// Reached the root error (sentinel).
sentinelMsg = current.Error()
break
}
current = unwrapped
}
// Extract the wrapped message by removing the sentinel prefix.
// The format from fmt.Errorf("%w: message", sentinel) is "sentinel: message".
if strings.HasPrefix(fullMsg, sentinelMsg+": ") {
wrappedMsg = strings.TrimPrefix(fullMsg, sentinelMsg+": ")
} else {
// If no wrapped message, just use sentinel.
sentinelMsg = fullMsg
}
return sentinelMsg, wrappedMsg
}
// addExplanationSection adds the explanation section if details or wrapped message exist.
func addExplanationSection(md *strings.Builder, err error, wrappedMsg string, maxLineLength int) {
// maxLineLength is unused here because the markdown renderer handles wrapping.
_ = maxLineLength
details := errors.GetAllDetails(err)
hasContent := len(details) > 0 || wrappedMsg != ""
if hasContent {
md.WriteString(newline + newline + "## Explanation" + newline + newline)
// Add wrapped message first if present.
// Don't wrap - let the markdown renderer handle it to preserve structure.
if wrappedMsg != "" {
md.WriteString(wrappedMsg + newline + newline)
}
// Add details from error chain.
// Don't wrap - let the markdown renderer handle it to preserve code blocks and newlines.
for _, detail := range details {
md.WriteString(fmt.Sprintf("%v", detail) + newline)
}
if len(details) > 0 {
md.WriteString(newline)
}
}
}
// extractCustomTitle extracts the custom title from error hints.
func extractCustomTitle(err error) string {
allHints := errors.GetAllHints(err)
for _, hint := range allHints {
if strings.HasPrefix(hint, "TITLE:") {
return strings.TrimPrefix(hint, "TITLE:")
}
}
return ""
}
// categorizeHints separates hints into examples and regular hints, filtering out empty hints.
func categorizeHints(allHints []string) (examples []string, hints []string) {
for _, hint := range allHints {
switch {
case strings.HasPrefix(hint, "TITLE:"):
// Skip title hints - they're extracted separately.
continue
case strings.HasPrefix(hint, "EXAMPLE:"):
examples = append(examples, strings.TrimPrefix(hint, "EXAMPLE:"))
default:
// Skip empty or whitespace-only hints.
if trimmed := strings.TrimSpace(hint); trimmed != "" {
hints = append(hints, hint)
}
}
}
return examples, hints
}
// addExampleAndHintsSection separates hints into examples and regular hints, then adds both sections.
func addExampleAndHintsSection(md *strings.Builder, err error, maxLineLength int) {
allHints := errors.GetAllHints(err)
examples, hints := categorizeHints(allHints)
// Add Example section.
if len(examples) > 0 {
md.WriteString(newline + newline + "## Example" + newline + newline)
for _, example := range examples {
// Wrap examples in code fences to prevent markdown interpretation,
// but only if they don't already have fences (for backward compatibility
// with WithExampleFile which may include pre-fenced markdown content).
hasFences := strings.HasPrefix(strings.TrimSpace(example), "```")
if !hasFences {
md.WriteString("```yaml" + newline)
}
md.WriteString(example)
if !strings.HasSuffix(example, newline) {
md.WriteString(newline)
}
if !hasFences {
md.WriteString("```" + newline)
}
}
md.WriteString(newline)
}
// Add Hints section.
// IMPORTANT: Each hint MUST be on its own line with a blank line after it to prevent
// markdown renderers from collapsing multiple hints into a single paragraph.
// We NEVER delete newlines - only trailing spaces/tabs before newlines are removed.
//
// Line breaks and spacing should be controlled by:
// - Markdown content itself (blank lines between paragraphs, etc.)
// - Markdown stylesheets (renderer configuration)
// - NOT by post-processing that removes newlines
if len(hints) > 0 {
md.WriteString(newline + newline + "## Hints" + newline + newline)
for _, hint := range hints {
// Don't wrap - let the markdown renderer handle it to preserve structure.
// The maxLineLength parameter is unused here.
_ = maxLineLength
md.WriteString("💡 " + hint + newline + newline)
}
}
}
// addCommandOutputSection adds the command output section for ExecError with stderr.
func addCommandOutputSection(md *strings.Builder, err error) {
var execErr *ExecError
if errors.As(err, &execErr) && execErr.Stderr != "" {
md.WriteString(newline + newline + "## Command Output" + newline + newline)
md.WriteString("```" + newline)
md.WriteString(execErr.Stderr)
if !strings.HasSuffix(execErr.Stderr, newline) {
md.WriteString(newline)
}
md.WriteString("```" + newline)
}
}
// addContextSection adds the context section if context exists.
func addContextSection(md *strings.Builder, err error, useColor bool) {
// Context is rendered as a markdown table, so we use formatContextForMarkdown.
// The useColor parameter is available for future use if we need color-aware context rendering.
_ = useColor
context := formatContextForMarkdown(err)
if context != "" {
md.WriteString(newline + newline + "## Context" + newline + newline)
md.WriteString(context)
md.WriteString(newline)
}
}
// addStackTraceSection adds the stack trace section in verbose mode.
func addStackTraceSection(md *strings.Builder, err error, useColor bool) {
// Stack traces are rendered in code blocks, so color doesn't apply.
// The useColor parameter is available for future use if needed.
_ = useColor
md.WriteString(newline + newline + "## Stack Trace" + newline + newline)
md.WriteString("```" + newline)
fmt.Fprintf(md, "%+v", err)
md.WriteString(newline + "```" + newline)
}
// renderMarkdown renders markdown string through Glamour with specified width.
//
// This function creates a fresh markdown renderer for each call rather than using
// the global renderer from pkg/ui to ensure:
// 1. The FormatterConfig.MaxLineLength parameter is respected (global renderer may have different width)
// 2. Error formatting works before the UI system is initialized (early startup errors)
// 3. No circular dependencies (pkg/ui imports errors package)
func renderMarkdown(md string, maxLineLength int) string {
// Use provided maxLineLength, or fall back to default if not set.
width := maxLineLength
if width <= 0 {
width = DefaultMarkdownWidth
}
// Always create a fresh renderer with the specified width to ensure
// MaxLineLength parameter is respected. The global renderer may have
// a different width configured.
config := schema.AtmosConfiguration{
Settings: schema.AtmosSettings{
Docs: schema.Docs{
MaxWidth: width,
},
},
}
renderer, err := markdown.NewTerminalMarkdownRenderer(config)
if err != nil {
// Fallback: return plain markdown if renderer creation fails.
return md
}
rendered, renderErr := renderer.RenderErrorf(md)
if renderErr == nil {
return rendered
}
// Fallback to plain markdown.
return md
}
// formatContextForMarkdown formats context as a markdown table.
func formatContextForMarkdown(err error) string {
details := errors.GetSafeDetails(err)
if len(details.SafeDetails) == 0 {
return ""
}
// Parse "component=vpc stack=prod" format into key-value pairs.
var rows []string
for _, detail := range details.SafeDetails {
str := fmt.Sprintf("%v", detail)
pairs := strings.Split(str, " ")
for _, pair := range pairs {
if parts := strings.SplitN(pair, "=", 2); len(parts) == 2 {
rows = append(rows, fmt.Sprintf("| %s | %s |", parts[0], parts[1]))
}
}
}
// Return empty if no rows were parsed.
if len(rows) == 0 {
return ""
}
// Build table with header.
var md strings.Builder
md.WriteString("| Key | Value |\n")
md.WriteString("|-----|-------|\n")
for _, row := range rows {
md.WriteString(row + "\n")
}
return md.String()
}
// shouldUseColor determines if color output should be used.
// This uses the terminal package's color logic which respects:
// - --no-color, --color, --force-color flags
// - NO_COLOR, CLICOLOR, CLICOLOR_FORCE environment variables
// - settings.terminal.color and settings.terminal.no_color in atmos.yaml
func shouldUseColor() bool {
// Build terminal config from all sources (flags, env vars, atmos.yaml).
termConfig := &terminal.Config{
NoColor: viper.GetBool("no-color"),
Color: viper.GetBool("color"),
ForceColor: viper.GetBool("force-color"),
EnvNoColor: os.Getenv("NO_COLOR") != "",
EnvCLIColor: os.Getenv("CLICOLOR"),
EnvCLIColorForce: os.Getenv("CLICOLOR_FORCE") != "" || os.Getenv("FORCE_COLOR") != "",
}
// Add atmos.yaml settings if available.
if atmosConfig != nil {
termConfig.AtmosConfig = *atmosConfig
}
// Check if stderr is a TTY.
isTTY := term.IsTerminal(int(os.Stderr.Fd()))
// Use terminal package's color logic.
return termConfig.ShouldUseColor(isTTY)
}
// stripANSI removes ANSI escape codes from a string.
func stripANSI(s string) string {
// ANSI escape code pattern: ESC [ ... m where ... can be numbers separated by semicolons.
// More comprehensive pattern to catch all ANSI codes including SGR (colors/formatting).
ansiPattern := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
return ansiPattern.ReplaceAllString(s, "")
}
// wrapText wraps text to the specified width while preserving intentional line breaks.
//
// DEPRECATED: This function is no longer used in production code.
// The markdown renderer (Glamour) handles text wrapping natively and correctly
// preserves markdown structure (code blocks, newlines, etc.). This function
// destroys markdown structure by calling strings.Fields() which removes ALL
// newlines, making it unsuitable for formatting error messages with code blocks.
//
// IMPORTANT: This function should NOT be used on text with intentional newlines,
// as strings.Fields() splits on ALL whitespace including newlines, which destroys
// the original line break structure. Only use this for wrapping single paragraphs.
// NEVER call this on multi-line text that needs to preserve its line break structure.
//
// Line breaks and spacing should be controlled by:
// - Markdown content itself (blank lines between paragraphs, etc.)
// - Markdown stylesheets (renderer configuration)
// - NOT by post-processing that removes newlines
func wrapText(text string, width int) string {
if width <= 0 {
width = DefaultMaxLineLength
}
var lines []string
var currentLine strings.Builder
// WARNING: strings.Fields() removes ALL whitespace including newlines.
// This destroys intentional line breaks in the input text.
// Only use this function on single-paragraph text.
words := strings.Fields(text)
for i, word := range words {
// Check if adding this word would exceed the width.
testLine := currentLine.String()
if len(testLine) > 0 {
testLine += space + word
} else {
testLine = word
}
if len(testLine) > width && currentLine.Len() > 0 {
// Start a new line.
lines = append(lines, currentLine.String())
currentLine.Reset()
currentLine.WriteString(word)
} else {
if i > 0 && currentLine.Len() > 0 {
currentLine.WriteString(space)
}
currentLine.WriteString(word)
}
}
// Add the last line.
if currentLine.Len() > 0 {
lines = append(lines, currentLine.String())
}
return strings.Join(lines, newline)
}
// formatStackTrace formats the full error chain with stack traces.
func formatStackTrace(err error, useColor bool) string {
style := lipgloss.NewStyle()
if useColor {
style = style.Foreground(lipgloss.Color("#808080")) // Gray
}
// Use cockroachdb/errors format with stack traces.
details := fmt.Sprintf("%+v", err)
return style.Render(details)
}