using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Xml; using UnrealBuildTool; namespace AutomationTool.Tasks { /// /// Parameters for a task that compiles a C# project /// public class CsCompileTaskParameters { /// /// The C# project file to be compile. More than one project file can be specified by separating with semicolons. /// [TaskParameter] public string Project; /// /// The configuration to compile /// [TaskParameter(Optional = true)] public string Configuration; /// /// The platform to compile /// [TaskParameter(Optional = true)] public string Platform; /// /// Additional options to pass to the compiler /// [TaskParameter(Optional = true)] public string Arguments; /// /// Directory containing output files /// [TaskParameter(Optional = true)] public string OutputDir; /// /// Patterns for output files /// [TaskParameter(Optional = true)] public string OutputFiles; /// /// Only enumerate build products; do not actually compile the projects. /// [TaskParameter(Optional = true)] public bool EnumerateOnly; /// /// Tag to be applied to build products of this task /// [TaskParameter(Optional = true, ValidationType = TaskParameterValidationType.TagList)] public string Tag; /// /// Tag to be applied to any non-private references the projects have /// (i.e. those that are external and not copied into the output dir) /// [TaskParameter(Optional = true, ValidationType = TaskParameterValidationType.TagList)] public string TagReferences; } /// /// Compile a C# project file /// [TaskElement("CsCompile", typeof(CsCompileTaskParameters))] public class CsCompileTask : CustomTask { /// /// Parameters for the task /// CsCompileTaskParameters Parameters; /// /// Constructor. /// /// Parameters for this task public CsCompileTask(CsCompileTaskParameters InParameters) { Parameters = InParameters; } /// /// Execute the task. /// /// Information about the current job /// Set of build products produced by this node. /// Mapping from tag names to the set of files they include /// True if the task succeeded public override bool Execute(JobContext Job, HashSet BuildProducts, Dictionary> TagNameToFileSet) { // Get the project file HashSet ProjectFiles = ResolveFilespec(CommandUtils.RootDirectory, Parameters.Project, TagNameToFileSet); foreach(FileReference ProjectFile in ProjectFiles) { if(!ProjectFile.Exists()) { CommandUtils.LogError("Couldn't find project file '{0}'", ProjectFile.FullName); return false; } if(!ProjectFile.HasExtension(".csproj")) { CommandUtils.LogError("File '{0}' is not a C# project", ProjectFile.FullName); return false; } } // Get the default properties Dictionary Properties = new Dictionary(StringComparer.InvariantCultureIgnoreCase); if(!String.IsNullOrEmpty(Parameters.Platform)) { Properties["Platform"] = Parameters.Platform; } if(!String.IsNullOrEmpty(Parameters.Configuration)) { Properties["Configuration"] = Parameters.Configuration; } // Build the arguments and run the build if(!Parameters.EnumerateOnly) { List Arguments = new List(); foreach(KeyValuePair PropertyPair in Properties) { Arguments.Add(String.Format("/property:{0}={1}", CommandUtils.MakePathSafeToUseWithCommandLine(PropertyPair.Key), CommandUtils.MakePathSafeToUseWithCommandLine(PropertyPair.Value))); } if(!String.IsNullOrEmpty(Parameters.Arguments)) { Arguments.Add(Parameters.Arguments); } Arguments.Add("/verbosity:minimal"); Arguments.Add("/nologo"); foreach(FileReference ProjectFile in ProjectFiles) { CommandUtils.MsBuild(CommandUtils.CmdEnv, ProjectFile.FullName, String.Join(" ", Arguments), null); } } // Try to figure out the output files HashSet ProjectBuildProducts; HashSet ProjectReferences; if(!FindBuildProducts(ProjectFiles, Properties, out ProjectBuildProducts, out ProjectReferences)) { return false; } // Apply the optional tag to the produced archive foreach(string TagName in FindTagNamesFromList(Parameters.Tag)) { FindOrAddTagSet(TagNameToFileSet, TagName).UnionWith(ProjectBuildProducts); } if (!String.IsNullOrEmpty(Parameters.TagReferences)) { // Apply the optional tag to any references foreach (string TagName in FindTagNamesFromList(Parameters.TagReferences)) { FindOrAddTagSet(TagNameToFileSet, TagName).UnionWith(ProjectReferences); } } // Merge them into the standard set of build products BuildProducts.UnionWith(ProjectBuildProducts); return true; } /// /// Output this task out to an XML writer. /// public override void Write(XmlWriter Writer) { Write(Writer, Parameters); } /// /// Find all the tags which are used as inputs to this task /// /// The tag names which are read by this task public override IEnumerable FindConsumedTagNames() { return FindTagNamesFromFilespec(Parameters.Project); } /// /// Find all the tags which are modified by this task /// /// The tag names which are modified by this task public override IEnumerable FindProducedTagNames() { foreach (string TagName in FindTagNamesFromList(Parameters.Tag)) { yield return TagName; } foreach (string TagName in FindTagNamesFromList(Parameters.TagReferences)) { yield return TagName; } } /// /// Find all the build products created by compiling the given project file /// /// Initial project file to read. All referenced projects will also be read. /// Mapping of property name to value /// Receives a set of build products on success /// Receives a set of non-private references on success /// True if the build products were found, false otherwise. static bool FindBuildProducts(HashSet ProjectFiles, Dictionary InitialProperties, out HashSet OutBuildProducts, out HashSet OutReferences) { // Read all the project information into a dictionary Dictionary FileToProjectInfo = new Dictionary(); foreach(FileReference ProjectFile in ProjectFiles) { if(!ReadProjectsRecursively(ProjectFile, InitialProperties, FileToProjectInfo)) { OutBuildProducts = null; OutReferences = null; return false; } } // Find all the build products and references HashSet BuildProducts = new HashSet(); HashSet References = new HashSet(); foreach(KeyValuePair Pair in FileToProjectInfo) { CsProjectInfo ProjectInfo = Pair.Value; // Add the standard build products DirectoryReference OutputDir = ProjectInfo.GetOutputDir(Pair.Key.Directory); ProjectInfo.AddBuildProducts(OutputDir, BuildProducts); // Add the referenced assemblies foreach(KeyValuePair Reference in ProjectInfo.References) { FileReference OtherAssembly = Reference.Key; if (Reference.Value) { // Add reference from the output dir FileReference OutputFile = FileReference.Combine(OutputDir, OtherAssembly.GetFileName()); BuildProducts.Add(OutputFile); FileReference OutputSymbolFile = OutputFile.ChangeExtension(".pdb"); if(OutputSymbolFile.Exists()) { BuildProducts.Add(OutputSymbolFile); } } else { // Add reference directly References.Add(OtherAssembly); FileReference SymbolFile = OtherAssembly.ChangeExtension(".pdb"); if(SymbolFile.Exists()) { References.Add(SymbolFile); } } } // Add build products from all the referenced projects. MSBuild only copy the directly referenced build products, not recursive references or other assemblies. foreach(CsProjectInfo OtherProjectInfo in ProjectInfo.ProjectReferences.Where(x => x.Value).Select(x => FileToProjectInfo[x.Key])) { OtherProjectInfo.AddBuildProducts(OutputDir, BuildProducts); } } // Update the output set OutBuildProducts = BuildProducts; OutReferences = References; return true; } /// /// Read a project file, plus all the project files it references. /// /// Project file to read /// Mapping of property name to value for the initial project /// /// True if the projects were read correctly, false (and prints an error to the log) if not static bool ReadProjectsRecursively(FileReference File, Dictionary InitialProperties, Dictionary FileToProjectInfo) { // Early out if we've already read this project, return succes if(FileToProjectInfo.ContainsKey(File)) { return true; } // Try to read this project CsProjectInfo ProjectInfo; if(!CsProjectInfo.TryRead(File, InitialProperties, out ProjectInfo)) { CommandUtils.LogError("Couldn't read project '{0}'", File.FullName); return false; } // Add it to the project lookup, and try to read all the projects it references FileToProjectInfo.Add(File, ProjectInfo); return ProjectInfo.ProjectReferences.Keys.All(x => ReadProjectsRecursively(x, new Dictionary(), FileToProjectInfo)); } } /// /// 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. /// 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)) { switch(OutputType) { case "Exe": case "WinExe": BuildProducts.Add(FileReference.Combine(OutputDir, AssemblyName + ".exe")); BuildProducts.Add(FileReference.Combine(OutputDir, AssemblyName + ".pdb")); FileReference ExeConfig = FileReference.Combine(OutputDir, AssemblyName + ".exe.config"); if (ExeConfig.Exists()) { BuildProducts.Add(ExeConfig); } FileReference ExeMDB = FileReference.Combine(OutputDir, AssemblyName + "exe.mdb"); if (ExeMDB.Exists()) { BuildProducts.Add(ExeMDB); } return true; case "Library": BuildProducts.Add(FileReference.Combine(OutputDir, AssemblyName + ".dll")); BuildProducts.Add(FileReference.Combine(OutputDir, AssemblyName + ".pdb")); return true; } } return false; } /// /// 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 AutomationException("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(); } } }