diff --git a/src/BenchmarkDotNet/ConsoleArguments/ConfigParser.cs b/src/BenchmarkDotNet/ConsoleArguments/ConfigParser.cs index 292b27cca8..6211330336 100644 --- a/src/BenchmarkDotNet/ConsoleArguments/ConfigParser.cs +++ b/src/BenchmarkDotNet/ConsoleArguments/ConfigParser.cs @@ -763,6 +763,9 @@ private static IEnumerable GetFilters(CommandLineOptions options) private static int GetMaximumDisplayWidth() { + if (Console.IsOutputRedirected) + return MinimumDisplayWidth; + try { return Console.WindowWidth; diff --git a/src/BenchmarkDotNet/Helpers/ConsoleHelper.cs b/src/BenchmarkDotNet/Helpers/ConsoleHelper.cs new file mode 100644 index 0000000000..5b76735400 --- /dev/null +++ b/src/BenchmarkDotNet/Helpers/ConsoleHelper.cs @@ -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 + + /// + /// Try to gets clickable link text for console. + /// If console doesn't support clickable link, it returns false. + /// + 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 _isWindowsTerminal = new(() + => Environment.GetEnvironmentVariable("WT_SESSION") != null); + + private static readonly Lazy _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); + } +} diff --git a/src/BenchmarkDotNet/Helpers/PathHelper.cs b/src/BenchmarkDotNet/Helpers/PathHelper.cs new file mode 100644 index 0000000000..2ce51a3ea7 --- /dev/null +++ b/src/BenchmarkDotNet/Helpers/PathHelper.cs @@ -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(); + + // 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 SplitPath(string path) + { + var segments = new List(); + 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 +} diff --git a/src/BenchmarkDotNet/Loggers/CompositeLogger.cs b/src/BenchmarkDotNet/Loggers/CompositeLogger.cs index 9906a6d4a1..d8f14377ee 100644 --- a/src/BenchmarkDotNet/Loggers/CompositeLogger.cs +++ b/src/BenchmarkDotNet/Loggers/CompositeLogger.cs @@ -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 loggers; diff --git a/src/BenchmarkDotNet/Loggers/ConsoleLogger.cs b/src/BenchmarkDotNet/Loggers/ConsoleLogger.cs index 52c33b27eb..cc363daece 100644 --- a/src/BenchmarkDotNet/Loggers/ConsoleLogger.cs +++ b/src/BenchmarkDotNet/Loggers/ConsoleLogger.cs @@ -9,7 +9,7 @@ namespace BenchmarkDotNet.Loggers { - public sealed class ConsoleLogger : ILogger + public sealed class ConsoleLogger : ILogger, ILinkLogger { private const ConsoleColor DefaultColor = ConsoleColor.Gray; diff --git a/src/BenchmarkDotNet/Loggers/ILinkLogger.cs b/src/BenchmarkDotNet/Loggers/ILinkLogger.cs new file mode 100644 index 0000000000..5c0c328711 --- /dev/null +++ b/src/BenchmarkDotNet/Loggers/ILinkLogger.cs @@ -0,0 +1,5 @@ +namespace BenchmarkDotNet.Loggers; + +internal interface ILinkLogger : ILogger +{ +} diff --git a/src/BenchmarkDotNet/Loggers/ILoggerExtensions.cs b/src/BenchmarkDotNet/Loggers/ILoggerExtensions.cs new file mode 100644 index 0000000000..8439812522 --- /dev/null +++ b/src/BenchmarkDotNet/Loggers/ILoggerExtensions.cs @@ -0,0 +1,45 @@ +using BenchmarkDotNet.Helpers; + +namespace BenchmarkDotNet.Loggers; + +#nullable enable + +public static class ILoggerExtensions +{ + /// + /// Write clickable link to logger. + /// If the logger doesn't implement . It's written as plain text. + /// + 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); + } + + /// + /// Write clickable link to logger. + /// If the logger doesn't implement . It's written as plain text. + /// + 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}"); + } +} diff --git a/src/BenchmarkDotNet/Running/BenchmarkRunnerClean.cs b/src/BenchmarkDotNet/Running/BenchmarkRunnerClean.cs index 0c096b6c9e..467fc9f900 100644 --- a/src/BenchmarkDotNet/Running/BenchmarkRunnerClean.cs +++ b/src/BenchmarkDotNet/Running/BenchmarkRunnerClean.cs @@ -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(); } @@ -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(); } } diff --git a/tests/BenchmarkDotNet.Tests/Helpers/PathHelperTests.cs b/tests/BenchmarkDotNet.Tests/Helpers/PathHelperTests.cs new file mode 100644 index 0000000000..f1d68a30bd --- /dev/null +++ b/tests/BenchmarkDotNet.Tests/Helpers/PathHelperTests.cs @@ -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 + } +}