Skip to content

Commit 9a10961

Browse files
authored
#285 add color output for mage list (#301)
* #285 add color output for mage list * added color configuration via MAGEFILE_ENABLE_COLOR and MAGEFILE_TARGET_COLOR env vars * use a list of specific terminals which don't support color
1 parent 310e198 commit 9a10961

File tree

7 files changed

+472
-4
lines changed

7 files changed

+472
-4
lines changed

mage/main_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,31 @@ func testmain(m *testing.M) int {
5757
if err := os.Unsetenv(mg.IgnoreDefaultEnv); err != nil {
5858
log.Fatal(err)
5959
}
60+
if err := os.Setenv(mg.CacheEnv, dir); err != nil {
61+
log.Fatal(err)
62+
}
63+
if err := os.Unsetenv(mg.EnableColorEnv); err != nil {
64+
log.Fatal(err)
65+
}
66+
if err := os.Unsetenv(mg.TargetColorEnv); err != nil {
67+
log.Fatal(err)
68+
}
69+
resetTerm()
6070
return m.Run()
6171
}
6272

73+
func resetTerm() {
74+
if term, exists := os.LookupEnv("TERM"); exists {
75+
log.Printf("Current terminal: %s", term)
76+
// unset TERM env var in order to disable color output to make the tests simpler
77+
// there is a specific test for colorized output, so all the other tests can use non-colorized one
78+
if err := os.Unsetenv("TERM"); err != nil {
79+
log.Fatal(err)
80+
}
81+
}
82+
os.Setenv(mg.EnableColorEnv, "false")
83+
}
84+
6385
func TestTransitiveDepCache(t *testing.T) {
6486
cache, err := internal.OutputDebug("go", "env", "GOCACHE")
6587
if err != nil {
@@ -292,6 +314,7 @@ func TestListMagefilesLib(t *testing.T) {
292314
}
293315

294316
func TestMixedMageImports(t *testing.T) {
317+
resetTerm()
295318
stderr := &bytes.Buffer{}
296319
stdout := &bytes.Buffer{}
297320
inv := Invocation{
@@ -420,7 +443,82 @@ Targets:
420443
}
421444
}
422445

446+
var terminals = []struct {
447+
code string
448+
supportsColor bool
449+
}{
450+
{"", true},
451+
{"vt100", false},
452+
{"cygwin", false},
453+
{"xterm-mono", false},
454+
{"xterm", true},
455+
{"xterm-vt220", true},
456+
{"xterm-16color", true},
457+
{"xterm-256color", true},
458+
{"screen-256color", true},
459+
}
460+
461+
func TestListWithColor(t *testing.T) {
462+
os.Setenv(mg.EnableColorEnv, "true")
463+
os.Setenv(mg.TargetColorEnv, mg.Cyan.String())
464+
465+
expectedPlainText := `
466+
This is a comment on the package which should get turned into output with the list of targets.
467+
468+
Targets:
469+
somePig* This is the synopsis for SomePig.
470+
testVerbose
471+
472+
* default target
473+
`[1:]
474+
475+
// NOTE: using the literal string would be complicated because I would need to break it
476+
// in the middle and join with a normal string for the target names,
477+
// otherwise the single backslash would be taken literally and encoded as \\
478+
expectedColorizedText := "" +
479+
"This is a comment on the package which should get turned into output with the list of targets.\n" +
480+
"\n" +
481+
"Targets:\n" +
482+
" \x1b[36msomePig*\x1b[0m This is the synopsis for SomePig.\n" +
483+
" \x1b[36mtestVerbose\x1b[0m \n" +
484+
"\n" +
485+
"* default target\n"
486+
487+
for _, terminal := range terminals {
488+
t.Run(terminal.code, func(t *testing.T) {
489+
os.Setenv("TERM", terminal.code)
490+
491+
stdout := &bytes.Buffer{}
492+
inv := Invocation{
493+
Dir: "./testdata/list",
494+
Stdout: stdout,
495+
Stderr: ioutil.Discard,
496+
List: true,
497+
}
498+
499+
code := Invoke(inv)
500+
if code != 0 {
501+
t.Errorf("expected to exit with code 0, but got %v", code)
502+
}
503+
actual := stdout.String()
504+
var expected string
505+
if terminal.supportsColor {
506+
expected = expectedColorizedText
507+
} else {
508+
expected = expectedPlainText
509+
}
510+
511+
if actual != expected {
512+
t.Logf("expected: %q", expected)
513+
t.Logf(" actual: %q", actual)
514+
t.Fatalf("expected:\n%v\n\ngot:\n%v", expected, actual)
515+
}
516+
})
517+
}
518+
}
519+
423520
func TestNoArgNoDefaultList(t *testing.T) {
521+
resetTerm()
424522
stdout := &bytes.Buffer{}
425523
stderr := &bytes.Buffer{}
426524
inv := Invocation{
@@ -458,6 +556,7 @@ func TestIgnoreDefault(t *testing.T) {
458556
if err := os.Setenv(mg.IgnoreDefaultEnv, "1"); err != nil {
459557
t.Fatal(err)
460558
}
559+
resetTerm()
461560

462561
code := Invoke(inv)
463562
if code != 0 {
@@ -1286,6 +1385,7 @@ func TestGoCmd(t *testing.T) {
12861385
var runtimeVer = regexp.MustCompile(`go1\.([0-9]+)`)
12871386

12881387
func TestGoModules(t *testing.T) {
1388+
resetTerm()
12891389
matches := runtimeVer.FindStringSubmatch(runtime.Version())
12901390
if len(matches) < 2 || minorVer(t, matches[1]) < 11 {
12911391
t.Skipf("Skipping Go modules test because go version %q is less than go1.11", runtime.Version())

mage/template.go

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,135 @@ Options:
9292
fs.Usage()
9393
return
9494
}
95-
95+
96+
97+
// color is ANSI color type
98+
type color int
99+
100+
// If you add/change/remove any items in this constant,
101+
// you will need to run "stringer -type=color" in this directory again.
102+
// NOTE: Please keep the list in an alphabetical order.
103+
const (
104+
black color = iota
105+
red
106+
green
107+
yellow
108+
blue
109+
magenta
110+
cyan
111+
white
112+
brightblack
113+
brightred
114+
brightgreen
115+
brightyellow
116+
brightblue
117+
brightmagenta
118+
brightcyan
119+
brightwhite
120+
)
121+
122+
// AnsiColor are ANSI color codes for supported terminal colors.
123+
var ansiColor = map[color]string{
124+
black: "\u001b[30m",
125+
red: "\u001b[31m",
126+
green: "\u001b[32m",
127+
yellow: "\u001b[33m",
128+
blue: "\u001b[34m",
129+
magenta: "\u001b[35m",
130+
cyan: "\u001b[36m",
131+
white: "\u001b[37m",
132+
brightblack: "\u001b[30;1m",
133+
brightred: "\u001b[31;1m",
134+
brightgreen: "\u001b[32;1m",
135+
brightyellow: "\u001b[33;1m",
136+
brightblue: "\u001b[34;1m",
137+
brightmagenta: "\u001b[35;1m",
138+
brightcyan: "\u001b[36;1m",
139+
brightwhite: "\u001b[37;1m",
140+
}
141+
142+
const _color_name = "blackredgreenyellowbluemagentacyanwhitebrightblackbrightredbrightgreenbrightyellowbrightbluebrightmagentabrightcyanbrightwhite"
143+
144+
var _color_index = [...]uint8{0, 5, 8, 13, 19, 23, 30, 34, 39, 50, 59, 70, 82, 92, 105, 115, 126}
145+
146+
colorToLowerString := func (i color) string {
147+
if i < 0 || i >= color(len(_color_index)-1) {
148+
return "color(" + strconv.FormatInt(int64(i), 10) + ")"
149+
}
150+
return _color_name[_color_index[i]:_color_index[i+1]]
151+
}
152+
153+
// ansiColorReset is an ANSI color code to reset the terminal color.
154+
const ansiColorReset = "\033[0m"
155+
156+
// defaultTargetAnsiColor is a default ANSI color for colorizing targets.
157+
// It is set to Cyan as an arbitrary color, because it has a neutral meaning
158+
var defaultTargetAnsiColor = ansiColor[cyan]
159+
160+
getAnsiColor := func(color string) (string, bool) {
161+
colorLower := strings.ToLower(color)
162+
for k, v := range ansiColor {
163+
colorConstLower := colorToLowerString(k)
164+
if colorConstLower == colorLower {
165+
return v, true
166+
}
167+
}
168+
return "", false
169+
}
170+
171+
// Terminals which don't support color:
172+
// TERM=vt100
173+
// TERM=cygwin
174+
// TERM=xterm-mono
175+
var noColorTerms = map[string]bool{
176+
"vt100": false,
177+
"cygwin": false,
178+
"xterm-mono": false,
179+
}
180+
181+
// terminalSupportsColor checks if the current console supports color output
182+
//
183+
// Supported:
184+
// linux, mac, or windows's ConEmu, Cmder, putty, git-bash.exe, pwsh.exe
185+
// Not supported:
186+
// windows cmd.exe, powerShell.exe
187+
terminalSupportsColor := func() bool {
188+
envTerm := os.Getenv("TERM")
189+
if _, ok := noColorTerms[envTerm]; ok {
190+
return false
191+
}
192+
return true
193+
}
194+
195+
// enableColor reports whether the user has requested to enable a color output.
196+
enableColor := func() bool {
197+
b, _ := strconv.ParseBool(os.Getenv("MAGEFILE_ENABLE_COLOR"))
198+
return b
199+
}
200+
201+
// targetColor returns the ANSI color which should be used to colorize targets.
202+
targetColor := func() string {
203+
s, exists := os.LookupEnv("MAGEFILE_TARGET_COLOR")
204+
if exists == true {
205+
if c, ok := getAnsiColor(s); ok == true {
206+
return c
207+
}
208+
}
209+
return defaultTargetAnsiColor
210+
}
211+
212+
// store the color terminal variables, so that the detection isn't repeated for each target
213+
var enableColorValue = enableColor() && terminalSupportsColor()
214+
var targetColorValue = targetColor()
215+
216+
printName := func(str string) string {
217+
if enableColorValue {
218+
return fmt.Sprintf("%s%s%s", targetColorValue, str, ansiColorReset)
219+
} else {
220+
return str
221+
}
222+
}
223+
96224
list := func() error {
97225
{{with .Description}}fmt.Println(` + "`{{.}}\n`" + `)
98226
{{- end}}
@@ -117,7 +245,7 @@ Options:
117245
fmt.Println("Targets:")
118246
w := tabwriter.NewWriter(os.Stdout, 0, 4, 4, ' ', 0)
119247
for _, name := range keys {
120-
fmt.Fprintf(w, " %v\t%v\n", name, targets[name])
248+
fmt.Fprintf(w, " %v\t%v\n", printName(name), targets[name])
121249
}
122250
err := w.Flush()
123251
{{- if .DefaultFunc.Name}}

mg/color.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package mg
2+
3+
// Color is ANSI color type
4+
type Color int
5+
6+
// If you add/change/remove any items in this constant,
7+
// you will need to run "stringer -type=Color" in this directory again.
8+
// NOTE: Please keep the list in an alphabetical order.
9+
const (
10+
Black Color = iota
11+
Red
12+
Green
13+
Yellow
14+
Blue
15+
Magenta
16+
Cyan
17+
White
18+
BrightBlack
19+
BrightRed
20+
BrightGreen
21+
BrightYellow
22+
BrightBlue
23+
BrightMagenta
24+
BrightCyan
25+
BrightWhite
26+
)
27+
28+
// AnsiColor are ANSI color codes for supported terminal colors.
29+
var ansiColor = map[Color]string{
30+
Black: "\u001b[30m",
31+
Red: "\u001b[31m",
32+
Green: "\u001b[32m",
33+
Yellow: "\u001b[33m",
34+
Blue: "\u001b[34m",
35+
Magenta: "\u001b[35m",
36+
Cyan: "\u001b[36m",
37+
White: "\u001b[37m",
38+
BrightBlack: "\u001b[30;1m",
39+
BrightRed: "\u001b[31;1m",
40+
BrightGreen: "\u001b[32;1m",
41+
BrightYellow: "\u001b[33;1m",
42+
BrightBlue: "\u001b[34;1m",
43+
BrightMagenta: "\u001b[35;1m",
44+
BrightCyan: "\u001b[36;1m",
45+
BrightWhite: "\u001b[37;1m",
46+
}
47+
48+
// AnsiColorReset is an ANSI color code to reset the terminal color.
49+
const AnsiColorReset = "\033[0m"
50+
51+
// DefaultTargetAnsiColor is a default ANSI color for colorizing targets.
52+
// It is set to Cyan as an arbitrary color, because it has a neutral meaning
53+
var DefaultTargetAnsiColor = ansiColor[Cyan]
54+
55+
func toLowerCase(s string) string {
56+
// this is a naive implementation
57+
// borrowed from https://golang.org/src/strings/strings.go
58+
// and only considers alphabetical characters [a-zA-Z]
59+
// so that we don't depend on the "strings" package
60+
buf := make([]byte, len(s))
61+
for i := 0; i < len(s); i++ {
62+
c := s[i]
63+
if 'A' <= c && c <= 'Z' {
64+
c += 'a' - 'A'
65+
}
66+
buf[i] = c
67+
}
68+
return string(buf)
69+
}
70+
71+
func getAnsiColor(color string) (string, bool) {
72+
colorLower := toLowerCase(color)
73+
for k, v := range ansiColor {
74+
colorConstLower := toLowerCase(k.String())
75+
if colorConstLower == colorLower {
76+
return v, true
77+
}
78+
}
79+
return "", false
80+
}

0 commit comments

Comments
 (0)