Skip to content

Commit c0af184

Browse files
authored
Merge pull request #696 from angelcerveraroldan/butane-pretty-ux
Pretty error reporting
2 parents 25687fa + 4749a7f commit c0af184

3 files changed

Lines changed: 195 additions & 1 deletion

File tree

docs/release-notes.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ nav_order: 9
1414

1515
### Misc. changes
1616

17+
- Add support for pretty error reporting, can be controlled through
18+
the use of `--raw-errors` (disable) and `--color`/`--colour`
19+
1720
### Docs changes
1821

1922
## Butane 0.28.0 (2026-05-19)

internal/main.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323

2424
"github.com/coreos/butane/config"
2525
"github.com/coreos/butane/config/common"
26+
breport "github.com/coreos/butane/internal/report"
2627
"github.com/coreos/butane/internal/version"
2728
)
2829

@@ -31,14 +32,25 @@ func fail(format string, args ...interface{}) {
3132
os.Exit(1)
3233
}
3334

35+
func isCharDevice(f *os.File) bool {
36+
stat, err := f.Stat()
37+
if err != nil {
38+
return false
39+
}
40+
return stat.Mode()&os.ModeCharDevice != 0
41+
}
42+
3443
func main() {
3544
var (
3645
input string
3746
output string
47+
colorFlag string
3848
check bool
3949
strict bool
4050
helpFlag bool
4151
versionFlag bool
52+
rawErrors bool
53+
colorize bool
4254
)
4355
options := common.TranslateBytesOptions{}
4456
pflag.BoolVarP(&helpFlag, "help", "h", false, "show usage and exit")
@@ -49,6 +61,12 @@ func main() {
4961
pflag.BoolVarP(&strict, "strict", "s", false, "fail on any warning")
5062
pflag.BoolVarP(&options.Pretty, "pretty", "p", false, "output formatted json")
5163
pflag.BoolVarP(&options.Raw, "raw", "r", false, "never wrap in a MachineConfig; force Ignition output")
64+
pflag.BoolVar(&rawErrors, "raw-errors", false, "show raw errors, rather than pretty printing them")
65+
pflag.StringVar(&colorFlag, "color", "auto", `control color output: "auto", "always", or "never"`)
66+
pflag.Lookup("color").NoOptDefVal = "always"
67+
pflag.StringVar(&colorFlag, "colour", "auto", `control color output: "auto", "always", or "never"`)
68+
pflag.Lookup("colour").NoOptDefVal = "always"
69+
pflag.Lookup("colour").Hidden = true
5270
pflag.StringVar(&input, "input", "", "read from input file instead of stdin")
5371
pflag.Lookup("input").Deprecated = "specify filename directly on command line"
5472
pflag.Lookup("input").Hidden = true
@@ -62,6 +80,17 @@ func main() {
6280
}
6381
pflag.Parse()
6482

83+
switch colorFlag {
84+
case "always", "yes":
85+
colorize = true
86+
case "never", "no":
87+
colorize = false
88+
case "auto":
89+
_, noColorSet := os.LookupEnv("NO_COLOR")
90+
isTTY := isCharDevice(os.Stderr)
91+
colorize = !noColorSet && isTTY
92+
}
93+
6594
args := pflag.Args()
6695
if len(args) == 1 && input == "" {
6796
input = args[0]
@@ -82,13 +111,15 @@ func main() {
82111
}
83112

84113
infile := os.Stdin
114+
filename := "<stdin>"
85115
if input != "" {
86116
var err error
87117
infile, err = os.Open(input)
88118
if err != nil {
89119
fail("failed to open %s: %v\n", input, err)
90120
}
91121
defer infile.Close()
122+
filename = input
92123
}
93124

94125
dataIn, err := io.ReadAll(infile)
@@ -97,7 +128,10 @@ func main() {
97128
}
98129

99130
dataOut, r, err := config.TranslateBytes(dataIn, options)
100-
fmt.Fprintf(os.Stderr, "%s", r.String())
131+
132+
errorString := breport.FormatError(r, filename, dataIn, colorize, rawErrors)
133+
fmt.Fprintf(os.Stderr, "%s", errorString)
134+
101135
if err != nil {
102136
fail("Error translating config: %v\n", err)
103137
}

internal/report/report.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Copyright 2020 Red Hat, Inc
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.)
14+
15+
// Package report allows butane to pretty print errors, the error format is as follows:
16+
package report
17+
18+
import (
19+
"fmt"
20+
"strings"
21+
"unicode"
22+
23+
"github.com/coreos/vcontext/report"
24+
)
25+
26+
const (
27+
red = "\033[1;31m"
28+
yellow = "\033[1;33m"
29+
cyan = "\033[1;36m"
30+
blue = "\033[1;34m"
31+
reset = "\033[0m"
32+
)
33+
34+
func FormatError(r report.Report, fileName string, source []byte, colorize, rawErrors bool) string {
35+
if rawErrors {
36+
return formatErrorSimple(r)
37+
} else {
38+
return formatErrorPretty(r, fileName, source, colorize)
39+
}
40+
}
41+
42+
func formatErrorSimple(r report.Report) string {
43+
return r.String()
44+
}
45+
46+
func formatErrorPretty(r report.Report, fileName string, source []byte, colorize bool) string {
47+
lines := strings.Split(string(source), "\n")
48+
var buf strings.Builder
49+
for i, entry := range r.Entries {
50+
if i > 0 {
51+
buf.WriteString("\n")
52+
}
53+
buf.WriteString(formatErrorEntry(entry, fileName, lines, colorize))
54+
}
55+
return buf.String()
56+
}
57+
58+
func color(text, code string, colorize bool) string {
59+
if !colorize {
60+
return text
61+
}
62+
return code + text + reset
63+
}
64+
65+
func severityColor(kind report.EntryKind) string {
66+
switch kind {
67+
case report.Error:
68+
return red
69+
case report.Info:
70+
return cyan
71+
case report.Warn:
72+
return yellow
73+
default:
74+
return reset
75+
}
76+
}
77+
78+
func writeUnderline(buf *strings.Builder, col, gutterWidth int, message, line string, colorize bool) {
79+
underlineStart := col - 1
80+
rest := line[underlineStart:]
81+
nextWhitespace := strings.IndexFunc(rest, unicode.IsSpace)
82+
if nextWhitespace == -1 {
83+
// If we didn't find whitespace then that means that we need to underline the entire string
84+
nextWhitespace = len(rest)
85+
}
86+
underlineEnd := underlineStart + nextWhitespace
87+
padding := strings.Repeat(" ", underlineStart)
88+
underline := strings.Repeat("^", underlineEnd-underlineStart)
89+
underline = color(underline, blue, colorize)
90+
91+
fmt.Fprintf(buf, " %s | %s%s %s\n",
92+
strings.Repeat(" ", gutterWidth), padding, underline, message)
93+
}
94+
95+
// formatErrorEntry will try to return the error as a pretty string in the following form
96+
//
97+
// error[$.boot_device.layout]:
98+
//
99+
// --> ../testing.bu:4:11
100+
// |
101+
// 2 | version: 1.6.0
102+
// 3 | boot_device:
103+
// 4 | layout: s390x-virt
104+
// | ^^^^^^^^^^ mirroring not supported on layouts: s390x-eckd, s390x-zfcp, s390x-virt
105+
// 5 | mirror:
106+
// 6 | devices:
107+
// |
108+
func formatErrorEntry(entry report.Entry, filename string, lines []string, colorize bool) string {
109+
if entry.Marker.StartP == nil {
110+
return entry.String() + "\n"
111+
}
112+
113+
line := int(entry.Marker.StartP.Line)
114+
col := int(entry.Marker.StartP.Column)
115+
116+
// this should never happen as lines and cols are 1 indexed, but we'll add a check in case the vcontext library ever changes
117+
if line < 1 || line > len(lines) || col < 1 || col > len(lines[line-1]) {
118+
return entry.String() + "\n"
119+
}
120+
121+
var buf strings.Builder
122+
kindColor := severityColor(entry.Kind)
123+
kindStr := color(entry.Kind.String(), kindColor, colorize)
124+
125+
path := ""
126+
if entry.Context.Len() > 0 {
127+
path = "[" + entry.Context.String() + "]"
128+
}
129+
fmt.Fprintf(&buf, "%s%s:\n", kindStr, path)
130+
// Add information about the location of the error in the following form:
131+
//
132+
// " --> testing.bu:10:4"
133+
arrow := color("-->", blue, colorize)
134+
fmt.Fprintf(&buf, " %s %s:%d:%d\n", arrow, filename, line, col)
135+
136+
// Number of lines to show before and after the error location
137+
contextLines := 2
138+
contextLineInit := max(1, line-contextLines)
139+
contextLineEnd := min(len(lines), line+contextLines)
140+
141+
// width of the largest line number
142+
gutterWidth := len(fmt.Sprintf("%d", contextLineEnd))
143+
fmt.Fprintf(&buf, " %s |\n", strings.Repeat(" ", gutterWidth))
144+
for lineNumber := contextLineInit; lineNumber <= contextLineEnd; lineNumber++ {
145+
lineNum := color(fmt.Sprintf("%*d", gutterWidth, lineNumber), blue, colorize)
146+
147+
fmt.Fprintf(&buf, " %s | %s\n", lineNum, lines[lineNumber-1])
148+
// Underline the error and write the error message
149+
if lineNumber == line {
150+
writeUnderline(&buf, col, gutterWidth, entry.Message, lines[lineNumber-1], colorize)
151+
}
152+
}
153+
154+
// Empty line at the end
155+
fmt.Fprintf(&buf, " %s |\n", strings.Repeat(" ", gutterWidth))
156+
return buf.String()
157+
}

0 commit comments

Comments
 (0)