File: Evaluation\Preprocessor.cs
Web Access
Project: src\msbuild\src\Build\Microsoft.Build.csproj (Microsoft.Build)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Xml;
using Microsoft.Build.Construction;
using Microsoft.Build.Shared;

#nullable disable

namespace Microsoft.Build.Evaluation
{
    /// <summary>
    /// Creates a view of an evaluated project's XML as if it had all been loaded from
    /// a single file, instead of being assembled by pulling in imported files as it actually was.
    /// </summary>
    /// <remarks>
    /// Ideally the result would be buildable on its own, and *usually* this should be the case.
    /// Known cases where it wouldn't be buildable:
    /// -- $(MSBuildThisFile) and similar properties aren't corrected
    /// -- relative path in exists(..) conditions is relative to the imported file
    /// -- same for AssemblyFile on UsingTask
    /// Paths in item includes are relative to the importing project, though.
    /// </remarks>
    internal class Preprocessor
    {
        /// <summary>140 equal signs.</summary>
        private const string Equals140 = "============================================================================================================================================";

        /// <summary>
        /// Project to preprocess
        /// </summary>
        private readonly Project _project;

        /// <summary>
        /// Table to resolve import tags
        /// </summary>
        private readonly Dictionary<XmlElement, IList<ProjectRootElement>> _importTable;

        /// <summary>
        /// Stack of file paths pushed as we follow imports
        /// </summary>
        private readonly Stack<string> _filePaths = new Stack<string>();

        /// <summary>
        /// Used to keep track of nodes that were added to the document from implicit imports which will be removed later.
        /// At the time of adding this feature, cloning is buggy so it is easier to just edit the DOM in memory.
        /// </summary>
        private List<XmlNode> _addedNodes;

        /// <summary>
        /// Table of implicit imports by document.  The list per document contains both top and bottom imports.
        /// </summary>
        private readonly Dictionary<XmlDocument, List<ResolvedImport>> _implicitImportsByProject = new Dictionary<XmlDocument, List<ResolvedImport>>();

        /// <summary>
        /// Constructor
        /// </summary>
        private Preprocessor(Project project)
        {
            _project = project;

            IList<ResolvedImport> imports = project.Imports;

            _importTable = new Dictionary<XmlElement, IList<ProjectRootElement>>(imports.Count);

            foreach (ResolvedImport entry in imports)
            {
                AddToImportTable(entry.ImportingElement.XmlElement, entry.ImportedProject);
            }
        }

        /// <summary>
        /// Returns an XmlDocument representing the evaluated project's XML as if it all had
        /// been loaded from a single file, instead of being assembled by pulling in imported files.
        /// </summary>
        internal static XmlDocument GetPreprocessedDocument(Project project)
        {
            Preprocessor preprocessor = new Preprocessor(project);

            XmlDocument result = preprocessor.Preprocess();

            return result;
        }

        /// <summary>
        /// Root of the preprocessing.
        /// </summary>
        private XmlDocument Preprocess()
        {
            XmlDocument outerDocument = _project.Xml.XmlDocument;

            CreateImplicitImportTable();

            AddImplicitImportNodes(outerDocument.DocumentElement);

            XmlDocument destinationDocument = (XmlDocument)outerDocument.CloneNode(false /* shallow */);

            _filePaths.Push(_project.FullPath);

            if (!String.IsNullOrEmpty(_project.FullPath)) // Ignore in-memory projects
            {
                destinationDocument.AppendChild(destinationDocument.CreateComment($"\r\n{Equals140}\r\n{_project.FullPath.Replace("--", "__")}\r\n{Equals140}\r\n"));
            }

            CloneChildrenResolvingImports(outerDocument, destinationDocument);

            // Remove the nodes that were added as implicit imports
            //
            foreach (XmlNode node in _addedNodes)
            {
                node.ParentNode?.RemoveChild(node);
            }

            return destinationDocument;
        }

        private void AddToImportTable(XmlElement element, ProjectRootElement importedProject)
        {
            IList<ProjectRootElement> list;
            if (!_importTable.TryGetValue(element, out list))
            {
                list = new List<ProjectRootElement>();
                _importTable[element] = list;
            }

            list.Add(importedProject);
        }

        /// <summary>
        /// Creates a table containing implicit imports by project document.
        /// </summary>
        private void CreateImplicitImportTable()
        {
            int implicitImportCount = 0;

            // Loop through all implicit imports top and bottom
            foreach (ResolvedImport resolvedImport in _project.Imports.Where(i => i.ImportingElement.ImplicitImportLocation != ImplicitImportLocation.None))
            {
                implicitImportCount++;
                List<ResolvedImport> imports;

                // Attempt to get an existing list from the dictionary
                if (!_implicitImportsByProject.TryGetValue(resolvedImport.ImportingElement.XmlDocument, out imports))
                {
                    // Add a new list
                    _implicitImportsByProject[resolvedImport.ImportingElement.XmlDocument] = new List<ResolvedImport>();

                    // Get a pointer to the list
                    imports = _implicitImportsByProject[resolvedImport.ImportingElement.XmlDocument];
                }

                imports.Add(resolvedImport);
            }

            // Create a list to store nodes which will be added.  Optimization here is that we now know how many items are going to be added.
            _addedNodes = new List<XmlNode>(implicitImportCount);
        }


        /// <summary>
        /// Adds all implicit import nodes to the specified document.
        /// </summary>
        /// <param name="documentElement">The document element to add nodes to.</param>
        private void AddImplicitImportNodes(XmlElement documentElement)
        {
            List<ResolvedImport> implicitImports;

            // Do nothing if this project has no implicit imports
            if (!_implicitImportsByProject.TryGetValue(documentElement.OwnerDocument, out implicitImports))
            {
                return;
            }

            // Top implicit imports need to be added in the correct order by adding the first one at the top and each one after the first
            // one.  This variable keeps track of the last import that was added.
            XmlNode lastImplicitImportAdded = null;

            // Add the implicit top imports
            //
            foreach (ResolvedImport import in implicitImports.Where(i => i.ImportingElement.ImplicitImportLocation == ImplicitImportLocation.Top))
            {
                XmlElement xmlElement = (XmlElement)documentElement.OwnerDocument.ImportNode(import.ImportingElement.XmlElement, false);
                if (lastImplicitImportAdded == null)
                {
                    if (documentElement.FirstChild == null)
                    {
                        documentElement.AppendChild(xmlElement);
                    }
                    else
                    {
                        documentElement.InsertBefore(xmlElement, documentElement.FirstChild);
                    }

                    lastImplicitImportAdded = xmlElement;
                }
                else
                {
                    documentElement.InsertAfter(xmlElement, lastImplicitImportAdded);
                }
                _addedNodes.Add(xmlElement);
                AddToImportTable(xmlElement, import.ImportedProject);
            }

            // Add the implicit bottom imports
            //
            foreach (var import in implicitImports.Where(i => i.ImportingElement.ImplicitImportLocation == ImplicitImportLocation.Bottom))
            {
                XmlElement xmlElement = (XmlElement)documentElement.InsertAfter(documentElement.OwnerDocument.ImportNode(import.ImportingElement.XmlElement, false), documentElement.LastChild);

                _addedNodes.Add(xmlElement);

                AddToImportTable(xmlElement, import.ImportedProject);
            }
        }

        /// <summary>
        /// Recursively called method that clones source nodes into nodes in the destination
        /// document.
        /// </summary>
        private void CloneChildrenResolvingImports(XmlNode source, XmlNode destination)
        {
            XmlDocument sourceDocument = source.OwnerDocument ?? (XmlDocument)source;
            XmlDocument destinationDocument = destination.OwnerDocument ?? (XmlDocument)destination;

            foreach (XmlNode child in source.ChildNodes)
            {
                // Only one of <?xml version="1.0" encoding="utf-16"?> and we got it automatically already
                if (child.NodeType == XmlNodeType.XmlDeclaration)
                {
                    continue;
                }

                // If this is not the first <Project> tag
                if (
                    child.NodeType == XmlNodeType.Element &&
                    sourceDocument.DocumentElement == child &&                                      // This is the root element, not some random element named 'Project'
                    destinationDocument.DocumentElement != null &&                                  // Skip <Project> tag from the outer project
                    String.Equals(XMakeElements.project, child.Name, StringComparison.Ordinal))
                {
                    // But suffix any InitialTargets attribute
                    string outerInitialTargets = destinationDocument.DocumentElement.GetAttribute(XMakeAttributes.initialTargets).Trim();
                    string innerInitialTargets = ((XmlElement)child).GetAttribute(XMakeAttributes.initialTargets).Trim();

                    if (innerInitialTargets.Length > 0)
                    {
                        if (outerInitialTargets.Length > 0)
                        {
                            outerInitialTargets += ";";
                        }

                        destinationDocument.DocumentElement.SetAttribute(XMakeAttributes.initialTargets, outerInitialTargets + innerInitialTargets);
                    }

                    // Also gather any DefaultTargets value if none has been encountered already; put it on the outer <Project> tag
                    string outerDefaultTargets = destinationDocument.DocumentElement.GetAttribute(XMakeAttributes.defaultTargets).Trim();

                    if (outerDefaultTargets.Length == 0)
                    {
                        string innerDefaultTargets = ((XmlElement)child).GetAttribute(XMakeAttributes.defaultTargets).Trim();

                        if (innerDefaultTargets.Trim().Length > 0)
                        {
                            destinationDocument.DocumentElement.SetAttribute(XMakeAttributes.defaultTargets, innerDefaultTargets);
                        }
                    }

                    // Add any implicit imports for an imported document
                    AddImplicitImportNodes(child.OwnerDocument.DocumentElement);

                    CloneChildrenResolvingImports(child, destination);
                    continue;
                }

                // Resolve <Import> to 0-n documents and walk into them
                if (child.NodeType == XmlNodeType.Element && String.Equals(XMakeElements.import, child.Name, StringComparison.Ordinal))
                {
                    // To display what the <Import> tag looked like
                    string importCondition = ((XmlElement)child).GetAttribute(XMakeAttributes.condition);
                    string condition = importCondition.Length > 0 ? $" Condition=\"{importCondition}\"" : String.Empty;
                    string importProject = ((XmlElement)child).GetAttribute(XMakeAttributes.project).Replace("--", "__");
                    string importSdk = ((XmlElement)child).GetAttribute(XMakeAttributes.sdk);
                    string sdk = importSdk.Length > 0 ? $" {XMakeAttributes.sdk}=\"{importSdk}\"" : String.Empty;

                    // Get the Sdk attribute of the Project element if specified
                    string projectSdk = source.NodeType == XmlNodeType.Element && String.Equals(XMakeElements.project, source.Name, StringComparison.Ordinal) ? ((XmlElement)source).GetAttribute(XMakeAttributes.sdk) : String.Empty;

                    IList<ProjectRootElement> resolvedList;
                    if (!_importTable.TryGetValue((XmlElement)child, out resolvedList))
                    {
                        // Import didn't resolve to anything; just display as a comment and move on
                        string closedImportTag =
                            $"<Import Project=\"{importProject}\"{sdk}{condition} />";
                        destination.AppendChild(destinationDocument.CreateComment(closedImportTag));

                        continue;
                    }

                    for (int i = 0; i < resolvedList.Count; i++)
                    {
                        ProjectRootElement resolved = resolvedList[i];
                        XmlDocument innerDocument = resolved.XmlDocument;

                        string importTag =
                            $"  <Import Project=\"{importProject}\"{sdk}{condition}>";

                        if (!String.IsNullOrWhiteSpace(importSdk) && projectSdk.IndexOf(importSdk, StringComparison.OrdinalIgnoreCase) >= 0)
                        {
                            importTag +=
                                $"\r\n  This import was added implicitly because the {XMakeElements.project} element's {XMakeAttributes.sdk} attribute specified \"{importSdk}\".";
                        }

                        destination.AppendChild(destinationDocument.CreateComment(
                            $"\r\n{Equals140}\r\n{importTag}\r\n\r\n{resolved.FullPath.Replace("--", "__")}\r\n{Equals140}\r\n"));

                        _filePaths.Push(resolved.FullPath);
                        CloneChildrenResolvingImports(innerDocument, destination);
                        _filePaths.Pop();

                        if (i < resolvedList.Count - 1)
                        {
                            destination.AppendChild(destinationDocument.CreateComment($"\r\n{Equals140}\r\n  </Import>\r\n{Equals140}\r\n"));
                        }
                        else
                        {
                            destination.AppendChild(destinationDocument.CreateComment($"\r\n{Equals140}\r\n  </Import>\r\n\r\n{_filePaths.Peek()?.Replace("--", "__")}\r\n{Equals140}\r\n"));
                        }
                    }

                    continue;
                }

                // Skip over <ImportGroup> into its children
                if (child.NodeType == XmlNodeType.Element && String.Equals(XMakeElements.importGroup, child.Name, StringComparison.Ordinal))
                {
                    // To display what the <ImportGroup> tag looked like
                    string importGroupCondition = ((XmlElement)child).GetAttribute(XMakeAttributes.condition);
                    string importGroupTag = "<ImportGroup" + ((importGroupCondition.Length > 0) ? " Condition=\"" + importGroupCondition + "\"" : String.Empty) + ">";
                    destination.AppendChild(destinationDocument.CreateComment(importGroupTag));

                    CloneChildrenResolvingImports(child, destination);

                    destination.AppendChild(destinationDocument.CreateComment($"</{XMakeElements.importGroup}>"));

                    continue;
                }

                // Node doesn't need special treatment, clone and append
                XmlNode clone = destinationDocument.ImportNode(child, false /* shallow */); // ImportNode does a clone but unlike CloneNode it works across XmlDocuments

                if (clone.NodeType == XmlNodeType.Element && String.Equals(XMakeElements.project, child.Name, StringComparison.Ordinal) && clone.Attributes?[XMakeAttributes.sdk] != null)
                {
                    clone.Attributes.Remove(clone.Attributes[XMakeAttributes.sdk]);
                }

                destination.AppendChild(clone);

                CloneChildrenResolvingImports(child, clone);
            }
        }
    }
}