Skip to content

Commit d9cf033

Browse files
authored
Reuse CSC arguments in file-based app runs (#50635)
1 parent 31539e1 commit d9cf033

File tree

6 files changed

+520
-48
lines changed

6 files changed

+520
-48
lines changed

documentation/general/dotnet-run-file.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,8 @@ We always need to re-run MSBuild if implicit build files like `Directory.Build.p
295295
from `.cs` files, the only relevant MSBuild inputs are the `#:` directives,
296296
hence we can first check the `.cs` file timestamps and for those that have changed, compare the sets of `#:` directives.
297297
If only `.cs` files change, it is enough to invoke `csc.exe` (directly or via a build server)
298-
re-using command-line arguments that the last MSBuild invocation passed to the compiler.
298+
re-using command-line arguments that the last MSBuild invocation passed to the compiler
299+
(you can opt out of this via an MSBuild property `FileBasedProgramCanSkipMSBuild=false`).
299300
If no inputs change, it is enough to start the target executable without invoking the build at all.
300301

301302
## Alternatives and future work

src/Cli/Microsoft.DotNet.Cli.Utils/Constants.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ public static class Constants
2626
public static readonly string EnableDefaultEmbeddedResourceItems = nameof(EnableDefaultEmbeddedResourceItems);
2727
public static readonly string EnableDefaultNoneItems = nameof(EnableDefaultNoneItems);
2828

29+
// MSBuild targets
30+
public const string Build = nameof(Build);
31+
public const string ComputeRunArguments = nameof(ComputeRunArguments);
32+
public const string CoreCompile = nameof(CoreCompile);
33+
34+
// MSBuild item metadata
35+
public const string Identity = nameof(Identity);
36+
public const string FullPath = nameof(FullPath);
37+
2938
public static readonly string ProjectArgumentName = "<PROJECT>";
3039
public static readonly string SolutionArgumentName = "<SLN_FILE>";
3140
public static readonly string ToolPackageArgumentName = "<PACKAGE_ID>";

src/Cli/dotnet/Commands/Run/CSharpCompilerCommand.cs

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ internal sealed partial class CSharpCompilerCommand
5050
public required string ArtifactsPath { get; init; }
5151
public required bool CanReuseAuxiliaryFiles { get; init; }
5252

53+
public string BaseDirectory => field ??= Path.GetDirectoryName(EntryPointFileFullPath)!;
54+
55+
/// <summary>
56+
/// Compiler command line arguments to use. If empty, default arguments are used.
57+
/// These should be already properly escaped.
58+
/// </summary>
59+
public required ImmutableArray<string> CscArguments { get; init; }
60+
61+
/// <summary>
62+
/// Path to the <c>bin/Program.dll</c> file. If specified,
63+
/// the compiled output (<c>obj/Program.dll</c>) will be copied to this location.
64+
/// </summary>
65+
public required string? BuildResultFile { get; init; }
66+
5367
/// <param name="fallbackToNormalBuild">
5468
/// Whether the returned error code should not cause the build to fail but instead fallback to full MSBuild.
5569
/// </param>
@@ -64,7 +78,7 @@ public int Execute(out bool fallbackToNormalBuild)
6478
requestId: EntryPointFileFullPath,
6579
language: RequestLanguage.CSharpCompile,
6680
arguments: ["/noconfig", "/nologo", $"@{EscapeSingleArg(rspPath)}"],
67-
workingDirectory: Environment.CurrentDirectory,
81+
workingDirectory: BaseDirectory,
6882
tempDirectory: Path.GetTempPath(),
6983
keepAlive: null,
7084
libDirectory: null,
@@ -87,7 +101,18 @@ public int Execute(out bool fallbackToNormalBuild)
87101
cancellationToken: default);
88102

89103
// Process the response.
90-
return ProcessBuildResponse(responseTask.Result, out fallbackToNormalBuild);
104+
var exitCode = ProcessBuildResponse(responseTask.Result, out fallbackToNormalBuild);
105+
106+
// Copy from obj to bin.
107+
if (BuildResultFile != null &&
108+
CSharpCommandLineParser.Default.Parse(CscArguments, BaseDirectory, sdkDirectory: null) is { OutputFileName: { } outputFileName } parsedArgs)
109+
{
110+
var objFile = parsedArgs.GetOutputFilePath(outputFileName);
111+
Reporter.Verbose.WriteLine($"Copying '{objFile}' to '{BuildResultFile}'.");
112+
File.Copy(objFile, BuildResultFile, overwrite: true);
113+
}
114+
115+
return exitCode;
91116

92117
static string GetCompilerCommitHash()
93118
{
@@ -129,6 +154,14 @@ static int ProcessBuildResponse(BuildResponse response, out bool fallbackToNorma
129154

130155
private void PrepareAuxiliaryFiles(out string rspPath)
131156
{
157+
rspPath = Path.Join(ArtifactsPath, "csc.rsp");
158+
159+
if (!CscArguments.IsDefaultOrEmpty)
160+
{
161+
File.WriteAllLines(rspPath, CscArguments);
162+
return;
163+
}
164+
132165
string fileDirectory = Path.GetDirectoryName(EntryPointFileFullPath) ?? string.Empty;
133166
string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(EntryPointFileFullPath);
134167

@@ -283,7 +316,6 @@ private void PrepareAuxiliaryFiles(out string rspPath)
283316
""");
284317
}
285318

286-
rspPath = Path.Join(ArtifactsPath, "csc.rsp");
287319
if (ShouldEmit(rspPath))
288320
{
289321
IEnumerable<string> args = GetCscArguments(
@@ -315,18 +347,18 @@ private static string EscapeSingleArg(string arg)
315347
{
316348
if (IsPathOption(arg, out var colonIndex))
317349
{
318-
return arg[..(colonIndex + 1)] + EscapeCore(arg[(colonIndex + 1)..]);
350+
return arg[..(colonIndex + 1)] + EscapePathArgument(arg[(colonIndex + 1)..]);
319351
}
320352

321-
return EscapeCore(arg);
353+
return EscapePathArgument(arg);
354+
}
322355

323-
static string EscapeCore(string arg)
356+
internal static string EscapePathArgument(string arg)
357+
{
358+
return ArgumentEscaper.EscapeSingleArg(arg, additionalShouldSurroundWithQuotes: static (string arg) =>
324359
{
325-
return ArgumentEscaper.EscapeSingleArg(arg, additionalShouldSurroundWithQuotes: static (string arg) =>
326-
{
327-
return arg.ContainsAny(s_additionalShouldSurroundWithQuotes);
328-
});
329-
}
360+
return arg.ContainsAny(s_additionalShouldSurroundWithQuotes);
361+
});
330362
}
331363

332364
public static bool IsPathOption(string arg, out int colonIndex)

src/Cli/dotnet/Commands/Run/RunCommand.cs

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -338,8 +338,8 @@ private VirtualProjectBuildingCommand CreateVirtualCommand()
338338
Debug.Assert(EntryPointFileFullPath != null);
339339

340340
var args = MSBuildArgs.RequestedTargets is null or []
341-
? MSBuildArgs.CloneWithAdditionalTargets("Build", ComputeRunArgumentsTarget)
342-
: MSBuildArgs.CloneWithAdditionalTargets(ComputeRunArgumentsTarget);
341+
? MSBuildArgs.CloneWithAdditionalTargets(Constants.Build, Constants.ComputeRunArguments, Constants.CoreCompile)
342+
: MSBuildArgs.CloneWithAdditionalTargets(Constants.ComputeRunArguments, Constants.CoreCompile);
343343

344344
return new(
345345
entryPointFileFullPath: EntryPointFileFullPath,
@@ -378,6 +378,14 @@ private MSBuildArgs SetupSilentBuildArgs(MSBuildArgs msbuildArgs)
378378

379379
internal ICommand GetTargetCommand(Func<ProjectCollection, ProjectInstance>? projectFactory, RunProperties? cachedRunProperties)
380380
{
381+
if (cachedRunProperties != null)
382+
{
383+
// We can skip project evaluation if we already evaluated the project during virtual build
384+
// or we have cached run properties in previous run (and this is a --no-build or skip-msbuild run).
385+
Reporter.Verbose.WriteLine("Getting target command: from cache.");
386+
return CreateCommandFromRunProperties(cachedRunProperties.WithApplicationArguments(ApplicationArgs));
387+
}
388+
381389
if (projectFactory is null && ProjectFileFullPath is null)
382390
{
383391
// If we are running a file-based app and projectFactory is null, it means csc was used instead of full msbuild.
@@ -387,14 +395,6 @@ internal ICommand GetTargetCommand(Func<ProjectCollection, ProjectInstance>? pro
387395
return CreateCommandForCscBuiltProgram(EntryPointFileFullPath);
388396
}
389397

390-
if (cachedRunProperties != null)
391-
{
392-
// We can also skip project evaluation if we already evaluated the project during virtual build
393-
// or we have cached run properties in previous run (and this is a --no-build run).
394-
Reporter.Verbose.WriteLine("Getting target command: from cache.");
395-
return CreateCommandFromRunProperties(cachedRunProperties.WithApplicationArguments(ApplicationArgs));
396-
}
397-
398398
Reporter.Verbose.WriteLine("Getting target command: evaluating project.");
399399
FacadeLogger? logger = LoggerUtility.DetermineBinlogger([.. MSBuildArgs.OtherMSBuildArgs], "dotnet-run");
400400
var project = EvaluateProject(ProjectFileFullPath, projectFactory, MSBuildArgs, logger);
@@ -487,15 +487,13 @@ static void InvokeRunArgumentsTarget(ProjectInstance project, bool noBuild, Faca
487487
loggersForBuild.Add(binaryLogger);
488488
}
489489

490-
if (!project.Build([ComputeRunArgumentsTarget], loggers: loggersForBuild, remoteLoggers: null, out _))
490+
if (!project.Build([Constants.ComputeRunArguments], loggers: loggersForBuild, remoteLoggers: null, out _))
491491
{
492-
throw new GracefulException(CliCommandStrings.RunCommandEvaluationExceptionBuildFailed, ComputeRunArgumentsTarget);
492+
throw new GracefulException(CliCommandStrings.RunCommandEvaluationExceptionBuildFailed, Constants.ComputeRunArguments);
493493
}
494494
}
495495
}
496496

497-
static readonly string ComputeRunArgumentsTarget = "ComputeRunArguments";
498-
499497
internal static void ThrowUnableToRunError(ProjectInstance project)
500498
{
501499
string targetFrameworks = project.GetPropertyValue("TargetFrameworks");

0 commit comments

Comments
 (0)