From 4749a7f343be5cf0d241e03ad69a5c34bf9d27a0 Mon Sep 17 00:00:00 2001 From: Angel Cervera Roldan <48255007+angelcerveraroldan@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:53:15 +0000 Subject: [PATCH] internal/report: pretty error reporting Add an optional flag to to enable pretty rust-like error reporting for butane errors. This introduces the flags: - raw-error - color / colour (auto, always, or never) While also respecting the `NO_COLOR` environment variable. --- docs/release-notes.md | 3 + internal/main.go | 36 ++++++++- internal/report/report.go | 157 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 internal/report/report.go diff --git a/docs/release-notes.md b/docs/release-notes.md index 15c7db23..da13b155 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -14,6 +14,9 @@ nav_order: 9 ### Misc. changes +- Add support for pretty error reporting, can be controlled through + the use of `--raw-errors` (disable) and `--color`/`--colour` + ### Docs changes ## Butane 0.28.0 (2026-05-19) diff --git a/internal/main.go b/internal/main.go index d55eeae1..1f8d3b47 100644 --- a/internal/main.go +++ b/internal/main.go @@ -23,6 +23,7 @@ import ( "github.com/coreos/butane/config" "github.com/coreos/butane/config/common" + breport "github.com/coreos/butane/internal/report" "github.com/coreos/butane/internal/version" ) @@ -31,14 +32,25 @@ func fail(format string, args ...interface{}) { os.Exit(1) } +func isCharDevice(f *os.File) bool { + stat, err := f.Stat() + if err != nil { + return false + } + return stat.Mode()&os.ModeCharDevice != 0 +} + func main() { var ( input string output string + colorFlag string check bool strict bool helpFlag bool versionFlag bool + rawErrors bool + colorize bool ) options := common.TranslateBytesOptions{} pflag.BoolVarP(&helpFlag, "help", "h", false, "show usage and exit") @@ -49,6 +61,12 @@ func main() { pflag.BoolVarP(&strict, "strict", "s", false, "fail on any warning") pflag.BoolVarP(&options.Pretty, "pretty", "p", false, "output formatted json") pflag.BoolVarP(&options.Raw, "raw", "r", false, "never wrap in a MachineConfig; force Ignition output") + pflag.BoolVar(&rawErrors, "raw-errors", false, "show raw errors, rather than pretty printing them") + pflag.StringVar(&colorFlag, "color", "auto", `control color output: "auto", "always", or "never"`) + pflag.Lookup("color").NoOptDefVal = "always" + pflag.StringVar(&colorFlag, "colour", "auto", `control color output: "auto", "always", or "never"`) + pflag.Lookup("colour").NoOptDefVal = "always" + pflag.Lookup("colour").Hidden = true pflag.StringVar(&input, "input", "", "read from input file instead of stdin") pflag.Lookup("input").Deprecated = "specify filename directly on command line" pflag.Lookup("input").Hidden = true @@ -62,6 +80,17 @@ func main() { } pflag.Parse() + switch colorFlag { + case "always", "yes": + colorize = true + case "never", "no": + colorize = false + case "auto": + _, noColorSet := os.LookupEnv("NO_COLOR") + isTTY := isCharDevice(os.Stderr) + colorize = !noColorSet && isTTY + } + args := pflag.Args() if len(args) == 1 && input == "" { input = args[0] @@ -82,6 +111,7 @@ func main() { } infile := os.Stdin + filename := "" if input != "" { var err error infile, err = os.Open(input) @@ -89,6 +119,7 @@ func main() { fail("failed to open %s: %v\n", input, err) } defer infile.Close() + filename = input } dataIn, err := io.ReadAll(infile) @@ -97,7 +128,10 @@ func main() { } dataOut, r, err := config.TranslateBytes(dataIn, options) - fmt.Fprintf(os.Stderr, "%s", r.String()) + + errorString := breport.FormatError(r, filename, dataIn, colorize, rawErrors) + fmt.Fprintf(os.Stderr, "%s", errorString) + if err != nil { fail("Error translating config: %v\n", err) } diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 00000000..e9763068 --- /dev/null +++ b/internal/report/report.go @@ -0,0 +1,157 @@ +// Copyright 2020 Red Hat, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License.) + +// Package report allows butane to pretty print errors, the error format is as follows: +package report + +import ( + "fmt" + "strings" + "unicode" + + "github.com/coreos/vcontext/report" +) + +const ( + red = "\033[1;31m" + yellow = "\033[1;33m" + cyan = "\033[1;36m" + blue = "\033[1;34m" + reset = "\033[0m" +) + +func FormatError(r report.Report, fileName string, source []byte, colorize, rawErrors bool) string { + if rawErrors { + return formatErrorSimple(r) + } else { + return formatErrorPretty(r, fileName, source, colorize) + } +} + +func formatErrorSimple(r report.Report) string { + return r.String() +} + +func formatErrorPretty(r report.Report, fileName string, source []byte, colorize bool) string { + lines := strings.Split(string(source), "\n") + var buf strings.Builder + for i, entry := range r.Entries { + if i > 0 { + buf.WriteString("\n") + } + buf.WriteString(formatErrorEntry(entry, fileName, lines, colorize)) + } + return buf.String() +} + +func color(text, code string, colorize bool) string { + if !colorize { + return text + } + return code + text + reset +} + +func severityColor(kind report.EntryKind) string { + switch kind { + case report.Error: + return red + case report.Info: + return cyan + case report.Warn: + return yellow + default: + return reset + } +} + +func writeUnderline(buf *strings.Builder, col, gutterWidth int, message, line string, colorize bool) { + underlineStart := col - 1 + rest := line[underlineStart:] + nextWhitespace := strings.IndexFunc(rest, unicode.IsSpace) + if nextWhitespace == -1 { + // If we didn't find whitespace then that means that we need to underline the entire string + nextWhitespace = len(rest) + } + underlineEnd := underlineStart + nextWhitespace + padding := strings.Repeat(" ", underlineStart) + underline := strings.Repeat("^", underlineEnd-underlineStart) + underline = color(underline, blue, colorize) + + fmt.Fprintf(buf, " %s | %s%s %s\n", + strings.Repeat(" ", gutterWidth), padding, underline, message) +} + +// formatErrorEntry will try to return the error as a pretty string in the following form +// +// error[$.boot_device.layout]: +// +// --> ../testing.bu:4:11 +// | +// 2 | version: 1.6.0 +// 3 | boot_device: +// 4 | layout: s390x-virt +// | ^^^^^^^^^^ mirroring not supported on layouts: s390x-eckd, s390x-zfcp, s390x-virt +// 5 | mirror: +// 6 | devices: +// | +func formatErrorEntry(entry report.Entry, filename string, lines []string, colorize bool) string { + if entry.Marker.StartP == nil { + return entry.String() + "\n" + } + + line := int(entry.Marker.StartP.Line) + col := int(entry.Marker.StartP.Column) + + // this should never happen as lines and cols are 1 indexed, but we'll add a check in case the vcontext library ever changes + if line < 1 || line > len(lines) || col < 1 || col > len(lines[line-1]) { + return entry.String() + "\n" + } + + var buf strings.Builder + kindColor := severityColor(entry.Kind) + kindStr := color(entry.Kind.String(), kindColor, colorize) + + path := "" + if entry.Context.Len() > 0 { + path = "[" + entry.Context.String() + "]" + } + fmt.Fprintf(&buf, "%s%s:\n", kindStr, path) + // Add information about the location of the error in the following form: + // + // " --> testing.bu:10:4" + arrow := color("-->", blue, colorize) + fmt.Fprintf(&buf, " %s %s:%d:%d\n", arrow, filename, line, col) + + // Number of lines to show before and after the error location + contextLines := 2 + contextLineInit := max(1, line-contextLines) + contextLineEnd := min(len(lines), line+contextLines) + + // width of the largest line number + gutterWidth := len(fmt.Sprintf("%d", contextLineEnd)) + fmt.Fprintf(&buf, " %s |\n", strings.Repeat(" ", gutterWidth)) + for lineNumber := contextLineInit; lineNumber <= contextLineEnd; lineNumber++ { + lineNum := color(fmt.Sprintf("%*d", gutterWidth, lineNumber), blue, colorize) + + fmt.Fprintf(&buf, " %s | %s\n", lineNum, lines[lineNumber-1]) + // Underline the error and write the error message + if lineNumber == line { + writeUnderline(&buf, col, gutterWidth, entry.Message, lines[lineNumber-1], colorize) + } + } + + // Empty line at the end + fmt.Fprintf(&buf, " %s |\n", strings.Repeat(" ", gutterWidth)) + return buf.String() +}