Skip to content

Commit

Permalink
#285 add color output for mage list (#301)
Browse files Browse the repository at this point in the history
* #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
  • Loading branch information
mirogta authored Jul 8, 2020
1 parent 310e198 commit 9a10961
Show file tree
Hide file tree
Showing 7 changed files with 472 additions and 4 deletions.
100 changes: 100 additions & 0 deletions mage/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -292,6 +314,7 @@ func TestListMagefilesLib(t *testing.T) {
}

func TestMixedMageImports(t *testing.T) {
resetTerm()
stderr := &bytes.Buffer{}
stdout := &bytes.Buffer{}
inv := Invocation{
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
Expand Down
132 changes: 130 additions & 2 deletions mage/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand All @@ -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}}
Expand Down
80 changes: 80 additions & 0 deletions mg/color.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 9a10961

Please sign in to comment.