diff --git a/src/IceRpc.Protobuf.Tools/IceRpc.Protobuf.Tools.props b/src/IceRpc.Protobuf.Tools/IceRpc.Protobuf.Tools.props index b0aa1b0337..3e5cfd131a 100644 --- a/src/IceRpc.Protobuf.Tools/IceRpc.Protobuf.Tools.props +++ b/src/IceRpc.Protobuf.Tools/IceRpc.Protobuf.Tools.props @@ -25,6 +25,7 @@ + <_ProtocExecutableExtension>.exe <_ProtocPluginScriptExtension>.bat diff --git a/src/IceRpc.Protobuf.Tools/IceRpc.Protobuf.Tools.targets b/src/IceRpc.Protobuf.Tools/IceRpc.Protobuf.Tools.targets index d6f0977dd6..d141ef465a 100644 --- a/src/IceRpc.Protobuf.Tools/IceRpc.Protobuf.Tools.targets +++ b/src/IceRpc.Protobuf.Tools/IceRpc.Protobuf.Tools.targets @@ -20,6 +20,7 @@ AssemblyFile="$(IceRpcProtobufToolsTaskAssembliesPath)IceRpc.Protobuf.Tools.dll" Runtime="NET" /> + + + + + + + <_ProtoOutput Include="$([MSBuild]::NormalizePath('%(_ProtoNamedFile.OutputDir)/%(_ProtoNamedFile.OutputFileName).cs'))" /> + <_ProtoOutput Include="$([MSBuild]::NormalizePath('%(_ProtoNamedFile.OutputDir)/%(_ProtoNamedFile.OutputFileName).IceRpc.cs'))" /> + <_ProtoOutput Include="$([MSBuild]::NormalizePath('%(_ProtoNamedFile.OutputDir)/%(_ProtoNamedFile.OutputFileName).d'))" /> + <_ProtoOutput Include="$([MSBuild]::NormalizePath('%(_ProtoNamedFile.OutputDir)/%(_ProtoNamedFile.OutputFileName).options'))" /> + <_ProtoOutput + Include="$([MSBuild]::NormalizePath('%(_ProtoNamedFile.OutputDir)/%(_ProtoNamedFile.OutputFileName).BuildTelemetry.txt'))" + Condition="'$(IceRpcBuildTelemetry)' == 'true' And '$(IceRpcBuildTelemetryDebug)' == 'true'" /> + + + + + + + + + + + <_StaleProtoOutput Include="@(_PreviousProtoOutput)" Exclude="@(_ProtoOutput)" + Condition="'@(_ProtoOutput)' != '' Or '@(ProtoFile)' == ''" /> + + + + + + + + + + + + + <_ProtocInputsCacheLine Include="protoc=$(ProtocBundledPluginVersion)" /> + <_ProtocInputsCacheLine Include="icerpc-csharp=$(ProtocIceRpcPluginVersion)" /> + <_ProtocInputsCacheLine Include="@(ProtoSearchPath->'search-path=%(FullPath)')" /> + + + + + + + + <_ProtocInput Include="$(IntermediateOutputPath)protoc.inputs.cache" /> + <_ProtocInput Include="$(IceRpcProtocPath)$(IceRpcProtocPrefix)/protoc$(_ProtocExecutableExtension)" /> + <_ProtocInput Include="$(IceRpcProtocGenPath)IceRpc.Protobuf.Generator.dll" /> + + + + + - + @@ -121,18 +211,24 @@ /> - - - - - - - - - + + + + + + + + + + diff --git a/src/IceRpc.Protobuf.Tools/README.md b/src/IceRpc.Protobuf.Tools/README.md index 824d439073..de4723e195 100644 --- a/src/IceRpc.Protobuf.Tools/README.md +++ b/src/IceRpc.Protobuf.Tools/README.md @@ -75,9 +75,15 @@ once per Proto file. | Pack | `false` | Specifies whether or not to include the items (Proto files) in the NuGet package. | | PackagePath | protobuf | Sets the target path in the NuGet package. Used only when Pack is `true`. | -> [!NOTE] -> Changing `AdditionalOptions` does not mark previously generated code as out of date. Run `dotnet clean` and then -> build again to regenerate the code with the new options. +## Incremental builds + +The build runs `protoc` only for the Proto files whose generated code is missing or out of date. A Proto file is +out of date when the Proto file itself, one of the files it imports, `protoc` or the `protoc-gen-icerpc-csharp` +generator is newer than one of its generated files, or when its `AdditionalOptions` differ from those used to generate +it. Changing `ProtoSearchPath` or upgrading this package regenerates the code of all Proto files. + +When you remove a Proto file from the project, rename it, or change its `OutputDir`, the next build deletes the code +previously generated for it. ## Generated code and NuGet packages diff --git a/src/IceRpc.Protobuf.Tools/UpToDateCheckTask.cs b/src/IceRpc.Protobuf.Tools/UpToDateCheckTask.cs index d6a2e48e94..e82565636b 100644 --- a/src/IceRpc.Protobuf.Tools/UpToDateCheckTask.cs +++ b/src/IceRpc.Protobuf.Tools/UpToDateCheckTask.cs @@ -12,6 +12,11 @@ namespace IceRpc.Protobuf.Tools; /// A MSBuild task to compute what Protobuf files have to be rebuild by protoc. public class UpToDateCheckTask : Microsoft.Build.Utilities.Task { + /// Gets or sets additional input files that every source depends on, typically the protoc + /// compiler and the code generator plug-in. A source is out of date when any of these files is missing or is + /// newer than one of the source's outputs. + public ITaskItem[] AdditionalInputs { get; set; } = []; + /// Gets or sets the output directory for the generated code. [Required] public string OutputDir { get; set; } = ""; @@ -31,42 +36,32 @@ public class UpToDateCheckTask : Microsoft.Build.Utilities.Task /// item is up to date or needs to be rebuilt. The OutputFileName metadata contains the base file name for /// the generated outputs. This is the input item's file name without the extension, and converted to PascalCase. /// + /// A source is up to date only when all of its outputs exist, every input recorded in its dependency + /// file, its options record and every entry exists, and the newest input is older + /// than the oldest output. /// Returns if the task was executed successfully, /// otherwise. public override bool Execute() { - var computedSources = new List(); + string[] additionalInputs = [.. AdditionalInputs.Select(item => item.GetMetadata("FullPath"))]; + var computedSources = new List(); foreach (ITaskItem source in Sources) { - bool upToDate = true; string fileName = source.GetMetadata("FileName").ToProtocPascalCase(); string dependOutput = Path.Combine(OutputDir, $"{fileName}.d"); - string csharpOutput = Path.Combine(OutputDir, $"{fileName}.cs"); - string icerpcOutput = Path.Combine(OutputDir, $"{fileName}.IceRpc.cs"); + string[] outputs = + [ + dependOutput, + Path.Combine(OutputDir, $"{fileName}.cs"), + Path.Combine(OutputDir, $"{fileName}.IceRpc.cs"), + ]; - if (File.Exists(dependOutput) && File.Exists(csharpOutput) && File.Exists(icerpcOutput)) - { - long lastWriteTime = Math.Max( - File.GetLastWriteTime(dependOutput).Ticks, - File.GetLastWriteTime(csharpOutput).Ticks); - lastWriteTime = Math.Max(lastWriteTime, File.GetLastWriteTime(icerpcOutput).Ticks); - List dependencies = ProcessDependencies(dependOutput); - foreach (string dependency in dependencies) - { - if (File.GetLastWriteTime(dependency).Ticks >= lastWriteTime) - { - // If a dependency is newer than any of the outputs the source is not up to date. - upToDate = false; - break; - } - } - } - else - { - // If any of the outputs is missing the file is not up to date. - upToDate = false; - } + // The options record is the source's own inputs cache: the build rewrites it only when the source's + // AdditionalOptions change, so it is newer than the outputs exactly then. + string[] inputs = [.. additionalInputs, Path.Combine(OutputDir, $"{fileName}.options")]; + + bool upToDate = IsUpToDate(source.ItemSpec, outputs, dependOutput, inputs); var computedSource = new TaskItem(source.ItemSpec); source.CopyMetadataTo(computedSource); @@ -78,36 +73,68 @@ public override bool Execute() ComputedSources = [.. computedSources]; return true; + } - static List ProcessDependencies(string dependOutput) + private bool IsUpToDate(string source, string[] outputs, string dependOutput, string[] additionalInputs) + { + string? missingOutput = outputs.FirstOrDefault(output => !File.Exists(output)); + if (missingOutput is not null) { - var depends = new List(); - string dependContents = File.ReadAllText(dependOutput); + Log.LogMessage(MessageImportance.Low, $"'{source}' is out of date: output '{missingOutput}' is missing."); + return false; + } - // Strip everything before and including "Xxx.cs:" (the output target). - const string outputPrefix = ".cs:"; - int i = dependContents.IndexOf(outputPrefix, StringComparison.CurrentCultureIgnoreCase); - if (i == -1 || i + outputPrefix.Length >= dependContents.Length) + // Every output must be newer than every input. + long oldestOutputTime = outputs.Min(output => File.GetLastWriteTime(output).Ticks); + + foreach (string input in ProcessDependencies(dependOutput).Concat(additionalInputs)) + { + if (!File.Exists(input)) { - return depends; + Log.LogMessage(MessageImportance.Low, $"'{source}' is out of date: input '{input}' is missing."); + return false; } - dependContents = dependContents[(i + outputPrefix.Length)..]; - - // The Make depfile format uses '\' at end of line as a line continuation, and escapes - // spaces inside paths as '\ '. Windows directory separators are emitted as literal '\' - // (not escaped). We split on newlines, strip the trailing continuation '\' and whitespace, - // then unescape '\ ' -> ' ' so paths containing spaces resolve correctly. - foreach (string line in dependContents.Split('\n')) + if (File.GetLastWriteTime(input).Ticks >= oldestOutputTime) { - string filePath = line.TrimEnd().TrimEnd('\\').Trim().Replace("\\ ", " ", StringComparison.Ordinal); - if (!string.IsNullOrEmpty(filePath)) - { - depends.Add(Path.GetFullPath(filePath)); - } + Log.LogMessage( + MessageImportance.Low, + $"'{source}' is out of date: input '{input}' is newer than one of its outputs."); + return false; } + } + + return true; + } + private static List ProcessDependencies(string dependOutput) + { + var depends = new List(); + string dependContents = File.ReadAllText(dependOutput); + + // Strip everything before and including "Xxx.cs:" (the output target). + const string outputPrefix = ".cs:"; + int i = dependContents.IndexOf(outputPrefix, StringComparison.CurrentCultureIgnoreCase); + if (i == -1 || i + outputPrefix.Length >= dependContents.Length) + { return depends; } + + dependContents = dependContents[(i + outputPrefix.Length)..]; + + // The Make depfile format uses '\' at end of line as a line continuation, and escapes + // spaces inside paths as '\ '. Windows directory separators are emitted as literal '\' + // (not escaped). We split on newlines, strip the trailing continuation '\' and whitespace, + // then unescape '\ ' -> ' ' so paths containing spaces resolve correctly. + foreach (string line in dependContents.Split('\n')) + { + string filePath = line.TrimEnd().TrimEnd('\\').Trim().Replace("\\ ", " ", StringComparison.Ordinal); + if (!string.IsNullOrEmpty(filePath)) + { + depends.Add(Path.GetFullPath(filePath)); + } + } + + return depends; } }