Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/libraries/Common/src/System/Console/ConsoleUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ internal static partial class ConsoleUtils
/// <summary>Whether to output ANSI color strings.</summary>
private static NullableBool s_emitAnsiColorCodes;

/// <summary>Whether color output was explicitly forced on by an environment variable.</summary>
private static NullableBool s_colorForcedByEnvironment;

/// <summary>Get whether to emit ANSI color codes.</summary>
public static bool EmitAnsiColorCodes
{
Expand All @@ -33,6 +36,32 @@ public static bool EmitAnsiColorCodes
}
}

/// <summary>
/// Gets whether color output was explicitly requested via <c>FORCE_COLOR</c> or
/// <c>DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION</c>, as opposed to being
/// enabled implicitly because output isn't redirected.
/// </summary>
/// <remarks>
/// Callers use this to distinguish "color is on because the terminal looks interactive"
/// from "the user asked for color unconditionally". The latter warrants emitting color
/// even when the terminal description doesn't advertise support for it.
/// </remarks>
internal static bool ColorForcedByEnvironment
{
get
{
NullableBool forced = s_colorForcedByEnvironment;
if (forced is not NullableBool.Undefined)
{
return forced is NullableBool.True;
}

bool value = GetColorOverrideFromEnvironment() is true;
s_colorForcedByEnvironment = value ? NullableBool.True : NullableBool.False;
return value;
}
}

/// <summary>
/// Checks FORCE_COLOR, DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION, and NO_COLOR environment variables.
/// Returns <see langword="true"/> to force color on, <see langword="false"/> to force color off,
Expand Down
91 changes: 63 additions & 28 deletions src/libraries/System.Console/src/System/ConsolePal.Unix.cs
Original file line number Diff line number Diff line change
Expand Up @@ -812,48 +812,83 @@ private static void WriteSetColorString(bool foreground, ConsoleColor color)
int maxColors = TerminalFormatStringsInstance.MaxColors; // often 8 or 16; 0 is invalid
if (maxColors > 0)
{
// The values of the ConsoleColor enums unfortunately don't map to the
// corresponding ANSI values. We need to do the mapping manually.
// See http://en.wikipedia.org/wiki/ANSI_escape_code#Colors
ReadOnlySpan<byte> consoleColorToAnsiCode =
[
// Dark/Normal colors
0, // Black,
4, // DarkBlue,
2, // DarkGreen,
6, // DarkCyan,
1, // DarkRed,
5, // DarkMagenta,
3, // DarkYellow,
7, // Gray,

// Bright colors
8, // DarkGray,
12, // Blue,
10, // Green,
14, // Cyan,
9, // Red,
13, // Magenta,
11, // Yellow,
15 // White
];

int ansiCode = consoleColorToAnsiCode[ccValue] % maxColors;
int ansiCode = ConsoleColorToAnsiCode[ccValue] % maxColors;
evaluatedString = TermInfo.ParameterizedStrings.Evaluate(formatString, ansiCode);

WriteTerminalAnsiColorString(evaluatedString);

s_fgbgAndColorStrings[fgbgIndex, ccValue] = evaluatedString; // benign race
return;
}
}

if (ConsoleUtils.ColorForcedByEnvironment)
{
// No setaf/setab capability: TERM=dumb, or TERM unset as in a minimal container
// or a child process spawned by a tool such as `dotnet watch`. FORCE_COLOR is an
// unconditional request for color, so emit the standard SGR sequence rather than
// silently dropping it. Only when explicitly forced -- if color is merely on
// because output isn't redirected, a missing capability genuinely means the
// terminal can't render escapes.
evaluatedString = GetDefaultAnsiColorString(foreground, ConsoleColorToAnsiCode[ccValue]);

WriteTerminalAnsiColorString(evaluatedString);

s_fgbgAndColorStrings[fgbgIndex, ccValue] = evaluatedString; // benign race
}
}

/// <summary>The ANSI color index for each <see cref="ConsoleColor"/>; the enum values don't match.</summary>
/// <remarks>See http://en.wikipedia.org/wiki/ANSI_escape_code#Colors</remarks>
private static ReadOnlySpan<byte> ConsoleColorToAnsiCode =>
[
// Dark/Normal colors
0, // Black,
4, // DarkBlue,
2, // DarkGreen,
6, // DarkCyan,
1, // DarkRed,
5, // DarkMagenta,
3, // DarkYellow,
7, // Gray,

// Bright colors
8, // DarkGray,
12, // Blue,
10, // Green,
14, // Cyan,
9, // Red,
13, // Magenta,
11, // Yellow,
15 // White
];

/// <summary>Builds a standard SGR escape sequence, for use when terminfo offers none.</summary>
private static string GetDefaultAnsiColorString(bool foreground, int ansiCode)
{
Debug.Assert((uint)ansiCode <= 15);

// 30-37 dark foreground, 90-97 bright foreground,
// 40-47 dark background, 100-107 bright background.
int sgr = ansiCode < 8
? (foreground ? 30 : 40) + ansiCode
: (foreground ? 90 : 100) + (ansiCode - 8);

return $"\u001b[{sgr}m";
}

/// <summary>Writes out the ANSI string to reset colors.</summary>
private static void WriteResetColorString()
{
if (ConsoleUtils.EmitAnsiColorCodes)
{
WriteTerminalAnsiColorString(TerminalFormatStringsInstance.Reset);
string? reset = TerminalFormatStringsInstance.Reset;
if (string.IsNullOrEmpty(reset) && ConsoleUtils.ColorForcedByEnvironment)
{
reset = "\u001b[0m"; // matches the fallback above, so forced color is still reset
}

WriteTerminalAnsiColorString(reset);
}
}

Expand Down
72 changes: 72 additions & 0 deletions src/libraries/System.Console/tests/Color.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,76 @@ public static void RedirectedOutput_ColorEnvVars_RespectColorPreference(
Assert.Equal(shouldEmitEscapes, parts[1].Contains(Esc));
}
}

[ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
[PlatformSpecific(TestPlatforms.AnyUnix)] // terminfo is only consulted on Unix
// TERM=dumb and an absent TERM both yield a terminal description with no
// setaf/setab capability. FORCE_COLOR is an unconditional request for color, so it
// must still emit; without an explicit request, an absent capability means no color.
[InlineData("dumb", true, true)]
[InlineData("dumb", false, false)]
[InlineData(null, true, true)]
[InlineData(null, false, false)]
public static void RedirectedOutput_ForceColorWithoutTerminfo_StillEmitsEscapes(
string? term, bool forceColor, bool shouldEmitEscapes)
{
var psi = new ProcessStartInfo { RedirectStandardOutput = true };
if (term is null)
{
psi.Environment.Remove("TERM");
}
else
{
psi.Environment["TERM"] = term;
}

if (forceColor)
{
psi.Environment["FORCE_COLOR"] = "1";
}

Action main = () =>
{
Console.Write("SEPARATOR");
Console.ForegroundColor = ConsoleColor.Red;
Console.Write("SEPARATOR");
};

using RemoteInvokeHandle remote = RemoteExecutor.Invoke(main, new RemoteInvokeOptions() { StartInfo = psi });

string stdout = remote.Process.StandardOutput.ReadToEnd();
string[] parts = stdout.Split("SEPARATOR");
Assert.Equal(3, parts.Length);
Assert.Equal(shouldEmitEscapes, parts[1].Contains(Esc));
}

[ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
[PlatformSpecific(TestPlatforms.AnyUnix)]
public static void RedirectedOutput_ForceColorWithoutTerminfo_ResetsColor()
{
// The fallback must also reset, or a forced color bleeds into everything after it.
var psi = new ProcessStartInfo { RedirectStandardOutput = true };
psi.Environment.Remove("TERM");
psi.Environment["FORCE_COLOR"] = "1";

Action main = () =>
{
// Bracket with separators so terminal initialization output, if any, can't
// affect the comparison -- same isolation the other tests in this file use.
Console.Write("SEPARATOR");
Console.ForegroundColor = ConsoleColor.Red;
Console.Write("X");
Console.ResetColor();
Console.Write("SEPARATOR");
};

using RemoteInvokeHandle remote = RemoteExecutor.Invoke(main, new RemoteInvokeOptions() { StartInfo = psi });

string stdout = remote.Process.StandardOutput.ReadToEnd();
string[] parts = stdout.Split("SEPARATOR");
Assert.Equal(3, parts.Length);
// RefreshColors resets before re-applying the tracked colors, so the reset
// fallback fires here too -- same shape terminfo produces on a real terminal.
Assert.Equal($"{Esc}[0m{Esc}[91mX{Esc}[0m", parts[1]);
}
}
Loading