// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Xml;
namespace EpicGames.Core
{
///
/// Exception parsing a csproj file
///
public sealed class CsProjectParseException : Exception
{
internal CsProjectParseException(string? message, Exception? exception = null) : base(message, exception)
{
}
}
///
/// 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 { get; }
///
/// Mapping of referenced assemblies to their 'CopyLocal' (aka 'Private') setting.
///
public Dictionary References { get; } = [];
///
/// Mapping of referenced projects to their 'CopyLocal' (aka 'Private') setting.
///
public Dictionary ProjectReferences { get; } = [];
///
/// List of compile references in the project.
///
public List CompileReferences { get; } = [];
///
/// Mapping of content IF they are flagged Always or Newer
///
public Dictionary ContentReferences { get; } = [];
///
/// Path to the CSProject file
///
public FileReference ProjectPath { get; }
///
/// Constructor
///
/// Initial mapping of property names to values
///
CsProjectInfo(Dictionary inProperties, FileReference inProjectPath)
{
ProjectPath = inProjectPath;
Properties = new Dictionary(inProperties);
Properties.TryAdd("MSBuildProjectFile", inProjectPath.FullName);
Properties.TryAdd("MSBuildThisFileDirectory", inProjectPath.Directory.FullName);
}
///
/// Get the ouptut file for this project
///
/// If successful, receives the assembly path
/// True if the output file was found
public bool TryGetOutputFile([NotNullWhen(true)] 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;
}
///
/// Returns whether or not this project is a modern dotnet project
///
/// True if this is a netcore project
private bool IsNetCoreProject()
{
string? framework;
return Properties.TryGetValue("TargetFramework", out framework)
&& (framework.StartsWith("netcoreapp", StringComparison.Ordinal) || Regex.Match(framework, @"net\d+\.0").Success);
}
///
/// 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 if (IsNetCoreProject())
{
string configuration = Properties.TryGetValue("Configuration", out string? value) ? value : "Development";
return !Properties.TryGetValue("AppendTargetFrameworkToOutputPath", out string? appendFrameworkStr) || appendFrameworkStr.Equals("true", StringComparison.OrdinalIgnoreCase)
? DirectoryReference.Combine(baseDirectory, "bin", configuration, Properties["TargetFramework"])
: DirectoryReference.Combine(baseDirectory, "bin", configuration);
}
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([NotNullWhen(true)] out DirectoryReference? outputDir)
{
string? outputPath;
if (Properties.TryGetValue("OutputPath", out outputPath))
{
outputDir = DirectoryReference.Combine(ProjectPath.Directory, outputPath);
return true;
}
else if (IsNetCoreProject())
{
string configuration = Properties.ContainsKey("Configuration") ? Properties["Configuration"] : "Development";
outputDir = DirectoryReference.Combine(ProjectPath.Directory, "bin", configuration, Properties["TargetFramework"]);
return true;
}
else
{
outputDir = null;
return false;
}
}
///
/// Returns the assembly name used by this project
///
///
public bool TryGetAssemblyName([NotNullWhen(true)] out string? assemblyName)
{
if (Properties.TryGetValue("AssemblyName", out assemblyName))
{
return true;
}
else if (IsNetCoreProject())
{
assemblyName = ProjectPath.GetFileNameWithoutExtension();
return true;
}
return false;
}
///
/// 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) && TryGetAssemblyName(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
public static 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 a TargetFramework is a .NET core framework
///
/// True if the TargetFramework is a .NET core framework
static bool IsDotNETCoreFramework(string targetFramework)
{
if (targetFramework.ToLower().Contains("netstandard", StringComparison.Ordinal) || targetFramework.ToLower().Contains("netcoreapp", StringComparison.Ordinal))
{
return true;
}
else if (targetFramework.StartsWith("net", StringComparison.OrdinalIgnoreCase))
{
string[] versionSplit = targetFramework.Substring(3).Split('.');
if (versionSplit.Length >= 1 && Int32.TryParse(versionSplit[0], out int majorVersion))
{
return majorVersion >= 5;
}
}
return false;
}
///
/// Determines if this project is a .NET core project
///
/// True if the project is a .NET core project
public bool IsDotNETCoreProject()
{
if (Properties.TryGetValue("TargetFramework", out string? targetFramework))
{
return IsDotNETCoreFramework(targetFramework);
}
if (Properties.TryGetValue("TargetFrameworks", out string? targetFrameworks))
{
return targetFramework?.Split(';', StringSplitOptions.RemoveEmptyEntries).Any(x => IsDotNETCoreFramework(x.Trim())) ?? false;
}
return false;
}
///
/// 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 "Import":
if (EvaluateCondition(element, projectInfo))
{
ParseImportProject(element, projectInfo);
}
break;
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, [NotNullWhen(true)] 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
try
{
ParseNode(document.DocumentElement, projectInfo);
}
catch (Exception ex)
{
throw new CsProjectParseException($"Error parsing {file}: {ex.Message}", ex);
}
// Return the complete project
outProjectInfo = projectInfo;
return true;
}
///
/// Parses a 'Import' emenebt
///
/// The element
/// Dictionary mapping property names to values
static void ParseImportProject(XmlElement element, CsProjectInfo projectInfo)
{
string importProjectStr = element.GetAttribute("Project");
foreach (Match match in Regex.Matches(importProjectStr, @"(\$\(.*?\))").OfType())
{
importProjectStr = importProjectStr.Replace(match.Value, projectInfo.Properties.GetValueOrDefault(match.Value.Substring(2, match.Length - 3), String.Empty), StringComparison.Ordinal);
}
FileReference importProject = Path.IsPathFullyQualified(importProjectStr) ? new FileReference(importProjectStr) : FileReference.Combine(projectInfo.ProjectPath.Directory, importProjectStr);
if (!FileReference.Exists(importProject))
{
return;
}
XmlDocument document = new XmlDocument();
document.Load(importProject.FullName);
if (document.DocumentElement?.Name == "Project")
{
projectInfo.Properties.TryGetValue("MSBuildThisFileDirectory", out string? msBuildThisFileDirectory);
projectInfo.Properties.Remove("MSBuildThisFileDirectory");
projectInfo.Properties.Add("MSBuildThisFileDirectory", importProject.Directory.FullName);
try
{
ParseNode(document.DocumentElement, projectInfo);
}
catch (Exception)
{
// Ignore issues in imported .props files
}
projectInfo.Properties.Remove("MSBuildThisFileDirectory");
if (msBuildThisFileDirectory != null)
{
projectInfo.Properties.Add("MSBuildThisFileDirectory", msBuildThisFileDirectory);
}
}
}
///
/// 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())
{
// Common properties used by UnrealEngine.csproj.props, manually handle because the parsing is awful
switch (element.Name)
{
case "IsLinux": projectInfo.Properties[element.Name] = OperatingSystem.IsLinux().ToString(); continue;
case "IsMacOS": projectInfo.Properties[element.Name] = OperatingSystem.IsMacOS().ToString(); continue;
case "IsWindows": projectInfo.Properties[element.Name] = OperatingSystem.IsWindows().ToString(); continue;
case "IsX64": projectInfo.Properties[element.Name] = (RuntimeInformation.OSArchitecture == Architecture.X64).ToString(); continue;
case "IsArm64": projectInfo.Properties[element.Name] = (RuntimeInformation.OSArchitecture == Architecture.Arm64).ToString(); continue;
case "OSArchitecture": projectInfo.Properties[element.Name] = RuntimeInformation.OSArchitecture.ToString(); continue;
case "EngineDirectory": projectInfo.Properties.TryAdd(element.Name, String.Empty); continue;
case "EngineDir":
case "EnginePath":
{
projectInfo.Properties[element.Name] = String.Empty;
if (projectInfo.Properties.TryGetValue("EngineDirectory", out string? engineDirectory) && !String.IsNullOrEmpty(engineDirectory))
{
projectInfo.Properties[element.Name] = engineDirectory;
}
continue;
}
case "IsEngineProject":
{
projectInfo.Properties[element.Name] = "False";
if (projectInfo.Properties.TryGetValue("EngineDirectory", out string? engineDirectory) && !String.IsNullOrEmpty(engineDirectory))
{
projectInfo.Properties[element.Name] = projectInfo.ProjectPath.IsUnderDirectory(new DirectoryReference(engineDirectory)).ToString();
}
continue;
}
case "IsAutomationProject": projectInfo.Properties[element.Name] = projectInfo.ProjectPath.FullName.EndsWith(".automation.csproj", StringComparison.OrdinalIgnoreCase).ToString(); continue;
case "HasAssemblyInfo": projectInfo.Properties[element.Name] = FileReference.Exists(FileReference.Combine(projectInfo.ProjectPath.Directory, "Properties", "AssemblyInfo.cs")).ToString(); continue;
default: break;
}
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 a file
if (EvaluateCondition(itemElement, projectInfo))
{
ParseCompileReference(baseDirectory, itemElement, projectInfo.CompileReferences);
}
break;
case "Content":
case "None":
// Reference to a file
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)
{
if (!OperatingSystem.IsWindows() && hintPath.Contains('\\', StringComparison.Ordinal))
{
hintPath = hintPath.Replace('\\', Path.DirectorySeparatorChar);
}
FileReference assemblyFile = Path.IsPathFullyQualified(hintPath) ? new FileReference(hintPath) : 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))
{
includePath = ExpandProperties(includePath, properties);
if (!OperatingSystem.IsWindows() && includePath.Contains('\\', StringComparison.Ordinal))
{
includePath = includePath.Replace('\\', Path.DirectorySeparatorChar);
}
FileReference projectFile = Path.IsPathFullyQualified(includePath) ? new FileReference(includePath) : FileReference.Combine(baseDirectory, includePath);
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("**", StringComparison.Ordinal))
{
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('*', StringComparison.Ordinal))
{
// ** 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 = [remainingComponents.Last()];
// ** includes files in the current directory too
directories = directories.Concat([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('*', StringComparison.Ordinal));
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 = [];
// 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([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]);
// Process all the components recursively
ProcessPathComponents(new DirectoryReference(driveRoot), pathComponents, foundFiles);
return foundFiles;
}
///
/// Parses a a compile file path from a given 'Compile' 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))
{
if (!OperatingSystem.IsWindows() && includePath.Contains('\\', StringComparison.Ordinal))
{
includePath = includePath.Replace('\\', Path.DirectorySeparatorChar);
}
FileReference sourceFile = Path.IsPathFullyQualified(includePath) ? new FileReference(includePath) : FileReference.Combine(baseDirectory, includePath);
if (sourceFile.FullName.Contains('*', StringComparison.Ordinal))
{
compileReferences.AddRange(FindMatchingFiles(sourceFile));
}
else
{
compileReferences.Add(sourceFile);
}
}
}
///
/// Parses a file path 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.OrdinalIgnoreCase) || copyTo.Equals("PreserveNewest", StringComparison.OrdinalIgnoreCase));
if (!OperatingSystem.IsWindows() && includePath.Contains('\\', StringComparison.Ordinal))
{
includePath = includePath.Replace('\\', Path.DirectorySeparatorChar);
}
FileReference contentFile = Path.IsPathFullyQualified(includePath) ? new FileReference(includePath) : FileReference.Combine(baseDirectory, includePath);
contents.TryAdd(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.OrdinalIgnoreCase))
{
return true;
}
else if (value.Equals("False", StringComparison.OrdinalIgnoreCase))
{
return false;
}
else
{
return defaultValue;
}
}
record class ConditionContext(DirectoryReference BaseDir);
///
/// 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 (Boolean.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
int tokenIdx = 0;
ConditionContext context = new ConditionContext(projectInfo.ProjectPath.Directory);
try
{
bool bResult = CoerceToBool(EvaluateLogicalAnd(context, tokens, ref tokenIdx));
if (tokenIdx != tokens.Length)
{
throw new CsProjectParseException("Unexpected tokens at end of condition");
}
return bResult;
}
catch (CsProjectParseException ex)
{
throw new CsProjectParseException($"{ex.Message} while parsing {element} in project file {projectInfo.ProjectPath}", ex);
}
}
static string EvaluateLogicalAnd(ConditionContext context, string[] tokens, ref int tokenIdx)
{
string result = EvaluateLogicalOr(context, tokens, ref tokenIdx);
while (tokenIdx < tokens.Length && tokens[tokenIdx].Equals("And", StringComparison.OrdinalIgnoreCase))
{
tokenIdx++;
string rhs = EvaluateEquality(context, tokens, ref tokenIdx);
result = (CoerceToBool(result) && CoerceToBool(rhs)).ToString();
}
return result;
}
static string EvaluateLogicalOr(ConditionContext context, string[] tokens, ref int tokenIdx)
{
string result = EvaluateEquality(context, tokens, ref tokenIdx);
while (tokenIdx < tokens.Length && tokens[tokenIdx].Equals("Or", StringComparison.OrdinalIgnoreCase))
{
tokenIdx++;
string rhs = EvaluateEquality(context, tokens, ref tokenIdx);
result = (CoerceToBool(result) || CoerceToBool(rhs)).ToString();
}
return result;
}
static string EvaluateEquality(ConditionContext context, string[] tokens, ref int tokenIdx)
{
// Otherwise try to parse an equality or inequality expression
string lhs = EvaluateValue(context, tokens, ref tokenIdx);
if (tokenIdx < tokens.Length)
{
if (tokens[tokenIdx] == "==")
{
tokenIdx++;
string rhs = EvaluateValue(context, tokens, ref tokenIdx);
return lhs.Equals(rhs, StringComparison.OrdinalIgnoreCase).ToString();
}
else if (tokens[tokenIdx] == "!=")
{
tokenIdx++;
string rhs = EvaluateValue(context, tokens, ref tokenIdx);
return lhs.Equals(rhs, StringComparison.OrdinalIgnoreCase).ToString();
}
}
return lhs;
}
static string EvaluateValue(ConditionContext context, string[] tokens, ref int tokenIdx)
{
// Handle Exists('Platform\Windows\Gauntlet.TargetDeviceWindows.cs')
if (tokens[tokenIdx].Equals("Exists", StringComparison.OrdinalIgnoreCase))
{
if (tokenIdx + 3 >= tokens.Length || !tokens[tokenIdx + 1].Equals("(", StringComparison.Ordinal) || !tokens[tokenIdx + 3].Equals(")", StringComparison.Ordinal))
{
throw new CsProjectParseException("Invalid 'Exists' expression", null);
}
// remove all quotes, apostrophes etc that are either tokens or wrap tokens (The Tokenize() function is a bit suspect).
string path = tokens[tokenIdx + 2].Trim('\'', '(', ')', '{', '}', '[', ']');
tokenIdx += 4;
FileSystemReference dependency = DirectoryReference.Combine(context.BaseDir, path);
bool exists = File.Exists(dependency.FullName) || Directory.Exists(dependency.FullName);
return exists.ToString();
}
// Handle negation
if (tokens[tokenIdx].Equals("!", StringComparison.Ordinal))
{
tokenIdx++;
bool value = CoerceToBool(EvaluateValue(context, tokens, ref tokenIdx));
return (!value).ToString();
}
// Handle subexpressions
if (tokens[tokenIdx].Equals("(", StringComparison.Ordinal))
{
tokenIdx++;
string result = EvaluateLogicalAnd(context, tokens, ref tokenIdx);
if (!tokens[tokenIdx].Equals(")", StringComparison.Ordinal))
{
throw new CsProjectParseException("Missing ')'", null);
}
tokenIdx++;
return result;
}
return tokens[tokenIdx++];
}
static bool CoerceToBool(string value)
{
return !value.Equals("false", StringComparison.OrdinalIgnoreCase) && !value.Equals("0", StringComparison.Ordinal);
}
///
/// 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("$(", StringComparison.Ordinal); idx != -1; idx = newText.IndexOf("$(", idx, StringComparison.Ordinal))
{
// 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 string? retrievedValue))
{
value = retrievedValue;
}
else
{
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