|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Collections.Frozen;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Security;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Xml;
using Microsoft.Build.Construction;
using Microsoft.Build.Definition;
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.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using Microsoft.DotNet.Cli.Commands.Clean.FileBasedAppArtifacts;
using Microsoft.DotNet.Cli.Commands.Restore;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;
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),
];
/// <remarks>
/// Kept in sync with the default <c>dotnet new console</c> project file (enforced by <c>DotnetProjectAddTests.SameAsTemplate</c>).
/// </remarks>
private static readonly FrozenDictionary<string, string> s_defaultProperties = FrozenDictionary.Create<string, string>(StringComparer.OrdinalIgnoreCase,
[
new("OutputType", "Exe"),
new("TargetFramework", $"net{TargetFrameworkVersion}"),
new("ImplicitUsings", "enable"),
new("Nullable", "enable"),
new("PublishAot", "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 VirtualProjectBuildingCommand(
string entryPointFileFullPath,
MSBuildArgs msbuildArgs)
{
Debug.Assert(Path.IsPathFullyQualified(entryPointFileFullPath));
EntryPointFileFullPath = entryPointFileFullPath;
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());
if (MSBuildArgs.RequestedTargets is null or [])
{
RequestedTargets = MSBuildArgs.GetTargetResult;
}
else if (MSBuildArgs.GetTargetResult is null or [])
{
RequestedTargets = MSBuildArgs.RequestedTargets;
}
else
{
RequestedTargets = MSBuildArgs.RequestedTargets
.Union(MSBuildArgs.GetTargetResult, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
public string EntryPointFileFullPath { get; }
public MSBuildArgs MSBuildArgs { get; }
private string[]? RequestedTargets { get; }
public string? CustomArtifactsPath { get; init; }
public string ArtifactsPath => field ??= CustomArtifactsPath ?? GetArtifactsPath(EntryPointFileFullPath);
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>
/// 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 ImmutableArray<CSharpDirective> Directives
{
get
{
if (field.IsDefault)
{
var sourceFile = SourceFile.Load(EntryPointFileFullPath);
field = FindDirectives(sourceFile, reportAllErrors: false, DiagnosticBag.ThrowOnFirst());
Debug.Assert(!field.IsDefault);
}
return field;
}
set;
}
public override int Execute()
{
bool msbuildGet = MSBuildArgs.GetProperty is [_, ..] || MSBuildArgs.GetItem is [_, ..] || MSBuildArgs.GetTargetResult is [_, ..];
bool evalOnly = msbuildGet && RequestedTargets is null or [];
bool minimizeStdOut = msbuildGet && MSBuildArgs.GetResultOutputFile is null or [];
var verbosity = MSBuildArgs.Verbosity ?? MSBuildForwardingAppWithoutLogging.DefaultVerbosity;
var consoleLogger = minimizeStdOut
? new SimpleErrorLogger()
: TerminalLogger.CreateTerminalOrConsoleLogger([$"--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(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)
{
if (binaryLogger is not null)
{
Reporter.Output.WriteLine(CliCommandStrings.NoBinaryLogBecauseRunningJustCsc.Yellow());
}
MarkBuildStart();
// Execute CSC.
int result = new CSharpCompilerCommand
{
EntryPointFileFullPath = EntryPointFileFullPath,
ArtifactsPath = ArtifactsPath,
CanReuseAuxiliaryFiles = cache.DetermineFinalCanReuseAuxiliaryFiles(),
CscArguments = cache.PreviousEntry?.CscArguments ?? [],
BuildResultFile = cache.PreviousEntry?.BuildResultFile,
}
.Execute(out bool fallbackToNormalBuild);
if (!fallbackToNormalBuild)
{
if (result == 0)
{
MarkBuildSuccess(cache);
}
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];
IEnumerable<ILogger> loggers = [.. binaryLoggers, consoleLogger];
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 buildRequest = new BuildRequestData(
CreateProjectInstance(projectCollection),
targetsToBuild: RequestedTargets ?? [Constants.Build, Constants.CoreCompile]);
var buildResult = BuildManager.DefaultBuildManager.BuildRequest(buildRequest);
if (buildResult.OverallResult != BuildResultCode.Success)
{
exitCode = 1;
}
if (exitCode == 0 && !msbuildGet)
{
Debug.Assert(cache != null);
Debug.Assert(buildRequest.ProjectInstance != null);
// Cache run info (to avoid re-evaluating the project instance).
cache.CurrentEntry.Run = RunProperties.FromProject(buildRequest.ProjectInstance);
TryCacheCscArguments(cache, buildResult, 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);
}
binaryLogger?.Value.ReallyShutdown();
consoleLogger?.Shutdown();
}
static Action<IDictionary<string, string>> AddRestoreGlobalProperties(ReadOnlyDictionary<string, string>? restoreProperties)
{
return globalProperties =>
{
globalProperties["MSBuildRestoreSessionId"] = Guid.NewGuid().ToString("D");
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 TryCacheCscArguments(CacheInfo cache, BuildResult result, ProjectInstance projectInstance)
{
if (!MSBuildUtilities.ConvertStringToBool(projectInstance.GetPropertyValue(FileBasedProgramCanSkipMSBuild), defaultValue: true))
{
Reporter.Verbose.WriteLine($"Not saving CSC arguments because there is an opt-out via MSBuild property {FileBasedProgramCanSkipMSBuild}.");
return;
}
// We cannot reuse CSC arguments from previous run and skip MSBuild if there are project references
// because we cannot easily detect whether any referenced projects have changed.
if (Directives.Any(static d => d is CSharpDirective.Project))
{
Reporter.Verbose.WriteLine("Not saving CSC arguments because there is a project directive.");
return;
}
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 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:
/// <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()
{
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(EntryPointFileFullPath);
// Collect current implicit build files.
CollectImplicitBuildFiles(entryPointFile.Directory, 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)
{
Debug.Assert(startDirectory != null);
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(out CacheInfo cache)
{
cache = ComputeCacheEntry();
// Check cache files.
string artifactsDirectory = 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;
}
// Check that the source file is not modified.
if (entryPointFile.LastWriteTimeUtc > buildTimeUtc)
{
cache.CanUseCscViaPreviousArguments = true;
Reporter.Verbose.WriteLine("Compiling because entry point file is modified: " + entryPointFile.FullName);
return true;
}
// Check that implicit build files are not modified.
foreach (var implicitBuildFilePath in previousCacheEntry.ImplicitBuildFiles)
{
var implicitBuildFileInfo = 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;
}
}
return false;
}
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(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.");
return BuildLevel.None;
}
// Determine whether we can invoke CSC using previous arguments.
if (cache.CanUseCscViaPreviousArguments)
{
if (cache.PreviousEntry?.CscArguments.IsDefaultOrEmpty != false)
{
Reporter.Verbose.WriteLine("No CSC arguments from previous run.");
}
else if (cache.PreviousEntry?.Run == null)
{
Reporter.Verbose.WriteLine("We have CSC arguments but not run properties. That's unexpected.");
}
else if (cache.PreviousEntry?.BuildResultFile == null)
{
Reporter.Verbose.WriteLine("We have CSC arguments but not build result file. That's unexpected.");
}
else if (!cache.PreviousEntry.Directives.SequenceEqual(cache.CurrentEntry.Directives))
{
Reporter.Verbose.WriteLine("Cannot use CSC arguments from previous run because directives changed.");
}
else
{
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.
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 = 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 = ArtifactsPath;
CreateTempSubdirectory(directory);
MarkArtifactsFolderUsed();
File.WriteAllText(Path.Join(directory, BuildStartCacheFileName), EntryPointFileFullPath);
}
private void MarkBuildSuccess(CacheInfo cache)
{
if (NoWriteBuildMarkers)
{
return;
}
string successCacheFile = Path.Join(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);
}
private ProjectInstance CreateProjectInstance(
ProjectCollection projectCollection,
Action<IDictionary<string, string>>? addGlobalProperties)
{
var projectRoot = CreateProjectRootElement(projectCollection);
var globalProperties = projectCollection.GlobalProperties;
if (addGlobalProperties is not null)
{
globalProperties = new Dictionary<string, string>(projectCollection.GlobalProperties, StringComparer.OrdinalIgnoreCase);
addGlobalProperties(globalProperties);
}
return ProjectInstance.FromProjectRootElement(projectRoot, new ProjectOptions
{
ProjectCollection = projectCollection,
GlobalProperties = globalProperties,
});
ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
{
var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
var projectFileWriter = new StringWriter();
WriteProjectFile(
projectFileWriter,
Directives,
isVirtualProject: true,
targetFilePath: EntryPointFileFullPath,
artifactsPath: ArtifactsPath,
includeRuntimeConfigInformation: RequestedTargets?.ContainsAny("Publish", "Pack") != true);
var projectFileText = projectFileWriter.ToString();
using var reader = new StringReader(projectFileText);
using var xmlReader = XmlReader.Create(reader);
var projectRoot = ProjectRootElement.Create(xmlReader, projectCollection);
projectRoot.FullPath = projectFileFullPath;
return projectRoot;
}
}
public static string GetArtifactsPath(string entryPointFileFullPath)
{
// Include entry point file name so the directory name is not completely opaque.
string fileName = Path.GetFileNameWithoutExtension(entryPointFileFullPath);
string hash = Sha256Hasher.HashWithNormalizedCasing(entryPointFileFullPath);
string directoryName = $"{fileName}-{hash}";
return GetTempSubpath(directoryName);
}
/// <summary>
/// Obtains a temporary subdirectory for file-based app artifacts, e.g., <c>/tmp/dotnet/runfile/</c>.
/// </summary>
public static string GetTempSubdirectory()
{
// We want a location where permissions are expected to be restricted to the current user.
string directory = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? Path.GetTempPath()
: Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Join(directory, "dotnet", "runfile");
}
/// <summary>
/// Obtains a specific temporary path in a subdirectory for file-based app artifacts, e.g., <c>/tmp/dotnet/runfile/{name}</c>.
/// </summary>
public static string GetTempSubpath(string name)
{
return Path.Join(GetTempSubdirectory(), name);
}
/// <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 void WriteProjectFile(
TextWriter writer,
ImmutableArray<CSharpDirective> directives,
bool isVirtualProject,
string? targetFilePath = null,
string? artifactsPath = null,
bool includeRuntimeConfigInformation = true)
{
int processedDirectives = 0;
var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
var propertyDirectives = directives.OfType<CSharpDirective.Property>();
var packageDirectives = directives.OfType<CSharpDirective.Package>();
var projectDirectives = directives.OfType<CSharpDirective.Project>();
string firstSdkName;
string? firstSdkVersion;
if (sdkDirectives.FirstOrDefault() is { } firstSdk)
{
firstSdkName = firstSdk.Name;
firstSdkVersion = firstSdk.Version;
processedDirectives++;
}
else
{
firstSdkName = "Microsoft.NET.Sdk";
firstSdkVersion = null;
}
if (isVirtualProject)
{
Debug.Assert(!string.IsNullOrWhiteSpace(artifactsPath));
// Note that ArtifactsPath needs to be specified before Sdk.props
// (usually it's recommended to specify it in Directory.Build.props
// but importing Sdk.props manually afterwards also works).
writer.WriteLine($"""
<Project>
<PropertyGroup>
<IncludeProjectNameInArtifactsPaths>false</IncludeProjectNameInArtifactsPaths>
<ArtifactsPath>{EscapeValue(artifactsPath)}</ArtifactsPath>
<PublishDir>artifacts/$(MSBuildProjectName)</PublishDir>
<PackageOutputPath>artifacts/$(MSBuildProjectName)</PackageOutputPath>
<FileBasedProgram>true</FileBasedProgram>
</PropertyGroup>
<ItemGroup>
<Clean Include="{EscapeValue(artifactsPath)}/*" />
</ItemGroup>
""");
if (firstSdkVersion is null)
{
writer.WriteLine($"""
<Import Project="Sdk.props" Sdk="{EscapeValue(firstSdkName)}" />
""");
}
else
{
writer.WriteLine($"""
<Import Project="Sdk.props" Sdk="{EscapeValue(firstSdkName)}" Version="{EscapeValue(firstSdkVersion)}" />
""");
}
}
else
{
string slashDelimited = firstSdkVersion is null
? firstSdkName
: $"{firstSdkName}/{firstSdkVersion}";
writer.WriteLine($"""
<Project Sdk="{EscapeValue(slashDelimited)}">
""");
}
foreach (var sdk in sdkDirectives.Skip(1))
{
if (isVirtualProject)
{
WriteImport(writer, "Sdk.props", sdk);
}
else if (sdk.Version is null)
{
writer.WriteLine($"""
<Sdk Name="{EscapeValue(sdk.Name)}" />
""");
}
else
{
writer.WriteLine($"""
<Sdk Name="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
""");
}
processedDirectives++;
}
if (isVirtualProject || processedDirectives > 1)
{
writer.WriteLine();
}
// Write default and custom properties.
{
writer.WriteLine("""
<PropertyGroup>
""");
// First write the default properties except those specified by the user.
var customPropertyNames = propertyDirectives.Select(d => d.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var (name, value) in s_defaultProperties)
{
if (!customPropertyNames.Contains(name))
{
writer.WriteLine($"""
<{name}>{EscapeValue(value)}</{name}>
""");
}
}
// Write virtual-only properties.
if (isVirtualProject)
{
writer.WriteLine("""
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<DisableDefaultItemsInProjectFolder>true</DisableDefaultItemsInProjectFolder>
<RestoreUseStaticGraphEvaluation>false</RestoreUseStaticGraphEvaluation>
""");
}
// Write custom properties.
foreach (var property in propertyDirectives)
{
writer.WriteLine($"""
<{property.Name}>{EscapeValue(property.Value)}</{property.Name}>
""");
processedDirectives++;
}
// Write virtual-only properties which cannot be overridden.
if (isVirtualProject)
{
writer.WriteLine("""
<Features>$(Features);FileBasedProgram</Features>
""");
}
writer.WriteLine("""
</PropertyGroup>
""");
}
if (packageDirectives.Any())
{
writer.WriteLine("""
<ItemGroup>
""");
foreach (var package in packageDirectives)
{
if (package.Version is null)
{
writer.WriteLine($"""
<PackageReference Include="{EscapeValue(package.Name)}" />
""");
}
else
{
writer.WriteLine($"""
<PackageReference Include="{EscapeValue(package.Name)}" Version="{EscapeValue(package.Version)}" />
""");
}
processedDirectives++;
}
writer.WriteLine("""
</ItemGroup>
""");
}
if (projectDirectives.Any())
{
writer.WriteLine("""
<ItemGroup>
""");
foreach (var projectReference in projectDirectives)
{
writer.WriteLine($"""
<ProjectReference Include="{EscapeValue(projectReference.Name)}" />
""");
processedDirectives++;
}
writer.WriteLine("""
</ItemGroup>
""");
}
Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);
if (isVirtualProject)
{
Debug.Assert(targetFilePath is not null);
writer.WriteLine($"""
<ItemGroup>
<Compile Include="{EscapeValue(targetFilePath)}" />
</ItemGroup>
""");
if (includeRuntimeConfigInformation)
{
var targetDirectory = Path.GetDirectoryName(targetFilePath) ?? "";
writer.WriteLine($"""
<ItemGroup>
<RuntimeHostConfigurationOption Include="EntryPointFilePath" Value="{EscapeValue(targetFilePath)}" />
<RuntimeHostConfigurationOption Include="EntryPointFileDirectoryPath" Value="{EscapeValue(targetDirectory)}" />
</ItemGroup>
""");
}
foreach (var sdk in sdkDirectives)
{
WriteImport(writer, "Sdk.targets", sdk);
}
if (!sdkDirectives.Any())
{
Debug.Assert(firstSdkName == "Microsoft.NET.Sdk" && firstSdkVersion == null);
writer.WriteLine("""
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
""");
}
writer.WriteLine();
}
writer.WriteLine("""
</Project>
""");
static string EscapeValue(string value) => SecurityElement.Escape(value);
static void WriteImport(TextWriter writer, string project, CSharpDirective.Sdk sdk)
{
if (sdk.Version is null)
{
writer.WriteLine($"""
<Import Project="{EscapeValue(project)}" Sdk="{EscapeValue(sdk.Name)}" />
""");
}
else
{
writer.WriteLine($"""
<Import Project="{EscapeValue(project)}" Sdk="{EscapeValue(sdk.Name)}" Version="{EscapeValue(sdk.Version)}" />
""");
}
}
}
#pragma warning disable RSEXPERIMENTAL003 // 'SyntaxTokenParser' is experimental
public static SyntaxTokenParser CreateTokenizer(SourceText text)
{
return SyntaxFactory.CreateTokenParser(text,
CSharpParseOptions.Default.WithFeatures([new("FileBasedProgram", "true")]));
}
#pragma warning restore RSEXPERIMENTAL003 // 'SyntaxTokenParser' is experimental
/// <param name="reportAllErrors">
/// If <see langword="true"/>, the whole <paramref name="sourceFile"/> is parsed to find diagnostics about every app directive.
/// Otherwise, only directives up to the first C# token is checked.
/// The former is useful for <c>dotnet project convert</c> where we want to report all errors because it would be difficult to fix them up after the conversion.
/// The latter is useful for <c>dotnet run file.cs</c> where if there are app directives after the first token,
/// compiler reports <see cref="ErrorCode.ERR_PPIgnoredFollowsToken"/> anyway, so we speed up success scenarios by not parsing the whole file up front in the SDK CLI.
/// </param>
public static ImmutableArray<CSharpDirective> FindDirectives(SourceFile sourceFile, bool reportAllErrors, DiagnosticBag diagnostics)
{
var deduplicated = new HashSet<CSharpDirective.Named>(NamedDirectiveComparer.Instance);
var builder = ImmutableArray.CreateBuilder<CSharpDirective>();
var tokenizer = CreateTokenizer(sourceFile.Text);
var result = tokenizer.ParseLeadingTrivia();
TextSpan previousWhiteSpaceSpan = default;
var triviaList = result.Token.LeadingTrivia;
foreach (var (index, trivia) in triviaList.Index())
{
// Stop when the trivia contains an error (e.g., because it's after #if).
if (trivia.ContainsDiagnostics)
{
break;
}
if (trivia.IsKind(SyntaxKind.WhitespaceTrivia))
{
Debug.Assert(previousWhiteSpaceSpan.IsEmpty);
previousWhiteSpaceSpan = trivia.FullSpan;
continue;
}
if (trivia.IsKind(SyntaxKind.ShebangDirectiveTrivia))
{
TextSpan span = GetFullSpan(previousWhiteSpaceSpan, trivia);
var whiteSpace = GetWhiteSpaceInfo(triviaList, index);
var info = new CSharpDirective.ParseInfo
{
Span = span,
LeadingWhiteSpace = whiteSpace.Leading,
TrailingWhiteSpace = whiteSpace.Trailing,
};
builder.Add(new CSharpDirective.Shebang(info));
}
else if (trivia.IsKind(SyntaxKind.IgnoredDirectiveTrivia))
{
TextSpan span = GetFullSpan(previousWhiteSpaceSpan, trivia);
var message = trivia.GetStructure() is IgnoredDirectiveTriviaSyntax { Content: { RawKind: (int)SyntaxKind.StringLiteralToken } content }
? content.Text.AsSpan().Trim()
: "";
var parts = Patterns.Whitespace.EnumerateSplits(message, 2);
var name = parts.MoveNext() ? message[parts.Current] : default;
var value = parts.MoveNext() ? message[parts.Current] : default;
Debug.Assert(!parts.MoveNext());
var whiteSpace = GetWhiteSpaceInfo(triviaList, index);
var context = new CSharpDirective.ParseContext
{
Info = new()
{
Span = span,
LeadingWhiteSpace = whiteSpace.Leading,
TrailingWhiteSpace = whiteSpace.Trailing,
},
Diagnostics = diagnostics,
SourceFile = sourceFile,
DirectiveKind = name.ToString(),
DirectiveText = value.ToString()
};
if (CSharpDirective.Parse(context) is { } directive)
{
// If the directive is already present, report an error.
if (deduplicated.TryGetValue(directive, out var existingDirective))
{
var typeAndName = $"#:{existingDirective.GetType().Name.ToLowerInvariant()} {existingDirective.Name}";
diagnostics.AddError(sourceFile, directive.Info.Span, string.Format(CliCommandStrings.DuplicateDirective, typeAndName));
}
else
{
deduplicated.Add(directive);
}
builder.Add(directive);
}
}
previousWhiteSpaceSpan = default;
}
// In conversion mode, we want to report errors for any invalid directives in the rest of the file
// so users don't end up with invalid directives in the converted project.
if (reportAllErrors)
{
tokenizer.ResetTo(result);
do
{
result = tokenizer.ParseNextToken();
foreach (var trivia in result.Token.LeadingTrivia)
{
ReportErrorFor(trivia);
}
foreach (var trivia in result.Token.TrailingTrivia)
{
ReportErrorFor(trivia);
}
}
while (!result.Token.IsKind(SyntaxKind.EndOfFileToken));
}
// The result should be ordered by source location, RemoveDirectivesFromFile depends on that.
return builder.ToImmutable();
static TextSpan GetFullSpan(TextSpan previousWhiteSpaceSpan, SyntaxTrivia trivia)
{
// Include the preceding whitespace in the span, i.e., span will be the whole line.
return previousWhiteSpaceSpan.IsEmpty ? trivia.FullSpan : TextSpan.FromBounds(previousWhiteSpaceSpan.Start, trivia.FullSpan.End);
}
void ReportErrorFor(SyntaxTrivia trivia)
{
if (trivia.ContainsDiagnostics && trivia.IsKind(SyntaxKind.IgnoredDirectiveTrivia))
{
diagnostics.AddError(sourceFile, trivia.Span, CliCommandStrings.CannotConvertDirective);
}
}
static (WhiteSpaceInfo Leading, WhiteSpaceInfo Trailing) GetWhiteSpaceInfo(in SyntaxTriviaList triviaList, int index)
{
(WhiteSpaceInfo Leading, WhiteSpaceInfo Trailing) result = default;
for (int i = index - 1; i >= 0; i--)
{
if (!Fill(ref result.Leading, triviaList, i)) break;
}
for (int i = index + 1; i < triviaList.Count; i++)
{
if (!Fill(ref result.Trailing, triviaList, i)) break;
}
return result;
static bool Fill(ref WhiteSpaceInfo info, in SyntaxTriviaList triviaList, int index)
{
var trivia = triviaList[index];
if (trivia.IsKind(SyntaxKind.EndOfLineTrivia))
{
info.LineBreaks += 1;
info.TotalLength += trivia.FullSpan.Length;
return true;
}
if (trivia.IsKind(SyntaxKind.WhitespaceTrivia))
{
info.TotalLength += trivia.FullSpan.Length;
return true;
}
return false;
}
}
}
public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
{
if (directives.Length == 0)
{
return null;
}
Debug.Assert(directives.OrderBy(d => d.Info.Span.Start).SequenceEqual(directives), "Directives should be ordered by source location.");
for (int i = directives.Length - 1; i >= 0; i--)
{
var directive = directives[i];
text = text.Replace(directive.Info.Span, string.Empty);
}
return text;
}
public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
{
if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
{
new SourceFile(filePath, modifiedText).Save();
}
}
public static bool IsValidEntryPointPath(string entryPointFilePath)
{
if (!File.Exists(entryPointFilePath))
{
return false;
}
if (entryPointFilePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Check if the first two characters are #!
try
{
using var stream = File.OpenRead(entryPointFilePath);
int first = stream.ReadByte();
int second = stream.ReadByte();
return first == '#' && second == '!';
}
catch
{
return false;
}
}
}
internal readonly record struct SourceFile(string Path, SourceText Text)
{
public static SourceFile Load(string filePath)
{
using var stream = File.OpenRead(filePath);
return new SourceFile(filePath, SourceText.From(stream, Encoding.UTF8));
}
public SourceFile WithText(SourceText newText)
{
return new SourceFile(Path, newText);
}
public void Save()
{
using var stream = File.Open(Path, FileMode.Create, FileAccess.Write);
using var writer = new StreamWriter(stream, Encoding.UTF8);
Text.Write(writer);
}
public FileLinePositionSpan GetFileLinePositionSpan(TextSpan span)
{
return new FileLinePositionSpan(Path, Text.Lines.GetLinePositionSpan(span));
}
public string GetLocationString(TextSpan span)
{
var positionSpan = GetFileLinePositionSpan(span);
return $"{positionSpan.Path}({positionSpan.StartLinePosition.Line + 1})";
}
}
internal static partial class Patterns
{
[GeneratedRegex("""\s+""")]
public static partial Regex Whitespace { get; }
[GeneratedRegex("""[\s@=/]""")]
public static partial Regex DisallowedNameCharacters { get; }
[GeneratedRegex("""^/\w+:".*"$""", RegexOptions.Singleline)]
public static partial Regex EscapedCompilerOption { get; }
}
internal struct WhiteSpaceInfo
{
public int LineBreaks;
public int TotalLength;
}
/// <summary>
/// Represents a C# directive starting with <c>#:</c> (a.k.a., "file-level directive").
/// Those are ignored by the language but recognized by us.
/// </summary>
internal abstract class CSharpDirective(in CSharpDirective.ParseInfo info)
{
public ParseInfo Info { get; } = info;
public readonly struct ParseInfo
{
/// <summary>
/// Span of the full line including the trailing line break.
/// </summary>
public required TextSpan Span { get; init; }
public required WhiteSpaceInfo LeadingWhiteSpace { get; init; }
public required WhiteSpaceInfo TrailingWhiteSpace { get; init; }
}
public readonly struct ParseContext
{
public required ParseInfo Info { get; init; }
public required DiagnosticBag Diagnostics { get; init; }
public required SourceFile SourceFile { get; init; }
public required string DirectiveKind { get; init; }
public required string DirectiveText { get; init; }
}
public static Named? Parse(in ParseContext context)
{
return context.DirectiveKind switch
{
"sdk" => Sdk.Parse(context),
"property" => Property.Parse(context),
"package" => Package.Parse(context),
"project" => Project.Parse(context),
var other => context.Diagnostics.AddError<Named>(context.SourceFile, context.Info.Span, string.Format(CliCommandStrings.UnrecognizedDirective, other)),
};
}
private static (string, string?)? ParseOptionalTwoParts(in ParseContext context, char separator)
{
var i = context.DirectiveText.IndexOf(separator, StringComparison.Ordinal);
var firstPart = (i < 0 ? context.DirectiveText : context.DirectiveText.AsSpan(..i)).TrimEnd();
string directiveKind = context.DirectiveKind;
if (firstPart.IsWhiteSpace())
{
return context.Diagnostics.AddError<(string, string?)?>(context.SourceFile, context.Info.Span, string.Format(CliCommandStrings.MissingDirectiveName, directiveKind));
}
// If the name contains characters that resemble separators, report an error to avoid any confusion.
if (Patterns.DisallowedNameCharacters.IsMatch(firstPart))
{
return context.Diagnostics.AddError<(string, string?)?>(context.SourceFile, context.Info.Span, string.Format(CliCommandStrings.InvalidDirectiveName, directiveKind, separator));
}
var secondPart = i < 0 ? [] : context.DirectiveText.AsSpan((i + 1)..).TrimStart();
if (i < 0 || secondPart.IsWhiteSpace())
{
return (firstPart.ToString(), null);
}
return (firstPart.ToString(), secondPart.ToString());
}
public abstract override string ToString();
/// <summary>
/// <c>#!</c> directive.
/// </summary>
public sealed class Shebang(in ParseInfo info) : CSharpDirective(info)
{
public override string ToString() => "#!";
}
public abstract class Named(in ParseInfo info) : CSharpDirective(info)
{
public required string Name { get; init; }
}
/// <summary>
/// <c>#:sdk</c> directive.
/// </summary>
public sealed class Sdk(in ParseInfo info) : Named(info)
{
public string? Version { get; init; }
public static new Sdk? Parse(in ParseContext context)
{
if (ParseOptionalTwoParts(context, separator: '@') is not var (sdkName, sdkVersion))
{
return null;
}
return new Sdk(context.Info)
{
Name = sdkName,
Version = sdkVersion,
};
}
public override string ToString() => Version is null ? $"#:sdk {Name}" : $"#:sdk {Name}@{Version}";
}
/// <summary>
/// <c>#:property</c> directive.
/// </summary>
public sealed class Property(in ParseInfo info) : Named(info)
{
public required string Value { get; init; }
public static new Property? Parse(in ParseContext context)
{
if (ParseOptionalTwoParts(context, separator: '=') is not var (propertyName, propertyValue))
{
return null;
}
if (propertyValue is null)
{
return context.Diagnostics.AddError<Property?>(context.SourceFile, context.Info.Span, CliCommandStrings.PropertyDirectiveMissingParts);
}
try
{
propertyName = XmlConvert.VerifyName(propertyName);
}
catch (XmlException ex)
{
return context.Diagnostics.AddError<Property?>(context.SourceFile, context.Info.Span, string.Format(CliCommandStrings.PropertyDirectiveInvalidName, ex.Message), ex);
}
if (propertyName.Equals("RestoreUseStaticGraphEvaluation", StringComparison.OrdinalIgnoreCase) &&
MSBuildUtilities.ConvertStringToBool(propertyValue))
{
context.Diagnostics.AddError(context.SourceFile, context.Info.Span, CliCommandStrings.StaticGraphRestoreNotSupported);
}
return new Property(context.Info)
{
Name = propertyName,
Value = propertyValue,
};
}
public override string ToString() => $"#:property {Name}={Value}";
}
/// <summary>
/// <c>#:package</c> directive.
/// </summary>
public sealed class Package(in ParseInfo info) : Named(info)
{
public string? Version { get; init; }
public static new Package? Parse(in ParseContext context)
{
if (ParseOptionalTwoParts(context, separator: '@') is not var (packageName, packageVersion))
{
return null;
}
return new Package(context.Info)
{
Name = packageName,
Version = packageVersion,
};
}
public override string ToString() => Version is null ? $"#:package {Name}" : $"#:package {Name}@{Version}";
}
/// <summary>
/// <c>#:project</c> directive.
/// </summary>
public sealed class Project(in ParseInfo info) : Named(info)
{
public static new Project? Parse(in ParseContext context)
{
var directiveText = context.DirectiveText;
if (directiveText.IsWhiteSpace())
{
string directiveKind = context.DirectiveKind;
return context.Diagnostics.AddError<Project?>(context.SourceFile, context.Info.Span, string.Format(CliCommandStrings.MissingDirectiveName, directiveKind));
}
try
{
// If the path is a directory like '../lib', transform it to a project file path like '../lib/lib.csproj'.
// Also normalize blackslashes to forward slashes to ensure the directive works on all platforms.
var sourceDirectory = Path.GetDirectoryName(context.SourceFile.Path) ?? ".";
var resolvedProjectPath = Path.Combine(sourceDirectory, directiveText.Replace('\\', '/'));
if (Directory.Exists(resolvedProjectPath))
{
var fullFilePath = MsbuildProject.GetProjectFileFromDirectory(resolvedProjectPath).FullName;
directiveText = Path.GetRelativePath(relativeTo: sourceDirectory, fullFilePath);
}
else if (!File.Exists(resolvedProjectPath))
{
throw new GracefulException(CliStrings.CouldNotFindProjectOrDirectory, resolvedProjectPath);
}
}
catch (GracefulException e)
{
context.Diagnostics.AddError(context.SourceFile, context.Info.Span, string.Format(CliCommandStrings.InvalidProjectDirective, e.Message), e);
}
return new Project(context.Info)
{
Name = directiveText,
};
}
public override string ToString() => $"#:project {Name}";
}
}
/// <summary>
/// Used for deduplication - compares directives by their type and name (ignoring case).
/// </summary>
internal sealed class NamedDirectiveComparer : IEqualityComparer<CSharpDirective.Named>
{
public static readonly NamedDirectiveComparer Instance = new();
private NamedDirectiveComparer() { }
public bool Equals(CSharpDirective.Named? x, CSharpDirective.Named? y)
{
if (ReferenceEquals(x, y)) return true;
if (x is null || y is null) return false;
return x.GetType() == y.GetType() &&
string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
}
public int GetHashCode(CSharpDirective.Named obj)
{
return HashCode.Combine(
obj.GetType().GetHashCode(),
obj.Name.GetHashCode(StringComparison.OrdinalIgnoreCase));
}
}
internal sealed class SimpleDiagnostic
{
public required Position Location { get; init; }
public required string Message { get; init; }
/// <summary>
/// An adapter of <see cref="FileLinePositionSpan"/> that ensures we JSON-serialize only the necessary fields.
/// </summary>
public readonly struct Position
{
public string Path { get; init; }
public LinePositionSpan Span { get; init; }
public static implicit operator Position(FileLinePositionSpan fileLinePositionSpan) => new()
{
Path = fileLinePositionSpan.Path,
Span = fileLinePositionSpan.Span,
};
}
}
internal readonly struct DiagnosticBag
{
public bool IgnoreDiagnostics { get; private init; }
/// <summary>
/// If <see langword="null"/> and <see cref="IgnoreDiagnostics"/> is <see langword="false"/>, the first diagnostic is thrown as <see cref="GracefulException"/>.
/// </summary>
public ImmutableArray<SimpleDiagnostic>.Builder? Builder { get; private init; }
public static DiagnosticBag ThrowOnFirst() => default;
public static DiagnosticBag Collect(out ImmutableArray<SimpleDiagnostic>.Builder builder) => new() { Builder = builder = ImmutableArray.CreateBuilder<SimpleDiagnostic>() };
public static DiagnosticBag Ignore() => new() { IgnoreDiagnostics = true, Builder = null };
public void AddError(SourceFile sourceFile, TextSpan span, string message, Exception? inner = null)
{
if (Builder != null)
{
Debug.Assert(!IgnoreDiagnostics);
Builder.Add(new SimpleDiagnostic { Location = sourceFile.GetFileLinePositionSpan(span), Message = message });
}
else if (!IgnoreDiagnostics)
{
throw new GracefulException($"{sourceFile.GetLocationString(span)}: {CliCommandStrings.DirectiveError}: {message}", inner);
}
}
public T? AddError<T>(SourceFile sourceFile, TextSpan span, string message, Exception? inner = null)
{
AddError(sourceFile, span, message, inner);
return default;
}
}
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 recognized by the SDK (i.e., except shebang).
/// </summary>
public ImmutableArray<string> Directives { get; set; } = [];
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);
}
public RunFileBuildCacheEntry(Dictionary<string, string> globalProperties)
{
Debug.Assert(globalProperties.Comparer == GlobalPropertiesComparer);
GlobalProperties = globalProperties;
ImplicitBuildFiles = 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,
}
|