File: FileModifiers\XmlTransformer.cs
Web Access
Project: src\src\nuget-client\src\NuGet.Core\NuGet.PackageManagement\NuGet.PackageManagement.csproj (NuGet.PackageManagement)
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

#nullable disable

using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using NuGet.Common;

namespace NuGet.ProjectManagement
{
    /// <summary>
    /// An XML project file transformer.
    /// </summary>
    public class XmlTransformer : IPackageFileTransformer
    {
        private readonly IDictionary<XName, Action<XElement, XElement>> _nodeActions;

        /// <summary>
        /// Initializes a new <see cref="XmlTransformer" /> class.
        /// </summary>
        /// <param name="nodeActions">A dictionary of XML node names to node actions.</param>
        /// <exception cref="ArgumentNullException">Thrown if <paramref name="nodeActions" />
        /// is <see langword="null" />.</exception>
        public XmlTransformer(IDictionary<XName, Action<XElement, XElement>> nodeActions)
        {
            if (nodeActions == null)
            {
                throw new ArgumentNullException(nameof(nodeActions));
            }

            _nodeActions = nodeActions;
        }

        /// <summary>
        /// Asynchronously transforms a file.
        /// </summary>
        /// <param name="streamTaskFactory">A stream task factory.</param>
        /// <param name="targetPath">A path to the file to be transformed.</param>
        /// <param name="projectSystem">The project where this change is taking place.</param>
        /// <param name="cancellationToken">A cancellation token.</param>
        /// <returns>A task that represents the asynchronous operation.</returns>
        /// <exception cref="ArgumentNullException">Thrown if <paramref name="streamTaskFactory" />
        /// is <see langword="null" />.</exception>
        /// <exception cref="ArgumentNullException">Thrown if <paramref name="projectSystem" />
        /// is <see langword="null" />.</exception>
        /// <exception cref="OperationCanceledException">Thrown if <paramref name="cancellationToken" />
        /// is cancelled.</exception>
        public async Task TransformFileAsync(
            Func<Task<Stream>> streamTaskFactory,
            string targetPath,
            IMSBuildProjectSystem projectSystem,
            CancellationToken cancellationToken)
        {
            if (streamTaskFactory == null)
            {
                throw new ArgumentNullException(nameof(streamTaskFactory));
            }

            if (projectSystem == null)
            {
                throw new ArgumentNullException(nameof(projectSystem));
            }

            cancellationToken.ThrowIfCancellationRequested();

            // Get the xml fragment
            var xmlFragment = await GetXmlAsync(streamTaskFactory, projectSystem, cancellationToken);

            var transformDocument = MSBuildNuGetProjectSystemUtility.GetOrCreateDocument(xmlFragment.Name, targetPath, projectSystem);

            // Do a merge
            transformDocument.Root.MergeWith(xmlFragment, _nodeActions);

            MSBuildNuGetProjectSystemUtility.AddFile(projectSystem, targetPath, transformDocument.Save);
        }

        /// <summary>
        /// Asynchronously reverses the transform on the targetPath, using all the potential source of change.
        /// </summary>
        /// <param name="streamTaskFactory">A factory for accessing the file to be reverted from the nupkg being uninstalled.</param>
        /// <param name="targetPath">A path to the file to be reverted.</param>
        /// <param name="matchingFiles">Other files in other packages that may have changed the <paramref name="targetPath" />.</param>
        /// <param name="projectSystem">The project where this change is taking place.</param>
        /// <param name="cancellationToken">A cancellation token.</param>
        /// <returns>A task that represents the asynchronous operation.</returns>
        /// <exception cref="ArgumentNullException">Thrown if <paramref name="streamTaskFactory" />
        /// is <see langword="null" />.</exception>
        /// <exception cref="ArgumentNullException">Thrown if <paramref name="projectSystem" />
        /// is <see langword="null" />.</exception>
        /// <exception cref="OperationCanceledException">Thrown if <paramref name="cancellationToken" />
        /// is cancelled.</exception>
        public async Task RevertFileAsync(
            Func<Task<Stream>> streamTaskFactory,
            string targetPath,
            IEnumerable<InternalZipFileInfo> matchingFiles,
            IMSBuildProjectSystem projectSystem,
            CancellationToken cancellationToken)
        {
            if (streamTaskFactory == null)
            {
                throw new ArgumentNullException(nameof(streamTaskFactory));
            }

            if (projectSystem == null)
            {
                throw new ArgumentNullException(nameof(projectSystem));
            }

            cancellationToken.ThrowIfCancellationRequested();

            // Get the xml snippet
            var xmlFragment = await GetXmlAsync(streamTaskFactory, projectSystem, cancellationToken);

            var document = XmlUtility.GetOrCreateDocument(xmlFragment.Name,
                projectSystem.ProjectFullPath,
                targetPath,
                projectSystem.NuGetProjectContext);

            // Merge the other xml elements into one element within this xml hierarchy (matching the config file path)
            var elements = new List<XElement>();

            foreach (var matchingFile in matchingFiles)
            {
                elements.Add(await GetXmlAsync(matchingFile, projectSystem, cancellationToken));
            }

            var mergedFragments = elements.Aggregate(
                new XElement(xmlFragment.Name),
                (left, right) => left.MergeWith(right, _nodeActions));

            // Take the difference of the xml and remove it from the main xml file
            document.Root.Except(xmlFragment.Except(mergedFragments));

            // Save the new content to the file system
            using (var fileStream = FileSystemUtility.CreateFile(
                projectSystem.ProjectFullPath,
                targetPath,
                projectSystem.NuGetProjectContext))
            {
                document.Save(fileStream);
            }
        }

        private static async Task<XElement> GetXmlAsync(
            InternalZipFileInfo packageFileInfo,
            IMSBuildProjectSystem projectSystem,
            CancellationToken cancellationToken)
        {
            string content;

            using var packageStream = File.OpenRead(packageFileInfo.ZipArchivePath);
            using var zipArchive = new ZipArchive(packageStream);

            var zipArchivePackageEntry = PathUtility.GetEntry(zipArchive, packageFileInfo.ZipArchiveEntryFullName);

            if (zipArchivePackageEntry == null)
            {
                throw new ArgumentException("internalZipFileInfo");
            }

            content = await Preprocessor.ProcessAsync(
                () => Task.FromResult(zipArchivePackageEntry.Open()),
                projectSystem,
                cancellationToken);

            return XElement.Parse(content, LoadOptions.PreserveWhitespace);
        }

        private static async Task<XElement> GetXmlAsync(
            Func<Task<Stream>> streamTaskFactory,
            IMSBuildProjectSystem projectSystem,
            CancellationToken cancellationToken)
        {
            var content = await Preprocessor.ProcessAsync(streamTaskFactory, projectSystem, cancellationToken);

            return XElement.Parse(content, LoadOptions.PreserveWhitespace);
        }
    }
}