// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.IO; using System.CodeDom.Compiler; using Microsoft.CSharp; using Microsoft.Win32; using System.Reflection; using System.Diagnostics; using UnrealBuildTool; using EpicGames.Core; using System.Threading.Tasks; namespace AutomationTool { /// /// Exception thrown by PreprocessScriptFile. /// public class CompilationException : AutomationException { public CompilationException(string Filename, int StartLine, int StartColumn, int EndLine, int EndColumn, string Message, params string[] Args) : base(String.Format("Compilation Failed.\n>{0}({1},{2},{3},{4}): error: {5}", Path.GetFullPath(Filename), StartLine + 1, StartColumn + 1, EndLine + 1, EndColumn + 1, String.Format(Message, Args))) { } } /// /// Compiles and loads script assemblies. /// public static class ScriptCompiler { private static Dictionary ScriptCommands; #if DEBUG const string BuildConfig = "Debug"; #else const string BuildConfig = "Development"; #endif const string DefaultScriptsDLLName = "AutomationScripts.Automation.dll"; /// /// Finds and/or compiles all script files and assemblies. /// /// Path to the current project. May be null, in which case we compile scripts for all projects. /// Additional script fodlers to look for source files in. public static void FindAndCompileAllScripts(string ScriptsForProjectFileName, List AdditionalScriptsFolders) { // Find all the project files Stopwatch SearchTimer = Stopwatch.StartNew(); List ProjectFiles = FindAutomationProjects(ScriptsForProjectFileName, AdditionalScriptsFolders); Log.TraceLog("Found {0} project files in {1:0.000}s", ProjectFiles.Count, SearchTimer.Elapsed.TotalSeconds); foreach(FileReference ProjectFile in ProjectFiles) { Log.TraceLog(" {0}", ProjectFile); } // Get the default properties for compiling the projects Dictionary MsBuildProperties = new Dictionary(StringComparer.OrdinalIgnoreCase); MsBuildProperties.Add("Platform", "AnyCPU"); MsBuildProperties.Add("Configuration", BuildConfig); MsBuildProperties.Add("EngineDir", CommandUtils.EngineDirectory.FullName); // Read all the projects Stopwatch ParsingTimer = Stopwatch.StartNew(); CsProjectInfo[] Projects = new CsProjectInfo[ProjectFiles.Count]; Parallel.For(0, ProjectFiles.Count, Idx => Projects[Idx] = CsProjectInfo.Read(ProjectFiles[Idx], MsBuildProperties)); Log.TraceLog("Parsed project files in {0:0.000}s", ParsingTimer.Elapsed.TotalSeconds); // Get all the build artifacts BuildProducts = new HashSet(); HashSet OutputDirs = new HashSet(); OutputDirs.Add(DirectoryReference.Combine(CommandUtils.EngineDirectory, "Binaries", "DotNET")); // Don't want any artifacts from this directory (just AutomationTool.exe and AutomationScripts.dll) foreach (CsProjectInfo Project in Projects) { DirectoryReference OutputDir; if (!Project.TryGetOutputDir(out OutputDir)) { throw new AutomationException("Unable to get output directory for {0}", Project.ProjectPath); } if (OutputDirs.Add(OutputDir)) { if (DirectoryReference.Exists(OutputDir)) { BuildProducts.UnionWith(DirectoryReference.EnumerateFiles(OutputDir)); } else { Log.TraceLog("Output directory {0} does not exist; ignoring", OutputDir); } } } // Load everything Stopwatch LoadTimer = Stopwatch.StartNew(); List Assemblies = LoadAutomationAssemblies(Projects); Log.TraceLog("Loaded assemblies in {0:0.000}s", LoadTimer.Elapsed.TotalSeconds); // Setup platforms Platform.InitializePlatforms(Assemblies.ToArray()); // Instantiate all the automation classes for interrogation Log.TraceVerbose("Creating commands."); ScriptCommands = new Dictionary(StringComparer.InvariantCultureIgnoreCase); foreach (Assembly CompiledScripts in Assemblies) { try { foreach (Type ClassType in CompiledScripts.GetTypes()) { if (ClassType.IsSubclassOf(typeof(BuildCommand)) && ClassType.IsAbstract == false) { if (ScriptCommands.ContainsKey(ClassType.Name) == false) { ScriptCommands.Add(ClassType.Name, ClassType); } else { bool IsSame = string.Equals(ClassType.AssemblyQualifiedName, ScriptCommands[ClassType.Name].AssemblyQualifiedName); if (IsSame == false) { Log.TraceWarning("Unable to add command {0} twice. Previous: {1}, Current: {2}", ClassType.Name, ClassType.AssemblyQualifiedName, ScriptCommands[ClassType.Name].AssemblyQualifiedName); } } } } } catch (ReflectionTypeLoadException LoadEx) { foreach (Exception SubEx in LoadEx.LoaderExceptions) { Log.TraceWarning("Got type loader exception: {0}", SubEx.ToString()); } throw new AutomationException("Failed to add commands from {0}. {1}", CompiledScripts, LoadEx); } catch (Exception Ex) { throw new AutomationException("Failed to add commands from {0}. {1}", CompiledScripts, Ex); } } } private static List FindAutomationProjects(string ScriptsForProjectFileName, List AdditionalScriptsFolders) { // Configure the rules compiler // Get all game folders and convert them to build subfolders. List AllGameFolders = new List(); if (ScriptsForProjectFileName == null) { AllGameFolders = NativeProjects.EnumerateProjectFiles().Select(x => x.Directory).ToList(); } else { // Project automation scripts currently require source engine builds if (!CommandUtils.IsEngineInstalled()) { AllGameFolders = new List { new DirectoryReference(Path.GetDirectoryName(ScriptsForProjectFileName)) }; } } List AllAdditionalScriptFolders = AdditionalScriptsFolders.Select(x => new DirectoryReference(x)).ToList(); foreach (DirectoryReference Folder in AllGameFolders) { DirectoryReference GameBuildFolder = DirectoryReference.Combine(Folder, "Build"); if (DirectoryReference.Exists(GameBuildFolder)) { AllAdditionalScriptFolders.Add(GameBuildFolder); } } Log.TraceVerbose("Discovering game folders."); List DiscoveredModules = UnrealBuildTool.RulesCompiler.FindAllRulesSourceFiles(UnrealBuildTool.RulesCompiler.RulesFileType.AutomationModule, GameFolders: AllGameFolders, ForeignPlugins: null, AdditionalSearchPaths: AllAdditionalScriptFolders); List ModulesToCompile = new List(DiscoveredModules.Count); foreach (FileReference ModuleFilename in DiscoveredModules) { if (HostPlatform.Current.IsScriptModuleSupported(ModuleFilename.GetFileNameWithoutAnyExtensions())) { ModulesToCompile.Add(ModuleFilename); } else { CommandUtils.LogVerbose("Script module {0} filtered by the Host Platform and will not be compiled.", ModuleFilename); } } return ModulesToCompile; } /// /// Creates a hash collection that represents the state of all modules in this list. If /// a module has no current output or is missing files then a null collection will be returned /// /// /// private static HashCollection HashModules(IEnumerable Modules, bool WarnOnFailure=true) { HashCollection Hashes = new HashCollection(); foreach (string Module in Modules) { // this project is built by the AutomationTool project that the RunUAT script builds so // it will always be newer than was last built if (Module.Contains("AutomationUtils.Automation")) { continue; } CsProjectInfo Proj; Dictionary Properties = new Dictionary(); Properties.Add("Platform", "AnyCPU"); Properties.Add("Configuration", "Development"); FileReference ModuleFile = new FileReference(Module); if (!CsProjectInfo.TryRead(ModuleFile, Properties, out Proj)) { if (WarnOnFailure) { Log.TraceWarning("Failed to read file {0}", ModuleFile); } return null; } if (!Hashes.AddCsProjectInfo(Proj, HashCollection.HashType.MetaData)) { if (WarnOnFailure) { Log.TraceWarning("Failed to hash file {0}", ModuleFile); } return null; } } return Hashes; } /// /// Pulls all dependencies from the specified module list, gathers the state of them, and /// compares it to the state expressed in the provided file from a previous run /// /// /// /// private static bool AreDependenciesUpToDate(IEnumerable ModuleList, string DependencyFile) { bool UpToDate = false; if (File.Exists(DependencyFile)) { Log.TraceVerbose("Read dependency file at {0}", DependencyFile); HashCollection OldHashes = HashCollection.CreateFromFile(DependencyFile); if (OldHashes != null) { HashCollection CurrentHashes = HashModules(ModuleList, false); if (OldHashes.Equals(CurrentHashes)) { UpToDate = true; } else { if (Log.OutputLevel >= LogEventType.VeryVerbose) { CurrentHashes.LogDifferences(OldHashes); } } } else { Log.TraceInformation("Failed to read dependency info!"); } } else { Log.TraceVerbose("No dependency file exists at {0}. Will do full build.", DependencyFile); } return UpToDate; } /// /// Converts a set of MSBuild properties into command line arguments /// /// The properties to set /// Command line arguments private static string GetMsBuildPropertyArguments(Dictionary Properties) { return String.Join(" ", Properties.Select(x => String.Format(" /p:{0}={1}", Utils.MakePathSafeToUseWithCommandLine(x.Key), Utils.MakePathSafeToUseWithCommandLine(x.Value)))); } /// /// Compiles all automation projects /// /// Projects to compile /// Properties to set private static void CompileAutomationProjects(List Projects, Dictionary MsBuildProperties) { Stopwatch Timer = Stopwatch.StartNew(); string DependencyFile = Path.Combine(CommandUtils.CmdEnv.EngineSavedFolder, "UATModuleHashes.xml"); if (AreDependenciesUpToDate(Projects.Select(x => x.ProjectPath.FullName), DependencyFile) && !GlobalCommandLine.IgnoreDependencies) { Log.TraceInformation("Dependencies are up to date ({0:0.000}s). Skipping compile.", Timer.Elapsed.TotalSeconds); return; } Log.TraceInformation("Dependencies are out of date. Compiling scripts...."); // clean old assemblies CleanupScriptsAssemblies(); string BuildTool = CommandUtils.CmdEnv.MsBuildExe; // msbuild (standard on windows, in mono >=5.0 is preferred due to speed and parallel compilation) bool UseParallelMsBuild = Path.GetFileNameWithoutExtension(BuildTool).ToLower() == "msbuild"; if (UseParallelMsBuild) { string ProjectsList = string.Join(";", Projects.Select(x => x.ProjectPath)); // Mono has an issue where arugments with semicolons or commas can't be passed through to // as arguments so we need to manually construct a temp file with the list of modules // see (https://github.com/Microsoft/msbuild/issues/471) string UATProjTemplate = CommandUtils.CombinePaths(CommandUtils.CmdEnv.LocalRoot, @"Engine\Source\Programs\AutomationTool\Scripts\UAT.proj"); string UATProjFile = Path.Combine(CommandUtils.CmdEnv.EngineSavedFolder, "UATTempProj.proj"); string ProjContents = File.ReadAllText(UATProjTemplate); ProjContents = ProjContents.Replace("$(Modules)", ProjectsList); Directory.CreateDirectory(Path.GetDirectoryName(UATProjFile)); File.WriteAllText(UATProjFile, ProjContents); string MsBuildVerbosity = Log.OutputLevel >= LogEventType.Verbose ? "minimal" : "quiet"; string CmdLine = String.Format("\"{0}\" {1} /verbosity:{2} /nologo", UATProjFile, GetMsBuildPropertyArguments(MsBuildProperties), MsBuildVerbosity); // suppress the run command because it can be long and intimidating, making the logs around this code harder to read. IProcessResult Result = CommandUtils.Run(BuildTool, CmdLine, Options: CommandUtils.ERunOptions.Default | CommandUtils.ERunOptions.NoLoggingOfRunCommand | CommandUtils.ERunOptions.LoggingOfRunDuration); if (Result.ExitCode != 0) { throw new AutomationException(String.Format("Failed to build \"{0}\":{1}{2}", UATProjFile, Environment.NewLine, Result.Output)); } } else { // Make sure DefaultScriptsDLLName is compiled first string DefaultScriptsProjName = Path.ChangeExtension(DefaultScriptsDLLName, "csproj"); // Primary modules must be built first List PrimaryProjects = Projects.Where(M => M.ProjectPath.FullName.IndexOf(DefaultScriptsProjName, StringComparison.InvariantCultureIgnoreCase) >= 0).ToList(); foreach (CsProjectInfo PrimaryProject in PrimaryProjects) { Log.TraceInformation("Building script module: {0}", PrimaryProject.ProjectPath); try { CompileAutomationProject(PrimaryProject.ProjectPath, MsBuildProperties); } catch (Exception Ex) { throw new AutomationException(Ex, "Failed to compile module {0}", PrimaryProject.ProjectPath); } break; } // Second pass, compile everything else List SecondaryProjects = Projects.Where(M => !PrimaryProjects.Contains(M)).ToList(); // Non-parallel method foreach (CsProjectInfo SecondaryProject in SecondaryProjects) { Log.TraceInformation("Building script module: {0}", SecondaryProject.ProjectPath); try { CompileAutomationProject(SecondaryProject.ProjectPath, MsBuildProperties); } catch (Exception Ex) { throw new AutomationException(Ex, "Failed to compile module {0}", SecondaryProject.ProjectPath); } } } Log.TraceInformation("Compiled {0} modules in {1:0.000} secs", Projects.Count, Timer.Elapsed.TotalSeconds); HashCollection NewHashes = HashModules(Projects.Select(x => x.ProjectPath.FullName)); if (NewHashes == null) { Log.TraceWarning("Failed to save dependency info!"); } else { NewHashes.SaveToFile(DependencyFile); Log.TraceVerbose("Wrote dependencies to {0}", DependencyFile); } } /// /// Starts compiling the provided project file and returns the process. Caller should check HasExited /// or call WaitForExit() to get results /// /// /// private static bool CompileAutomationProject(FileReference ProjectFile, Dictionary Properties) { if (!ProjectFile.HasExtension(".csproj")) { throw new AutomationException(String.Format("Unable to build Project {0}. Not a valid .csproj file.", ProjectFile)); } if (!FileReference.Exists(ProjectFile)) { throw new AutomationException(String.Format("Unable to build Project {0}. Project file not found.", ProjectFile)); } string CmdLine = String.Format("\"{0}\" /verbosity:quiet /nologo /target:Build {1} /p:TreatWarningsAsErrors=false /p:NoWarn=\"612,618,672,1591\" /p:BuildProjectReferences=true", ProjectFile, GetMsBuildPropertyArguments(Properties)); // Compile the project IProcessResult Result = CommandUtils.Run(CommandUtils.CmdEnv.MsBuildExe, CmdLine); if (Result.ExitCode != 0) { throw new AutomationException(String.Format("Failed to build \"{0}\":{1}{2}", ProjectFile, Environment.NewLine, Result.Output)); } else { // Remove .Automation.csproj and copy to target dir Log.TraceVerbose("Successfully compiled {0}", ProjectFile); } return Result.ExitCode == 0; } /// /// Loads all precompiled assemblies (DLLs that end with *Scripts.dll). /// /// Projects to load /// List of compiled assemblies private static List LoadAutomationAssemblies(IEnumerable Projects) { List Assemblies = new List(); foreach (CsProjectInfo Project in Projects) { // Get the output assembly name FileReference AssemblyLocation; if (!Project.TryGetOutputFile(out AssemblyLocation)) { throw new AutomationException("Unable to get output file for {0}", Project.ProjectPath); } // Load the assembly into our app domain CommandUtils.LogLog("Loading script DLL: {0}", AssemblyLocation); try { AssemblyUtils.AddFileToAssemblyCache(AssemblyLocation.FullName); Assembly Assembly = AppDomain.CurrentDomain.Load(AssemblyName.GetAssemblyName(AssemblyLocation.FullName)); Assemblies.Add(Assembly); } catch (Exception Ex) { throw new AutomationException("Failed to load script DLL: {0}: {1}", AssemblyLocation, Ex.Message); } } return Assemblies; } private static void CleanupScriptsAssemblies() { if (!CommandUtils.IsEngineInstalled()) { CommandUtils.LogVerbose("Cleaning up script DLL folder"); CommandUtils.DeleteDirectory(GetScriptAssemblyFolder()); // Bug in PortalPublishingTool caused these DLLs to be copied into Engine/Binaries/DotNET. Delete any files left over. DirectoryReference BinariesDir = DirectoryReference.Combine(CommandUtils.RootDirectory, "Engine", "Binaries", "DotNET"); foreach (FileReference FileToDelete in DirectoryReference.EnumerateFiles(BinariesDir, "*.automation.dll")) { CommandUtils.DeleteFile(FileToDelete.FullName); } foreach (FileReference FileToDelete in DirectoryReference.EnumerateFiles(BinariesDir, "*.automation.pdb")) { CommandUtils.DeleteFile(FileToDelete.FullName); } } } private static DirectoryReference GetScriptAssemblyFolder() { return DirectoryReference.Combine(CommandUtils.EngineDirectory, "Binaries", "DotNET", "AutomationScripts"); } public static Dictionary Commands { get { return ScriptCommands; } } public static HashSet BuildProducts { get; private set; } } }