Skip to content

Commit

Permalink
Fix parsing of compiler path from binlog (#136)
Browse files Browse the repository at this point in the history
closes #135
  • Loading branch information
jaredpar authored Jun 7, 2024
1 parent 0af1a60 commit 04ec6db
Show file tree
Hide file tree
Showing 13 changed files with 335 additions and 37 deletions.
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 @@ public List<CompilerCall> ReadAllCompilerCalls(Func<CompilerCall, bool>? predica
{
predicate ??= static _ => true;

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

Expand Down Expand Up @@ -299,6 +301,38 @@ public List<ReferenceData> ReadAllReferenceData(CompilerCall compilerCall)
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;
}

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

0 comments on commit 04ec6db

Please sign in to comment.