From 3a638076181fb3efa9806c41aa537948de17a075 Mon Sep 17 00:00:00 2001 From: jojosenthusiast Date: Mon, 29 Jun 2026 17:02:57 -0600 Subject: [PATCH] feat: add force16Color config option --- README.md | 2 + cmd/src/cmd.go | 1 + cmd/src/cmd_test.go | 71 +++++++++++ cmd/src/colors.go | 26 ++++ cmd/src/colors_test.go | 172 +++++++++++++++++++++++++++ cmd/src/main.go | 5 + cmd/src/main_test.go | 36 ++++++ cmd/src/run_migration_compat.go | 1 + cmd/src/run_migration_compat_test.go | 93 +++++++++++++++ lib/output/setup_test.go | 12 ++ lib/output/style.go | 105 +++++++++++++++- lib/output/style_test.go | 79 ++++++++++++ 12 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 cmd/src/cmd_test.go create mode 100644 cmd/src/colors_test.go create mode 100644 cmd/src/run_migration_compat_test.go create mode 100644 lib/output/setup_test.go create mode 100644 lib/output/style_test.go diff --git a/README.md b/README.md index 46ee3f42d1..ad3334b056 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ Run src login SOURCEGRAPH-URL to authentica - `SRC_ENDPOINT`: the URL to your Sourcegraph instance (such as `https://sourcegraph.example.com`) - `SRC_ACCESS_TOKEN`: your Sourcegraph access token (on your Sourcegraph instance, click your user menu in the top right, then select **Settings > Access tokens** to create one) +To force terminal-theme colours instead of 256-colour ANSI output, set `"force16Color": true` in `~/src-config.json`. + For convenience, you can add these environment variables persistently. ### Configuration: Mac OS / Linux diff --git a/cmd/src/cmd.go b/cmd/src/cmd.go index 9182dc57ea..581098fb32 100644 --- a/cmd/src/cmd.go +++ b/cmd/src/cmd.go @@ -78,6 +78,7 @@ func (c commander) run(flagSet *flag.FlagSet, cmdName, usageText string, args [] if err != nil { log.Fatal("reading config: ", err) } + applyColorMode(cfg.force16Color) exitCode, err := runLegacy(cmd, flagSet) if err != nil { diff --git a/cmd/src/cmd_test.go b/cmd/src/cmd_test.go new file mode 100644 index 0000000000..c0c6e22998 --- /dev/null +++ b/cmd/src/cmd_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "encoding/json" + "flag" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestCommanderRunAppliesColorMode verifies the legacy commander.run path, +// which calls os.Exit after running a command, in a subprocess. +func TestCommanderRunAppliesColorMode(t *testing.T) { + snapshotAnsiColors(t) + + // Sanity precondition: the default "logo" entry must be a 256-color + // SGR sequence, otherwise observing a remap is meaningless. + if before := ansiColors["logo"]; !strings.Contains(before, "38;5;") { + t.Fatalf(`precondition failed: ansiColors["logo"] = %q, expected a 256-color sequence`, before) + } + + // 1. Write a config file enabling force16Color, point *configPath at it. + home := t.TempDir() + cfgFile := filepath.Join(home, "src-config.json") + data, err := json.Marshal(configFromFile{ + Endpoint: "https://example.com", + AccessToken: "test-token", + Force16Color: true, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cfgFile, data, 0o600); err != nil { + t.Fatal(err) + } + oldConfigPath := *configPath + *configPath = cfgFile + t.Cleanup(func() { *configPath = oldConfigPath }) + + child := exec.Command(os.Args[0], "-test.run=^TestCommanderRunAppliesColorModeChild$") + child.Env = append(os.Environ(), + "SRC_CLI_TEST_COMMANDER_FORCE16=1", + "SRC_CLI_TEST_CONFIG="+cfgFile, + ) + out, err := child.Output() + if err != nil { + t.Fatalf("child command failed: %v", err) + } + if got := string(out); strings.Contains(got, "38;5;") || strings.Contains(got, "48;5;") { + t.Errorf(`commander.run did not apply force16Color before dispatching the subcommand handler: ansiColors["logo"] = %q, still a 256-color SGR sequence`, got) + } +} + +func TestCommanderRunAppliesColorModeChild(t *testing.T) { + if os.Getenv("SRC_CLI_TEST_COMMANDER_FORCE16") != "1" { + t.Skip("helper process") + } + *configPath = os.Getenv("SRC_CLI_TEST_CONFIG") + + const subName = "__test_legacy_color" + c := commander{&command{ + flagSet: flag.NewFlagSet(subName, flag.ContinueOnError), + handler: func(args []string) error { + _, _ = os.Stdout.WriteString(ansiColors["logo"]) + return nil + }, + }} + c.run(flag.NewFlagSet("src", flag.ContinueOnError), "src", "usage", []string{subName}) +} diff --git a/cmd/src/colors.go b/cmd/src/colors.go index 162a3842e1..514a474c81 100644 --- a/cmd/src/colors.go +++ b/cmd/src/colors.go @@ -8,6 +8,8 @@ import ( "github.com/grafana/regexp" "github.com/mattn/go-isatty" + + "github.com/sourcegraph/sourcegraph/lib/output" ) // Returns the string for a foreground ANSI 8 bit color code. @@ -59,6 +61,30 @@ var ansiColors = map[string]string{ // MIT licensed, see https://github.com/acarl005/stripansi/blob/master/LICENSE var ansiRegexp = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))") +// applyColorMode optionally remaps ansiColors entries that use 256-color +// SGR sequences to their nearest basic 16-color SGR equivalents and flips +// lib/output's force16 state so package-level Style values render the same +// way. +// +// When force16 is false this is a no-op (the default lib/output state is +// already 256-color). Disabled color entries (empty strings, set by +// NO_COLOR / COLOR=false / non-tty) are left untouched so we never +// re-enable colored output the environment asked us to suppress. +// +// See sourcegraph/src-cli#1144. +func applyColorMode(force16 bool) { + if !force16 { + return + } + output.SetForce16Color(true) + for name, code := range ansiColors { + if code == "" { + continue + } + ansiColors[name] = output.Remap256To16SGR(code) + } +} + var isTest bool var colorDisabled bool diff --git a/cmd/src/colors_test.go b/cmd/src/colors_test.go new file mode 100644 index 0000000000..f009ab79fe --- /dev/null +++ b/cmd/src/colors_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "regexp" + "strings" + "testing" + + "github.com/sourcegraph/sourcegraph/lib/output" +) + +// snapshotAnsiColors copies ansiColors and restores it on test cleanup, +// and also resets lib/output's force16 state so mutations in colorMode +// tests do not leak into other tests in this package or in lib/output. +func snapshotAnsiColors(t *testing.T) { + t.Helper() + original := make(map[string]string, len(ansiColors)) + for k, v := range ansiColors { + original[k] = v + } + t.Cleanup(func() { + for k := range ansiColors { + delete(ansiColors, k) + } + for k, v := range original { + ansiColors[k] = v + } + output.SetForce16Color(false) + }) +} + +// TestApplyColorMode16Remap is a regression test for sourcegraph/src-cli#1144. +// When 16-color mode is enabled via config, ansiColors entries that currently +// use 256-color SGR sequences (e.g. "logo") must be remapped to plain 16-color +// SGR sequences so the terminal does not receive any 38;5;N or 48;5;N codes. +func TestApplyColorMode16Remap(t *testing.T) { + snapshotAnsiColors(t) + + before := ansiColors["logo"] + if before == "" { + t.Fatal(`precondition failed: ansiColors["logo"] is empty, cannot verify remap`) + } + if !strings.Contains(before, "38;5;") { + t.Fatalf(`precondition failed: ansiColors["logo"] = %q, expected a 256-color sequence containing "38;5;"`, before) + } + + applyColorMode(true) + + after := ansiColors["logo"] + if after == before { + t.Fatalf(`applyColorMode(true) did not remap ansiColors["logo"]: still %q`, after) + } + if strings.Contains(after, "38;5;") || strings.Contains(after, "48;5;") { + t.Fatalf(`applyColorMode(true) left a 256-color sequence in ansiColors["logo"]: %q`, after) + } + + // Validate the remapped value is a concatenation of basic 16-color SGR + // codes: foreground 30-37 / 90-97 and optional background 40-47 / 100-107. + sgr16 := regexp.MustCompile(`^(\x1b\[(3[0-7]|9[0-7]|4[0-7]|10[0-7])m)+$`) + if !sgr16.MatchString(after) { + t.Fatalf(`ansiColors["logo"] after applyColorMode(true) = %q, want a 16-color ANSI SGR sequence`, after) + } +} + +// TestApplyColorMode16PropagatesToLibOutput verifies that force16Color also +// affects lib/output package-level Style variables (e.g. StyleLogo, +// StyleSearchMatch). Those styles are emitted by output.Output consumers such +// as batch progress bars and search rendering. +// +// The test enables 16-color mode through the config-driven helper and +// asserts that lib/output's exported styles no longer contain 38;5;N or +// 48;5;N sequences. +func TestApplyColorMode16PropagatesToLibOutput(t *testing.T) { + snapshotAnsiColors(t) + + if before := output.StyleLogo.String(); !strings.Contains(before, "38;5;") { + t.Fatalf(`precondition failed: output.StyleLogo = %q, expected a 256-color sequence`, before) + } + + applyColorMode(true) + + checks := map[string]output.Style{ + "StyleLogo": output.StyleLogo, + "StyleWarning": output.StyleWarning, + "StyleSuccess": output.StyleSuccess, + "StyleFailure": output.StyleFailure, + "StyleSearchMatch": output.StyleSearchMatch, + } + for name, s := range checks { + got := s.String() + if strings.Contains(got, "38;5;") || strings.Contains(got, "48;5;") { + t.Errorf("after applyColorMode(true), output.%s still emits 256-color SGR: %q", name, got) + } + } +} + +// TestApplyColorMode16PreservesDisabledColors is a regression test for +// the NO_COLOR / COLOR=false / non-tty edge of sourcegraph/src-cli#1144: +// when an ansiColors entry has been disabled (set to "") the 16-color +// remap must not promote it back to an active escape. The contract is +// "force16 downgrades 256-color to 16-color", not "force16 forces some +// color on", so disabled entries must round-trip through applyColorMode(true) +// untouched. +func TestApplyColorMode16PreservesDisabledColors(t *testing.T) { + snapshotAnsiColors(t) + + // Disable a representative mix: a custom 256-color entry, a basic + // color, the reset code, and the already-empty alert title. + ansiColors["logo"] = "" + ansiColors["blue"] = "" + ansiColors["nc"] = "" + // "search-alert-proposed-title" is already "" in the default table; + // listed explicitly for documentation. + ansiColors["search-alert-proposed-title"] = "" + + applyColorMode(true) + + for _, name := range []string{ + "logo", + "blue", + "nc", + "search-alert-proposed-title", + } { + if got := ansiColors[name]; got != "" { + t.Errorf(`applyColorMode(true) re-enabled disabled color %q: got %q, want ""`, name, got) + } + } +} + +// TestApplyColorMode16WithFullyDisabledTable covers the NO_COLOR / +// COLOR=false path end-to-end for sourcegraph/src-cli#1144: when the +// environment has asked us to suppress all color (every ansiColors entry +// is ""), enabling force16 must not reintroduce any escape sequence +// anywhere in the table. +func TestApplyColorMode16WithFullyDisabledTable(t *testing.T) { + snapshotAnsiColors(t) + + // Simulate the NO_COLOR / COLOR=false branch in colors.go's init(): + // every entry blanked. + for k := range ansiColors { + ansiColors[k] = "" + } + + applyColorMode(true) + + for name, code := range ansiColors { + if code != "" { + t.Errorf(`applyColorMode(true) reintroduced an escape for disabled color %q: got %q, want ""`, name, code) + } + } +} + +// TestApplyColorModeFalseIsNoop ensures that disabling the 16-color override +// leaves the original ansiColors table untouched. +func TestApplyColorModeFalseIsNoop(t *testing.T) { + snapshotAnsiColors(t) + + before := make(map[string]string, len(ansiColors)) + for k, v := range ansiColors { + before[k] = v + } + + applyColorMode(false) + + if len(ansiColors) != len(before) { + t.Fatalf("applyColorMode(false) changed ansiColors size: got %d, want %d", len(ansiColors), len(before)) + } + for k, want := range before { + if got := ansiColors[k]; got != want { + t.Errorf("applyColorMode(false) mutated ansiColors[%q]: got %q, want %q", k, got, want) + } + } +} diff --git a/cmd/src/main.go b/cmd/src/main.go index fd217ba51f..b28f348f15 100644 --- a/cmd/src/main.go +++ b/cmd/src/main.go @@ -151,6 +151,7 @@ type config struct { endpointURL *url.URL // always non-nil; defaults to https://sourcegraph.com via readConfig usingDefaultEndpoint bool inCI bool + force16Color bool } // configFromFile holds the config as read from the config file, @@ -160,6 +161,9 @@ type configFromFile struct { AccessToken string `json:"accessToken"` AdditionalHeaders map[string]string `json:"additionalHeaders"` Proxy string `json:"proxy"` + // Force16Color restricts colored output to the basic 16-color SGR palette + // instead of 256-color sequences. See sourcegraph/src-cli#1144. + Force16Color bool `json:"force16Color"` } type AuthMode int @@ -247,6 +251,7 @@ func readConfig() (*config, error) { cfg.accessToken = cfgFromFile.AccessToken cfg.additionalHeaders = cfgFromFile.AdditionalHeaders proxyStr = cfgFromFile.Proxy + cfg.force16Color = cfgFromFile.Force16Color } envToken := os.Getenv("SRC_ACCESS_TOKEN") diff --git a/cmd/src/main_test.go b/cmd/src/main_test.go index 0b23cb9938..a5a22614f9 100644 --- a/cmd/src/main_test.go +++ b/cmd/src/main_test.go @@ -358,6 +358,42 @@ func TestReadConfig(t *testing.T) { inCI: true, }, }, + { + // Regression for sourcegraph/src-cli#1144: 16-color mode is a + // persistent config option, not a per-invocation flag. The JSON + // key "force16Color" must round-trip into config.force16Color. + name: "config file, force16Color enabled", + fileContents: &configFromFile{ + Endpoint: "https://example.com/", + AccessToken: "deadbeef", + Force16Color: true, + }, + want: &config{ + endpointURL: &url.URL{ + Scheme: "https", + Host: "example.com", + }, + accessToken: "deadbeef", + additionalHeaders: map[string]string{}, + force16Color: true, + }, + }, + { + name: "config file, force16Color omitted defaults to false", + fileContents: &configFromFile{ + Endpoint: "https://example.com/", + AccessToken: "deadbeef", + }, + want: &config{ + endpointURL: &url.URL{ + Scheme: "https", + Host: "example.com", + }, + accessToken: "deadbeef", + additionalHeaders: map[string]string{}, + force16Color: false, + }, + }, } for _, test := range tests { diff --git a/cmd/src/run_migration_compat.go b/cmd/src/run_migration_compat.go index 6671b07da3..053a817bf0 100644 --- a/cmd/src/run_migration_compat.go +++ b/cmd/src/run_migration_compat.go @@ -44,6 +44,7 @@ func maybeRunMigratedCommand() (isMigrated bool, exitCode int, err error) { if err != nil { log.Fatal("reading config: ", err) } + applyColorMode(cfg.force16Color) exitCode, err = runMigrated() return diff --git a/cmd/src/run_migration_compat_test.go b/cmd/src/run_migration_compat_test.go new file mode 100644 index 0000000000..e7f8a35cf3 --- /dev/null +++ b/cmd/src/run_migration_compat_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/urfave/cli/v3" + + "github.com/sourcegraph/src-cli/internal/clicompat" +) + +// TestMaybeRunMigratedCommandAppliesColorMode verifies that migrated +// subcommands (e.g. `src version`) apply force16Color from config before +// running the command. +// +// The test injects a temporary no-op migrated command so we don't run real +// network handlers, points readConfig at a temp config file with +// force16Color enabled, drives maybeRunMigratedCommand, and asserts that +// the ansiColors table was remapped to the 16-color palette. +func TestMaybeRunMigratedCommandAppliesColorMode(t *testing.T) { + snapshotAnsiColors(t) + + // 1. Inject a no-op migrated command so we don't run real handlers. + const testCmd = "__test_color_noop" + migratedCommands[testCmd] = clicompat.Wrap(&cli.Command{ + Name: testCmd, + HideHelp: true, + HideVersion: true, + Action: func(ctx context.Context, c *cli.Command) error { + return nil + }, + }) + t.Cleanup(func() { delete(migratedCommands, testCmd) }) + + // 2. Write a config file enabling force16Color. + home := t.TempDir() + cfgFile := filepath.Join(home, "src-config.json") + data, err := json.Marshal(configFromFile{ + Endpoint: "https://example.com", + AccessToken: "test-token", + Force16Color: true, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cfgFile, data, 0o600); err != nil { + t.Fatal(err) + } + oldConfigPath := *configPath + *configPath = cfgFile + t.Cleanup(func() { *configPath = oldConfigPath }) + + // 3. Swap os.Args so flag.Parse() and runMigrated() see our command. + oldArgs := os.Args + os.Args = []string{"src", testCmd} + t.Cleanup(func() { os.Args = oldArgs }) + + // 4. Reset flag.CommandLine so the in-test flag.Parse picks up our + // os.Args without conflicting with previously registered flags. + oldFlagSet := flag.CommandLine + flag.CommandLine = flag.NewFlagSet(oldArgs[0], flag.ContinueOnError) + t.Cleanup(func() { flag.CommandLine = oldFlagSet }) + + // 5. Redirect stdout/stderr to /dev/null (or NUL on Windows) so + // incidental output from urfave/cli doesn't pollute test output. + devnull, errOpen := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if errOpen == nil { + oldStdout, oldStderr := os.Stdout, os.Stderr + os.Stdout, os.Stderr = devnull, devnull + t.Cleanup(func() { + os.Stdout, os.Stderr = oldStdout, oldStderr + devnull.Close() + }) + } + + isMigrated, _, runErr := maybeRunMigratedCommand() + if runErr != nil { + t.Fatalf("maybeRunMigratedCommand returned error: %v", runErr) + } + if !isMigrated { + t.Fatalf("expected %q to be detected as a migrated command", testCmd) + } + + after := ansiColors["logo"] + if strings.Contains(after, "38;5;") || strings.Contains(after, "48;5;") { + t.Errorf(`migrated command path did not apply force16Color: ansiColors["logo"] = %q, still a 256-color SGR sequence`, after) + } +} diff --git a/lib/output/setup_test.go b/lib/output/setup_test.go new file mode 100644 index 0000000000..d29707440b --- /dev/null +++ b/lib/output/setup_test.go @@ -0,0 +1,12 @@ +package output + +// Enable force-16 rendering for lib/output's own test binary so package- +// level Style values (Fg256Color/Bg256Color-derived) and the +// Fg256Color/Bg256Color helpers render through the basic 16-color palette. +// Callers that import lib/output from another package (e.g. cmd/src) are +// not affected: this init only ships in lib/output's test binary. +// +// See sourcegraph/src-cli#1144. +func init() { + SetForce16Color(true) +} diff --git a/lib/output/style.go b/lib/output/style.go index f27a6d6530..5a1c91700d 100644 --- a/lib/output/style.go +++ b/lib/output/style.go @@ -2,12 +2,31 @@ package output import ( "fmt" + "strconv" "strings" + + "github.com/grafana/regexp" ) type Style struct{ code string } -func (s Style) String() string { return s.code } +// force16Color, when true, causes Style.String() to remap 256-color SGR +// introducers (ESC[38;5;Nm, ESC[48;5;Nm) to nearest basic 16-color SGR +// codes. Set via SetForce16Color, typically from cmd/src after reading +// the user's force16Color config. See sourcegraph/src-cli#1144. +var force16Color bool + +// SetForce16Color toggles 16-color rewriting for Style.String(). While +// enabled, embedded 256-color SGR introducers are rewritten to basic +// 16-color SGR codes. +func SetForce16Color(b bool) { force16Color = b } + +func (s Style) String() string { + if force16Color { + return Remap256To16SGR(s.code) + } + return s.code +} // Line returns a FancyLine using this style as an alias for using output.Styledf(...) func (s Style) Line(format string) FancyLine { return Styled(s, format) } @@ -28,6 +47,90 @@ func CombineStyles(styles ...Style) Style { func Fg256Color(code int) Style { return Style{fmt.Sprintf("\033[38;5;%dm", code)} } func Bg256Color(code int) Style { return Style{fmt.Sprintf("\033[48;5;%dm", code)} } +// sgr256Re matches a single 256-color SGR introducer: ESC[38;5;Nm (fg) or +// ESC[48;5;Nm (bg). +var sgr256Re = regexp.MustCompile(`\x1b\[(38|48);5;(\d{1,3})m`) + +// Remap256To16SGR rewrites every 256-color SGR introducer in s to its +// nearest basic 16-color SGR equivalent. Other escape sequences (bold, +// reset, existing 16-color codes, etc.) pass through unchanged. Empty input +// returns empty output, which preserves NO_COLOR / COLOR=false semantics +// for callers that store color-disabled values as empty strings. +// +// See sourcegraph/src-cli#1144. +func Remap256To16SGR(s string) string { + return sgr256Re.ReplaceAllStringFunc(s, func(match string) string { + m := sgr256Re.FindStringSubmatch(match) + isBackground := m[1] == "48" + n, err := strconv.Atoi(m[2]) + if err != nil { + return match + } + return fmt.Sprintf("\x1b[%dm", basic16SGR(n, isBackground)) + }) +} + +// basic16SGR returns a basic 16-color SGR parameter (30-37, 90-97 for +// foreground; 40-47, 100-107 for background) approximating the given 8-bit +// xterm 256-color code. +func basic16SGR(code int, isBackground bool) int { + idx := nearest16(code) + switch { + case idx < 8 && !isBackground: + return 30 + idx + case idx < 8 && isBackground: + return 40 + idx + case !isBackground: + return 90 + (idx - 8) + default: + return 100 + (idx - 8) + } +} + +// nearest16 collapses an xterm 8-bit color code (0..255) to an index into +// the 16-color basic palette (0..15). It is a coarse approximation; the +// goal is "no 256-color escapes leak through", not perceptual accuracy. +func nearest16(code int) int { + switch { + case code < 0: + return 0 + case code <= 15: + return code + case code <= 231: + // 6x6x6 RGB cube. + n := code - 16 + r := n / 36 + g := (n / 6) % 6 + b := n % 6 + idx := 0 + if r >= 3 { + idx |= 1 + } + if g >= 3 { + idx |= 2 + } + if b >= 3 { + idx |= 4 + } + // Bright bit when any channel saturates the top of the cube. + if r >= 4 || g >= 4 || b >= 4 { + idx |= 8 + } + return idx + default: + // Grayscale ramp 232..255. + step := code - 232 + switch { + case step < 8: + return 0 // black + case step < 16: + return 8 // bright black (dark gray) + default: + return 15 // bright white + } + } +} + var ( StyleReset = Style{"\033[0m"} StyleLogo = Fg256Color(57) diff --git a/lib/output/style_test.go b/lib/output/style_test.go new file mode 100644 index 0000000000..06164b8a6e --- /dev/null +++ b/lib/output/style_test.go @@ -0,0 +1,79 @@ +package output + +import ( + "strings" + "testing" +) + +// TestPackageStylesAvoid256ColorSGRWhenForce16Enabled is a regression +// test for sourcegraph/src-cli#1144. The issue requires src to render +// using only the terminal-theme 16-color palette WHEN the force16 mode +// is enabled (via the user's "force16Color": true config). Today every +// package-level Style variable in lib/output is defined via +// Fg256Color/Bg256Color and so emits raw 256-color SGR escapes +// (e.g. "\x1b[38;5;57m") regardless of mode, which leaks through every +// output.Output consumer regardless of the force16Color config in +// cmd/src. +// +// This test runs against lib/output's test binary, which has force16 +// pinned on via setup_test.go's init(). It pins the desired end state +// under that mode: no exported package Style may emit a 256-color SGR +// introducer once force16 is in effect. (Default-mode rendering is +// not asserted here — the helpers Fg256Color/Bg256Color intentionally +// still produce 256-color escapes when force16 is off.) +func TestPackageStylesAvoid256ColorSGRWhenForce16Enabled(t *testing.T) { + styles := map[string]Style{ + "StyleLogo": StyleLogo, + "StylePending": StylePending, + "StyleWarning": StyleWarning, + "StyleFailure": StyleFailure, + "StyleSuccess": StyleSuccess, + "StyleSuggestion": StyleSuggestion, + "StyleSearchQuery": StyleSearchQuery, + "StyleSearchBorder": StyleSearchBorder, + "StyleSearchLink": StyleSearchLink, + "StyleSearchRepository": StyleSearchRepository, + "StyleSearchFilename": StyleSearchFilename, + "StyleSearchMatch": StyleSearchMatch, + "StyleSearchLineNumbers": StyleSearchLineNumbers, + "StyleSearchCommitAuthor": StyleSearchCommitAuthor, + "StyleSearchCommitSubject": StyleSearchCommitSubject, + "StyleSearchCommitDate": StyleSearchCommitDate, + "StyleWhiteOnPurple": StyleWhiteOnPurple, + "StyleGreyBackground": StyleGreyBackground, + "StyleSearchAlertTitle": StyleSearchAlertTitle, + "StyleSearchAlertDescription": StyleSearchAlertDescription, + "StyleSearchAlertProposedQuery": StyleSearchAlertProposedQuery, + "StyleLinesDeleted": StyleLinesDeleted, + "StyleLinesAdded": StyleLinesAdded, + "StyleGrey": StyleGrey, + "StyleYellow": StyleYellow, + "StyleOrange": StyleOrange, + "StyleRed": StyleRed, + "StyleGreen": StyleGreen, + } + for name, s := range styles { + got := s.String() + if strings.Contains(got, "\x1b[38;5;") || strings.Contains(got, "\x1b[48;5;") { + t.Errorf("%s emits a 256-color SGR sequence (want only 16-color): %q", name, got) + } + } +} + +// TestFg256ColorAndBg256ColorAvoid256ColorSGRWhenForce16Enabled pins +// that the direct Fg256Color/Bg256Color helpers, used across the +// package for legacy 8-bit color definitions, must not emit a 256-color +// SGR introducer WHEN force16 mode is enabled (sourcegraph/src-cli#1144). +// +// This test runs against lib/output's test binary, which has force16 +// pinned on via setup_test.go's init(). With force16 off, both helpers +// intentionally still emit "\x1b[38;5;Nm" / "\x1b[48;5;Nm" — they only +// downgrade to the basic 16-color palette while force16 is active. +func TestFg256ColorAndBg256ColorAvoid256ColorSGRWhenForce16Enabled(t *testing.T) { + if got := Fg256Color(57).String(); strings.Contains(got, "\x1b[38;5;") { + t.Errorf("Fg256Color(57) emits 256-color SGR (want only 16-color): %q", got) + } + if got := Bg256Color(11).String(); strings.Contains(got, "\x1b[48;5;") { + t.Errorf("Bg256Color(11) emits 256-color SGR (want only 16-color): %q", got) + } +}