|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Build.Evaluation;
using Microsoft.Build.Exceptions;
using Microsoft.Build.Execution;
using Microsoft.Build.Framework;
using Microsoft.DotNet.Cli.Utils;
using Spectre.Console;
namespace Microsoft.DotNet.Cli.Commands.Run;
/// <summary>
/// Handles target framework and device selection for dotnet run.
/// Caches the project instance to avoid reloading it multiple times.
/// </summary>
internal sealed class RunCommandSelector : IDisposable
{
// Spectre.Console markup color constants
private const string CyanMarkup = "[cyan]";
private const string GrayMarkup = "[gray]";
private const string EndMarkup = "[/]";
private readonly string _projectFilePath;
private readonly Dictionary<string, string> _globalProperties;
private readonly FacadeLogger? _binaryLogger;
private readonly bool _isInteractive;
private readonly MSBuildArgs _msbuildArgs;
private readonly IReadOnlyDictionary<string, string> _environmentVariables;
private ProjectCollection? _collection;
private Microsoft.Build.Evaluation.Project? _project;
// Project/collection without TargetFramework, used for restore. Lazily populated.
private ProjectCollection? _restoreCollection;
private Microsoft.Build.Evaluation.Project? _restoreProject;
/// <summary>
/// Gets whether the selector has a valid project that can be evaluated.
/// This is false for .sln files or other invalid project files.
/// </summary>
public bool HasValidProject { get; private set; }
/// <summary>
/// Gets the IntermediateOutputPath property from the evaluated project.
/// This will evaluate the project if it hasn't been evaluated yet.
/// Returns null if the project cannot be evaluated or the property is not set.
/// </summary>
public string? IntermediateOutputPath
{
get
{
if (OpenProjectIfNeeded(out var projectInstance))
{
return projectInstance.GetPropertyValue(Constants.IntermediateOutputPath);
}
return null;
}
}
/// <summary>
/// Gets whether the project has opted in to receiving environment variables as MSBuild items.
/// When true, 'dotnet run -e' will pass environment variables as @(RuntimeEnvironmentVariable) items
/// via CustomBeforeMicrosoftCommonProps.
/// </summary>
public bool HasRuntimeEnvironmentVariableSupport
{
get
{
if (OpenProjectIfNeeded(out var projectInstance))
{
return projectInstance.GetItems(Constants.ProjectCapability)
.Any(item => string.Equals(item.EvaluatedInclude, Constants.RuntimeEnvironmentVariableSupport, StringComparison.OrdinalIgnoreCase));
}
return false;
}
}
/// <param name="projectFilePath">Path to the project file to evaluate</param>
/// <param name="isInteractive">Whether to prompt the user for selections</param>
/// <param name="msbuildArgs">MSBuild arguments containing properties and verbosity settings</param>
/// <param name="environmentVariables">Environment variables to pass to MSBuild targets as items</param>
/// <param name="binaryLogger">Optional binary logger for MSBuild operations. The logger will not be disposed by this class.</param>
public RunCommandSelector(
string projectFilePath,
bool isInteractive,
MSBuildArgs msbuildArgs,
IReadOnlyDictionary<string, string> environmentVariables,
FacadeLogger? binaryLogger = null)
{
_projectFilePath = projectFilePath;
_globalProperties = CommonRunHelpers.GetGlobalPropertiesFromArgs(msbuildArgs);
_isInteractive = isInteractive;
_msbuildArgs = msbuildArgs;
_environmentVariables = environmentVariables;
_binaryLogger = binaryLogger;
}
/// <summary>
/// Evaluates the project to determine if target framework selection is needed.
/// If the project has multiple target frameworks and none was specified, prompts the user to select one.
/// </summary>
/// <param name="selectedFramework">The selected target framework, or null if not needed</param>
/// <returns>True if we should continue, false if we should exit with error</returns>
public bool TrySelectTargetFramework(out string? selectedFramework)
{
selectedFramework = null;
// If a framework is already specified, no need to prompt
if (_globalProperties.TryGetValue("TargetFramework", out var existingFramework) && !string.IsNullOrWhiteSpace(existingFramework))
{
return true;
}
// Evaluate the project to get TargetFrameworks
if (!OpenProjectIfNeeded(out var projectInstance))
{
// Invalid project file, return true to continue for normal error handling
return true;
}
string targetFrameworks = projectInstance.GetPropertyValue("TargetFrameworks");
// If there's no TargetFrameworks property or only one framework, no selection needed
if (string.IsNullOrWhiteSpace(targetFrameworks))
{
return true;
}
// parse the TargetFrameworks property and make sure to account for any additional whitespace
// users may have added for formatting reasons.
var frameworks = targetFrameworks.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return TrySelectTargetFramework(frameworks, _isInteractive, out selectedFramework);
}
/// <summary>
/// Invalidates the loaded project with updated global properties.
/// This is needed after framework selection to get the correct device list for that framework.
/// </summary>
public void InvalidateGlobalProperties(Dictionary<string, string> updatedProperties)
{
// When TargetFramework is first added, save the current project for restore use
// instead of disposing it. See https://github.com/dotnet/sdk/issues/53488
if (_restoreProject is null &&
_project is not null &&
updatedProperties.ContainsKey("TargetFramework") &&
!_globalProperties.ContainsKey("TargetFramework"))
{
_restoreProject = _project;
_restoreCollection = _collection;
}
else
{
_collection?.Dispose();
}
// Update our stored global properties
foreach (var (key, value) in updatedProperties)
{
_globalProperties[key] = value;
}
// Reset to force re-evaluation with new global properties
_project = null;
_collection = null;
HasValidProject = false;
}
/// <summary>
/// Opens the project if it hasn't been opened yet.
/// </summary>
private bool OpenProjectIfNeeded([NotNullWhen(true)] out ProjectInstance? projectInstance)
{
if (_project is not null)
{
// Create a fresh ProjectInstance for each build operation
// to avoid accumulating state (existing item groups) from previous builds
projectInstance = _project.CreateProjectInstance();
HasValidProject = true;
return true;
}
try
{
_collection = new ProjectCollection(
globalProperties: _globalProperties,
loggers: GetLoggers(),
toolsetDefinitionLocations: ToolsetDefinitionLocations.Default);
_project = _collection.LoadProject(_projectFilePath);
projectInstance = _project.CreateProjectInstance();
HasValidProject = true;
return true;
}
catch (InvalidProjectFileException)
{
// Invalid project file, return false
projectInstance = null;
HasValidProject = false;
return false;
}
}
public void Dispose()
{
// NOTE: _binaryLogger is not disposed here because it is *owned* by the caller
_collection?.Dispose();
_restoreCollection?.Dispose();
}
/// <summary>
/// Creates a ProjectInstance without TargetFramework for use during restore,
/// preventing the TFM from cascading to dependency projects.
/// Reuses the pre-TF project when available (saved by InvalidateGlobalProperties),
/// or the current project if it was already loaded without TargetFramework.
/// </summary>
private ProjectInstance CreateRestoreProjectInstance()
{
if (_restoreProject is not null)
{
return _restoreProject.CreateProjectInstance();
}
// If the current project was loaded without TargetFramework, reuse it directly.
if (_project is not null && !_globalProperties.ContainsKey("TargetFramework"))
{
return _project.CreateProjectInstance();
}
// TargetFramework is set and no pre-TF project was saved (e.g. --framework was explicit).
// Create a new project without TargetFramework.
var restoreProperties = new Dictionary<string, string>(_globalProperties, StringComparer.OrdinalIgnoreCase);
restoreProperties.Remove("TargetFramework");
_restoreCollection = new ProjectCollection(
globalProperties: restoreProperties,
loggers: null,
toolsetDefinitionLocations: ToolsetDefinitionLocations.Default);
_restoreProject = _restoreCollection.LoadProject(_projectFilePath);
return _restoreProject.CreateProjectInstance();
}
/// <summary>
/// Handles target framework selection when given an array of frameworks.
/// If there's only one framework, selects it automatically.
/// If there are multiple frameworks, prompts the user (interactive) or shows an error (non-interactive).
/// </summary>
/// <param name="frameworks">Array of target frameworks to choose from</param>
/// <param name="isInteractive">Whether we're running in interactive mode (can prompt user)</param>
/// <param name="selectedFramework">The selected target framework, or null if selection was cancelled</param>
/// <returns>True if we should continue, false if we should exit with error</returns>
public static bool TrySelectTargetFramework(string[] frameworks, bool isInteractive, out string? selectedFramework)
{
// If there's only one framework in the TargetFrameworks, we do need to pick it to force the subsequent builds/evaluations
// to act against the correct 'view' of the project
if (frameworks.Length == 1)
{
selectedFramework = frameworks[0];
return true;
}
if (isInteractive)
{
selectedFramework = PromptForTargetFramework(frameworks);
return selectedFramework != null;
}
else
{
Reporter.Error.WriteLine(string.Format(CliCommandStrings.RunCommandExceptionUnableToRunSpecifyFramework, "--framework"));
Reporter.Error.WriteLine();
Reporter.Error.WriteLine(CliCommandStrings.RunCommandAvailableTargetFrameworks);
Reporter.Error.WriteLine();
for (int i = 0; i < frameworks.Length; i++)
{
Reporter.Error.WriteLine($" {i + 1}. {frameworks[i]}");
}
Reporter.Error.WriteLine();
Reporter.Error.WriteLine($"{CliCommandStrings.RunCommandExampleText}: dotnet run --framework {frameworks[0]}");
Reporter.Error.WriteLine();
selectedFramework = null;
return false;
}
}
/// <summary>
/// Prompts the user to select a target framework from the available options using Spectre.Console.
/// </summary>
private static string? PromptForTargetFramework(string[] frameworks)
{
try
{
var prompt = new SelectionPrompt<string>()
.Title($"{CyanMarkup}{Markup.Escape(CliCommandStrings.RunCommandSelectTargetFrameworkPrompt)}{EndMarkup}")
.PageSize(10)
.MoreChoicesText($"{GrayMarkup}({Markup.Escape(CliCommandStrings.RunCommandMoreFrameworksText)}){EndMarkup}")
.AddChoices(frameworks)
.EnableSearch()
.SearchPlaceholderText(CliCommandStrings.RunCommandSearchPlaceholderText);
return Spectre.Console.AnsiConsole.Prompt(prompt);
}
catch (Exception)
{
// If Spectre.Console fails (e.g., terminal doesn't support it), return null
return null;
}
}
/// <summary>
/// Represents a device item returned from the ComputeAvailableDevices MSBuild target.
/// </summary>
public record DeviceItem(string Id, string? Description, string? Type, string? Status, string? RuntimeIdentifier);
/// <summary>
/// Computes available devices by calling the ComputeAvailableDevices MSBuild target if it exists.
/// </summary>
/// <param name="noRestore">Whether restore should be skipped before computing devices</param>
/// <param name="devices">List of available devices if the target exists, null otherwise</param>
/// <param name="restoreWasPerformed">True if restore was performed, false otherwise</param>
/// <returns>True if the target was found and executed, false otherwise</returns>
public bool TryComputeAvailableDevices(bool noRestore, out List<DeviceItem>? devices, out bool restoreWasPerformed)
{
devices = null;
restoreWasPerformed = false;
if (!OpenProjectIfNeeded(out var projectInstance))
{
// Invalid project file, return false
return false;
}
// Check if the ComputeAvailableDevices target exists
if (!projectInstance.Targets.ContainsKey(Constants.ComputeAvailableDevices))
{
return false;
}
// If restore is allowed, run restore first so device computation sees the restored assets
if (!noRestore)
{
// Run restore without TargetFramework to prevent it from cascading to
// dependency projects. See https://github.com/dotnet/sdk/issues/53488
var restoreResult = CreateRestoreProjectInstance().Build(
targets: ["Restore"],
loggers: GetLoggers(),
remoteLoggers: null,
out _);
if (!restoreResult)
{
return false;
}
restoreWasPerformed = true;
}
// Build the target
var buildResult = projectInstance.Build(
targets: [Constants.ComputeAvailableDevices],
loggers: GetLoggers(),
remoteLoggers: null,
out var targetOutputs);
if (!buildResult)
{
return false;
}
// Get the Devices items from the target output
if (!targetOutputs.TryGetValue(Constants.ComputeAvailableDevices, out var targetResult))
{
return false;
}
devices = new(targetResult.Items.Length);
foreach (var item in targetResult.Items)
{
devices.Add(new DeviceItem(
item.ItemSpec,
item.GetMetadata("Description"),
item.GetMetadata("Type"),
item.GetMetadata("Status"),
item.GetMetadata("RuntimeIdentifier")
));
}
return true;
}
/// <summary>
/// Attempts to select a device for running the application.
/// If devices are available and none was specified, prompts the user to select one (interactive mode)
/// or shows an error (non-interactive mode).
/// </summary>
/// <param name="listDevices">Whether to list devices and exit</param>
/// <param name="noRestore">Whether restore should be skipped</param>
/// <param name="selectedDevice">The selected device, or null if not needed</param>
/// <param name="runtimeIdentifier">The RuntimeIdentifier for the selected device, or null if not provided</param>
/// <param name="restoreWasPerformed">True if restore was performed, false otherwise</param>
/// <returns>True if we should continue, false if we should exit</returns>
public bool TrySelectDevice(
bool listDevices,
bool noRestore,
out string? selectedDevice,
out string? runtimeIdentifier,
out bool restoreWasPerformed)
{
selectedDevice = null;
runtimeIdentifier = null;
restoreWasPerformed = false;
// Try to get available devices from the project
bool targetExists = TryComputeAvailableDevices(noRestore, out var devices, out restoreWasPerformed);
// If the target doesn't exist, continue without device selection
if (!targetExists)
{
// No device support in this project
return true;
}
// Target exists - check if we have devices
if (devices is null || devices.Count == 0)
{
if (listDevices)
{
Reporter.Output.WriteLine(CliCommandStrings.RunCommandNoDevicesAvailable);
return true;
}
// Target exists but no devices available - this is an error
Reporter.Error.WriteLine(CliCommandStrings.RunCommandNoDevicesAvailable);
return false;
}
// If listing devices, display them and exit
if (listDevices)
{
Reporter.Output.WriteLine(CliCommandStrings.RunCommandAvailableDevices);
Reporter.Output.WriteLine();
for (int i = 0; i < devices.Count; i++)
{
var device = devices[i];
var displayBuilder = new StringBuilder($" {i + 1}. {device.Id}");
if (!string.IsNullOrWhiteSpace(device.Description))
{
displayBuilder.Append($" - {device.Description}");
}
if (!string.IsNullOrWhiteSpace(device.Type))
{
displayBuilder.Append($" ({device.Type}");
if (!string.IsNullOrWhiteSpace(device.Status))
{
displayBuilder.Append($", {device.Status}");
}
displayBuilder.Append(')');
}
else if (!string.IsNullOrWhiteSpace(device.Status))
{
displayBuilder.Append($" ({device.Status})");
}
Reporter.Output.WriteLine(displayBuilder.ToString());
}
Reporter.Output.WriteLine();
Reporter.Output.WriteLine($"{CliCommandStrings.RunCommandExampleText}: dotnet run --device {ArgumentEscaper.EscapeSingleArg(devices[0].Id)}");
Reporter.Output.WriteLine();
return true;
}
// If there's only one device, automatically select it (similar to single framework selection)
if (devices.Count == 1)
{
selectedDevice = devices[0].Id;
runtimeIdentifier = devices[0].RuntimeIdentifier;
return true;
}
if (_isInteractive)
{
var deviceItem = PromptForDevice(devices);
if (deviceItem is null)
{
return false;
}
selectedDevice = deviceItem.Id;
runtimeIdentifier = deviceItem.RuntimeIdentifier;
return true;
}
else
{
Reporter.Error.WriteLine(string.Format(CliCommandStrings.RunCommandExceptionUnableToRunSpecifyDevice, "--device"));
Reporter.Error.WriteLine();
Reporter.Error.WriteLine(CliCommandStrings.RunCommandAvailableDevices);
Reporter.Error.WriteLine();
for (int i = 0; i < devices.Count; i++)
{
var device = devices[i];
var displayText = $" {i + 1}. {device.Id}";
if (!string.IsNullOrWhiteSpace(device.Description))
{
displayText += $" - {device.Description}";
}
Reporter.Error.WriteLine(displayText);
}
Reporter.Error.WriteLine();
Reporter.Error.WriteLine($"{CliCommandStrings.RunCommandExampleText}: dotnet run --device {ArgumentEscaper.EscapeSingleArg(devices[0].Id)}");
Reporter.Error.WriteLine();
return false;
}
}
/// <summary>
/// Prompts the user to select a device from the available options using Spectre.Console.
/// </summary>
private static DeviceItem? PromptForDevice(List<DeviceItem> devices)
{
List<(string Display, DeviceItem Device)> choices = new(devices.Count);
foreach (var d in devices)
{
var display = d.Id;
if (!string.IsNullOrWhiteSpace(d.Description))
{
display += $" - {d.Description}";
}
choices.Add((display, d));
}
try
{
var prompt = new SelectionPrompt<(string Display, DeviceItem Device)>()
.Title($"{CyanMarkup}{Markup.Escape(CliCommandStrings.RunCommandSelectDevicePrompt)}{EndMarkup}")
.PageSize(10)
.MoreChoicesText($"{GrayMarkup}({Markup.Escape(CliCommandStrings.RunCommandMoreDevicesText)}){EndMarkup}")
.AddChoices(choices)
.UseConverter(choice => choice.Display)
.EnableSearch()
.SearchPlaceholderText(CliCommandStrings.RunCommandSearchPlaceholderText);
var (Display, Device) = Spectre.Console.AnsiConsole.Prompt(prompt);
return Device;
}
catch (Exception)
{
// If Spectre.Console fails (e.g., terminal doesn't support it), return null
return null;
}
}
/// <summary>
/// Attempts to deploy to a device by calling the DeployToDevice MSBuild target if it exists.
/// This reuses the already-loaded project instance for performance.
/// </summary>
/// <returns>True if deployment succeeded or was skipped (no target), false if deployment failed</returns>
public bool TryDeployToDevice()
{
if (!OpenProjectIfNeeded(out var projectInstance))
{
// Invalid project file
return false;
}
// Check if the DeployToDevice target exists in the project
if (!projectInstance.Targets.ContainsKey(Constants.DeployToDevice))
{
// Target doesn't exist, skip deploy step
return true;
}
// Add environment variables as items before building the target, only if opted in
if (HasRuntimeEnvironmentVariableSupport)
{
EnvironmentVariablesToMSBuild.AddAsItems(projectInstance, _environmentVariables);
}
// Build the DeployToDevice target
var buildResult = projectInstance.Build(
targets: [Constants.DeployToDevice],
loggers: GetLoggers(),
remoteLoggers: null,
out _);
return buildResult;
}
/// <summary>
/// Gets the list of loggers to use for MSBuild operations.
/// Creates a fresh console logger each time to avoid disposal issues when calling Build() multiple times.
/// </summary>
private IEnumerable<ILogger> GetLoggers()
{
if (_binaryLogger is not null)
yield return _binaryLogger;
yield return CommonRunHelpers.GetConsoleLogger(_msbuildArgs);
}
}
|