// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using EpicGames.Core; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Emit; using Microsoft.CodeAnalysis.Text; using Microsoft.Extensions.Logging; using OpenTracing.Util; using UnrealBuildBase; namespace UnrealBuildTool { /// /// Methods for dynamically compiling C# source files /// public class DynamicCompilation { /// /// Checks to see if the assembly needs compilation /// /// Set of source files /// File containing information about this assembly, like which source files it was built with and engine version /// Output path for the assembly /// Logger for output /// True if the assembly needs to be built private static bool RequiresCompilation(IEnumerable SourceFiles, FileReference AssemblyManifestFilePath, FileReference OutputAssemblyPath, ILogger Logger) { // Do not compile the file if it's installed if (Unreal.IsFileInstalled(OutputAssemblyPath)) { Logger.LogDebug("Skipping {OutputAssemblyPath}: File is installed", OutputAssemblyPath); return false; } // Check to see if we already have a compiled assembly file on disk FileItem OutputAssemblyInfo = FileItem.GetItemByFileReference(OutputAssemblyPath); if (!OutputAssemblyInfo.Exists) { Logger.LogDebug("Compiling {OutputAssemblyPath}: Assembly does not exist", OutputAssemblyPath); return true; } // Check the time stamp of the UnrealBuildTool assembly. If Unreal Build Tool was compiled more // recently than the dynamically-compiled assembly, then we'll always recompile it. This is // because Unreal Build Tool's code may have changed in such a way that invalidate these // previously-compiled assembly files. FileItem UnrealBuildToolDllItem = FileItem.GetItemByFileReference(Unreal.UnrealBuildToolDllPath); if (UnrealBuildToolDllItem.LastWriteTimeUtc > OutputAssemblyInfo.LastWriteTimeUtc) { Logger.LogDebug("Compiling {OutputAssemblyPath}: {UnrealBuildToolDllItemName} is newer", OutputAssemblyPath, UnrealBuildToolDllItem.Name); return true; } // Make sure we have a manifest of source files used to compile the output assembly. If it doesn't exist // for some reason (not an expected case) then we'll need to recompile. FileItem AssemblySourceListFile = FileItem.GetItemByFileReference(AssemblyManifestFilePath); if (!AssemblySourceListFile.Exists) { Logger.LogDebug("Compiling {OutputAssemblyPath}: Missing source file list ({AssemblyManifestFilePath})", OutputAssemblyPath, AssemblyManifestFilePath); return true; } JsonObject Manifest; try { Manifest = JsonObject.Read(AssemblyManifestFilePath); } catch (Exception) { Logger.LogDebug("Compiling {OutputAssemblyPath}: Error reading source file list ({AssemblyManifestFilePath})", OutputAssemblyPath, AssemblyManifestFilePath); return true; } // check if the engine version is different string EngineVersionManifest = Manifest.GetStringField("EngineVersion"); string EngineVersionCurrent = FormatVersionNumber(ReadOnlyBuildVersion.Current); if (EngineVersionManifest != EngineVersionCurrent) { Logger.LogDebug("Compiling {OutputAssemblyPath}: Engine Version changed from {EngineVersionManifest} to {EngineVersionCurrent}", OutputAssemblyPath, EngineVersionManifest, EngineVersionCurrent); return true; } // Make sure the source files we're compiling are the same as the source files that were compiled // for the assembly that we want to load HashSet CurrentSourceFileItems = new HashSet(); foreach (string Line in Manifest.GetStringArrayField("SourceFiles")) { CurrentSourceFileItems.Add(FileItem.GetItemByPath(Line)); } // Get the new source files HashSet SourceFileItems = new HashSet(); foreach (FileReference SourceFile in SourceFiles) { SourceFileItems.Add(FileItem.GetItemByFileReference(SourceFile)); } // Check if there are any differences between the sets foreach (FileItem CurrentSourceFileItem in CurrentSourceFileItems) { if (!SourceFileItems.Contains(CurrentSourceFileItem)) { Logger.LogDebug("Compiling {OutputAssemblyPath}: Removed source file ({CurrentSourceFileItem})", OutputAssemblyPath, CurrentSourceFileItem); return true; } } foreach (FileItem SourceFileItem in SourceFileItems) { if (!CurrentSourceFileItems.Contains(SourceFileItem)) { Logger.LogDebug("Compiling {OutputAssemblyPath}: Added source file ({SourceFileItem})", OutputAssemblyPath, SourceFileItem); return true; } } // Check if any of the timestamps are newer foreach (FileItem SourceFileItem in SourceFileItems) { if (SourceFileItem.LastWriteTimeUtc > OutputAssemblyInfo.LastWriteTimeUtc) { Logger.LogDebug("Compiling {OutputAssemblyPath}: {SourceFileItem} is newer", OutputAssemblyPath, SourceFileItem); return true; } } return false; } private static void LogDiagnostics(IEnumerable Diagnostics, ILogger Logger) { using LogEventParser Parser = new LogEventParser(Logger); Parser.AddMatchersFromAssembly(Assembly.GetExecutingAssembly()); foreach (Diagnostic Diag in Diagnostics) { switch (Diag.Severity) { // Diagnostics are pre-formatted suitable for Visual Studio consumption - print them without an additional severity prefix case DiagnosticSeverity.Error: { Parser.WriteLine(Diag.ToString()); break; } case DiagnosticSeverity.Hidden: { break; } case DiagnosticSeverity.Warning: { Parser.WriteLine(Diag.ToString()); break; } case DiagnosticSeverity.Info: { Parser.WriteLine(Diag.ToString()); break; } } } } private static async Task ParseSyntaxTreeAsync(FileReference sourceFileName, CSharpParseOptions parseOptions, CancellationToken cancellationToken) { string content = await FileReference.ReadAllTextAsync(sourceFileName, cancellationToken); SourceText source = SourceText.From(content, System.Text.Encoding.UTF8); return CSharpSyntaxTree.ParseText(source, parseOptions, sourceFileName.FullName, cancellationToken); } private static async Task> ParseSyntaxTreesAsync(IEnumerable sourceFileNames, IEnumerable? preprocessorDefines, CancellationToken cancellationToken) { CSharpParseOptions parseOptions = new( languageVersion: LanguageVersion.Latest, kind: SourceCodeKind.Regular, preprocessorSymbols: preprocessorDefines ); IEnumerable> tasks = sourceFileNames.Select(sourceFileName => ParseSyntaxTreeAsync(sourceFileName, parseOptions, cancellationToken)); return (await Task.WhenAll(tasks)).OrderBy(x => x.FilePath); } private static async Task CompileAssemblyAsync(FileReference OutputAssemblyPath, IEnumerable SourceFileNames, ILogger Logger, IEnumerable? ReferencedAssembies, IEnumerable? PreprocessorDefines = null, bool TreatWarningsAsErrors = false) { IEnumerable SyntaxTrees = await ParseSyntaxTreesAsync(SourceFileNames, PreprocessorDefines, default); // Check for errors { IEnumerable syntaxTreeErrors = SyntaxTrees.SelectMany(x => x.GetDiagnostics()); if (syntaxTreeErrors.Where(x => x.Severity == DiagnosticSeverity.Error).Any()) { LogDiagnostics(syntaxTreeErrors, Logger); return null; } } // Create the output directory if it doesn't exist already DirectoryInfo DirInfo = new DirectoryInfo(OutputAssemblyPath.Directory.FullName); if (!DirInfo.Exists) { try { DirInfo.Create(); } catch (Exception Ex) { throw new BuildException(Ex, "Unable to create directory '{0}' for intermediate assemblies (Exception: {1})", OutputAssemblyPath, Ex.Message); } } List ReferenceAssemblyTypes = [ typeof(object), typeof(UnrealBuildTool), typeof(FileReference), typeof(UEBuildPlatformSDK), ]; List ReferenceAssemblyStrings = [ // system references, "System.Runtime", "System.CodeDom", "System.Collections", "System.IO", "System.IO.FileSystem", "System.Linq", "System.Text.Json", "System.Threading.Tasks", "System.Threading.Tasks.Parallel", "System.Collections.Concurrent", "System.Private.Xml", "System.Private.Xml.Linq", "System.Text.RegularExpressions", "Microsoft.CodeAnalysis.CSharp", "System.Console", "System.Runtime.Extensions", "Microsoft.Extensions.Logging.Abstractions", "netstandard", // process start dependencies "System.ComponentModel.Primitives", "System.Diagnostics.Process", // registry access "Microsoft.Win32.Registry", // RNGCryptoServiceProvider, used to generate random hex bytes "System.Security.Cryptography", "System.Security.Cryptography.Algorithms", "System.Security.Cryptography.Csp", ]; List MetadataReferences = [ .. ReferencedAssembies?.Select(x => MetadataReference.CreateFromFile(x)) ?? [], .. ReferenceAssemblyTypes.Select(x => MetadataReference.CreateFromFile(x.Assembly.Location)), .. ReferenceAssemblyStrings.Select(x => MetadataReference.CreateFromFile(Assembly.Load(x).Location)) ]; CSharpCompilationOptions CompilationOptions = new CSharpCompilationOptions( outputKind: OutputKind.DynamicallyLinkedLibrary, #if DEBUG optimizationLevel: OptimizationLevel.Debug, #else // Optimize the managed code in Development optimizationLevel: OptimizationLevel.Release, #endif warningLevel: 4, assemblyIdentityComparer: DesktopAssemblyIdentityComparer.Default, reportSuppressedDiagnostics: true ); CSharpCompilation Compilation = CSharpCompilation.Create( assemblyName: OutputAssemblyPath.GetFileNameWithoutAnyExtensions(), syntaxTrees: SyntaxTrees, references: MetadataReferences, options: CompilationOptions ); { using FileStream AssemblyStream = FileReference.Open(OutputAssemblyPath, FileMode.Create); using FileStream? PdbStream = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? FileReference.Open(OutputAssemblyPath.ChangeExtension(".pdb"), FileMode.Create) : null; EmitOptions EmitOptions = new( includePrivateMembers: true ); EmitResult Result = Compilation.Emit( peStream: AssemblyStream, pdbStream: PdbStream, options: EmitOptions); LogDiagnostics(Result.Diagnostics, Logger); if (!Result.Success) { return null; } } return Assembly.LoadFile(OutputAssemblyPath.FullName); } /// /// Dynamically compiles an assembly async for the specified source file and loads that assembly into the application's /// current domain. If an assembly has already been compiled and is not out of date, then it will be loaded and /// no compilation is necessary. /// /// Full path to the assembly to be created /// List of source file name /// Logger for output /// /// /// /// /// /// The assembly that was loaded public static async Task CompileAndLoadAssemblyAsync(FileReference OutputAssemblyPath, IEnumerable SourceFileNames, ILogger Logger, IEnumerable? ReferencedAssembies = null, IEnumerable? PreprocessorDefines = null, bool DoNotCompile = false, bool ForceCompile = false, bool TreatWarningsAsErrors = false) { // Check to see if the resulting assembly is compiled and up to date FileReference AssemblyManifestFilePath = FileReference.Combine(OutputAssemblyPath.Directory, Path.GetFileNameWithoutExtension(OutputAssemblyPath.FullName) + "Manifest.json"); bool bNeedsCompilation = ForceCompile; if (!DoNotCompile) { bNeedsCompilation = RequiresCompilation(SourceFileNames, AssemblyManifestFilePath, OutputAssemblyPath, Logger); } // Load the assembly to ensure it is correct Assembly? CompiledAssembly = null; if (!bNeedsCompilation) { try { // Load the previously-compiled assembly from disk CompiledAssembly = Assembly.LoadFile(OutputAssemblyPath.FullName); } catch (FileLoadException Ex) { Logger.LogInformation("Unable to load the previously-compiled assembly file '{File}'. Unreal Build Tool will try to recompile this assembly now. (Exception: {Ex})", OutputAssemblyPath, Ex.Message); bNeedsCompilation = true; } catch (BadImageFormatException Ex) { Logger.LogInformation("Compiled assembly file '{File}' appears to be for a newer CLR version or is otherwise invalid. Unreal Build Tool will try to recompile this assembly now. (Exception: {Ex})", OutputAssemblyPath, Ex.Message); bNeedsCompilation = true; } catch (FileNotFoundException) { throw new BuildException("Precompiled rules assembly '{0}' does not exist.", OutputAssemblyPath); } catch (Exception Ex) { throw new BuildException(Ex, "Error while loading previously-compiled assembly file '{0}'. (Exception: {1})", OutputAssemblyPath, Ex.Message); } } // Compile the assembly if me if (bNeedsCompilation) { using (GlobalTracer.Instance.BuildSpan(String.Format("Compiling rules assembly ({0})", OutputAssemblyPath.GetFileName())).StartActive()) { CompiledAssembly = await CompileAssemblyAsync(OutputAssemblyPath, SourceFileNames, Logger, ReferencedAssembies, PreprocessorDefines, TreatWarningsAsErrors); } using (JsonWriter Writer = new JsonWriter(AssemblyManifestFilePath)) { ReadOnlyBuildVersion Version = ReadOnlyBuildVersion.Current; Writer.WriteObjectStart(); // Save out a list of all the source files we compiled. This is so that we can tell if whole files were added or removed // since the previous time we compiled the assembly. In that case, we'll always want to recompile it! Writer.WriteStringArrayField("SourceFiles", SourceFileNames.Select(x => x.FullName)); Writer.WriteValue("EngineVersion", FormatVersionNumber(Version)); Writer.WriteObjectEnd(); } } return CompiledAssembly; } /// /// Dynamically compiles an assembly for the specified source file and loads that assembly into the application's /// current domain. If an assembly has already been compiled and is not out of date, then it will be loaded and /// no compilation is necessary. /// /// Full path to the assembly to be created /// List of source file name /// Logger for output /// /// /// /// /// /// The assembly that was loaded public static Assembly? CompileAndLoadAssembly(FileReference OutputAssemblyPath, IEnumerable SourceFileNames, ILogger Logger, IEnumerable? ReferencedAssembies = null, IEnumerable? PreprocessorDefines = null, bool DoNotCompile = false, bool ForceCompile = false, bool TreatWarningsAsErrors = false) { return CompileAndLoadAssemblyAsync(OutputAssemblyPath, SourceFileNames, Logger, ReferencedAssembies, PreprocessorDefines, DoNotCompile, ForceCompile, TreatWarningsAsErrors).Result; } private static string FormatVersionNumber(ReadOnlyBuildVersion Version) => $"{Version.MajorVersion}.{Version.MinorVersion}.{Version.PatchVersion}"; } }