Skip to content

Commit d4e0181

Browse files
committed
feat: show log file path on benchmark finished
1 parent 4e58c49 commit d4e0181

8 files changed

Lines changed: 327 additions & 2 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
using BenchmarkDotNet.Detectors;
2+
using BenchmarkDotNet.Extensions;
3+
using System.Diagnostics;
4+
using System.Runtime.InteropServices;
5+
using System.Runtime.Versioning;
6+
using System.Text.RegularExpressions;
7+
8+
namespace BenchmarkDotNet.Helpers;
9+
10+
internal static class ConsoleHelper
11+
{
12+
private const string ESC = "\e"; // Escape sequence.
13+
private const string OSC8 = $"{ESC}]8;;"; // Operating System Command 8
14+
private const string ST = ESC + @"\"; // String Terminator
15+
16+
/// <summary>
17+
/// Try to gets clickable link text for console.
18+
/// If console doesn't support clickable link, it returns false.
19+
/// </summary>
20+
public static bool TryGetClickableLink(string link, string? linkCaption, out string result)
21+
{
22+
if (!IsClickableLinkSupported)
23+
{
24+
result = "";
25+
return false;
26+
}
27+
28+
result = @$"{OSC8}{link}{ST}{linkCaption ?? link}{OSC8}{ST}";
29+
return true;
30+
}
31+
32+
public static bool IsWindowsTerminal => _isWindowsTerminal.Value;
33+
34+
public static bool IsClickableLinkSupported => _isClickableLinkSupported.Value;
35+
36+
private static readonly Lazy<bool> _isWindowsTerminal = new(()
37+
=> Environment.GetEnvironmentVariable("WT_SESSION") != null);
38+
39+
private static readonly Lazy<bool> _isClickableLinkSupported = new(() =>
40+
{
41+
if (Console.IsOutputRedirected)
42+
return false;
43+
44+
// The current console doesn't have a valid buffer size, which means it is not a real console.
45+
if (Console.BufferHeight == 0 || Console.BufferWidth == 0)
46+
return false;
47+
48+
// Disable clickable link on CI environment.
49+
if (Environment.GetEnvironmentVariable("CI").IsNotBlank())
50+
return false;
51+
52+
// dumb terminal don't support ANSI escape sequence.
53+
var term = Environment.GetEnvironmentVariable("TERM") ?? "";
54+
if (term == "dumb")
55+
return false;
56+
57+
if (OsDetector.IsWindows())
58+
{
59+
try
60+
{
61+
// conhost.exe don't support clickable link with OSC8.
62+
if (IsRunningOnConhost())
63+
return false;
64+
65+
// ConEmu and don't support OSC8.
66+
var conEmu = Environment.GetEnvironmentVariable("ConEmuANSI");
67+
if (conEmu != null)
68+
return false;
69+
70+
// Return true if Virtual Terminal Processing mode is enabled.
71+
return IsVirtualTerminalProcessingEnabled();
72+
}
73+
catch
74+
{
75+
return false; // Ignore unexpected exception.
76+
}
77+
}
78+
else
79+
{
80+
// screen don't support OSC8 clickable link.
81+
if (Regex.IsMatch(term, "^screen"))
82+
return false;
83+
84+
// Other major terminal supports OSC8 by default. https://github.com/Alhadis/OSC8-Adoption
85+
return true;
86+
}
87+
});
88+
89+
[SupportedOSPlatform("windows")]
90+
private static bool IsVirtualTerminalProcessingEnabled()
91+
{
92+
const uint STD_OUTPUT_HANDLE = unchecked((uint)-11);
93+
IntPtr handle = NativeMethods.GetStdHandle(STD_OUTPUT_HANDLE);
94+
if (handle == IntPtr.Zero)
95+
return false;
96+
97+
if (NativeMethods.GetConsoleMode(handle, out uint consoleMode))
98+
{
99+
const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;
100+
if ((consoleMode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) > 0)
101+
{
102+
return true;
103+
}
104+
}
105+
return false;
106+
}
107+
108+
[SupportedOSPlatform("windows")]
109+
private static bool IsRunningOnConhost()
110+
{
111+
IntPtr hwnd = NativeMethods.GetConsoleWindow();
112+
if (hwnd == IntPtr.Zero)
113+
return false;
114+
115+
NativeMethods.GetWindowThreadProcessId(hwnd, out uint pid);
116+
using var process = Process.GetProcessById((int)pid);
117+
return process.ProcessName == "conhost";
118+
}
119+
120+
[SupportedOSPlatform("windows")]
121+
private static class NativeMethods
122+
{
123+
[DllImport("kernel32.dll", SetLastError = true)]
124+
public static extern IntPtr GetStdHandle(uint nStdHandle);
125+
126+
[DllImport("kernel32.dll", SetLastError = true)]
127+
public static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
128+
129+
[DllImport("kernel32.dll", SetLastError = true)]
130+
public static extern IntPtr GetConsoleWindow();
131+
132+
[DllImport("user32.dll", SetLastError = true)]
133+
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
134+
}
135+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
5+
namespace BenchmarkDotNet.Helpers;
6+
7+
internal static class PathHelper
8+
{
9+
public static string GetRelativePath(string relativeTo, string path)
10+
{
11+
#if NETSTANDARD2_0
12+
return GetRelativePathCompat(relativeTo, path);
13+
#else
14+
return Path.GetRelativePath(relativeTo, path);
15+
#endif
16+
}
17+
18+
#if NETSTANDARD2_0
19+
private static string GetRelativePathCompat(string relativeTo, string path)
20+
{
21+
// Get absolute full paths
22+
string basePath = Path.GetFullPath(relativeTo);
23+
string targetPath = Path.GetFullPath(path);
24+
25+
// Normalize base to directory (Path.GetRelativePath treats base as directory always)
26+
if (!basePath.EndsWith(Path.DirectorySeparatorChar.ToString()))
27+
basePath += Path.DirectorySeparatorChar;
28+
29+
// If roots differ, return the absolute target
30+
string baseRoot = Path.GetPathRoot(basePath)!;
31+
string targetRoot = Path.GetPathRoot(targetPath)!;
32+
if (!string.Equals(baseRoot, targetRoot, StringComparison.OrdinalIgnoreCase))
33+
return targetPath;
34+
35+
// Break into segments
36+
var baseSegments = SplitPath(basePath);
37+
var targetSegments = SplitPath(targetPath);
38+
39+
// Find common prefix
40+
int i = 0;
41+
while (i < baseSegments.Count && i < targetSegments.Count && string.Equals(baseSegments[i], targetSegments[i], StringComparison.OrdinalIgnoreCase))
42+
{
43+
i++;
44+
}
45+
46+
// Build relative parts
47+
var relativeParts = new List<string>();
48+
49+
// For each remaining segment in base -> go up one level
50+
for (int j = i; j < baseSegments.Count; j++)
51+
relativeParts.Add("..");
52+
53+
// For each remaining in target -> add those segments
54+
for (int j = i; j < targetSegments.Count; j++)
55+
relativeParts.Add(targetSegments[j]);
56+
57+
// If nothing added, it is the same directory
58+
if (relativeParts.Count == 0)
59+
return ".";
60+
61+
// Join with separator and return
62+
return string.Join(Path.DirectorySeparatorChar.ToString(), relativeParts);
63+
}
64+
65+
private static List<string> SplitPath(string path)
66+
{
67+
var segments = new List<string>();
68+
string[] raw = path.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries);
69+
70+
foreach (var seg in raw)
71+
{
72+
// Skip root parts like "C:\"
73+
if (seg.EndsWith(":"))
74+
continue;
75+
segments.Add(seg);
76+
}
77+
78+
return segments;
79+
}
80+
#endif
81+
}

src/BenchmarkDotNet/Loggers/CompositeLogger.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
namespace BenchmarkDotNet.Loggers
44
{
5-
internal class CompositeLogger : ILogger
5+
internal class CompositeLogger : ILogger, ILinkLogger
66
{
77
private readonly ImmutableHashSet<ILogger> loggers;
88

src/BenchmarkDotNet/Loggers/ConsoleLogger.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
namespace BenchmarkDotNet.Loggers
88
{
9-
public sealed class ConsoleLogger : ILogger
9+
public sealed class ConsoleLogger : ILogger, ILinkLogger
1010
{
1111
private const ConsoleColor DefaultColor = ConsoleColor.Gray;
1212

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
namespace BenchmarkDotNet.Loggers;
2+
3+
internal interface ILinkLogger : ILogger
4+
{
5+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using BenchmarkDotNet.Helpers;
2+
3+
namespace BenchmarkDotNet.Loggers;
4+
5+
public static class ILoggerExtensions
6+
{
7+
/// <summary>
8+
/// Write clickable link to logger.
9+
/// If the logger doesn't implement <see cref="ILinkLogger"/>. It's written as plain text.
10+
/// </summary>
11+
public static void WriteLink(this ILogger logger, string link, string? linkCaption = null, LogKind logKind = LogKind.Info)
12+
{
13+
if (logger is ILinkLogger && ConsoleHelper.TryGetClickableLink(link, linkCaption, out var clickableLink))
14+
{
15+
link = clickableLink;
16+
}
17+
18+
logger.Write(logKind, link);
19+
}
20+
21+
/// <summary>
22+
/// Write clickable link to logger.
23+
/// If the logger doesn't implement <see cref="ILinkLogger"/>. It's written as plain text.
24+
/// </summary>
25+
public static void WriteLineLink(this ILogger logger, string link, string? linkCaption = null, string prefixText = "", string suffixText = "", LogKind logKind = LogKind.Info)
26+
{
27+
if (logger is ILinkLogger && ConsoleHelper.TryGetClickableLink(link, linkCaption, out var clickableLink))
28+
{
29+
link = clickableLink;
30+
}
31+
32+
logger.WriteLine(logKind, link);
33+
}
34+
}

src/BenchmarkDotNet/Running/BenchmarkRunnerClean.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,21 @@ internal static async ValueTask<Summary[]> Run(BenchmarkRunInfo[] benchmarkRunIn
178178
benchmarkInfo.Dispose();
179179
}
180180

181+
// Output additional information to console.
182+
var logFileEnabled = benchmarkRunInfos.All(info => !info.Config.Options.IsSet(ConfigOptions.DisableLogFile));
183+
if (logFileEnabled)
184+
{
185+
var artifactDirectoryFullPath = Path.GetFullPath(rootArtifactsFolderPath);
186+
var logFileFullPath = Path.GetFullPath(logFilePath);
187+
var logFileRelativePath = PathHelper.GetRelativePath(artifactDirectoryFullPath, logFileFullPath);
188+
189+
compositeLogger.WriteLine();
190+
compositeLogger.WriteLineHeader("// * Benchmark LogFile *");
191+
compositeLogger.WriteLineLink(artifactDirectoryFullPath);
192+
compositeLogger.WriteLineLink(logFileFullPath, linkCaption: logFileRelativePath, prefixText: " ");
193+
compositeLogger.WriteLine();
194+
}
195+
181196
compositeLogger.WriteLineHeader("// * Artifacts cleanup *");
182197
Cleanup(compositeLogger, new HashSet<string>(artifactsToCleanup.Distinct()));
183198
compositeLogger.WriteLineInfo("Artifacts cleanup is finished");
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using BenchmarkDotNet.Helpers;
2+
using BenchmarkDotNet.Tests.XUnit;
3+
4+
namespace BenchmarkDotNet.Tests.Helpers;
5+
6+
// Using test patterns of Path.GetRelativePath
7+
// https://github.com/dotnet/runtime/blob/v10.0.0/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/IO/Path.GetRelativePath.cs
8+
public class PathHelperTests
9+
{
10+
[TheoryEnvSpecific(EnvRequirement.WindowsOnly)]
11+
[InlineData(@"C:\", @"C:\", @".")]
12+
[InlineData(@"C:\a", @"C:\a\", @".")]
13+
[InlineData(@"C:\A", @"C:\a\", @".")]
14+
[InlineData(@"C:\a\", @"C:\a", @".")]
15+
[InlineData(@"C:\", @"C:\b", @"b")]
16+
[InlineData(@"C:\a", @"C:\b", @"..\b")]
17+
// [InlineData(@"C:\a", @"C:\b\", @"..\b\")] // This test failed with GetRelativePathCompat.
18+
[InlineData(@"C:\a\b", @"C:\a", @"..")]
19+
[InlineData(@"C:\a\b", @"C:\a\", @"..")]
20+
[InlineData(@"C:\a\b\", @"C:\a", @"..")]
21+
[InlineData(@"C:\a\b\", @"C:\a\", @"..")]
22+
[InlineData(@"C:\a\b\c", @"C:\a\b", @"..")]
23+
[InlineData(@"C:\a\b\c", @"C:\a\b\", @"..")]
24+
[InlineData(@"C:\a\b\c", @"C:\a", @"..\..")]
25+
[InlineData(@"C:\a\b\c", @"C:\a\", @"..\..")]
26+
[InlineData(@"C:\a\b\c\", @"C:\a\b", @"..")]
27+
[InlineData(@"C:\a\b\c\", @"C:\a\b\", @"..")]
28+
[InlineData(@"C:\a\b\c\", @"C:\a", @"..\..")]
29+
[InlineData(@"C:\a\b\c\", @"C:\a\", @"..\..")]
30+
[InlineData(@"C:\a\", @"C:\b", @"..\b")]
31+
[InlineData(@"C:\a", @"C:\a\b", @"b")]
32+
[InlineData(@"C:\a", @"C:\A\b", @"b")]
33+
[InlineData(@"C:\a", @"C:\b\c", @"..\b\c")]
34+
[InlineData(@"C:\a\", @"C:\a\b", @"b")]
35+
[InlineData(@"C:\", @"D:\", @"D:\")]
36+
[InlineData(@"C:\", @"D:\b", @"D:\b")]
37+
[InlineData(@"C:\", @"D:\b\", @"D:\b\")]
38+
[InlineData(@"C:\a", @"D:\b", @"D:\b")]
39+
[InlineData(@"C:\a\", @"D:\b", @"D:\b")]
40+
[InlineData(@"C:\ab", @"C:\a", @"..\a")]
41+
[InlineData(@"C:\a", @"C:\ab", @"..\ab")]
42+
[InlineData(@"C:\", @"\\LOCALHOST\Share\b", @"\\LOCALHOST\Share\b")]
43+
[InlineData(@"\\LOCALHOST\Share\a", @"\\LOCALHOST\Share\b", @"..\b")]
44+
public void GetRelativePathTest(string relativeTo, string path, string expected)
45+
{
46+
// Arrange
47+
expected = expected.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
48+
49+
// Act
50+
var result = PathHelper.GetRelativePath(relativeTo, path);
51+
52+
// Assert
53+
Assert.Equal(expected, result);
54+
}
55+
}

0 commit comments

Comments
 (0)