Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix parsing of compiler path from binlog #136

Merged
merged 7 commits into from
Jun 7, 2024
Merged
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
13 changes: 13 additions & 0 deletions src/Basic.CompilerLog.UnitTests/BinaryLogReaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ public void CreateFilePathLogReaderState()
state.Dispose();
}

/// <summary>
/// Make sure the underlying stream is managed properly so we can read the compiler calls twice.
/// </summary>
[Fact]
public void ReadAllCompilerCallsTwice()
{
using var state = new LogReaderState();
using var reader = BinaryLogReader.Create(Fixture.Console.Value.BinaryLogPath!, BasicAnalyzerKind.OnDisk, state);
Assert.Single(reader.ReadAllCompilerCalls());
Assert.Single(reader.ReadAllCompilerCalls());
state.Dispose();
}

[Theory]
[InlineData(BasicAnalyzerKind.InMemory, true)]
[InlineData(BasicAnalyzerKind.OnDisk, true)]
Expand Down
44 changes: 39 additions & 5 deletions src/Basic.CompilerLog.UnitTests/BinaryLogUtilTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,33 @@ public sealed class BinaryLogUtilTests
[InlineData("csc.exe a.cs b.cs", "csc.exe", "a.cs b.cs")]
public void ParseCompilerAndArgumentsCsc(string inputArgs, string? expectedCompilerFilePath, string expectedArgs)
{
var (actualCompilerFilePath, actualArgs) = BinaryLogUtil.ParseTaskForCompilerAndArguments(ToArray(inputArgs), "csc.exe", "csc.dll");
var (actualCompilerFilePath, actualArgs) = BinaryLogUtil.ParseTaskForCompilerAndArguments(inputArgs, "csc.exe", "csc.dll");
Assert.Equal(ToArray(expectedArgs), actualArgs);
Assert.Equal(expectedCompilerFilePath, actualCompilerFilePath);
static string[] ToArray(string arg) => arg.Split(new char[]{' '}, StringSplitOptions.RemoveEmptyEntries);
}

[WindowsTheory]
[InlineData(@" C:\Program Files\dotnet\dotnet.exe exec ""C:\Program Files\dotnet\sdk\8.0.301\Roslyn\bincore\csc.dll"" a.cs", @"C:\Program Files\dotnet\sdk\8.0.301\Roslyn\bincore\csc.dll", "a.cs")]
[InlineData(@"C:\Program Files\dotnet\dotnet.exe exec ""C:\Program Files\dotnet\sdk\8.0.301\Roslyn\bincore\csc.dll"" a.cs", @"C:\Program Files\dotnet\sdk\8.0.301\Roslyn\bincore\csc.dll", "a.cs")]
[InlineData(@"""C:\Program Files\dotnet\dotnet.exe"" exec ""C:\Program Files\dotnet\sdk\8.0.301\Roslyn\bincore\csc.dll"" a.cs", @"C:\Program Files\dotnet\sdk\8.0.301\Roslyn\bincore\csc.dll", "a.cs")]
[InlineData(@"'C:\Program Files\dotnet\dotnet.exe' exec ""C:\Program Files\dotnet\sdk\8.0.301\Roslyn\bincore\csc.dll"" a.cs", @"C:\Program Files\dotnet\sdk\8.0.301\Roslyn\bincore\csc.dll", "a.cs")]
[InlineData(@"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\Roslyn\csc.exe a.cs b.cs", @"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\Roslyn\csc.exe", "a.cs b.cs")]
[InlineData(@"""C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\Roslyn\csc.exe"" a.cs b.cs", @"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\Roslyn\csc.exe", "a.cs b.cs")]
public void ParseCompilerAndArgumentsCscWindows(string inputArgs, string? expectedCompilerFilePath, string expectedArgs)
{
var (actualCompilerFilePath, actualArgs) = BinaryLogUtil.ParseTaskForCompilerAndArguments(inputArgs, "csc.exe", "csc.dll");
Assert.Equal(ToArray(expectedArgs), actualArgs);
Assert.Equal(expectedCompilerFilePath, actualCompilerFilePath);
static string[] ToArray(string arg) => arg.Split(new char[]{' '}, StringSplitOptions.RemoveEmptyEntries);
}

[UnixTheory]
[InlineData(@"/dotnet/dotnet exec /dotnet/sdk/bincore/csc.dll a.cs", "/dotnet/sdk/bincore/csc.dll", "a.cs")]
[InlineData(@"/dotnet/dotnet exec ""/dotnet/sdk/bincore/csc.dll"" a.cs", "/dotnet/sdk/bincore/csc.dll", "a.cs")]
public void ParseCompilerAndArgumentsCscUnix(string inputArgs, string? expectedCompilerFilePath, string expectedArgs)
{
var (actualCompilerFilePath, actualArgs) = BinaryLogUtil.ParseTaskForCompilerAndArguments(inputArgs, "csc.exe", "csc.dll");
Assert.Equal(ToArray(expectedArgs), actualArgs);
Assert.Equal(expectedCompilerFilePath, actualCompilerFilePath);
static string[] ToArray(string arg) => arg.Split(new char[]{' '}, StringSplitOptions.RemoveEmptyEntries);
Expand All @@ -39,21 +65,29 @@ public void ParseCompilerAndArgumentsCsc(string inputArgs, string? expectedCompi
[InlineData("vbc.exe a.cs b.cs", "vbc.exe", "a.cs b.cs")]
public void ParseCompilerAndArgumentsVbc(string inputArgs, string? expectedCompilerFilePath, string expectedArgs)
{
var (actualCompilerFilePath, actualArgs) = BinaryLogUtil.ParseTaskForCompilerAndArguments(ToArray(inputArgs), "vbc.exe", "vbc.dll");
var (actualCompilerFilePath, actualArgs) = BinaryLogUtil.ParseTaskForCompilerAndArguments(inputArgs, "vbc.exe", "vbc.dll");
Assert.Equal(ToArray(expectedArgs), actualArgs);
Assert.Equal(expectedCompilerFilePath, actualCompilerFilePath);
static string[] ToArray(string arg) => arg.Split(new char[]{' '}, StringSplitOptions.RemoveEmptyEntries);
}


[Theory]
[InlineData("dotnet not what we expect a.cs")]
[InlineData("dotnet csc2 what we expect a.cs")]
[InlineData("dotnet exec vbc.dll what we expect a.cs")]
[InlineData("empty")]
[InlineData(" ")]
public void ParseCompilerAndArgumentsBad(string inputArgs)
{
Assert.Throws<InvalidOperationException>(() => BinaryLogUtil.ParseTaskForCompilerAndArguments(ToArray(inputArgs), "csc.exe", "csc.dll"));
static string[] ToArray(string arg) => arg.Split(new char[]{' '}, StringSplitOptions.RemoveEmptyEntries);
Assert.Throws<InvalidOperationException>(() => BinaryLogUtil.ParseTaskForCompilerAndArguments(inputArgs, "csc.exe", "csc.dll"));
}

[Fact]
public void ParseCompilerAndArgumentsNull()
{
var (actualCompilerFilePath, actualArgs) = BinaryLogUtil.ParseTaskForCompilerAndArguments(null, "csc.exe", "csc.dll");
Assert.Null(actualCompilerFilePath);
Assert.Empty(actualArgs);
}
}

Expand Down
23 changes: 23 additions & 0 deletions src/Basic.CompilerLog.UnitTests/ConditionalFacts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,26 @@ public WindowsFactAttribute()
}
}
}

public sealed class WindowsTheoryAttribute : TheoryAttribute
{
public WindowsTheoryAttribute()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Skip = "This test is only supported on Windows";
}
}
}

public sealed class UnixTheoryAttribute : TheoryAttribute
{
public UnixTheoryAttribute()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Skip = "This test is only supported on Windows";
}
}
}

13 changes: 12 additions & 1 deletion src/Basic.CompilerLog.UnitTests/ProgramTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -717,12 +717,23 @@ public void PrintCompilers()
var tuple = reader.ReadAllCompilerAssemblies().Single();
Assert.Contains($"""
Compilers
{'\t'}File Path: {tuple.CompilerFilePath}
{'\t'}File Path: {tuple.FilePath}
{'\t'}Assembly Name: {tuple.AssemblyName}
{'\t'}Commit Hash: {tuple.CommitHash}
""", output);
}

/// <summary>
/// Ensure that print can run without the code being present
/// </summary>
[Fact]
public void PrintWithoutProject()
{
var (exitCode, output) = RunCompLogEx($"print {Fixture.RemovedBinaryLogPath} -c");
Assert.Equal(Constants.ExitSuccess, exitCode);
Assert.StartsWith("Projects", output);
}

/// <summary>
/// Engage the code to find files in the specified directory
/// </summary>
Expand Down
3 changes: 3 additions & 0 deletions src/Basic.CompilerLog.UnitTests/SolutionFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ public sealed class SolutionFixture : FixtureBase, IDisposable

internal string ConsoleWithDiagnosticsProjectName => Path.GetFileName(ConsoleWithDiagnosticsProjectPath);

/// <summary>
/// The binary log for a project that has been removed from disk
/// </summary>
internal string RemovedBinaryLogPath { get; }

/// <summary>
Expand Down
34 changes: 34 additions & 0 deletions src/Basic.CompilerLog.Util/BinaryLogReader.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Reflection;
using Basic.CompilerLog.Util.Impl;
using MessagePack.Formatters;
using Microsoft.Build.Logging.StructuredLogger;
Expand Down Expand Up @@ -82,6 +83,7 @@
{
predicate ??= static _ => true;

_stream.Position = 0;
return BinaryLogUtil.ReadAllCompilerCalls(_stream, predicate, ownerState: this);
}

Expand Down Expand Up @@ -299,6 +301,38 @@
return ReadAllReferenceDataCore(args.MetadataReferences.Select(x => x.Reference), args.MetadataReferences.Length);
}

public List<CompilerAssemblyData> ReadAllCompilerAssemblies()
{
var list = new List<(string CompilerFilePath, AssemblyName AssemblyName)>();
var map = new Dictionary<string, (AssemblyName, string?)>(PathUtil.Comparer);
foreach (var compilerCall in ReadAllCompilerCalls())
{
if (compilerCall.CompilerFilePath is string compilerFilePath &&
!map.ContainsKey(compilerFilePath))
{
AssemblyName name;
string? commitHash;
try
{
name = AssemblyName.GetAssemblyName(compilerFilePath);
commitHash = RoslynUtil.ReadCompilerCommitHash(compilerFilePath);
}
catch
{
name = new AssemblyName(Path.GetFileName(compilerFilePath));
commitHash = null;
}

Check warning on line 324 in src/Basic.CompilerLog.Util/BinaryLogReader.cs

View check run for this annotation

Codecov / codecov/patch

src/Basic.CompilerLog.Util/BinaryLogReader.cs#L320-L324

Added lines #L320 - L324 were not covered by tests

map[compilerCall.CompilerFilePath] = (name, commitHash);
}
}

return map
.OrderBy(x => x.Key, PathUtil.Comparer)
.Select(x => new CompilerAssemblyData(x.Key, x.Value.Item1, x.Value.Item2))
.ToList();
}

/// <summary>
/// Attempt to add all the generated files from generators. When successful the generators
/// don't need to be run when re-hydrating the compilation.
Expand Down
133 changes: 113 additions & 20 deletions src/Basic.CompilerLog.Util/BinaryLogUtil.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Web;
using Microsoft.Build.Framework;
using Microsoft.Build.Logging.StructuredLogger;
using Microsoft.CodeAnalysis;
Expand Down Expand Up @@ -92,10 +93,9 @@ public CompilationTaskData(MSBuildProjectData projectData, int targetId)
}

var kind = Kind ?? CompilerCallKind.Unknown;
var rawArgs = CommandLineParser.SplitCommandLineIntoArguments(CommandLineArguments, removeHashComments: true);
var (compilerFilePath, args) = IsCSharp
? ParseTaskForCompilerAndArguments(rawArgs, "csc.exe", "csc.dll")
: ParseTaskForCompilerAndArguments(rawArgs, "vbc.exe", "vbc.dll");
? ParseTaskForCompilerAndArguments(CommandLineArguments, "csc.exe", "csc.dll")
: ParseTaskForCompilerAndArguments(CommandLineArguments, "vbc.exe", "vbc.dll");

return new CompilerCall(
compilerFilePath,
Expand Down Expand Up @@ -288,37 +288,52 @@ void SetTargetFramework(ref string? targetFramework, IEnumerable? rawProperties)
/// The argument list is going to include either `dotnet exec csc.dll` or `csc.exe`. Need
/// to skip past that to get to the real command line.
/// </summary>
internal static (string? CompilerFilePath, string[] Arguments) ParseTaskForCompilerAndArguments(IEnumerable<string> args, string exeName, string dllName)
internal static (string? CompilerFilePath, string[] Arguments) ParseTaskForCompilerAndArguments(string? args, string exeName, string dllName)
{
using var e = args.GetEnumerator();
if (args is null)
{
return (null, []);
}

var argsStart = 0;
var appFilePath = FindApplication(args.AsSpan(), ref argsStart, out bool isDotNet);
if (appFilePath.IsEmpty)
{
throw InvalidCommandLine();
}

var rawArgs = CommandLineParser.SplitCommandLineIntoArguments(args.Substring(argsStart), removeHashComments: true);
using var e = rawArgs.GetEnumerator();

// The path to the executable is not escaped like the other command line arguments. Need
// to skip until we see an exec or a path with the exe as the file name.
string? compilerFilePath = null;
var found = false;
while (e.MoveNext())
if (isDotNet)
{
if (PathUtil.Comparer.Equals(e.Current, "exec"))
// The path to the executable is not escaped like the other command line arguments. Need
// to skip until we see an exec or a path with the exe as the file name.
while (e.MoveNext())
{
if (e.MoveNext() && PathUtil.Comparer.Equals(Path.GetFileName(e.Current), dllName))
if (PathUtil.Comparer.Equals(e.Current, "exec"))
{
compilerFilePath = e.Current;
found = true;
if (e.MoveNext() && PathUtil.Comparer.Equals(Path.GetFileName(e.Current), dllName))
{
compilerFilePath = e.Current;
}

break;
}
break;
}
else if (e.Current.EndsWith(exeName, PathUtil.Comparison))

if (compilerFilePath is null)
{
compilerFilePath = e.Current;
found = true;
break;
throw InvalidCommandLine();
}
}

if (!found)
else
{
var cmdLine = string.Join(" ", args);
throw new InvalidOperationException($"Could not parse command line arguments: {cmdLine}");
// Direct call to the compiler so we already have the compiler file path in hand
compilerFilePath = appFilePath.Trim('"').ToString();
}

var list = new List<string>();
Expand All @@ -328,6 +343,84 @@ internal static (string? CompilerFilePath, string[] Arguments) ParseTaskForCompi
}

return (compilerFilePath, list.ToArray());

// This search is tricky because there is no attempt by MSBuild to properly quote the
ReadOnlySpan<char> FindApplication(ReadOnlySpan<char> args, ref int index, out bool isDotNet)
{
isDotNet = false;
while (index < args.Length && char.IsWhiteSpace(args[index]))
{
index++;
}

if (index >= args.Length)
{
return Span<char>.Empty;
}

if (args[index] is '"' or '\'')
{
// Quote based parsing, just move to the next quote and return.
var start = index + 1;
var quote = args[index];
do
{
index++;
}
while (index < args.Length && args[index] != quote);

index++; // move past the quote
var span = args.Slice(start, index - start - 1);
isDotNet = CheckDotNet(span);
return span;
}
else
{
// Move forward until we see a signal that we've reached the compiler
// executable.
//
// Note: Specifically don't need to handle the case of the application ending at the
// exact end of the string. There is always at least one argument to the compiler.
while (index < args.Length)
{
if (char.IsWhiteSpace(args[index]))
{
var span = args.Slice(0, index);
if (span.EndsWith(exeName.AsSpan()))
{
isDotNet = false;
return span;
}

if (CheckDotNet(span))
{
isDotNet = true;
return span;
}

if (span.EndsWith(" exec".AsSpan()))
{
// This can happen when the dotnet host is not called dotnet. Need to back
// up to the path before that.
index -= 5;
span = args.Slice(0, index);
isDotNet = true;
return span;
}
}

index++;
}
}

return Span<char>.Empty;

bool CheckDotNet(ReadOnlySpan<char> span) =>
span.EndsWith("dotnet".AsSpan()) ||
span.EndsWith("dotnet.exe".AsSpan());
}

Exception InvalidCommandLine() => new InvalidOperationException($"Could not parse command line arguments: {args}");
}

/// <summary>
Expand Down
16 changes: 16 additions & 0 deletions src/Basic.CompilerLog.Util/CompilerAssemblyData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;

namespace Basic.CompilerLog.Util;

public sealed class CompilerAssemblyData(string filePath, AssemblyName assemblyName, string? commitHash)
{
public string FilePath { get; } = filePath;
public AssemblyName AssemblyName { get; } = assemblyName;
public string? CommitHash { get; } = commitHash;

[ExcludeFromCodeCoverage]
public override string ToString() => $"{FilePath} {CommitHash}";
}

Loading
Loading