1package colorprofile
2
3import (
4 "bytes"
5 "io"
6 "os/exec"
7 "runtime"
8 "strconv"
9 "strings"
10
11 "github.com/charmbracelet/x/term"
12 "github.com/xo/terminfo"
13)
14
15const dumbTerm = "dumb"
16
17// Detect returns the color profile based on the terminal output, and
18// environment variables. This respects NO_COLOR, CLICOLOR, and CLICOLOR_FORCE
19// environment variables.
20//
21// The rules as follows:
22// - TERM=dumb is always treated as NoTTY unless CLICOLOR_FORCE=1 is set.
23// - If COLORTERM=truecolor, and the profile is not NoTTY, it gest upgraded to TrueColor.
24// - Using any 256 color terminal (e.g. TERM=xterm-256color) will set the profile to ANSI256.
25// - Using any color terminal (e.g. TERM=xterm-color) will set the profile to ANSI.
26// - Using CLICOLOR=1 without TERM defined should be treated as ANSI if the
27// output is a terminal.
28// - NO_COLOR takes precedence over CLICOLOR/CLICOLOR_FORCE, and will disable
29// colors but not text decoration, i.e. bold, italic, faint, etc.
30//
31// See https://no-color.org/ and https://bixense.com/clicolors/ for more information.
32func Detect(output io.Writer, env []string) Profile {
33 out, ok := output.(term.File)
34 environ := newEnviron(env)
35 isatty := isTTYForced(environ) || (ok && term.IsTerminal(out.Fd()))
36 term, ok := environ.lookup("TERM")
37 isDumb := !ok || term == dumbTerm
38 envp := colorProfile(isatty, environ)
39 if envp == TrueColor || envNoColor(environ) {
40 // We already know we have TrueColor, or NO_COLOR is set.
41 return envp
42 }
43
44 if isatty && !isDumb {
45 tip := Terminfo(term)
46 tmuxp := tmux(environ)
47
48 // Color profile is the maximum of env, terminfo, and tmux.
49 return max(envp, max(tip, tmuxp))
50 }
51
52 return envp
53}
54
55// Env returns the color profile based on the terminal environment variables.
56// This respects NO_COLOR, CLICOLOR, and CLICOLOR_FORCE environment variables.
57//
58// The rules as follows:
59// - TERM=dumb is always treated as NoTTY unless CLICOLOR_FORCE=1 is set.
60// - If COLORTERM=truecolor, and the profile is not NoTTY, it gest upgraded to TrueColor.
61// - Using any 256 color terminal (e.g. TERM=xterm-256color) will set the profile to ANSI256.
62// - Using any color terminal (e.g. TERM=xterm-color) will set the profile to ANSI.
63// - Using CLICOLOR=1 without TERM defined should be treated as ANSI if the
64// output is a terminal.
65// - NO_COLOR takes precedence over CLICOLOR/CLICOLOR_FORCE, and will disable
66// colors but not text decoration, i.e. bold, italic, faint, etc.
67//
68// See https://no-color.org/ and https://bixense.com/clicolors/ for more information.
69func Env(env []string) (p Profile) {
70 return colorProfile(true, newEnviron(env))
71}
72
73func colorProfile(isatty bool, env environ) (p Profile) {
74 term, ok := env.lookup("TERM")
75 isDumb := (!ok && runtime.GOOS != "windows") || term == dumbTerm
76 envp := envColorProfile(env)
77 if !isatty || isDumb {
78 // Check if the output is a terminal.
79 // Treat dumb terminals as NoTTY
80 p = NoTTY
81 } else {
82 p = envp
83 }
84
85 if envNoColor(env) && isatty {
86 if p > Ascii {
87 p = Ascii
88 }
89 return //nolint:nakedret
90 }
91
92 if cliColorForced(env) {
93 if p < ANSI {
94 p = ANSI
95 }
96 if envp > p {
97 p = envp
98 }
99
100 return //nolint:nakedret
101 }
102
103 if cliColor(env) {
104 if isatty && !isDumb && p < ANSI {
105 p = ANSI
106 }
107 }
108
109 return p
110}
111
112// envNoColor returns true if the environment variables explicitly disable color output
113// by setting NO_COLOR (https://no-color.org/).
114func envNoColor(env environ) bool {
115 noColor, _ := strconv.ParseBool(env.get("NO_COLOR"))
116 return noColor
117}
118
119func cliColor(env environ) bool {
120 cliColor, _ := strconv.ParseBool(env.get("CLICOLOR"))
121 return cliColor
122}
123
124func cliColorForced(env environ) bool {
125 cliColorForce, _ := strconv.ParseBool(env.get("CLICOLOR_FORCE"))
126 return cliColorForce
127}
128
129func isTTYForced(env environ) bool {
130 skip, _ := strconv.ParseBool(env.get("TTY_FORCE"))
131 return skip
132}
133
134func colorTerm(env environ) bool {
135 colorTerm := strings.ToLower(env.get("COLORTERM"))
136 return colorTerm == "truecolor" || colorTerm == "24bit" ||
137 colorTerm == "yes" || colorTerm == "true"
138}
139
140// envColorProfile returns infers the color profile from the environment.
141func envColorProfile(env environ) (p Profile) {
142 term, ok := env.lookup("TERM")
143 if !ok || len(term) == 0 || term == dumbTerm {
144 p = NoTTY
145 if runtime.GOOS == "windows" {
146 // Use Windows API to detect color profile. Windows Terminal and
147 // cmd.exe don't define $TERM.
148 if wcp, ok := windowsColorProfile(env); ok {
149 p = wcp
150 }
151 }
152 } else {
153 p = ANSI
154 }
155
156 parts := strings.Split(term, "-")
157 switch parts[0] {
158 case "alacritty",
159 "contour",
160 "foot",
161 "ghostty",
162 "kitty",
163 "rio",
164 "st",
165 "wezterm":
166 return TrueColor
167 case "xterm":
168 if len(parts) > 1 {
169 switch parts[1] {
170 case "ghostty", "kitty":
171 // These terminals can be defined as xterm-TERMNAME
172 return TrueColor
173 }
174 }
175 case "tmux", "screen":
176 if p < ANSI256 {
177 p = ANSI256
178 }
179 }
180
181 if isCloudShell, _ := strconv.ParseBool(env.get("GOOGLE_CLOUD_SHELL")); isCloudShell {
182 return TrueColor
183 }
184
185 // GNU Screen doesn't support TrueColor
186 // Tmux doesn't support $COLORTERM
187 if colorTerm(env) && !strings.HasPrefix(term, "screen") && !strings.HasPrefix(term, "tmux") {
188 return TrueColor
189 }
190
191 if strings.HasSuffix(term, "256color") && p < ANSI256 {
192 p = ANSI256
193 }
194
195 // Direct color terminals support true colors.
196 if strings.HasSuffix(term, "direct") {
197 return TrueColor
198 }
199
200 return //nolint:nakedret
201}
202
203// Terminfo returns the color profile based on the terminal's terminfo
204// database. This relies on the Tc and RGB capabilities to determine if the
205// terminal supports TrueColor.
206// If term is empty or "dumb", it returns NoTTY.
207func Terminfo(term string) (p Profile) {
208 if len(term) == 0 || term == "dumb" {
209 return NoTTY
210 }
211
212 p = ANSI
213 ti, err := terminfo.Load(term)
214 if err != nil {
215 return
216 }
217
218 extbools := ti.ExtBoolCapsShort()
219 if _, ok := extbools["Tc"]; ok {
220 return TrueColor
221 }
222
223 if _, ok := extbools["RGB"]; ok {
224 return TrueColor
225 }
226
227 return
228}
229
230// Tmux returns the color profile based on `tmux info` output. Tmux supports
231// overriding the terminal's color capabilities, so this function will return
232// the color profile based on the tmux configuration.
233func Tmux(env []string) Profile {
234 return tmux(newEnviron(env))
235}
236
237// tmux returns the color profile based on the tmux environment variables.
238func tmux(env environ) (p Profile) {
239 if tmux, ok := env.lookup("TMUX"); !ok || len(tmux) == 0 {
240 // Not in tmux
241 return NoTTY
242 }
243
244 // Check if tmux has either Tc or RGB capabilities. Otherwise, return
245 // ANSI256.
246 p = ANSI256
247 cmd := exec.Command("tmux", "info")
248 out, err := cmd.Output()
249 if err != nil {
250 return
251 }
252
253 for _, line := range bytes.Split(out, []byte("\n")) {
254 if (bytes.Contains(line, []byte("Tc")) || bytes.Contains(line, []byte("RGB"))) &&
255 bytes.Contains(line, []byte("true")) {
256 return TrueColor
257 }
258 }
259
260 return
261}
262
263// environ is a map of environment variables.
264type environ map[string]string
265
266// newEnviron returns a new environment map from a slice of environment
267// variables.
268func newEnviron(environ []string) environ {
269 m := make(map[string]string, len(environ))
270 for _, e := range environ {
271 parts := strings.SplitN(e, "=", 2)
272 var value string
273 if len(parts) == 2 {
274 value = parts[1]
275 }
276 m[parts[0]] = value
277 }
278 return m
279}
280
281// lookup returns the value of an environment variable and a boolean indicating
282// if it exists.
283func (e environ) lookup(key string) (string, bool) {
284 v, ok := e[key]
285 return v, ok
286}
287
288// get returns the value of an environment variable and empty string if it
289// doesn't exist.
290func (e environ) get(key string) string {
291 v, _ := e.lookup(key)
292 return v
293}