env.go

  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}