using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Xml; namespace Tools.DotNETCommon { /// /// Basic information from a preprocessed C# project file. Supports reading a project file, expanding simple conditions in it, parsing property values, assembly references and references to other projects. /// public class CsProjectInfo { /// /// Evaluated properties from the project file /// public Dictionary Properties; /// /// Mapping of referenced assemblies to their 'CopyLocal' (aka 'Private') setting. /// public Dictionary References = new Dictionary(); /// /// Mapping of referenced projects to their 'CopyLocal' (aka 'Private') setting. /// public Dictionary ProjectReferences = new Dictionary(); /// /// Constructor /// /// Initial mapping of property names to values CsProjectInfo(Dictionary InProperties) { Properties = new Dictionary(InProperties); } /// /// Resolve the project's output directory /// /// Base directory to resolve relative paths to /// The configured output directory public DirectoryReference GetOutputDir(DirectoryReference BaseDirectory) { string OutputPath; if (Properties.TryGetValue("OutputPath", out OutputPath)) { return DirectoryReference.Combine(BaseDirectory, OutputPath); } else { return BaseDirectory; } } /// /// Adds build products from the project to the given set. /// /// Output directory for the build products. May be different to the project's output directory in the case that we're copying local to another project. /// Set to receive the list of build products public bool AddBuildProducts(DirectoryReference OutputDir, HashSet BuildProducts) { string OutputType, AssemblyName; if (Properties.TryGetValue("OutputType", out OutputType) && Properties.TryGetValue("AssemblyName", out AssemblyName)) { // DotNET Core framework doesn't produce .exe files, it produces DLLs in all cases if (IsDotNETCoreProject()) { OutputType = "Library"; } switch (OutputType) { case "Exe": case "WinExe": BuildProducts.Add(FileReference.Combine(OutputDir, AssemblyName + ".exe")); AddOptionalBuildProduct(FileReference.Combine(OutputDir, AssemblyName + ".pdb"), BuildProducts); AddOptionalBuildProduct(FileReference.Combine(OutputDir, AssemblyName + ".exe.config"), BuildProducts); AddOptionalBuildProduct(FileReference.Combine(OutputDir, AssemblyName + ".exe.mdb"), BuildProducts); return true; case "Library": BuildProducts.Add(FileReference.Combine(OutputDir, AssemblyName + ".dll")); AddOptionalBuildProduct(FileReference.Combine(OutputDir, AssemblyName + ".pdb"), BuildProducts); AddOptionalBuildProduct(FileReference.Combine(OutputDir, AssemblyName + ".dll.config"), BuildProducts); AddOptionalBuildProduct(FileReference.Combine(OutputDir, AssemblyName + ".dll.mdb"), BuildProducts); return true; } } return false; } public bool IsDotNETCoreProject() { bool bIsDotNetCoreProject = false; string TargetFramework; if (Properties.TryGetValue("TargetFramework", out TargetFramework)) { bIsDotNetCoreProject = TargetFramework.ToLower().Contains("netstandard") || TargetFramework.ToLower().Contains("netcoreapp"); } return bIsDotNetCoreProject; } /// /// Adds a build product to the output list if it exists /// /// The build product to add /// List of output build products public static void AddOptionalBuildProduct(FileReference BuildProduct, HashSet BuildProducts) { if (FileReference.Exists(BuildProduct)) { BuildProducts.Add(BuildProduct); } } /// /// Attempts to read project information for the given file. /// /// The project file to read /// Initial set of property values /// If successful, the parsed project info /// True if the project was read successfully, false otherwise public static bool TryRead(FileReference File, Dictionary Properties, out CsProjectInfo OutProjectInfo) { // Read the project file XmlDocument Document = new XmlDocument(); Document.Load(File.FullName); // Check the root element is the right type // HashSet ProjectBuildProducts = new HashSet(); if (Document.DocumentElement.Name != "Project") { OutProjectInfo = null; return false; } // Parse the basic structure of the document, updating properties and recursing into other referenced projects as we go CsProjectInfo ProjectInfo = new CsProjectInfo(Properties); foreach (XmlElement Element in Document.DocumentElement.ChildNodes.OfType()) { switch (Element.Name) { case "PropertyGroup": if (EvaluateCondition(Element, ProjectInfo.Properties)) { ParsePropertyGroup(Element, ProjectInfo.Properties); } break; case "ItemGroup": if (EvaluateCondition(Element, ProjectInfo.Properties)) { ParseItemGroup(File.Directory, Element, ProjectInfo); } break; } } // Return the complete project OutProjectInfo = ProjectInfo; return true; } /// /// Parses a 'PropertyGroup' element. /// /// The parent 'PropertyGroup' element /// Dictionary mapping property names to values static void ParsePropertyGroup(XmlElement ParentElement, Dictionary Properties) { // We need to know the overridden output type and output path for the selected configuration. foreach (XmlElement Element in ParentElement.ChildNodes.OfType()) { if (EvaluateCondition(Element, Properties)) { Properties[Element.Name] = ExpandProperties(Element.InnerText, Properties); } } } /// /// Parses an 'ItemGroup' element. /// /// Base directory to resolve relative paths against /// The parent 'ItemGroup' element /// Project info object to be updated static void ParseItemGroup(DirectoryReference BaseDirectory, XmlElement ParentElement, CsProjectInfo ProjectInfo) { // Parse any external assembly references foreach (XmlElement ItemElement in ParentElement.ChildNodes.OfType()) { switch (ItemElement.Name) { case "Reference": // Reference to an external assembly if (EvaluateCondition(ItemElement, ProjectInfo.Properties)) { ParseReference(BaseDirectory, ItemElement, ProjectInfo.References); } break; case "ProjectReference": // Reference to another project if (EvaluateCondition(ItemElement, ProjectInfo.Properties)) { ParseProjectReference(BaseDirectory, ItemElement, ProjectInfo.ProjectReferences); } break; } } } /// /// Parses an assembly reference from a given 'Reference' element /// /// Directory to resolve relative paths against /// The parent 'Reference' element /// Dictionary of project files to a bool indicating whether the assembly should be copied locally to the referencing project. static void ParseReference(DirectoryReference BaseDirectory, XmlElement ParentElement, Dictionary References) { string HintPath = GetChildElementString(ParentElement, "HintPath", null); if (!String.IsNullOrEmpty(HintPath)) { FileReference AssemblyFile = FileReference.Combine(BaseDirectory, HintPath); bool bPrivate = GetChildElementBoolean(ParentElement, "Private", true); References.Add(AssemblyFile, bPrivate); } } /// /// Parses a project reference from a given 'ProjectReference' element /// /// Directory to resolve relative paths against /// The parent 'ProjectReference' element /// Dictionary of project files to a bool indicating whether the outputs of the project should be copied locally to the referencing project. static void ParseProjectReference(DirectoryReference BaseDirectory, XmlElement ParentElement, Dictionary ProjectReferences) { string IncludePath = ParentElement.GetAttribute("Include"); if (!String.IsNullOrEmpty(IncludePath)) { FileReference ProjectFile = FileReference.Combine(BaseDirectory, IncludePath); bool bPrivate = GetChildElementBoolean(ParentElement, "Private", true); ProjectReferences[ProjectFile] = bPrivate; } } /// /// Reads the inner text of a child XML element /// /// The parent element to check /// Name of the child element /// Default value to return if the child element is missing /// The contents of the child element, or default value if it's not present static string GetChildElementString(XmlElement ParentElement, string Name, string DefaultValue) { XmlElement ChildElement = ParentElement.ChildNodes.OfType().FirstOrDefault(x => x.Name == Name); if (ChildElement == null) { return DefaultValue; } else { return ChildElement.InnerText ?? DefaultValue; } } /// /// Read a child XML element with the given name, and parse it as a boolean. /// /// Parent element to check /// Name of the child element to look for /// Default value to return if the element is missing or not a valid bool /// The parsed boolean, or the default value static bool GetChildElementBoolean(XmlElement ParentElement, string Name, bool DefaultValue) { string Value = GetChildElementString(ParentElement, Name, null); if (Value == null) { return DefaultValue; } else if (Value.Equals("True", StringComparison.InvariantCultureIgnoreCase)) { return true; } else if (Value.Equals("False", StringComparison.InvariantCultureIgnoreCase)) { return false; } else { return DefaultValue; } } /// /// Evaluate whether the optional MSBuild condition on an XML element evaluates to true. Currently only supports 'ABC' == 'DEF' style expressions, but can be expanded as needed. /// /// The XML element to check /// Dictionary mapping from property names to values. /// static bool EvaluateCondition(XmlElement Element, Dictionary Properties) { // Read the condition attribute. If it's not present, assume it evaluates to true. string Condition = Element.GetAttribute("Condition"); if (String.IsNullOrEmpty(Condition)) { return true; } // Expand all the properties Condition = ExpandProperties(Condition, Properties); // Tokenize the condition string[] Tokens = Tokenize(Condition); // Try to evaluate it. We only support a very limited class of condition expressions at the moment, but it's enough to parse standard projects bool bResult; if (Tokens.Length == 3 && Tokens[0].StartsWith("'") && Tokens[1] == "==" && Tokens[2].StartsWith("'")) { bResult = String.Compare(Tokens[0], Tokens[2], StringComparison.InvariantCultureIgnoreCase) == 0; } else { throw new Exception("Couldn't parse condition in project file"); } return bResult; } /// /// Expand MSBuild properties within a string. If referenced properties are not in this dictionary, the process' environment variables are expanded. Unknown properties are expanded to an empty string. /// /// The input string to expand /// Dictionary mapping from property names to values. /// String with all properties expanded. static string ExpandProperties(string Text, Dictionary Properties) { string NewText = Text; for (int Idx = NewText.IndexOf("$("); Idx != -1; Idx = NewText.IndexOf("$(", Idx)) { // Find the end of the variable name int EndIdx = NewText.IndexOf(')', Idx + 2); if (EndIdx != -1) { // Extract the variable name from the string string Name = NewText.Substring(Idx + 2, EndIdx - (Idx + 2)); // Find the value for it, either from the dictionary or the environment block string Value; if (!Properties.TryGetValue(Name, out Value)) { Value = Environment.GetEnvironmentVariable(Name) ?? ""; } // Replace the variable, or skip past it NewText = NewText.Substring(0, Idx) + Value + NewText.Substring(EndIdx + 1); // Make sure we skip over the expanded variable; we don't want to recurse on it. Idx += Value.Length; } } return NewText; } /// /// Split an MSBuild condition into tokens /// /// The condition expression /// Array of the parsed tokens static string[] Tokenize(string Condition) { List Tokens = new List(); for (int Idx = 0; Idx < Condition.Length; Idx++) { if (Idx + 1 < Condition.Length && Condition[Idx] == '=' && Condition[Idx + 1] == '=') { // "==" operator Idx++; Tokens.Add("=="); } else if (Condition[Idx] == '\'') { // Quoted string int StartIdx = Idx++; while (Idx + 1 < Condition.Length && Condition[Idx] != '\'') { Idx++; } Tokens.Add(Condition.Substring(StartIdx, Idx - StartIdx)); } else if (!Char.IsWhiteSpace(Condition[Idx])) { // Other token; assume a single character. string Token = Condition.Substring(Idx, 1); Tokens.Add(Token); } } return Tokens.ToArray(); } } }