package bonfire import ( "errors" "io" "os" "path/filepath" "strings" "git.golem15.com/golem15/summercms/modules/sunscreen" "github.com/charmbracelet/colorprofile" ) // UsageError marks a mistake in how a command was called: an unknown // command or flag, or a wrong number of arguments. Execute returns ExitUsage // for it. type UsageError struct { Err error // command is the command that was called wrongly, when known; the // error block suggests its help. command string } func (e *UsageError) Error() string { return e.Err.Error() } func (e *UsageError) Unwrap() error { return e.Err } // Usage wraps err as a UsageError; nil stays nil. func Usage(err error) error { if err == nil { return nil } var ue *UsageError if errors.As(err, &ue) { return err } return &UsageError{Err: err} } // isUsage reports a UsageError in the chain or cobra's unknown command error. func isUsage(err error) bool { var ue *UsageError if errors.As(err, &ue) { return true } return strings.HasPrefix(err.Error(), "unknown command") } // HintError adds an explanation and a command to try to an error. The error // block prints both under the error message; {bin} in Try becomes summer or // bin/. type HintError struct { Err error Explanation string Try string } func (e *HintError) Error() string { return e.Err.Error() } func (e *HintError) Unwrap() error { return e.Err } // WithHint returns err with an explanation and a Try line (several lines are // allowed); "{bin}" in try is replaced by how the binary is called. A nil err // stays nil. func WithHint(err error, explanation, try string) error { if err == nil { return nil } return &HintError{Err: err, Explanation: explanation, Try: try} } // ErrReported marks an error whose details the command already printed: // the error block prints nothing and the exit code is 1. var ErrReported = errors.New("bonfire: error already reported") type reportedError struct { err error } func (e *reportedError) Error() string { return e.err.Error() } func (e *reportedError) Unwrap() error { return e.err } func (e *reportedError) Is(target error) bool { return target == ErrReported } // Reported returns an error that matches both ErrReported and err, for a // command that has already printed its problems. A nil err gives // ErrReported. func Reported(err error) error { if err == nil { return ErrReported } return &reportedError{err: err} } // RenderError writes err to w as the console error block: the ERROR badge // and the message, the explanation and the Try lines of a HintError ({bin} // replaced by summer or bin/, for the root named name), then "Run with // -v for details." at verbosity 0 or the wrapped error chain under "Caused // by:" at -v and above. cobra's unknown command error becomes `Command "x" // is not defined.` with its suggestions as Try lines, and other usage errors // suggest the command's help. Every message passes through sunscreen.Scrub. // Errors wrapped by Reported print nothing. func RenderError(w io.Writer, name string, err error, g Globals) { if err == nil || w == nil || errors.Is(err, ErrReported) { return } if cw, ok := w.(*colorprofile.Writer); ok { w = cw.Forward } bin := usageName(name) p := DefaultPalette() message := err.Error() explanation, try := "", "" var hint *HintError if errors.As(err, &hint) { explanation, try = hint.Explanation, hint.Try } if strings.HasPrefix(message, "unknown command") { cmdName, suggestions := parseUnknownCommand(message) message = `Command "` + cmdName + `" is not defined.` if try == "" { var lines []string for _, s := range suggestions { lines = append(lines, "{bin} "+s) } if len(lines) == 0 { lines = []string{"{bin} list"} } try = strings.Join(lines, "\n") } } else if try == "" { var ue *UsageError if errors.As(err, &ue) { if ue.command != "" { try = "{bin} help " + ue.command } else { try = "{bin} list" } } } var b strings.Builder b.WriteString("\n") for i, line := range strings.Split(sunscreen.Scrub(strings.TrimRight(message, "\n")), "\n") { if i == 0 { b.WriteString(" " + p.ErrorBold.Reverse(true).Render(" ERROR ") + " " + line + "\n") continue } b.WriteString(" " + line + "\n") } if explanation != "" { b.WriteString("\n") for _, line := range strings.Split(sunscreen.Scrub(explanation), "\n") { b.WriteString(" " + line + "\n") } } if try != "" { b.WriteString("\n " + p.Header.Render("Try:") + "\n") for _, line := range strings.Split(strings.ReplaceAll(try, "{bin}", bin), "\n") { b.WriteString(" " + p.Success.Render(sunscreen.Scrub(line)) + "\n") } } if g.Verbosity > 0 { if chain := causes(err, err.Error()); len(chain) > 0 { b.WriteString("\n " + p.Header.Render("Caused by:") + "\n") for _, c := range chain { b.WriteString(" " + p.Muted.Render("- "+sunscreen.Scrub(c)) + "\n") } } } else { b.WriteString("\n " + p.Muted.Render("Run with -v for details.") + "\n") } b.WriteString("\n") _ = writeStyled(w, g, b.String()) } // wrongDirectoryExplanation is the hint added when an application binary // fails outside its application root. const wrongDirectoryExplanation = "This command runs from the application root." // wrongDirectory adds a hint to err when an application binary (any root // name other than summer) failed while the working directory dir ("" means // os.Getwd) has no config directory and a parent directory holds // summer.yaml: compass then found no configuration, which is why the command // failed (D-19). The Try line is "cd ; bin/ ". // Usage errors, Reported errors and summer are left alone; nothing else // about the failure changes. func wrongDirectory(name string, err error, args []string, dir string) error { if err == nil || name == "summer" || errors.Is(err, ErrReported) || isUsage(err) { return err } if dir == "" { wd, werr := os.Getwd() if werr != nil { return err } dir = wd } if info, serr := os.Stat(filepath.Join(dir, "config")); serr == nil && info.IsDir() { return err } root := "" for d := filepath.Dir(dir); ; d = filepath.Dir(d) { if _, serr := os.Stat(filepath.Join(d, "summer.yaml")); serr == nil { root = d break } if filepath.Dir(d) == d { break } } if root == "" { return err } rel, rerr := filepath.Rel(dir, root) if rerr != nil { return err } try := "cd " + shellQuote(rel) + "; {bin}" for _, a := range args { try += " " + shellQuote(a) } return &HintError{Err: err, Explanation: wrongDirectoryExplanation, Try: try} } // shellQuote single-quotes s when a shell would split or expand it. func shellQuote(s string) string { if s != "" && !strings.ContainsAny(s, " \t\n'\"\\$`*?[]{}()<>|&;#~!") { return s } return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } // parseUnknownCommand reads the command name and cobra's "Did you mean // this?" suggestions from an unknown command error. func parseUnknownCommand(msg string) (string, []string) { name := "" if i := strings.Index(msg, `"`); i >= 0 { if j := strings.Index(msg[i+1:], `"`); j >= 0 { name = msg[i+1 : i+1+j] } } var suggestions []string if _, rest, ok := strings.Cut(msg, "Did you mean this?"); ok { for _, line := range strings.Split(rest, "\n") { if s := strings.TrimSpace(line); s != "" { suggestions = append(suggestions, s) } } } return name, suggestions } // causes walks the wrapped errors below err (errors.Join members expanded) // and returns each distinct message other than top. func causes(err error, top string) []string { var out []string seen := map[string]bool{top: true} var walk func(error) walk = func(e error) { for e != nil { if msg := e.Error(); !seen[msg] { seen[msg] = true out = append(out, msg) } if joined, ok := e.(interface{ Unwrap() []error }); ok { for _, m := range joined.Unwrap() { walk(m) } return } e = errors.Unwrap(e) } } walk(errors.Unwrap(err)) if joined, ok := err.(interface{ Unwrap() []error }); ok { for _, m := range joined.Unwrap() { walk(m) } } return out }