diff --git a/mage/main_test.go b/mage/main_test.go index defd5098..9fd5dbb4 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/mage/template.go b/mage/template.go index f361ab82..fbe69719 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 00000000..3e271033 --- /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 00000000..06debca5 --- /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/color_test.go b/mg/color_test.go new file mode 100644 index 00000000..f27a5e7f --- /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) + } +} diff --git a/mg/runtime.go b/mg/runtime.go index 4dbe0b14..9a8de12c 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 f58829e2..33d774d6 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.