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 - +