|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Build.Evaluation;
using Microsoft.Build.Execution;
using Microsoft.Build.Framework;
using Microsoft.Build.Logging;
using Microsoft.Build.Logging.SimpleErrorLogger;
using Microsoft.CodeAnalysis;
using Microsoft.DotNet.Cli.Commands.Clean.FileBasedAppArtifacts;
using Microsoft.DotNet.Cli.Commands.Restore;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;
using Microsoft.DotNet.FileBasedPrograms;
using Microsoft.DotNet.ProjectTools;
namespace Microsoft.DotNet.Cli.Commands.Run;
/// <summary>
/// Used to build a virtual project file in memory to support <c>dotnet run file.cs</c>.
/// </summary>
internal sealed class VirtualProjectBuildingCommand : CommandBase
{
/// <summary>
/// A file put into the artifacts directory when build starts.
/// It contains full path to the original source file to allow tracking down the input corresponding to the output.
/// It is also used to check whether the previous build has failed (when it is newer than the <see cref="BuildSuccessCacheFileName"/>).
/// </summary>
private const string BuildStartCacheFileName = "build-start.cache";
/// <summary>
/// A file written in the artifacts directory on successful builds used to determine whether a re-build is needed.
/// </summary>
private const string BuildSuccessCacheFileName = "build-success.cache";
internal const string FileBasedProgramCanSkipMSBuild = nameof(FileBasedProgramCanSkipMSBuild);
/// <summary>
/// <c>IsMSBuildFile</c> is <see langword="true"/> if the presence of the implicit build file (even if there are no <see cref="CSharpDirective"/>s)
/// implies that CSC is not enough and MSBuild is needed to build the project, i.e., the file alone can affect MSBuild props or targets.
/// </summary>
/// <remarks>
/// For example, the simple programs our CSC optimized path handles do not need NuGet restore, hence we can ignore NuGet config files.
/// </remarks>
private static readonly ImmutableArray<(string Name, bool IsMSBuildFile)> s_implicitBuildFiles =
[
("global.json", false),
// All these casings are recognized on case-sensitive platforms:
// https://github.com/NuGet/NuGet.Client/blob/ab6b96fd9ba07ed3bf629ee389799ca4fb9a20fb/src/NuGet.Core/NuGet.Configuration/Settings/Settings.cs#L32-L37
("nuget.config", false),
("NuGet.config", false),
("NuGet.Config", false),
("Directory.Build.props", true),
("Directory.Build.targets", true),
("Directory.Packages.props", true),
("Directory.Build.rsp", true),
("MSBuild.rsp", true),
];
/// <summary>
/// For purposes of determining whether CSC is enough to build as opposed to full MSBuild,
/// we can ignore properties that do not affect the build on their own.
/// See also the <c>IsMSBuildFile</c> flag in <see cref="s_implicitBuildFiles"/>.
/// </summary>
/// <remarks>
/// This is an <see cref="IEnumerable{T}"/> rather than <see cref="ImmutableArray{T}"/> to avoid boxing at the use site.
/// </remarks>
private static readonly IEnumerable<string> s_ignorableProperties =
[
// These are set by default by `dotnet run`, so at least these must be ignored otherwise the CSC optimization would not kick in by default.
"NuGetInteractive",
"_BuildNonexistentProjectsByDefault",
"RestoreUseSkipNonexistentTargets",
"ProvideCommandLineArgs",
];
public static string TargetFrameworkVersion => Product.TargetFrameworkVersion;
public static string TargetFramework => $"net{Product.TargetFrameworkVersion}";
public bool NoRestore { get; init; }
/// <summary>
/// If <see langword="true"/>, build markers are not checked and hence MSBuild is always run.
/// This property does not control whether the build markers are written, use <see cref="NoWriteBuildMarkers"/> for that.
/// </summary>
public bool NoCache { get; init; }
public bool NoBuild { get; init; }
/// <summary>
/// Filled during <see cref="Execute"/>.
/// </summary>
public (BuildLevel Level, CacheInfo? Cache) LastBuild { get; private set; }
/// <summary>
/// Filled during <see cref="Execute"/>.
/// </summary>
public RunProperties? LastRunProperties { get; private set; }
/// <summary>
/// If <see langword="true"/>, no build markers are written
/// (like <see cref="BuildStartCacheFileName"/> and <see cref="BuildSuccessCacheFileName"/>).
/// Also skips automatic cleanup.
/// This property does not control whether the markers are checked, use <see cref="NoCache"/> for that.
/// </summary>
public bool NoWriteBuildMarkers { get; init; }
public bool NoConsoleLogger { get; init; }
public VirtualProjectBuilder Builder { get; }
public MSBuildArgs MSBuildArgs { get; }
/// <summary>
/// Keeps strong references to <see cref="VirtualProjectBuilder"/>s created for <c>#:ref</c> directives,
/// preventing their <see cref="ProjectRootElement"/>s from being garbage collected
/// (same reason as <c>VirtualProjectBuilder._projectRootElement</c>).
/// </summary>
private readonly List<VirtualProjectBuilder> _referencedBuilders = [];
public ImmutableArray<CSharpDirective> Directives
{
get
{
if (field.IsDefault)
{
field = FileLevelDirectiveHelpers.FindDirectives(Builder.EntryPointSourceFile, reportAllErrors: false, ThrowingReporter);
Debug.Assert(!field.IsDefault);
}
return field;
}
set
{
field = value;
EvaluatedDirectives = default;
}
}
public ImmutableArray<CSharpDirective> EvaluatedDirectives { get; private set; }
public VirtualProjectBuildingCommand(
string entryPointFileFullPath,
MSBuildArgs msbuildArgs,
string? artifactsPath = null)
{
MSBuildArgs = msbuildArgs.CloneWithAdditionalProperties(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
// See https://github.com/dotnet/msbuild/blob/main/documentation/specs/build-nonexistent-projects-by-default.md.
{ "_BuildNonexistentProjectsByDefault", bool.TrueString },
{ "RestoreUseSkipNonexistentTargets", bool.FalseString },
{ "ProvideCommandLineArgs", bool.TrueString },
}
.AsReadOnly());
Builder = new VirtualProjectBuilder(entryPointFileFullPath, TargetFramework, MSBuildArgs.GetResolvedTargets(), artifactsPath);
}
public override int Execute()
{
bool msbuildGet = MSBuildArgs.GetProperty is [_, ..] || MSBuildArgs.GetItem is [_, ..] || MSBuildArgs.GetTargetResult is [_, ..];
bool evalOnly = msbuildGet && Builder.RequestedTargets is null or [];
bool minimizeStdOut = msbuildGet && MSBuildArgs.GetResultOutputFile is null or [];
var verbosity = MSBuildArgs.Verbosity ?? MSBuildForwardingAppWithoutLogging.DefaultVerbosity;
var consoleLogger = NoConsoleLogger
? null
: minimizeStdOut
? new SimpleErrorLogger()
: CommonRunHelpers.GetConsoleLogger(MSBuildArgs.CloneWithExplicitArgs([$"--verbosity:{verbosity}", .. MSBuildArgs.OtherMSBuildArgs]));
var binaryLogger = GetBinaryLogger(MSBuildArgs.OtherMSBuildArgs);
CacheInfo? cache = null;
if (msbuildGet)
{
LastBuild = (BuildLevel.None, Cache: null);
}
else if (NoBuild)
{
// This is reached only during `restore`, not `run --no-build`
// (in the latter case, this virtual building command is not executed at all).
Debug.Assert(!NoRestore);
LastBuild = (BuildLevel.None, Cache: null);
if (!NoWriteBuildMarkers)
{
CreateTempSubdirectory(Builder.ArtifactsPath);
MarkArtifactsFolderUsed();
}
}
else
{
if (NoCache)
{
cache = ComputeCacheEntry();
cache?.CurrentEntry.BuildLevel = BuildLevel.All;
LastBuild = (BuildLevel.All, cache);
}
else
{
var buildLevel = GetBuildLevel(out cache);
cache?.CurrentEntry.BuildLevel = buildLevel;
LastBuild = (buildLevel, cache);
if (buildLevel is BuildLevel.None)
{
if (binaryLogger is not null)
{
Reporter.Output.WriteLine(CliCommandStrings.NoBinaryLogBecauseUpToDate.Yellow());
}
// No rebuild, can reuse run properties.
cache?.CurrentEntry.Run = cache.PreviousEntry?.Run;
MarkArtifactsFolderUsed();
return 0;
}
if (buildLevel is BuildLevel.Csc)
{
Debug.Assert(cache is not null);
MarkBuildStart();
// Execute CSC.
int result = new CSharpCompilerCommand
{
EntryPointFileFullPath = Builder.EntryPointFileFullPath,
ArtifactsPath = Builder.ArtifactsPath,
CanReuseAuxiliaryFiles = cache.DetermineFinalCanReuseAuxiliaryFiles(),
CscArguments = cache.PreviousEntry?.CscArguments ?? [],
BuildResultFile = cache.PreviousEntry?.BuildResultFile,
}
.Execute(out bool fallbackToNormalBuild);
if (!fallbackToNormalBuild)
{
if (result == 0)
{
ReuseInfoFromPreviousCacheEntry(cache);
MarkBuildSuccess(cache);
}
if (binaryLogger is not null)
{
Reporter.Output.WriteLine(CliCommandStrings.NoBinaryLogBecauseRunningJustCsc.Yellow());
}
return result;
}
Debug.Assert(result != 0);
}
Debug.Assert(buildLevel is BuildLevel.All or BuildLevel.Csc);
}
MarkBuildStart();
}
if (!NoWriteBuildMarkers && !msbuildGet)
{
CleanFileBasedAppArtifactsCommand.StartAutomaticCleanupIfNeeded();
}
Dictionary<string, string?> savedEnvironmentVariables = [];
try
{
// Set environment variables.
foreach (var (key, value) in MSBuildForwardingAppWithoutLogging.GetMSBuildRequiredEnvironmentVariables())
{
savedEnvironmentVariables[key] = Environment.GetEnvironmentVariable(key);
Environment.SetEnvironmentVariable(key, value);
}
// Set up MSBuild.
ReadOnlySpan<ILogger> binaryLoggers = binaryLogger is null ? [] : [binaryLogger.Value];
ReadOnlySpan<ILogger> consoleLoggers = consoleLogger is null ? [] : [consoleLogger];
IEnumerable<ILogger> loggers = [.. binaryLoggers, .. consoleLoggers];
var projectCollection = new ProjectCollection(
MSBuildArgs.GlobalProperties,
loggers,
ToolsetDefinitionLocations.Default);
var parameters = new BuildParameters(projectCollection)
{
Loggers = loggers,
LogTaskInputs = binaryLoggers.Length != 0,
};
BuildManager.DefaultBuildManager.BeginBuild(parameters);
int exitCode = 0;
ProjectInstance? projectInstance = null;
BuildResult? buildOrRestoreResult = null;
// Do a restore first (equivalent to MSBuild's "implicit restore", i.e., `/restore`).
// See https://github.com/dotnet/msbuild/blob/a1c2e7402ef0abe36bf493e395b04dd2cb1b3540/src/MSBuild/XMake.cs#L1838
// and https://github.com/dotnet/msbuild/issues/11519.
if (!NoRestore && !evalOnly)
{
var restoreRequest = new BuildRequestData(
CreateProjectInstance(projectCollection, addGlobalProperties: AddRestoreGlobalProperties(MSBuildArgs.RestoreGlobalProperties)),
targetsToBuild: ["Restore"],
hostServices: null,
BuildRequestDataFlags.ClearCachesAfterBuild | BuildRequestDataFlags.SkipNonexistentTargets | BuildRequestDataFlags.IgnoreMissingEmptyAndInvalidImports | BuildRequestDataFlags.FailOnUnresolvedSdk);
var restoreResult = BuildManager.DefaultBuildManager.BuildRequest(restoreRequest);
if (restoreResult.OverallResult != BuildResultCode.Success)
{
exitCode = 1;
}
projectInstance = restoreRequest.ProjectInstance;
buildOrRestoreResult = restoreResult;
}
// Then do a build.
if (exitCode == 0 && !NoBuild && !evalOnly)
{
var effectiveTargets = Builder.RequestedTargets is { Length: > 0 } requestedTargets
? requestedTargets
: [Constants.Build, Constants.CoreCompile];
var buildRequest = new BuildRequestData(
CreateProjectInstance(projectCollection),
targetsToBuild: effectiveTargets,
hostServices: null,
// SkipNonexistentTargets: CoreCompile doesn't exist in the outer build of multi-target projects.
BuildRequestDataFlags.SkipNonexistentTargets);
var buildResult = BuildManager.DefaultBuildManager.BuildRequest(buildRequest);
if (buildResult.OverallResult != BuildResultCode.Success)
{
exitCode = 1;
}
if (exitCode == 0 && !msbuildGet)
{
Debug.Assert(buildRequest.ProjectInstance != null);
// Cache run info (to avoid re-evaluating the project instance).
LastRunProperties = RunProperties.TryFromProject(buildRequest.ProjectInstance, out var runProperties)
? runProperties
: null;
if (cache is not null && CanSaveCache(buildRequest.ProjectInstance))
{
cache.CurrentEntry.Run = LastRunProperties;
CacheCscArguments(cache, buildResult);
WriteCscRsp(cache);
CollectAdditionalSources(cache, buildRequest.ProjectInstance);
MarkBuildSuccess(cache);
}
}
projectInstance = buildRequest.ProjectInstance;
buildOrRestoreResult = buildResult;
}
// Print build information.
if (msbuildGet)
{
projectInstance ??= CreateProjectInstance(projectCollection);
PrintBuildInformation(projectCollection, projectInstance, buildOrRestoreResult);
}
BuildManager.DefaultBuildManager.EndBuild();
consoleLogger = null; // avoid double disposal which would throw
return exitCode;
}
catch (Exception e)
{
Reporter.Error.WriteLine(CommandLoggingContext.IsVerbose ?
e.ToString().Red().Bold() :
e.Message.Red().Bold());
return 1;
}
finally
{
foreach (var (key, value) in savedEnvironmentVariables)
{
Environment.SetEnvironmentVariable(key, value);
}
if (binaryLogger?.IsValueCreated == true) binaryLogger.Value.ReallyShutdown();
consoleLogger?.Shutdown();
}
static Action<IDictionary<string, string>> AddRestoreGlobalProperties(ReadOnlyDictionary<string, string>? restoreProperties)
{
// Compute the session ID outside the lambda to ensure it's the same for all project instances
// (since there can be multiple project instances created while evaluating file-level directives).
var sessionId = Guid.NewGuid().ToString("D");
return globalProperties =>
{
globalProperties["MSBuildRestoreSessionId"] = sessionId;
globalProperties["MSBuildIsRestoring"] = bool.TrueString;
foreach (var (key, value) in RestoringCommand.RestoreOptimizationProperties)
{
globalProperties[key] = value;
}
if (restoreProperties is null)
{
return;
}
foreach (var (key, value) in restoreProperties)
{
if (value is not null)
{
globalProperties[key] = value;
}
}
};
}
static Lazy<FacadeLogger>? GetBinaryLogger(IReadOnlyList<string>? args)
{
if (args is null) return null;
// Like in MSBuild, only the last binary logger is used.
for (int i = args.Count - 1; i >= 0; i--)
{
var arg = args[i];
if (LoggerUtility.IsBinLogArgument(arg))
{
// We don't want to create the binlog file until actually needed, hence we wrap this in a Lazy.
return new(() =>
{
var logger = new BinaryLogger
{
Parameters = arg.IndexOf(':') is >= 0 and var index
? arg[(index + 1)..]
: "msbuild.binlog",
};
return LoggerUtility.CreateFacadeLogger([logger]);
});
}
}
return null;
}
void CacheCscArguments(CacheInfo cache, BuildResult result)
{
if (result.TryGetResultsForTarget(Constants.CoreCompile, out var coreCompileResult) &&
coreCompileResult.ResultCode == TargetResultCode.Success &&
result.TryGetResultsForTarget(Constants.Build, out var buildResult) &&
buildResult.ResultCode == TargetResultCode.Success &&
buildResult.Items is [{ } buildResultItem])
{
if (coreCompileResult.Items.Length == 0)
{
EnsurePreviousCacheEntry(cache);
cache.CurrentEntry.CscArguments = cache.PreviousEntry?.CscArguments ?? [];
cache.CurrentEntry.BuildResultFile = cache.PreviousEntry?.BuildResultFile;
Reporter.Verbose.WriteLine($"Reusing previous CSC arguments ({cache.CurrentEntry.CscArguments.Length}) because none were found in the {Constants.CoreCompile} target.");
}
else
{
cache.CurrentEntry.CscArguments = coreCompileResult.Items
.Select(static i => i.GetMetadata(Constants.Identity))
.Where(static a => a != "/noconfig") // this option cannot be in the rsp file
.Select(Escape)
.ToImmutableArray();
cache.CurrentEntry.BuildResultFile = buildResultItem.GetMetadata(Constants.FullPath);
Reporter.Verbose.WriteLine($"Found CSC arguments ({cache.CurrentEntry.CscArguments.Length}) and build result path: {cache.CurrentEntry.BuildResultFile}");
}
}
else
{
Reporter.Verbose.WriteLine($"No CSC arguments found in targets: {string.Join(", ", result.ResultsByTarget.Keys)}");
}
// Arguments coming from CoreCompile are escaped if they are in the form of `/option:"some path"`
// but not if they are standalone paths - we need to escape the latter kind ourselves.
static string Escape(string arg)
{
if (!Patterns.EscapedCompilerOption.IsMatch(arg))
{
return CSharpCompilerCommand.EscapePathArgument(arg);
}
return arg;
}
}
void ReuseInfoFromPreviousCacheEntry(CacheInfo cache)
{
Debug.Assert(cache.CurrentEntry.AdditionalSources.Count == 0);
if (cache.PreviousEntry != null)
{
foreach (var file in cache.PreviousEntry.AdditionalSources)
{
cache.CurrentEntry.AdditionalSources.Add(file);
}
}
}
void WriteCscRsp(CacheInfo cache)
{
if (cache.CurrentEntry.CscArguments.IsDefaultOrEmpty)
{
return;
}
string rspPath = CSharpCompilerCommand.WriteCscRspFile(Builder.ArtifactsPath, cache.CurrentEntry.CscArguments);
Reporter.Verbose.WriteLine($"Wrote '{rspPath}'.");
}
bool CanSaveCache(ProjectInstance projectInstance)
{
if (!MSBuildUtilities.ConvertStringToBool(projectInstance.GetPropertyValue(FileBasedProgramCanSkipMSBuild), defaultValue: true))
{
Reporter.Verbose.WriteLine($"Not saving cache because there is an opt-out via MSBuild property {FileBasedProgramCanSkipMSBuild}.");
return false;
}
if (EvaluatedDirectives.Any(static d => d is CSharpDirective.Project))
{
Reporter.Verbose.WriteLine("Not saving cache because there is a project directive.");
return false;
}
if (EvaluatedDirectives.Any(static d => d is CSharpDirective.Ref))
{
Reporter.Verbose.WriteLine("Not saving cache because there is a ref directive.");
return false;
}
if (EvaluatedDirectives.Any(static d =>
d is CSharpDirective.IncludeOrExclude { Kind: CSharpDirective.IncludeOrExcludeKind.Include } includeDirective &&
includeDirective.Name.AsSpan().ContainsAny('*', '?')))
{
Reporter.Verbose.WriteLine("Not saving cache because there is a glob include directive.");
return false;
}
return true;
}
void CollectAdditionalSources(CacheInfo cache, ProjectInstance projectInstance)
{
Debug.Assert(cache.CurrentEntry.AdditionalSources.Count == 0);
var entryPointFileDirectory = Path.GetDirectoryName(Builder.EntryPointFileFullPath);
Debug.Assert(entryPointFileDirectory != null);
var mapping = Builder.GetItemMapping(projectInstance, ErrorReporters.IgnoringReporter);
foreach (var entry in mapping)
{
if (string.Equals(entry.ItemType, "None", StringComparison.OrdinalIgnoreCase))
{
continue;
}
foreach (var item in projectInstance.GetItems(entry.ItemType))
{
var fullPath = Path.GetFullPath(
path: item.GetMetadataValue("FullPath"),
basePath: entryPointFileDirectory);
cache.CurrentEntry.AdditionalSources.Add(fullPath);
}
}
cache.CurrentEntry.AdditionalSources.Remove(Builder.EntryPointFileFullPath);
}
void PrintBuildInformation(ProjectCollection projectCollection, ProjectInstance projectInstance, BuildResult? buildOrRestoreResult)
{
var resultOutputFile = MSBuildArgs.GetResultOutputFile is [{ } file, ..] ? file : null;
// If a single property is requested, don't print as JSON.
if (MSBuildArgs is { GetProperty: [{ } singlePropertyName], GetItem: null or [], GetTargetResult: null or [] })
{
var result = projectInstance.GetPropertyValue(singlePropertyName);
if (resultOutputFile == null)
{
Console.WriteLine(result);
}
else
{
File.WriteAllText(path: resultOutputFile, contents: result + Environment.NewLine);
}
}
else
{
using var stream = resultOutputFile == null
? Console.OpenStandardOutput()
: new FileStream(resultOutputFile, FileMode.Create, FileAccess.Write, FileShare.Read);
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
writer.WriteStartObject();
if (MSBuildArgs.GetProperty is [_, ..])
{
writer.WritePropertyName("Properties");
writer.WriteStartObject();
foreach (var propertyName in MSBuildArgs.GetProperty)
{
writer.WriteString(propertyName, projectInstance.GetPropertyValue(propertyName));
}
writer.WriteEndObject();
}
if (MSBuildArgs.GetItem is [_, ..])
{
writer.WritePropertyName("Items");
writer.WriteStartObject();
foreach (var itemName in MSBuildArgs.GetItem)
{
writer.WritePropertyName(itemName);
writer.WriteStartArray();
foreach (var item in projectInstance.GetItems(itemName))
{
writer.WriteStartObject();
writer.WriteString("Identity", item.GetMetadataValue("Identity"));
foreach (var metadatumName in item.MetadataNames)
{
if (metadatumName.Equals("Identity", StringComparison.OrdinalIgnoreCase))
{
continue;
}
writer.WriteString(metadatumName, item.GetMetadataValue(metadatumName));
}
writer.WriteEndObject();
}
writer.WriteEndArray();
}
writer.WriteEndObject();
}
if (MSBuildArgs.GetTargetResult is [_, ..])
{
Debug.Assert(buildOrRestoreResult != null);
writer.WritePropertyName("TargetResults");
writer.WriteStartObject();
foreach (var targetName in MSBuildArgs.GetTargetResult)
{
var targetResult = buildOrRestoreResult.ResultsByTarget[targetName];
writer.WritePropertyName(targetName);
writer.WriteStartObject();
writer.WriteString("Result", targetResult.TargetResultCodeToString());
writer.WritePropertyName("Items");
writer.WriteStartArray();
foreach (var item in targetResult.Items)
{
writer.WriteStartObject();
writer.WriteString("Identity", item.GetMetadata("Identity"));
foreach (string metadatumName in item.MetadataNames)
{
if (metadatumName.Equals("Identity", StringComparison.OrdinalIgnoreCase))
{
continue;
}
writer.WriteString(metadatumName, item.GetMetadata(metadatumName));
}
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
writer.WriteEndObject();
}
writer.WriteEndObject();
writer.Flush();
stream.Write(Encoding.UTF8.GetBytes(Environment.NewLine));
}
}
}
/// <summary>
/// Common info needed by <see cref="ComputeCacheEntry"/> but also later stages.
/// </summary>
public sealed class CacheInfo
{
public required FileInfo EntryPointFile { get; init; }
/// <summary>
/// If <see cref="PreviousEntry"/> is <see langword="null"/> and this is
/// <see langword="true"/>, it means previous entry was deserialized
/// unsuccessfully (so no need to try again).
/// </summary>
public bool TriedDeserializingPreviousEntry { get; set; }
public RunFileBuildCacheEntry? PreviousEntry { get; set; }
public required RunFileBuildCacheEntry CurrentEntry { get; init; }
/// <summary>
/// The first of <see cref="CurrentEntry"/>'s <see cref="RunFileBuildCacheEntry.ImplicitBuildFiles"/>
/// which is from the set of MSBuild <see cref="s_implicitBuildFiles"/>.
/// </summary>
public string? ExampleMSBuildFile { get; set; }
/// <summary>
/// We cannot reuse auxiliary files like <c>csc.rsp</c> for example when SDK version changes.
/// </summary>
/// <remarks>
/// Only set during <see cref="NeedsToBuild"/> or <see cref="GetBuildLevel"/>.
/// </remarks>
public bool InitialCanReuseAuxiliaryFiles { get; set; } = true;
/// <summary>
/// Set during <see cref="NeedsToBuild"/>.
/// </summary>
public bool CanUseCscViaPreviousArguments { get; set; }
public bool DetermineFinalCanReuseAuxiliaryFiles()
{
if (PreviousEntry?.CscArguments.IsDefaultOrEmpty == false)
{
return false;
}
if (!InitialCanReuseAuxiliaryFiles)
{
Reporter.Verbose.WriteLine("CSC auxiliary files can NOT be reused due to the same reason build is needed.");
return false;
}
if (PreviousEntry?.BuildLevel != BuildLevel.Csc)
{
Reporter.Verbose.WriteLine("CSC auxiliary files can NOT be reused because previous build level was not CSC " +
$"(it was {PreviousEntry?.BuildLevel.ToString() ?? "N/A"}).");
return false;
}
Reporter.Verbose.WriteLine("CSC auxiliary files can be reused.");
return true;
}
}
/// <summary>
/// Compute current cache entry - we need to do this always (except if we already know we will skip saving the cache):
/// <list type="bullet">
/// <item>if we can skip build, we still need to check everything in the cache entry (e.g., implicit build files)</item>
/// <item>if we have to build, we need to have the cache entry to write it to the success cache file</item>
/// </list>
/// </summary>
private CacheInfo? ComputeCacheEntry()
{
if (Directives.Any(static d => d is CSharpDirective.Project or CSharpDirective.Ref))
{
Reporter.Verbose.WriteLine("Skipping computing cache because there are project or ref directives.");
return null;
}
var cacheEntry = new RunFileBuildCacheEntry(MSBuildArgs.GlobalProperties?.ToDictionary(StringComparer.OrdinalIgnoreCase) ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase))
{
Directives = Directives
.Where(static d => d is not CSharpDirective.Shebang)
.Select(static d => d.ToString())
.ToImmutableArray(),
SdkVersion = Product.Version,
RuntimeVersion = CSharpCompilerCommand.RuntimeVersion,
};
var entryPointFile = new FileInfo(Builder.EntryPointFileFullPath);
var entryPointFileDirectory = entryPointFile.Directory;
Debug.Assert(entryPointFileDirectory != null);
// Collect current implicit build files.
CollectImplicitBuildFiles(entryPointFileDirectory, cacheEntry.ImplicitBuildFiles, out var exampleMSBuildFile);
return new CacheInfo
{
EntryPointFile = entryPointFile,
CurrentEntry = cacheEntry,
ExampleMSBuildFile = exampleMSBuildFile,
};
}
// internal for testing
internal static void CollectImplicitBuildFiles(DirectoryInfo startDirectory, HashSet<string> collectedPaths, out string? exampleMSBuildFile)
{
exampleMSBuildFile = null;
for (DirectoryInfo? directory = startDirectory; directory != null; directory = directory.Parent)
{
foreach (var implicitBuildFile in s_implicitBuildFiles)
{
string implicitBuildFilePath = Path.Join(directory.FullName, implicitBuildFile.Name);
if (File.Exists(implicitBuildFilePath))
{
collectedPaths.Add(implicitBuildFilePath);
if (implicitBuildFile.IsMSBuildFile && exampleMSBuildFile is null)
{
exampleMSBuildFile = implicitBuildFilePath;
}
}
}
}
}
private bool NeedsToBuild([NotNullWhen(returnValue: false)] out CacheInfo? cache)
{
cache = ComputeCacheEntry();
if (cache is null)
{
return true;
}
// Check cache files.
string artifactsDirectory = Builder.ArtifactsPath;
var successCacheFile = new FileInfo(Path.Join(artifactsDirectory, BuildSuccessCacheFileName));
if (!successCacheFile.Exists)
{
Reporter.Verbose.WriteLine("Building because cache file does not exist: " + successCacheFile.FullName);
return true;
}
var startCacheFile = new FileInfo(Path.Join(artifactsDirectory, BuildStartCacheFileName));
if (!startCacheFile.Exists)
{
Reporter.Verbose.WriteLine("Building because start cache file does not exist: " + startCacheFile.FullName);
return true;
}
DateTime buildTimeUtc = successCacheFile.LastWriteTimeUtc;
if (startCacheFile.LastWriteTimeUtc > buildTimeUtc)
{
Reporter.Verbose.WriteLine("Building because start cache file is newer than success cache file (previous build likely failed): " + startCacheFile.FullName);
return true;
}
Debug.Assert(!cache.TriedDeserializingPreviousEntry);
var previousCacheEntry = DeserializeCacheEntry(successCacheFile.FullName);
cache.TriedDeserializingPreviousEntry = true;
if (previousCacheEntry is null)
{
cache.InitialCanReuseAuxiliaryFiles = false;
Reporter.Verbose.WriteLine("Building because previous cache entry could not be deserialized: " + successCacheFile.FullName);
return true;
}
cache.PreviousEntry = previousCacheEntry;
var cacheEntry = cache.CurrentEntry;
// Check that versions match.
if (previousCacheEntry.SdkVersion != cacheEntry.SdkVersion)
{
cache.InitialCanReuseAuxiliaryFiles = false;
Reporter.Verbose.WriteLine($"""
Building because previous SDK version ({previousCacheEntry.SdkVersion}) does not match current ({cacheEntry.SdkVersion}): {successCacheFile.FullName}
""");
return true;
}
if (previousCacheEntry.RuntimeVersion != cacheEntry.RuntimeVersion)
{
cache.InitialCanReuseAuxiliaryFiles = false;
Reporter.Verbose.WriteLine($"""
Building because previous runtime version ({previousCacheEntry.RuntimeVersion}) does not match current ({cacheEntry.RuntimeVersion}): {successCacheFile.FullName}
""");
return true;
}
// Check that properties match.
if (previousCacheEntry.GlobalProperties.Count != cacheEntry.GlobalProperties.Count)
{
Reporter.Verbose.WriteLine($"""
Building because previous global properties count ({previousCacheEntry.GlobalProperties.Count}) does not match current count ({cacheEntry.GlobalProperties.Count}): {successCacheFile.FullName}
""");
return true;
}
foreach (var (key, value) in cacheEntry.GlobalProperties)
{
if (!previousCacheEntry.GlobalProperties.TryGetValue(key, out var otherValue) ||
value != otherValue)
{
Reporter.Verbose.WriteLine($"""
Building because previous global property "{key}" ({otherValue}) does not match current ({value}): {successCacheFile.FullName}
""");
return true;
}
}
var entryPointFile = cache.EntryPointFile;
// If the source file does not exist, we want to build so proper errors are reported.
if (!entryPointFile.Exists)
{
Reporter.Verbose.WriteLine("Building because entry point file is missing: " + entryPointFile.FullName);
return true;
}
var reasonToNotReuseCscArguments = GetReasonToNotReuseCscArguments(cache);
var targetFile = ResolveLinkTargetOrSelf(entryPointFile);
// Check that the source file is not modified.
// Only do this here if we cannot reuse CSC arguments (then checking this first is faster); otherwise we need to check implicit build files anyway.
if (reasonToNotReuseCscArguments != null && targetFile.LastWriteTimeUtc > buildTimeUtc)
{
Reporter.Verbose.WriteLine("Compiling because entry point file is modified: " + targetFile.FullName);
Reporter.Verbose.WriteLine(reasonToNotReuseCscArguments);
return true;
}
// Check that implicit build files are not modified.
foreach (var implicitBuildFilePath in previousCacheEntry.ImplicitBuildFiles)
{
var implicitBuildFileInfo = ResolveLinkTargetOrSelf(new FileInfo(implicitBuildFilePath));
if (!implicitBuildFileInfo.Exists || implicitBuildFileInfo.LastWriteTimeUtc > buildTimeUtc)
{
Reporter.Verbose.WriteLine("Building because implicit build file is missing or modified: " + implicitBuildFileInfo.FullName);
return true;
}
}
// Check that no new implicit build files are present.
foreach (var implicitBuildFilePath in cacheEntry.ImplicitBuildFiles)
{
if (!previousCacheEntry.ImplicitBuildFiles.Contains(implicitBuildFilePath))
{
Reporter.Verbose.WriteLine("Building because new implicit build file is present: " + implicitBuildFilePath);
return true;
}
}
// Check that additional sources are not modified.
// NOTE: We currently don't support the CSC-arg-reuse optimization through additional sources (i.e., we don't set `CanUseCscViaPreviousArguments=true` here).
// If that changes, we will also need to make sure `RunFileBuildCacheEntry.Directives` contains directives from other files
// (as that is used to determine whether we can reuse CSC args, see `GetReasonToNotReuseCscArguments`).
foreach (var additionalSourcePath in previousCacheEntry.AdditionalSources)
{
var additionalSourceFileInfo = ResolveLinkTargetOrSelf(new FileInfo(additionalSourcePath));
if (!additionalSourceFileInfo.Exists || additionalSourceFileInfo.LastWriteTimeUtc > buildTimeUtc)
{
Reporter.Verbose.WriteLine("Building because additional source file is missing or modified: " + additionalSourceFileInfo.FullName);
return true;
}
}
// If we might be able to reuse CSC arguments, check whether the source file is modified.
// NOTE: This must be the last check (otherwise setting cache.CanUseCscViaPreviousArguments would be incorrect).
if (reasonToNotReuseCscArguments == null && targetFile.LastWriteTimeUtc > buildTimeUtc)
{
cache.CanUseCscViaPreviousArguments = true;
Reporter.Verbose.WriteLine("Compiling because entry point file is modified: " + targetFile.FullName);
return true;
}
return false;
static FileSystemInfo ResolveLinkTargetOrSelf(FileSystemInfo fileSystemInfo)
{
if (!fileSystemInfo.Exists)
{
return fileSystemInfo;
}
return fileSystemInfo.ResolveLinkTarget(returnFinalTarget: true) ?? fileSystemInfo;
}
static string? GetReasonToNotReuseCscArguments(CacheInfo cache)
{
if (cache.PreviousEntry?.CscArguments.IsDefaultOrEmpty != false)
{
return "No CSC arguments from previous run.";
}
else if (cache.PreviousEntry.Run == null)
{
return "We have CSC arguments but not run properties. That's unexpected.";
}
else if (cache.PreviousEntry.BuildResultFile == null)
{
return "We have CSC arguments but not build result file. That's unexpected.";
}
else if (!cache.PreviousEntry.Directives.SequenceEqual(cache.CurrentEntry.Directives))
{
return "Cannot use CSC arguments from previous run because directives changed.";
}
else
{
return null;
}
}
}
private static RunFileBuildCacheEntry? DeserializeCacheEntry(string path)
{
try
{
using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
return JsonSerializer.Deserialize(stream, RunFileJsonSerializerContext.Default.RunFileBuildCacheEntry);
}
catch (Exception e)
{
Reporter.Verbose.WriteLine($"Failed to deserialize cache entry ({path}): {e.GetType().FullName}: {e.Message}");
return null;
}
}
public RunFileBuildCacheEntry? GetPreviousCacheEntry()
{
return DeserializeCacheEntry(Path.Join(Builder.ArtifactsPath, BuildSuccessCacheFileName));
}
private void EnsurePreviousCacheEntry(CacheInfo cache)
{
if (cache.PreviousEntry is null && !cache.TriedDeserializingPreviousEntry)
{
cache.PreviousEntry = GetPreviousCacheEntry();
cache.TriedDeserializingPreviousEntry = true;
}
}
private BuildLevel GetBuildLevel(out CacheInfo? cache)
{
if (!NeedsToBuild(out cache))
{
Reporter.Verbose.WriteLine("No need to build, the output is up to date. Cache: " + Builder.ArtifactsPath);
return BuildLevel.None;
}
if (cache is null)
{
return BuildLevel.All;
}
if (cache.CanUseCscViaPreviousArguments)
{
Reporter.Verbose.WriteLine("We have CSC arguments from previous run. Skipping MSBuild and using CSC only.");
// Keep the cached info for next time, so we can use CSC again.
Debug.Assert(cache.PreviousEntry != null);
cache.CurrentEntry.CscArguments = cache.PreviousEntry.CscArguments;
cache.CurrentEntry.BuildResultFile = cache.PreviousEntry.BuildResultFile;
cache.CurrentEntry.Run = cache.PreviousEntry.Run;
return BuildLevel.Csc;
}
// Determine whether we can use CSC only or need to use MSBuild.
var cacheEntry = cache.CurrentEntry;
if (!cacheEntry.Directives.IsDefaultOrEmpty)
{
Reporter.Verbose.WriteLine("Using MSBuild because there are directives in the source file.");
return BuildLevel.All;
}
var globalProperties = cacheEntry.GlobalProperties.Keys.Except(s_ignorableProperties, cacheEntry.GlobalProperties.Comparer);
if (globalProperties.FirstOrDefault() is { } exampleKey)
{
var exampleValue = cacheEntry.GlobalProperties[exampleKey];
Reporter.Verbose.WriteLine($"Using MSBuild because there are global properties, for example '{exampleKey}={exampleValue}'.");
return BuildLevel.All;
}
if (cache.ExampleMSBuildFile is { } exampleMSBuildFile)
{
Debug.Assert(cacheEntry.ImplicitBuildFiles.Count != 0);
Reporter.Verbose.WriteLine($"Using MSBuild because there are implicit build files, for example '{exampleMSBuildFile}'.");
return BuildLevel.All;
}
foreach (var filePath in CSharpCompilerCommand.GetPathsOfCscInputsFromNuGetCache())
{
if (!File.Exists(filePath))
{
Reporter.Verbose.WriteLine($"Using MSBuild because NuGet package file does not exist: {filePath}");
return BuildLevel.All;
}
}
Reporter.Verbose.WriteLine("Skipping MSBuild and using CSC only.");
// Don't reuse CSC arguments, this is the "simple" CSC-only build (the one where we use hard-coded CSC arguments).
if (cache.PreviousEntry != null)
{
// If we re-used CSC arguments in previous run and
// want to use hard-coded CSC arguments in this run,
// we cannot reuse the csc.rsp file.
if (!cache.PreviousEntry.CscArguments.IsDefaultOrEmpty)
{
cache.InitialCanReuseAuxiliaryFiles = false;
}
cache.PreviousEntry.CscArguments = [];
cache.PreviousEntry.BuildResultFile = null;
cache.PreviousEntry.Run = null;
}
return BuildLevel.Csc;
}
/// <summary>
/// Touching the artifacts folder ensures it's considered as recently used and not cleaned up by <see cref="CleanFileBasedAppArtifactsCommand"/>.
/// </summary>
public void MarkArtifactsFolderUsed()
{
if (NoWriteBuildMarkers)
{
return;
}
string directory = Builder.ArtifactsPath;
try
{
Directory.SetLastWriteTimeUtc(directory, DateTime.UtcNow);
}
catch (Exception ex)
{
Reporter.Verbose.WriteLine($"Cannot touch folder '{directory}': {ex}");
}
}
private void MarkBuildStart()
{
if (NoWriteBuildMarkers)
{
return;
}
string directory = Builder.ArtifactsPath;
CreateTempSubdirectory(directory);
MarkArtifactsFolderUsed();
File.WriteAllText(Path.Join(directory, BuildStartCacheFileName), Builder.EntryPointFileFullPath);
}
private void MarkBuildSuccess(CacheInfo cache)
{
if (NoWriteBuildMarkers)
{
return;
}
string successCacheFile = Path.Join(Builder.ArtifactsPath, BuildSuccessCacheFileName);
using var stream = File.Open(successCacheFile, FileMode.Create, FileAccess.Write, FileShare.None);
JsonSerializer.Serialize(stream, cache.CurrentEntry, RunFileJsonSerializerContext.Default.RunFileBuildCacheEntry);
}
public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection)
{
return CreateProjectInstance(projectCollection, addGlobalProperties: null);
}
public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection, Action<IDictionary<string, string>>? addGlobalProperties)
{
Builder.CreateProjectInstance(
projectCollection,
ThrowingReporter,
out var project,
projectRootElement: out _,
out var evaluatedDirectives,
Directives,
addGlobalProperties);
EvaluatedDirectives = evaluatedDirectives;
// Create virtual ProjectRootElements for all #:ref directives so MSBuild can resolve them.
CreateReferencedVirtualProjects(projectCollection, evaluatedDirectives);
return project;
}
/// <summary>
/// Recursively creates virtual <see cref="ProjectRootElement"/>s for all <c>#:ref</c> directives
/// in the given <paramref name="directives"/> (and transitively in referenced files).
/// The <see cref="ProjectRootElement"/>s are registered in the <paramref name="projectCollection"/>'s
/// <c>ProjectRootElementCache</c> so MSBuild can resolve <c><ProjectReference></c> items to them.
/// </summary>
private void CreateReferencedVirtualProjects(
ProjectCollection projectCollection,
ImmutableArray<CSharpDirective> directives)
{
var processedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { Builder.EntryPointFileFullPath };
CreateReferencedVirtualProjectsCore(projectCollection, directives, processedFiles, _referencedBuilders);
static void CreateReferencedVirtualProjectsCore(
ProjectCollection projectCollection,
ImmutableArray<CSharpDirective> directives,
HashSet<string> processedFiles,
List<VirtualProjectBuilder> referencedBuilders)
{
foreach (var refDirective in directives.OfType<CSharpDirective.Ref>())
{
// ResolvedPath is always set when using ThrowingReporter (EnsureResolvedPath throws on error).
Debug.Assert(refDirective.ResolvedPath is not null);
if (refDirective.ResolvedPath is not { } resolvedPath)
{
continue;
}
if (!processedFiles.Add(resolvedPath))
{
// Already processed or cycle detected.
continue;
}
var refBuilder = new VirtualProjectBuilder(
resolvedPath,
TargetFramework);
refBuilder.CreateProjectInstance(
projectCollection,
ThrowingReporter,
project: out _,
projectRootElement: out _,
out var refEvaluatedDirectives);
// Keep a strong reference to prevent GC from collecting the ProjectRootElement
// after MSBuild's ProjectRootElementCache demotes it to a weak reference.
referencedBuilders.Add(refBuilder);
// Recursively create virtual projects for any #:ref in the referenced file.
CreateReferencedVirtualProjectsCore(projectCollection, refEvaluatedDirectives, processedFiles, referencedBuilders);
}
}
}
/// <summary>
/// Creates a temporary subdirectory for file-based apps.
/// Use <see cref="GetTempSubpath"/> to obtain the path.
/// </summary>
public static void CreateTempSubdirectory(string path)
{
if (OperatingSystem.IsWindows())
{
Directory.CreateDirectory(path);
}
else
{
// Ensure only the current user has access to the directory to avoid leaking the program to other users.
// We don't mind that permissions might be different if the directory already exists,
// since it's under user's local directory and its path should be unique.
Directory.CreateDirectory(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
}
}
public static readonly ErrorReporter ThrowingReporter =
static (text, path, textSpan, message, innerException) =>
throw new GracefulException(
$"{new SourceFile(path, text).GetLocationString(textSpan)}: {FileBasedProgramsResources.DirectiveError}: {message}",
innerException);
public static SourceFile RemoveDirectivesFromFile(SourceFile sourceFile)
{
var editor = FileBasedAppSourceEditor.Load(sourceFile);
while (editor.Directives is [{ } directive, ..])
{
editor.Remove(directive);
}
return editor.SourceFile;
}
public static void RemoveDirectivesFromFile(SourceFile sourceFile, string targetFilePath)
{
var modifiedFile = RemoveDirectivesFromFile(sourceFile);
(modifiedFile with { Path = targetFilePath }).Save();
}
}
internal sealed class RunFileBuildCacheEntry
{
private static StringComparer GlobalPropertiesComparer => StringComparer.OrdinalIgnoreCase;
/// <summary>
/// We can't know which parts of the path are case insensitive, so we are conservative
/// to avoid false positives in the cache (saying we are up to date even if we are not).
/// </summary>
private static StringComparer FilePathComparer => StringComparer.Ordinal;
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Dictionary<string, string> GlobalProperties { get; }
/// <summary>
/// Full paths.
/// </summary>
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public HashSet<string> ImplicitBuildFiles { get; }
/// <summary>
/// <see cref="CSharpDirective"/>s from the entry point file recognized by the SDK (i.e., except shebang).
/// </summary>
public ImmutableArray<string> Directives { get; set; } = [];
/// <summary>
/// Full paths of non-entry-point files that participate in the build
/// (e.g., default items like <c>.resx</c> and C# source files from <c>#:include</c> directives).
/// </summary>
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public HashSet<string> AdditionalSources { get; }
public BuildLevel BuildLevel { get; set; }
public string? SdkVersion { get; set; } // should be required and init-only but https://github.com/dotnet/runtime/issues/92877
public string? RuntimeVersion { get; set; } // should be required and init-only but https://github.com/dotnet/runtime/issues/92877
public RunProperties? Run { get; set; }
/// <summary>
/// <see cref="CSharpCompilerCommand.CscArguments"/>
/// </summary>
public ImmutableArray<string> CscArguments { get; set; } = [];
/// <summary>
/// <see cref="CSharpCompilerCommand.BuildResultFile"/>
/// </summary>
public string? BuildResultFile { get; set; }
[JsonConstructor]
public RunFileBuildCacheEntry()
{
GlobalProperties = new(GlobalPropertiesComparer);
ImplicitBuildFiles = new(FilePathComparer);
AdditionalSources = new(FilePathComparer);
}
public RunFileBuildCacheEntry(Dictionary<string, string> globalProperties)
{
Debug.Assert(globalProperties.Comparer == GlobalPropertiesComparer);
GlobalProperties = globalProperties;
ImplicitBuildFiles = new(FilePathComparer);
AdditionalSources = new(FilePathComparer);
}
}
[JsonSerializable(typeof(RunFileBuildCacheEntry))]
[JsonSerializable(typeof(RunFileArtifactsMetadata))]
internal partial class RunFileJsonSerializerContext : JsonSerializerContext;
internal enum BuildLevel
{
/// <summary>
/// No build is necessary, build outputs are up to date wrt. inputs.
/// </summary>
None,
/// <summary>
/// Only C# files are modified and there are no SDK-recognized <see cref="CSharpDirective"/>s.
/// We can invoke just the C# compiler to get up to date.
/// </summary>
Csc,
/// <summary>
/// We need to invoke MSBuild to get up to date.
/// </summary>
All,
}
[Flags]
internal enum AppKinds
{
None = 0,
ProjectBased = 1 << 0,
FileBased = 1 << 1,
Any = ProjectBased | FileBased,
}
|