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
3 changes: 3 additions & 0 deletions src/BenchmarkDotNet/ConsoleArguments/ConfigParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,9 @@ private static IEnumerable<IFilter> GetFilters(CommandLineOptions options)

private static int GetMaximumDisplayWidth()
{
if (Console.IsOutputRedirected)
return MinimumDisplayWidth;

try
{
return Console.WindowWidth;
Expand Down
138 changes: 138 additions & 0 deletions src/BenchmarkDotNet/Helpers/ConsoleHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using BenchmarkDotNet.Detectors;
using BenchmarkDotNet.Loggers;
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text.RegularExpressions;

namespace BenchmarkDotNet.Helpers;

#nullable enable

internal static class ConsoleHelper
{
private const string ESC = "\e"; // Escape sequence.
private const string OSC8 = $"{ESC}]8;;"; // Operating System Command 8
private const string ST = ESC + @"\"; // String Terminator

/// <summary>
/// Try to gets clickable link text for console.
/// If console doesn't support clickable link, it returns false.
/// </summary>
public static bool TryGetClickableLink(string link, string? linkCaption, out string result)
{
if (!IsClickableLinkSupported)
{
result = "";
return false;
}

result = @$"{OSC8}{link}{ST}{linkCaption ?? link}{OSC8}{ST}";
return true;
}

public static bool IsWindowsTerminal => _isWindowsTerminal.Value;

public static bool IsClickableLinkSupported => _isClickableLinkSupported.Value;

private static readonly Lazy<bool> _isWindowsTerminal = new(()
=> Environment.GetEnvironmentVariable("WT_SESSION") != null);

private static readonly Lazy<bool> _isClickableLinkSupported = new(() =>
{
if (Console.IsOutputRedirected)
return false;

// The current console doesn't have a valid buffer size, which means it is not a real console.
if (Console.BufferHeight == 0 || Console.BufferWidth == 0)
return false;

// Disable clickable link on CI environment.
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")))
return false;

// dumb terminal don't support ANSI escape sequence.
var term = Environment.GetEnvironmentVariable("TERM") ?? "";
if (term == "dumb")
return false;

if (OsDetector.IsWindows())
{
try
{
// conhost.exe don't support clickable link with OSC8.
if (IsRunningOnConhost())
return false;

// ConEmu and don't support OSC8.
var conEmu = Environment.GetEnvironmentVariable("ConEmuANSI");
if (conEmu != null)
return false;

// Return true if Virtual Terminal Processing mode is enabled.
return IsVirtualTerminalProcessingEnabled();
}
catch
{
return false; // Ignore unexpected exception.
}
}
else
{
// screen don't support OSC8 clickable link.
if (Regex.IsMatch(term, "^screen"))
return false;

// Other major terminal supports OSC8 by default. https://github.com/Alhadis/OSC8-Adoption
return true;
}
});

[SupportedOSPlatform("windows")]
private static bool IsVirtualTerminalProcessingEnabled()
{
const uint STD_OUTPUT_HANDLE = unchecked((uint)-11);
IntPtr handle = NativeMethods.GetStdHandle(STD_OUTPUT_HANDLE);
if (handle == IntPtr.Zero)
return false;

if (NativeMethods.GetConsoleMode(handle, out uint consoleMode))
{
const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;
if ((consoleMode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) > 0)
{
return true;
}
}
return false;
}

[SupportedOSPlatform("windows")]
private static bool IsRunningOnConhost()
{
IntPtr hwnd = NativeMethods.GetConsoleWindow();
if (hwnd == IntPtr.Zero)
return false;

NativeMethods.GetWindowThreadProcessId(hwnd, out uint pid);
using var process = Process.GetProcessById((int)pid);
return process.ProcessName == "conhost";
}

[SupportedOSPlatform("windows")]
private static class NativeMethods
{
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr GetStdHandle(uint nStdHandle);

[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);

[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr GetConsoleWindow();

[DllImport("user32.dll", SetLastError = true)]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
}
}
83 changes: 83 additions & 0 deletions src/BenchmarkDotNet/Helpers/PathHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.IO;

namespace BenchmarkDotNet.Helpers;

#nullable enable

internal static class PathHelper
{
public static string GetRelativePath(string relativeTo, string path)
{
#if !NETSTANDARD2_0
return Path.GetRelativePath(relativeTo, path);
#else
return GetRelativePathCompat(relativeTo, path);
#endif
}

#if NETSTANDARD2_0
private static string GetRelativePathCompat(string relativeTo, string path)
{
// Get absolute full paths
string basePath = Path.GetFullPath(relativeTo);
string targetPath = Path.GetFullPath(path);

// Normalize base to directory (Path.GetRelativePath treats base as directory always)
if (!basePath.EndsWith(Path.DirectorySeparatorChar.ToString()))
basePath += Path.DirectorySeparatorChar;

// If roots differ, return the absolute target
string baseRoot = Path.GetPathRoot(basePath)!;
string targetRoot = Path.GetPathRoot(targetPath)!;
if (!string.Equals(baseRoot, targetRoot, StringComparison.OrdinalIgnoreCase))
return targetPath;

// Break into segments
var baseSegments = SplitPath(basePath);
var targetSegments = SplitPath(targetPath);

// Find common prefix
int i = 0;
while (i < baseSegments.Count && i < targetSegments.Count && string.Equals(baseSegments[i], targetSegments[i], StringComparison.OrdinalIgnoreCase))
{
i++;
}

// Build relative parts
var relativeParts = new List<string>();

// For each remaining segment in base -> go up one level
for (int j = i; j < baseSegments.Count; j++)
relativeParts.Add("..");

// For each remaining in target -> add those segments
for (int j = i; j < targetSegments.Count; j++)
relativeParts.Add(targetSegments[j]);

// If nothing added, it is the same directory
if (relativeParts.Count == 0)
return ".";

// Join with separator and return
return string.Join(Path.DirectorySeparatorChar.ToString(), relativeParts);
}

private static List<string> SplitPath(string path)
{
var segments = new List<string>();
string[] raw = path.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries);

foreach (var seg in raw)
{
// Skip root parts like "C:\"
if (seg.EndsWith(":"))
continue;
segments.Add(seg);
}

return segments;
}
#endif
}
6 changes: 5 additions & 1 deletion src/BenchmarkDotNet/Loggers/CompositeLogger.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Linq;

#nullable enable

namespace BenchmarkDotNet.Loggers
{
internal class CompositeLogger : ILogger
internal class CompositeLogger : ILogger, ILinkLogger
{
private readonly ImmutableHashSet<ILogger> loggers;

Expand Down
2 changes: 1 addition & 1 deletion src/BenchmarkDotNet/Loggers/ConsoleLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

namespace BenchmarkDotNet.Loggers
{
public sealed class ConsoleLogger : ILogger
public sealed class ConsoleLogger : ILogger, ILinkLogger
{
private const ConsoleColor DefaultColor = ConsoleColor.Gray;

Expand Down
5 changes: 5 additions & 0 deletions src/BenchmarkDotNet/Loggers/ILinkLogger.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace BenchmarkDotNet.Loggers;

internal interface ILinkLogger : ILogger
{
}
45 changes: 45 additions & 0 deletions src/BenchmarkDotNet/Loggers/ILoggerExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using BenchmarkDotNet.Helpers;

namespace BenchmarkDotNet.Loggers;

#nullable enable

public static class ILoggerExtensions
{
/// <summary>
/// Write clickable link to logger.
/// If the logger doesn't implement <see cref="ILinkLogger"/>. It's written as plain text.
/// </summary>
public static void WriteLink(this ILogger logger, string link, string? linkCaption = null, LogKind logKind = LogKind.Info)
{
if (logger is ILinkLogger)
{
if (ConsoleHelper.TryGetClickableLink(link, linkCaption, out var clickableLink))
link = clickableLink;
}

logger.Write(logKind, link);
}

/// <summary>
/// Write clickable link to logger.
/// If the logger doesn't implement <see cref="ILinkLogger"/>. It's written as plain text.
/// </summary>
public static void WriteLineLink(this ILogger logger, string link, string? linkCaption = null, string prefixText = "", string suffixText = "", LogKind logKind = LogKind.Info)
{
if (logger is ILinkLogger)
{
if (ConsoleHelper.TryGetClickableLink(link, linkCaption, out var clickableLink))
{
link = clickableLink;

// Temporary workaround for Windows Terminal.
// To avoid link style corruption issue when output ends with a clickable link and window is resized.
if (ConsoleHelper.IsWindowsTerminal && suffixText == "")
suffixText = " ";
}
}

logger.WriteLine(logKind, $"{prefixText}{link}{suffixText}");
}
}
16 changes: 16 additions & 0 deletions src/BenchmarkDotNet/Running/BenchmarkRunnerClean.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ internal static Summary[] Run(BenchmarkRunInfo[] benchmarkRunInfos)
var totalTime = globalChronometer.GetElapsed().GetTimeSpan();
int totalNumberOfExecutedBenchmarks = results.Sum(summary => summary.GetNumberOfExecutedBenchmarks());
LogTotalTime(compositeLogger, totalTime, totalNumberOfExecutedBenchmarks, "Global total time");
compositeLogger.WriteLine();

return results.ToArray();
}
Expand All @@ -191,6 +192,21 @@ internal static Summary[] Run(BenchmarkRunInfo[] benchmarkRunInfos)
compositeLogger.WriteLineInfo("Artifacts cleanup is finished");
compositeLogger.Flush();

// Output additional information to console.
var logFileEnabled = benchmarkRunInfos.All(info => !info.Config.Options.IsSet(ConfigOptions.DisableLogFile));
if (logFileEnabled)
{
var artifactDirectoryFullPath = Path.GetFullPath(rootArtifactsFolderPath);
var logFileFullPath = Path.GetFullPath(logFilePath);
var logFileRelativePath = PathHelper.GetRelativePath(artifactDirectoryFullPath, logFileFullPath);

compositeLogger.WriteLine();
compositeLogger.WriteLineHeader("// * Benchmark LogFile *");
compositeLogger.WriteLineLink(artifactDirectoryFullPath);
compositeLogger.WriteLineLink(logFileFullPath, linkCaption: logFileRelativePath, prefixText: " ");
compositeLogger.Flush();
}

eventProcessor.OnEndRunStage();
}
}
Expand Down
59 changes: 59 additions & 0 deletions tests/BenchmarkDotNet.Tests/Helpers/PathHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using BenchmarkDotNet.Helpers;
using System.IO;
using Xunit;

namespace BenchmarkDotNet.Tests.Helpers
{
// Using test patterns of Path.GetRelativePath
// https://github.com/dotnet/runtime/blob/v10.0.0/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/IO/Path.GetRelativePath.cs
public class PathHelperTests
{
#if NETFRAMEWORK
[Theory]
[InlineData(@"C:\", @"C:\", @".")]
[InlineData(@"C:\a", @"C:\a\", @".")]
[InlineData(@"C:\A", @"C:\a\", @".")]
[InlineData(@"C:\a\", @"C:\a", @".")]
[InlineData(@"C:\", @"C:\b", @"b")]
[InlineData(@"C:\a", @"C:\b", @"..\b")]
// [InlineData(@"C:\a", @"C:\b\", @"..\b\")] // This test failed with GetRelativePathCompat.
[InlineData(@"C:\a\b", @"C:\a", @"..")]
[InlineData(@"C:\a\b", @"C:\a\", @"..")]
[InlineData(@"C:\a\b\", @"C:\a", @"..")]
[InlineData(@"C:\a\b\", @"C:\a\", @"..")]
[InlineData(@"C:\a\b\c", @"C:\a\b", @"..")]
[InlineData(@"C:\a\b\c", @"C:\a\b\", @"..")]
[InlineData(@"C:\a\b\c", @"C:\a", @"..\..")]
[InlineData(@"C:\a\b\c", @"C:\a\", @"..\..")]
[InlineData(@"C:\a\b\c\", @"C:\a\b", @"..")]
[InlineData(@"C:\a\b\c\", @"C:\a\b\", @"..")]
[InlineData(@"C:\a\b\c\", @"C:\a", @"..\..")]
[InlineData(@"C:\a\b\c\", @"C:\a\", @"..\..")]
[InlineData(@"C:\a\", @"C:\b", @"..\b")]
[InlineData(@"C:\a", @"C:\a\b", @"b")]
[InlineData(@"C:\a", @"C:\A\b", @"b")]
[InlineData(@"C:\a", @"C:\b\c", @"..\b\c")]
[InlineData(@"C:\a\", @"C:\a\b", @"b")]
[InlineData(@"C:\", @"D:\", @"D:\")]
[InlineData(@"C:\", @"D:\b", @"D:\b")]
[InlineData(@"C:\", @"D:\b\", @"D:\b\")]
[InlineData(@"C:\a", @"D:\b", @"D:\b")]
[InlineData(@"C:\a\", @"D:\b", @"D:\b")]
[InlineData(@"C:\ab", @"C:\a", @"..\a")]
[InlineData(@"C:\a", @"C:\ab", @"..\ab")]
[InlineData(@"C:\", @"\\LOCALHOST\Share\b", @"\\LOCALHOST\Share\b")]
[InlineData(@"\\LOCALHOST\Share\a", @"\\LOCALHOST\Share\b", @"..\b")]
public void GetRelativePathTest_Windows(string relativeTo, string path, string expected)
{
// Arrange
expected = expected.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);

// Act
var result = PathHelper.GetRelativePath(relativeTo, path);

// Assert
Assert.Equal(expected, result);
}
#endif
}
}