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; -} -