diff --git a/log/term/colorwriter_windows.go b/log/term/colorwriter_windows.go index 4d2d673..fcacda3 100644 --- a/log/term/colorwriter_windows.go +++ b/log/term/colorwriter_windows.go @@ -25,7 +25,7 @@ // platform support for ANSI color codes. If w is not a terminal it is // returned unmodified. func NewColorWriter(w io.Writer) io.Writer { - if !IsTerminal(w) { + if !IsConsole(w) { return w } diff --git a/log/term/terminal_windows.go b/log/term/terminal_windows.go index 5e797f4..753fd12 100644 --- a/log/term/terminal_windows.go +++ b/log/term/terminal_windows.go @@ -8,7 +8,9 @@ package term import ( + "encoding/binary" "io" + "regexp" "syscall" "unsafe" ) @@ -16,16 +18,85 @@ var kernel32 = syscall.NewLazyDLL("kernel32.dll") var ( - procGetConsoleMode = kernel32.NewProc("GetConsoleMode") + procGetFileInformationByHandleEx = kernel32.NewProc("GetFileInformationByHandleEx") + msysPipeNameRegex = regexp.MustCompile(`\\(cygwin|msys)-\w+-pty\d?-(to|from)-master`) +) + +const ( + fileNameInfo = 0x02 ) // IsTerminal returns true if w writes to a terminal. func IsTerminal(w io.Writer) bool { - fw, ok := w.(fder) - if !ok { + return IsConsole(w) || IsMSYSTerminal(w) +} + +// IsConsole returns true if w writes to a Windows console. +func IsConsole(w io.Writer) bool { + var handle syscall.Handle + + if fw, ok := w.(fder); ok { + handle = syscall.Handle(fw.Fd()) + } else { + // The writer has no file-descriptor and so can't be a terminal. return false } + var st uint32 - r, _, e := syscall.Syscall(procGetConsoleMode.Addr(), 2, fw.Fd(), uintptr(unsafe.Pointer(&st)), 0) - return r != 0 && e == 0 + err := syscall.GetConsoleMode(handle, &st) + + // If the handle is attached to a terminal, GetConsoleMode returns a + // non-zero value containing the console mode flags. We don't care about + // the specifics of flags, just that it is not zero. + return (err == nil && st != 0) } + +// IsMSYSTerminal returns true if w writes to a MSYS/MSYS2 terminal. +func IsMSYSTerminal(w io.Writer) bool { + var handle syscall.Handle + + if fw, ok := w.(fder); ok { + handle = syscall.Handle(fw.Fd()) + } else { + // The writer has no file-descriptor and so can't be a terminal. + return false + } + + // MSYS(2) terminal reports as a pipe for STDIN/STDOUT/STDERR. If it isn't + // a pipe, it can't be a MSYS(2) terminal. + filetype, err := syscall.GetFileType(handle) + + if filetype != syscall.FILE_TYPE_PIPE || err != nil { + return false + } + + // MSYS2/Cygwin terminal's name looks like: \msys-dd50a72ab4668b33-pty2-to-master + data := make([]byte, 256, 256) + + r, _, e := syscall.Syscall6( + procGetFileInformationByHandleEx.Addr(), + 4, + uintptr(handle), + uintptr(fileNameInfo), + uintptr(unsafe.Pointer(&data[0])), + uintptr(len(data)), + 0, + 0, + ) + + if r != 0 && e == 0 { + // The first 4 bytes of the buffer are the size of the UTF16 name, in bytes. + unameLen := binary.LittleEndian.Uint32(data[:4]) / 2 + uname := make([]uint16, unameLen, unameLen) + + for i := uint32(0); i < unameLen; i++ { + uname[i] = binary.LittleEndian.Uint16(data[i*2+4 : i*2+2+4]) + } + + name := syscall.UTF16ToString(uname) + + return msysPipeNameRegex.MatchString(name) + } + + return false +} diff --git a/log/term/terminal_windows_test.go b/log/term/terminal_windows_test.go new file mode 100644 index 0000000..b6da8e3 --- /dev/null +++ b/log/term/terminal_windows_test.go @@ -0,0 +1,71 @@ +package term + +import ( + "fmt" + "syscall" + "testing" +) + +// +build windows + +type myWriter struct { + fd uintptr +} + +func (w *myWriter) Write(p []byte) (int, error) { + return 0, fmt.Errorf("not implemented") +} + +func (w *myWriter) Fd() uintptr { + return w.fd +} + +var procGetStdHandle = kernel32.NewProc("GetStdHandle") + +const stdOutputHandle = ^uintptr(0) - 11 + 1 + +func getConsoleHandle() syscall.Handle { + ptr, err := syscall.UTF16PtrFromString("CONOUT$") + + if err != nil { + panic(err) + } + + handle, err := syscall.CreateFile(ptr, syscall.GENERIC_READ|syscall.GENERIC_WRITE, syscall.FILE_SHARE_READ, nil, syscall.OPEN_EXISTING, 0, 0) + + if err != nil { + panic(err) + } + + return handle +} + +func TestIsTerminal(t *testing.T) { + // This is necessary because depending on whether `go test` is called with + // the `-v` option, stdout will or will not be bound, changing the behavior + // of the test. So we refer to it directly to avoid flakyness. + handle := getConsoleHandle() + + writer := &myWriter{ + fd: uintptr(handle), + } + + if !IsTerminal(writer) { + t.Errorf("output is supposed to be a terminal") + } +} + +func TestIsConsole(t *testing.T) { + // This is necessary because depending on whether `go test` is called with + // the `-v` option, stdout will or will not be bound, changing the behavior + // of the test. So we refer to it directly to avoid flakyness. + handle := getConsoleHandle() + + writer := &myWriter{ + fd: uintptr(handle), + } + + if !IsConsole(writer) { + t.Errorf("output is supposed to be a console") + } +}