From 433b852a43b9669bcca80baa7c0e9ce766e12faa Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 26 Jan 2026 14:35:56 -0500 Subject: [PATCH] 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 --- .../GenerateProtoFileTask.cs | 256 ++++++++++++++++++ .../ProtoFileSourceGenerator.cs | 124 --------- .../WriteProtoFileTask.cs | 187 ------------- .../build/Svrnty.CQRS.Grpc.Generators.targets | 37 ++- Svrnty.Sample/Protos/cqrs_services.proto | 50 ---- 5 files changed, 283 insertions(+), 371 deletions(-) create mode 100644 Svrnty.CQRS.Grpc.Generators/GenerateProtoFileTask.cs delete mode 100644 Svrnty.CQRS.Grpc.Generators/ProtoFileSourceGenerator.cs delete mode 100644 Svrnty.CQRS.Grpc.Generators/WriteProtoFileTask.cs diff --git a/Svrnty.CQRS.Grpc.Generators/GenerateProtoFileTask.cs b/Svrnty.CQRS.Grpc.Generators/GenerateProtoFileTask.cs new file mode 100644 index 0000000..2a22459 --- /dev/null +++ b/Svrnty.CQRS.Grpc.Generators/GenerateProtoFileTask.cs @@ -0,0 +1,256 @@ +#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; + +/// +/// MSBuild task that generates .proto files by creating its own Roslyn compilation. +/// This runs BEFORE CoreCompile to solve the source generator timing issue. +/// +public class GenerateProtoFileTask : Task +{ + /// + /// The project directory + /// + [Required] + public string ProjectDirectory { get; set; } = string.Empty; + + /// + /// The output directory where the proto file should be written (typically Protos/) + /// + [Required] + public string OutputDirectory { get; set; } = string.Empty; + + /// + /// The name of the proto file to generate (typically cqrs_services.proto) + /// + [Required] + public string ProtoFileName { get; set; } = string.Empty; + + /// + /// The C# source files to compile (from @(Compile) ItemGroup) + /// + [Required] + public ITaskItem[] SourceFiles { get; set; } = Array.Empty(); + + /// + /// The assembly references (from @(ReferencePath) ItemGroup) + /// + [Required] + public ITaskItem[] References { get; set; } = Array.Empty(); + + /// + /// The root namespace of the project + /// + public string RootNamespace { get; set; } = string.Empty; + + /// + /// The assembly name of the project + /// + 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(); + 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(); + 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}"); + } +} diff --git a/Svrnty.CQRS.Grpc.Generators/ProtoFileSourceGenerator.cs b/Svrnty.CQRS.Grpc.Generators/ProtoFileSourceGenerator.cs deleted file mode 100644 index 0e065db..0000000 --- a/Svrnty.CQRS.Grpc.Generators/ProtoFileSourceGenerator.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Collections.Immutable; -using System.Linq; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace Svrnty.CQRS.Grpc.Generators; - -/// -/// Incremental source generator that generates .proto files from C# commands and queries -/// -[Generator] -public class ProtoFileSourceGenerator : IIncrementalGenerator -{ - public void Initialize(IncrementalGeneratorInitializationContext context) - { - // Register a post-initialization output to generate the proto file - context.RegisterPostInitializationOutput(ctx => - { - // Generate a placeholder - the actual proto will be generated in the source output - }); - - // Collect type declarations to trigger generation - // We use any type declaration as a trigger since ProtoFileGenerator scans all assemblies - var typeDeclarations = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (s, _) => s is TypeDeclarationSyntax, - transform: static (ctx, _) => GetTypeSymbol(ctx)) - .Where(static m => m is not null) - .Collect(); - - // Combine with compilation to have access to it - var compilationAndTypes = context.CompilationProvider.Combine(typeDeclarations); - - // Generate proto file when commands/queries change - context.RegisterSourceOutput(compilationAndTypes, (spc, source) => - { - var (compilation, types) = source; - - // Note: We no longer bail out early since ProtoFileGenerator now scans all referenced assemblies - // The types from source are just a trigger - the generator will find types from all assemblies - - try - { - // Get the root namespace from the compilation - this matches what GrpcGenerator does - var rootNamespace = compilation.AssemblyName ?? "Generated"; - var packageName = "cqrs"; - var csharpNamespace = $"{rootNamespace}.Grpc"; - - // Generate the proto file content - var generator = new ProtoFileGenerator(compilation); - var protoContent = generator.Generate(packageName, csharpNamespace); - - // Output as an embedded resource that can be extracted - var protoFileName = "cqrs_services.proto"; - - // Generate a C# class that contains the proto content - // This allows build tools to extract it if needed - var csContent = $$""" - // - #nullable enable - - namespace Svrnty.CQRS.Grpc.Generated - { - /// - /// Contains the auto-generated Protocol Buffer definition - /// - public static class GeneratedProtoFile - { - public const string FileName = "{{protoFileName}}"; - - public const string Content = @"{{protoContent.Replace("\"", "\"\"")}}"; - } - } - """; - - spc.AddSource("GeneratedProtoFile.g.cs", csContent); - - // Report that we generated the proto content - var descriptor = new DiagnosticDescriptor( - "CQRSGRPC002", - "Proto file generated", - "Generated proto file content in GeneratedProtoFile class", - "Svrnty.CQRS.Grpc", - DiagnosticSeverity.Info, - isEnabledByDefault: true); - - spc.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None)); - } - catch (Exception ex) - { - // Report diagnostic if generation fails - var descriptor = new DiagnosticDescriptor( - "CQRSGRPC001", - "Proto file generation failed", - "Failed to generate proto file: {0}", - "Svrnty.CQRS.Grpc", - DiagnosticSeverity.Warning, - isEnabledByDefault: true); - - spc.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None, ex.Message)); - } - }); - } - - private static INamedTypeSymbol? GetTypeSymbol(GeneratorSyntaxContext context) - { - var typeDecl = (TypeDeclarationSyntax)context.Node; - var symbol = context.SemanticModel.GetDeclaredSymbol(typeDecl) as INamedTypeSymbol; - - // Skip if it has GrpcIgnore attribute - if (symbol?.GetAttributes().Any(a => a.AttributeClass?.Name == "GrpcIgnoreAttribute") == true) - return null; - - return symbol; - } - - private static string? GetBuildProperty(SourceProductionContext context, string propertyName) - { - // Try to get build properties from the compilation options - // This is a simplified approach - in practice, you might need analyzer config - return null; // Will use defaults - } -} diff --git a/Svrnty.CQRS.Grpc.Generators/WriteProtoFileTask.cs b/Svrnty.CQRS.Grpc.Generators/WriteProtoFileTask.cs deleted file mode 100644 index 894b1c6..0000000 --- a/Svrnty.CQRS.Grpc.Generators/WriteProtoFileTask.cs +++ /dev/null @@ -1,187 +0,0 @@ -#pragma warning disable RS1035 // Do not use APIs banned for analyzers - This is an MSBuild task, not an analyzer - -using System; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; - -namespace Svrnty.CQRS.Grpc.Generators; - -/// -/// MSBuild task that extracts the auto-generated proto file content from the source generator -/// output and writes it to disk so Grpc.Tools can process it -/// -public class WriteProtoFileTask : Task -{ - /// - /// The project directory where we should look for generated files - /// - [Required] - public string ProjectDirectory { get; set; } = string.Empty; - - /// - /// The intermediate output path (typically obj/Debug/net10.0) - /// - [Required] - public string IntermediateOutputPath { get; set; } = string.Empty; - - /// - /// The output directory where the proto file should be written (typically Protos/) - /// - [Required] - public string OutputDirectory { get; set; } = string.Empty; - - /// - /// The name of the proto file to generate (typically cqrs_services.proto) - /// - [Required] - public string ProtoFileName { get; set; } = string.Empty; - - /// - /// The root namespace of the project (optional, falls back to AssemblyName) - /// - public string RootNamespace { get; set; } = string.Empty; - - /// - /// The assembly name of the project (used for proto namespace if RootNamespace not set) - /// - public string AssemblyName { get; set; } = string.Empty; - - public override bool Execute() - { - try - { - Log.LogMessage(MessageImportance.High, - "Svrnty.CQRS.Grpc: Extracting auto-generated proto file..."); - - // Look for the generated C# file containing the proto content - // Source generators output to obj/Generated, not IntermediateOutputPath/Generated - var generatedFilePath = Path.Combine( - ProjectDirectory, - "obj", - "Generated", - "Svrnty.CQRS.Grpc.Generators", - "Svrnty.CQRS.Grpc.Generators.ProtoFileSourceGenerator", - "GeneratedProtoFile.g.cs" - ); - - // Check if proto file already exists (committed to repo or from previous build) - var existingProtoPath = Path.Combine(ProjectDirectory, OutputDirectory, ProtoFileName); - if (File.Exists(existingProtoPath) && !File.Exists(generatedFilePath)) - { - Log.LogMessage(MessageImportance.High, - $"Svrnty.CQRS.Grpc: Using existing proto file at {existingProtoPath}. " + - "To regenerate, delete the file and build twice."); - return true; - } - - if (!File.Exists(generatedFilePath)) - { - Log.LogWarning( - $"Generated proto file not found at {generatedFilePath}. " + - "The proto file may not have been generated yet. This is normal on first build."); - - // Write a minimal placeholder proto file so Grpc.Tools doesn't fail - // The real content will be generated on the next build - // Use project's namespace so GrpcGenerator can find the service base types - var projectNamespace = !string.IsNullOrEmpty(RootNamespace) ? RootNamespace - : !string.IsNullOrEmpty(AssemblyName) ? AssemblyName - : "Generated"; - var grpcNamespace = $"{projectNamespace}.Grpc"; - - var placeholderProto = $@"syntax = ""proto3""; - -option csharp_namespace = ""{grpcNamespace}""; - -package cqrs; - -// Placeholder proto file - will be regenerated on next build with actual services -// Using namespace: {grpcNamespace} - -// Empty service definitions so Grpc.Tools generates base classes -service CommandService {{ -}} - -service QueryService {{ -}} - -service DynamicQueryService {{ -}} -"; - var placeholderOutputPath = Path.Combine(ProjectDirectory, OutputDirectory); - Directory.CreateDirectory(placeholderOutputPath); - var placeholderProtoFilePath = Path.Combine(placeholderOutputPath, ProtoFileName); - File.WriteAllText(placeholderProtoFilePath, placeholderProto); - - Log.LogMessage(MessageImportance.High, - $"Svrnty.CQRS.Grpc: Wrote placeholder proto file at {placeholderProtoFilePath}. " + - "Run build again to generate the actual proto content."); - - return true; - } - - // Read the generated C# file - var csContent = File.ReadAllText(generatedFilePath); - - // Extract the proto content using a more robust approach - // Looking for: public const string Content = @"..."; - var startMarker = "public const string Content = @\""; - var startIndex = csContent.IndexOf(startMarker); - - if (startIndex < 0) - { - Log.LogError($"Could not find Content property in {generatedFilePath}"); - return false; - } - - startIndex += startMarker.Length; - - // Find the closing "; - We need the LAST occurrence because the content contains escaped quotes - // The pattern is: Content = @"...content..."; - // where content has "" for literal quotes - var endMarker = "\";"; - - // Find where the next field starts or class ends to limit our search - var nextFieldOrEnd = csContent.IndexOf("\n }", startIndex); // End of class - if (nextFieldOrEnd < 0) - { - nextFieldOrEnd = csContent.Length; - } - - var endIndex = csContent.LastIndexOf(endMarker, nextFieldOrEnd, nextFieldOrEnd - startIndex); - - if (endIndex < 0 || endIndex < startIndex) - { - Log.LogError($"Could not find end of Content property in {generatedFilePath}"); - return false; - } - - // Extract and unescape doubled quotes - var protoContent = csContent.Substring(startIndex, endIndex - startIndex); - protoContent = protoContent.Replace("\"\"", "\""); - - Log.LogMessage(MessageImportance.High, - $"Extracted proto content length: {protoContent.Length} characters"); - - // Ensure output directory exists - var fullOutputPath = 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; - } - } -} diff --git a/Svrnty.CQRS.Grpc.Generators/build/Svrnty.CQRS.Grpc.Generators.targets b/Svrnty.CQRS.Grpc.Generators/build/Svrnty.CQRS.Grpc.Generators.targets index e4584c1..d977f97 100644 --- a/Svrnty.CQRS.Grpc.Generators/build/Svrnty.CQRS.Grpc.Generators.targets +++ b/Svrnty.CQRS.Grpc.Generators/build/Svrnty.CQRS.Grpc.Generators.targets @@ -9,30 +9,47 @@ <_GeneratorsAssemblyPath Condition="Exists('$(MSBuildThisFileDirectory)\..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll + <_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)Svrnty.CQRS.Grpc.Generators.dll <_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)\..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll <_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)\..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll - - + - - + + - - - - + + + + + + + + diff --git a/Svrnty.Sample/Protos/cqrs_services.proto b/Svrnty.Sample/Protos/cqrs_services.proto index 10bad1c..b84bd32 100644 --- a/Svrnty.Sample/Protos/cqrs_services.proto +++ b/Svrnty.Sample/Protos/cqrs_services.proto @@ -21,13 +21,6 @@ service QueryService { } -// DynamicQuery service for CQRS operations -service DynamicQueryService { - // Dynamic query for User - rpc QueryUsers (DynamicQueryUsersRequest) returns (DynamicQueryUsersResponse); - -} - // Request message for AddUserCommand message AddUserCommandRequest { string name = 1; @@ -66,46 +59,3 @@ message User { string email = 3; } -// Dynamic query filter with AND/OR support -message DynamicQueryFilter { - string path = 1; - int32 type = 2; // PoweredSoft.DynamicQuery.Core.FilterType - string value = 3; - repeated DynamicQueryFilter and = 4; - repeated DynamicQueryFilter or = 5; -} - -// Dynamic query sort -message DynamicQuerySort { - string path = 1; - bool ascending = 2; -} - -// Dynamic query group -message DynamicQueryGroup { - string path = 1; -} - -// Dynamic query aggregate -message DynamicQueryAggregate { - string path = 1; - int32 type = 2; // PoweredSoft.DynamicQuery.Core.AggregateType -} - -// Dynamic query request for User -message DynamicQueryUsersRequest { - int32 page = 1; - int32 page_size = 2; - repeated DynamicQueryFilter filters = 3; - repeated DynamicQuerySort sorts = 4; - repeated DynamicQueryGroup groups = 5; - repeated DynamicQueryAggregate aggregates = 6; -} - -// Dynamic query response for User -message DynamicQueryUsersResponse { - repeated User data = 1; - int64 total_records = 2; - int32 number_of_pages = 3; -} -