dotnet-cqrs/Svrnty.CQRS.Grpc.Generators/GenerateProtoFileTask.cs
David Nguyen 433b852a43
All checks were successful
Publish NuGets / build (release) Successful in 40s
Refactor proto generation from source generator to MSBuild task
Replace ProtoFileSourceGenerator and WriteProtoFileTask with a new
GenerateProtoFileTask that creates its own Roslyn compilation. This
solves the timing issue where source generators run too late for
Grpc.Tools to process the generated proto files.

The new task runs after ResolveAssemblyReferences but before
_gRPC_GetProtoc and CoreCompile, ensuring the proto file exists
when Grpc.Tools needs it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 14:35:56 -05:00

257 lines
8.9 KiB
C#

#pragma warning disable RS1035 // Do not use APIs banned for analyzers - This is an MSBuild task, not an analyzer
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
namespace Svrnty.CQRS.Grpc.Generators;
/// <summary>
/// MSBuild task that generates .proto files by creating its own Roslyn compilation.
/// This runs BEFORE CoreCompile to solve the source generator timing issue.
/// </summary>
public class GenerateProtoFileTask : Task
{
/// <summary>
/// The project directory
/// </summary>
[Required]
public string ProjectDirectory { get; set; } = string.Empty;
/// <summary>
/// The output directory where the proto file should be written (typically Protos/)
/// </summary>
[Required]
public string OutputDirectory { get; set; } = string.Empty;
/// <summary>
/// The name of the proto file to generate (typically cqrs_services.proto)
/// </summary>
[Required]
public string ProtoFileName { get; set; } = string.Empty;
/// <summary>
/// The C# source files to compile (from @(Compile) ItemGroup)
/// </summary>
[Required]
public ITaskItem[] SourceFiles { get; set; } = Array.Empty<ITaskItem>();
/// <summary>
/// The assembly references (from @(ReferencePath) ItemGroup)
/// </summary>
[Required]
public ITaskItem[] References { get; set; } = Array.Empty<ITaskItem>();
/// <summary>
/// The root namespace of the project
/// </summary>
public string RootNamespace { get; set; } = string.Empty;
/// <summary>
/// The assembly name of the project
/// </summary>
public string AssemblyName { get; set; } = string.Empty;
public override bool Execute()
{
try
{
Log.LogMessage(MessageImportance.High,
"Svrnty.CQRS.Grpc: Generating proto file via MSBuild task...");
// Determine the namespace for the proto file
var projectNamespace = !string.IsNullOrEmpty(RootNamespace) ? RootNamespace
: !string.IsNullOrEmpty(AssemblyName) ? AssemblyName
: "Generated";
var grpcNamespace = $"{projectNamespace}.Grpc";
var packageName = "cqrs";
// Create the compilation
var compilation = CreateCompilation();
if (compilation == null)
{
Log.LogWarning("Svrnty.CQRS.Grpc: Could not create compilation. Writing placeholder proto file.");
WritePlaceholderProto(grpcNamespace);
return true;
}
// Check for compilation errors that would prevent proper analysis
var diagnostics = compilation.GetDiagnostics()
.Where(d => d.Severity == DiagnosticSeverity.Error)
.ToList();
if (diagnostics.Count > 0)
{
Log.LogMessage(MessageImportance.Normal,
$"Svrnty.CQRS.Grpc: Compilation has {diagnostics.Count} errors. Attempting to generate proto anyway...");
// Log first few errors for debugging
foreach (var diag in diagnostics.Take(5))
{
Log.LogMessage(MessageImportance.Low, $" {diag.GetMessage()}");
}
}
// Use the ProtoFileGenerator to generate content
var generator = new ProtoFileGenerator(compilation);
var protoContent = generator.Generate(packageName, grpcNamespace);
// Check if we got meaningful content
if (string.IsNullOrWhiteSpace(protoContent) || !protoContent.Contains("rpc "))
{
Log.LogMessage(MessageImportance.High,
"Svrnty.CQRS.Grpc: No commands/queries/notifications found. Writing minimal proto file.");
WritePlaceholderProto(grpcNamespace);
return true;
}
// Ensure output directory exists
var fullOutputPath = Path.IsPathRooted(OutputDirectory)
? OutputDirectory
: Path.Combine(ProjectDirectory, OutputDirectory);
Directory.CreateDirectory(fullOutputPath);
// Write the proto file
var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
File.WriteAllText(protoFilePath, protoContent);
Log.LogMessage(MessageImportance.High,
$"Svrnty.CQRS.Grpc: Successfully generated proto file at {protoFilePath}");
return true;
}
catch (Exception ex)
{
Log.LogErrorFromException(ex, true);
return false;
}
}
private CSharpCompilation? CreateCompilation()
{
try
{
// Parse all source files into syntax trees
var syntaxTrees = new List<SyntaxTree>();
foreach (var sourceFile in SourceFiles)
{
var filePath = sourceFile.ItemSpec;
if (!Path.IsPathRooted(filePath))
{
filePath = Path.Combine(ProjectDirectory, filePath);
}
if (!File.Exists(filePath))
{
Log.LogMessage(MessageImportance.Low, $"Source file not found: {filePath}");
continue;
}
try
{
var sourceText = File.ReadAllText(filePath);
var syntaxTree = CSharpSyntaxTree.ParseText(
sourceText,
CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest),
path: filePath);
syntaxTrees.Add(syntaxTree);
}
catch (Exception ex)
{
Log.LogMessage(MessageImportance.Low, $"Failed to parse {filePath}: {ex.Message}");
}
}
if (syntaxTrees.Count == 0)
{
Log.LogWarning("Svrnty.CQRS.Grpc: No source files could be parsed.");
return null;
}
Log.LogMessage(MessageImportance.Normal,
$"Svrnty.CQRS.Grpc: Parsed {syntaxTrees.Count} source files");
// Create metadata references from the References
var metadataReferences = new List<MetadataReference>();
foreach (var reference in References)
{
var refPath = reference.ItemSpec;
if (!File.Exists(refPath))
{
Log.LogMessage(MessageImportance.Low, $"Reference not found: {refPath}");
continue;
}
try
{
var metadataRef = MetadataReference.CreateFromFile(refPath);
metadataReferences.Add(metadataRef);
}
catch (Exception ex)
{
Log.LogMessage(MessageImportance.Low, $"Failed to load reference {refPath}: {ex.Message}");
}
}
Log.LogMessage(MessageImportance.Normal,
$"Svrnty.CQRS.Grpc: Loaded {metadataReferences.Count} references");
// Create the compilation
var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
.WithNullableContextOptions(NullableContextOptions.Enable);
var assemblyName = !string.IsNullOrEmpty(AssemblyName) ? AssemblyName : "TempCompilation";
var compilation = CSharpCompilation.Create(
assemblyName,
syntaxTrees,
metadataReferences,
compilationOptions);
return compilation;
}
catch (Exception ex)
{
Log.LogMessage(MessageImportance.High,
$"Svrnty.CQRS.Grpc: Failed to create compilation: {ex.Message}");
return null;
}
}
private void WritePlaceholderProto(string grpcNamespace)
{
var placeholderProto = $@"syntax = ""proto3"";
option csharp_namespace = ""{grpcNamespace}"";
package cqrs;
// Placeholder proto file - will be regenerated when commands/queries are available
// Using namespace: {grpcNamespace}
// Empty service definitions so Grpc.Tools generates base classes
service CommandService {{
}}
service QueryService {{
}}
service DynamicQueryService {{
}}
";
var fullOutputPath = Path.IsPathRooted(OutputDirectory)
? OutputDirectory
: Path.Combine(ProjectDirectory, OutputDirectory);
Directory.CreateDirectory(fullOutputPath);
var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
File.WriteAllText(protoFilePath, placeholderProto);
Log.LogMessage(MessageImportance.High,
$"Svrnty.CQRS.Grpc: Wrote placeholder proto file at {protoFilePath}");
}
}