diff --git a/bin/BuildTool/BuildInfo.cs b/bin/BuildTool/BuildInfo.cs index 860e50f..6216cb8 100644 --- a/bin/BuildTool/BuildInfo.cs +++ b/bin/BuildTool/BuildInfo.cs @@ -1,6 +1,6 @@ namespace BuildTool; -class BuildInfo +struct BuildInfo { public WorkingDirectoryHistory WorkingDirectory; public bool ForceRebuild; diff --git a/bin/BuildTool/BuildTool.cs b/bin/BuildTool/BuildTool.cs index 9c91de2..ae2c870 100644 --- a/bin/BuildTool/BuildTool.cs +++ b/bin/BuildTool/BuildTool.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.Diagnostics; +using System.Net; using SimpleExec; using Spectre.Console; using BuildTool.Modules; @@ -247,7 +248,11 @@ class Program } List acutalModuleOrder = allModulesOrdered.Intersect(modulesToBuild).ToList(); - + + string cacheFilePath = Path.Combine(workspaceRoot.FullName, "build", ".build_tool.json"); + + ModuleCache moduleCache = ModuleCache.Load(cacheFilePath); + Console.WriteLine($"Building modules: {string.Join(", ", acutalModuleOrder.Select(m => m.Name))}"); await Task.Delay(1000); @@ -263,21 +268,33 @@ class Program AnsiConsole.MarkupLine(message); Console.WriteLine(new string('-', message.Length)); Console.WriteLine(); - + buildInfo.WorkingDirectory.NavigateToIndex(0); + // create a copy of the build info updated with the module's watched sources and cache info (last build time etc.) + BuildInfo moduleBuildInfo = module.GetBuildInfo(buildInfo, moduleCache); + try { - await module.Run(buildInfo); - + await module.Run(moduleBuildInfo); + AnsiConsole.MarkupLine($"\n[green]Module [bold italic]{module.Name}[/] completed successfully.[/]\n"); + + module.UpdateCacheInfo(moduleBuildInfo, moduleCache, true); } catch (ExitCodeException e) { - AnsiConsole.MarkupLine($"\n[bold red]Error:[/] Module [bold italic]{module.Name}[/] failed with exit code: [red]{e.ExitCode}[/]\n"); + AnsiConsole.MarkupLine( + $"\n[bold red]Error:[/] Module [bold italic]{module.Name}[/] failed with exit code: [red]{e.ExitCode}[/]\n"); + + module.UpdateCacheInfo(moduleBuildInfo, moduleCache, false); break; } + finally + { + moduleCache.Save(cacheFilePath); + } } } } diff --git a/bin/BuildTool/Module.cs b/bin/BuildTool/Module.cs index 5e6b5ed..1c857ca 100644 --- a/bin/BuildTool/Module.cs +++ b/bin/BuildTool/Module.cs @@ -1,4 +1,8 @@ -namespace BuildTool; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; + +namespace BuildTool; abstract class Module { @@ -8,4 +12,173 @@ abstract class Module public abstract List DependencyTypes { get; } public abstract Task Run(BuildInfo buildInfo); + + protected virtual List GetWatchedSources(BuildInfo buildInfo) => []; + + /// + /// Check whether the requested build configurations actually need to be rebuilt. + /// + /// + /// + /// The updated build information. + public BuildInfo GetBuildInfo(BuildInfo buildInfo, ModuleCache moduleBuildCache) + { + BuildInfo moduleBuildInfo = buildInfo; + + if (buildInfo.BuildDebug) + { + ModuleInfo cacheInfo = moduleBuildCache.GetModuleInfo(Name, BuildConfig.Debug); + + moduleBuildInfo.BuildDebug = HasSourceChanged(moduleBuildInfo, cacheInfo); + } + + if (buildInfo.BuildRelease) + { + ModuleInfo cacheInfo = moduleBuildCache.GetModuleInfo(Name, BuildConfig.Release); + + moduleBuildInfo.BuildRelease = HasSourceChanged(moduleBuildInfo, cacheInfo); + } + + return moduleBuildInfo; + } + + public bool HasSourceChanged(BuildInfo buildInfo, ModuleInfo cacheInfo) + { + bool needsRebuild = false; + + if (buildInfo.ForceRebuild) + needsRebuild = true; + + // If we never successfully built before, we need to build now. + if (cacheInfo.LastSuccessfulBuild == DateTime.MinValue) + needsRebuild = true; + + List watchedSources = GetWatchedSources(buildInfo); + + if (watchedSources.Count == 0) + needsRebuild = true; + + foreach (WatchedSource source in watchedSources) + { + bool cacheEntryFound = cacheInfo.TryGetCachedHash(source.Path, out SourceHash cachedHash); + + SourceHash newHash; + + if (Directory.Exists(source.Path)) + { + Debug.Assert(source.Mode == WatchMode.Metadata, "Directory watching is only supported in Metadata mode."); + + DirectoryInfo dirInfo = new (source.Path); + + if (dirInfo.LastWriteTimeUtc > cacheInfo.LastSuccessfulBuild) + { + Console.WriteLine("Needs rebuild"); + needsRebuild = true; + } + + // Since we don't save anything directory specific in the has, we can skip this check if we already know that we are going to rebuild. + if (!needsRebuild) + { + foreach (FileSystemInfo dirEntry in dirInfo.EnumerateFileSystemInfos("**", + enumerationOptions: new EnumerationOptions() + { MatchType = MatchType.Win32, RecurseSubdirectories = source.Recursive })) + { + //string relativePath = Path.GetRelativePath(dirInfo.FullName, dirEntry.FullName); + //if (source.ExcludedDirectories.Contains(relativePath, StringComparer.OrdinalIgnoreCase)) + // continue; + + Console.WriteLine("Checking entry: " + dirEntry.FullName); + if (dirEntry.LastWriteTimeUtc > cacheInfo.LastSuccessfulBuild) + { + Console.WriteLine("Needs rebuild"); + needsRebuild = true; + break; + } + } + } + + // We really just need any timestamp, to know that the directory existed at some point. + newHash = SourceHash.CreateTimeStamp(DateTime.UtcNow, 1); + } + else if (File.Exists(source.Path)) + { + FileInfo fileInfo = new (source.Path); + + if (source.Mode == WatchMode.Metadata) + { + newHash = SourceHash.CreateTimeStamp(fileInfo.LastWriteTimeUtc, fileInfo.Length); + + if (newHash != cachedHash) + needsRebuild = true; + } + else if (source.Mode == WatchMode.Content) + { + string hash = ComputeFileHash(fileInfo.FullName); + newHash = SourceHash.CreateHash(hash, fileInfo.Length); + + if (newHash != cachedHash) + { + Console.WriteLine("Needs rebuild"); + needsRebuild = true; + } + } + else + { + throw new InvalidOperationException("Unsupported file watch mode: " + source.Mode); + } + } + else + { + if (source.Mode == WatchMode.Metadata) + { + newHash = SourceHash.CreateTimeStamp(DateTime.MinValue, -1); + } + else + { + newHash = SourceHash.CreateHash(string.Empty, -1); + } + + if (!cacheEntryFound || newHash != cachedHash) + { + // If we didn't have a cache entry before then this is the first time building. + // If we had a cache entry before we need to check whether it didn't exist before. + needsRebuild = true; + } + } + + cacheInfo.SetNewHash(source.Path, newHash); + } + + return needsRebuild; + } + + private static string ComputeFileHash(string filePath) + { + using var stream = File.OpenRead(filePath); + using var sha256 = SHA256.Create(); + + byte[] hash = sha256.ComputeHash(stream); + return Convert.ToHexString(hash); + } + + public void UpdateCacheInfo(BuildInfo buildInfo, ModuleCache moduleCache, bool success) + { + if (buildInfo.BuildDebug) + { + ModuleInfo cacheInfo = moduleCache.GetModuleInfo(Name, BuildConfig.Debug); + cacheInfo.LastSuccessfulBuild = success ? DateTime.UtcNow : DateTime.MinValue; + + if (!success) + cacheInfo.ResetHashes(); + } + + if (buildInfo.BuildRelease) + { + ModuleInfo cacheInfo = moduleCache.GetModuleInfo(Name, BuildConfig.Release); + cacheInfo.LastSuccessfulBuild = success ? DateTime.UtcNow : DateTime.MinValue; + + if (!success) + cacheInfo.ResetHashes(); + } + } } diff --git a/bin/BuildTool/ModuleCache.cs b/bin/BuildTool/ModuleCache.cs new file mode 100644 index 0000000..049e8e0 --- /dev/null +++ b/bin/BuildTool/ModuleCache.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace BuildTool; + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(Dictionary))] +internal partial class SourceGenerationContext : JsonSerializerContext { } + +internal class ModuleCache +{ + private Dictionary _modules = new(); + + public static ModuleCache Load(string filePath) + { + if (!File.Exists(filePath)) + { + return new ModuleCache(); + } + + using FileStream stream = File.OpenRead(filePath); + Dictionary? modules = + JsonSerializer.Deserialize(stream, SourceGenerationContext.Default.DictionaryStringModuleInfo); + + return new ModuleCache() + { + _modules = modules ?? new Dictionary() + }; + } + + public void Save(string filePath) + { + using FileStream stream = File.Create(filePath); + JsonSerializer.Serialize(stream, _modules, SourceGenerationContext.Default.DictionaryStringModuleInfo); + } + + private static string GetModuleKey(string moduleName, BuildConfig configuration) => + $"{moduleName}: {configuration}"; + + public void SetModuleInfo(ModuleInfo moduleInfo) + { + _modules[GetModuleKey(moduleInfo.Name, moduleInfo.Configuration)] = moduleInfo; + } + + public ModuleInfo GetModuleInfo(string moduleName, BuildConfig configuration) + { + if (_modules.TryGetValue(GetModuleKey(moduleName, configuration), out ModuleInfo? moduleInfo)) + { + return moduleInfo; + } + + ModuleInfo info = new() + { + Name = moduleName, + Configuration = configuration, + LastSuccessfulBuild = DateTime.MinValue + }; + + SetModuleInfo(info); + + return info; + } + +} + +public enum BuildConfig +{ + Debug, + Release +} + +public record ModuleInfo +{ + public string Name { get; set; } + public BuildConfig Configuration { get; set; } + + public DateTime LastSuccessfulBuild { get; set; } + + [JsonIgnore] + private Dictionary _cachedSourceHashes = new(); + + [JsonIgnore] + private Dictionary _newSourceHashes = new(); + + /// + /// Serializes and deserializes into . + /// + [JsonInclude] + [JsonPropertyName("hashes")] + internal Dictionary Hashes + { + get => _newSourceHashes; + set => _cachedSourceHashes = value; + } + + public bool TryGetCachedHash(string fileName, out SourceHash cachedHash) + { + return _cachedSourceHashes.TryGetValue(fileName, out cachedHash); + } + + public void SetNewHash(string fileName, SourceHash hash) + { + _newSourceHashes[fileName] = hash; + } + + public void ResetHashes() + { + _cachedSourceHashes = _newSourceHashes; + _newSourceHashes = new(); + } +} + +public enum HashType +{ + Hash, + Date +} + +public struct SourceHash : IEquatable +{ + public HashType Type { get; init; } + public string? Hash { get; init; } + public long Length { get; init; } + + public static SourceHash CreateHash(string hash, long length) + { + return new SourceHash() + { + Type = HashType.Hash, + Hash = hash, + Length = length + }; + } + + public static SourceHash CreateTimeStamp(DateTime timeStamp, long length) + { + return new SourceHash() + { + Type = HashType.Date, + Hash = timeStamp.ToString("o") // ISO 8601 format + }; + } + + public static bool operator ==(SourceHash left, SourceHash right) => left.Equals(right); + public static bool operator !=(SourceHash left, SourceHash right) => !left.Equals(right); + + public override bool Equals([NotNullWhen(true)] object? obj) + { + return base.Equals(obj); + } + + public bool Equals(SourceHash other) + { + return Type == other.Type && Hash == other.Hash && Length == other.Length; + } + + public override int GetHashCode() + { + return HashCode.Combine((int)Type, Hash, Length); + } +} diff --git a/bin/BuildTool/Modules/ScriptCoreModule.cs b/bin/BuildTool/Modules/ScriptCoreModule.cs index ff7216c..436d612 100644 --- a/bin/BuildTool/Modules/ScriptCoreModule.cs +++ b/bin/BuildTool/Modules/ScriptCoreModule.cs @@ -12,7 +12,7 @@ class ScriptCoreModule : Module public override async Task Run(BuildInfo buildInfo) { - string projectFile = Path.Join(buildInfo.WorkingDirectory.WorkspaceRoot, "ScriptCore/ScriptCore.csproj"); + string projectFile = Path.Join(buildInfo.WorkingDirectory.WorkspaceRoot, "ScriptCore", "ScriptCore.csproj"); if (buildInfo.BuildDebug) { @@ -26,4 +26,25 @@ class ScriptCoreModule : Module return true; } + + protected override List GetWatchedSources(BuildInfo buildInfo) + { + List sources = new(); + + // Watch ScriptGlue-Definitions file by content + sources.Add(new WatchedSource("generated/ScriptGlue.json", WatchMode.Content)); + + // Watch source files by metadata + sources.Add(new WatchedSource("ScriptCore/", WatchMode.Metadata) + { + Recursive = true, + ExcludedDirectories = + [ + "bin", + "obj" + ] + }); + + return sources; + } } \ No newline at end of file diff --git a/bin/BuildTool/Properties/launchSettings.json b/bin/BuildTool/Properties/launchSettings.json index 483840d..944850b 100644 --- a/bin/BuildTool/Properties/launchSettings.json +++ b/bin/BuildTool/Properties/launchSettings.json @@ -3,6 +3,10 @@ "BuildTool": { "commandName": "Project", "commandLineArgs": "build All --debug --workspace \"$(ProjectDir)/../../\"" + }, + "Build ScriptCore": { + "commandName": "Project", + "commandLineArgs": "build ScriptCore --debug -n --workspace \"$(ProjectDir)/../../\"" } } } \ No newline at end of file diff --git a/bin/BuildTool/WatchedSource.cs b/bin/BuildTool/WatchedSource.cs new file mode 100644 index 0000000..ca2f5fb --- /dev/null +++ b/bin/BuildTool/WatchedSource.cs @@ -0,0 +1,35 @@ +namespace BuildTool; + +public enum WatchMode +{ + /// + /// Check if the files/directories have changed by comparing their metadata (e.g. last modified time). + /// + Metadata, + /// + /// For files only, check if the content of the file has changed by comparing a hash of the file contents. + /// + Content +} + +internal record WatchedSource(string Path, WatchMode Mode) +{ + /// + /// Path to a file or directory to be checked for changes. + /// + public string Path { get; init; } = Path; + + /// + /// Gets or sets whether to check for changes in the file's metadata (e.g. last modified time) or content (e.g. hash of the file contents). + /// + public WatchMode Mode { get; init; } = Mode; + + /// + /// Only if Path points to a directory, a list of subdirectories to exclude from change checking. + /// + public List ExcludedDirectories { get; init; } = new(); + /// + /// Only if Path points to a directory, whether to check for changes in subdirectories as well. + /// + public bool Recursive { get; init; } = true; +} diff --git a/bin/build.ps1 b/bin/build.ps1 new file mode 100644 index 0000000..4e8fbb7 --- /dev/null +++ b/bin/build.ps1 @@ -0,0 +1,2 @@ +# Run the BuildTool project as release, pass all arguments to it. +dotnet run --project bin/BuildTool/BuildTool.csproj --configuration Release -- $args