diff --git a/src/Cake/Commands/DefaultCommandSettings.cs b/src/Cake/Commands/DefaultCommandSettings.cs index 88d0c43270..5e7fce5691 100644 --- a/src/Cake/Commands/DefaultCommandSettings.cs +++ b/src/Cake/Commands/DefaultCommandSettings.cs @@ -59,5 +59,9 @@ public sealed class DefaultCommandSettings : CommandSettings [CommandOption("--info")] [Description("Displays additional information about Cake.")] public bool ShowInfo { get; set; } + + [CommandOption("--" + Infrastructure.Constants.Cache.InvalidateScriptCache)] + [Description("Forces the script to be recompiled if caching is enabled.")] + public bool Recompile { get; set; } } } diff --git a/src/Cake/Infrastructure/CakeConfigurationExtensions.cs b/src/Cake/Infrastructure/CakeConfigurationExtensions.cs new file mode 100644 index 0000000000..eebc9e9b64 --- /dev/null +++ b/src/Cake/Infrastructure/CakeConfigurationExtensions.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Cake.Core; +using Cake.Core.Configuration; +using Cake.Core.IO; + +namespace Cake.Infrastructure +{ + /// + /// Contains extension methods for . + /// + internal static class CakeConfigurationExtensions + { + /// + /// Gets the script cache directory path. + /// + /// The Cake configuration. + /// The default root path. + /// The environment. + /// The script cache directory path. + public static DirectoryPath GetScriptCachePath(this ICakeConfiguration configuration, DirectoryPath defaultRoot, ICakeEnvironment environment) + { + var cachePath = configuration.GetValue(Constants.Paths.Cache); + if (!string.IsNullOrWhiteSpace(cachePath)) + { + return new DirectoryPath(cachePath).MakeAbsolute(environment); + } + var toolPath = configuration.GetToolPath(defaultRoot, environment); + return toolPath.Combine("cache").Collapse(); + } + } +} diff --git a/src/Cake/Infrastructure/Constants.cs b/src/Cake/Infrastructure/Constants.cs new file mode 100644 index 0000000000..f3af4a73db --- /dev/null +++ b/src/Cake/Infrastructure/Constants.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Cake.Infrastructure +{ + internal static class Constants + { + public static class Settings + { + public const string EnableScriptCache = "Settings_EnableScriptCache"; + } + + public static class Paths + { + public const string Cache = "Paths_Cache"; + } + + public static class Cache + { + public const string InvalidateScriptCache = "invalidate-script-cache"; + } + } +} diff --git a/src/Cake/Infrastructure/IScriptHostSettings.cs b/src/Cake/Infrastructure/IScriptHostSettings.cs index c9d34d39f5..3755facfa8 100644 --- a/src/Cake/Infrastructure/IScriptHostSettings.cs +++ b/src/Cake/Infrastructure/IScriptHostSettings.cs @@ -2,10 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using Cake.Core.IO; + namespace Cake.Infrastructure { public interface IScriptHostSettings { bool Debug { get; } + + FilePath Script { get; } } } diff --git a/src/Cake/Infrastructure/Scripting/RoslynScriptSession.cs b/src/Cake/Infrastructure/Scripting/RoslynScriptSession.cs index a610344540..6153104195 100644 --- a/src/Cake/Infrastructure/Scripting/RoslynScriptSession.cs +++ b/src/Cake/Infrastructure/Scripting/RoslynScriptSession.cs @@ -5,14 +5,18 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Linq; using System.Reflection; +using System.Text; +using System.Threading.Tasks; using Cake.Core; using Cake.Core.Configuration; using Cake.Core.Diagnostics; using Cake.Core.IO; using Cake.Core.Reflection; using Cake.Core.Scripting; +using Cake.Infrastructure.Utilities; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Scripting; @@ -21,11 +25,16 @@ namespace Cake.Infrastructure.Scripting public sealed class RoslynScriptSession : IScriptSession { private readonly IScriptHost _host; + private readonly IFileSystem _fileSystem; private readonly IAssemblyLoader _loader; private readonly ICakeLog _log; private readonly ICakeConfiguration _configuration; private readonly IScriptHostSettings _settings; + private readonly bool _scriptCacheEnabled; + private readonly bool _regenerateCache; + private readonly DirectoryPath _scriptCachePath; + public HashSet ReferencePaths { get; } public HashSet References { get; } @@ -40,6 +49,7 @@ public RoslynScriptSession( IScriptHostSettings settings) { _host = host; + _fileSystem = host.Context.FileSystem; _loader = loader; _log = log; _configuration = configuration; @@ -48,6 +58,11 @@ public RoslynScriptSession( ReferencePaths = new HashSet(PathComparer.Default); References = new HashSet(); Namespaces = new HashSet(StringComparer.Ordinal); + + var cacheEnabled = configuration.GetValue(Constants.Settings.EnableScriptCache) ?? bool.FalseString; + _scriptCacheEnabled = cacheEnabled.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase); + _regenerateCache = host.Context.Arguments.HasArgument(Constants.Cache.InvalidateScriptCache); + _scriptCachePath = configuration.GetScriptCachePath(settings.Script.GetDirectory(), host.Context.Environment); } public void AddReference(Assembly assembly) @@ -82,6 +97,30 @@ public void ImportNamespace(string @namespace) public void Execute(Script script) { + var scriptName = _settings.Script.GetFilename(); + var cacheDLLFileName = $"{scriptName}.dll"; + var cacheHashFileName = $"{scriptName}.hash"; + var cachedAssembly = _scriptCachePath.CombineWithFilePath(cacheDLLFileName); + var hashFile = _scriptCachePath.CombineWithFilePath(cacheHashFileName); + string scriptHash = default; + if (_scriptCacheEnabled && _fileSystem.Exist(cachedAssembly) && !_regenerateCache) + { + _log.Verbose($"Cache enabled: Checking cache build script ({cacheDLLFileName})"); + scriptHash = FastHash.GenerateHash(Encoding.UTF8.GetBytes(string.Concat(script.Lines))); + var cachedHash = _fileSystem.Exist(hashFile) + ? _fileSystem.GetFile(hashFile).ReadLines(Encoding.UTF8).FirstOrDefault() + : string.Empty; + if (scriptHash.Equals(cachedHash, StringComparison.Ordinal)) + { + _log.Verbose("Running cached build script..."); + RunScriptAssembly(cachedAssembly.FullPath); + return; + } + else + { + _log.Verbose("Cache check failed."); + } + } // Generate the script code. var generator = new RoslynCodeGenerator(); var code = generator.Generate(script); @@ -159,7 +198,42 @@ public void Execute(Script script) throw new CakeException(message); } - roslynScript.RunAsync(_host).Wait(); + if (_scriptCacheEnabled) + { + // Verify cache directory exists + if (!_fileSystem.GetDirectory(_scriptCachePath).Exists) + { + _fileSystem.GetDirectory(_scriptCachePath).Create(); + } + if (string.IsNullOrWhiteSpace(scriptHash)) + { + scriptHash = FastHash.GenerateHash(Encoding.UTF8.GetBytes(string.Concat(script.Lines))); + } + var emitResult = compilation.Emit(cachedAssembly.FullPath); + + if (emitResult.Success) + { + using (var stream = _fileSystem.GetFile(hashFile).OpenWrite()) + using (var writer = new StreamWriter(stream, Encoding.UTF8)) + { + writer.Write(scriptHash); + } + RunScriptAssembly(cachedAssembly.FullPath); + } + } + else + { + roslynScript.RunAsync(_host).GetAwaiter().GetResult(); + } + } + + private void RunScriptAssembly(string assemblyPath) + { + var assembly = _loader.Load(assemblyPath, false); + var type = assembly.GetType("Submission#0"); + var factoryMethod = type.GetMethod("", new[] { typeof(object[]) }); + var task = (Task)factoryMethod.Invoke(null, new object[] { new object[] { _host, null } }); + task.GetAwaiter().GetResult(); } } } \ No newline at end of file diff --git a/src/Cake/Infrastructure/Utilities/FastHash.cs b/src/Cake/Infrastructure/Utilities/FastHash.cs new file mode 100644 index 0000000000..6fec2a5439 --- /dev/null +++ b/src/Cake/Infrastructure/Utilities/FastHash.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; + +namespace Cake.Infrastructure.Utilities +{ + /// + /// Optimized hash generator. Using SHA512 since it is FIPS compliant. + /// + internal static class FastHash + { + /// + /// Generates a hash of the passed byte arrays. + /// + /// The binary data to hash. + /// The hash value. + public static string GenerateHash(byte[] input) + { + using (var sha512 = SHA512.Create()) + { + sha512.TransformBlock(input, 0, input.Length, input, 0); + + // Just finalize with empty bytes so we don't have to iterate over the enumerable multiple times + sha512.TransformFinalBlock(Encoding.UTF8.GetBytes(string.Empty), 0, 0); + // Convert to hex string; This method is supposedly faster than the usual StringBuilder approach + return ConvertBits(sha512.Hash); + } + } + + /// + /// Generates a hash of the passed byte arrays. + /// + /// The binary data to hash. + /// The hash value. + public static string GenerateHash(IEnumerable inputs) + { + using (var sha512 = SHA512.Create()) + { + foreach (var input in inputs) + { + sha512.TransformBlock(input, 0, input.Length, input, 0); + } + + // Just finalize with empty bytes so we don't have to iterate over the enumerable multiple times + sha512.TransformFinalBlock(Encoding.UTF8.GetBytes(string.Empty), 0, 0); + // Convert to hex string; This method is supposedly faster than the usual StringBuilder approach + return ConvertBits(sha512.Hash); + } + } + + private static string ConvertBits(byte[] hash) + { +#if NETCOREAPP3_1 + return BitConverter.ToString(hash) + // without dashes + .Replace("-", string.Empty); +#else + return Convert.ToHexString(hash); +#endif + } + } +} diff --git a/tests/integration/Cake/ScriptCache.cake b/tests/integration/Cake/ScriptCache.cake new file mode 100644 index 0000000000..22d51fb6b7 --- /dev/null +++ b/tests/integration/Cake/ScriptCache.cake @@ -0,0 +1,136 @@ +#load "./../../../utilities/xunit.cake" +#load "./../../../utilities/paths.cake" +using System.Diagnostics; + +public class ScriptCacheData +{ + public FilePath ScriptPath { get; } + public FilePath ScriptCacheAssemblyPath { get; } + public FilePath ScriptCacheHashPath { get; } + public (TimeSpan Elapsed, string Hash) CompileResult { get; set; } + public (TimeSpan Elapsed, string Hash) ExecuteResult { get; set; } + public (TimeSpan Elapsed, string Hash) ReCompileResult { get; set; } + public CakeSettings Settings { get; } + private Action CakeExecuteScript { get; } + private Func CalculateFileHash { get; } + + public TimeSpan Time(Action action) + { + var stopwatch = Stopwatch.StartNew(); + try + { + action(); + } + finally + { + stopwatch.Stop(); + } + return stopwatch.Elapsed; + } + + public (TimeSpan Elapsed, string Hash) TimeCakeExecuteScript() => TimeCakeExecuteScript(args => args); + + public (TimeSpan Elapsed, string Hash) TimeCakeExecuteScript(Func argumentCustomization) => + ( + Time( + () => { + Settings.ArgumentCustomization = argumentCustomization; + CakeExecuteScript( + ScriptPath, + Settings); + }), + CalculateFileHash(ScriptCacheAssemblyPath).ToHex() + ); + + public ScriptCacheData( + DirectoryPath scriptDirectoryPath, + Action cakeExecuteScript, + Func calculateFileHash + ) + { + ScriptPath = scriptDirectoryPath.CombineWithFilePath("build.cake"); + var cacheDirectoryPath = scriptDirectoryPath.Combine("tools").Combine("cache"); + ScriptCacheAssemblyPath = cacheDirectoryPath.CombineWithFilePath("build.cake.dll"); + ScriptCacheHashPath = cacheDirectoryPath.CombineWithFilePath("build.cake.hash"); + Settings = new CakeSettings { + EnvironmentVariables = new Dictionary { + { "CAKE_SETTINGS_ENABLESCRIPTCACHE", "true" } + }, + Verbosity = Verbosity.Quiet + }; + CakeExecuteScript = cakeExecuteScript; + CalculateFileHash = calculateFileHash; + } +} + +Setup(context => + new ScriptCacheData( + Paths.Temp + .Combine("./Cake/ScriptCache"), + context.CakeExecuteScript, + context.CalculateFileHash + )); + +Task("Cake.ScriptCache.Setup") + .Does(() => +{ + var sourcePath = Paths.Resources.Combine("./Cake/ScriptCache"); + var targetPath = Paths.Temp.Combine("./Cake/ScriptCache"); + EnsureDirectoryExists(targetPath.Combine("../").Collapse()); + if (DirectoryExists(targetPath)) + { + DeleteDirectory( + targetPath, + new DeleteDirectorySettings { + Recursive = true, + Force = true + }); + } + CopyDirectory(sourcePath, targetPath); +}); + +Task("Cake.ScriptCache.Compile") + .IsDependentOn("Cake.ScriptCache.Setup") + .Does((context, data) => +{ + // Given / When + data.CompileResult = data.TimeCakeExecuteScript(); + + // Then + Assert.True(FileExists(data.ScriptCacheAssemblyPath), $"Script Cache Assembly Path {data.ScriptCacheAssemblyPath} missing."); + Assert.True(FileExists(data.ScriptCacheAssemblyPath), $"Script Cache Hash Path {data.ScriptCacheHashPath} missing."); +}); + +var scriptCacheExecute = Task("Cake.ScriptCache.Execute"); +for(var index = 1; index <= 5; index++) +{ + scriptCacheExecute.IsDependentOn( + Task($"Cake.ScriptCache.Execute.{index}") + .Does((context, data) => + { + // Given / When + data.ExecuteResult = data.TimeCakeExecuteScript(); + + // Then + Assert.True(data.CompileResult.Elapsed > data.ExecuteResult.Elapsed, $"Compile time {data.CompileResult.Elapsed} should be greater than execute time {data.ExecuteResult.Elapsed}."); + Assert.Equal(data.CompileResult.Hash, data.ExecuteResult.Hash); + }) + ); +} + +Task("Cake.ScriptCache.ReCompile") + .IsDependentOn("Cake.ScriptCache.Execute") + .Does((context, data) => { + // Given / When + data.ReCompileResult = data.TimeCakeExecuteScript(args => args.Append("--invalidate-script-cache")); + + // Then + Assert.True(data.ReCompileResult.Elapsed> data.ExecuteResult.Elapsed, $"ReCompileTime time {data.ReCompileResult.Elapsed} should be greater than execute time {data.ExecuteResult.Elapsed}."); + Assert.NotEqual(data.CompileResult.Hash , data.ReCompileResult.Hash); + }); + +Task("Cake.ScriptCache") + .IsDependentOn("Cake.ScriptCache.Setup") + .IsDependentOn("Cake.ScriptCache.Compile") + .IsDependentOn("Cake.ScriptCache.Execute") + .IsDependentOn("Cake.ScriptCache.ReCompile"); \ No newline at end of file diff --git a/tests/integration/build.cake b/tests/integration/build.cake index 143f685e50..3f7a2c536a 100644 --- a/tests/integration/build.cake +++ b/tests/integration/build.cake @@ -6,6 +6,7 @@ // Tests #load "setup.cake" #load "teardown.cake" +#load "./Cake/ScriptCache.cake" #load "./Cake.Common/ArgumentAliases.cake" #load "./Cake.Common/Build/BuildSystemAliases.cake" #load "./Cake.Common/EnvironmentAliases.cake" @@ -54,6 +55,9 @@ var target = Argument("target", "Run-All-Tests"); // TARGETS ////////////////////////////////////////////////// +Task("Cake") + .IsDependentOn("Cake.ScriptCache"); + Task("Cake.Core") .IsDependentOn("Cake.Core.Diagnostics") .IsDependentOn("Cake.Core.IO.Path") @@ -100,6 +104,7 @@ Task("Cake.Chocolatey") Task("Run-All-Tests") .IsDependentOn("Setup-Tests") + .IsDependentOn("Cake") .IsDependentOn("Cake.Core") .IsDependentOn("Cake.Common") .IsDependentOn("Cake.DotNetTool.Module") diff --git a/tests/integration/resources/Cake/ScriptCache/build.cake b/tests/integration/resources/Cake/ScriptCache/build.cake new file mode 100644 index 0000000000..e98d44e8e9 --- /dev/null +++ b/tests/integration/resources/Cake/ScriptCache/build.cake @@ -0,0 +1 @@ +Information("Hello from compiled script!");