Skip to content

feat: improve CLI error messages #6778

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Mar 28, 2023
112 changes: 40 additions & 72 deletions cli/root.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package cli

import (
"bufio"
"context"
"errors"
"flag"
Expand All @@ -14,14 +13,11 @@ import (
"os"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"strings"
"syscall"
"time"
"unicode/utf8"

"golang.org/x/crypto/ssh/terminal"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"

Expand Down Expand Up @@ -822,89 +818,61 @@ func isConnectionError(err error) bool {
}

type prettyErrorFormatter struct {
level int
w io.Writer
}

func (prettyErrorFormatter) prefixLines(spaces int, s string) string {
twidth, _, err := terminal.GetSize(0)
if err != nil {
twidth = 80
}

s = lipgloss.NewStyle().Width(twidth - spaces).Render(s)

var b strings.Builder
scanner := bufio.NewScanner(strings.NewReader(s))
for i := 0; scanner.Scan(); i++ {
// The first line is already padded.
if i == 0 {
_, _ = fmt.Fprintf(&b, "%s\n", scanner.Text())
continue
}
_, _ = fmt.Fprintf(&b, "%s%s\n", strings.Repeat(" ", spaces), scanner.Text())
}
return strings.TrimSuffix(strings.TrimSuffix(b.String(), "\n"), " ")
w io.Writer
}

func (p *prettyErrorFormatter) format(err error) {
underErr := errors.Unwrap(err)

arrowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#515151"))
errTail := errors.Unwrap(err)

//nolint:errorlint
if _, ok := err.(*clibase.RunCommandError); ok && p.level == 0 && underErr != nil {
// We can do a better job now.
p.format(underErr)
if _, ok := err.(*clibase.RunCommandError); ok && errTail != nil {
// Avoid extra nesting.
p.format(errTail)
return
}

var (
padding string
arrowWidth int
)
if p.level > 0 {
const arrow = "┗━ "
arrowWidth = utf8.RuneCount([]byte(arrow))
padding = strings.Repeat(" ", arrowWidth*p.level)
_, _ = fmt.Fprintf(p.w, "%v%v", padding, arrowStyle.Render(arrow))
}

if underErr != nil {
header := strings.TrimSuffix(err.Error(), ": "+underErr.Error())
_, _ = fmt.Fprintf(p.w, "%s\n", p.prefixLines(len(padding)+arrowWidth, header))
p.level++
p.format(underErr)
return
var headErr string
if errTail != nil {
headErr = strings.TrimSuffix(err.Error(), ": "+errTail.Error())
} else {
headErr = err.Error()
}

{
style := lipgloss.NewStyle().Foreground(lipgloss.Color("#D16644")).Background(lipgloss.Color("#000000")).Bold(false)
// This is the last error in a tree.
p.wrappedPrintf(
"%s\n",
p.prefixLines(
len(padding)+arrowWidth,
fmt.Sprintf(
"%s%s%s",
lipgloss.NewStyle().Inherit(style).Underline(true).Render("ERROR"),
lipgloss.NewStyle().Inherit(style).Foreground(arrowStyle.GetForeground()).Render(" ► "),
style.Render(err.Error()),
),
),
)
var msg string
var sdkError *codersdk.Error
if errors.As(err, &sdkError) {
// We don't want to repeat the same error message twice, so we
// only show the SDK error on the top of the stack.
msg = sdkError.Message
if sdkError.Helper != "" {
msg = msg + "\n" + sdkError.Helper
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The newline here makes sense and makes this look nice. 👍🏻

}
// The SDK error is usually good enough, and we don't want to overwhelm
// the user with output.
errTail = nil
} else {
msg = headErr
}
}

func (p *prettyErrorFormatter) wrappedPrintf(format string, a ...interface{}) {
s := lipgloss.NewStyle().Width(ttyWidth()).Render(
fmt.Sprintf(format, a...),
headStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#D16644"))
p.printf(
headStyle,
"%s",
msg,
)

// Not sure why, but lipgloss is adding extra spaces we need to remove.
excessSpaceRe := regexp.MustCompile(`[[:blank:]]*\n[[:blank:]]*$`)
s = excessSpaceRe.ReplaceAllString(s, "\n")
tailStyle := headStyle.Copy().Foreground(lipgloss.Color("#969696"))

if errTail != nil {
p.printf(headStyle, ": ")
// Grey out the less important, deep errors.
p.printf(tailStyle, "%s", errTail.Error())
}
p.printf(tailStyle, "\n")
}

func (p *prettyErrorFormatter) printf(style lipgloss.Style, format string, a ...interface{}) {
s := style.Render(fmt.Sprintf(format, a...))
_, _ = p.w.Write(
[]byte(
s,
Expand Down