File: CreateTrimDependencyGroups.cs
Web Access
Project: Microsoft.DotNet.Build.Tasks.Packaging.csproj (Microsoft.DotNet.Build.Tasks.Packaging)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using NuGet.Frameworks;
using NuGet.Packaging;
using NuGet.Packaging.Core;
using NuGet.Versioning;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace Microsoft.DotNet.Build.Tasks.Packaging;

public class CreateTrimDependencyGroups : Task
{
    private const string PlaceHolderDependency = "_._";

    [Required]
    public ITaskItem[] Dependencies
    {
        get;
        set;
    }

    [Required]
    public ITaskItem[] Files
    {
        get;
        set;
    }

    /// <summary>
    /// Package index files used to define stable package list.
    /// </summary>
    [Required]
    public ITaskItem[] PackageIndexes
    {
        get;
        set;
    }


    [Output]
    public ITaskItem[] TrimmedDependencies
    {
        get;
        set;
    }

    /* Given a set of available frameworks ("InboxOnTargetFrameworks"), and a list of desired frameworks,
    reduce the set of frameworks to the minimum set of frameworks which is compatible (preferring inbox frameworks. */
    public override bool Execute()
    {
        if (Dependencies == null)
        {
            Log.LogError("Dependencies argument must be specified");
            return false;
        }
        if (PackageIndexes == null || PackageIndexes.Length == 0)
        {
            Log.LogError("PackageIndexes argument must be specified");
            return false;
        }

        var index = PackageIndex.Load(PackageIndexes.Select(pi => pi.GetMetadata("FullPath")));

        // Retrieve the list of dependency group TFM's
        var dependencyGroups = Dependencies
            .Select(dependencyItem => new TaskItemPackageDependency(dependencyItem))
            .GroupBy(dependency => dependency.TargetFramework)
            .Select(dependencyGrouping => new TaskItemPackageDependencyGroup(dependencyGrouping.Key, dependencyGrouping))
            .ToArray();

        // Prepare a resolver for evaluating if candidate frameworks are actually supported by the package
        PackageItem[] packageItems = Files.Select(f => new PackageItem(f)).ToArray();
        var packagePaths = packageItems.Select(pi => pi.TargetPath);
        NuGetAssetResolver resolver = new NuGetAssetResolver(null, packagePaths);

        // Determine all inbox frameworks which are supported by this package
        var supportedInboxFrameworks = index.GetAlllInboxFrameworks().Where(fx => IsSupported(fx, resolver));

        var newDependencyGroups = new Queue<TaskItemPackageDependencyGroup>();
        // For each inbox framework determine its best compatible dependency group and create an explicit group, trimming out any inbox dependencies
        foreach(var supportedInboxFramework in supportedInboxFrameworks)
        {
            var nearestDependencyGroup = dependencyGroups.GetNearest(supportedInboxFramework);

            // We found a compatible dependency group that is not the same as this framework
            if (nearestDependencyGroup != null  && nearestDependencyGroup.TargetFramework != supportedInboxFramework)
            {
                // remove all dependencies which are inbox on supportedInboxFramework
                var filteredDependencies = nearestDependencyGroup.Packages.Where(d => !index.IsInbox(d.Id, supportedInboxFramework, d.AssemblyVersion)).ToArray();
                
                newDependencyGroups.Enqueue(new TaskItemPackageDependencyGroup(supportedInboxFramework, filteredDependencies));
            }
        }

        // Remove any redundant groups from the added set (EG: net45 and net46 with the same set of dependencies)
        int groupsToCheck = newDependencyGroups.Count;
        for(int i = 0; i < groupsToCheck; i++)
        {
            // to determine if this is a redundant group, we dequeue so that it won't be considered in the following check for nearest group.
            var group = newDependencyGroups.Dequeue();

            // of the remaining groups, find the most compatible one
            var nearestGroup = newDependencyGroups.Concat(dependencyGroups).GetNearest(group.TargetFramework);

            // either we found no compatible group, 
            // or the closest compatible group has different dependencies, 
            // or the closest compatible group is portable and this is not (Portable profiles have different framework precedence, https://github.com/NuGet/Home/issues/6483),
            // keep it in the set of additions
            if (nearestGroup == null || 
                !group.Packages.SetEquals(nearestGroup.Packages) || 
                FrameworkUtilities.IsPortableMoniker(group.TargetFramework) != FrameworkUtilities.IsPortableMoniker(nearestGroup.TargetFramework))
            {
                // not redundant, keep it in the queue
                newDependencyGroups.Enqueue(group);
            }
        }

        // Build the items representing added dependency groups.
        List<ITaskItem> trimmedDependencies = new List<ITaskItem>();
        foreach (var newDependencyGroup in newDependencyGroups)
        {
            if (newDependencyGroup.Packages.Count == 0)
            {
                // no dependencies (all inbox), use a placeholder dependency.
                var item = new TaskItem(PlaceHolderDependency);
                item.SetMetadata("TargetFramework", newDependencyGroup.TargetFramework.GetShortFolderName());
                trimmedDependencies.Add(item);
            }
            else
            {
                foreach(var dependency in newDependencyGroup.Packages)
                {
                    var item = new TaskItem(dependency.Item);
                    // emit CopiedFromTargetFramework to aide in debugging.
                    item.SetMetadata("CopiedFromTargetFramework", item.GetMetadata("TargetFramework"));
                    item.SetMetadata("TargetFramework", newDependencyGroup.TargetFramework.GetShortFolderName());
                    trimmedDependencies.Add(item);
                }
            }
        }
        TrimmedDependencies = trimmedDependencies.ToArray();
        return !Log.HasLoggedErrors;
    }

    private bool IsSupported(NuGetFramework inboxFx, NuGetAssetResolver resolver)
    {
        var compileAssets = resolver.ResolveCompileAssets(inboxFx);

        // We assume that packages will only support inbox frameworks with lib/tfm assets and not runtime specific assets.
        // This effectively means we'll never reduce dependencies if a package happens to support an inbox framework with
        // a RID asset, but that is OK because RID assets can only be used by nuget3 + project.json
        // and we don't care about reducing dependencies for project.json because indirect dependencies are hidden.
        var runtimeAssets = resolver.ResolveRuntimeAssets(inboxFx, null);

        foreach (var compileAsset in compileAssets.Where(c => !NuGetAssetResolver.IsPlaceholder(c)))
        {
            string fileName = Path.GetFileName(compileAsset);

            if (!runtimeAssets.Any(r => Path.GetFileName(r).Equals(fileName, StringComparison.OrdinalIgnoreCase)))
            {
                // ref with no matching lib
                return false;
            }
        }

        // Either all compile assets had matching runtime assets, or all were placeholders, make sure we have at
        // least one runtime asset to cover the placeholder case
        return runtimeAssets.Any();
    }

    /// <summary>
    /// Similar to NuGet.Packaging.Core.PackageDependency but also allows for flowing the original ITaskItem.
    /// </summary>
    class TaskItemPackageDependency : PackageDependency
    {
        public TaskItemPackageDependency(ITaskItem item) : base(item.ItemSpec, TryParseVersionRange(item.GetMetadata("Version")))
        {
            Item = item;
            TargetFramework = NuGetFramework.Parse(item.GetMetadata(nameof(TargetFramework)));
            AssemblyVersion = GetAssemblyVersion(item);
        }

        private static VersionRange TryParseVersionRange(string versionString)
        {
            VersionRange value;

            return VersionRange.TryParse(versionString, out value) ? value : null;
        }

        private static Version GetAssemblyVersion(ITaskItem dependency)
        {
            // If we don't have the AssemblyVersion metadata (4 part version string), fall back and use Version (3 part version string)
            string versionString = dependency.GetMetadata("AssemblyVersion");
            if (string.IsNullOrEmpty(versionString))
            {
                versionString = dependency.GetMetadata("Version");
            }

            return FrameworkUtilities.Ensure4PartVersion(versionString);
        }

        public ITaskItem Item { get; }
        public NuGetFramework TargetFramework { get; }
        public Version AssemblyVersion { get; }
    }

    /// <summary>
    /// An IFrameworkSpecific type that can be used with FrameworkUtilties.GetNearest.
    /// This differs from NuGet.Packaging.PackageDependencyGroup in that it exposes the package dependencies as an ISet which can
    /// undergo an unordered comparison with another ISet.
    /// </summary>
    class TaskItemPackageDependencyGroup : IFrameworkSpecific
    {
        public TaskItemPackageDependencyGroup(NuGetFramework targetFramework, IEnumerable<TaskItemPackageDependency> packages)
        {
            TargetFramework = targetFramework;
            Packages = new HashSet<TaskItemPackageDependency>(packages.Where(d => d.Id != PlaceHolderDependency));
        }

        public NuGetFramework TargetFramework { get; }

        public ISet<TaskItemPackageDependency> Packages { get; }

    }
}