Repository: magefile/mage
Pull request #301 (2020-04-25 23:10:46)

#285 add color output for mage list
I've chosen a cyan color, because it's a good contrast on a typically black terminal (better than e.g. blue) and it's neutral, as opposed to e.g. green, red, yellow.

Added one test to verify the colorized output. All other tests remain without the colorized output, because I unset the TERM env var, using `resetTerm` func. I've added a call to the `resetTerm` into the `testmain` func, but it wasn't enough and some tests were still failing so I've added an explicit call to the `resetTerm` to them as well.

natefinch: Sorry this sat around so long, I can't believe it's been 23 days.  Time no longer has meaning, I guess.

I love that you did this without requiring an import of a 3rd party library. 
mirogta: > Sorry this sat around so long, I can't believe it's been 23 days. Time no longer has meaning, I guess.
> 
> I love that you did this without requiring an import of a 3rd party library.

No problem at all, my time is also completely skewed - can't believe it's almost June! I'll crack on the suggestions :-) Thank you
mirogta: @natefinch please have a look if it looks sensible and I'll add more tests around it. I know, TDD and so on, but it would be quite difficult to follow in this case while I was just exploring the best approach.

Please note I've left the new `color.go` file with a test there, even though it's not really used in any code (yet). It has helped me massively to get the code working, which I could then copy&paste with just minor modifications into the `template.go`.

Patch:
diff --git a/mage/template.go b/mage/template.go
index f361ab8..fbe6971 100644
--- a/mage/template.go
+++ b/mage/template.go
@@ -92,7 +92,135 @@ Options:
 		fs.Usage()
 		return
 	}
-	  
+
+		
+	// color is ANSI color type
+	type color int
+
+	// If you add/change/remove any items in this constant,
+	// you will need to run "stringer -type=color" in this directory again.
+	// NOTE: Please keep the list in an alphabetical order.
+	const (
+		black color = iota
+		red
+		green
+		yellow
+		blue
+		magenta
+		cyan
+		white
+		brightblack
+		brightred
+		brightgreen
+		brightyellow
+		brightblue
+		brightmagenta
+		brightcyan
+		brightwhite
+	)
+
+	// AnsiColor are ANSI color codes for supported terminal colors.
+	var ansiColor = map[color]string{
+		black:         "\u001b[30m",
+		red:           "\u001b[31m",
+		green:         "\u001b[32m",
+		yellow:        "\u001b[33m",
+		blue:          "\u001b[34m",
+		magenta:       "\u001b[35m",
+		cyan:          "\u001b[36m",
+		white:         "\u001b[37m",
+		brightblack:   "\u001b[30;1m",
+		brightred:     "\u001b[31;1m",
+		brightgreen:   "\u001b[32;1m",
+		brightyellow:  "\u001b[33;1m",
+		brightblue:    "\u001b[34;1m",
+		brightmagenta: "\u001b[35;1m",
+		brightcyan:    "\u001b[36;1m",
+		brightwhite:   "\u001b[37;1m",
+	}
+	
+	const _color_name = "blackredgreenyellowbluemagentacyanwhitebrightblackbrightredbrightgreenbrightyellowbrightbluebrightmagentabrightcyanbrightwhite"
+
+	var _color_index = [...]uint8{0, 5, 8, 13, 19, 23, 30, 34, 39, 50, 59, 70, 82, 92, 105, 115, 126}
+
+	colorToLowerString := func (i color) string {
+		if i < 0 || i >= color(len(_color_index)-1) {
+			return "color(" + strconv.FormatInt(int64(i), 10) + ")"
+		}
+		return _color_name[_color_index[i]:_color_index[i+1]]
+	}
+
+	// ansiColorReset is an ANSI color code to reset the terminal color.
+	const ansiColorReset = "\033[0m"
+
+	// defaultTargetAnsiColor is a default ANSI color for colorizing targets.
+	// It is set to Cyan as an arbitrary color, because it has a neutral meaning
+	var defaultTargetAnsiColor = ansiColor[cyan]
+
+	getAnsiColor := func(color string) (string, bool) {
+		colorLower := strings.ToLower(color)
+		for k, v := range ansiColor {
+			colorConstLower := colorToLowerString(k)
+			if colorConstLower == colorLower {
+				return v, true
+			}
+		}
+		return "", false
+	}
+
+	// Terminals which  don't support color:
+	// 	TERM=vt100
+	// 	TERM=cygwin
+	// 	TERM=xterm-mono
+    var noColorTerms = map[string]bool{
+		"vt100":      false,
+		"cygwin":     false,
+		"xterm-mono": false,
+	}
+
+	// terminalSupportsColor checks if the current console supports color output
+	//
+	// Supported:
+	// 	linux, mac, or windows's ConEmu, Cmder, putty, git-bash.exe, pwsh.exe
+	// Not supported:
+	// 	windows cmd.exe, powerShell.exe
+	terminalSupportsColor := func() bool {
+		envTerm := os.Getenv("TERM")
+		if _, ok := noColorTerms[envTerm]; ok {
+			return false
+		}
+		return true
+	}
+
+	// enableColor reports whether the user has requested to enable a color output.
+	enableColor := func() bool {
+		b, _ := strconv.ParseBool(os.Getenv("MAGEFILE_ENABLE_COLOR"))
+		return b
+	}
+
+	// targetColor returns the ANSI color which should be used to colorize targets.
+	targetColor := func() string {
+		s, exists := os.LookupEnv("MAGEFILE_TARGET_COLOR")
+		if exists == true {
+			if c, ok := getAnsiColor(s); ok == true {
+				return c
+			}
+		}
+		return defaultTargetAnsiColor
+	}
+
+	// store the color terminal variables, so that the detection isn't repeated for each target
+	var enableColorValue = enableColor() && terminalSupportsColor()
+	var targetColorValue = targetColor()
+
+	printName := func(str string) string {
+		if enableColorValue {
+			return fmt.Sprintf("%s%s%s", targetColorValue, str, ansiColorReset)
+		} else {
+			return str
+		}
+	}
+
 	list := func() error {
 		{{with .Description}}fmt.Println(` + "`{{.}}\n`" + `)
 		{{- end}}
@@ -117,7 +245,7 @@ Options:
 		fmt.Println("Targets:")
 		w := tabwriter.NewWriter(os.Stdout, 0, 4, 4, ' ', 0)
 		for _, name := range keys {
-			fmt.Fprintf(w, "  %v\t%v\n", name, targets[name])
+			fmt.Fprintf(w, "  %v\t%v\n", printName(name), targets[name])
 		}
 		err := w.Flush()
 		{{- if .DefaultFunc.Name}}
diff --git a/mg/color.go b/mg/color.go
new file mode 100644
index 0000000..3e27103
--- /dev/null
+++ b/mg/color.go
@@ -0,0 +1,80 @@
+package mg
+
+// Color is ANSI color type
+type Color int
+
+// If you add/change/remove any items in this constant,
+// you will need to run "stringer -type=Color" in this directory again.
+// NOTE: Please keep the list in an alphabetical order.
+const (
+	Black Color = iota
+	Red
+	Green
+	Yellow
+	Blue
+	Magenta
+	Cyan
+	White
+	BrightBlack
+	BrightRed
+	BrightGreen
+	BrightYellow
+	BrightBlue
+	BrightMagenta
+	BrightCyan
+	BrightWhite
+)
+
+// AnsiColor are ANSI color codes for supported terminal colors.
+var ansiColor = map[Color]string{
+	Black:         "\u001b[30m",
+	Red:           "\u001b[31m",
+	Green:         "\u001b[32m",
+	Yellow:        "\u001b[33m",
+	Blue:          "\u001b[34m",
+	Magenta:       "\u001b[35m",
+	Cyan:          "\u001b[36m",
+	White:         "\u001b[37m",
+	BrightBlack:   "\u001b[30;1m",
+	BrightRed:     "\u001b[31;1m",
+	BrightGreen:   "\u001b[32;1m",
+	BrightYellow:  "\u001b[33;1m",
+	BrightBlue:    "\u001b[34;1m",
+	BrightMagenta: "\u001b[35;1m",
+	BrightCyan:    "\u001b[36;1m",
+	BrightWhite:   "\u001b[37;1m",
+}
+
+// AnsiColorReset is an ANSI color code to reset the terminal color.
+const AnsiColorReset = "\033[0m"
+
+// DefaultTargetAnsiColor is a default ANSI color for colorizing targets.
+// It is set to Cyan as an arbitrary color, because it has a neutral meaning
+var DefaultTargetAnsiColor = ansiColor[Cyan]
+
+func toLowerCase(s string) string {
+	// this is a naive implementation
+	// borrowed from https://golang.org/src/strings/strings.go
+	// and only considers alphabetical characters [a-zA-Z]
+	// so that we don't depend on the "strings" package
+	buf := make([]byte, len(s))
+	for i := 0; i < len(s); i++ {
+		c := s[i]
+		if 'A' <= c && c <= 'Z' {
+			c += 'a' - 'A'
+		}
+		buf[i] = c
+	}
+	return string(buf)
+}
+
+func getAnsiColor(color string) (string, bool) {
+	colorLower := toLowerCase(color)
+	for k, v := range ansiColor {
+		colorConstLower := toLowerCase(k.String())
+		if colorConstLower == colorLower {
+			return v, true
+		}
+	}
+	return "", false
+}
diff --git a/mg/color_string.go b/mg/color_string.go
new file mode 100644
index 0000000..06debca
--- /dev/null
+++ b/mg/color_string.go
@@ -0,0 +1,38 @@
+// Code generated by "stringer -type=Color"; DO NOT EDIT.
+
+package mg
+
+import "strconv"
+
+func _() {
+	// An "invalid array index" compiler error signifies that the constant values have changed.
+	// Re-run the stringer command to generate them again.
+	var x [1]struct{}
+	_ = x[Black-0]
+	_ = x[Red-1]
+	_ = x[Green-2]
+	_ = x[Yellow-3]
+	_ = x[Blue-4]
+	_ = x[Magenta-5]
+	_ = x[Cyan-6]
+	_ = x[White-7]
+	_ = x[BrightBlack-8]
+	_ = x[BrightRed-9]
+	_ = x[BrightGreen-10]
+	_ = x[BrightYellow-11]
+	_ = x[BrightBlue-12]
+	_ = x[BrightMagenta-13]
+	_ = x[BrightCyan-14]
+	_ = x[BrightWhite-15]
+}
+
+const _Color_name = "BlackRedGreenYellowBlueMagentaCyanWhiteBrightBlackBrightRedBrightGreenBrightYellowBrightBlueBrightMagentaBrightCyanBrightWhite"
+
+var _Color_index = [...]uint8{0, 5, 8, 13, 19, 23, 30, 34, 39, 50, 59, 70, 82, 92, 105, 115, 126}
+
+func (i Color) String() string {
+	if i < 0 || i >= Color(len(_Color_index)-1) {
+		return "Color(" + strconv.FormatInt(int64(i), 10) + ")"
+	}
+	return _Color_name[_Color_index[i]:_Color_index[i+1]]
+}
diff --git a/mg/runtime.go b/mg/runtime.go
index 4dbe0b1..9a8de12 100644
--- a/mg/runtime.go
+++ b/mg/runtime.go
@@ -34,6 +34,36 @@ const IgnoreDefaultEnv = "MAGEFILE_IGNOREDEFAULT"
 // mage with the -f flag.
 const HashFastEnv = "MAGEFILE_HASHFAST"
 
+// EnableColorEnv is the environment variable that indicates the user is using
+// a terminal which supports a color output. The default is false for backwards
+// compatibility. When the value is true and the detected terminal does support colors
+// then the list of mage targets will be displayed in ANSI color. When the value
+// is true but the detected terminal does not support colors, then the list of
+// mage targets will be displayed in the default colors (e.g. black and white).
+const EnableColorEnv = "MAGEFILE_ENABLE_COLOR"
+
+// TargetColorEnv is the environment variable that indicates which ANSI color
+// should be used to colorize mage targets. This is only applicable when
+// the MAGEFILE_ENABLE_COLOR environment variable is true.
+// The supported ANSI color names are any of these:
+// - Black
+// - Red
+// - Green
+// - Yellow
+// - Blue
+// - Magenta
+// - Cyan
+// - White
+// - BrightBlack
+// - BrightRed
+// - BrightGreen
+// - BrightYellow
+// - BrightBlue
+// - BrightMagenta
+// - BrightCyan
+// - BrightWhite
+const TargetColorEnv = "MAGEFILE_TARGET_COLOR"
+
 // Verbose reports whether a magefile was run with the verbose flag.
 func Verbose() bool {
 	b, _ := strconv.ParseBool(os.Getenv(VerboseEnv))
@@ -85,5 +115,22 @@ func CacheDir() string {
 	}
 }
 
+// EnableColor reports whether the user has requested to enable a color output.
+func EnableColor() bool {
+	b, _ := strconv.ParseBool(os.Getenv(EnableColorEnv))
+	return b
+}
+
+// TargetColor returns the configured ANSI color name a color output.
+func TargetColor() string {
+	s, exists := os.LookupEnv(TargetColorEnv)
+	if exists {
+		if c, ok := getAnsiColor(s); ok {
+			return c
+		}
+	}
+	return DefaultTargetAnsiColor
+}
+
 // Namespace allows for the grouping of similar commands
 type Namespace struct{}
diff --git a/site/content/environment/_index.en.md b/site/content/environment/_index.en.md
index f58829e..33d774d 100644
--- a/site/content/environment/_index.en.md
+++ b/site/content/environment/_index.en.md
@@ -7,7 +7,7 @@ weight = 40
 
 Set to "1" or "true" to turn on verbose mode (like running with -v)
 
-## MAGEFILE_DEBUG 
+## MAGEFILE_DEBUG
 
 Set to "1" or "true" to turn on debug mode (like running with -debug)
 
@@ -31,4 +31,45 @@ If set to "1" or "true", tells mage to use a quick hash of magefiles to
 determine whether or not the magefile binary needs to be rebuilt. This results
 in faster run times (especially on Windows), but means that mage will fail to
 rebuild if a dependency has changed. To force a rebuild when you know or suspect
-a dependency has changed, run mage with the -f flag.
\ No newline at end of file
+a dependency has changed, run mage with the -f flag.
+
+## MAGEFILE_ENABLE_COLOR
+
+If set to "1" or "true", tells the compiled magefile to print the list of target
+when you run `mage` or `mage -l` in ANSI colors.
+
+The default is false for backwards compatibility.
+
+When the value is true and the detected terminal does support colors
+then the list of mage targets will be displayed in ANSI color.
+
+When the value is true but the detected terminal does not support colors,
+then the list of mage targets will be displayed in the default colors
+(e.g. black and white).
+
+## MAGEFILE_TARGET_COLOR
+
+Sets the target ANSI color name which should be used to colorize mage targets.
+Only set this when you also set the `MAGEFILE_ENABLE_COLOR` environment
+variable to true and want to override the default target ANSI color (Cyan).
+
+The supported ANSI color names are any of these:
+
+- Black
+- Red
+- Green
+- Yellow
+- Blue
+- Magenta
+- Cyan
+- White
+- BrightBlack
+- BrightRed
+- BrightGreen
+- BrightYellow
+- BrightBlue
+- BrightMagenta
+- BrightCyan
+- BrightWhite
+
+The names are case-insensitive.


Test patch:
diff --git a/mage/main_test.go b/mage/main_test.go
index defd509..9fd5dbb 100644
--- a/mage/main_test.go
+++ b/mage/main_test.go
@@ -57,9 +57,31 @@ func testmain(m *testing.M) int {
 	if err := os.Unsetenv(mg.IgnoreDefaultEnv); err != nil {
 		log.Fatal(err)
 	}
+	if err := os.Setenv(mg.CacheEnv, dir); err != nil {
+		log.Fatal(err)
+	}
+	if err := os.Unsetenv(mg.EnableColorEnv); err != nil {
+		log.Fatal(err)
+	}
+	if err := os.Unsetenv(mg.TargetColorEnv); err != nil {
+		log.Fatal(err)
+	}
+	resetTerm()
 	return m.Run()
 }
 
+func resetTerm() {
+	if term, exists := os.LookupEnv("TERM"); exists {
+		log.Printf("Current terminal: %s", term)
+		// unset TERM env var in order to disable color output to make the tests simpler
+		// there is a specific test for colorized output, so all the other tests can use non-colorized one
+		if err := os.Unsetenv("TERM"); err != nil {
+			log.Fatal(err)
+		}
+	}
+	os.Setenv(mg.EnableColorEnv, "false")
+}
+
 func TestTransitiveDepCache(t *testing.T) {
 	cache, err := internal.OutputDebug("go", "env", "GOCACHE")
 	if err != nil {
@@ -292,6 +314,7 @@ func TestListMagefilesLib(t *testing.T) {
 }
 
 func TestMixedMageImports(t *testing.T) {
+	resetTerm()
 	stderr := &bytes.Buffer{}
 	stdout := &bytes.Buffer{}
 	inv := Invocation{
@@ -420,7 +443,82 @@ Targets:
 	}
 }
 
+var terminals = []struct {
+	code          string
+	supportsColor bool
+}{
+	{"", true},
+	{"vt100", false},
+	{"cygwin", false},
+	{"xterm-mono", false},
+	{"xterm", true},
+	{"xterm-vt220", true},
+	{"xterm-16color", true},
+	{"xterm-256color", true},
+	{"screen-256color", true},
+}
+
+func TestListWithColor(t *testing.T) {
+	os.Setenv(mg.EnableColorEnv, "true")
+	os.Setenv(mg.TargetColorEnv, mg.Cyan.String())
+
+	expectedPlainText := `
+This is a comment on the package which should get turned into output with the list of targets.
+
+Targets:
+  somePig*       This is the synopsis for SomePig.
+  testVerbose    
+
+* default target
+`[1:]
+
+	// NOTE: using the literal string would be complicated because I would need to break it
+	// in the middle and join with a normal string for the target names,
+	// otherwise the single backslash would be taken literally and encoded as \\
+	expectedColorizedText := "" +
+		"This is a comment on the package which should get turned into output with the list of targets.\n" +
+		"\n" +
+		"Targets:\n" +
+		"  \x1b[36msomePig*\x1b[0m       This is the synopsis for SomePig.\n" +
+		"  \x1b[36mtestVerbose\x1b[0m    \n" +
+		"\n" +
+		"* default target\n"
+
+	for _, terminal := range terminals {
+		t.Run(terminal.code, func(t *testing.T) {
+			os.Setenv("TERM", terminal.code)
+
+			stdout := &bytes.Buffer{}
+			inv := Invocation{
+				Dir:    "./testdata/list",
+				Stdout: stdout,
+				Stderr: ioutil.Discard,
+				List:   true,
+			}
+
+			code := Invoke(inv)
+			if code != 0 {
+				t.Errorf("expected to exit with code 0, but got %v", code)
+			}
+			actual := stdout.String()
+			var expected string
+			if terminal.supportsColor {
+				expected = expectedColorizedText
+			} else {
+				expected = expectedPlainText
+			}
+
+			if actual != expected {
+				t.Logf("expected: %q", expected)
+				t.Logf("  actual: %q", actual)
+				t.Fatalf("expected:\n%v\n\ngot:\n%v", expected, actual)
+			}
+		})
+	}
+}
+
 func TestNoArgNoDefaultList(t *testing.T) {
+	resetTerm()
 	stdout := &bytes.Buffer{}
 	stderr := &bytes.Buffer{}
 	inv := Invocation{
@@ -458,6 +556,7 @@ func TestIgnoreDefault(t *testing.T) {
 	if err := os.Setenv(mg.IgnoreDefaultEnv, "1"); err != nil {
 		t.Fatal(err)
 	}
+	resetTerm()
 
 	code := Invoke(inv)
 	if code != 0 {
@@ -1286,6 +1385,7 @@ func TestGoCmd(t *testing.T) {
 var runtimeVer = regexp.MustCompile(`go1\.([0-9]+)`)
 
 func TestGoModules(t *testing.T) {
+	resetTerm()
 	matches := runtimeVer.FindStringSubmatch(runtime.Version())
 	if len(matches) < 2 || minorVer(t, matches[1]) < 11 {
 		t.Skipf("Skipping Go modules test because go version %q is less than go1.11", runtime.Version())
diff --git a/mg/color_test.go b/mg/color_test.go
new file mode 100644
index 0000000..f27a5e7
--- /dev/null
+++ b/mg/color_test.go
@@ -0,0 +1,34 @@
+package mg
+
+import (
+	"os"
+	"testing"
+)
+
+func TestValidTargetColor(t *testing.T) {
+	os.Setenv(EnableColorEnv, "true")
+	os.Setenv(TargetColorEnv, "Yellow")
+	expected := "\u001b[33m"
+	if actual := TargetColor(); actual != expected {
+		t.Fatalf("expected %v but got %s", expected, actual)
+	}
+}
+
+func TestValidTargetColorCaseInsensitive(t *testing.T) {
+	os.Setenv(EnableColorEnv, "true")
+	os.Setenv(TargetColorEnv, "rED")
+	expected := "\u001b[31m"
+	if actual := TargetColor(); actual != expected {
+		t.Fatalf("expected %v but got %s", expected, actual)
+	}
+}
+
+func TestInvalidTargetColor(t *testing.T) {
+	os.Setenv(EnableColorEnv, "true")
+	// NOTE: Brown is not a defined Color constant
+	os.Setenv(TargetColorEnv, "Brown")
+	expected := DefaultTargetAnsiColor
+	if actual := TargetColor(); actual != expected {
+		t.Fatalf("expected %v but got %s", expected, actual)
+	}
+}