File: CommandFactory\CommandResolution\ProjectToolsCommandResolver.cs
Web Access
Project: ..\..\..\src\Cli\dotnet\dotnet.csproj (dotnet)
// 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.CodeAnalysis;
using Microsoft.DotNet.Cli.Commands.Build;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;
using NuGet.Frameworks;
using NuGet.ProjectModel;
using NuGet.Versioning;
using ConcurrencyUtilities = NuGet.Common.ConcurrencyUtilities;
 
namespace Microsoft.DotNet.Cli.CommandFactory.CommandResolution;
 
public class ProjectToolsCommandResolver(
    IPackagedCommandSpecFactory packagedCommandSpecFactory,
    IEnvironmentProvider environment) : ICommandResolver
{
    private const string ProjectToolsCommandResolverName = "projecttoolscommandresolver";
 
    private readonly List<string> _allowedCommandExtensions = [FileNameSuffixes.DotNet.DynamicLib];
    private readonly IPackagedCommandSpecFactory _packagedCommandSpecFactory = packagedCommandSpecFactory;
 
    private readonly IEnvironmentProvider _environment = environment;
 
    public CommandSpec? Resolve(CommandResolverArguments commandResolverArguments)
    {
        if (commandResolverArguments.CommandName == null
            || commandResolverArguments.ProjectDirectory == null)
        {
            Reporter.Verbose.WriteLine(string.Format(
                CliStrings.InvalidCommandResolverArguments,
                ProjectToolsCommandResolverName));
 
            return null;
        }
 
        return ResolveFromProjectTools(commandResolverArguments);
    }
 
    private CommandSpec? ResolveFromProjectTools(CommandResolverArguments commandResolverArguments)
    {
        var projectFactory = new ProjectFactory(_environment);
 
        var project = projectFactory.GetProject(
            commandResolverArguments.ProjectDirectory,
            commandResolverArguments.Framework,
            commandResolverArguments.Configuration,
            commandResolverArguments.BuildBasePath,
            commandResolverArguments.OutputPath);
 
        if (project == null)
        {
            Reporter.Verbose.WriteLine(string.Format(
                CliStrings.DidNotFindProject, ProjectToolsCommandResolverName));
 
            return null;
        }
 
        var tools = project.GetTools();
 
        return ResolveCommandSpecFromAllToolLibraries(
            tools,
            commandResolverArguments.CommandName,
            commandResolverArguments.CommandArguments.OrEmptyIfNull(),
            project);
    }
 
    private CommandSpec? ResolveCommandSpecFromAllToolLibraries(
        IEnumerable<SingleProjectInfo> toolsLibraries,
        string commandName,
        IEnumerable<string> args,
        IProject project)
    {
        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.ResolvingCommandSpec,
            ProjectToolsCommandResolverName,
            toolsLibraries.Count()));
 
        foreach (var toolLibrary in toolsLibraries)
        {
            var commandSpec = ResolveCommandSpecFromToolLibrary(
                toolLibrary,
                commandName,
                args,
                project);
 
            if (commandSpec != null)
            {
                return commandSpec;
            }
        }
 
        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.FailedToResolveCommandSpec,
            ProjectToolsCommandResolverName));
 
        return null;
    }
 
    private CommandSpec? ResolveCommandSpecFromToolLibrary(
        SingleProjectInfo toolLibraryRange,
        string commandName,
        IEnumerable<string> args,
        IProject project)
    {
        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.AttemptingToResolveCommandSpec,
            ProjectToolsCommandResolverName,
            toolLibraryRange.Name));
 
        var possiblePackageRoots = GetPossiblePackageRoots(project).ToList();
        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.NuGetPackagesRoot,
            ProjectToolsCommandResolverName,
            string.Join(Environment.NewLine, possiblePackageRoots.Select((p) => $"- {p}"))));
 
        List<NuGetFramework> toolFrameworksToCheck = [project.DotnetCliToolTargetFramework];
 
        //  NuGet restore in Visual Studio may restore for netcoreapp1.0. So if that happens, fall back to
        //  looking for a netcoreapp1.0 or netcoreapp1.1 tool restore.
        if (project.DotnetCliToolTargetFramework.Framework == FrameworkConstants.FrameworkIdentifiers.NetCoreApp &&
            project.DotnetCliToolTargetFramework.Version >= new Version(2, 0, 0))
        {
            toolFrameworksToCheck.Add(NuGetFramework.Parse("netcoreapp1.1"));
            toolFrameworksToCheck.Add(NuGetFramework.Parse("netcoreapp1.0"));
        }
 
        LockFile? toolLockFile = null;
        NuGetFramework? toolTargetFramework = null;
 
        foreach (var toolFramework in toolFrameworksToCheck)
        {
            toolLockFile = GetToolLockFile(
                toolLibraryRange,
                toolFramework,
                possiblePackageRoots);
 
            if (toolLockFile != null)
            {
                toolTargetFramework = toolFramework;
                break;
            }
        }
 
        if (toolLockFile == null)
        {
            return null;
        }
 
        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.FoundToolLockFile,
            ProjectToolsCommandResolverName,
            toolLockFile.Path));
 
        var toolLibrary = toolLockFile.Targets
            .FirstOrDefault(t => toolTargetFramework == t.TargetFramework)
            ?.Libraries.FirstOrDefault(
                l => StringComparer.OrdinalIgnoreCase.Equals(l.Name, toolLibraryRange.Name));
        if (toolLibrary == null)
        {
            Reporter.Verbose.WriteLine(string.Format(
                CliStrings.LibraryNotFoundInLockFile,
                ProjectToolsCommandResolverName));
 
            return null;
        }
 
        var depsFileRoot = Path.GetDirectoryName(toolLockFile.Path)!;
 
        var depsFilePath = GetToolDepsFilePath(
            toolLibraryRange,
            toolTargetFramework!, // should be safe now because we found the toolLockFile
            toolLockFile,
            depsFileRoot,
            project.ToolDepsJsonGeneratorProject);
 
        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.AttemptingToCreateCommandSpec,
            ProjectToolsCommandResolverName));
 
        var commandSpec = _packagedCommandSpecFactory.CreateCommandSpecFromLibrary(
                toolLibrary,
                commandName,
                args,
                _allowedCommandExtensions,
                toolLockFile,
                depsFilePath,
                null);
 
        if (commandSpec == null)
        {
            Reporter.Verbose.WriteLine(string.Format(
                CliStrings.CommandSpecIsNull,
                ProjectToolsCommandResolverName));
        }
 
        commandSpec?.AddEnvironmentVariablesFromProject(project);
 
        return commandSpec;
    }
 
    private static IEnumerable<string> GetPossiblePackageRoots(IProject project)
    {
        if (project.TryGetLockFile(out LockFile lockFile))
        {
            return lockFile.PackageFolders.Select((packageFolder) => packageFolder.Path);
        }
 
        return [];
    }
 
    private LockFile? GetToolLockFile(
        SingleProjectInfo toolLibrary,
        NuGetFramework framework,
        IEnumerable<string> possibleNugetPackagesRoot)
    {
        foreach (var packagesRoot in possibleNugetPackagesRoot)
        {
            if (TryGetToolLockFile(toolLibrary, framework, packagesRoot, out var lockFile))
            {
                return lockFile;
            }
        }
 
        return null;
    }
 
 
    private static async Task<bool> FileExistsWithLock(string path)
    {
        return await ConcurrencyUtilities.ExecuteWithFileLockedAsync(
            path,
            lockedToken => Task.FromResult(File.Exists(path)),
            CancellationToken.None);
    }
 
    private bool TryGetToolLockFile(
        SingleProjectInfo toolLibrary,
        NuGetFramework framework,
        string nugetPackagesRoot,
        [NotNullWhen(true)] out LockFile? lockFile)
    {
        lockFile = null;
        var lockFilePath = GetToolLockFilePath(toolLibrary, framework, nugetPackagesRoot);
 
        if (!FileExistsWithLock(lockFilePath).Result)
        {
            return false;
        }
 
        try
        {
            lockFile = new LockFileFormat()
                .ReadWithLock(lockFilePath)
                .Result;
        }
        catch (FileFormatException)
        {
            throw;
        }
 
        return true;
    }
 
    private static string GetToolLockFilePath(
        SingleProjectInfo toolLibrary,
        NuGetFramework framework,
        string nugetPackagesRoot)
    {
        var toolPathCalculator = new ToolPathCalculator(nugetPackagesRoot);
 
        return toolPathCalculator.GetBestLockFilePath(
            toolLibrary.Name,
            VersionRange.Parse(toolLibrary.Version),
            framework);
    }
 
    private string GetToolDepsFilePath(
        SingleProjectInfo toolLibrary,
        NuGetFramework framework,
        LockFile toolLockFile,
        string depsPathRoot,
        string toolDepsJsonGeneratorProject)
    {
        var depsJsonPath = Path.Combine(
            depsPathRoot,
            toolLibrary.Name + FileNameSuffixes.DepsJson);
 
        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.ExpectDepsJsonAt,
            ProjectToolsCommandResolverName,
            depsJsonPath));
 
        EnsureToolJsonDepsFileExists(toolLockFile, framework, depsJsonPath, toolLibrary, toolDepsJsonGeneratorProject);
 
        return depsJsonPath;
    }
 
    private void EnsureToolJsonDepsFileExists(
        LockFile toolLockFile,
        NuGetFramework framework,
        string depsPath,
        SingleProjectInfo toolLibrary,
        string toolDepsJsonGeneratorProject)
    {
        if (!File.Exists(depsPath))
        {
            GenerateDepsJsonFile(toolLockFile, framework, depsPath, toolLibrary, toolDepsJsonGeneratorProject);
        }
    }
 
    internal void GenerateDepsJsonFile(
        LockFile toolLockFile,
        NuGetFramework framework,
        string depsPath,
        SingleProjectInfo toolLibrary,
        string toolDepsJsonGeneratorProject)
    {
        if (string.IsNullOrEmpty(toolDepsJsonGeneratorProject) ||
            !File.Exists(toolDepsJsonGeneratorProject))
        {
            throw new GracefulException(CliStrings.DepsJsonGeneratorProjectNotSet);
        }
 
        Reporter.Verbose.WriteLine(string.Format(
            CliStrings.GeneratingDepsJson,
            depsPath));
 
        var tempDepsFile = Path.Combine(PathUtilities.CreateTempSubdirectory(), Path.GetRandomFileName());
 
        List<string> args =
        [
            toolDepsJsonGeneratorProject,
            $"-property:ProjectAssetsFile=\"{toolLockFile.Path}\"",
            $"-property:ToolName={toolLibrary.Name}",
            $"-property:ProjectDepsFilePath={tempDepsFile}"
        ];
 
        var toolTargetFramework = toolLockFile.Targets.First().TargetFramework.GetShortFolderName();
        args.Add($"-property:TargetFramework={toolTargetFramework}");
 
 
        //  Look for the .props file in the Microsoft.NETCore.App package, until NuGet
        //  generates .props and .targets files for tool restores (https://github.com/NuGet/Home/issues/5037)
        var platformLibrary = toolLockFile.Targets
            .FirstOrDefault(t => framework == t.TargetFramework)
            ?.GetPlatformLibrary();
 
        if (platformLibrary != null)
        {
            string? buildRelativePath = platformLibrary.Build.FirstOrDefault()?.Path;
 
            var platformLibraryPath = toolLockFile.GetPackageDirectory(platformLibrary);
 
            if (platformLibraryPath != null && buildRelativePath != null)
            {
                //  Get rid of "_._" filename
                buildRelativePath = Path.GetDirectoryName(buildRelativePath)!;
 
                string platformLibraryBuildFolderPath = Path.Combine(platformLibraryPath, buildRelativePath);
                var platformLibraryPropsFile = Directory.GetFiles(platformLibraryBuildFolderPath, "*.props").FirstOrDefault();
 
                if (platformLibraryPropsFile != null)
                {
                    args.Add($"-property:AdditionalImport={platformLibraryPropsFile}");
                }
            }
        }
 
        //  Delete temporary file created by Path.GetTempFileName(), otherwise the GenerateBuildDependencyFile target
        //  will think the deps file is up-to-date and skip executing
        File.Delete(tempDepsFile);
 
        var msBuildExePath = _environment.GetEnvironmentVariable(Constants.MSBUILD_EXE_PATH);
 
        msBuildExePath = string.IsNullOrEmpty(msBuildExePath) ?
            Path.Combine(AppContext.BaseDirectory, "MSBuild.dll") :
            msBuildExePath;
 
        Reporter.Verbose.WriteLine(string.Format(CliStrings.MSBuildArgs,
            ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(args)));
 
        int result;
        string? stdOut;
        string? stdErr;
 
        var msbuildArgs = MSBuildArgs.AnalyzeMSBuildArguments([..args], CommonOptions.PropertiesOption, CommonOptions.RestorePropertiesOption, BuildCommandParser.TargetOption, BuildCommandParser.VerbosityOption);
        var forwardingAppWithoutLogging = new MSBuildForwardingAppWithoutLogging(msbuildArgs, msBuildExePath);
        if (forwardingAppWithoutLogging.ExecuteMSBuildOutOfProc)
        {
            result = forwardingAppWithoutLogging
                .GetProcessStartInfo()
                .ExecuteAndCaptureOutput(out stdOut, out stdErr);
        }
        else
        {
            // Execute and capture output of MSBuild running in-process.
            var outWriter = new StringWriter();
            var errWriter = new StringWriter();
            var savedOutWriter = Console.Out;
            var savedErrWriter = Console.Error;
            try
            {
                Console.SetOut(outWriter);
                Console.SetError(errWriter);
 
                result = forwardingAppWithoutLogging.Execute();
 
                stdOut = outWriter.ToString();
                stdErr = errWriter.ToString();
            }
            finally
            {
                Console.SetOut(savedOutWriter);
                Console.SetError(savedErrWriter);
            }
        }
 
        if (result != 0)
        {
            Reporter.Verbose.WriteLine(string.Format(
                CliStrings.UnableToGenerateDepsJson,
                stdOut + Environment.NewLine + stdErr));
 
            throw new GracefulException(string.Format(CliStrings.UnableToGenerateDepsJson, toolDepsJsonGeneratorProject));
        }
 
        try
        {
            File.Move(tempDepsFile, depsPath);
        }
        catch (Exception e)
        {
            Reporter.Verbose.WriteLine(string.Format(
                CliStrings.UnableToGenerateDepsJson,
                e.Message));
 
            try
            {
                File.Delete(tempDepsFile);
            }
            catch (Exception e2)
            {
                Reporter.Verbose.WriteLine(string.Format(
                    CliStrings.UnableToDeleteTemporaryDepsJson,
                    e2.Message));
            }
        }
    }
}