Skip to content

Commit 86d2890

Browse files
Andrei-hub11ccoVeillecmilesio
authored
feat: Adds ANSI color support for Windows terminals (#39)
* feat: Adds ANSI color support for Windows terminals and prevents color output when stdout is not a TTY * fix: CI * refactor: streamline ANSI support initialization for Windows terminals * refactor: unify windowsansi into ansi Co-authored-by: ccoVeille <3875889+ccoVeille@users.noreply.github.com> * chore: add simple benchmark --------- Co-authored-by: ccoVeille <3875889+ccoVeille@users.noreply.github.com> Co-authored-by: Chris Miles <chris.miles.e@gmail.com>
1 parent 815ba5c commit 86d2890

11 files changed

Lines changed: 303 additions & 5 deletions

ansi_windows.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//go:build windows
2+
3+
package godump
4+
5+
import (
6+
"log"
7+
8+
"github.com/goforj/godump/internal/ansi"
9+
)
10+
11+
// init activates ANSI support on Windows terminals by calling the Enable
12+
// function from the internal ansi package.
13+
// If enabling ANSI fails (e.g., not running in a real console), it logs
14+
// the error but continues execution, as colors are optional.
15+
func init() {
16+
if err := ansi.Enable(); err != nil {
17+
log.Printf("godump: failed to enable ANSI (likely due to output redirection): %v\n", err)
18+
}
19+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ go 1.18
44

55
require github.com/stretchr/testify v1.10.0
66

7+
require golang.org/x/sys v0.30.0 // indirect
8+
79
require (
810
github.com/davecgh/go-spew v1.1.1 // indirect
911
github.com/pmezard/go-difflib v1.0.0 // indirect
12+
golang.org/x/term v0.29.0
1013
gopkg.in/yaml.v3 v3.0.1 // indirect
1114
)

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
44
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
55
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
66
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
7+
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
8+
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
9+
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
10+
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
711
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
812
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
913
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

godump.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,8 @@ func detectColor() bool {
698698
if os.Getenv("FORCE_COLOR") != "" {
699699
return true
700700
}
701-
return true
701+
702+
return isTerminal(os.Stdout)
702703
}
703704

704705
func newColorizer() Colorizer {

godump_benchmark_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package godump
2+
3+
import (
4+
"io"
5+
"testing"
6+
)
7+
8+
func BenchmarkDumpStruct(b *testing.B) {
9+
type Sample struct {
10+
ID int
11+
Name string
12+
Email string
13+
Meta map[string]any
14+
}
15+
16+
s := Sample{
17+
ID: 1,
18+
Name: "Test User",
19+
Email: "",
20+
Meta: map[string]any{"active": true, "roles": []string{"admin", "user"}},
21+
}
22+
23+
d := NewDumper(WithWriter(io.Discard)) // no console output
24+
25+
b.ReportAllocs() // capture memory allocations
26+
27+
for i := 0; i < b.N; i++ {
28+
d.Dump(s)
29+
}
30+
}

godump_integration_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package godump
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// TestAnsiInNonTty verifies that no ANSI codes are produced when output is redirected.
11+
func TestAnsiInNonTty(t *testing.T) {
12+
// ANSI escape character. We expect this to be ABSENT from the output.
13+
const escape = "\x1b"
14+
15+
// The source code for the program we're going to run.
16+
const sourceCode = `
17+
package main
18+
import "github.com/goforj/godump"
19+
func main() {
20+
s := struct{ Name string }{"test"}
21+
godump.Dump(s)
22+
}
23+
`
24+
25+
// Create a temporary directory to avoid package main collision.
26+
tempDir := t.TempDir()
27+
tempFile, err := os.CreateTemp(tempDir, "test_*.go")
28+
if err != nil {
29+
t.Fatalf("failed to create temp file: %v", err)
30+
}
31+
32+
_, err = tempFile.WriteString(sourceCode)
33+
if err != nil {
34+
t.Fatalf("failed to write temp file: %v", err)
35+
}
36+
tempFile.Close()
37+
38+
// Run the program using `go run`. By capturing the output, we ensure
39+
// that the program's stdout is not a TTY.
40+
//nolint:gosec // tempFile.Name() is a controlled temporary file created by this test
41+
cmd := exec.Command("go", "run", tempFile.Name())
42+
output, err := cmd.CombinedOutput()
43+
if err != nil {
44+
t.Fatalf("failed to run test program: %v\nOutput:\n%s", err, string(output))
45+
}
46+
47+
if strings.Contains(string(output), escape) {
48+
t.Errorf("expected output to NOT contain ANSI escape codes when not in a TTY, but it did. Output:\n%s", string(output))
49+
}
50+
}
51+
52+
// TestAnsiInTty verifies that ANSI codes are produced when FORCE_COLOR is set.
53+
func TestAnsiInTty(t *testing.T) {
54+
// ANSI escape character. We expect this to be PRESENT in the output.
55+
const escape = "\x1b"
56+
57+
// The source code for the program we're going to run.
58+
const sourceCode = `
59+
package main
60+
import "github.com/goforj/godump"
61+
func main() {
62+
s := struct{ Name string }{"test"}
63+
godump.Dump(s)
64+
}
65+
`
66+
// Create a temporary directory to avoid package main collision.
67+
tempDir := t.TempDir()
68+
tempFile, err := os.CreateTemp(tempDir, "test_*.go")
69+
if err != nil {
70+
t.Fatalf("failed to create temp file: %v", err)
71+
}
72+
73+
_, err = tempFile.WriteString(sourceCode)
74+
if err != nil {
75+
t.Fatalf("failed to write temp file: %v", err)
76+
}
77+
tempFile.Close()
78+
79+
// Run the program using `go run`. By capturing the output, we ensure
80+
// that the program's stdout is not a TTY.
81+
//nolint:gosec // tempFile.Name() is a controlled temporary file created by this test
82+
cmd := exec.Command("go", "run", tempFile.Name())
83+
84+
cmd.Env = append(os.Environ(), "FORCE_COLOR=1")
85+
output, err := cmd.CombinedOutput()
86+
if err != nil {
87+
t.Fatalf("failed to run test program: %v\nOutput:\n%s", err, string(output))
88+
}
89+
90+
if !strings.Contains(string(output), escape) {
91+
t.Errorf("expected output to contain ANSI escape codes when FORCE_COLOR is set, but it didn't. Output:\n%s", string(output))
92+
}
93+
}

godump_test.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,14 @@ func TestForceExported(t *testing.T) {
186186

187187
func TestDetectColorVariants(t *testing.T) {
188188
t.Run("no environment variables", func(t *testing.T) {
189-
assert.True(t, detectColor())
190-
191-
out := NewDumper().colorize(colorYellow, "test")
192-
assert.Equal(t, "\x1b[33mtest\x1b[0m", out)
189+
// The result depends on whether stdout is a real terminal during tests
190+
// On Windows with PowerShell, it's usually true; on Unix with redirected output, it's false
191+
result := detectColor()
192+
isTerminalResult := isTerminal(os.Stdout)
193+
t.Logf("detectColor() returned: %v, isTerminal(os.Stdout): %v", result, isTerminalResult)
194+
195+
// The result should match what isTerminal returns for os.Stdout
196+
assert.Equal(t, isTerminalResult, result)
193197
})
194198

195199
t.Run("forcing no color", func(t *testing.T) {

internal/ansi/ansi_windows.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//go:build windows
2+
3+
package ansi
4+
5+
import (
6+
"os"
7+
"syscall"
8+
"unsafe"
9+
)
10+
11+
const (
12+
SYS_CALL_FAILURE = 0
13+
enableVirtualTerminalProcessing = 0x0004
14+
)
15+
16+
// Enable activates ANSI support on Windows terminals by setting the
17+
// ENABLE_VIRTUAL_TERMINAL_PROCESSING flag.
18+
// Returns an error if the output is not a console or if setting the mode fails.
19+
func Enable() error {
20+
21+
// Load kernel32.dll and the necessary procedures dynamically.
22+
// This avoids a hard dependency and allows the program to run on non-Windows
23+
// systems, although this file is guarded by a build tag.
24+
kernel32 := syscall.NewLazyDLL("kernel32.dll")
25+
procGetConsoleMode := kernel32.NewProc("GetConsoleMode")
26+
procSetConsoleMode := kernel32.NewProc("SetConsoleMode")
27+
28+
// Get the handle for standard output.
29+
handle := syscall.Handle(os.Stdout.Fd())
30+
var mode uint32
31+
32+
// GetConsoleMode fails if not in a real console.
33+
ret, _, err := procGetConsoleMode.Call(uintptr(handle), uintptr(unsafe.Pointer(&mode)))
34+
if ret == SYS_CALL_FAILURE {
35+
return err
36+
}
37+
38+
// Add the virtual terminal processing flag to the current mode.
39+
newMode := mode | enableVirtualTerminalProcessing
40+
41+
// Try to set the new console mode.
42+
ret, _, err = procSetConsoleMode.Call(uintptr(handle), uintptr(newMode))
43+
if ret == SYS_CALL_FAILURE {
44+
return err
45+
}
46+
47+
return nil
48+
}

internal/ansi/ansi_windows_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
//go:build windows
2+
3+
package ansi
4+
5+
import (
6+
"os"
7+
"syscall"
8+
"testing"
9+
"unsafe"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// getConsoleMode is a helper to retrieve the current console mode for a given handle.
15+
func getConsoleMode(handle syscall.Handle) (uint32, error) {
16+
var mode uint32
17+
ret, _, err := syscall.NewLazyDLL("kernel32.dll").NewProc("GetConsoleMode").Call(uintptr(handle), uintptr(unsafe.Pointer(&mode)))
18+
if ret == SYS_CALL_FAILURE {
19+
// Note: err may be non-nil even on success, so we must check ret first.
20+
return 0, err
21+
}
22+
return mode, nil
23+
}
24+
25+
// setConsoleMode is a helper to set the console mode for a given handle.
26+
func setConsoleMode(handle syscall.Handle, mode uint32) error {
27+
ret, _, err := syscall.NewLazyDLL("kernel32.dll").NewProc("SetConsoleMode").Call(uintptr(handle), uintptr(mode))
28+
if ret == SYS_CALL_FAILURE {
29+
return err
30+
}
31+
return nil
32+
}
33+
34+
func TestEnable(t *testing.T) {
35+
// This test requires a real Windows console. If stdout is redirected (e.g., in some CI/CD
36+
// environments), GetConsoleMode will fail. In that case, we should skip the test.
37+
handle := syscall.Handle(os.Stdout.Fd())
38+
originalMode, err := getConsoleMode(handle)
39+
if err != nil && err.Error() != "The handle is invalid." {
40+
// "The handle is invalid." is the typical error when not in a console.
41+
// We skip on this specific error.
42+
t.Skipf("cannot get console mode, skipping test: %v", err)
43+
}
44+
45+
// Defer the restoration of the original console mode.
46+
// This ensures that we don't mess up the terminal for subsequent tests.
47+
if err == nil { // Only restore if we successfully got the mode.
48+
defer func() {
49+
err := setConsoleMode(handle, originalMode)
50+
require.NoError(t, err, "failed to restore original console mode")
51+
}()
52+
}
53+
54+
// Run the function we want to test.
55+
err = Enable()
56+
require.NoError(t, err, "Enable() should not return an error in a real console")
57+
58+
// After running Enable(), check the console mode again to see if the flag was set.
59+
newMode, err := getConsoleMode(handle)
60+
require.NoError(t, err, "failed to get new console mode after enabling ANSI")
61+
62+
// Assert that the flag for virtual terminal processing is now set.
63+
flagIsSet := (newMode & enableVirtualTerminalProcessing) != 0
64+
require.True(t, flagIsSet, "ENABLE_VIRTUAL_TERMINAL_PROCESSING flag should have been set")
65+
}

terminal.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package godump
2+
3+
import (
4+
"os"
5+
6+
"golang.org/x/term"
7+
)
8+
9+
// isTerminal checks if the given file is a terminal using the Go standard library.
10+
func isTerminal(f *os.File) bool {
11+
return term.IsTerminal(int(f.Fd()))
12+
}

0 commit comments

Comments
 (0)