// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved.
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();
///
/// Mapping of content IF they are flagged Always or Newer
///
public Dictionary ContentReferences = 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;
}
}
///
/// Finds all build products from this project. This includes content and other assemblies marked to be copied local.
///
/// The output directory
/// Receives the set of build products
/// Map of project file to information, to resolve build products from referenced projects copied locally
public void FindBuildProducts(DirectoryReference OutputDir, HashSet BuildProducts, Dictionary ProjectFileToInfo)
{
// Add the standard build products
FindCompiledBuildProducts(OutputDir, BuildProducts);
// Add the referenced assemblies which are marked to be copied into the output directory. This only happens for the main project, and does not happen for referenced projects.
foreach(KeyValuePair Reference in References)
{
if (Reference.Value)
{
FileReference OutputFile = FileReference.Combine(OutputDir, Reference.Key.GetFileName());
AddReferencedAssemblyAndSupportFiles(OutputFile, BuildProducts);
}
}
// Copy the build products for any referenced projects. Note that this does NOT operate recursively.
foreach(KeyValuePair ProjectReference in ProjectReferences)
{
CsProjectInfo OtherProjectInfo;
if(ProjectFileToInfo.TryGetValue(ProjectReference.Key, out OtherProjectInfo))
{
OtherProjectInfo.FindCompiledBuildProducts(OutputDir, BuildProducts);
}
}
// Add any copied content. This DOES operate recursively.
FindCopiedContent(OutputDir, BuildProducts, ProjectFileToInfo);
}
///
/// Determines all the compiled build products (executable, etc...) directly built from this project.
///
/// The output directory
/// Receives the set of build products
public void FindCompiledBuildProducts(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);
break;
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);
break;
}
}
}
///
/// Finds all content which will be copied into the output directory for this project. This includes content from any project references as "copy local" recursively (though MSBuild only traverses a single reference for actual binaries, in such cases)
///
/// The output directory
/// Receives the set of build products
/// Map of project file to information, to resolve build products from referenced projects copied locally
private void FindCopiedContent(DirectoryReference OutputDir, HashSet OutputFiles, Dictionary ProjectFileToInfo)
{
// Copy any referenced projects too.
foreach(KeyValuePair ProjectReference in ProjectReferences)
{
CsProjectInfo OtherProjectInfo;
if(ProjectFileToInfo.TryGetValue(ProjectReference.Key, out OtherProjectInfo))
{
OtherProjectInfo.FindCopiedContent(OutputDir, OutputFiles, ProjectFileToInfo);
}
}
// Add the content which is copied to the output directory
foreach (KeyValuePair ContentReference in ContentReferences)
{
FileReference ContentFile = ContentReference.Key;
if (ContentReference.Value)
{
OutputFiles.Add(FileReference.Combine(OutputDir, ContentFile.GetFileName()));
}
}
}
///
/// Adds the given file and any additional build products to the output set
///
/// The assembly to add
/// Set to receive the file and support files
static public void AddReferencedAssemblyAndSupportFiles(FileReference OutputFile, HashSet OutputFiles)
{
OutputFiles.Add(OutputFile);
FileReference SymbolFile = OutputFile.ChangeExtension(".pdb");
if (FileReference.Exists(SymbolFile))
{
OutputFiles.Add(SymbolFile);
}
FileReference DocumentationFile = OutputFile.ChangeExtension(".xml");
if (FileReference.Exists(DocumentationFile))
{
OutputFiles.Add(DocumentationFile);
}
}
///
/// Determines if this project is a .NET core project
///
/// True if the project is a .NET core project
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;
case "Content":
case "None":
// Reference to another project
if (EvaluateCondition(ItemElement, ProjectInfo.Properties))
{
ParseContent(BaseDirectory, ItemElement, ProjectInfo.ContentReferences);
}
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 = UnescapeString(GetChildElementString(ParentElement, "HintPath", null));
if (!String.IsNullOrEmpty(HintPath))
{
// Don't include embedded assemblies; they aren't referenced externally by the compiled executable
bool bEmbedInteropTypes = GetChildElementBoolean(ParentElement, "EmbedInteropTypes", false);
if(!bEmbedInteropTypes)
{
FileReference AssemblyFile = FileReference.Combine(BaseDirectory, HintPath);
bool bPrivate = GetChildElementBoolean(ParentElement, "Private", !bEmbedInteropTypes);
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 = UnescapeString(ParentElement.GetAttribute("Include"));
if (!String.IsNullOrEmpty(IncludePath))
{
FileReference ProjectFile = FileReference.Combine(BaseDirectory, IncludePath);
bool bPrivate = GetChildElementBoolean(ParentElement, "Private", true);
ProjectReferences[ProjectFile] = bPrivate;
}
}
///
/// Parses an assembly reference from a given 'Content' element
///
/// Directory to resolve relative paths against
/// The parent 'Content' element
/// Dictionary of project files to a bool indicating whether the assembly should be copied locally to the referencing project.
static void ParseContent(DirectoryReference BaseDirectory, XmlElement ParentElement, Dictionary Contents)
{
string IncludePath = UnescapeString(ParentElement.GetAttribute("Include"));
if (!String.IsNullOrEmpty(IncludePath))
{
string CopyTo = GetChildElementString(ParentElement, "CopyToOutputDirectory", null);
bool ShouldCopy = !String.IsNullOrEmpty(CopyTo) && (CopyTo.Equals("Always", StringComparison.InvariantCultureIgnoreCase) || CopyTo.Equals("PreserveNewest", StringComparison.InvariantCultureIgnoreCase));
FileReference ContentFile = FileReference.Combine(BaseDirectory, IncludePath);
Contents.Add(ContentFile, ShouldCopy);
}
}
///
/// 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);
// Parse literal true/false values
bool OutResult;
if (bool.TryParse(Condition, out OutResult))
{
return OutResult;
}
// 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 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, accounting for changes in scope
int EndIdx = Idx + 2;
for(int Depth = 1; Depth > 0; EndIdx++)
{
if(EndIdx == NewText.Length)
{
throw new Exception("Encountered end of string while expanding properties");
}
else if(NewText[EndIdx] == '(')
{
Depth++;
}
else if(NewText[EndIdx] == ')')
{
Depth--;
}
}
// Convert the property name to tokens
string[] Tokens = Tokenize(NewText.Substring(Idx + 2, (EndIdx - 1) - (Idx + 2)));
// Make sure the first token is a valid property name
if(Tokens.Length == 0 || !(Char.IsLetter(Tokens[0][0]) || Tokens[0][0] == '_'))
{
throw new Exception(String.Format("Invalid property name '{0}' in .csproj file", Tokens[0]));
}
// Find the value for it, either from the dictionary or the environment block
string Value;
if (!Properties.TryGetValue(Tokens[0], out Value))
{
Value = Environment.GetEnvironmentVariable(Tokens[0]) ?? "";
}
// Evaluate any functions within it
int TokenIdx = 1;
while(TokenIdx + 3 < Tokens.Length && Tokens[TokenIdx] == "." && Tokens[TokenIdx + 2] == "(")
{
// Read the method name
string MethodName = Tokens[TokenIdx + 1];
// Skip to the first argument
TokenIdx += 3;
// Parse any arguments
List