From f45fb878dfca88485127791f2e36da08a4e4423b Mon Sep 17 00:00:00 2001 From: Jan Jones Date: Wed, 2 Jul 2025 17:19:34 +0200 Subject: [PATCH 01/12] Implement '#:package' manipulation for file-based apps --- documentation/general/dotnet-run-file.md | 6 +- src/Cli/dotnet/CliStrings.resx | 11 +- .../dotnet/Commands/CliCommandStrings.resx | 13 +- .../Commands/Hidden/Add/AddCommandParser.cs | 10 +- .../Add/Package/AddPackageCommandParser.cs | 18 +- .../Package/RemovePackageCommandParser.cs | 2 - .../Hidden/Remove/RemoveCommandParser.cs | 10 +- .../Commands/New/DotnetCommandCallbacks.cs | 2 +- .../Commands/Package/Add/PackageAddCommand.cs | 236 +++++++++- .../Package/Add/PackageAddCommandParser.cs | 11 +- .../Commands/Package/PackageCommandParser.cs | 38 +- .../Package/Remove/PackageRemoveCommand.cs | 69 ++- .../Remove/PackageRemoveCommandParser.cs | 5 +- .../Project/Convert/ProjectConvertCommand.cs | 4 +- .../Reference/Add/ReferenceAddCommand.cs | 4 +- .../Remove/ReferenceRemoveCommand.cs | 4 +- .../dotnet/Commands/Run/Api/RunApiCommand.cs | 9 +- .../Commands/Run/FileBasedAppSourceEditor.cs | 223 +++++++++ src/Cli/dotnet/Commands/Run/RunCommand.cs | 4 +- .../Run/VirtualProjectBuildingCommand.cs | 367 +++++++++------ .../Commands/xlf/CliCommandStrings.cs.xlf | 14 +- .../Commands/xlf/CliCommandStrings.de.xlf | 14 +- .../Commands/xlf/CliCommandStrings.es.xlf | 14 +- .../Commands/xlf/CliCommandStrings.fr.xlf | 14 +- .../Commands/xlf/CliCommandStrings.it.xlf | 14 +- .../Commands/xlf/CliCommandStrings.ja.xlf | 14 +- .../Commands/xlf/CliCommandStrings.ko.xlf | 14 +- .../Commands/xlf/CliCommandStrings.pl.xlf | 14 +- .../Commands/xlf/CliCommandStrings.pt-BR.xlf | 14 +- .../Commands/xlf/CliCommandStrings.ru.xlf | 14 +- .../Commands/xlf/CliCommandStrings.tr.xlf | 14 +- .../xlf/CliCommandStrings.zh-Hans.xlf | 14 +- .../xlf/CliCommandStrings.zh-Hant.xlf | 14 +- src/Cli/dotnet/CommonArguments.cs | 2 + src/Cli/dotnet/xlf/CliStrings.cs.xlf | 15 + src/Cli/dotnet/xlf/CliStrings.de.xlf | 15 + src/Cli/dotnet/xlf/CliStrings.es.xlf | 15 + src/Cli/dotnet/xlf/CliStrings.fr.xlf | 15 + src/Cli/dotnet/xlf/CliStrings.it.xlf | 15 + src/Cli/dotnet/xlf/CliStrings.ja.xlf | 15 + src/Cli/dotnet/xlf/CliStrings.ko.xlf | 15 + src/Cli/dotnet/xlf/CliStrings.pl.xlf | 15 + src/Cli/dotnet/xlf/CliStrings.pt-BR.xlf | 15 + src/Cli/dotnet/xlf/CliStrings.ru.xlf | 15 + src/Cli/dotnet/xlf/CliStrings.tr.xlf | 15 + src/Cli/dotnet/xlf/CliStrings.zh-Hans.xlf | 15 + src/Cli/dotnet/xlf/CliStrings.zh-Hant.xlf | 15 + .../Package/Add/GivenDotnetPackageAdd.cs | 424 +++++++++++++++++- .../Remove/GivenDotnetRemovePackage.cs | 79 +++- .../Convert/DotnetProjectConvertTests.cs | 55 ++- .../Reference/Add/AddReferenceParserTests.cs | 4 +- .../Run/FileBasedAppSourceEditorTests.cs | 344 ++++++++++++++ .../CommandTests/Run/RunFileTests.cs | 2 +- ...napshotTests.VerifyCompletions.verified.sh | 4 +- ...apshotTests.VerifyCompletions.verified.ps1 | 2 + ...apshotTests.VerifyCompletions.verified.zsh | 2 + 56 files changed, 2020 insertions(+), 321 deletions(-) create mode 100644 src/Cli/dotnet/Commands/Run/FileBasedAppSourceEditor.cs create mode 100644 test/dotnet.Tests/CommandTests/Run/FileBasedAppSourceEditorTests.cs diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index c8fddd91aead..1cdb95f549da 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -100,6 +100,9 @@ To opt out, use `#:property PublishAot=false` directive in your `.cs` file. Command `dotnet clean file.cs` can be used to clean build artifacts of the file-based program. +Commands `dotnet package add PackageName --file app.cs` and `dotnet package remove PackageName --file app.cs` +can be used to manipulate `#:package` directives in the C# files, similarly to what the commands do for project-based apps. + ## Entry points If a file is given to `dotnet run`, it has to be an *entry-point file*, otherwise an error is reported. @@ -369,8 +372,7 @@ We could also add `dotnet compile` command that would be the equivalent of `dotn `dotnet clean` could be extended to support cleaning all file-based app outputs, e.g., `dotnet clean --all-file-based-apps`. -Adding references via `dotnet package add`/`dotnet reference add` could be supported for file-based programs as well, -i.e., the command would add a `#:package`/`#:project` directive to the top of a `.cs` file. +More NuGet commands (like `dotnet nuget why` or `dotnet package list`) could be supported for file-based programs as well. ### Explicit importing diff --git a/src/Cli/dotnet/CliStrings.resx b/src/Cli/dotnet/CliStrings.resx index 39e1a3e261df..a41cfa9c3ea1 100644 --- a/src/Cli/dotnet/CliStrings.resx +++ b/src/Cli/dotnet/CliStrings.resx @@ -273,9 +273,18 @@ PROJECT + + PROJECT | FILE + The project file to operate on. If a file is not specified, the command will search the current directory for one. + + The project file or C# file-based app to operate on. If a file is not specified, the command will search the current directory for a project file. + + + The file-based app to operate on. + FRAMEWORK @@ -814,4 +823,4 @@ The default is 'false.' However, when targeting .NET 7 or lower, the default is Display the command schema as JSON. - \ No newline at end of file + diff --git a/src/Cli/dotnet/Commands/CliCommandStrings.resx b/src/Cli/dotnet/Commands/CliCommandStrings.resx index 75c76f009030..773e525cbdce 100644 --- a/src/Cli/dotnet/Commands/CliCommandStrings.resx +++ b/src/Cli/dotnet/Commands/CliCommandStrings.resx @@ -1251,6 +1251,11 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man Specify only one package reference to remove. + + Removed '{0}' directives ({1}) for '{2}' from: {3} + {0} is a directive kind (like '#:package'). {1} is number of removed directives. + {2} is directive key (e.g., package name). {3} is file path from which directives were removed. + Command names conflict. Command names are case insensitive. {0} @@ -1530,14 +1535,14 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man Duplicate directives are not supported: {0} at {1} {0} is the directive type and name. {1} is the file path and line number. - - Cannot combine option '{0}' and '{1}'. - {0} and {1} are option names like '--no-build'. - Cannot specify option '{0}' when also using '-' to read the file from standard input. {0} is an option name like '--no-build'. + + Cannot specify option '{0}' when operating on a file-based app. + {0} is an option name like '--source'. + Warning: Binary log option was specified but build will be skipped because output is up to date, specify '--no-cache' to force build. {Locked="--no-cache"} diff --git a/src/Cli/dotnet/Commands/Hidden/Add/AddCommandParser.cs b/src/Cli/dotnet/Commands/Hidden/Add/AddCommandParser.cs index 89c12c174c08..43cb90737cd6 100644 --- a/src/Cli/dotnet/Commands/Hidden/Add/AddCommandParser.cs +++ b/src/Cli/dotnet/Commands/Hidden/Add/AddCommandParser.cs @@ -1,11 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.CommandLine; using Microsoft.DotNet.Cli.Commands.Hidden.Add.Package; using Microsoft.DotNet.Cli.Commands.Hidden.Add.Reference; +using Microsoft.DotNet.Cli.Commands.Package; using Microsoft.DotNet.Cli.Extensions; namespace Microsoft.DotNet.Cli.Commands.Hidden.Add; @@ -14,11 +13,6 @@ internal static class AddCommandParser { public static readonly string DocsLink = "https://aka.ms/dotnet-add"; - public static readonly Argument ProjectArgument = new Argument(CliStrings.ProjectArgumentName) - { - Description = CliStrings.ProjectArgumentDescription - }.DefaultToCurrentDirectory(); - private static readonly Command Command = ConstructCommand(); public static Command GetCommand() @@ -33,7 +27,7 @@ private static Command ConstructCommand() Hidden = true }; - command.Arguments.Add(ProjectArgument); + command.Arguments.Add(PackageCommandParser.ProjectOrFileArgument); command.Subcommands.Add(AddPackageCommandParser.GetCommand()); command.Subcommands.Add(AddReferenceCommandParser.GetCommand()); diff --git a/src/Cli/dotnet/Commands/Hidden/Add/Package/AddPackageCommandParser.cs b/src/Cli/dotnet/Commands/Hidden/Add/Package/AddPackageCommandParser.cs index ca1e014bf4a6..f6cb1909b23d 100644 --- a/src/Cli/dotnet/Commands/Hidden/Add/Package/AddPackageCommandParser.cs +++ b/src/Cli/dotnet/Commands/Hidden/Add/Package/AddPackageCommandParser.cs @@ -1,12 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.CommandLine; using Microsoft.DotNet.Cli.Commands.Package; using Microsoft.DotNet.Cli.Commands.Package.Add; -using Microsoft.DotNet.Cli.Extensions; namespace Microsoft.DotNet.Cli.Commands.Hidden.Add.Package; @@ -32,20 +29,9 @@ private static Command ConstructCommand() command.Options.Add(PackageAddCommandParser.InteractiveOption); command.Options.Add(PackageAddCommandParser.PrereleaseOption); command.Options.Add(PackageCommandParser.ProjectOption); + command.Options.Add(PackageCommandParser.FileOption); - command.SetAction((parseResult) => - { - // this command can be called with an argument or an option for the project path - we prefer the option. - // if the option is not present, we use the argument value instead. - if (parseResult.HasOption(PackageCommandParser.ProjectOption)) - { - return new PackageAddCommand(parseResult, parseResult.GetValue(PackageCommandParser.ProjectOption)).Execute(); - } - else - { - return new PackageAddCommand(parseResult, parseResult.GetValue(AddCommandParser.ProjectArgument) ?? Directory.GetCurrentDirectory()).Execute(); - } - }); + command.SetAction((parseResult) => new PackageAddCommand(parseResult).Execute()); return command; } diff --git a/src/Cli/dotnet/Commands/Hidden/Remove/Package/RemovePackageCommandParser.cs b/src/Cli/dotnet/Commands/Hidden/Remove/Package/RemovePackageCommandParser.cs index bc661b2a71da..51eb32535cc2 100644 --- a/src/Cli/dotnet/Commands/Hidden/Remove/Package/RemovePackageCommandParser.cs +++ b/src/Cli/dotnet/Commands/Hidden/Remove/Package/RemovePackageCommandParser.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.CommandLine; using Microsoft.DotNet.Cli.Commands.Package.Remove; diff --git a/src/Cli/dotnet/Commands/Hidden/Remove/RemoveCommandParser.cs b/src/Cli/dotnet/Commands/Hidden/Remove/RemoveCommandParser.cs index 02ba2402c32f..0ac1f1834dbb 100644 --- a/src/Cli/dotnet/Commands/Hidden/Remove/RemoveCommandParser.cs +++ b/src/Cli/dotnet/Commands/Hidden/Remove/RemoveCommandParser.cs @@ -1,11 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.CommandLine; using Microsoft.DotNet.Cli.Commands.Hidden.Remove.Package; using Microsoft.DotNet.Cli.Commands.Hidden.Remove.Reference; +using Microsoft.DotNet.Cli.Commands.Package; using Microsoft.DotNet.Cli.Extensions; namespace Microsoft.DotNet.Cli.Commands.Hidden.Remove; @@ -14,11 +13,6 @@ internal static class RemoveCommandParser { public static readonly string DocsLink = "https://aka.ms/dotnet-remove"; - public static readonly Argument ProjectArgument = new Argument(CliStrings.ProjectArgumentName) - { - Description = CliStrings.ProjectArgumentDescription - }.DefaultToCurrentDirectory(); - private static readonly Command Command = ConstructCommand(); public static Command GetCommand() @@ -33,7 +27,7 @@ private static Command ConstructCommand() Hidden = true }; - command.Arguments.Add(ProjectArgument); + command.Arguments.Add(PackageCommandParser.ProjectOrFileArgument); command.Subcommands.Add(RemovePackageCommandParser.GetCommand()); command.Subcommands.Add(RemoveReferenceCommandParser.GetCommand()); diff --git a/src/Cli/dotnet/Commands/New/DotnetCommandCallbacks.cs b/src/Cli/dotnet/Commands/New/DotnetCommandCallbacks.cs index fabaa4ba0787..46ddfe28c4b0 100644 --- a/src/Cli/dotnet/Commands/New/DotnetCommandCallbacks.cs +++ b/src/Cli/dotnet/Commands/New/DotnetCommandCallbacks.cs @@ -21,7 +21,7 @@ internal static bool AddPackageReference(string projectPath, string packageName, { commandArgs = commandArgs.Append(PackageAddCommandParser.VersionOption.Name).Append(version); } - var addPackageReferenceCommand = new PackageAddCommand(AddCommandParser.GetCommand().Parse([.. commandArgs]), projectPath); + var addPackageReferenceCommand = new PackageAddCommand(AddCommandParser.GetCommand().Parse([.. commandArgs])); return addPackageReferenceCommand.Execute() == 0; } diff --git a/src/Cli/dotnet/Commands/Package/Add/PackageAddCommand.cs b/src/Cli/dotnet/Commands/Package/Add/PackageAddCommand.cs index 080fa97a52e6..e97898a0a5ec 100644 --- a/src/Cli/dotnet/Commands/Package/Add/PackageAddCommand.cs +++ b/src/Cli/dotnet/Commands/Package/Add/PackageAddCommand.cs @@ -1,27 +1,33 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#nullable disable - using System.CommandLine; +using System.Diagnostics; +using Microsoft.Build.Evaluation; using Microsoft.DotNet.Cli.Commands.MSBuild; using Microsoft.DotNet.Cli.Commands.NuGet; +using Microsoft.DotNet.Cli.Commands.Run; using Microsoft.DotNet.Cli.Extensions; using Microsoft.DotNet.Cli.Utils; +using NuGet.ProjectModel; namespace Microsoft.DotNet.Cli.Commands.Package.Add; -/// -/// -/// Since this command is invoked via both 'package add' and 'add package', different symbols will control what the project path to search is. -/// It's cleaner for the separate callsites to know this instead of pushing that logic here. -/// -internal class PackageAddCommand(ParseResult parseResult, string fileOrDirectory) : CommandBase(parseResult) +internal class PackageAddCommand(ParseResult parseResult) : CommandBase(parseResult) { - private readonly PackageIdentityWithRange _packageId = parseResult.GetValue(PackageAddCommandParser.CmdPackageArgument); + private readonly PackageIdentityWithRange _packageId = parseResult.GetValue(PackageAddCommandParser.CmdPackageArgument)!; public override int Execute() { + var (fileOrDirectory, allowedAppKinds) = PackageCommandParser.ProcessPathOptions(_parseResult); + + if (allowedAppKinds.HasFlag(AppKinds.FileBased) && VirtualProjectBuildingCommand.IsValidEntryPointPath(fileOrDirectory)) + { + return ExecuteForFileBasedApp(fileOrDirectory); + } + + Debug.Assert(allowedAppKinds.HasFlag(AppKinds.ProjectBased)); + string projectFilePath; if (!File.Exists(fileOrDirectory)) { @@ -114,7 +120,7 @@ private string[] TransformArgs(PackageIdentityWithRange packageId, string tempDg if (packageId.HasVersion) { args.Add("--version"); - args.Add(packageId.VersionRange.OriginalString); + args.Add(packageId.VersionRange.OriginalString ?? string.Empty); } args.AddRange(_parseResult @@ -133,4 +139,214 @@ private string[] TransformArgs(PackageIdentityWithRange packageId, string tempDg return [.. args]; } + + // More logic should live in NuGet: https://github.com/NuGet/Home/issues/14390 + private int ExecuteForFileBasedApp(string path) + { + // Check disallowed options. + ReadOnlySpan