// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
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.
///
[Obsolete("Functionality in the Tools.DotNETCommon namespace is deprecated. Please reference the EpicGames.Core namespace and assembly instead.")]
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();
///
/// List of compile references in the project.
///
public List CompileReferences = new List();
///
/// Mapping of content IF they are flagged Always or Newer
///
public Dictionary ContentReferences = new Dictionary();
///
/// Path to the CSProject file
///
public FileReference ProjectPath;
///
/// Constructor
///
/// Initial mapping of property names to values
CsProjectInfo(Dictionary InProperties, FileReference InProjectPath)
{
ProjectPath = InProjectPath;
Properties = new Dictionary(InProperties);
}
///
/// Get the ouptut file for this project
///
/// If successful, receives the assembly path
/// True if the output file was found
public bool TryGetOutputFile(out FileReference File)
{
DirectoryReference OutputDir;
if(!TryGetOutputDir(out OutputDir))
{
File = null;
return false;
}
string AssemblyName;
if(!TryGetAssemblyName(out AssemblyName))
{
File = null;
return false;
}
File = FileReference.Combine(OutputDir, AssemblyName + ".dll");
return true;
}
///
/// 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;
}
}
///
/// Resolve the project's output directory
///
/// If successful, receives the output directory
/// True if the output directory was found
public bool TryGetOutputDir(out DirectoryReference OutputDir)
{
string OutputPath;
if (Properties.TryGetValue("OutputPath", out OutputPath))
{
OutputDir = DirectoryReference.Combine(ProjectPath.Directory, OutputPath);
return true;
}
else
{
OutputDir = null;
return false;
}
}
///
/// Returns the assembly name used by this project
///
///
public bool TryGetAssemblyName(out string AssemblyName)
{
return Properties.TryGetValue("AssemblyName", out AssemblyName);
}
///
/// 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))
{
switch (OutputType)
{
case "Exe":
case "WinExe":
string ExecutableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : "";
BuildProducts.Add(FileReference.Combine(OutputDir, AssemblyName + ExecutableExtension));
// dotnet outputs a apphost executable and a dll with the actual assembly
AddOptionalBuildProduct(FileReference.Combine(OutputDir, AssemblyName + ".dll"), BuildProducts);
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);
}
}
///
/// Parses supported elements from the children of the provided note. May recurse
/// for conditional elements.
///
///
///
static void ParseNode(XmlNode Node, CsProjectInfo ProjectInfo)
{
foreach (XmlElement Element in Node.ChildNodes.OfType())
{
switch (Element.Name)
{
case "PropertyGroup":
if (EvaluateCondition(Element, ProjectInfo))
{
ParsePropertyGroup(Element, ProjectInfo);
}
break;
case "ItemGroup":
if (EvaluateCondition(Element, ProjectInfo))
{
ParseItemGroup(ProjectInfo.ProjectPath.Directory, Element, ProjectInfo);
}
break;
case "Choose":
case "When":
if (EvaluateCondition(Element, ProjectInfo))
{
ParseNode(Element, ProjectInfo);
}
break;
}
}
}
///
/// Reads project information for the given file.
///
/// The project file to read
/// Initial set of property values
/// The parsed project info
public static CsProjectInfo Read(FileReference File, Dictionary Properties)
{
CsProjectInfo Project;
if(!TryRead(File, Properties, out Project))
{
throw new Exception(String.Format("Unable to read '{0}'", File));
}
return Project;
}
///
/// 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, File);
// Parse elements in the root node
ParseNode(Document.DocumentElement, ProjectInfo);
// 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, CsProjectInfo ProjectInfo)
{
// 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, ProjectInfo))
{
ProjectInfo.Properties[Element.Name] = ExpandProperties(Element.InnerText, ProjectInfo.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))
{
ParseReference(BaseDirectory, ItemElement, ProjectInfo.References);
}
break;
case "ProjectReference":
// Reference to another project
if (EvaluateCondition(ItemElement, ProjectInfo))
{
ParseProjectReference(BaseDirectory, ItemElement, ProjectInfo.Properties, ProjectInfo.ProjectReferences);
}
break;
case "Compile":
// Reference to another project
if (EvaluateCondition(ItemElement, ProjectInfo))
{
ParseCompileReference(BaseDirectory, ItemElement, ProjectInfo.CompileReferences);
}
break;
case "Content":
case "None":
// Reference to another project
if (EvaluateCondition(ItemElement, ProjectInfo))
{
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", 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 properties for parsing the file
/// 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 Properties, Dictionary ProjectReferences)
{
string IncludePath = UnescapeString(ParentElement.GetAttribute("Include"));
if (!String.IsNullOrEmpty(IncludePath))
{
FileReference ProjectFile = FileReference.Combine(BaseDirectory, ExpandProperties(IncludePath, Properties));
bool bPrivate = GetChildElementBoolean(ParentElement, "Private", true);
ProjectReferences[ProjectFile] = bPrivate;
}
}
/// recursive helper used by the function below that will append RemainingComponents one by one to ExistingPath,
/// expanding wildcards as necessary. The complete list of files that match the complete path is returned out OutFoundFiles
static void ProcessPathComponents(DirectoryReference ExistingPath, IEnumerable RemainingComponents, List OutFoundFiles)
{
if (!RemainingComponents.Any())
{
return;
}
// take a look at the first component
string CurrentComponent = RemainingComponents.First();
RemainingComponents = RemainingComponents.Skip(1);
// If no other components then this is either a file pattern or a greedy pattern
if (!RemainingComponents.Any())
{
// ** means include all files under this tree, so enumerate them all
if (CurrentComponent.Contains("**"))
{
OutFoundFiles.AddRange(DirectoryReference.EnumerateFiles(ExistingPath, "*", SearchOption.AllDirectories));
}
else
{
// easy, a regular path with a file that may or may not be a wildcard
OutFoundFiles.AddRange(DirectoryReference.EnumerateFiles(ExistingPath, CurrentComponent));
}
}
else
{
// new component contains a wildcard, and based on the above we know there are more entries so find
// matching directories
if (CurrentComponent.Contains("*"))
{
// ** means all directories, no matter how deep
SearchOption Option = CurrentComponent == "**" ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
IEnumerable Directories = DirectoryReference.EnumerateDirectories(ExistingPath, CurrentComponent, Option);
// if we searched all directories regardless of depth, the rest of the components other than the last (file) are irrelevant
if (Option == SearchOption.AllDirectories)
{
RemainingComponents = new[] { RemainingComponents.Last() };
// ** includes files in the current directory too
Directories = Directories.Concat(new[] { ExistingPath });
}
foreach (DirectoryReference Dir in Directories)
{
ProcessPathComponents(Dir, RemainingComponents, OutFoundFiles);
}
}
else
{
// add this component to our path and recurse.
ExistingPath = DirectoryReference.Combine(ExistingPath, CurrentComponent);
// but... we can just take all the next components that don't have wildcards in them instead of recursing
// into each one!
IEnumerable NonWildCardComponents = RemainingComponents.TakeWhile(C => !C.Contains("*"));
RemainingComponents = RemainingComponents.Skip(NonWildCardComponents.Count());
ExistingPath = DirectoryReference.Combine(ExistingPath, NonWildCardComponents.ToArray());
if (Directory.Exists(ExistingPath.FullName))
{
ProcessPathComponents(ExistingPath, RemainingComponents, OutFoundFiles);
}
}
}
}
///
/// Finds all files in the provided path, which may be a csproj wildcard specification.
/// E.g. The following are all valid
/// Foo/Bar/Item.cs
/// Foo/Bar/*.cs
/// Foo/*/Item.cs
/// Foo/*/*.cs
/// Foo/**
/// (the last means include all files under the path).
///
/// Path specifier to process
///
static IEnumerable FindMatchingFiles(FileReference InPath)
{
List FoundFiles = new List();
// split off the drive root
string DriveRoot = Path.GetPathRoot(InPath.FullName);
// break the rest of the path into components
string[] PathComponents = InPath.FullName.Substring(DriveRoot.Length).Split(new char[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar });
// Process all the components recursively
ProcessPathComponents(new DirectoryReference(DriveRoot), PathComponents, FoundFiles);
return FoundFiles;
}
///
/// Parses a project reference from a given 'ProjectReference' element
///
/// Directory to resolve relative paths against
/// The parent 'ProjectReference' element
/// List of source files.
static void ParseCompileReference(DirectoryReference BaseDirectory, XmlElement ParentElement, List CompileReferences)
{
string IncludePath = UnescapeString(ParentElement.GetAttribute("Include"));
if (!String.IsNullOrEmpty(IncludePath))
{
FileReference SourceFile = FileReference.Combine(BaseDirectory, IncludePath);
if (SourceFile.FullName.Contains("*"))
{
CompileReferences.AddRange(FindMatchingFiles(SourceFile));
}
else
{
CompileReferences.Add(SourceFile);
}
}
}
///
/// 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, CsProjectInfo ProjectInfo)
{
// 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, ProjectInfo.Properties);
// Parse literal true/false values
bool OutResult;
if (bool.TryParse(Condition, out OutResult))
{
return OutResult;
}
// Tokenize the condition
string[] Tokens = Tokenize(Condition);
char[] TokenQuotes = new[] { '\'', '(', ')', '{', '}', '[', ']' };
// 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;
// Handle Exists('Platform\Windows\Gauntlet.TargetDeviceWindows.cs')
if (Tokens[0] == "Exists")
{
// remove all quotes, apostrophes etc that are either tokens or wrap tokens (The Tokenize() function is a bit suspect).
string[] Arguments = Tokens.Select(S => S.Trim(TokenQuotes)).Where(S => S.Length > 0).ToArray();
if (Tokens.Length > 1)
{
FileSystemReference Dependency = DirectoryReference.Combine(ProjectInfo.ProjectPath.Directory, Arguments[1]);
if (File.Exists(Dependency.FullName) || Directory.Exists(Dependency.FullName))
{
return true;
}
return false;
}
}
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
{
string Msg = string.Format("Couldn't parse condition {0} in project file {1}", Element.ToString(), ProjectInfo.ProjectPath);
throw new Exception(Msg);
}
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] == '_' || 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