Skip to content

Commit b465890

Browse files
jonathanpeppersbaronfelpremun
authored
[dotnet-cli] prompt for target framework using Spectre.Console (#51509)
Implements the first part of the spec mentioned in: https://github.com/dotnet/sdk/blob/522c88a6abfc4a011556f839d15844d07ba62cd9/documentation/specs/dotnet-run-for-maui.md?plain=1#L35-L46 Add interactive target framework selection to `dotnet run` When running a multi-targeted project without specifying `--framework`, `dotnet run` now: * Prompts interactively (using `Spectre.Console`) to select a framework with arrow keys * Shows a formatted error list in non-interactive mode with available frameworks * Handles selection early before project build/evaluation * Removes redundant multi-TFM error checking from `ThrowUnableToRunError()` * Adds a few unit tests to validate these changes. Other changes: * Pin `darc` to "1.1.0-beta.25514.2" Co-authored-by: Chet Husk <[email protected]> Co-authored-by: Premek Vysoky <[email protected]>
1 parent 09b8b8a commit b465890

25 files changed

+826
-17
lines changed

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
<PackageVersion Include="runtime.linux-musl-x64.Microsoft.NETCore.DotNetHostResolver" Version="$(MicrosoftNETCoreDotNetHostResolverPackageVersion)" />
115115
<PackageVersion Include="runtime.linux-x64.Microsoft.NETCore.DotNetHostResolver" Version="$(MicrosoftNETCoreDotNetHostResolverPackageVersion)" />
116116
<PackageVersion Include="runtime.osx-x64.Microsoft.NETCore.DotNetHostResolver" Version="$(MicrosoftNETCoreDotNetHostResolverPackageVersion)" />
117+
<PackageVersion Include="Spectre.Console" Version="0.52.0" />
117118
<PackageVersion Include="StyleCop.Analyzers" Version="$(StyleCopAnalyzersPackageVersion)" />
118119
<PackageVersion Include="System.CodeDom" Version="$(SystemCodeDomPackageVersion)" />
119120
<PackageVersion Include="System.CommandLine" Version="$(SystemCommandLineVersion)" />

eng/Signing.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
<FileSignInfo Include="MessagePack.dll" CertificateName="$(ExternalCertificateId)" />
6565
<FileSignInfo Include="Nerdbank.Streams.dll" CertificateName="$(ExternalCertificateId)" />
6666
<FileSignInfo Include="Newtonsoft.Json.dll" CertificateName="$(ExternalCertificateId)" />
67+
<FileSignInfo Include="Spectre.Console.dll" CertificateName="$(ExternalCertificateId)" />
6768
<FileSignInfo Include="Valleysoft.DockerCredsProvider.dll" CertificateName="$(ExternalCertificateId)" />
6869

6970
<!-- Additionally, we need to notarize any .pkg files -->

eng/common/vmr-sync.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ Set-StrictMode -Version Latest
103103
Highlight 'Installing .NET, preparing the tooling..'
104104
. .\eng\common\tools.ps1
105105
$dotnetRoot = InitializeDotNetCli -install:$true
106-
$darc = Get-Darc
106+
$darc = Get-Darc "1.1.0-beta.25514.2"
107107
$dotnet = "$dotnetRoot\dotnet.exe"
108108

109109
Highlight "Starting the synchronization of VMR.."

eng/common/vmr-sync.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ set -e
164164
highlight 'Installing .NET, preparing the tooling..'
165165
source "./eng/common/tools.sh"
166166
InitializeDotNetCli true
167-
GetDarc
167+
GetDarc "1.1.0-beta.25514.2"
168168
dotnetDir=$( cd ./.dotnet/; pwd -P )
169169
dotnet=$dotnetDir/dotnet
170170

src/Cli/dotnet/Commands/CliCommandStrings.resx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1746,6 +1746,21 @@ The current OutputType is '{2}'.</value>
17461746
<value>Unable to run your project
17471747
Your project targets multiple frameworks. Specify which framework to run using '{0}'.</value>
17481748
</data>
1749+
<data name="RunCommandSelectTargetFrameworkPrompt" xml:space="preserve">
1750+
<value>Select the target framework to run:</value>
1751+
</data>
1752+
<data name="RunCommandMoreFrameworksText" xml:space="preserve">
1753+
<value>Move up and down to reveal more frameworks</value>
1754+
</data>
1755+
<data name="RunCommandSearchPlaceholderText" xml:space="preserve">
1756+
<value>Type to search</value>
1757+
</data>
1758+
<data name="RunCommandAvailableTargetFrameworks" xml:space="preserve">
1759+
<value>Available target frameworks:</value>
1760+
</data>
1761+
<data name="RunCommandExampleText" xml:space="preserve">
1762+
<value>Example</value>
1763+
</data>
17491764
<data name="RunCommandProjectAbbreviationDeprecated" xml:space="preserve">
17501765
<value>Warning NETSDK1174: The abbreviation of -p for --project is deprecated. Please use --project.</value>
17511766
<comment>{Locked="--project"}</comment>

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

Lines changed: 113 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
using Microsoft.DotNet.Cli.Extensions;
2020
using Microsoft.DotNet.Cli.Utils;
2121
using Microsoft.DotNet.Cli.Utils.Extensions;
22+
using Microsoft.DotNet.FileBasedPrograms;
2223

2324
namespace Microsoft.DotNet.Cli.Commands.Run;
2425

@@ -56,8 +57,11 @@ public class RunCommand
5657

5758
/// <summary>
5859
/// Parsed structure representing the MSBuild arguments that will be used to build the project.
60+
///
61+
/// Note: This property has a private setter and is mutated within the class when framework selection modifies it.
62+
/// This mutability is necessary to allow the command to update MSBuild arguments after construction based on framework selection.
5963
/// </summary>
60-
public MSBuildArgs MSBuildArgs { get; }
64+
public MSBuildArgs MSBuildArgs { get; private set; }
6165
public bool Interactive { get; }
6266

6367
/// <summary>
@@ -124,6 +128,18 @@ public int Execute()
124128
return 1;
125129
}
126130

131+
// Pre-run evaluation: Handle target framework selection for multi-targeted projects
132+
if (ProjectFileFullPath is not null && !TrySelectTargetFrameworkIfNeeded())
133+
{
134+
return 1;
135+
}
136+
137+
// For file-based projects, check for multi-targeting before building
138+
if (EntryPointFileFullPath is not null && !TrySelectTargetFrameworkForFileBasedProject())
139+
{
140+
return 1;
141+
}
142+
127143
Func<ProjectCollection, ProjectInstance>? projectFactory = null;
128144
RunProperties? cachedRunProperties = null;
129145
VirtualProjectBuildingCommand? virtualCommand = null;
@@ -182,6 +198,100 @@ public int Execute()
182198
}
183199
}
184200

201+
/// <summary>
202+
/// Checks if target framework selection is needed for multi-targeted projects.
203+
/// If needed and we're in interactive mode, prompts the user to select a framework.
204+
/// If needed and we're in non-interactive mode, shows an error.
205+
/// </summary>
206+
/// <returns>True if we can continue, false if we should exit</returns>
207+
private bool TrySelectTargetFrameworkIfNeeded()
208+
{
209+
Debug.Assert(ProjectFileFullPath is not null);
210+
211+
var globalProperties = CommonRunHelpers.GetGlobalPropertiesFromArgs(MSBuildArgs);
212+
if (TargetFrameworkSelector.TrySelectTargetFramework(
213+
ProjectFileFullPath,
214+
globalProperties,
215+
Interactive,
216+
out string? selectedFramework))
217+
{
218+
ApplySelectedFramework(selectedFramework);
219+
return true;
220+
}
221+
222+
return false;
223+
}
224+
225+
/// <summary>
226+
/// Checks if target framework selection is needed for file-based projects.
227+
/// Parses directives from the source file to detect multi-targeting.
228+
/// </summary>
229+
/// <returns>True if we can continue, false if we should exit</returns>
230+
private bool TrySelectTargetFrameworkForFileBasedProject()
231+
{
232+
Debug.Assert(EntryPointFileFullPath is not null);
233+
234+
var globalProperties = CommonRunHelpers.GetGlobalPropertiesFromArgs(MSBuildArgs);
235+
236+
// If a framework is already specified via --framework, no need to check
237+
if (globalProperties.TryGetValue("TargetFramework", out var existingFramework) && !string.IsNullOrWhiteSpace(existingFramework))
238+
{
239+
return true;
240+
}
241+
242+
// Get frameworks from source file directives
243+
var frameworks = GetTargetFrameworksFromSourceFile(EntryPointFileFullPath);
244+
if (frameworks is null || frameworks.Length == 0)
245+
{
246+
return true; // Not multi-targeted
247+
}
248+
249+
// Use TargetFrameworkSelector to handle multi-target selection (or single framework selection)
250+
if (TargetFrameworkSelector.TrySelectTargetFramework(frameworks, Interactive, out string? selectedFramework))
251+
{
252+
ApplySelectedFramework(selectedFramework);
253+
return true;
254+
}
255+
256+
return false;
257+
}
258+
259+
/// <summary>
260+
/// Parses a source file to extract target frameworks from directives.
261+
/// </summary>
262+
/// <returns>Array of frameworks if TargetFrameworks is specified, null otherwise</returns>
263+
private static string[]? GetTargetFrameworksFromSourceFile(string sourceFilePath)
264+
{
265+
var sourceFile = SourceFile.Load(sourceFilePath);
266+
var directives = FileLevelDirectiveHelpers.FindDirectives(sourceFile, reportAllErrors: false, DiagnosticBag.Ignore());
267+
268+
var targetFrameworksDirective = directives.OfType<CSharpDirective.Property>()
269+
.FirstOrDefault(p => string.Equals(p.Name, "TargetFrameworks", StringComparison.OrdinalIgnoreCase));
270+
271+
if (targetFrameworksDirective is null)
272+
{
273+
return null;
274+
}
275+
276+
return targetFrameworksDirective.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
277+
}
278+
279+
/// <summary>
280+
/// Applies the selected target framework to MSBuildArgs if a framework was provided.
281+
/// </summary>
282+
/// <param name="selectedFramework">The framework to apply, or null if no framework selection was needed</param>
283+
private void ApplySelectedFramework(string? selectedFramework)
284+
{
285+
// If selectedFramework is null, it means no framework selection was needed
286+
// (e.g., user already specified --framework, or single-target project)
287+
if (selectedFramework is not null)
288+
{
289+
var additionalProperties = new ReadOnlyDictionary<string, string>(
290+
new Dictionary<string, string> { { "TargetFramework", selectedFramework } });
291+
MSBuildArgs = MSBuildArgs.CloneWithAdditionalProperties(additionalProperties);
292+
}
293+
}
294+
185295
internal void ApplyLaunchSettingsProfileToCommand(ICommand targetCommand, ProjectLaunchSettingsModel? launchSettings)
186296
{
187297
if (launchSettings == null)
@@ -431,7 +541,8 @@ static ProjectInstance EvaluateProject(string? projectFilePath, Func<ProjectColl
431541

432542
static void ValidatePreconditions(ProjectInstance project)
433543
{
434-
if (string.IsNullOrWhiteSpace(project.GetPropertyValue("TargetFramework")))
544+
// there must be some kind of TFM available to run a project
545+
if (string.IsNullOrWhiteSpace(project.GetPropertyValue("TargetFramework")) && string.IsNullOrEmpty(project.GetPropertyValue("TargetFrameworks")))
435546
{
436547
ThrowUnableToRunError(project);
437548
}
@@ -504,16 +615,6 @@ static void InvokeRunArgumentsTarget(ProjectInstance project, bool noBuild, Faca
504615
[DoesNotReturn]
505616
internal static void ThrowUnableToRunError(ProjectInstance project)
506617
{
507-
string targetFrameworks = project.GetPropertyValue("TargetFrameworks");
508-
if (!string.IsNullOrEmpty(targetFrameworks))
509-
{
510-
string targetFramework = project.GetPropertyValue("TargetFramework");
511-
if (string.IsNullOrEmpty(targetFramework))
512-
{
513-
throw new GracefulException(CliCommandStrings.RunCommandExceptionUnableToRunSpecifyFramework, "--framework");
514-
}
515-
}
516-
517618
throw new GracefulException(
518619
string.Format(
519620
CliCommandStrings.RunCommandExceptionUnableToRun,
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.Build.Evaluation;
5+
using Microsoft.Build.Exceptions;
6+
using Microsoft.DotNet.Cli.Utils;
7+
using Spectre.Console;
8+
9+
namespace Microsoft.DotNet.Cli.Commands.Run;
10+
11+
internal static class TargetFrameworkSelector
12+
{
13+
/// <summary>
14+
/// Evaluates the project to determine if target framework selection is needed.
15+
/// If the project has multiple target frameworks and none was specified, prompts the user to select one.
16+
/// </summary>
17+
/// <param name="projectFilePath">Path to the project file</param>
18+
/// <param name="globalProperties">Global properties for MSBuild evaluation</param>
19+
/// <param name="isInteractive">Whether we're running in interactive mode (can prompt user)</param>
20+
/// <param name="selectedFramework">The selected target framework, or null if not needed</param>
21+
/// <returns>True if we should continue, false if we should exit with error</returns>
22+
public static bool TrySelectTargetFramework(
23+
string projectFilePath,
24+
Dictionary<string, string> globalProperties,
25+
bool isInteractive,
26+
out string? selectedFramework)
27+
{
28+
selectedFramework = null;
29+
30+
// If a framework is already specified, no need to prompt
31+
if (globalProperties.TryGetValue("TargetFramework", out var existingFramework) && !string.IsNullOrWhiteSpace(existingFramework))
32+
{
33+
return true;
34+
}
35+
36+
// Evaluate the project to get TargetFrameworks
37+
string targetFrameworks;
38+
try
39+
{
40+
using var collection = new ProjectCollection(globalProperties: globalProperties);
41+
var project = collection.LoadProject(projectFilePath);
42+
targetFrameworks = project.GetPropertyValue("TargetFrameworks");
43+
}
44+
catch (InvalidProjectFileException)
45+
{
46+
// Invalid project file, return true to continue for normal error handling
47+
return true;
48+
}
49+
50+
// If there's no TargetFrameworks property or only one framework, no selection needed
51+
if (string.IsNullOrWhiteSpace(targetFrameworks))
52+
{
53+
return true;
54+
}
55+
56+
// parse the TargetFrameworks property and make sure to account for any additional whitespace
57+
// users may have added for formatting reasons.
58+
var frameworks = targetFrameworks.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
59+
60+
return TrySelectTargetFramework(frameworks, isInteractive, out selectedFramework);
61+
}
62+
63+
/// <summary>
64+
/// Handles target framework selection when given an array of frameworks.
65+
/// If there's only one framework, selects it automatically.
66+
/// If there are multiple frameworks, prompts the user (interactive) or shows an error (non-interactive).
67+
/// </summary>
68+
/// <param name="frameworks">Array of target frameworks to choose from</param>
69+
/// <param name="isInteractive">Whether we're running in interactive mode (can prompt user)</param>
70+
/// <param name="selectedFramework">The selected target framework, or null if selection was cancelled</param>
71+
/// <returns>True if we should continue, false if we should exit with error</returns>
72+
public static bool TrySelectTargetFramework(string[] frameworks, bool isInteractive, out string? selectedFramework)
73+
{
74+
// If there's only one framework in the TargetFrameworks, we do need to pick it to force the subsequent builds/evaluations
75+
// to act against the correct 'view' of the project
76+
if (frameworks.Length == 1)
77+
{
78+
selectedFramework = frameworks[0];
79+
return true;
80+
}
81+
82+
if (isInteractive)
83+
{
84+
selectedFramework = PromptForTargetFramework(frameworks);
85+
return selectedFramework != null;
86+
}
87+
else
88+
{
89+
Reporter.Error.WriteLine(string.Format(CliCommandStrings.RunCommandExceptionUnableToRunSpecifyFramework, "--framework"));
90+
Reporter.Error.WriteLine();
91+
Reporter.Error.WriteLine(CliCommandStrings.RunCommandAvailableTargetFrameworks);
92+
Reporter.Error.WriteLine();
93+
94+
for (int i = 0; i < frameworks.Length; i++)
95+
{
96+
Reporter.Error.WriteLine($" {i + 1}. {frameworks[i]}");
97+
}
98+
99+
Reporter.Error.WriteLine();
100+
Reporter.Error.WriteLine($"{CliCommandStrings.RunCommandExampleText}: dotnet run --framework {frameworks[0]}");
101+
Reporter.Error.WriteLine();
102+
selectedFramework = null;
103+
return false;
104+
}
105+
}
106+
107+
/// <summary>
108+
/// Prompts the user to select a target framework from the available options using Spectre.Console.
109+
/// </summary>
110+
private static string? PromptForTargetFramework(string[] frameworks)
111+
{
112+
try
113+
{
114+
var prompt = new SelectionPrompt<string>()
115+
.Title($"[cyan]{Markup.Escape(CliCommandStrings.RunCommandSelectTargetFrameworkPrompt)}[/]")
116+
.PageSize(10)
117+
.MoreChoicesText($"[grey]({Markup.Escape(CliCommandStrings.RunCommandMoreFrameworksText)})[/]")
118+
.AddChoices(frameworks)
119+
.EnableSearch()
120+
.SearchPlaceholderText(CliCommandStrings.RunCommandSearchPlaceholderText);
121+
122+
return Spectre.Console.AnsiConsole.Prompt(prompt);
123+
}
124+
catch (Exception)
125+
{
126+
// If Spectre.Console fails (e.g., terminal doesn't support it), return null
127+
return null;
128+
}
129+
}
130+
}

0 commit comments

Comments
 (0)