Skip to content

Commit c8e605e

Browse files
committed
feat: Adds ANSI color support for Windows terminals and prevents color output
when stdout is not a TTY
1 parent c7f103e commit c8e605e

File tree

3 files changed

+153
-2
lines changed

3 files changed

+153
-2
lines changed

ansi_windows.go

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

godump.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"reflect"
1010
"runtime"
1111
"strings"
12+
"syscall"
1213
"text/tabwriter"
1314
"unicode/utf8"
1415
"unsafe"
@@ -619,5 +620,25 @@ func detectColor() bool {
619620
if os.Getenv("FORCE_COLOR") != "" {
620621
return true
621622
}
622-
return true
623-
}
623+
624+
return isTerminal(os.Stdout)
625+
}
626+
627+
// isTerminal checks if the given file is a terminal.
628+
// Uses GetConsoleMode on Windows, ModeCharDevice on Unix.
629+
func isTerminal(f *os.File) bool {
630+
if runtime.GOOS == "windows" {
631+
var mode uint32
632+
// GetConsoleMode succeeds only for console handles
633+
// Fails for redirected/piped output
634+
err := syscall.GetConsoleMode(syscall.Handle(f.Fd()), &mode)
635+
return err == nil
636+
}
637+
638+
// Unix: ModeCharDevice works reliably
639+
fileInfo, err := f.Stat()
640+
if err != nil {
641+
return false
642+
}
643+
return (fileInfo.Mode() & os.ModeCharDevice) != 0
644+
}

godump_integration_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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+
if _, err := tempFile.WriteString(sourceCode); err != nil {
33+
t.Fatalf("failed to write temp file: %v", err)
34+
}
35+
tempFile.Close()
36+
37+
// Run the program using `go run`. By capturing the output, we ensure
38+
// that the program's stdout is not a TTY.
39+
cmd := exec.Command("go", "run", tempFile.Name())
40+
output, err := cmd.CombinedOutput()
41+
if err != nil {
42+
t.Fatalf("failed to run test program: %v\nOutput:\n%s", err, string(output))
43+
}
44+
45+
if strings.Contains(string(output), escape) {
46+
t.Errorf("expected output to NOT contain ANSI escape codes when not in a TTY, but it did. Output:\n%s", string(output))
47+
}
48+
}
49+
50+
// TestAnsiInTty verifies that ANSI codes are produced when FORCE_COLOR is set.
51+
func TestAnsiInTty(t *testing.T) {
52+
// ANSI escape character. We expect this to be PRESENT in the output.
53+
const escape = "\x1b"
54+
55+
// The source code for the program we're going to run.
56+
const sourceCode = `
57+
package main
58+
import "github.com/goforj/godump"
59+
func main() {
60+
s := struct{ Name string }{"test"}
61+
godump.Dump(s)
62+
}
63+
`
64+
// Create a temporary directory to avoid package main collision.
65+
tempDir := t.TempDir()
66+
tempFile, err := os.CreateTemp(tempDir, "test_*.go")
67+
if err != nil {
68+
t.Fatalf("failed to create temp file: %v", err)
69+
}
70+
71+
if _, err := tempFile.WriteString(sourceCode); err != nil {
72+
t.Fatalf("failed to write temp file: %v", err)
73+
}
74+
tempFile.Close()
75+
76+
// Run the program using `go run`. By capturing the output, we ensure
77+
// that the program's stdout is not a TTY.
78+
cmd := exec.Command("go", "run", tempFile.Name())
79+
80+
cmd.Env = append(os.Environ(), "FORCE_COLOR=1")
81+
output, err := cmd.CombinedOutput()
82+
if err != nil {
83+
t.Fatalf("failed to run test program: %v\nOutput:\n%s", err, string(output))
84+
}
85+
86+
if !strings.Contains(string(output), escape) {
87+
t.Errorf("expected output to contain ANSI escape codes when FORCE_COLOR is set, but it didn't. Output:\n%s", string(output))
88+
}
89+
}

0 commit comments

Comments
 (0)