- 
                Notifications
    You must be signed in to change notification settings 
- Fork 32
feat: Adds ANSI color support for Windows terminals #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
41020b5    to
    3c38e93      
    Compare
  
    | Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! | 
022bde1    to
    ae774fa      
    Compare
  
    There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would move these terminal/ansi file to a dedicated internal package
I'm a bit afraid it would add complexity to someone reading the code. You could then add a README or godoc package explaining why this solution was preferred to known lib like isatty or /x/terms
I'm fine with your idea, I just think it should be moved in a dedicated place
        
          
                terminal_unix.go
              
                Outdated
          
        
      | "testing" | ||
| ) | ||
|  | ||
| var isTestEnv = testing.Testing | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add a comment about the fact this variable exist to be overwritten in test if needed for test purpose.
Also please consider to move it to a new file named terminal.go that might only contain this variable.
You are repeating code in terminal_unix.go terminal_windows.go
| 
 @ccoVeille Yeah, you might be right. I initially intended to leave it as a draft and ask for your help with it, because the logic is indeed complex, even though it’s not many lines and other libraries do something similar to support Windows. Also, even if we decide to move the support to an internal package, we would still need to keep the check to know whether the output is being redirected to a file, so that ANSI codes aren’t added where they would only create noise. | 
ae4f99b    to
    58045bf      
    Compare
  
    | func TestIsTerminal_Windows(t *testing.T) { | ||
| // Mock IsTestEnv to bypass the test environment check | ||
| originalIsTestEnv := IsTestEnv | ||
| IsTestEnv = func() bool { return false } | ||
| defer func() { IsTestEnv = originalIsTestEnv }() | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please consider this
| func TestIsTerminal_Windows(t *testing.T) { | |
| // Mock IsTestEnv to bypass the test environment check | |
| originalIsTestEnv := IsTestEnv | |
| IsTestEnv = func() bool { return false } | |
| defer func() { IsTestEnv = originalIsTestEnv }() | |
| // MockIsTestEnv bypasses the test environment check | |
| func MockIsTestEnv(t *testing. T, value bool) { | |
| t.Helper () | |
| originalIsTestEnv := IsTestEnv | |
| IsTestEnv = func() bool { return value } | |
| t.Cleanup(func() { IsTestEnv = originalIsTestEnv }) | |
| } | |
| func TestIsTerminal_Windows(t *testing.T) { | |
| MockIsTestEnv(t, false) | 
The mock func could be move to terminal.go
58045bf    to
    a2a43d2      
    Compare
  
    | @ccoVeille Hey, I refactored the check to use a more meaningful approach: one that relies on a Go-maintained package and properly handles the complexity of detecting terminals. I also tried writing an integration-style test like this: …but honestly, it didn’t feel quite right, and was actually problematic in practice. So I ended up removing it. Do you happen to see a better way to test this? | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wouldn't add integration test.
The lib you import are tested.
The only thing you need I think is to test you the behavior of the code when you overload the result of IsTerminal
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exactly. We're on the same page. I just got the impression you thought I was still using a mock for that.
| tmpFile, err := os.CreateTemp("", "test-is-terminal") | ||
| assert.NoError(t, err) | ||
| defer os.Remove(tmpFile.Name()) | ||
| defer tmpFile.Close() | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is enough
| tmpFile, err := os.CreateTemp("", "test-is-terminal") | |
| assert.NoError(t, err) | |
| defer os.Remove(tmpFile.Name()) | |
| defer tmpFile.Close() | |
| tmpFile, err := os.CreateTemp(t.TmpDir(), "test-is-terminal") | |
| assert.NoError(t, err) | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please consider this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't get the purpose of this.
It's a package that does things, OK.
The Enable is called via an init when using windows, OK.
But then?
I mean how does that interact or has effect of the fact godump calls term.IsTerminal ?
I feel like the windowansi package and its initialization leads to something that is not used.
What am I missing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
windowsansi.Enable() enables ANSI color support on Windows terminals. On the other hand, IsTerminal ensures that color output is only shown when the output is actually a terminal. For example, it prevents color codes from appearing when output is redirected to a file.
So while they handle different concerns, they work together: one enables support, the other controls when it's appropriate to use it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel they should in the same module then
I mean the init call to windowansi.Enable made for window module could be in the terminal module
| Where is this at ? I see a lot of open threads | 
| Also, from what I understand, ANSI support is enabled by default in newer versions of windows and terminals (needs verification). If that is the case it'd be ideal to not have to do all of this. | 
| It’s true that recent versions of Windows have ANSI support, but it’s not always enabled in the console or in all situations. In CMD, some built-in commands already support ANSI without needing to enable anything. The attached images show this behavior. In my case, I had to enable it manually. The fatih/color project, written in Go, also had to implement something similar to enable ANSI support on Windows. | 
| @ccoVeille since you handled a lot of the review here - what's your take on where this PR stands ? | 
| ret, _, err := syscall.NewLazyDLL("kernel32.dll").NewProc("GetConsoleMode").Call(uintptr(handle), uintptr(unsafe.Pointer(&mode))) | ||
| if ret == 0 { | ||
| // In Go 1.16+, err is not nil on failure. On older versions, it might be. | ||
| // So we return the error from the syscall call directly. | ||
| return 0, err | ||
| } | ||
| return mode, nil | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is something I don't get either with the code or comment.
It's a question of logic.
The comment is "In Go 1.16+, err is not nil on failure. On older versions, it might be."
And here is the code
	ret, _, err := syscall.NewLazyDLL("kernel32.dll").NewProc("GetConsoleMode").Call(uintptr(handle), uintptr(unsafe.Pointer(&mode)))
	if ret == 0 {
		// In Go 1.16+, err is not nil on failure. On older versions, it might be.
		// So we return the error from the syscall call directly.
		return 0, err
	}
	return mode, nilThe comment is about having a nil error on failure for version prior Go 1.16
So the code should be this, no ?
| ret, _, err := syscall.NewLazyDLL("kernel32.dll").NewProc("GetConsoleMode").Call(uintptr(handle), uintptr(unsafe.Pointer(&mode))) | |
| if ret == 0 { | |
| // In Go 1.16+, err is not nil on failure. On older versions, it might be. | |
| // So we return the error from the syscall call directly. | |
| return 0, err | |
| } | |
| return mode, nil | |
| ret, _, err := syscall.NewLazyDLL("kernel32.dll").NewProc("GetConsoleMode").Call(uintptr(handle), uintptr(unsafe.Pointer(&mode))) | |
| if err != nil { | |
| return 0, err | |
| } | |
| if ret == 0 { | |
| // For Go versions older than 1.16, err could be nil on failure | |
| return 0, errors.New("blah blah") | |
| } | |
| return mode, nil | 
If the comment is wrong, and the code behaving like an error might be returned on success. Then the code should be this
	ret, _, err := syscall.NewLazyDLL("kernel32.dll").NewProc("GetConsoleMode").Call(uintptr(handle), uintptr(unsafe.Pointer(&mode)))
	if err != nil && ret != 0 {
		return 0, err
	}
	return mode, nilThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation says:
“The returned error is always non-nil, constructed from the result of GetLastError. Callers must inspect the primary return value to decide whether an error occurred (according to the semantics of the specific function being called) before consulting the error.”
But since the comment was a bit confusing anyway, I’ve updated it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I checked kernel32 package it's indeed far from being idiomatic or obvious. Your code is fine
        
          
                internal/ansi/ansi_windows_test.go
              
                Outdated
          
        
      | if ret == 0 { | ||
| return err | ||
| } | ||
| return nil | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here also, there is something to do, at least a comment
| // GetConsoleMode fails if not in a real console. | ||
| ret, _, _ := procGetConsoleMode.Call(uintptr(handle), uintptr(unsafe.Pointer(&mode))) | ||
| if ret == 0 { | ||
| return | ||
| } | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error is ignored, it could be returned via Enable() error
It would be cleaner. Even if for now, Enable is called via init that would ignore it.
See a suggestion I'm doing here
| tmpFile, err := os.CreateTemp("", "test-is-terminal") | ||
| assert.NoError(t, err) | ||
| defer os.Remove(tmpFile.Name()) | ||
| defer tmpFile.Close() | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please consider this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would remove this file, and rework the windowsansi package to something like this.
internal/ansi/ansi_windows.go
//go:build windows
package ansi
// remove the init thing
func Enable() error {
   // Then the content of internal/windowsansi/winansi.go
   return nil
}internal/ansi/ansi_others.go
//go:build !windows
package ansi
func Enable() error {
   return nil
}Then call ansi.Enable from godump when needed. Then you can catch the error returned by the DDL methods
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But if the windows lib require to use init, I would do this
internal/fixansi/ansi_windows.go
//go:build windows
package fixansi
func init() {
  _ = enable()
}
// enable is here to be able to test it
func enable() error {
  // Then the content of internal/windowsansi/winansi.go
}And then
In godump, I would use this
import (
	// ...
	_ internal/fixansi
)I would prefer the first option, or the exiting code currently in the PR
a2a43d2    to
    b456762      
    Compare
  
    | @ccoVeille see if it’s better now or if I misunderstood any of your comments. | 
        
          
                internal/ansi/ansi_windows.go
              
                Outdated
          
        
      |  | ||
| // GetConsoleMode fails if not in a real console. | ||
| ret, _, err := procGetConsoleMode.Call(uintptr(handle), uintptr(unsafe.Pointer(&mode))) | ||
| if ret == 0 { | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This no ?
| if ret == 0 { | |
| if ret != 0 { | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No. Take a look:
"If the function succeeds, the return value is nonzero. If the function fails, the return value is zero."
        
          
                internal/ansi/ansi_windows.go
              
                Outdated
          
        
      |  | ||
| // Try to set the new console mode. | ||
| ret, _, err = procSetConsoleMode.Call(uintptr(handle), uintptr(newMode)) | ||
| if ret == 0 { | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| if ret == 0 { | |
| if ret != 0 { | 
        
          
                internal/ansi/ansi_windows.go
              
                Outdated
          
        
      |  | ||
| // GetConsoleMode fails if not in a real console. | ||
| ret, _, err := procGetConsoleMode.Call(uintptr(handle), uintptr(unsafe.Pointer(&mode))) | ||
| if ret == 0 { | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would use a constant
something like
const syscallNoError = 0It would make it clearer when comparing to 0
| 
 Thanks for doing the change. I hope you found interest in the suggestions I made | 
        
          
                internal/ansi/ansi_windows.go
              
                Outdated
          
        
      |  | ||
| // GetConsoleMode fails if not in a real console. | ||
| ret, _, err := procGetConsoleMode.Call(uintptr(handle), uintptr(unsafe.Pointer(&mode))) | ||
| if ret == 0 { | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What about something like this?
Maybe it would be simpler to read, and more idiomatic
ret, _, err := procGetConsoleMode.Call(uintptr(handle), uintptr(unsafe.Pointer(&mode)))
if err != nil && ret != syscallNoError {
  return err
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the previous answer explains it.
b456762    to
    0fb5b1c      
    Compare
  
    | 
 Yeah, I did. And I appreciate it. Take a new look at the forced commit. @Akkadius, add @ccoVeille as co-author if it gets approved at some point '--------- Co-authored-by: ccoVeille [email protected]' | 
| It works very well here: go run demo/main.go > "C:\path\to\demo_output.txt"
2025/10/15 18:13:13 Warning: Failed to enable ANSI support: Invalid identifier.Output (demo_output.txt): --- godump.Dump(user) ---
<#dump // demo\main.go:35
User: Alice #main.User
--- output := godump.DumpStr(user) ---
<#dump // demo\main.go:39
User: Alice #main.User
--- html := godump.DumpHTML(user) ---
<div style='background-color:black;'><pre style="background-color:black; color:white; padding:5px; border-radius: 5px">
<span style="color:#999"><#dump // demo\main.go:44</span>
<span style="color:#80ff80">User: Alice</span><span style="color:#999"> #main.User</span>
</pre></div>
--- godump.DumpJSON(user) ---
{
  "Name": "Alice",
  "Profile": {
    "Age": 30,
    "Email": "[email protected]"
  }
} | 
| 
 You know that as the commit author you can do it Take a look at your last commit message You simply have to edit it like this I'm not even sure Akkadius can do it for you without altering your commit in this exact way. | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM 👍
A minor comment anyway
| 
 Actually, he can include that in the merge commit without any problem, but I’ll go ahead and redo the commit message. That’s totally fine | 
0fb5b1c    to
    3e67219      
    Compare
  
    Co-authored-by: ccoVeille <[email protected]>
3e67219    to
    345aae0      
    Compare
  
    
This PR introduces ANSI color support for Windows terminals and ensures
dump output does not pollute automated scripts or log files.
ansi_windows.go.Before:
After: