diff --git a/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs b/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs
index 1f94685c28a8..b32e1f6da392 100644
--- a/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs
+++ b/src/Cli/Microsoft.DotNet.Cli.Utils/MSBuildForwardingAppWithoutLogging.cs
@@ -9,23 +9,52 @@ namespace Microsoft.DotNet.Cli.Utils;
internal sealed class MSBuildForwardingAppWithoutLogging
{
+ ///
+ /// An override flag that determines whether to always execute MSBuild out-of-process. By default the managed dotnet CLI
+ /// prefers to execute MSBuild in-process to prevent needing to spawn another process' central/worker node,
+ /// but this flag can be used to force out-of-process execution.
+ ///
private static readonly bool AlwaysExecuteMSBuildOutOfProc = Env.GetEnvironmentVariableAsBool("DOTNET_CLI_RUN_MSBUILD_OUTOFPROC");
+
+ ///
+ /// An override flag that determines whether to use the MSBuild server - a persistent central node that can serve
+ /// as a place to cache data and prevent re-doing CoreCLR startup/JITting for small builds.
+ /// By default, the MSBuild server is disabled due to stability/correctness concerns with some 1P tasks that keep static state around,
+ /// but it can be used by users that are confident they will not encounter those issues.
+ ///
private static readonly bool UseMSBuildServer = Env.GetEnvironmentVariableAsBool("DOTNET_CLI_USE_MSBUILD_SERVER", false);
+
+ ///
+ /// What the SDK's opinion is on the default terminal logger. The SDK defaults to 'auto' which will use the terminal logger if the output is going to a terminal, otherwise it will use the console logger.
+ /// Some users prefer to always use the legacy console logger, so this gives them a way to consistently do so.
+ ///
private static readonly string? TerminalLoggerDefault = Env.GetEnvironmentVariable("DOTNET_CLI_CONFIGURE_MSBUILD_TERMINAL_LOGGER");
public static string MSBuildVersion
{
get => Build.Evaluation.ProjectCollection.DisplayVersion;
}
+
private const string MSBuildExeName = "MSBuild.dll";
private const string SdksDirectoryName = "Sdks";
+ ///
+ /// The SDK's default MSBuild verbosity level - we choose as a good balance between information and terminal noise.
+ ///
internal const VerbosityOptions DefaultVerbosity = VerbosityOptions.m;
- // Null if we're running MSBuild in-proc.
+ ///
+ /// The forwarding app implementation for executing MSBuild out-of-process.
+ ///
+ ///
+ /// This is null if we're running MSBuild in-process.
+ ///
private ForwardingAppImplementation? _forwardingApp;
+ ///
+ /// A test-only hook for the MSBuildExtensionsPath, which is a key location that MSBuild logic is read from by the MSBuild Common Targets.
+ ///
internal static string? MSBuildExtensionsPathTestHook = null;
///
@@ -33,17 +62,25 @@ public static string MSBuildVersion
///
private MSBuildArgs _msbuildArgs;
- // Path to the MSBuild binary to use.
+ ///
+ /// Path to the MSBuild binary to use - this is set by constructor parameter or looked up via .
+ ///
public string MSBuildPath { get; }
- // True if, given current state of the class, MSBuild would be executed in its own process.
+ ///
+ /// True if, given current state of the class, MSBuild would be executed in its own process.
+ ///
public bool ExecuteMSBuildOutOfProc => _forwardingApp != null;
+ ///
+ /// The set of environment variables that must be set on the MSBuild process (or the current
+ /// process when executing in-proc) for the build to behave correctly.
+ ///
private readonly Dictionary _msbuildRequiredEnvironmentVariables = GetMSBuildRequiredEnvironmentVariables();
private readonly List _msbuildRequiredParameters = ["-maxcpucount", $"--verbosity:{DefaultVerbosity}"];
- public MSBuildForwardingAppWithoutLogging(MSBuildArgs msbuildArgs, string? msbuildPath = null)
+ public MSBuildForwardingAppWithoutLogging(MSBuildArgs msbuildArgs, string? msbuildPath = null, bool forceOutOfProc = false)
{
string defaultMSBuildPath = GetMSBuildExePath();
_msbuildArgs = msbuildArgs;
@@ -63,8 +100,9 @@ public MSBuildForwardingAppWithoutLogging(MSBuildArgs msbuildArgs, string? msbui
EnvironmentVariable("MSBUILDUSESERVER", UseMSBuildServer ? "1" : "0");
- // If DOTNET_CLI_RUN_MSBUILD_OUTOFPROC is set or we're asked to execute a non-default binary, call MSBuild out-of-proc.
- if (AlwaysExecuteMSBuildOutOfProc || !string.Equals(MSBuildPath, defaultMSBuildPath, StringComparison.OrdinalIgnoreCase))
+ // If DOTNET_CLI_RUN_MSBUILD_OUTOFPROC is set, the caller requires it (e.g. the AOT CLI, which
+ // cannot host MSBuild in-process), or we're asked to execute a non-default binary, call MSBuild out-of-proc.
+ if (AlwaysExecuteMSBuildOutOfProc || forceOutOfProc || !string.Equals(MSBuildPath, defaultMSBuildPath, StringComparison.OrdinalIgnoreCase))
{
InitializeForOutOfProcForwarding();
}
@@ -106,6 +144,9 @@ private static string EmitProperty(KeyValuePair property, string
: $"--{label}:{property.Key}={property.Value}";
}
+ ///
+ /// Add an environment variable to the state that will be passed to MSBuild when it is run.
+ ///
public void EnvironmentVariable(string name, string? value)
{
if (_forwardingApp != null)
@@ -129,6 +170,9 @@ public void EnvironmentVariable(string name, string? value)
}
}
+ ///
+ /// Run the MSBuild arguments that have been previously specified.
+ ///
public int Execute()
{
if (_forwardingApp != null)
@@ -141,6 +185,11 @@ public int Execute()
}
}
+ ///
+ /// Directly executes MSBuild's method in the current process.
+ /// Sets up the local environment with required MSBuild environment variables before handing off execution entirely to MSBuild.
+ /// After execution, the original environment variables are restored for any remaining cleanup work the dotnet CLI needs to perform.
+ ///
public int ExecuteInProc(string[] arguments)
{
// Save current environment variables before overwriting them.
@@ -186,6 +235,10 @@ public int ExecuteInProc(string[] arguments)
private static string Escape(string propertyValue) =>
propertyValue.Replace(";", "%3B").Replace("://", ":%2F%2F");
+ ///
+ /// Gets the path to the MSBuild executable. By default, this will be the 'MSBuild.dll' file in the same location as the `dotnet.dll` binary.
+ ///
+ ///
private static string GetMSBuildExePath()
{
return Path.Combine(
@@ -193,6 +246,11 @@ private static string GetMSBuildExePath()
MSBuildExeName);
}
+ ///
+ /// Gets the path to the MSBuild SDKs directory - where the SDKs will be loaded from by the default, local-path-based SDK resolver.
+ /// By default, this will be the 'SDKs' directory in the same location as the `dotnet.dll` binary, but it can be overridden by the `MSBuildSDKsPath` environment variable.
+ ///
+ ///
public static string GetMSBuildSDKsPath()
{
var envMSBuildSDKsPath = Environment.GetEnvironmentVariable("MSBuildSDKsPath");
@@ -207,11 +265,17 @@ public static string GetMSBuildSDKsPath()
SdksDirectoryName);
}
- private static string GetDotnetPath()
- {
- return new Muxer().MuxerPath;
- }
+ private static string GetDotnetPath() => new Muxer().MuxerPath;
+ ///
+ /// Gets the required environment variables for MSBuild.
+ /// The Common Targets require specific environment variables to be set in order to function correctly:
+ ///
+ /// - MSBuildExtensionsPathThe path to the 'MSBuild extensions' - where the Common Targets themselves will be loaded from. Also where SDK Resolvers will be loaded from.
+ /// - MSBuildSDKsPathThe path to the 'MSBuild SDKs' - where the SDKs will be loaded from by the default resolver.
+ /// - DOTNET_HOST_PATHThe path to the .NET SDK host - used to execute .NET applications by targets in the Common Targets that need to run managed .NET binaries that are not shipped with apphosts.
+ ///
+ ///
internal static Dictionary GetMSBuildRequiredEnvironmentVariables()
{
return new()
diff --git a/src/Cli/dn/dn-native-debug.vcxproj b/src/Cli/dn/dn-native-debug.vcxproj
index ea0f3bc53b95..f827a50fd789 100644
--- a/src/Cli/dn/dn-native-debug.vcxproj
+++ b/src/Cli/dn/dn-native-debug.vcxproj
@@ -145,7 +145,6 @@ DOTNET_CLI_ENABLEAOT=true
-
diff --git a/src/Cli/dotnet-aot/AotSourceFiles.props b/src/Cli/dotnet-aot/AotSourceFiles.props
index dc323d44757d..68bb6faf525d 100644
--- a/src/Cli/dotnet-aot/AotSourceFiles.props
+++ b/src/Cli/dotnet-aot/AotSourceFiles.props
@@ -25,8 +25,10 @@
-
+
+
+
@@ -73,4 +75,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Cli/dotnet-aot/DESIGN.md b/src/Cli/dotnet-aot/DESIGN.md
index 9e75132c2944..908cf009e995 100644
--- a/src/Cli/dotnet-aot/DESIGN.md
+++ b/src/Cli/dotnet-aot/DESIGN.md
@@ -94,10 +94,14 @@ A NativeAOT shared library (`NativeLib=Shared`) that exports a single
`[UnmanagedCallersOnly]` entry point: `dotnet_execute`. This layer contains
the dual-path dispatch logic.
-**Fast path** — When `DOTNET_CLI_ENABLEAOT=true`, the AOT bridge compiles a
-minimal `Parser` (guarded by `#if CLI_AOT`) that handles simple commands
-(`--version`, `--info`) entirely in native code. If the parser recognizes the
-command, it executes immediately and returns.
+**Fast path** — When `DOTNET_CLI_ENABLEAOT=true`, the AOT bridge builds the
+**full** command tree (the same `DotNetCommandDefinition` used by the managed
+CLI) so that parsing and `--help` match the managed CLI exactly. Commands that
+can run entirely in AOT (`--version`, `--info`, and the AOT-capable `sln`
+subcommands) execute immediately and return. Every other command is wired with
+a fallback action that throws `CommandNotAvailableInAotException`; the bridge
+catches it (and any unexpected parse-time failure) and transparently falls
+through to the managed CLI.
**Slow path** — When `DOTNET_CLI_ENABLEAOT` is not set or the AOT parser does
not handle the command, the bridge calls `ManagedHost.RunApp()`, which uses the
@@ -151,20 +155,28 @@ the appropriate implementation:
$(DefineConstants);CLI_AOT
-
+
```
In the shared files:
-- **`Parser.cs`** — Under `#if CLI_AOT`, defines a minimal parser with only
- `--version` and `--info`. Under `#else`, defines the full command tree.
+- **`Parser.cs`** — A single shared `Parser` class builds the same full
+ `DotNetCommandDefinition` tree in both modes. Only the action wiring differs,
+ isolated to small inline `#if CLI_AOT` regions: the managed build wires the
+ real command handlers, while the AOT build attaches a managed-fallback handler
+ to every command (overriding it with real implementations where AOT can run
+ the command, e.g. `sln`). The help writer (`DotnetHelpBuilder`) has no
+ conditional compilation: help for the external-tool commands
+ (msbuild/nuget/vstest/format/fsi) renders from AOT because those forwarding
+ apps use AOT-friendly out-of-process codepaths under `#if CLI_AOT`.
- **`Program.cs`** — Under `#if CLI_AOT`, provides a simple `Main` that
delegates to the AOT parser. Under `#else`, provides the full CLI entry point
with telemetry, signal handlers, and workload checks.
-- **`CommandLineInfo.cs`** — Uses `#if CLI_AOT` to substitute lightweight
- implementations for workload info, localized strings, and OS detection that
- would otherwise pull in dependencies incompatible with AOT.
+- **`ParserOptionActions.cs`** — The shared `--help`/`--version`/`--info` option
+ actions. `PrintInfoAction` uses `#if !CLI_AOT` to omit the workload and MSBuild
+ details that aren't AOT-compatible yet; the diagnostics and `--cli-schema`
+ actions are `#if !CLI_AOT` (the AOT build defers those to the managed CLI).
```mermaid
graph LR
diff --git a/src/Cli/dotnet-aot/NativeEntryPoint.cs b/src/Cli/dotnet-aot/NativeEntryPoint.cs
index f7db22defb4f..542ccb57dd09 100644
--- a/src/Cli/dotnet-aot/NativeEntryPoint.cs
+++ b/src/Cli/dotnet-aot/NativeEntryPoint.cs
@@ -5,6 +5,7 @@
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;
using Microsoft.DotNet.Cli.Telemetry;
+using System.CommandLine;
using System.Diagnostics;
using Microsoft.DotNet.NativeWrapper;
@@ -36,6 +37,8 @@ static int Execute(
return ExecuteCore(hostPath, dotnetRoot, sdkDir, hostfxrPath, args);
}
+ public static ITelemetryClient? TelemetryClient { get; private set; }
+
///
/// Core execution logic, separated from native marshalling for testability.
///
@@ -51,10 +54,11 @@ internal static int ExecuteCore(
try
{
DateTime preTelemetry = DateTime.UtcNow;
- // Initialize OTel telemetry (mirrors managed Program.cs setup).
- var telemetryClient = new TelemetryClient(sessionId: null);
+ // Initialize OTel telemetry (mirrors managed Program.cs setup). The client is stored in
+ // the TelemetryClient property so AOT command actions (e.g. --cli-schema) can reuse it.
+ TelemetryClient = new Telemetry.TelemetryClient(sessionId: null);
DateTime postTelemetry = DateTime.UtcNow;
- mainActivity = Activities.Source.StartActivity("native-entrypoint", TelemetryClient.ActivityKind, TelemetryClient.ParentActivityContext);
+ mainActivity = Activities.Source.StartActivity("native-entrypoint", Telemetry.TelemetryClient.ActivityKind, Telemetry.TelemetryClient.ParentActivityContext);
// Backdate the activity start to process start time for accurate timing.
if (mainActivity is not null)
@@ -84,11 +88,36 @@ internal static int ExecuteCore(
// Try the AOT-compiled path for supported commands (if enabled)
if (EnvironmentVariableParser.ParseBool(Environment.GetEnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_ENABLEAOT), defaultValue: false))
{
- var parse = Activities.Source.StartActivity("aot-parsing");
- var parseResult = Parser.Parse(args);
- parse?.Stop();
- mainActivity?.SetDisplayName(parseResult);
- if (parseResult.Errors.Count == 0)
+ ParseResult? parseResult = null;
+ using (var parse = Activities.Source.StartActivity("aot-parsing"))
+ {
+ try
+ {
+ parseResult = Parser.Parse(args);
+ mainActivity?.SetDisplayName(parseResult);
+ }
+ catch (Exception ex)
+ {
+ // The full command tree is shared with the managed CLI, so a command-specific parser or
+ // validator may run during Parse that is only fully supported there. Rather than surface
+ // an AOT failure, treat any unexpected error while parsing as a signal to fall back to the
+ // managed CLI, which will re-parse and handle the command (or report the error). This catch
+ // is intentionally scoped to Parse only — invocation failures must not be masked here, since
+ // doing so could re-execute a command that already ran (and had side effects) in AOT.
+ parse?.SetStatus(ActivityStatusCode.Error);
+ parse?.AddException(ex);
+ parseResult = null;
+ }
+ }
+
+ if (parseResult is not null
+ && parseResult.Errors.Count == 0
+ // An unrecognized top-level token - an external command (`dotnet ef`) or an implicit
+ // file-based app (`dotnet app.cs`) - is resolved by the managed CLI's external command
+ // resolution / run pipeline. The shared parser only sees it as the root's hidden
+ // subcommand argument, so defer to the managed fallback below instead of running the
+ // root command's usage action.
+ && !parseResult.RequiresManagedCommandResolution())
{
using var invoke = Activities.Source.StartActivity("aot-invocation");
try
@@ -97,19 +126,18 @@ internal static int ExecuteCore(
success = true;
return exitCode;
}
- catch (Utils.GracefulException ex)
+ catch (CommandNotAvailableInAotException)
+ {
+ // The parsed command requires the managed CLI — fall through to the managed fallback below.
+ }
+ catch (Exception ex)
{
- Reporter.Error.WriteLine(ex.Message.Red());
invoke?.SetStatus(ActivityStatusCode.Error);
invoke?.AddException(ex);
+ exitCode = Parser.ExceptionHandler(ex, parseResult);
success = false;
- exitCode = 1;
return exitCode;
}
- catch (CommandNotAvailableInAotException)
- {
- // Command requires managed CLI — fall through to managed fallback below.
- }
}
}
@@ -137,7 +165,7 @@ internal static int ExecuteCore(
mainActivity?.AddTag("process.exit.code", exitCode);
mainActivity?.SetStatus(success ? ActivityStatusCode.Ok : ActivityStatusCode.Error);
mainActivity?.Stop();
- TelemetryClient.FlushProviders();
+ Telemetry.TelemetryClient.FlushProviders();
}
}
}
diff --git a/src/Cli/dotnet/CliSchema.cs b/src/Cli/dotnet/CliSchema.cs
index 770f33b65fab..bc2ece066319 100644
--- a/src/Cli/dotnet/CliSchema.cs
+++ b/src/Cli/dotnet/CliSchema.cs
@@ -91,7 +91,7 @@ public static void PrintCliSchema(ParseResult parseResult, TextWriter outputWrit
public static object GetJsonSchema()
{
- var node = s_jsonContext.Options.GetJsonSchemaAsNode(typeof(RootCommandDetails), new JsonSchemaExporterOptions());
+ var node = s_jsonContext.RootCommandDetails.GetJsonSchemaAsNode(new JsonSchemaExporterOptions());
return node.ToJsonString(s_jsonContext.Options);
}
@@ -199,10 +199,15 @@ private static RootCommandDetails CreateRootCommandDetails(Command command)
///
/// Maps some types that don't serialize well to more human-readable strings.
/// For example, is serialized as a string instead of an integer.
+ /// Enums in general are rendered as their name: besides being more readable, this avoids
+ /// requiring the source-generated to carry metadata
+ /// for every enum type that might appear as an option/argument default (which is also required
+ /// for the schema to serialize under NativeAOT).
///
private static object? HumanizeValue(object? v) => v switch
{
VerbosityOptions o => Enum.GetName(o),
+ Enum e => e.ToString(),
null => null,
_ => v // For other types, return as is
};
diff --git a/src/Cli/dotnet/CommandLineInfo.cs b/src/Cli/dotnet/CommandLineInfo.cs
deleted file mode 100644
index b4b99b807711..000000000000
--- a/src/Cli/dotnet/CommandLineInfo.cs
+++ /dev/null
@@ -1,78 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-#nullable disable
-
-#if !CLI_AOT
-using Microsoft.DotNet.Cli.Commands.Workload;
-#endif
-using Microsoft.DotNet.Cli.Utils;
-#if !CLI_AOT
-using LocalizableStrings = Microsoft.DotNet.Cli.Utils.LocalizableStrings;
-using RuntimeEnvironment = Microsoft.DotNet.Cli.Utils.RuntimeEnvironment;
-#endif
-
-namespace Microsoft.DotNet.Cli;
-
-public class CommandLineInfo
-{
- public static void PrintVersion()
- {
- Reporter.Output.WriteLine(Product.Version);
- }
-
- public static void PrintInfo()
- {
- DotnetVersionFile versionFile = DotnetFiles.VersionFileObject;
- var commitSha = versionFile.CommitSha ?? "N/A";
-#if CLI_AOT
- Reporter.Output.WriteLine(".NET SDK:");
-#else
- Reporter.Output.WriteLine($"{LocalizableStrings.DotNetSdkInfoLabel}");
-#endif
- Reporter.Output.WriteLine($" Version: {Product.Version}");
- Reporter.Output.WriteLine($" Commit: {commitSha}");
-#if !CLI_AOT
- Reporter.Output.WriteLine($" Workload version: {WorkloadInfoHelper.GetWorkloadsVersion()}");
- Reporter.Output.WriteLine($" MSBuild version: {MSBuildForwardingAppWithoutLogging.MSBuildVersion}");
-#endif
- Reporter.Output.WriteLine();
-#if CLI_AOT
- Reporter.Output.WriteLine("Runtime Environment:");
- Reporter.Output.WriteLine($" OS Name: {System.Runtime.InteropServices.RuntimeInformation.OSDescription}");
- Reporter.Output.WriteLine($" OS Platform: {(OperatingSystem.IsWindows() ? "Windows" : OperatingSystem.IsMacOS() ? "Darwin" : "Linux")}");
-#else
- Reporter.Output.WriteLine($"{LocalizableStrings.DotNetRuntimeInfoLabel}");
- Reporter.Output.WriteLine($" OS Name: {RuntimeEnvironment.OperatingSystem}");
- Reporter.Output.WriteLine($" OS Version: {RuntimeEnvironment.OperatingSystemVersion}");
- Reporter.Output.WriteLine($" OS Platform: {RuntimeEnvironment.OperatingSystemPlatform}");
-#endif
- Reporter.Output.WriteLine($" RID: {RuntimeInformation.RuntimeIdentifier}");
- Reporter.Output.WriteLine($" Base Path: {AppContext.BaseDirectory}");
-#if !CLI_AOT
- PrintWorkloadsInfo();
-#endif
- }
-
-#if !CLI_AOT
- private static void PrintWorkloadsInfo()
- {
- Reporter.Output.WriteLine();
- Reporter.Output.WriteLine($"{LocalizableStrings.DotnetWorkloadInfoLabel}");
- new WorkloadInfoHelper(isInteractive: false).ShowWorkloadsInfo(showVersion: false);
- }
-
- private static string GetDisplayRid(DotnetVersionFile versionFile)
- {
- FrameworkDependencyFile fxDepsFile = new();
-
- string currentRid = RuntimeInformation.RuntimeIdentifier;
-
- // if the current RID isn't supported by the shared framework, display the RID the CLI was
- // built with instead, so the user knows which RID they should put in their "runtimes" section.
- return fxDepsFile.IsRuntimeSupported(currentRid) ?
- currentRid :
- versionFile.BuildRid;
- }
-#endif
-}
diff --git a/src/Cli/dotnet/Commands/MSBuild/MSBuildForwardingApp.cs b/src/Cli/dotnet/Commands/MSBuild/MSBuildForwardingApp.cs
index 7f8364ddf11c..78f8bbf4581c 100644
--- a/src/Cli/dotnet/Commands/MSBuild/MSBuildForwardingApp.cs
+++ b/src/Cli/dotnet/Commands/MSBuild/MSBuildForwardingApp.cs
@@ -2,7 +2,10 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
+
+#if !CLI_AOT
using System.Reflection;
+#endif
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Telemetry;
using Microsoft.DotNet.Cli.Utils;
@@ -10,6 +13,18 @@
namespace Microsoft.DotNet.Cli.Commands.MSBuild;
+///
+/// Invokes MSBuild consistently across different environments - either in-process or out-of-process.
+/// It also ensures that the SDK modifications to default MSBuild behaviors are applied - for example
+///
+/// - Consuming MSBuild-engine- and SDK-build-logic-emitted telemetry via the central and per-worker-node
+/// - LLM environment adjustments
+///
+///
+///
+/// In AOT mode all MSBuild invocations happen via out-of-process execution, so this should be used with caution - most AOT commands at time of writing
+/// do not use MSBuild, and this is mostly intended to make `--help` output for MSBuild-based commands not require jumping into the managed process space.
+///
public class MSBuildForwardingApp : CommandBase
{
private readonly MSBuildForwardingAppWithoutLogging _forwardingAppWithoutLogging;
@@ -23,12 +38,24 @@ private static MSBuildArgs ConcatTelemetryLogger(MSBuildArgs msbuildArgs)
{
try
{
+#if !CLI_AOT
Type loggerType = typeof(MSBuildLogger);
Type forwardingLoggerType = typeof(MSBuildForwardingLogger);
-
+ string loggerTypeFullName = loggerType.FullName!; // not-null because these are part of the same assembly
+ string forwardingLoggerTypeFullName = forwardingLoggerType.FullName!; // not-null because these are part of the same assembly
+ // The logger assembly locations come from the dotnet assembly we are currently executing in.
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file
- msbuildArgs.OtherMSBuildArgs.Add($"-distributedlogger:{loggerType.FullName},{loggerType.GetTypeInfo().Assembly.Location}*{forwardingLoggerType.FullName},{forwardingLoggerType.GetTypeInfo().Assembly.Location}");
-#pragma warning restore IL3000
+ string loggerTypeLocation = loggerType.GetTypeInfo().Assembly.Location;
+ string forwardingLoggerTypeLocation = forwardingLoggerType.GetTypeInfo().Assembly.Location;
+#pragma warning restore IL3000 // Avoid accessing Assembly file path when publishing as a single file
+#else
+ string loggerTypeFullName = "Microsoft.DotNet.Cli.Commands.MSBuild.MSBuildLogger";
+ string forwardingLoggerTypeFullName = "Microsoft.DotNet.Cli.Commands.MSBuild.MSBuildForwardingLogger";
+ string loggerTypeLocation = Path.Combine(AppContext.BaseDirectory, "dotnet.dll");
+ string forwardingLoggerTypeLocation = loggerTypeLocation;
+#endif
+
+ msbuildArgs.OtherMSBuildArgs.Add($"-distributedlogger:{loggerTypeFullName},{loggerTypeLocation}*{forwardingLoggerTypeFullName},{forwardingLoggerTypeLocation}");
return msbuildArgs;
}
catch (Exception)
@@ -40,20 +67,37 @@ private static MSBuildArgs ConcatTelemetryLogger(MSBuildArgs msbuildArgs)
}
///
- /// Mostly intended for quick/one-shot usage - most 'core' SDK commands should do more hands-on parsing.
+ /// Initializes a new instance of the class with a set of raw MSBuild arguments.
///
+ ///
+ /// Mostly intended for quick/one-shot usage - most 'core' SDK commands should do more hands-on parsing.
+ ///
public MSBuildForwardingApp(IEnumerable rawMSBuildArgs, string? msbuildPath = null) : this(
MSBuildArgs.AnalyzeMSBuildArguments(rawMSBuildArgs.ToArray(), CommonOptions.CreatePropertyOption(), CommonOptions.CreateRestorePropertyOption(), CommonOptions.CreateMSBuildTargetOption(), CommonOptions.CreateVerbosityOption(), CommonOptions.CreateNoLogoOption()),
msbuildPath)
{
}
+ ///
+ /// Initializes a new instance of the class with a parsed set of MSBuild arguments.
+ /// These arguments are usually unique per SDK command that needs to invoke MSBuild, because each command may have its own options that
+ /// 'forward' as different MSBuild arguments.
+ ///
+ /// MSBuild arguments to forward to the builder process, parsed by using to apply a set of per-command s to a list of unparsed command line input tokens.
+ /// The path to the MSBuild executable. If null, the default MSBuild executable will be used.
public MSBuildForwardingApp(MSBuildArgs msBuildArgs, string? msbuildPath = null)
{
var modifiedMSBuildArgs = CommonRunHelpers.AdjustMSBuildForLLMs(ConcatTelemetryLogger(msBuildArgs));
+#if CLI_AOT
+ const bool forceOutOfProc = true;
+#else
+ const bool forceOutOfProc = false;
+#endif
_forwardingAppWithoutLogging = new MSBuildForwardingAppWithoutLogging(
modifiedMSBuildArgs,
- msbuildPath: msbuildPath);
+ msbuildPath: msbuildPath,
+ forceOutOfProc: forceOutOfProc);
+ InitializeRequiredEnvironmentVariables();
}
public IEnumerable MSBuildArguments { get { return _forwardingAppWithoutLogging.GetAllArguments(); } }
@@ -63,12 +107,7 @@ public void EnvironmentVariable(string name, string? value)
_forwardingAppWithoutLogging.EnvironmentVariable(name, value);
}
- public ProcessStartInfo GetProcessStartInfo()
- {
- InitializeRequiredEnvironmentVariables();
-
- return _forwardingAppWithoutLogging.GetProcessStartInfo();
- }
+ public ProcessStartInfo GetProcessStartInfo() => _forwardingAppWithoutLogging.GetProcessStartInfo();
private void InitializeRequiredEnvironmentVariables()
{
@@ -87,20 +126,6 @@ public override int Execute()
// Ignore Ctrl-C for the remainder of the command's execution
// Forwarding commands will just spawn the child process and exit
Console.CancelKeyPress += (sender, e) => { e.Cancel = true; };
-
- int exitCode;
- if (_forwardingAppWithoutLogging.ExecuteMSBuildOutOfProc)
- {
- ProcessStartInfo startInfo = GetProcessStartInfo();
- exitCode = startInfo.Execute();
- }
- else
- {
- InitializeRequiredEnvironmentVariables();
- string[] arguments = _forwardingAppWithoutLogging.GetAllArguments();
- exitCode = _forwardingAppWithoutLogging.ExecuteInProc(arguments);
- }
-
- return exitCode;
+ return _forwardingAppWithoutLogging.Execute();
}
}
diff --git a/src/Cli/dotnet/Commands/NuGet/NuGetCommand.cs b/src/Cli/dotnet/Commands/NuGet/NuGetCommand.cs
index 296e57ede7db..747a00f1d1c4 100644
--- a/src/Cli/dotnet/Commands/NuGet/NuGetCommand.cs
+++ b/src/Cli/dotnet/Commands/NuGet/NuGetCommand.cs
@@ -14,15 +14,24 @@ internal class NuGetCommand
{
public static int Run(string[] args, bool isFileBasedApp = false)
{
+#if CLI_AOT
+ // The in-process NuGet runner relies on NuGet.CommandLine.XPlat, which isn't AOT-compatible,
+ // so AOT always forwards to the out-of-process NuGet CLI.
+ return Run(args, new NuGetCommandRunner());
+#else
return Run(args, isFileBasedApp
? new InProcessNuGetCommandRunner(NuGetVirtualProjectBuilder.Instance)
: new NuGetCommandRunner());
+#endif
}
public static int Run(ParseResult parseResult)
{
ICommandRunner runner;
+#if CLI_AOT
+ runner = new NuGetCommandRunner();
+#else
if (parseResult.CommandResult.Command.Name == "why"
&& parseResult.CommandResult.Command.Arguments.FirstOrDefault() is Argument pathArg
&& parseResult.GetValue(pathArg) is { } path
@@ -34,6 +43,7 @@ public static int Run(ParseResult parseResult)
{
runner = new NuGetCommandRunner();
}
+#endif
return Run(parseResult.GetArguments(), runner);
}
@@ -65,6 +75,7 @@ public int Run(string[] args)
}
}
+#if !CLI_AOT
private class InProcessNuGetCommandRunner(NuGetVirtualProjectBuilder virtualProjectBuilder) : ICommandRunner
{
public int Run(string[] args)
@@ -81,6 +92,7 @@ public int Run(string[] args)
}
}
}
+#endif
private static string GetDotnetPath()
{
diff --git a/src/Cli/dotnet/Commands/Run/CommonRunHelpers.cs b/src/Cli/dotnet/Commands/Run/CommonRunHelpers.cs
index 59ad5ce7dbba..b85a3297c9c1 100644
--- a/src/Cli/dotnet/Commands/Run/CommonRunHelpers.cs
+++ b/src/Cli/dotnet/Commands/Run/CommonRunHelpers.cs
@@ -37,11 +37,17 @@ public static MSBuildArgs AdjustMSBuildForLLMs(MSBuildArgs msbuildArgs)
}
}
+#if !CLI_AOT
///
/// Creates a TerminalLogger or ConsoleLogger based on the provided MSBuild arguments.
/// If the environment is detected to be an LLM environment, the logger is adjusted to
/// better suit that environment.
///
+ ///
+ /// This uses the in-process MSBuild logging APIs (Microsoft.Build.*) and so is excluded
+ /// from the AOT build, which only ever forwards MSBuild out-of-process.
+ ///
public static Microsoft.Build.Framework.ILogger GetConsoleLogger(MSBuildArgs args) =>
Microsoft.Build.Logging.TerminalLogger.CreateTerminalOrConsoleLogger([.. AdjustMSBuildForLLMs(args).OtherMSBuildArgs]);
+#endif
}
diff --git a/src/Cli/dotnet/Extensions/ParseResultExtensions.cs b/src/Cli/dotnet/Extensions/ParseResultExtensions.cs
index c60ece1080a4..743c30134dcb 100644
--- a/src/Cli/dotnet/Extensions/ParseResultExtensions.cs
+++ b/src/Cli/dotnet/Extensions/ParseResultExtensions.cs
@@ -8,14 +8,58 @@
using Microsoft.DotNet.Cli.CommandLine;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;
+using Microsoft.DotNet.ProjectTools;
using CommandResult = System.CommandLine.Parsing.CommandResult;
namespace Microsoft.DotNet.Cli.Extensions;
public static class ParseResultExtensions
{
+ public static string RootSubCommandResult(this ParseResult parseResult) => parseResult.RootCommandResult.Children?
+ .Select(child => parseResult.GetSymbolResultValue(child))
+ .FirstOrDefault(subcommand => !string.IsNullOrEmpty(subcommand)) ?? string.Empty;
+
+ public static bool IsTopLevelDotnetCommand(this ParseResult parseResult) =>
+ parseResult.CommandResult.Command.Equals(Parser.RootCommand) && string.IsNullOrEmpty(parseResult.RootSubCommandResult());
+
+ ///
+ /// Returns true when the parse result is an unrecognized top-level token that did not match a
+ /// built-in command and so landed on the root's hidden subcommand argument - e.g. an external
+ /// command (dotnet ef) or an implicit file-based app (dotnet app.cs).
+ ///
+ ///
+ /// The managed CLI resolves these via external command resolution or its file-based run pipeline
+ /// (see Program.ExecuteExternalCommand/TryRunFileBasedApp). The NativeAOT entry
+ /// point cannot do either, so it uses this to defer such invocations to the managed CLI rather
+ /// than running the root command's usage action.
+ ///
+ public static bool RequiresManagedCommandResolution(this ParseResult parseResult) =>
+ parseResult.CommandResult.Command.Equals(Parser.RootCommand)
+ && !string.IsNullOrEmpty(parseResult.GetValue(Parser.RootCommand.DotnetSubCommand));
+
+ ///
+ /// Detects whether this parse result looks like an implicit file-based app invocation
+ /// (e.g. dotnet app.cs ...), where the only unmatched token is a first argument that
+ /// resolves to a valid C# entry-point path. Returns the matching token, or .
+ ///
+ ///
+ /// This detection is shared between the managed CLI - which re-dispatches these invocations as
+ /// dotnet run --file app.cs (see Program.TryRunFileBasedApp) - and the NativeAOT
+ /// entry point, which cannot run file-based apps itself and so defers them to the managed CLI.
+ ///
+ public static Token? GetFileBasedAppEntryPointToken(this ParseResult parseResult) =>
+ parseResult.GetResult(Parser.RootCommand.DotnetSubCommand) is { Tokens: [{ Type: TokenType.Argument, Value: { } } unmatchedCommandOrFile] }
+ && VirtualProjectBuilder.IsValidEntryPointPath(unmatchedCommandOrFile.Value)
+ ? unmatchedCommandOrFile
+ : null;
+
+ private static string? GetSymbolResultValue(this ParseResult parseResult, SymbolResult symbolResult) => symbolResult switch
+ {
+ CommandResult commandResult => commandResult.Command.Name,
+ ArgumentResult argResult => argResult.Tokens.FirstOrDefault()?.Value,
+ _ => parseResult.GetResult(Parser.RootCommand.DotnetSubCommand)?.GetValueOrDefault()
+ };
-#if !CLI_AOT
///
/// Finds the command of the parse result and invokes help for that command.
/// If no command is specified, invokes help for the application.
@@ -39,6 +83,31 @@ public static void ShowHelp(this ParseResult parseResult)
Parser.Parse([.. filteredTokenValues, "-h"]).Invoke();
}
+ public static string[] GetArguments(this ParseResult parseResult) =>
+ parseResult.Tokens.Select(t => t.Value).ToArray().GetSubArguments();
+
+ public static string[] GetSubArguments(this string[] args)
+ {
+ var subargs = args.ToList();
+
+ // Don't remove any arguments that are being passed to the app in dotnet run
+ var dashDashIndex = subargs.IndexOf("--");
+
+ var runArgs = dashDashIndex > -1 ? subargs.GetRange(dashDashIndex, subargs.Count() - dashDashIndex) : [];
+ subargs = dashDashIndex > -1 ? subargs.GetRange(0, dashDashIndex) : subargs;
+
+ // Remove top level command (ex build or publish).
+ var subargsFiltered = subargs
+ .SkipWhile(arg => Parser.RootCommand.DiagOption.Name.Equals(arg)
+ || Parser.RootCommand.DiagOption.Aliases.Contains(arg)
+ || arg.Equals("dotnet"))
+ .Skip(1);
+
+ return [.. subargsFiltered, .. runArgs];
+ }
+
+#if !CLI_AOT
+
public static void ShowHelpOrErrorIfAppropriate(this ParseResult parseResult)
{
if (parseResult.Errors.Any())
@@ -87,17 +156,10 @@ static bool ErrorContainsAllParts(ReadOnlySpan error, string[] parts)
}
}
- public static string RootSubCommandResult(this ParseResult parseResult) => parseResult.RootCommandResult.Children?
- .Select(child => parseResult.GetSymbolResultValue(child))
- .FirstOrDefault(subcommand => !string.IsNullOrEmpty(subcommand)) ?? string.Empty;
-
public static bool IsDotnetBuiltInCommand(this ParseResult parseResult) =>
string.IsNullOrEmpty(parseResult.RootSubCommandResult())
|| Parser.GetBuiltInCommand(parseResult.RootSubCommandResult()) != null;
- public static bool IsTopLevelDotnetCommand(this ParseResult parseResult) =>
- parseResult.CommandResult.Command.Equals(Parser.RootCommand) && string.IsNullOrEmpty(parseResult.RootSubCommandResult());
-
public static bool CanBeInvoked(this ParseResult parseResult) =>
Parser.GetBuiltInCommand(parseResult.RootSubCommandResult()) != null
|| parseResult.Tokens.Any(token => token.Type == TokenType.Directive)
@@ -110,36 +172,6 @@ public static int HandleMissingCommand(this ParseResult parseResult)
return 1;
}
- public static string[] GetArguments(this ParseResult parseResult) =>
- parseResult.Tokens.Select(t => t.Value).ToArray().GetSubArguments();
-
- public static string[] GetSubArguments(this string[] args)
- {
- var subargs = args.ToList();
-
- // Don't remove any arguments that are being passed to the app in dotnet run
- var dashDashIndex = subargs.IndexOf("--");
-
- var runArgs = dashDashIndex > -1 ? subargs.GetRange(dashDashIndex, subargs.Count() - dashDashIndex) : [];
- subargs = dashDashIndex > -1 ? subargs.GetRange(0, dashDashIndex) : subargs;
-
- // Remove top level command (ex build or publish).
- var subargsFiltered = subargs
- .SkipWhile(arg => Parser.RootCommand.DiagOption.Name.Equals(arg)
- || Parser.RootCommand.DiagOption.Aliases.Contains(arg)
- || arg.Equals("dotnet"))
- .Skip(1);
-
- return [.. subargsFiltered, .. runArgs];
- }
-
- private static string? GetSymbolResultValue(this ParseResult parseResult, SymbolResult symbolResult) => symbolResult switch
- {
- CommandResult commandResult => commandResult.Command.Name,
- ArgumentResult argResult => argResult.Tokens.FirstOrDefault()?.Value,
- _ => parseResult.GetResult(Parser.RootCommand.DotnetSubCommand)?.GetValueOrDefault()
- };
-
public static IEnumerable? GetRunCommandShorthandProjectValues(this ParseResult parseResult) =>
parseResult.GetRunPropertyOptions(true)?.Where(property => !property.Contains("="));
diff --git a/src/Cli/dotnet/NuGetSignatureVerificationEnabler.cs b/src/Cli/dotnet/NuGetSignatureVerificationEnabler.cs
index dcf54e505a92..8df5aee7c453 100644
--- a/src/Cli/dotnet/NuGetSignatureVerificationEnabler.cs
+++ b/src/Cli/dotnet/NuGetSignatureVerificationEnabler.cs
@@ -1,7 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+#if !CLI_AOT
using Microsoft.DotNet.Cli.Commands.MSBuild;
+#endif
using Microsoft.DotNet.Cli.Utils;
namespace Microsoft.DotNet.Cli;
@@ -26,6 +28,7 @@ public static void ConditionallyEnable(ForwardingApp forwardingApp, IEnvironment
forwardingApp.WithEnvironmentVariable(DotNetNuGetSignatureVerification, value);
}
+#if !CLI_AOT
public static void ConditionallyEnable(MSBuildForwardingApp forwardingApp, IEnvironmentProvider? environmentProvider = null)
{
ArgumentNullException.ThrowIfNull(forwardingApp, nameof(forwardingApp));
@@ -39,6 +42,7 @@ public static void ConditionallyEnable(MSBuildForwardingApp forwardingApp, IEnvi
forwardingApp.EnvironmentVariable(DotNetNuGetSignatureVerification, value);
}
+#endif
private static string GetSignatureVerificationEnablementValue(IEnvironmentProvider? environmentProvider)
{
diff --git a/src/Cli/dotnet/Parser.cs b/src/Cli/dotnet/Parser.cs
index 9bfa564564eb..3174169add90 100644
--- a/src/Cli/dotnet/Parser.cs
+++ b/src/Cli/dotnet/Parser.cs
@@ -1,131 +1,41 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-#if CLI_AOT
using System.CommandLine;
+using System.CommandLine.Help;
+using System.Reflection;
+using Microsoft.DotNet.Cli.Commands;
+using Microsoft.DotNet.Cli.Commands.Format;
+using Microsoft.DotNet.Cli.Commands.Fsi;
+using Microsoft.DotNet.Cli.Commands.Hidden.Add;
+using Microsoft.DotNet.Cli.Commands.Hidden.Add.Package;
+using Microsoft.DotNet.Cli.Commands.Hidden.List;
+using Microsoft.DotNet.Cli.Commands.Hidden.List.Reference;
+using Microsoft.DotNet.Cli.Commands.MSBuild;
+using Microsoft.DotNet.Cli.Commands.NuGet;
using Microsoft.DotNet.Cli.Commands.Solution;
+using Microsoft.DotNet.Cli.Commands.Test;
+using Microsoft.DotNet.Cli.Commands.VSTest;
+using Microsoft.DotNet.Cli.Commands.Workload.Search;
+using Microsoft.DotNet.Cli.Extensions;
+using Microsoft.DotNet.Cli.Help;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;
+using Microsoft.TemplateEngine.Cli;
using Command = System.CommandLine.Command;
-namespace Microsoft.DotNet.Cli;
-
-public static class Parser
-{
- internal static RootCommand RootCommand { get; } = CreateCommand();
-
- private static RootCommand CreateCommand()
- {
- var versionOption = new Option("--version") { Arity = ArgumentArity.Zero };
- var infoOption = new Option("--info") { Arity = ArgumentArity.Zero };
-
- var rootCommand = new RootCommand("dotnet")
- {
- versionOption,
- infoOption,
- };
-
- rootCommand.SetAction(parseResult =>
- {
- if (parseResult.GetValue(versionOption))
- {
- CommandLineInfo.PrintVersion();
- return 0;
- }
- if (parseResult.GetValue(infoOption))
- {
- CommandLineInfo.PrintInfo();
- return 0;
- }
- parseResult.InvocationConfiguration.Output.WriteLine("Usage: dn [options]");
- return 0;
- });
-
- ConfigureSolutionCommand(rootCommand);
-
- return rootCommand;
- }
-
- private static void ConfigureSolutionCommand(RootCommand rootCommand)
- {
- var slnDef = new SolutionCommandDefinition();
- SolutionCommandParser.ConfigureCommand(slnDef);
- rootCommand.Subcommands.Add(slnDef);
- }
-
- public static Command? GetBuiltInCommand(string commandName) =>
- RootCommand.Subcommands.FirstOrDefault(c => c.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase));
-
- public static ParseResult Parse(string[] args) => RootCommand.Parse(args, ParserConfiguration);
-
- public static int Invoke(ParseResult parseResult) => parseResult.Invoke(InvocationConfiguration);
-
- ///
- /// Implements token-per-line response file handling for the CLI. We use this instead of the built-in S.CL handling
- /// to ensure backwards-compatibility with MSBuild.
- ///
- public static bool TokenPerLine(string tokenToReplace, out IReadOnlyList? replacementTokens, out string? errorMessage)
- {
- var filePath = Path.GetFullPath(tokenToReplace);
- if (File.Exists(filePath))
- {
- var lines = File.ReadAllLines(filePath);
- var trimmedLines =
- lines
- // Remove content in the lines that start with # after trimmer leading whitespace
- .Select(line => line.TrimStart().StartsWith('#') ? string.Empty : line)
- // trim leading/trailing whitespace to not pass along dead spaces
- .Select(x => x.Trim())
- // Remove empty lines
- .Where(line => line.Length > 0);
- replacementTokens = [.. trimmedLines];
- errorMessage = null;
- return true;
- }
- else
- {
- replacementTokens = null;
- errorMessage = string.Format(CliStrings.ResponseFileNotFound, tokenToReplace);
- return false;
- }
- }
-
- public static ParserConfiguration ParserConfiguration { get; } = new()
- {
- EnablePosixBundling = false,
- ResponseFileTokenReplacer = TokenPerLine
- };
-
- public static InvocationConfiguration InvocationConfiguration { get; } = new()
- {
- EnableDefaultExceptionHandler = false,
- };
-}
-
-#else
-using System.CommandLine;
-using System.CommandLine.Help;
+#if !CLI_AOT
using System.CommandLine.StaticCompletions;
-using System.Reflection;
-using Microsoft.DotNet.Cli.Commands;
using Microsoft.DotNet.Cli.Commands.Build;
using Microsoft.DotNet.Cli.Commands.BuildServer;
using Microsoft.DotNet.Cli.Commands.Clean;
using Microsoft.DotNet.Cli.Commands.Dnx;
-using Microsoft.DotNet.Cli.Commands.Format;
-using Microsoft.DotNet.Cli.Commands.Fsi;
using Microsoft.DotNet.Cli.Commands.Help;
-using Microsoft.DotNet.Cli.Commands.Hidden.Add;
-using Microsoft.DotNet.Cli.Commands.Hidden.Add.Package;
using Microsoft.DotNet.Cli.Commands.Hidden.Complete;
using Microsoft.DotNet.Cli.Commands.Hidden.InternalReportInstallSuccess;
-using Microsoft.DotNet.Cli.Commands.Hidden.List;
-using Microsoft.DotNet.Cli.Commands.Hidden.List.Reference;
using Microsoft.DotNet.Cli.Commands.Hidden.Parse;
using Microsoft.DotNet.Cli.Commands.Hidden.Remove;
-using Microsoft.DotNet.Cli.Commands.MSBuild;
using Microsoft.DotNet.Cli.Commands.New;
-using Microsoft.DotNet.Cli.Commands.NuGet;
using Microsoft.DotNet.Cli.Commands.Pack;
using Microsoft.DotNet.Cli.Commands.Package;
using Microsoft.DotNet.Cli.Commands.Project;
@@ -135,28 +45,67 @@ public static bool TokenPerLine(string tokenToReplace, out IReadOnlyList
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Commands.Run.Api;
using Microsoft.DotNet.Cli.Commands.Sdk;
-using Microsoft.DotNet.Cli.Commands.Solution;
-using Microsoft.DotNet.Cli.Commands.Test;
using Microsoft.DotNet.Cli.Commands.Tool;
using Microsoft.DotNet.Cli.Commands.Tool.Store;
-using Microsoft.DotNet.Cli.Commands.VSTest;
using Microsoft.DotNet.Cli.Commands.Workload;
-using Microsoft.DotNet.Cli.Commands.Workload.Search;
-using Microsoft.DotNet.Cli.Extensions;
-using Microsoft.DotNet.Cli.Help;
-using Microsoft.DotNet.Cli.Utils;
-using Microsoft.DotNet.Cli.Utils.Extensions;
-using Microsoft.TemplateEngine.Cli;
-using Command = System.CommandLine.Command;
+#endif
namespace Microsoft.DotNet.Cli;
public static class Parser
{
+ ///
+ /// The root command for the .NET CLI.
+ ///
+ ///
+ /// If you use this Command directly, you _must_ use
+ /// and to ensure that the command line parser
+ /// and invoker are configured correctly.
+ ///
+ internal static DotNetCommandDefinition RootCommand { get; } = CreateCommand();
+
private static DotNetCommandDefinition CreateCommand()
{
+ // The full command surface is described by DotNetCommandDefinition, which lives in the
+ // AOT-compatible Microsoft.DotNet.Cli.Definitions project. Both the managed CLI and the
+ // AOT bridge build this same tree so that parsing and help match exactly; only the action
+ // wiring differs between the two (see ConfigureManagedActions / ConfigureAotActions below).
var rootCommand = new DotNetCommandDefinition();
+ NormalizeRootOptions(rootCommand);
+
+#if CLI_AOT
+ ConfigureAotActions(rootCommand);
+#else
+ ConfigureManagedActions(rootCommand);
+#endif
+
+ rootCommand.SetAction(parseResult =>
+ {
+ if (parseResult.GetValue(rootCommand.DiagOption) && parseResult.Tokens.Count == 1)
+ {
+ // When user does not specify any args except of diagnostics ("dotnet -d"),
+ // we do nothing as HandleDiagnosticAction already enabled the diagnostic output.
+ return 0;
+ }
+ else
+ {
+ // When user does not specify any args (just "dotnet"), a usage needs to be printed.
+ parseResult.InvocationConfiguration.Output.WriteLine(CliUsage.HelpText);
+ return 0;
+ }
+ });
+
+ return rootCommand;
+ }
+
+ ///
+ /// Applies tweaks to the options that inherits from
+ /// : the SDK defines its own --version option, so the built-in
+ /// one is removed, and the help option is re-pointed at .
+ ///
+ private static void NormalizeRootOptions(DotNetCommandDefinition rootCommand)
+ {
for (int i = rootCommand.Options.Count - 1; i >= 0; i--)
{
Option option = rootCommand.Options[i];
@@ -168,10 +117,14 @@ private static DotNetCommandDefinition CreateCommand()
else if (option is HelpOption helpOption)
{
helpOption.Action = new PrintHelpAction(helpOption, DotnetHelpBuilder.Instance.Value);
- option.Description = CliStrings.ShowHelpDescription;
+ helpOption.Description = CliStrings.ShowHelpDescription;
}
}
+ }
+#if !CLI_AOT
+ private static void ConfigureManagedActions(DotNetCommandDefinition rootCommand)
+ {
// Augment the definition of each subcommand with command-specific actions and completions.
AddCommandParser.ConfigureCommand(rootCommand.AddCommand);
BuildCommandParser.ConfigureCommand(rootCommand.BuildCommand);
@@ -225,25 +178,58 @@ private static DotNetCommandDefinition CreateCommand()
// https://github.com/NuGet/NuGet.Client/blob/bf048eb714eb6b1912ba868edca4c7cfec454841/src/NuGet.Core/NuGet.CommandLine.XPlat/NuGetCommands.cs
// Add `package` subcommands to the definition instead.
NuGet.CommandLine.XPlat.NuGetCommands.Add(rootCommand, CommonOptions.CreateInteractiveOption(acceptArgument: true), NuGetVirtualProjectBuilder.Instance);
+ }
+#else
+ private static void ConfigureAotActions(DotNetCommandDefinition rootCommand)
+ {
+ // By default, every command in the AOT build reports that it must run in the managed CLI.
+ // NativeEntryPoint catches CommandNotAvailableInAotException and falls back to hosting dotnet.dll.
+ foreach (Command subcommand in rootCommand.Subcommands)
+ {
+ SetAotFallbackRecursively(subcommand);
+ }
- rootCommand.SetAction(parseResult =>
+ // Commands that can run entirely in AOT mode wire their real implementations on top of the
+ // fallback defaults above. SolutionCommandParser is AOT-aware: it keeps real implementations
+ // for `sln list`/`migrate`/`remove` and falls back for `sln` and `sln add`.
+ SolutionCommandParser.ConfigureCommand(rootCommand.SolutionCommand);
+
+ rootCommand.VersionOption.Action = new PrintVersionAction(rootCommand.VersionOption);
+ rootCommand.InfoOption.Action = new PrintInfoAction(rootCommand.InfoOption);
+ rootCommand.CliSchemaOption.Action = new PrintCliSchemaAction(rootCommand.CliSchemaOption);
+
+ // --list-sdks / --list-runtimes are resolved by the host before the SDK is invoked. If they
+ // still reach the AOT bridge, defer to the managed CLI rather than printing root usage.
+ rootCommand.ListSdksOption.Action = new AotFallbackOptionAction(rootCommand.ListSdksOption);
+ rootCommand.ListRuntimesOption.Action = new AotFallbackOptionAction(rootCommand.ListRuntimesOption);
+ }
+
+ private static void SetAotFallbackRecursively(Command command)
+ {
+ command.SetAction(InvokeAotFallback);
+ foreach (Command subcommand in command.Subcommands)
{
- if (parseResult.GetValue(rootCommand.DiagOption) && parseResult.Tokens.Count == 1)
- {
- // When user does not specify any args except of diagnostics ("dotnet -d"),
- // we do nothing as HandleDiagnosticAction already enabled the diagnostic output.
- return 0;
- }
- else
+ SetAotFallbackRecursively(subcommand);
+ }
+ }
+
+ private static int InvokeAotFallback(ParseResult parseResult) => throw new CommandNotAvailableInAotException();
+
+ private sealed class AotFallbackOptionAction(Option option) : InvocableOptionAction(option)
+ {
+ public override bool Terminating => true;
+
+ public override int Invoke(ParseResult parseResult)
+ {
+ if (parseResult.GetValue((Option)Option))
{
- // When user does not specify any args (just "dotnet"), a usage needs to be printed.
- parseResult.InvocationConfiguration.Output.WriteLine(CliUsage.HelpText);
- return 0;
+ throw new CommandNotAvailableInAotException();
}
- });
- return rootCommand;
+ return 0;
+ }
}
+#endif
public static Command? GetBuiltInCommand(string commandName) =>
RootCommand.Subcommands.FirstOrDefault(c => c.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase));
@@ -289,16 +275,6 @@ public static bool TokenPerLine(string tokenToReplace, out IReadOnlyList
EnableDefaultExceptionHandler = false,
};
- ///
- /// The root command for the .NET CLI.
- ///
- ///
- /// If you use this Command directly, you _must_ use
- /// and to ensure that the command line parser
- /// and invoker are configured correctly.
- ///
- internal static DotNetCommandDefinition RootCommand { get; } = CreateCommand();
-
///
/// You probably want to use instead of this method.
/// This has to internally split the string into an array of arguments
@@ -312,6 +288,11 @@ public static bool TokenPerLine(string tokenToReplace, out IReadOnlyList
public static int Invoke(string[] args) => Invoke(Parse(args));
public static Task InvokeAsync(string[] args, CancellationToken cancellationToken = default) => InvokeAsync(Parse(args), cancellationToken);
+ ///
+ /// Renders an exception thrown while invoking a command. Shared by the managed CLI
+ /// (Program.ExecuteInternalCommand) and the AOT bridge (NativeEntryPoint) so both
+ /// report non-fallback exceptions identically.
+ ///
internal static int ExceptionHandler(Exception? exception, ParseResult parseResult)
{
if (exception is TargetInvocationException)
@@ -395,7 +376,6 @@ public static void additionalOption(HelpContext context)
public override void Write(HelpContext context)
{
var command = context.Command;
- var helpArgs = new string[] { "--help" };
// custom help overrides
if (command.Equals(RootCommand))
@@ -422,16 +402,16 @@ public override void Write(HelpContext context)
}
else if (command is VSTestCommandDefinition)
{
- new VSTestForwardingApp(helpArgs).Execute();
+ new VSTestForwardingApp(["--help"]).Execute();
}
else if (command is FormatCommandDefinition format)
{
var arguments = context.ParseResult.GetValue(format.Arguments) ?? [];
- new FormatForwardingApp([.. arguments, .. helpArgs]).Execute();
+ new FormatForwardingApp([.. arguments, "--help"]).Execute();
}
else if (command is FsiCommandDefinition)
{
- new FsiForwardingApp(helpArgs).Execute();
+ new FsiForwardingApp(["--help"]).Execute();
}
else if (command is ICustomHelp helpCommand)
{
@@ -494,4 +474,3 @@ private static bool IsInNuGetCommandTree(Command command)
}
}
}
-#endif
diff --git a/src/Cli/dotnet/ParserOptionActions.cs b/src/Cli/dotnet/ParserOptionActions.cs
index 772ec3fdd8af..0616059bb228 100644
--- a/src/Cli/dotnet/ParserOptionActions.cs
+++ b/src/Cli/dotnet/ParserOptionActions.cs
@@ -3,12 +3,17 @@
using System.CommandLine;
using System.CommandLine.Invocation;
+using System.Runtime.InteropServices;
+#if !CLI_AOT
using Microsoft.DotNet.Cli.CommandLine;
using Microsoft.DotNet.Cli.Commands.Workload;
+#endif
using Microsoft.DotNet.Cli.Extensions;
using Microsoft.DotNet.Cli.Help;
using Microsoft.DotNet.Cli.Utils;
+#if !CLI_AOT
using Microsoft.DotNet.Configurer;
+#endif
using RuntimeEnvironment = Microsoft.DotNet.Cli.Utils.RuntimeEnvironment;
namespace Microsoft.DotNet.Cli;
@@ -25,6 +30,7 @@ internal abstract class InvocableOptionAction(Option option) : SynchronousComman
public Option Option { get; } = option;
}
+#if !CLI_AOT
internal class HandleDiagnosticAction(Option option) : InvocableOptionAction(option)
{
public override bool Terminating => false;
@@ -84,6 +90,7 @@ private bool OptionPrecedesSubcommand(IEnumerable tokens, string subComm
return false;
}
}
+#endif
internal class PrintHelpAction(Option option, HelpBuilder builder) : InvocableOptionAction(option)
{
@@ -109,7 +116,9 @@ internal class PrintVersionAction(Option option) : InvocableOptionAction(o
public override int Invoke(ParseResult parseResult)
{
- if (!parseResult.HasOption(Option) || !parseResult.GetValue(option)
+ // GetResult(Option) is { Implicit: false } is the AOT-friendly equivalent of parseResult.HasOption(Option);
+ // the HasOption extension lives in Microsoft.DotNet.Cli.CommandLine, which the AOT build doesn't reference.
+ if (parseResult.GetResult(Option) is not { Implicit: false } || !parseResult.GetValue(option)
// Only print for top-level commands.
|| !parseResult.IsTopLevelDotnetCommand())
{
@@ -128,7 +137,7 @@ internal class PrintInfoAction(Option option) : InvocableOptionAction(opti
public override int Invoke(ParseResult parseResult)
{
- if (!parseResult.HasOption(Option) || !parseResult.GetValue(option)
+ if (parseResult.GetResult(Option) is not { Implicit: false } || !parseResult.GetValue(option)
// Only print for top-level commands.
|| !parseResult.IsTopLevelDotnetCommand())
{
@@ -140,22 +149,34 @@ public override int Invoke(ParseResult parseResult)
Reporter.Output.WriteLine($"{LocalizableStrings.DotNetSdkInfoLabel}");
Reporter.Output.WriteLine($" Version: {Product.Version}");
Reporter.Output.WriteLine($" Commit: {commitSha}");
+#if !CLI_AOT
+ // Workload and MSBuild version reporting are not AOT-compatible yet (they pull in the
+ // workload manager and MSBuild forwarding machinery), so they are omitted from the AOT build.
Reporter.Output.WriteLine($" Workload version: {WorkloadInfoHelper.GetWorkloadsVersion()}");
Reporter.Output.WriteLine($" MSBuild version: {MSBuildForwardingAppWithoutLogging.MSBuildVersion}");
+#endif
Reporter.Output.WriteLine();
Reporter.Output.WriteLine($"{LocalizableStrings.DotNetRuntimeInfoLabel}");
Reporter.Output.WriteLine($" OS Name: {RuntimeEnvironment.OperatingSystem}");
Reporter.Output.WriteLine($" OS Version: {RuntimeEnvironment.OperatingSystemVersion}");
Reporter.Output.WriteLine($" OS Platform: {RuntimeEnvironment.OperatingSystemPlatform}");
+#if !CLI_AOT
Reporter.Output.WriteLine($" RID: {GetDisplayRid(versionFile)}");
+#else
+ // GetDisplayRid consults the shared framework's deps file, which isn't available in AOT.
+ Reporter.Output.WriteLine($" RID: {RuntimeInformation.RuntimeIdentifier}");
+#endif
Reporter.Output.WriteLine($" Base Path: {AppContext.BaseDirectory}");
+#if !CLI_AOT
Reporter.Output.WriteLine();
Reporter.Output.WriteLine($"{LocalizableStrings.DotnetWorkloadInfoLabel}");
new WorkloadInfoHelper(isInteractive: false).ShowWorkloadsInfo(showVersion: false);
+#endif
return 0;
}
+#if !CLI_AOT
private static string? GetDisplayRid(DotnetVersionFile versionFile)
{
FrameworkDependencyFile fxDepsFile = new();
@@ -164,6 +185,7 @@ public override int Invoke(ParseResult parseResult)
// so the user knows which RID they should put in their "runtimes" section.
return fxDepsFile.IsRuntimeSupported(currentRid) ? currentRid : versionFile.BuildRid;
}
+#endif
}
internal class PrintCliSchemaAction(Option option) : InvocableOptionAction(option)
@@ -172,12 +194,20 @@ internal class PrintCliSchemaAction(Option option) : InvocableOptionAction
public override int Invoke(ParseResult parseResult)
{
- if (!parseResult.HasOption(Option) || !parseResult.GetValue(option))
+ if (parseResult.GetResult(Option) is not { Implicit: false } || !parseResult.GetValue(option))
{
return 0;
}
- CliSchema.PrintCliSchema(parseResult, parseResult.InvocationConfiguration.Output, Program.TelemetryInstance);
+ CliSchema.PrintCliSchema(
+ parseResult,
+ parseResult.InvocationConfiguration.Output,
+#if CLI_AOT
+ telemetryClient: NativeEntryPoint.TelemetryClient
+#else
+ telemetryClient: Program.TelemetryInstance
+#endif
+ );
return 0;
}
diff --git a/src/Cli/dotnet/Program.cs b/src/Cli/dotnet/Program.cs
index 37f76e57910b..4ae31bec3b6a 100644
--- a/src/Cli/dotnet/Program.cs
+++ b/src/Cli/dotnet/Program.cs
@@ -333,8 +333,7 @@ private static int ExecuteExternalCommand(string[] args, ParseResult parseResult
{
// If we didn't match any built-in commands, and a C# file path is the first argument,
// parse as `dotnet run file.cs ..rest_of_args` instead.
- if (parseResult.GetResult(Parser.RootCommand.DotnetSubCommand) is { Tokens: [{ Type: TokenType.Argument, Value: { } } unmatchedCommandOrFile] }
- && VirtualProjectBuilder.IsValidEntryPointPath(unmatchedCommandOrFile.Value))
+ if (parseResult.GetFileBasedAppEntryPointToken() is { } unmatchedCommandOrFile)
{
List otherTokens = new(parseResult.Tokens.Count - 1);
foreach (var token in parseResult.Tokens)
diff --git a/test/dotnet-aot.Tests/AotParserTests.cs b/test/dotnet-aot.Tests/AotParserTests.cs
index 1b91bd22e0c2..fb51cb69cdd4 100644
--- a/test/dotnet-aot.Tests/AotParserTests.cs
+++ b/test/dotnet-aot.Tests/AotParserTests.cs
@@ -3,6 +3,7 @@
using System.CommandLine;
using Microsoft.DotNet.Cli;
+using Microsoft.DotNet.Cli.Extensions;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.NET.TestFramework.Utilities;
using Xunit;
@@ -11,8 +12,10 @@ namespace Microsoft.DotNet.Cli.Tests;
///
/// Tests for the AOT-compiled CLI parser (the #if CLI_AOT path in Parser.cs).
-/// Validates that --version, --info, and default usage work correctly,
-/// and that unsupported commands produce parse errors.
+/// Validates that --version, --info, --help, and default usage are served entirely
+/// from AOT, that the full command surface now parses (matching the managed CLI),
+/// and that commands which require the managed CLI report this via
+/// so the bridge can fall back.
///
public class AotParserTests
{
@@ -38,17 +41,149 @@ public void ParseNoArgs_HasNoErrors()
}
[Fact]
- public void ParseUnrecognizedCommand_HasErrors()
+ public void ParseKnownCommand_HasNoErrors()
{
+ // The AOT parser now builds the full command tree, so real commands like `build`
+ // parse cleanly (they no longer surface as unknown). Execution still falls back.
var result = Parser.Parse(["build"]);
- Assert.NotEmpty(result.Errors);
+ Assert.Empty(result.Errors);
+ }
+
+ [Fact]
+ public void DetectFileBasedApp_WhenFirstArgIsCSharpFile()
+ {
+ // `dotnet app.cs` is an implicit file-based app invocation. The AOT parser only sees the
+ // path as an unmatched root argument, so the shared detection (reused from the managed CLI)
+ // identifies it so NativeEntryPoint can defer to the managed run pipeline.
+ var csFile = Path.Combine(Path.GetTempPath(), $"aot-filebased-{Guid.NewGuid():N}.cs");
+ File.WriteAllText(csFile, "Console.WriteLine(\"hi\");");
+ try
+ {
+ var result = Parser.Parse([csFile]);
+ Assert.Empty(result.Errors);
+ Assert.NotNull(result.GetFileBasedAppEntryPointToken());
+ }
+ finally
+ {
+ File.Delete(csFile);
+ }
+ }
+
+ [Fact]
+ public void DoesNotDetectFileBasedApp_ForBuiltInCommand()
+ {
+ var result = Parser.Parse(["build"]);
+ Assert.Null(result.GetFileBasedAppEntryPointToken());
+ }
+
+ [Fact]
+ public void DoesNotDetectFileBasedApp_ForNonExistentFile()
+ {
+ // IsValidEntryPointPath requires the file to exist, so a bogus *.cs argument is not
+ // treated as a file-based app (it would resolve as an external `dotnet-` command).
+ var result = Parser.Parse([$"does-not-exist-{Guid.NewGuid():N}.cs"]);
+ Assert.Null(result.GetFileBasedAppEntryPointToken());
+ }
+
+ [Theory]
+ [InlineData("ef")] // external command: dotnet-ef
+ [InlineData("does-not-exist-command")] // unknown external command
+ public void DetectExternalCommand_RequiresManagedResolution(string command)
+ {
+ // `dotnet ` doesn't match a built-in command, so it lands on the root's hidden
+ // subcommand argument and must be deferred to the managed CLI's external command resolution
+ // rather than executed by the AOT root usage action.
+ var result = Parser.Parse([command]);
+ Assert.Empty(result.Errors);
+ Assert.True(result.RequiresManagedCommandResolution());
+ }
+
+ [Theory]
+ [InlineData("")] // `dotnet` (usage)
+ [InlineData("--version")]
+ [InlineData("--info")]
+ public void RootInvocations_DoNotRequireManagedResolution(string arg)
+ {
+ // Bare `dotnet`, `--version`, `--info`, etc. are handled entirely in AOT.
+ string[] args = arg.Length == 0 ? [] : [arg];
+ var result = Parser.Parse(args);
+ Assert.False(result.RequiresManagedCommandResolution());
+ }
+
+ [Fact]
+ public void ParseHostHandledOption_HasNoErrors()
+ {
+ // --list-sdks / --list-runtimes are host-handled options defined on the root command
+ // so they appear in help and parse without error. The host resolves them before AOT.
+ Assert.Empty(Parser.Parse(["--list-sdks"]).Errors);
+ Assert.Empty(Parser.Parse(["--list-runtimes"]).Errors);
+ }
+
+ [Fact]
+ public void ParseUnknownToken_IsToleratedForExternalCommandForwarding()
+ {
+ // The dotnet root command is intentionally tolerant of unknown tokens so that
+ // `dotnet foo` can be forwarded to an external `dotnet-foo` command. Unknown tokens
+ // therefore do not produce parse errors; they are resolved by the managed CLI on fallback.
+ var result = Parser.Parse(["--this-option-does-not-exist"]);
+ Assert.Empty(result.Errors);
+ }
+
+ [Fact]
+ public void InvokeKnownCommand_FallsBackToManaged()
+ {
+ // Commands that cannot run in AOT must signal a managed fallback rather than execute.
+ var result = Parser.Parse(["build"]);
+ Assert.Empty(result.Errors);
+ Assert.Throws(() => Parser.Invoke(result));
+ }
+
+ [Fact]
+ public void InvokeRootHelp_RendersUsageFromAot()
+ {
+ var (exitCode, stdout, _) = InvokeWithCapture(["--help"]);
+
+ Assert.Equal(0, exitCode);
+ Assert.Contains("dotnet", stdout);
+ Assert.Contains("build", stdout);
+ }
+
+ [Fact]
+ public void InvokeCommandHelp_RendersFromAotWithoutFallback()
+ {
+ // Help for a definition-backed command (one that does not shell out to an external
+ // tool) renders entirely from AOT and must not request a managed fallback.
+ var result = Parser.Parse(["build", "--help"]);
+ var exception = Record.Exception(() => Parser.Invoke(result));
+
+ Assert.Null(exception);
}
[Fact]
- public void ParseUnrecognizedOption_HasErrors()
+ public void InvokeExternalToolHelp_RendersFromAotWithoutFallback()
{
- var result = Parser.Parse(["--list-sdks"]);
- Assert.NotEmpty(result.Errors);
+ // Help for the external-tool commands (msbuild/nuget/vstest/format/fsi) now shells out to the
+ // underlying tool from AOT instead of falling back to the managed CLI. The forwarded process
+ // may fail in the test environment, but help must never request a managed fallback.
+ var result = Parser.Parse(["msbuild", "--help"]);
+ var exception = Record.Exception(() => Parser.Invoke(result));
+
+ Assert.IsNotType(exception);
+ }
+
+ [Fact]
+ public void InvokeCliSchema_RendersSchemaJsonFromAot()
+ {
+ // --cli-schema serializes the command tree via a source-generated JsonSerializerContext,
+ // so it runs entirely in AOT (no managed fallback) and emits the command surface as JSON.
+ var (exitCode, stdout, _) = InvokeWithCapture(["--cli-schema"]);
+
+ Assert.Equal(0, exitCode);
+ // The root command name reflects the host executable, so assert on stable content instead:
+ // the SDK version, the subcommands collection, and a representative built-in subcommand.
+ Assert.Contains($"\"version\": \"{Product.Version}\"", stdout);
+ Assert.Contains("\"subcommands\"", stdout);
+ Assert.Contains("\"build\"", stdout);
}
[Fact]
diff --git a/test/dotnet-aot.Tests/NativeEntryPointTests.cs b/test/dotnet-aot.Tests/NativeEntryPointTests.cs
index c6e095645034..958740f7c24f 100644
--- a/test/dotnet-aot.Tests/NativeEntryPointTests.cs
+++ b/test/dotnet-aot.Tests/NativeEntryPointTests.cs
@@ -227,6 +227,43 @@ public void ExecuteCore_AotEnabled_UnsupportedCommand_NoAotErrorLeaked()
});
}
+ [Fact]
+ public void ExecuteCore_AotEnabled_FileBasedApp_FallsBackToManaged()
+ {
+ WithEnvRestore(() =>
+ {
+ Environment.SetEnvironmentVariable("DOTNET_CLI_ENABLEAOT", "true");
+
+ // `dotnet app.cs` must not be served by the AOT path (which would print root usage);
+ // it has to fall back to the managed CLI's run pipeline. With a nonexistent SDK dir the
+ // fallback can't be hosted, so it reports the missing dotnet.dll - proving we reached it.
+ var csFile = Path.Combine(Path.GetTempPath(), $"aot-entry-filebased-{Guid.NewGuid():N}.cs");
+ File.WriteAllText(csFile, "Console.WriteLine(\"hi\");");
+
+ var originalErr = Console.Error;
+ var stderrWriter = new StringWriter();
+ Console.SetError(stderrWriter);
+
+ try
+ {
+ int exitCode = NativeEntryPoint.ExecuteCore(
+ hostPath: "test-host",
+ dotnetRoot: "test-root",
+ sdkDir: "nonexistent-sdk-dir",
+ hostfxrPath: "",
+ args: [csFile]);
+
+ Assert.Equal(1, exitCode);
+ Assert.Contains("dotnet.dll", stderrWriter.ToString());
+ }
+ finally
+ {
+ Console.SetError(originalErr);
+ File.Delete(csFile);
+ }
+ });
+ }
+
[Fact]
public void ExecuteCore_SetsHostfxrPathInAppContext()
{
diff --git a/test/dotnet-aot.Tests/dotnet-aot.Tests.csproj b/test/dotnet-aot.Tests/dotnet-aot.Tests.csproj
index 08abe586b9c7..a580340bbbcb 100644
--- a/test/dotnet-aot.Tests/dotnet-aot.Tests.csproj
+++ b/test/dotnet-aot.Tests/dotnet-aot.Tests.csproj
@@ -32,7 +32,7 @@
false
-
+