diff --git a/src/libraries/System.Console/src/System/ConsolePal.Unix.cs b/src/libraries/System.Console/src/System/ConsolePal.Unix.cs index eacf15a203efdb..5f2e485aca5e43 100644 --- a/src/libraries/System.Console/src/System/ConsolePal.Unix.cs +++ b/src/libraries/System.Console/src/System/ConsolePal.Unix.cs @@ -39,6 +39,9 @@ internal static class ConsolePal private static int s_windowHeight; // Cached WindowHeight, invalid when s_windowWidth == -1. private static int s_invalidateCachedSettings = 1; // Tracks whether we should invalidate the cached settings. + /// Whether to output ansi color strings. + private static volatile int s_emitAnsiColorCodes = -1; + public static Stream OpenStandardInput() { return new UnixConsoleStream(SafeFileHandleHelper.Open(() => Interop.Sys.Dup(Interop.Sys.FileDescriptors.STDIN_FILENO)), FileAccess.Read, @@ -779,8 +782,10 @@ private static void WriteSetColorString(bool foreground, ConsoleColor color) // Changing the color involves writing an ANSI character sequence out to the output stream. // We only want to do this if we know that sequence will be interpreted by the output. // rather than simply displayed visibly. - if (Console.IsOutputRedirected) + if (!EmitAnsiColorCodes) + { return; + } // See if we've already cached a format string for this foreground/background // and specific color choice. If we have, just output that format string again. @@ -813,13 +818,52 @@ private static void WriteSetColorString(bool foreground, ConsoleColor color) /// Writes out the ANSI string to reset colors. private static void WriteResetColorString() { - // We only want to send the reset string if we're targeting a TTY device - if (!Console.IsOutputRedirected) + if (EmitAnsiColorCodes) { WriteStdoutAnsiString(TerminalFormatStrings.Instance.Reset); } } + /// Get whether to emit ANSI color codes. + private static bool EmitAnsiColorCodes + { + get + { + // The flag starts at -1. If it's no longer -1, it's 0 or 1 to represent false or true. + int emitAnsiColorCodes = s_emitAnsiColorCodes; + if (emitAnsiColorCodes != -1) + { + return Convert.ToBoolean(emitAnsiColorCodes); + } + + // We've not yet computed whether to emit codes or not. Do so now. We may race with + // other threads, and that's ok; this is idempotent unless someone is currently changing + // the value of the relevant environment variables, in which case behavior here is undefined. + + // By default, we emit ANSI color codes if output isn't redirected, and suppress them if output is redirected. + bool enabled = !Console.IsOutputRedirected; + + if (enabled) + { + // We subscribe to the informal standard from https://no-color.org/. If we'd otherwise emit + // ANSI color codes but the NO_COLOR environment variable is set, disable emitting them. + enabled = Environment.GetEnvironmentVariable("NO_COLOR") is null; + } + else + { + // We also support overriding in the other direction. If we'd otherwise avoid emitting color + // codes but the DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION environment variable is + // set to 1 or true, enable color. + string? envVar = Environment.GetEnvironmentVariable("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION"); + enabled = envVar is not null && (envVar == "1" || envVar.Equals("true", StringComparison.OrdinalIgnoreCase)); + } + + // Store and return the computed answer. + s_emitAnsiColorCodes = Convert.ToInt32(enabled); + return enabled; + } + } + /// /// The values of the ConsoleColor enums unfortunately don't map to the /// corresponding ANSI values. We need to do the mapping manually. diff --git a/src/libraries/System.Console/tests/Color.cs b/src/libraries/System.Console/tests/Color.cs index 567faad01ca4c4..55843aa2f37246 100644 --- a/src/libraries/System.Console/tests/Color.cs +++ b/src/libraries/System.Console/tests/Color.cs @@ -2,15 +2,18 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.IO; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; using System.Linq; -using System.Runtime.InteropServices; using System.Text; -using Microsoft.DotNet.XUnitExtensions; +using Microsoft.DotNet.RemoteExecutor; using Xunit; public class Color { + private const char Esc = (char)0x1B; + [Fact] [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.MacCatalyst | TestPlatforms.tvOS, "Not supported on Browser, iOS, MacCatalyst, or tvOS.")] public static void InvalidColors() @@ -64,9 +67,58 @@ public static void RedirectedOutputDoesNotUseAnsiSequences() Console.ResetColor(); Console.Write('4'); - const char Esc = (char)0x1B; Assert.Equal(0, Encoding.UTF8.GetString(data.ToArray()).ToCharArray().Count(c => c == Esc)); Assert.Equal("1234", Encoding.UTF8.GetString(data.ToArray())); }); } + + public static bool TermIsSet => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("TERM")); + + [ConditionalTheory(nameof(TermIsSet))] + [PlatformSpecific(TestPlatforms.AnyUnix)] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.MacCatalyst | TestPlatforms.tvOS, "Not supported on Browser, iOS, MacCatalyst, or tvOS.")] + [InlineData(null)] + [InlineData("1")] + [InlineData("true")] + [InlineData("tRuE")] + [InlineData("0")] + [InlineData("false")] + public static void RedirectedOutput_EnvVarSet_EmitsAnsiCodes(string envVar) + { + var psi = new ProcessStartInfo { RedirectStandardOutput = true }; + psi.Environment["DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION"] = envVar; + + for (int i = 0; i < 3; i++) + { + Action main = i => + { + Console.Write("SEPARATOR"); + switch (i) + { + case "0": + Console.ForegroundColor = ConsoleColor.Blue; + break; + + case "1": + Console.BackgroundColor = ConsoleColor.Red; + break; + + case "2": + Console.ResetColor(); + break; + } + Console.Write("SEPARATOR"); + }; + + using RemoteInvokeHandle remote = RemoteExecutor.Invoke(main, i.ToString(CultureInfo.InvariantCulture), new RemoteInvokeOptions() { StartInfo = psi }); + + bool expectedEscapes = envVar is not null && (envVar == "1" || envVar.Equals("true", StringComparison.OrdinalIgnoreCase)); + + string stdout = remote.Process.StandardOutput.ReadToEnd(); + string[] parts = stdout.Split("SEPARATOR"); + Assert.Equal(3, parts.Length); + + Assert.Equal(expectedEscapes, parts[1].Contains(Esc)); + } + } }