diff --git a/src/libraries/Common/src/System/Console/ConsoleUtils.cs b/src/libraries/Common/src/System/Console/ConsoleUtils.cs index f8472390d1f858..7da5e30a088205 100644 --- a/src/libraries/Common/src/System/Console/ConsoleUtils.cs +++ b/src/libraries/Common/src/System/Console/ConsoleUtils.cs @@ -5,7 +5,7 @@ namespace System { internal static partial class ConsoleUtils { - /// Whether to output ansi color strings. + /// Whether to output ANSI color strings. private static volatile int s_emitAnsiColorCodes = -1; /// Get whether to emit ANSI color codes. @@ -13,34 +13,35 @@ public 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. + // 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 + // We've not yet computed whether to emit codes or not. 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. + // Per https://force-color.org/, FORCE_COLOR forces ANSI color output when set to a non-empty value. + // DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION is treated as a legacy alias for the same behavior. + // These take highest priority and override all other checks. + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("FORCE_COLOR")) || + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION"))) + { + s_emitAnsiColorCodes = 1; + return true; + } + // 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. + // Per https://no-color.org/, NO_COLOR disables ANSI color output when set. 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); diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleLoggerProvider.cs b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleLoggerProvider.cs index 68a356de9ba748..f05da9785bd457 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleLoggerProvider.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/src/ConsoleLoggerProvider.cs @@ -68,13 +68,20 @@ public ConsoleLoggerProvider(IOptionsMonitor options, IEnu [UnsupportedOSPlatformGuard("windows")] private static bool DoesConsoleSupportAnsi() { - string? envVar = Environment.GetEnvironmentVariable("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION"); - if (envVar is not null && (envVar == "1" || envVar.Equals("true", StringComparison.OrdinalIgnoreCase))) + // Per https://force-color.org/, FORCE_COLOR forces ANSI color output when set to a non-empty value. + // DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION is treated as a legacy alias for the same behavior. + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("FORCE_COLOR")) || + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION"))) { - // ANSI color support forcibly enabled via environment variable. This logic matches the behaviour - // found in System.ConsoleUtils.EmitAnsiColorCodes. return true; } + + // Per https://no-color.org/, NO_COLOR disables ANSI color output when set. + if (Environment.GetEnvironmentVariable("NO_COLOR") is not null) + { + return false; + } + if ( #if NETFRAMEWORK Environment.OSVersion.Platform != PlatformID.Win32NT diff --git a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs index 796604d60a53c6..c7b1d4cc1bcf0c 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Console/tests/Microsoft.Extensions.Logging.Console.Tests/ConsoleLoggerTest.cs @@ -445,6 +445,43 @@ public void AddConsole_IsOutputRedirected_ColorDisabled() }, new RemoteInvokeOptions { StartInfo = new ProcessStartInfo() { RedirectStandardOutput = true } }).Dispose(); } + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] + [InlineData("FORCE_COLOR", "1", null, null, "AnsiLogConsole")] + [InlineData("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", "1", null, null, "AnsiLogConsole")] + [InlineData(null, null, "NO_COLOR", "1", "AnsiParsingLogConsole")] + [InlineData("FORCE_COLOR", "1", "NO_COLOR", "1", "AnsiLogConsole")] + public void DoesConsoleSupportAnsi_RespectsColorEnvVars( + string? envVarName1, string? envVarValue1, + string? envVarName2, string? envVarValue2, + string expectedConsoleTypeName) + { + var psi = new ProcessStartInfo { RedirectStandardOutput = true }; + if (envVarName1 is not null) + { + psi.Environment[envVarName1] = envVarValue1; + } + if (envVarName2 is not null) + { + psi.Environment[envVarName2] = envVarValue2; + } + + RemoteExecutor.Invoke(static (expectedTypeName) => + { + var loggerProvider = new ServiceCollection() + .AddLogging(builder => builder.AddConsole()) + .BuildServiceProvider() + .GetRequiredService(); + + var consoleLoggerProvider = Assert.IsType(loggerProvider); + + var messageQueueField = typeof(ConsoleLoggerProvider).GetField("_messageQueue", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var processor = (ConsoleLoggerProcessor)messageQueueField!.GetValue(consoleLoggerProvider)!; + Assert.Equal(expectedTypeName, processor.Console.GetType().Name); + + loggerProvider.Dispose(); + }, expectedConsoleTypeName, new RemoteInvokeOptions { StartInfo = psi }).Dispose(); + } + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] public void WriteDebug_LogsCorrectColors() { diff --git a/src/libraries/System.Console/src/System/ConsolePal.Unix.cs b/src/libraries/System.Console/src/System/ConsolePal.Unix.cs index 57d4ef11fd62e3..0aef24b28da7c7 100644 --- a/src/libraries/System.Console/src/System/ConsolePal.Unix.cs +++ b/src/libraries/System.Console/src/System/ConsolePal.Unix.cs @@ -1112,9 +1112,9 @@ private static void InvalidateTerminalSettings() Volatile.Write(ref s_invalidateCachedSettings, 1); } - // Ansi colors are enabled when stdout is a terminal or when - // DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION is set. - // In both cases, they are written to stdout. + // Ansi colors are enabled when stdout is a terminal, when + // FORCE_COLOR is set, or when DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION is set. + // In all cases, they are written to stdout. internal static void WriteTerminalAnsiColorString(string? value) => WriteTerminalAnsiString(value, OpenStandardOutputHandle(), mayChangeCursorPosition: false); diff --git a/src/libraries/System.Console/tests/Color.cs b/src/libraries/System.Console/tests/Color.cs index 3e56d816b16a0c..520ebadde8366c 100644 --- a/src/libraries/System.Console/tests/Color.cs +++ b/src/libraries/System.Console/tests/Color.cs @@ -51,25 +51,46 @@ public static void BackgroundColor_Throws_PlatformNotSupportedException() Assert.Throws(() => Console.BackgroundColor = ConsoleColor.Red); } - [Fact] + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.MacCatalyst | TestPlatforms.tvOS, "Not supported on Browser, iOS, MacCatalyst, or tvOS.")] public static void RedirectedOutputDoesNotUseAnsiSequences() { - // Make sure that redirecting to a memory stream causes Console not to write out the ANSI sequences - - Helpers.RunInRedirectedOutput((data) => + // Run in a child process with redirected stdout so that no in-process + // test framework output (e.g. xunit skip messages) can pollute the stream. + var startInfo = new ProcessStartInfo { RedirectStandardOutput = true }; + using RemoteInvokeHandle handle = RemoteExecutor.Invoke(static () => { - Console.Write('1'); + Console.Error.WriteLine($"IsOutputRedirected: {Console.IsOutputRedirected}"); + Console.Error.WriteLine($"TERM: {Environment.GetEnvironmentVariable("TERM")}"); + Console.Write("1"); Console.ForegroundColor = ConsoleColor.Blue; - Console.Write('2'); + Console.Write("2"); Console.BackgroundColor = ConsoleColor.Red; - Console.Write('3'); + Console.Write("3"); Console.ResetColor(); - Console.Write('4'); - - Assert.Equal(0, Encoding.UTF8.GetString(data.ToArray()).ToCharArray().Count(c => c == Esc)); - Assert.Equal("1234", Encoding.UTF8.GetString(data.ToArray())); - }); + Console.Write("4"); + Console.Error.WriteLine($"Done writing"); + }, new RemoteInvokeOptions { StartInfo = startInfo }); + + string capturedOutput = handle.Process.StandardOutput.ReadToEnd(); + byte[] rawBytes = System.Text.Encoding.UTF8.GetBytes(capturedOutput); + Console.Error.WriteLine($"Output length: {rawBytes.Length}"); + Console.Error.WriteLine($"Output hex: {BitConverter.ToString(rawBytes)}"); + Console.Error.Write("Output chars: "); + foreach (char c in capturedOutput) + { + if (c >= 32 && c < 127) + Console.Error.Write(c); + else + Console.Error.Write($"[0x{(int)c:X2}]"); + } + Console.Error.WriteLine(); + Console.Error.WriteLine($"capturedOutput[0] = 0x{(int)capturedOutput[0]:X2}"); + Console.Error.WriteLine($"capturedOutput == \"1234\": {capturedOutput == "1234"}"); + Console.Error.WriteLine($"Contains ESC: {capturedOutput.Contains(Esc)}"); + Console.Error.WriteLine($"Esc char value: 0x{(int)Esc:X2}"); + Assert.DoesNotContain(Esc.ToString(), capturedOutput); + Assert.Equal("1234", capturedOutput); } public static bool TermIsSetAndRemoteExecutorIsSupported @@ -78,16 +99,35 @@ public static bool TermIsSetAndRemoteExecutorIsSupported [ConditionalTheory(typeof(Color), nameof(TermIsSetAndRemoteExecutorIsSupported))] [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) + [InlineData("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", "1", null, null, true)] + [InlineData("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", "true", null, null, true)] + [InlineData("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", "tRuE", null, null, true)] + [InlineData("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", "0", null, null, true)] + [InlineData("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", "any-value", null, null, true)] + [InlineData("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", "", null, null, false)] + [InlineData(null, null, "FORCE_COLOR", "1", true)] + [InlineData(null, null, "FORCE_COLOR", "true", true)] + [InlineData(null, null, "FORCE_COLOR", "any-value", true)] + [InlineData(null, null, "FORCE_COLOR", "", false)] + [InlineData(null, null, "NO_COLOR", "1", false)] + [InlineData(null, null, "NO_COLOR", "true", false)] + [InlineData(null, null, "NO_COLOR", "any-value", false)] + [InlineData("FORCE_COLOR", "1", "NO_COLOR", "1", true)] + [InlineData("DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION", "1", "NO_COLOR", "1", true)] + public static void RedirectedOutput_ColorEnvVars_RespectColorPreference( + string? envVarName1, string? envVarValue1, + string? envVarName2, string? envVarValue2, + bool shouldEmitEscapes) { var psi = new ProcessStartInfo { RedirectStandardOutput = true }; - psi.Environment["DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION"] = envVar; + if (envVarName1 is not null) + { + psi.Environment[envVarName1] = envVarValue1; + } + if (envVarName2 is not null) + { + psi.Environment[envVarName2] = envVarValue2; + } for (int i = 0; i < 3; i++) { @@ -113,13 +153,11 @@ public static void RedirectedOutput_EnvVarSet_EmitsAnsiCodes(string? envVar) 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)); + Assert.Equal(shouldEmitEscapes, parts[1].Contains(Esc)); } } }