From b0bcd79740027ae2f969209c3ebbcdba8c7ddcf0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:39:33 +0000 Subject: [PATCH 1/6] Initial plan From 8abff00c450c7e3da17d49c00f09379d3b40e281 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:55:34 +0000 Subject: [PATCH 2/6] Extract source emitting code into SourceEmitting namespace and refactor ExecutionRuntime - Create CSharpLiteralFormatter for value/key literal formatting - Create CSharpAccessibilityKeyword for accessibility mapping - Create CSharpTypeKeyword for type kind mapping - Create PartialMethodEmitData record and PartialMethodSourceEmitter - Create DummyImplementationEmitter with data records - Create RoslynSymbolDataMapper to bridge Roslyn types to emitter records - Create GeneratorAssemblyExecutor for shared compile/load logic - Create BodyGenerationDataExtractor for reflection-based data extraction - Refactor GeneratesMethodExecutionRuntime to use new classes - Refactor GeneratesMethodPatternSourceBuilder to delegate to SourceEmitting - Add documentation comments to all internal/public methods Co-authored-by: dex3r <3155725+dex3r@users.noreply.github.com> Agent-Logs-Url: https://github.com/dex3r/EasySourceGenerators/sessions/cb205769-20ee-4e1e-9b44-3ef890163968 --- .../BodyGenerationDataExtractor.cs | 94 ++++ .../GeneratesMethodExecutionRuntime.cs | 452 +++++------------- .../GeneratesMethodGenerationPipeline.cs | 20 + ...eneratesMethodGenerationTargetCollector.cs | 13 + .../GeneratesMethodGenerator.cs | 8 + .../GeneratesMethodPatternSourceBuilder.cs | 368 ++------------ .../GeneratorAssemblyExecutor.cs | 229 +++++++++ .../CSharpAccessibilityKeyword.cs | 44 ++ .../SourceEmitting/CSharpLiteralFormatter.cs | 54 +++ .../SourceEmitting/CSharpTypeKeyword.cs | 23 + .../DummyImplementationEmitter.cs | 68 +++ .../SourceEmitting/PartialMethodEmitData.cs | 18 + .../PartialMethodSourceEmitter.cs | 60 +++ .../SourceEmitting/RoslynSymbolDataMapper.cs | 103 ++++ 14 files changed, 896 insertions(+), 658 deletions(-) create mode 100644 EasySourceGenerators.Generators/IncrementalGenerators/BodyGenerationDataExtractor.cs create mode 100644 EasySourceGenerators.Generators/IncrementalGenerators/GeneratorAssemblyExecutor.cs create mode 100644 EasySourceGenerators.Generators/SourceEmitting/CSharpAccessibilityKeyword.cs create mode 100644 EasySourceGenerators.Generators/SourceEmitting/CSharpLiteralFormatter.cs create mode 100644 EasySourceGenerators.Generators/SourceEmitting/CSharpTypeKeyword.cs create mode 100644 EasySourceGenerators.Generators/SourceEmitting/DummyImplementationEmitter.cs create mode 100644 EasySourceGenerators.Generators/SourceEmitting/PartialMethodEmitData.cs create mode 100644 EasySourceGenerators.Generators/SourceEmitting/PartialMethodSourceEmitter.cs create mode 100644 EasySourceGenerators.Generators/SourceEmitting/RoslynSymbolDataMapper.cs diff --git a/EasySourceGenerators.Generators/IncrementalGenerators/BodyGenerationDataExtractor.cs b/EasySourceGenerators.Generators/IncrementalGenerators/BodyGenerationDataExtractor.cs new file mode 100644 index 0000000..9130a7a --- /dev/null +++ b/EasySourceGenerators.Generators/IncrementalGenerators/BodyGenerationDataExtractor.cs @@ -0,0 +1,94 @@ +using System; +using System.Reflection; + +namespace EasySourceGenerators.Generators.IncrementalGenerators; + +/// +/// Extracts data from a method result object via reflection. +/// The result object is expected to be a DataMethodBodyGenerator containing a +/// BodyGenerationData property, from which return values and delegate bodies are extracted. +/// +internal static class BodyGenerationDataExtractor +{ + /// + /// Extracts the return value from a fluent body generator method result using reflection. + /// Checks for ReturnConstantValueFactory first, then RuntimeDelegateBody. + /// Returns a with the extracted value, or null return value + /// if neither factory nor body are present. + /// + internal static FluentBodyResult Extract(object methodResult, bool isVoidReturnType) + { + Type resultType = methodResult.GetType(); + + // The result should be a DataMethodBodyGenerator containing a BodyGenerationData Data property + PropertyInfo? dataProperty = resultType.GetProperty(Consts.BodyGenerationDataPropertyName); + if (dataProperty == null) + { + // The method returned something that isn't a DataMethodBodyGenerator. + // This may happen when the fluent chain is incomplete (e.g., user returned an intermediate builder). + return new FluentBodyResult(null, isVoidReturnType); + } + + object? bodyGenerationData = dataProperty.GetValue(methodResult); + if (bodyGenerationData == null) + { + return new FluentBodyResult(null, isVoidReturnType); + } + + Type dataType = bodyGenerationData.GetType(); + PropertyInfo? returnTypeProperty = dataType.GetProperty("ReturnType"); + Type? dataReturnType = returnTypeProperty?.GetValue(bodyGenerationData) as Type; + bool isVoid = dataReturnType == typeof(void); + + return TryExtractFromConstantFactory(dataType, bodyGenerationData, isVoid) + ?? TryExtractFromRuntimeBody(dataType, bodyGenerationData, isVoid) + ?? new FluentBodyResult(null, isVoid); + } + + /// + /// Attempts to extract a return value by invoking the ReturnConstantValueFactory delegate. + /// + private static FluentBodyResult? TryExtractFromConstantFactory( + Type dataType, + object bodyGenerationData, + bool isVoid) + { + PropertyInfo? constantFactoryProperty = dataType.GetProperty("ReturnConstantValueFactory"); + Delegate? constantFactory = constantFactoryProperty?.GetValue(bodyGenerationData) as Delegate; + if (constantFactory == null) + { + return null; + } + + object? constantValue = constantFactory.DynamicInvoke(); + return new FluentBodyResult(constantValue?.ToString(), isVoid); + } + + /// + /// Attempts to extract a return value by invoking the RuntimeDelegateBody delegate. + /// Only invokes delegates with zero parameters; parameterized delegates cannot be executed + /// at compile time without concrete values. + /// + private static FluentBodyResult? TryExtractFromRuntimeBody( + Type dataType, + object bodyGenerationData, + bool isVoid) + { + PropertyInfo? runtimeBodyProperty = dataType.GetProperty("RuntimeDelegateBody"); + Delegate? runtimeBody = runtimeBodyProperty?.GetValue(bodyGenerationData) as Delegate; + if (runtimeBody == null) + { + return null; + } + + ParameterInfo[] bodyParams = runtimeBody.Method.GetParameters(); + if (bodyParams.Length == 0) + { + object? bodyResult = runtimeBody.DynamicInvoke(); + return new FluentBodyResult(bodyResult?.ToString(), isVoid); + } + + // For delegates with parameters, we can't invoke at compile time without values + return new FluentBodyResult(null, isVoid); + } +} diff --git a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodExecutionRuntime.cs b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodExecutionRuntime.cs index 0f998f0..4c8d5cb 100644 --- a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodExecutionRuntime.cs +++ b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodExecutionRuntime.cs @@ -3,13 +3,9 @@ using System.IO; using System.Linq; using System.Reflection; -using System.Runtime.Loader; -using System.Text; -using EasySourceGenerators.Abstractions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Emit; namespace EasySourceGenerators.Generators.IncrementalGenerators; @@ -24,8 +20,16 @@ internal sealed record FluentBodyResult( string? ReturnValue, bool IsVoid); +/// +/// Orchestrates the execution of generator methods at compile time. +/// Delegates compilation and assembly loading to , +/// and data extraction to . +/// internal static class GeneratesMethodExecutionRuntime { + /// + /// Executes a simple (non-fluent) generator method with no arguments and returns its string result. + /// internal static (string? value, string? error) ExecuteSimpleGeneratorMethod( IMethodSymbol generatorMethod, IMethodSymbol partialMethod, @@ -35,228 +39,116 @@ internal static (string? value, string? error) ExecuteSimpleGeneratorMethod( return ExecuteGeneratorMethodWithArgs(generatorMethod, allPartials, compilation, null); } - // SwitchBodyData-based fluent execution has been replaced by the data abstraction layer. - // Use ExecuteFluentBodyGeneratorMethod instead. - + /// + /// Executes a fluent body generator method and extracts the + /// from the returned DataMethodBodyGenerator. + /// internal static (FluentBodyResult? result, string? error) ExecuteFluentBodyGeneratorMethod( IMethodSymbol generatorMethod, IMethodSymbol partialMethod, Compilation compilation) { IReadOnlyList allPartials = GetAllUnimplementedPartialMethods(compilation); - CSharpCompilation executableCompilation = BuildExecutionCompilation(allPartials, compilation); - using MemoryStream stream = new(); - EmitResult emitResult = executableCompilation.Emit(stream); - if (!emitResult.Success) + (LoadedAssemblyContext? loadedContext, string? loadError) = + GeneratorAssemblyExecutor.CompileAndLoadAssembly(allPartials, compilation); + if (loadError != null) { - string errors = string.Join("; ", emitResult.Diagnostics - .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) - .Select(diagnostic => diagnostic.GetMessage())); - return (null, $"Compilation failed: {errors}"); + return (null, loadError); } - stream.Position = 0; - AssemblyLoadContext? loadContext = null; + using LoadedAssemblyContext context = loadedContext!; try { - Dictionary compilationReferenceBytes = EmitCompilationReferences(compilation); - - loadContext = new AssemblyLoadContext("__GeneratorExec", isCollectible: true); - Assembly? capturedAbstractionsAssembly = null; - loadContext.Resolving += (context, assemblyName) => + (Assembly? abstractionsAssembly, string? abstractionsError) = + ResolveAbstractionsAssembly(context, compilation); + if (abstractionsError != null) { - PortableExecutableReference? match = compilation.References - .OfType() - .FirstOrDefault(reference => reference.FilePath is not null && string.Equals( - Path.GetFileNameWithoutExtension(reference.FilePath), - assemblyName.Name, - StringComparison.OrdinalIgnoreCase)); - if (match?.FilePath != null) - return context.LoadFromAssemblyPath(ResolveImplementationAssemblyPath(match.FilePath)); - - if (assemblyName.Name != null && compilationReferenceBytes.TryGetValue(assemblyName.Name, out byte[]? bytes)) - { - Assembly loaded = context.LoadFromStream(new MemoryStream(bytes)); - if (string.Equals(assemblyName.Name, Consts.AbstractionsAssemblyName, StringComparison.OrdinalIgnoreCase)) - capturedAbstractionsAssembly = loaded; - return loaded; - } - - return null; - }; - - Assembly assembly = loadContext.LoadFromStream(stream); - - MetadataReference[] abstractionsMatchingReferences = compilation.References.Where(reference => reference.Display is not null && ( - reference.Display.Equals(Consts.AbstractionsAssemblyName, StringComparison.OrdinalIgnoreCase) - || (reference is PortableExecutableReference peRef && peRef.FilePath is not null && Path.GetFileNameWithoutExtension(peRef.FilePath) - .Equals(Consts.AbstractionsAssemblyName, StringComparison.OrdinalIgnoreCase)))) - .ToArray(); - - if (abstractionsMatchingReferences.Length == 0) - { - MetadataReference[] closestMatches = compilation.References.Where(reference => - reference.Display is not null - && reference.Display.Contains(Consts.SolutionNamespace, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - string closestMatchesString = string.Join(", ", closestMatches.Select(reference => reference.Display)); - - return (null, $"Could not find any reference matching '{Consts.AbstractionsAssemblyName}' in compilation references.\n" + - $" Found total references: {compilation.References.Count()}. \nMatching references: {closestMatches.Length}: \n{closestMatchesString}"); + return (null, abstractionsError); } - PortableExecutableReference[] peMatchingReferences = abstractionsMatchingReferences.OfType().ToArray(); - CompilationReference[] csharpCompilationReference = abstractionsMatchingReferences.OfType().ToArray(); - - Assembly abstractionsAssembly; - - if (peMatchingReferences.Length > 0) - { - PortableExecutableReference abstractionsReference = peMatchingReferences.First(); - - if (string.IsNullOrEmpty(abstractionsReference.FilePath)) - { - return (null, $"The reference matching '{Consts.AbstractionsAssemblyName}' does not have a valid file path."); - } - - string abstractionsAssemblyPath = ResolveImplementationAssemblyPath(abstractionsReference.FilePath); - abstractionsAssembly = loadContext.LoadFromAssemblyPath(abstractionsAssemblyPath); - } - else if (csharpCompilationReference.Length > 0) - { - if (capturedAbstractionsAssembly != null) - { - abstractionsAssembly = capturedAbstractionsAssembly; - } - else if (compilationReferenceBytes.TryGetValue(Consts.AbstractionsAssemblyName, out byte[]? abstractionBytes)) - { - abstractionsAssembly = loadContext.LoadFromStream(new MemoryStream(abstractionBytes)); - } - else - { - return (null, $"Found reference matching '{Consts.AbstractionsAssemblyName}' as a CompilationReference, but failed to emit it to a loadable assembly."); - } - } - else - { - string matchesString = string.Join(", ", abstractionsMatchingReferences.Select(reference => $"{reference.Display} (type: {reference.GetType().Name})")); - return (null, $"Found references matching '{Consts.AbstractionsAssemblyName}' but none were PortableExecutableReference or CompilationReference with valid file paths. \nMatching references: {matchesString}"); - } - - Type? generatorStaticType = abstractionsAssembly.GetType(Consts.GenerateTypeFullName); - Type? dataGeneratorsFactoryType = assembly.GetType(Consts.DataGeneratorsFactoryTypeFullName); - if (generatorStaticType == null || dataGeneratorsFactoryType == null) + string? setupError = SetupDataGeneratorsFactory(context.Assembly, abstractionsAssembly!, context); + if (setupError != null) { - return (null, $"Could not find {Consts.GenerateTypeFullName} or {Consts.DataGeneratorsFactoryTypeFullName} types in compiled assembly"); + return (null, setupError); } - object? dataGeneratorsFactory = Activator.CreateInstance(dataGeneratorsFactoryType); - PropertyInfo? currentGeneratorProperty = generatorStaticType.GetProperty(Consts.CurrentGeneratorPropertyName, BindingFlags.NonPublic | BindingFlags.Static); - currentGeneratorProperty?.SetValue(null, dataGeneratorsFactory); - string typeName = generatorMethod.ContainingType.ToDisplayString(); - Type? loadedType = assembly.GetType(typeName); - if (loadedType == null) + (Type? loadedType, string? typeError) = GeneratorAssemblyExecutor.FindType(context.Assembly, typeName); + if (typeError != null) { - return (null, $"Could not find type '{typeName}' in compiled assembly"); + return (null, typeError); } - MethodInfo? generatorMethodInfo = loadedType.GetMethod(generatorMethod.Name, BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); - if (generatorMethodInfo == null) + (MethodInfo? methodInfo, string? methodError) = + GeneratorAssemblyExecutor.FindStaticMethod(loadedType!, generatorMethod.Name, typeName); + if (methodError != null) { - return (null, $"Could not find method '{generatorMethod.Name}' in type '{typeName}'"); + return (null, methodError); } - object? methodResult = generatorMethodInfo.Invoke(null, null); + object? methodResult = methodInfo!.Invoke(null, null); if (methodResult == null) { return (null, "Fluent body generator method returned null"); } - return (ExtractBodyGenerationData(methodResult, partialMethod.ReturnType), null); + bool isVoidReturnType = partialMethod.ReturnType.SpecialType == SpecialType.System_Void; + FluentBodyResult bodyResult = BodyGenerationDataExtractor.Extract(methodResult, isVoidReturnType); + return (bodyResult, null); } catch (Exception ex) { return (null, $"Error executing generator method '{generatorMethod.Name}': {ex.GetBaseException()}"); } - finally - { - loadContext?.Unload(); - } } + /// + /// Executes a generator method with optional arguments and returns its string result. + /// internal static (string? value, string? error) ExecuteGeneratorMethodWithArgs( IMethodSymbol generatorMethod, IReadOnlyList allPartialMethods, Compilation compilation, object?[]? args) { - CSharpCompilation executableCompilation = BuildExecutionCompilation(allPartialMethods, compilation); - - using MemoryStream stream = new(); - EmitResult emitResult = executableCompilation.Emit(stream); - if (!emitResult.Success) + (LoadedAssemblyContext? loadedContext, string? loadError) = + GeneratorAssemblyExecutor.CompileAndLoadAssembly(allPartialMethods, compilation); + if (loadError != null) { - string errors = string.Join("; ", emitResult.Diagnostics - .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) - .Select(diagnostic => diagnostic.GetMessage())); - return (null, $"Compilation failed: {errors}"); + return (null, loadError); } - stream.Position = 0; - AssemblyLoadContext? loadContext = null; + using LoadedAssemblyContext context = loadedContext!; try { - Dictionary compilationReferenceBytes = EmitCompilationReferences(compilation); - - loadContext = new AssemblyLoadContext("__GeneratorExec", isCollectible: true); - loadContext.Resolving += (context, assemblyName) => - { - PortableExecutableReference? match = compilation.References - .OfType() - .FirstOrDefault(reference => reference.FilePath is not null && string.Equals( - Path.GetFileNameWithoutExtension(reference.FilePath), - assemblyName.Name, - StringComparison.OrdinalIgnoreCase)); - if (match?.FilePath != null) - return context.LoadFromAssemblyPath(ResolveImplementationAssemblyPath(match.FilePath)); - - if (assemblyName.Name != null && compilationReferenceBytes.TryGetValue(assemblyName.Name, out byte[]? bytes)) - return context.LoadFromStream(new MemoryStream(bytes)); - - return null; - }; - - Assembly assembly = loadContext.LoadFromStream(stream); string typeName = generatorMethod.ContainingType.ToDisplayString(); - Type? loadedType = assembly.GetType(typeName); - if (loadedType == null) + (Type? loadedType, string? typeError) = GeneratorAssemblyExecutor.FindType(context.Assembly, typeName); + if (typeError != null) { - return (null, $"Could not find type '{typeName}' in compiled assembly"); + return (null, typeError); } - MethodInfo? generatorMethodInfo = loadedType.GetMethod(generatorMethod.Name, BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); - if (generatorMethodInfo == null) + (MethodInfo? methodInfo, string? methodError) = + GeneratorAssemblyExecutor.FindStaticMethod(loadedType!, generatorMethod.Name, typeName); + if (methodError != null) { - return (null, $"Could not find method '{generatorMethod.Name}' in type '{typeName}'"); + return (null, methodError); } - object?[]? convertedArgs = ConvertArguments(args, generatorMethodInfo); - object? result = generatorMethodInfo.Invoke(null, convertedArgs); + object?[]? convertedArgs = GeneratorAssemblyExecutor.ConvertArguments(args, methodInfo!); + object? result = methodInfo!.Invoke(null, convertedArgs); return (result?.ToString(), null); } catch (Exception ex) { return (null, $"Error executing generator method '{generatorMethod.Name}': {ex.GetBaseException()}"); } - finally - { - loadContext?.Unload(); - } } + /// + /// Finds all unimplemented partial method definitions across all syntax trees in the compilation. + /// internal static IReadOnlyList GetAllUnimplementedPartialMethods(Compilation compilation) { List methods = new(); @@ -279,188 +171,106 @@ internal static IReadOnlyList GetAllUnimplementedPartialMethods(C return methods; } - private static object?[]? ConvertArguments(object?[]? args, MethodInfo methodInfo) - { - if (args == null || methodInfo.GetParameters().Length == 0) - { - return null; - } - - Type parameterType = methodInfo.GetParameters()[0].ParameterType; - return new[] { Convert.ChangeType(args[0], parameterType) }; - } - - private static FluentBodyResult ExtractBodyGenerationData(object methodResult, ITypeSymbol returnType) + /// + /// Resolves the abstractions assembly from the compilation references. + /// Handles both (file-based) and + /// (in-memory, e.g., from Rider's code inspector). + /// + private static (Assembly? assembly, string? error) ResolveAbstractionsAssembly( + LoadedAssemblyContext context, + Compilation compilation) { - Type resultType = methodResult.GetType(); + MetadataReference[] abstractionsMatchingReferences = compilation.References.Where(reference => + reference.Display is not null && ( + reference.Display.Equals(Consts.AbstractionsAssemblyName, StringComparison.OrdinalIgnoreCase) + || (reference is PortableExecutableReference peRef && peRef.FilePath is not null && + Path.GetFileNameWithoutExtension(peRef.FilePath) + .Equals(Consts.AbstractionsAssemblyName, StringComparison.OrdinalIgnoreCase)))) + .ToArray(); - // The result should be a DataMethodBodyGenerator containing a BodyGenerationData Data property - PropertyInfo? dataProperty = resultType.GetProperty(Consts.BodyGenerationDataPropertyName); - if (dataProperty == null) + if (abstractionsMatchingReferences.Length == 0) { - // The method returned something that isn't a DataMethodBodyGenerator. - // This may happen when the fluent chain is incomplete (e.g., user returned an intermediate builder). - return new FluentBodyResult(null, returnType.SpecialType == SpecialType.System_Void); - } + MetadataReference[] closestMatches = compilation.References.Where(reference => + reference.Display is not null + && reference.Display.Contains(Consts.SolutionNamespace, StringComparison.OrdinalIgnoreCase)) + .ToArray(); - object? bodyGenerationData = dataProperty.GetValue(methodResult); - if (bodyGenerationData == null) - { - return new FluentBodyResult(null, returnType.SpecialType == SpecialType.System_Void); + string closestMatchesString = string.Join(", ", closestMatches.Select(reference => reference.Display)); + + return (null, + $"Could not find any reference matching '{Consts.AbstractionsAssemblyName}' in compilation references.\n" + + $" Found total references: {compilation.References.Count()}. \nMatching references: {closestMatches.Length}: \n{closestMatchesString}"); } - Type dataType = bodyGenerationData.GetType(); - PropertyInfo? returnTypeProperty = dataType.GetProperty("ReturnType"); - Type? dataReturnType = returnTypeProperty?.GetValue(bodyGenerationData) as Type; - bool isVoid = dataReturnType == typeof(void); + PortableExecutableReference[] peMatchingReferences = + abstractionsMatchingReferences.OfType().ToArray(); + CompilationReference[] csharpCompilationReference = + abstractionsMatchingReferences.OfType().ToArray(); - // Try ReturnConstantValueFactory first - PropertyInfo? constantFactoryProperty = dataType.GetProperty("ReturnConstantValueFactory"); - Delegate? constantFactory = constantFactoryProperty?.GetValue(bodyGenerationData) as Delegate; - if (constantFactory != null) + if (peMatchingReferences.Length > 0) { - object? constantValue = constantFactory.DynamicInvoke(); - return new FluentBodyResult(constantValue?.ToString(), isVoid); - } + PortableExecutableReference abstractionsReference = peMatchingReferences.First(); - // Try RuntimeDelegateBody - PropertyInfo? runtimeBodyProperty = dataType.GetProperty("RuntimeDelegateBody"); - Delegate? runtimeBody = runtimeBodyProperty?.GetValue(bodyGenerationData) as Delegate; - if (runtimeBody != null) - { - ParameterInfo[] bodyParams = runtimeBody.Method.GetParameters(); - if (bodyParams.Length == 0) + if (string.IsNullOrEmpty(abstractionsReference.FilePath)) { - object? bodyResult = runtimeBody.DynamicInvoke(); - return new FluentBodyResult(bodyResult?.ToString(), isVoid); + return (null, + $"The reference matching '{Consts.AbstractionsAssemblyName}' does not have a valid file path."); } - // For delegates with parameters, we can't invoke at compile time without values - return new FluentBodyResult(null, isVoid); + string abstractionsAssemblyPath = + GeneratorAssemblyExecutor.ResolveImplementationAssemblyPath(abstractionsReference.FilePath); + Assembly abstractionsAssembly = context.LoadContext.LoadFromAssemblyPath(abstractionsAssemblyPath); + return (abstractionsAssembly, null); } - return new FluentBodyResult(null, isVoid); - } - - private static Dictionary EmitCompilationReferences(Compilation compilation) - { - Dictionary result = new(StringComparer.OrdinalIgnoreCase); - foreach (CompilationReference compilationRef in compilation.References.OfType()) + if (csharpCompilationReference.Length > 0) { - string assemblyName = compilationRef.Compilation.AssemblyName ?? string.Empty; - if (string.IsNullOrEmpty(assemblyName)) - continue; - using MemoryStream refStream = new(); - if (compilationRef.Compilation.Emit(refStream).Success) - result[assemblyName] = refStream.ToArray(); - } + if (context.CapturedAbstractionsAssembly != null) + { + return (context.CapturedAbstractionsAssembly, null); + } - return result; - } + if (context.CompilationReferenceBytes.TryGetValue(Consts.AbstractionsAssemblyName, + out byte[]? abstractionBytes)) + { + Assembly abstractionsAssembly = + context.LoadContext.LoadFromStream(new MemoryStream(abstractionBytes)); + return (abstractionsAssembly, null); + } - private static string ResolveImplementationAssemblyPath(string path) - { - string? directory = Path.GetDirectoryName(path); - string? parentDirectory = directory != null ? Path.GetDirectoryName(directory) : null; - if (directory != null && - parentDirectory != null && - string.Equals(Path.GetFileName(directory), "ref", StringComparison.OrdinalIgnoreCase)) - { - return Path.Combine(parentDirectory, Path.GetFileName(path)); + return (null, + $"Found reference matching '{Consts.AbstractionsAssemblyName}' as a CompilationReference, but failed to emit it to a loadable assembly."); } - return path; - } - - private static CSharpCompilation BuildExecutionCompilation( - IReadOnlyList allPartialMethods, - Compilation compilation) - { - string dummySource = BuildDummyImplementation(allPartialMethods); - string dataGeneratorsFactorySource = ReadEmbeddedResource($"{Consts.GeneratorsAssemblyName}.DataGeneratorsFactory.cs"); - string dataMethodBodyBuildersSource = ReadEmbeddedResource($"{Consts.GeneratorsAssemblyName}.DataMethodBodyBuilders.cs"); - string dataRecordsSource = ReadEmbeddedResource($"{Consts.GeneratorsAssemblyName}.DataRecords.cs"); - CSharpParseOptions parseOptions = compilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions - ?? CSharpParseOptions.Default; - - return (CSharpCompilation)compilation - .AddSyntaxTrees( - CSharpSyntaxTree.ParseText(dummySource, parseOptions), - CSharpSyntaxTree.ParseText(dataGeneratorsFactorySource, parseOptions), - CSharpSyntaxTree.ParseText(dataMethodBodyBuildersSource, parseOptions), - CSharpSyntaxTree.ParseText(dataRecordsSource, parseOptions)); - } - - private static string ReadEmbeddedResource(string resourceName) - { - using Stream? stream = typeof(GeneratesMethodExecutionRuntime).Assembly.GetManifestResourceStream(resourceName); - if (stream == null) - throw new InvalidOperationException($"Embedded resource '{resourceName}' not found in {Consts.GeneratorsAssemblyName} assembly"); - using StreamReader reader = new StreamReader(stream); - return reader.ReadToEnd(); + string matchesString = string.Join(", ", + abstractionsMatchingReferences.Select(reference => + $"{reference.Display} (type: {reference.GetType().Name})")); + return (null, + $"Found references matching '{Consts.AbstractionsAssemblyName}' but none were PortableExecutableReference or CompilationReference with valid file paths. \nMatching references: {matchesString}"); } - private static string BuildDummyImplementation(IEnumerable partialMethods) + /// + /// Sets up the DataGeneratorsFactory and assigns it to Generate.CurrentGenerator + /// in the loaded abstractions assembly, enabling fluent API usage during generator execution. + /// + private static string? SetupDataGeneratorsFactory( + Assembly executionAssembly, + Assembly abstractionsAssembly, + LoadedAssemblyContext context) { - StringBuilder builder = new(); - - IEnumerable> groupedMethods = partialMethods.GroupBy( - method => (Namespace: method.ContainingType.ContainingNamespace?.IsGlobalNamespace == false - ? method.ContainingType.ContainingNamespace.ToDisplayString() - : null, - TypeName: method.ContainingType.Name, - IsStatic: method.ContainingType.IsStatic, - TypeKind: method.ContainingType.TypeKind)); - - foreach (IGrouping<(string? Namespace, string TypeName, bool IsStatic, TypeKind TypeKind), IMethodSymbol> typeGroup in groupedMethods) + Type? generatorStaticType = abstractionsAssembly.GetType(Consts.GenerateTypeFullName); + Type? dataGeneratorsFactoryType = executionAssembly.GetType(Consts.DataGeneratorsFactoryTypeFullName); + if (generatorStaticType == null || dataGeneratorsFactoryType == null) { - string? namespaceName = typeGroup.Key.Namespace; - if (namespaceName != null) - { - builder.AppendLine($"namespace {namespaceName} {{"); - } - - string typeKeyword = typeGroup.Key.TypeKind switch - { - TypeKind.Struct => "struct", - _ => "class" - }; - - string typeModifiers = typeGroup.Key.IsStatic ? "static partial" : "partial"; - builder.AppendLine($"{typeModifiers} {typeKeyword} {typeGroup.Key.TypeName} {{"); - - foreach (IMethodSymbol partialMethod in typeGroup) - { - string accessibility = partialMethod.DeclaredAccessibility switch - { - Accessibility.Public => "public", - Accessibility.Protected => "protected", - Accessibility.Internal => "internal", - Accessibility.ProtectedOrInternal => "protected internal", - Accessibility.ProtectedAndInternal => "private protected", - _ => "" - }; - - string staticModifier = partialMethod.IsStatic ? "static " : ""; - string returnType = partialMethod.ReturnType.ToDisplayString(); - string parameters = string.Join(", ", partialMethod.Parameters.Select(parameter => $"{parameter.Type.ToDisplayString()} {parameter.Name}")); - - builder.AppendLine($"{accessibility} {staticModifier}partial {returnType} {partialMethod.Name}({parameters}) {{"); - string exceptionName = $"{Consts.AbstractionsAssemblyName}.{nameof(PartialMethodCalledDuringGenerationException)}"; - string throwStatement = $"throw new global::{exceptionName}(\"{partialMethod.Name}\", \"{partialMethod.ContainingType.Name}\");"; - builder.AppendLine(throwStatement); - - builder.AppendLine("}"); - } - - builder.AppendLine("}"); - - if (namespaceName != null) - { - builder.AppendLine("}"); - } + return + $"Could not find {Consts.GenerateTypeFullName} or {Consts.DataGeneratorsFactoryTypeFullName} types in compiled assembly"; } - return builder.ToString(); + object? dataGeneratorsFactory = Activator.CreateInstance(dataGeneratorsFactoryType); + PropertyInfo? currentGeneratorProperty = generatorStaticType.GetProperty( + Consts.CurrentGeneratorPropertyName, BindingFlags.NonPublic | BindingFlags.Static); + currentGeneratorProperty?.SetValue(null, dataGeneratorsFactory); + + return null; } } diff --git a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodGenerationPipeline.cs b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodGenerationPipeline.cs index c795277..802c97f 100644 --- a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodGenerationPipeline.cs +++ b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodGenerationPipeline.cs @@ -8,8 +8,16 @@ namespace EasySourceGenerators.Generators.IncrementalGenerators; +/// +/// Orchestrates the full source generation pipeline: collecting generation targets, +/// grouping them by target method, and generating C# source for each group. +/// internal static class GeneratesMethodGenerationPipeline { + /// + /// Executes the generation pipeline for all methods marked with the generator attribute. + /// Collects valid targets, groups by containing type and target method, then generates source. + /// internal static void Execute( SourceProductionContext context, ImmutableArray generatorMethods, @@ -42,6 +50,10 @@ internal static void Execute( } } + /// + /// Determines the generation pattern (fluent or simple) and generates source for a group + /// of generator methods targeting the same partial method. + /// private static string GenerateSourceForGroup( SourceProductionContext context, List methods, @@ -83,6 +95,10 @@ private static string GenerateSourceForGroup( return GenerateFromSimplePattern(context, firstMethod, compilation); } + /// + /// Generates source code from a fluent body pattern, executing the generator method + /// and extracting the return value from the fluent API result. + /// private static string GenerateFromFluentBodyPattern( SourceProductionContext context, GeneratesMethodGenerationTarget methodInfo, @@ -111,6 +127,10 @@ private static string GenerateFromFluentBodyPattern( result!.ReturnValue); } + /// + /// Generates source code from a simple pattern, executing the generator method + /// and using its return value as the partial method's return expression. + /// private static string GenerateFromSimplePattern( SourceProductionContext context, GeneratesMethodGenerationTarget firstMethod, diff --git a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodGenerationTargetCollector.cs b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodGenerationTargetCollector.cs index dd74631..7d69104 100644 --- a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodGenerationTargetCollector.cs +++ b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodGenerationTargetCollector.cs @@ -9,6 +9,10 @@ namespace EasySourceGenerators.Generators.IncrementalGenerators; +/// +/// Represents a validated generation target: a generator method with its resolved +/// target partial method and containing type information. +/// internal sealed record GeneratesMethodGenerationTarget( MethodDeclarationSyntax Syntax, IMethodSymbol Symbol, @@ -16,8 +20,17 @@ internal sealed record GeneratesMethodGenerationTarget( IMethodSymbol PartialMethod, INamedTypeSymbol ContainingType); +/// +/// Collects and validates generator methods marked with [MethodBodyGenerator], +/// resolving each to its target partial method and reporting diagnostics for invalid configurations. +/// internal static class GeneratesMethodGenerationTargetCollector { + /// + /// Scans all generator methods, validates their configuration, and returns a list of + /// valid generation targets. Reports diagnostics for non-static generators (MSGH002) + /// and missing partial methods (MSGH001). + /// internal static List Collect( SourceProductionContext context, ImmutableArray generatorMethods, diff --git a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodGenerator.cs b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodGenerator.cs index 3a4e9e2..4730ee6 100644 --- a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodGenerator.cs +++ b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodGenerator.cs @@ -7,9 +7,17 @@ namespace EasySourceGenerators.Generators.IncrementalGenerators; +/// +/// Roslyn incremental source generator entry point. Detects methods marked with +/// [MethodBodyGenerator] and delegates to +/// for source code generation. +/// [Generator] public sealed class GeneratesMethodGenerator : IIncrementalGenerator { + /// + /// Registers the syntax provider and source output for the incremental generator pipeline. + /// public void Initialize(IncrementalGeneratorInitializationContext context) { IncrementalValueProvider> methodsWithAttribute = context.SyntaxProvider diff --git a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodPatternSourceBuilder.cs b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodPatternSourceBuilder.cs index 310660b..215668b 100644 --- a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodPatternSourceBuilder.cs +++ b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodPatternSourceBuilder.cs @@ -1,361 +1,55 @@ using System.Linq; -using System.Text; +using EasySourceGenerators.Generators.SourceEmitting; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; namespace EasySourceGenerators.Generators.IncrementalGenerators; +/// +/// Builds C# source code for partial method implementations by delegating to +/// the classes for string emission and literal formatting. +/// internal static class GeneratesMethodPatternSourceBuilder { - // SwitchCase attribute-based and fluent switch generation are commented out pending replacement - // with a data-driven approach. See DataMethodBodyBuilders.cs for details. - /* - internal static string GenerateFromSwitchAttributes( - SourceProductionContext context, - List methods, - IMethodSymbol partialMethod, - INamedTypeSymbol containingType, - IReadOnlyList allPartials, - Compilation compilation) - { - List switchCaseMethods = methods - .Where(method => method.Symbol.GetAttributes().Any(attribute => attribute.AttributeClass?.ToDisplayString() == SwitchCaseAttributeFullName)) - .ToList(); - GeneratesMethodGenerationTarget? switchDefaultMethod = methods - .FirstOrDefault(method => method.Symbol.GetAttributes().Any(attribute => attribute.AttributeClass?.ToDisplayString() == SwitchDefaultAttributeFullName)); - - List<(object key, string value)> switchCases = new(); - foreach (GeneratesMethodGenerationTarget switchMethod in switchCaseMethods) - { - if (switchMethod.Symbol.Parameters.Length > 1) - { - context.ReportDiagnostic(Diagnostic.Create( - GeneratesMethodGeneratorDiagnostics.GeneratorMethodTooManyParametersError, - switchMethod.Syntax.GetLocation(), - switchMethod.Symbol.Name, - switchMethod.Symbol.Parameters.Length)); - continue; - } - - IEnumerable switchCaseAttributes = switchMethod.Symbol.GetAttributes() - .Where(attribute => attribute.AttributeClass?.ToDisplayString() == SwitchCaseAttributeFullName); - - foreach (AttributeData switchCaseAttribute in switchCaseAttributes) - { - if (switchCaseAttribute.ConstructorArguments.Length == 0) - { - continue; - } - - IMethodSymbol? attributeConstructor = switchCaseAttribute.AttributeConstructor; - if (attributeConstructor is null) - { - continue; - } - - int switchCaseArgIndex = attributeConstructor.Parameters - .Select((parameter, index) => (parameter, index)) - .Where(tuple => string.Equals(tuple.parameter.Name, nameof(SwitchCase.Arg1), StringComparison.OrdinalIgnoreCase)) - .Select(tuple => tuple.index) - .DefaultIfEmpty(-1) - .First(); - if (switchCaseArgIndex < 0 || switchCaseArgIndex >= switchCaseAttribute.ConstructorArguments.Length) - { - continue; - } - - object? caseArgument = switchCaseAttribute.ConstructorArguments[switchCaseArgIndex].Value; - if (caseArgument is null) - { - continue; - } - - if (partialMethod.Parameters.Length > 0) - { - ITypeSymbol? switchArgType = switchCaseAttribute.ConstructorArguments[switchCaseArgIndex].Type; - ITypeSymbol partialMethodParamType = partialMethod.Parameters[0].Type; - - if (switchArgType != null && !SymbolEqualityComparer.Default.Equals(switchArgType, partialMethodParamType)) - { - Location attributeLocation = switchCaseAttribute.ApplicationSyntaxReference?.GetSyntax()?.GetLocation() - ?? switchMethod.Syntax.GetLocation(); - context.ReportDiagnostic(Diagnostic.Create( - GeneratesMethodGeneratorDiagnostics.SwitchCaseArgumentTypeMismatchError, - attributeLocation, - switchArgType.ToDisplayString(), - partialMethodParamType.ToDisplayString())); - continue; - } - } - - (string? result, string? error) = GeneratesMethodExecutionRuntime.ExecuteGeneratorMethodWithArgs( - switchMethod.Symbol, - allPartials, - compilation, - new[] { caseArgument }); - - if (error != null) - { - context.ReportDiagnostic(Diagnostic.Create( - GeneratesMethodGeneratorDiagnostics.GeneratorMethodExecutionError, - switchMethod.Syntax.GetLocation(), - switchMethod.Symbol.Name, - error)); - continue; - } - - switchCases.Add((caseArgument, FormatValueAsCSharpLiteral(result, partialMethod.ReturnType))); - } - } - - string? defaultExpression = switchDefaultMethod is not null - ? ExtractDefaultExpressionFromSwitchDefaultMethod(switchDefaultMethod.Syntax) - : null; - - return GenerateSwitchMethodSource(containingType, partialMethod, switchCases, defaultExpression); - } - - internal static string GenerateFromFluent( - SourceProductionContext context, - GeneratesMethodGenerationTarget methodInfo, - IMethodSymbol partialMethod, - INamedTypeSymbol containingType, - Compilation compilation) - { - (SwitchBodyData? record, string? error) = GeneratesMethodExecutionRuntime.ExecuteFluentGeneratorMethod( - methodInfo.Symbol, - partialMethod, - compilation); - - if (error != null) - { - context.ReportDiagnostic(Diagnostic.Create( - GeneratesMethodGeneratorDiagnostics.GeneratorMethodExecutionError, - methodInfo.Syntax.GetLocation(), - methodInfo.Symbol.Name, - error)); - return string.Empty; - } - - SwitchBodyData switchBodyData = record!; - string? defaultExpression = switchBodyData.HasDefaultCase - ? ExtractDefaultExpressionFromFluentMethod(methodInfo.Syntax) - : null; - - return GenerateSwitchMethodSource(containingType, partialMethod, switchBodyData.CasePairs, defaultExpression); - } - */ - + /// + /// Generates a complete C# source file containing a simple partial method implementation + /// that returns the given value, formatted as a C# literal. + /// internal static string GenerateSimplePartialMethod( INamedTypeSymbol containingType, IMethodSymbol partialMethod, string? returnValue) { - StringBuilder builder = new(); - AppendNamespaceAndTypeHeader(builder, containingType, partialMethod); - - if (!partialMethod.ReturnsVoid) - { - string literal = FormatValueAsCSharpLiteral(returnValue, partialMethod.ReturnType); - builder.AppendLine($" return {literal};"); - } - - builder.AppendLine(" }"); - builder.AppendLine("}"); - return builder.ToString(); - } - - // Switch-related helper methods are commented out pending replacement with data-driven approach - /* - private static string? ExtractDefaultExpressionFromSwitchDefaultMethod(MethodDeclarationSyntax method) - { - ExpressionSyntax? bodyExpression = method.ExpressionBody?.Expression; - if (bodyExpression == null && method.Body != null) - { - ReturnStatementSyntax? returnStatement = method.Body.Statements.OfType().FirstOrDefault(); - bodyExpression = returnStatement?.Expression; - } + PartialMethodEmitData emitData = RoslynSymbolDataMapper.ToPartialMethodEmitData(containingType, partialMethod); - return ExtractInnermostLambdaBody(bodyExpression); - } - - private static string? ExtractDefaultExpressionFromFluentMethod(MethodDeclarationSyntax method) - { - IEnumerable invocations = method.DescendantNodes().OfType(); - foreach (InvocationExpressionSyntax invocation in invocations) - { - if (invocation.Expression is not MemberAccessExpressionSyntax memberAccessExpression) - { - continue; - } - - string methodName = memberAccessExpression.Name.Identifier.Text; - if (methodName is not ("ReturnConstantValue" or "BodyReturningConstantValue")) - { - continue; - } - - ExpressionSyntax? argumentExpression = invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression; - return ExtractInnermostLambdaBody(argumentExpression); - } - - return null; - } - - private static string? ExtractInnermostLambdaBody(ExpressionSyntax? expression) - { - while (true) - { - switch (expression) - { - case SimpleLambdaExpressionSyntax simpleLambdaExpression: - expression = simpleLambdaExpression.Body as ExpressionSyntax; - break; - case ParenthesizedLambdaExpressionSyntax parenthesizedLambdaExpression: - expression = parenthesizedLambdaExpression.Body as ExpressionSyntax; - break; - default: - return expression?.ToString(); - } - } - } - - private static string GenerateSwitchMethodSource( - INamedTypeSymbol containingType, - IMethodSymbol partialMethod, - IReadOnlyList<(object key, string value)> cases, - string? defaultExpression) - { - StringBuilder builder = new(); - AppendNamespaceAndTypeHeader(builder, containingType, partialMethod); - - if (partialMethod.Parameters.Length == 0) - { - string fallbackExpression = defaultExpression ?? "default"; - builder.AppendLine($" return {fallbackExpression};"); - builder.AppendLine(" }"); - builder.AppendLine("}"); - return builder.ToString(); - } - - string switchParameterName = partialMethod.Parameters[0].Name; - builder.AppendLine($" switch ({switchParameterName})"); - builder.AppendLine(" {"); - - ITypeSymbol? parameterType = partialMethod.Parameters.Length > 0 ? partialMethod.Parameters[0].Type : null; - foreach ((object key, string value) in cases) - { - string formattedKey = FormatKeyAsCSharpLiteral(key, parameterType); - builder.AppendLine($" case {formattedKey}: return {value};"); - } - - if (defaultExpression != null) - { - string defaultStatement = defaultExpression.TrimStart().StartsWith("throw ", StringComparison.Ordinal) - ? $" default: {defaultExpression};" - : $" default: return {defaultExpression};"; - builder.AppendLine(defaultStatement); - } - - builder.AppendLine(" }"); - builder.AppendLine(" }"); - builder.AppendLine("}"); - return builder.ToString(); - } - - private static string FormatKeyAsCSharpLiteral(object key, ITypeSymbol? parameterType) - { - if (parameterType?.TypeKind == TypeKind.Enum) - { - return $"{parameterType.ToDisplayString()}.{key}"; - } - - return key switch - { - bool b => b ? "true" : "false", - // SyntaxFactory.Literal handles escaping and quoting (e.g. "hello" → "\"hello\"") - string s => SyntaxFactory.Literal(s).Text, - _ => key.ToString()! - }; - } - */ - - private static void AppendNamespaceAndTypeHeader(StringBuilder builder, INamedTypeSymbol containingType, IMethodSymbol partialMethod) - { - builder.AppendLine("// "); - builder.AppendLine($"// Generated by {typeof(GeneratesMethodGenerator).FullName} for method '{partialMethod.Name}'."); - builder.AppendLine("#pragma warning disable"); - builder.AppendLine(); - - string? namespaceName = containingType.ContainingNamespace?.IsGlobalNamespace == false - ? containingType.ContainingNamespace.ToDisplayString() + string? returnValueLiteral = !partialMethod.ReturnsVoid + ? FormatValueAsCSharpLiteral(returnValue, partialMethod.ReturnType) : null; - if (namespaceName != null) - { - builder.AppendLine($"namespace {namespaceName};"); - builder.AppendLine(); - } - - string typeKeyword = containingType.TypeKind switch - { - TypeKind.Struct => "struct", - TypeKind.Interface => "interface", - _ => "class" - }; - - string typeModifiers = containingType.IsStatic ? "static partial" : "partial"; - builder.AppendLine($"{typeModifiers} {typeKeyword} {containingType.Name}"); - builder.AppendLine("{"); - string accessibility = partialMethod.DeclaredAccessibility switch - { - Accessibility.Public => "public", - Accessibility.Protected => "protected", - Accessibility.Internal => "internal", - Accessibility.ProtectedOrInternal => "protected internal", - Accessibility.ProtectedAndInternal => "private protected", - _ => "private" - }; - - string returnTypeName = partialMethod.ReturnType.ToDisplayString(); - string methodName = partialMethod.Name; - string parameters = string.Join(", ", partialMethod.Parameters.Select(parameter => $"{parameter.Type.ToDisplayString()} {parameter.Name}")); - string methodModifiers = partialMethod.IsStatic ? "static partial" : "partial"; - - builder.AppendLine($" {accessibility} {methodModifiers} {returnTypeName} {methodName}({parameters})"); - builder.AppendLine(" {"); + return PartialMethodSourceEmitter.Emit(emitData, returnValueLiteral); } + /// + /// Formats a string value as a C# literal expression based on the target return type. + /// Delegates to . + /// internal static string FormatValueAsCSharpLiteral(string? value, ITypeSymbol returnType) { - if (value == null) - { - return "default"; - } - - return returnType.SpecialType switch - { - SpecialType.System_String => SyntaxFactory.Literal(value).Text, - SpecialType.System_Char when value.Length == 1 => SyntaxFactory.Literal(value[0]).Text, - SpecialType.System_Boolean => value.ToLowerInvariant(), - _ when returnType.TypeKind == TypeKind.Enum => $"{returnType.ToDisplayString()}.{value}", - _ => value - }; + return CSharpLiteralFormatter.FormatValueAsLiteral( + value, + returnType.SpecialType, + returnType.TypeKind, + returnType.ToDisplayString()); } - private static string FormatKeyAsCSharpLiteral(object key, ITypeSymbol? parameterType) + /// + /// Formats a key object as a C# literal expression for use in switch case labels. + /// Delegates to . + /// + internal static string FormatKeyAsCSharpLiteral(object key, ITypeSymbol? parameterType) { - if (parameterType?.TypeKind == TypeKind.Enum) - { - return $"{parameterType.ToDisplayString()}.{key}"; - } - - return key switch - { - bool b => b ? "true" : "false", - // SyntaxFactory.Literal handles escaping and quoting (e.g. "hello" → "\"hello\"") - string s => SyntaxFactory.Literal(s).Text, - _ => key.ToString()! - }; + return CSharpLiteralFormatter.FormatKeyAsLiteral( + key, + parameterType?.TypeKind, + parameterType?.ToDisplayString()); } } diff --git a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratorAssemblyExecutor.cs b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratorAssemblyExecutor.cs new file mode 100644 index 0000000..86839f7 --- /dev/null +++ b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratorAssemblyExecutor.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Loader; +using EasySourceGenerators.Generators.SourceEmitting; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; + +namespace EasySourceGenerators.Generators.IncrementalGenerators; + +/// +/// Holds the result of compiling and loading a generator assembly in an isolated context. +/// Implements to ensure the is unloaded. +/// +internal sealed class LoadedAssemblyContext : IDisposable +{ + internal Assembly Assembly { get; } + internal AssemblyLoadContext LoadContext { get; } + internal Dictionary CompilationReferenceBytes { get; } + + /// + /// The abstractions assembly loaded during assembly resolution, if it was resolved + /// from a . May be null if the abstractions + /// assembly was loaded from a file path instead. + /// + internal Assembly? CapturedAbstractionsAssembly { get; } + + internal LoadedAssemblyContext( + Assembly assembly, + AssemblyLoadContext loadContext, + Dictionary compilationReferenceBytes, + Assembly? capturedAbstractionsAssembly) + { + Assembly = assembly; + LoadContext = loadContext; + CompilationReferenceBytes = compilationReferenceBytes; + CapturedAbstractionsAssembly = capturedAbstractionsAssembly; + } + + /// + /// Unloads the isolated and all assemblies loaded within it. + /// + public void Dispose() + { + LoadContext.Unload(); + } +} + +/// +/// Handles the compilation, assembly loading, and method invocation steps +/// required to execute generator methods at compile time in an isolated context. +/// +internal static class GeneratorAssemblyExecutor +{ + /// + /// Compiles the generator source code and loads the resulting assembly in an isolated + /// . Returns a + /// that must be disposed to unload the context. + /// + internal static (LoadedAssemblyContext? context, string? error) CompileAndLoadAssembly( + IReadOnlyList allPartialMethods, + Compilation compilation) + { + CSharpCompilation executableCompilation = BuildExecutionCompilation(allPartialMethods, compilation); + + using MemoryStream stream = new(); + EmitResult emitResult = executableCompilation.Emit(stream); + if (!emitResult.Success) + { + string errors = string.Join("; ", emitResult.Diagnostics + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(diagnostic => diagnostic.GetMessage())); + return (null, $"Compilation failed: {errors}"); + } + + stream.Position = 0; + Dictionary compilationReferenceBytes = EmitCompilationReferences(compilation); + + AssemblyLoadContext loadContext = new("__GeneratorExec", isCollectible: true); + Assembly? capturedAbstractionsAssembly = null; + loadContext.Resolving += (context, assemblyName) => + { + PortableExecutableReference? match = compilation.References + .OfType() + .FirstOrDefault(reference => reference.FilePath is not null && string.Equals( + Path.GetFileNameWithoutExtension(reference.FilePath), + assemblyName.Name, + StringComparison.OrdinalIgnoreCase)); + if (match?.FilePath != null) + return context.LoadFromAssemblyPath(ResolveImplementationAssemblyPath(match.FilePath)); + + if (assemblyName.Name != null && compilationReferenceBytes.TryGetValue(assemblyName.Name, out byte[]? bytes)) + { + Assembly loaded = context.LoadFromStream(new MemoryStream(bytes)); + if (string.Equals(assemblyName.Name, Consts.AbstractionsAssemblyName, StringComparison.OrdinalIgnoreCase)) + capturedAbstractionsAssembly = loaded; + return loaded; + } + + return null; + }; + + Assembly assembly = loadContext.LoadFromStream(stream); + + return (new LoadedAssemblyContext(assembly, loadContext, compilationReferenceBytes, capturedAbstractionsAssembly), null); + } + + /// + /// Finds a type in the loaded assembly by its full name. + /// + internal static (Type? type, string? error) FindType(Assembly assembly, string typeName) + { + Type? loadedType = assembly.GetType(typeName); + if (loadedType == null) + { + return (null, $"Could not find type '{typeName}' in compiled assembly"); + } + + return (loadedType, null); + } + + /// + /// Finds a static method in the given type by name. + /// + internal static (MethodInfo? method, string? error) FindStaticMethod(Type type, string methodName, string typeName) + { + MethodInfo? methodInfo = type.GetMethod(methodName, BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); + if (methodInfo == null) + { + return (null, $"Could not find method '{methodName}' in type '{typeName}'"); + } + + return (methodInfo, null); + } + + /// + /// Converts argument values to match the target method's parameter types. + /// Returns null when is null or the method has no parameters. + /// + internal static object?[]? ConvertArguments(object?[]? args, MethodInfo methodInfo) + { + if (args == null || methodInfo.GetParameters().Length == 0) + { + return null; + } + + Type parameterType = methodInfo.GetParameters()[0].ParameterType; + return new[] { Convert.ChangeType(args[0], parameterType) }; + } + + /// + /// Builds a suitable for executing generator methods, + /// by adding dummy partial method implementations and embedded data building sources + /// to the original compilation. + /// + internal static CSharpCompilation BuildExecutionCompilation( + IReadOnlyList allPartialMethods, + Compilation compilation) + { + IReadOnlyList dummyTypeGroups = RoslynSymbolDataMapper.ToDummyTypeGroups(allPartialMethods); + string dummySource = DummyImplementationEmitter.Emit(dummyTypeGroups); + string dataGeneratorsFactorySource = ReadEmbeddedResource($"{Consts.GeneratorsAssemblyName}.DataGeneratorsFactory.cs"); + string dataMethodBodyBuildersSource = ReadEmbeddedResource($"{Consts.GeneratorsAssemblyName}.DataMethodBodyBuilders.cs"); + string dataRecordsSource = ReadEmbeddedResource($"{Consts.GeneratorsAssemblyName}.DataRecords.cs"); + CSharpParseOptions parseOptions = compilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions + ?? CSharpParseOptions.Default; + + return (CSharpCompilation)compilation + .AddSyntaxTrees( + CSharpSyntaxTree.ParseText(dummySource, parseOptions), + CSharpSyntaxTree.ParseText(dataGeneratorsFactorySource, parseOptions), + CSharpSyntaxTree.ParseText(dataMethodBodyBuildersSource, parseOptions), + CSharpSyntaxTree.ParseText(dataRecordsSource, parseOptions)); + } + + /// + /// Reads an embedded resource from the current assembly by its logical name. + /// + internal static string ReadEmbeddedResource(string resourceName) + { + using Stream? stream = typeof(GeneratorAssemblyExecutor).Assembly.GetManifestResourceStream(resourceName); + if (stream == null) + throw new InvalidOperationException($"Embedded resource '{resourceName}' not found in {Consts.GeneratorsAssemblyName} assembly"); + using StreamReader reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + /// + /// Emits all s in the compilation to in-memory byte arrays, + /// keyed by assembly name. These are used to resolve assemblies during isolated execution. + /// + internal static Dictionary EmitCompilationReferences(Compilation compilation) + { + Dictionary result = new(StringComparer.OrdinalIgnoreCase); + foreach (CompilationReference compilationRef in compilation.References.OfType()) + { + string assemblyName = compilationRef.Compilation.AssemblyName ?? string.Empty; + if (string.IsNullOrEmpty(assemblyName)) + continue; + using MemoryStream refStream = new(); + if (compilationRef.Compilation.Emit(refStream).Success) + result[assemblyName] = refStream.ToArray(); + } + + return result; + } + + /// + /// Resolves a reference assembly path to its implementation assembly path. + /// When the path points to a ref/ directory, returns the corresponding + /// implementation assembly one level up. + /// + internal static string ResolveImplementationAssemblyPath(string path) + { + string? directory = Path.GetDirectoryName(path); + string? parentDirectory = directory != null ? Path.GetDirectoryName(directory) : null; + if (directory != null && + parentDirectory != null && + string.Equals(Path.GetFileName(directory), "ref", StringComparison.OrdinalIgnoreCase)) + { + return Path.Combine(parentDirectory, Path.GetFileName(path)); + } + + return path; + } +} diff --git a/EasySourceGenerators.Generators/SourceEmitting/CSharpAccessibilityKeyword.cs b/EasySourceGenerators.Generators/SourceEmitting/CSharpAccessibilityKeyword.cs new file mode 100644 index 0000000..9ea6518 --- /dev/null +++ b/EasySourceGenerators.Generators/SourceEmitting/CSharpAccessibilityKeyword.cs @@ -0,0 +1,44 @@ +using Microsoft.CodeAnalysis; + +namespace EasySourceGenerators.Generators.SourceEmitting; + +/// +/// Maps Roslyn values to their C# keyword representations. +/// +internal static class CSharpAccessibilityKeyword +{ + /// + /// Returns the C# keyword for the given accessibility level. + /// Returns "private" for and unrecognized values. + /// + internal static string From(Accessibility accessibility) + { + return accessibility switch + { + Accessibility.Public => "public", + Accessibility.Protected => "protected", + Accessibility.Internal => "internal", + Accessibility.ProtectedOrInternal => "protected internal", + Accessibility.ProtectedAndInternal => "private protected", + _ => "private" + }; + } + + /// + /// Returns the C# keyword for the given accessibility level, or an empty string + /// for and unrecognized values. + /// Used in contexts where the private keyword is implicit (e.g., dummy implementations). + /// + internal static string FromOrEmpty(Accessibility accessibility) + { + return accessibility switch + { + Accessibility.Public => "public", + Accessibility.Protected => "protected", + Accessibility.Internal => "internal", + Accessibility.ProtectedOrInternal => "protected internal", + Accessibility.ProtectedAndInternal => "private protected", + _ => "" + }; + } +} diff --git a/EasySourceGenerators.Generators/SourceEmitting/CSharpLiteralFormatter.cs b/EasySourceGenerators.Generators/SourceEmitting/CSharpLiteralFormatter.cs new file mode 100644 index 0000000..2e60af0 --- /dev/null +++ b/EasySourceGenerators.Generators/SourceEmitting/CSharpLiteralFormatter.cs @@ -0,0 +1,54 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace EasySourceGenerators.Generators.SourceEmitting; + +/// +/// Formats values as C# literal expressions suitable for source-generated code. +/// +internal static class CSharpLiteralFormatter +{ + /// + /// Formats a string value as a C# literal expression based on the target return type. + /// Returns "default" when is null. + /// + internal static string FormatValueAsLiteral( + string? value, + SpecialType specialType, + TypeKind typeKind, + string typeDisplayString) + { + if (value == null) + { + return "default"; + } + + return specialType switch + { + SpecialType.System_String => SyntaxFactory.Literal(value).Text, + SpecialType.System_Char when value.Length == 1 => SyntaxFactory.Literal(value[0]).Text, + SpecialType.System_Boolean => value.ToLowerInvariant(), + _ when typeKind == TypeKind.Enum => $"{typeDisplayString}.{value}", + _ => value + }; + } + + /// + /// Formats a key object as a C# literal expression for use in switch case labels. + /// + internal static string FormatKeyAsLiteral(object key, TypeKind? typeKind, string? typeDisplayString) + { + if (typeKind == TypeKind.Enum) + { + return $"{typeDisplayString}.{key}"; + } + + return key switch + { + bool b => b ? "true" : "false", + // SyntaxFactory.Literal handles escaping and quoting (e.g. "hello" → "\"hello\"") + string s => SyntaxFactory.Literal(s).Text, + _ => key.ToString()! + }; + } +} diff --git a/EasySourceGenerators.Generators/SourceEmitting/CSharpTypeKeyword.cs b/EasySourceGenerators.Generators/SourceEmitting/CSharpTypeKeyword.cs new file mode 100644 index 0000000..d8f5232 --- /dev/null +++ b/EasySourceGenerators.Generators/SourceEmitting/CSharpTypeKeyword.cs @@ -0,0 +1,23 @@ +using Microsoft.CodeAnalysis; + +namespace EasySourceGenerators.Generators.SourceEmitting; + +/// +/// Maps Roslyn values to their C# type-declaration keyword. +/// +internal static class CSharpTypeKeyword +{ + /// + /// Returns the C# keyword ("class", "struct", or "interface") + /// for the given type kind. Returns "class" for unrecognized values. + /// + internal static string From(TypeKind typeKind) + { + return typeKind switch + { + TypeKind.Struct => "struct", + TypeKind.Interface => "interface", + _ => "class" + }; + } +} diff --git a/EasySourceGenerators.Generators/SourceEmitting/DummyImplementationEmitter.cs b/EasySourceGenerators.Generators/SourceEmitting/DummyImplementationEmitter.cs new file mode 100644 index 0000000..494b595 --- /dev/null +++ b/EasySourceGenerators.Generators/SourceEmitting/DummyImplementationEmitter.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Text; + +namespace EasySourceGenerators.Generators.SourceEmitting; + +/// +/// Contains the data for a group of dummy partial method implementations within a single type. +/// +internal sealed record DummyTypeGroupData( + string? NamespaceName, + string TypeName, + string TypeKeyword, + string TypeModifiers, + IReadOnlyList Methods); + +/// +/// Contains the data for a single dummy partial method implementation. +/// +internal sealed record DummyMethodData( + string AccessibilityKeyword, + string StaticModifier, + string ReturnTypeName, + string MethodName, + string ParameterList, + string BodyStatement); + +/// +/// Emits dummy partial method implementations used during generator execution compilations. +/// These implementations throw exceptions when called, preventing accidental invocations +/// of unimplemented partial methods during compile-time code generation. +/// +internal static class DummyImplementationEmitter +{ + /// + /// Generates C# source containing dummy implementations for all provided type groups. + /// Each dummy method body consists of a single statement (typically a throw expression). + /// + internal static string Emit(IEnumerable typeGroups) + { + StringBuilder builder = new(); + + foreach (DummyTypeGroupData typeGroup in typeGroups) + { + if (typeGroup.NamespaceName != null) + { + builder.AppendLine($"namespace {typeGroup.NamespaceName} {{"); + } + + builder.AppendLine($"{typeGroup.TypeModifiers} {typeGroup.TypeKeyword} {typeGroup.TypeName} {{"); + + foreach (DummyMethodData method in typeGroup.Methods) + { + builder.AppendLine($"{method.AccessibilityKeyword} {method.StaticModifier}partial {method.ReturnTypeName} {method.MethodName}({method.ParameterList}) {{"); + builder.AppendLine(method.BodyStatement); + builder.AppendLine("}"); + } + + builder.AppendLine("}"); + + if (typeGroup.NamespaceName != null) + { + builder.AppendLine("}"); + } + } + + return builder.ToString(); + } +} diff --git a/EasySourceGenerators.Generators/SourceEmitting/PartialMethodEmitData.cs b/EasySourceGenerators.Generators/SourceEmitting/PartialMethodEmitData.cs new file mode 100644 index 0000000..9b56dc4 --- /dev/null +++ b/EasySourceGenerators.Generators/SourceEmitting/PartialMethodEmitData.cs @@ -0,0 +1,18 @@ +namespace EasySourceGenerators.Generators.SourceEmitting; + +/// +/// Contains all data needed to emit a partial method implementation source file, +/// decoupled from Roslyn symbol types for easier unit testing. +/// +internal sealed record PartialMethodEmitData( + string GeneratorFullName, + string? NamespaceName, + string TypeName, + string TypeKeyword, + string TypeModifiers, + string AccessibilityKeyword, + string MethodModifiers, + string ReturnTypeName, + string MethodName, + string ParameterList, + bool ReturnsVoid); diff --git a/EasySourceGenerators.Generators/SourceEmitting/PartialMethodSourceEmitter.cs b/EasySourceGenerators.Generators/SourceEmitting/PartialMethodSourceEmitter.cs new file mode 100644 index 0000000..ce557ae --- /dev/null +++ b/EasySourceGenerators.Generators/SourceEmitting/PartialMethodSourceEmitter.cs @@ -0,0 +1,60 @@ +using System.Text; + +namespace EasySourceGenerators.Generators.SourceEmitting; + +/// +/// Emits C# source code for partial method implementations. +/// Works with plain data records, independent of Roslyn symbol types. +/// +internal static class PartialMethodSourceEmitter +{ + /// + /// Generates a complete C# source file containing a partial method implementation + /// that returns the given literal value. + /// + internal static string Emit(PartialMethodEmitData data, string? returnValueLiteral) + { + StringBuilder builder = new(); + AppendFileHeader(builder, data); + + if (!data.ReturnsVoid) + { + builder.AppendLine($" return {returnValueLiteral ?? "default"};"); + } + + AppendClosingBraces(builder); + return builder.ToString(); + } + + /// + /// Appends the auto-generated file header, namespace declaration, type declaration, + /// and method signature opening to the . + /// + internal static void AppendFileHeader(StringBuilder builder, PartialMethodEmitData data) + { + builder.AppendLine("// "); + builder.AppendLine($"// Generated by {data.GeneratorFullName} for method '{data.MethodName}'."); + builder.AppendLine("#pragma warning disable"); + builder.AppendLine(); + + if (data.NamespaceName != null) + { + builder.AppendLine($"namespace {data.NamespaceName};"); + builder.AppendLine(); + } + + builder.AppendLine($"{data.TypeModifiers} {data.TypeKeyword} {data.TypeName}"); + builder.AppendLine("{"); + builder.AppendLine($" {data.AccessibilityKeyword} {data.MethodModifiers} {data.ReturnTypeName} {data.MethodName}({data.ParameterList})"); + builder.AppendLine(" {"); + } + + /// + /// Appends closing braces for the method and type declarations. + /// + internal static void AppendClosingBraces(StringBuilder builder) + { + builder.AppendLine(" }"); + builder.AppendLine("}"); + } +} diff --git a/EasySourceGenerators.Generators/SourceEmitting/RoslynSymbolDataMapper.cs b/EasySourceGenerators.Generators/SourceEmitting/RoslynSymbolDataMapper.cs new file mode 100644 index 0000000..fbddd75 --- /dev/null +++ b/EasySourceGenerators.Generators/SourceEmitting/RoslynSymbolDataMapper.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using EasySourceGenerators.Abstractions; +using EasySourceGenerators.Generators.IncrementalGenerators; +using Microsoft.CodeAnalysis; + +namespace EasySourceGenerators.Generators.SourceEmitting; + +/// +/// Converts Roslyn symbol types to plain data records used by source emitters. +/// This is the thin bridge between Roslyn's type system and the Roslyn-free emitter classes. +/// +internal static class RoslynSymbolDataMapper +{ + /// + /// Converts Roslyn and + /// into a record for source emission. + /// + internal static PartialMethodEmitData ToPartialMethodEmitData( + INamedTypeSymbol containingType, + IMethodSymbol partialMethod) + { + string? namespaceName = containingType.ContainingNamespace?.IsGlobalNamespace == false + ? containingType.ContainingNamespace.ToDisplayString() + : null; + + string typeKeyword = CSharpTypeKeyword.From(containingType.TypeKind); + string typeModifiers = containingType.IsStatic ? "static partial" : "partial"; + string accessibility = CSharpAccessibilityKeyword.From(partialMethod.DeclaredAccessibility); + string methodModifiers = partialMethod.IsStatic ? "static partial" : "partial"; + string returnTypeName = partialMethod.ReturnType.ToDisplayString(); + string parameterList = string.Join(", ", partialMethod.Parameters.Select( + parameter => $"{parameter.Type.ToDisplayString()} {parameter.Name}")); + + return new PartialMethodEmitData( + GeneratorFullName: typeof(GeneratesMethodGenerator).FullName!, + NamespaceName: namespaceName, + TypeName: containingType.Name, + TypeKeyword: typeKeyword, + TypeModifiers: typeModifiers, + AccessibilityKeyword: accessibility, + MethodModifiers: methodModifiers, + ReturnTypeName: returnTypeName, + MethodName: partialMethod.Name, + ParameterList: parameterList, + ReturnsVoid: partialMethod.ReturnsVoid); + } + + /// + /// Converts a collection of partial method symbols into + /// records grouped by containing type, suitable for . + /// + internal static IReadOnlyList ToDummyTypeGroups(IEnumerable partialMethods) + { + List result = new(); + + IEnumerable> groupedMethods = + partialMethods.GroupBy(method => ( + Namespace: method.ContainingType.ContainingNamespace?.IsGlobalNamespace == false + ? method.ContainingType.ContainingNamespace.ToDisplayString() + : null, + TypeName: method.ContainingType.Name, + IsStatic: method.ContainingType.IsStatic, + TypeKind: method.ContainingType.TypeKind)); + + foreach (IGrouping<(string? Namespace, string TypeName, bool IsStatic, TypeKind TypeKind), IMethodSymbol> typeGroup in groupedMethods) + { + string typeKeyword = CSharpTypeKeyword.From(typeGroup.Key.TypeKind); + string typeModifiers = typeGroup.Key.IsStatic ? "static partial" : "partial"; + + List methods = new(); + foreach (IMethodSymbol partialMethod in typeGroup) + { + string accessibility = CSharpAccessibilityKeyword.FromOrEmpty(partialMethod.DeclaredAccessibility); + string staticModifier = partialMethod.IsStatic ? "static " : ""; + string returnTypeName = partialMethod.ReturnType.ToDisplayString(); + string parameterList = string.Join(", ", partialMethod.Parameters.Select( + parameter => $"{parameter.Type.ToDisplayString()} {parameter.Name}")); + + string exceptionFullName = $"{Consts.AbstractionsAssemblyName}.{nameof(PartialMethodCalledDuringGenerationException)}"; + string bodyStatement = $"throw new global::{exceptionFullName}(\"{partialMethod.Name}\", \"{partialMethod.ContainingType.Name}\");"; + + methods.Add(new DummyMethodData( + AccessibilityKeyword: accessibility, + StaticModifier: staticModifier, + ReturnTypeName: returnTypeName, + MethodName: partialMethod.Name, + ParameterList: parameterList, + BodyStatement: bodyStatement)); + } + + result.Add(new DummyTypeGroupData( + NamespaceName: typeGroup.Key.Namespace, + TypeName: typeGroup.Key.TypeName, + TypeKeyword: typeKeyword, + TypeModifiers: typeModifiers, + Methods: methods)); + } + + return result; + } +} From 0e8ada5299a57d4d2ab8fe7e77a928476055dd54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:57:29 +0000 Subject: [PATCH 3/6] Add unit tests for all extracted SourceEmitting classes - CSharpLiteralFormatterTests: 16 tests for value/key literal formatting - CSharpAccessibilityKeywordTests: 10 tests for From and FromOrEmpty - CSharpTypeKeywordTests: 5 tests for type kind mapping - PartialMethodSourceEmitterTests: 12 tests for partial method source emission - DummyImplementationEmitterTests: 11 tests for dummy implementation generation Co-authored-by: dex3r <3155725+dex3r@users.noreply.github.com> Agent-Logs-Url: https://github.com/dex3r/EasySourceGenerators/sessions/cb205769-20ee-4e1e-9b44-3ef890163968 --- .../CSharpAccessibilityKeywordTests.cs | 112 ++++++++ .../CSharpLiteralFormatterTests.cs | 137 +++++++++ .../CSharpTypeKeywordTests.cs | 48 ++++ .../DummyImplementationEmitterTests.cs | 269 ++++++++++++++++++ .../PartialMethodSourceEmitterTests.cs | 164 +++++++++++ 5 files changed, 730 insertions(+) create mode 100644 EasySourceGenerators.GeneratorTests/CSharpAccessibilityKeywordTests.cs create mode 100644 EasySourceGenerators.GeneratorTests/CSharpLiteralFormatterTests.cs create mode 100644 EasySourceGenerators.GeneratorTests/CSharpTypeKeywordTests.cs create mode 100644 EasySourceGenerators.GeneratorTests/DummyImplementationEmitterTests.cs create mode 100644 EasySourceGenerators.GeneratorTests/PartialMethodSourceEmitterTests.cs diff --git a/EasySourceGenerators.GeneratorTests/CSharpAccessibilityKeywordTests.cs b/EasySourceGenerators.GeneratorTests/CSharpAccessibilityKeywordTests.cs new file mode 100644 index 0000000..e98033b --- /dev/null +++ b/EasySourceGenerators.GeneratorTests/CSharpAccessibilityKeywordTests.cs @@ -0,0 +1,112 @@ +using EasySourceGenerators.Generators.SourceEmitting; +using Microsoft.CodeAnalysis; + +namespace EasySourceGenerators.GeneratorTests; + +[TestFixture] +public class CSharpAccessibilityKeywordTests +{ + // ----------------------------------------------------------------------- + // From (returns "private" as default) + // ----------------------------------------------------------------------- + + [Test] + public void From_Public_ReturnsPublic() + { + string result = CSharpAccessibilityKeyword.From(Accessibility.Public); + + Assert.That(result, Is.EqualTo("public")); + } + + [Test] + public void From_Protected_ReturnsProtected() + { + string result = CSharpAccessibilityKeyword.From(Accessibility.Protected); + + Assert.That(result, Is.EqualTo("protected")); + } + + [Test] + public void From_Internal_ReturnsInternal() + { + string result = CSharpAccessibilityKeyword.From(Accessibility.Internal); + + Assert.That(result, Is.EqualTo("internal")); + } + + [Test] + public void From_ProtectedOrInternal_ReturnsProtectedInternal() + { + string result = CSharpAccessibilityKeyword.From(Accessibility.ProtectedOrInternal); + + Assert.That(result, Is.EqualTo("protected internal")); + } + + [Test] + public void From_ProtectedAndInternal_ReturnsPrivateProtected() + { + string result = CSharpAccessibilityKeyword.From(Accessibility.ProtectedAndInternal); + + Assert.That(result, Is.EqualTo("private protected")); + } + + [Test] + public void From_Private_ReturnsPrivate() + { + string result = CSharpAccessibilityKeyword.From(Accessibility.Private); + + Assert.That(result, Is.EqualTo("private")); + } + + [Test] + public void From_NotApplicable_ReturnsPrivate() + { + string result = CSharpAccessibilityKeyword.From(Accessibility.NotApplicable); + + Assert.That(result, Is.EqualTo("private")); + } + + // ----------------------------------------------------------------------- + // FromOrEmpty (returns "" as default) + // ----------------------------------------------------------------------- + + [Test] + public void FromOrEmpty_Public_ReturnsPublic() + { + string result = CSharpAccessibilityKeyword.FromOrEmpty(Accessibility.Public); + + Assert.That(result, Is.EqualTo("public")); + } + + [Test] + public void FromOrEmpty_Protected_ReturnsProtected() + { + string result = CSharpAccessibilityKeyword.FromOrEmpty(Accessibility.Protected); + + Assert.That(result, Is.EqualTo("protected")); + } + + [Test] + public void FromOrEmpty_Internal_ReturnsInternal() + { + string result = CSharpAccessibilityKeyword.FromOrEmpty(Accessibility.Internal); + + Assert.That(result, Is.EqualTo("internal")); + } + + [Test] + public void FromOrEmpty_Private_ReturnsEmptyString() + { + string result = CSharpAccessibilityKeyword.FromOrEmpty(Accessibility.Private); + + Assert.That(result, Is.EqualTo("")); + } + + [Test] + public void FromOrEmpty_NotApplicable_ReturnsEmptyString() + { + string result = CSharpAccessibilityKeyword.FromOrEmpty(Accessibility.NotApplicable); + + Assert.That(result, Is.EqualTo("")); + } +} diff --git a/EasySourceGenerators.GeneratorTests/CSharpLiteralFormatterTests.cs b/EasySourceGenerators.GeneratorTests/CSharpLiteralFormatterTests.cs new file mode 100644 index 0000000..da5eb36 --- /dev/null +++ b/EasySourceGenerators.GeneratorTests/CSharpLiteralFormatterTests.cs @@ -0,0 +1,137 @@ +using EasySourceGenerators.Generators.SourceEmitting; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace EasySourceGenerators.GeneratorTests; + +[TestFixture] +public class CSharpLiteralFormatterTests +{ + // ----------------------------------------------------------------------- + // FormatValueAsLiteral + // ----------------------------------------------------------------------- + + [Test] + public void FormatValueAsLiteral_NullValue_ReturnsDefault() + { + string result = CSharpLiteralFormatter.FormatValueAsLiteral(null, SpecialType.System_String, TypeKind.Class, "string"); + + Assert.That(result, Is.EqualTo("default")); + } + + [Test] + public void FormatValueAsLiteral_StringType_ReturnsQuotedLiteral() + { + string result = CSharpLiteralFormatter.FormatValueAsLiteral("hello", SpecialType.System_String, TypeKind.Class, "string"); + + Assert.That(result, Is.EqualTo("\"hello\"")); + } + + [Test] + public void FormatValueAsLiteral_StringWithSpecialCharacters_ReturnsEscapedLiteral() + { + string result = CSharpLiteralFormatter.FormatValueAsLiteral("line1\nline2", SpecialType.System_String, TypeKind.Class, "string"); + + Assert.That(result, Does.Contain("\\n")); + } + + [Test] + public void FormatValueAsLiteral_CharType_ReturnsSingleQuotedLiteral() + { + string result = CSharpLiteralFormatter.FormatValueAsLiteral("A", SpecialType.System_Char, TypeKind.Struct, "char"); + + Assert.That(result, Is.EqualTo("'A'")); + } + + [Test] + public void FormatValueAsLiteral_BooleanTrue_ReturnsLowercase() + { + string result = CSharpLiteralFormatter.FormatValueAsLiteral("True", SpecialType.System_Boolean, TypeKind.Struct, "bool"); + + Assert.That(result, Is.EqualTo("true")); + } + + [Test] + public void FormatValueAsLiteral_BooleanFalse_ReturnsLowercase() + { + string result = CSharpLiteralFormatter.FormatValueAsLiteral("False", SpecialType.System_Boolean, TypeKind.Struct, "bool"); + + Assert.That(result, Is.EqualTo("false")); + } + + [Test] + public void FormatValueAsLiteral_EnumType_ReturnsPrefixedValue() + { + string result = CSharpLiteralFormatter.FormatValueAsLiteral("Red", SpecialType.None, TypeKind.Enum, "MyNamespace.Colors"); + + Assert.That(result, Is.EqualTo("MyNamespace.Colors.Red")); + } + + [Test] + public void FormatValueAsLiteral_IntegerType_ReturnsValueAsIs() + { + string result = CSharpLiteralFormatter.FormatValueAsLiteral("42", SpecialType.System_Int32, TypeKind.Struct, "int"); + + Assert.That(result, Is.EqualTo("42")); + } + + [Test] + public void FormatValueAsLiteral_DecimalType_ReturnsValueAsIs() + { + string result = CSharpLiteralFormatter.FormatValueAsLiteral("3.14", SpecialType.System_Double, TypeKind.Struct, "double"); + + Assert.That(result, Is.EqualTo("3.14")); + } + + // ----------------------------------------------------------------------- + // FormatKeyAsLiteral + // ----------------------------------------------------------------------- + + [Test] + public void FormatKeyAsLiteral_EnumType_ReturnsPrefixedValue() + { + string result = CSharpLiteralFormatter.FormatKeyAsLiteral("Green", TypeKind.Enum, "MyNamespace.Colors"); + + Assert.That(result, Is.EqualTo("MyNamespace.Colors.Green")); + } + + [Test] + public void FormatKeyAsLiteral_BoolTrue_ReturnsTrueLiteral() + { + string result = CSharpLiteralFormatter.FormatKeyAsLiteral(true, TypeKind.Struct, "bool"); + + Assert.That(result, Is.EqualTo("true")); + } + + [Test] + public void FormatKeyAsLiteral_BoolFalse_ReturnsFalseLiteral() + { + string result = CSharpLiteralFormatter.FormatKeyAsLiteral(false, TypeKind.Struct, "bool"); + + Assert.That(result, Is.EqualTo("false")); + } + + [Test] + public void FormatKeyAsLiteral_String_ReturnsQuotedLiteral() + { + string result = CSharpLiteralFormatter.FormatKeyAsLiteral("hello", TypeKind.Class, "string"); + + Assert.That(result, Is.EqualTo("\"hello\"")); + } + + [Test] + public void FormatKeyAsLiteral_Integer_ReturnsToStringResult() + { + string result = CSharpLiteralFormatter.FormatKeyAsLiteral(42, TypeKind.Struct, "int"); + + Assert.That(result, Is.EqualTo("42")); + } + + [Test] + public void FormatKeyAsLiteral_NullTypeKind_UsesDefaultBehavior() + { + string result = CSharpLiteralFormatter.FormatKeyAsLiteral(99, null, null); + + Assert.That(result, Is.EqualTo("99")); + } +} diff --git a/EasySourceGenerators.GeneratorTests/CSharpTypeKeywordTests.cs b/EasySourceGenerators.GeneratorTests/CSharpTypeKeywordTests.cs new file mode 100644 index 0000000..2f86359 --- /dev/null +++ b/EasySourceGenerators.GeneratorTests/CSharpTypeKeywordTests.cs @@ -0,0 +1,48 @@ +using EasySourceGenerators.Generators.SourceEmitting; +using Microsoft.CodeAnalysis; + +namespace EasySourceGenerators.GeneratorTests; + +[TestFixture] +public class CSharpTypeKeywordTests +{ + [Test] + public void From_Class_ReturnsClass() + { + string result = CSharpTypeKeyword.From(TypeKind.Class); + + Assert.That(result, Is.EqualTo("class")); + } + + [Test] + public void From_Struct_ReturnsStruct() + { + string result = CSharpTypeKeyword.From(TypeKind.Struct); + + Assert.That(result, Is.EqualTo("struct")); + } + + [Test] + public void From_Interface_ReturnsInterface() + { + string result = CSharpTypeKeyword.From(TypeKind.Interface); + + Assert.That(result, Is.EqualTo("interface")); + } + + [Test] + public void From_Enum_ReturnsClass() + { + string result = CSharpTypeKeyword.From(TypeKind.Enum); + + Assert.That(result, Is.EqualTo("class")); + } + + [Test] + public void From_Delegate_ReturnsClass() + { + string result = CSharpTypeKeyword.From(TypeKind.Delegate); + + Assert.That(result, Is.EqualTo("class")); + } +} diff --git a/EasySourceGenerators.GeneratorTests/DummyImplementationEmitterTests.cs b/EasySourceGenerators.GeneratorTests/DummyImplementationEmitterTests.cs new file mode 100644 index 0000000..2996408 --- /dev/null +++ b/EasySourceGenerators.GeneratorTests/DummyImplementationEmitterTests.cs @@ -0,0 +1,269 @@ +using EasySourceGenerators.Generators.SourceEmitting; + +namespace EasySourceGenerators.GeneratorTests; + +[TestFixture] +public class DummyImplementationEmitterTests +{ + [Test] + public void Emit_SingleMethodWithNamespace_ContainsNamespaceDeclaration() + { + List groups = new() + { + new DummyTypeGroupData( + NamespaceName: "TestNamespace", + TypeName: "MyClass", + TypeKeyword: "class", + TypeModifiers: "partial", + Methods: new List + { + new DummyMethodData( + AccessibilityKeyword: "public", + StaticModifier: "", + ReturnTypeName: "string", + MethodName: "GetValue", + ParameterList: "", + BodyStatement: "throw new System.Exception();") + }) + }; + + string result = DummyImplementationEmitter.Emit(groups); + + Assert.That(result, Does.Contain("namespace TestNamespace {")); + } + + [Test] + public void Emit_SingleMethodWithNamespace_ContainsClosingNamespaceBrace() + { + List groups = new() + { + new DummyTypeGroupData( + NamespaceName: "TestNamespace", + TypeName: "MyClass", + TypeKeyword: "class", + TypeModifiers: "partial", + Methods: new List + { + new DummyMethodData( + AccessibilityKeyword: "public", + StaticModifier: "", + ReturnTypeName: "string", + MethodName: "GetValue", + ParameterList: "", + BodyStatement: "throw new System.Exception();") + }) + }; + + string result = DummyImplementationEmitter.Emit(groups); + + // Count closing braces: should have namespace }, type }, and method } + int closingBraceCount = result.Split('\n').Count(line => line.Trim() == "}"); + Assert.That(closingBraceCount, Is.GreaterThanOrEqualTo(2)); + } + + [Test] + public void Emit_WithoutNamespace_DoesNotContainNamespaceDeclaration() + { + List groups = new() + { + new DummyTypeGroupData( + NamespaceName: null, + TypeName: "MyClass", + TypeKeyword: "class", + TypeModifiers: "partial", + Methods: new List + { + new DummyMethodData( + AccessibilityKeyword: "public", + StaticModifier: "", + ReturnTypeName: "void", + MethodName: "DoWork", + ParameterList: "", + BodyStatement: "throw new System.Exception();") + }) + }; + + string result = DummyImplementationEmitter.Emit(groups); + + Assert.That(result, Does.Not.Contain("namespace")); + } + + [Test] + public void Emit_ContainsTypeDeclaration() + { + List groups = new() + { + new DummyTypeGroupData( + NamespaceName: null, + TypeName: "Helper", + TypeKeyword: "class", + TypeModifiers: "static partial", + Methods: new List + { + new DummyMethodData( + AccessibilityKeyword: "public", + StaticModifier: "static ", + ReturnTypeName: "int", + MethodName: "Calculate", + ParameterList: "", + BodyStatement: "throw new System.Exception();") + }) + }; + + string result = DummyImplementationEmitter.Emit(groups); + + Assert.That(result, Does.Contain("static partial class Helper {")); + } + + [Test] + public void Emit_ContainsMethodWithPartialKeyword() + { + List groups = new() + { + new DummyTypeGroupData( + NamespaceName: null, + TypeName: "MyClass", + TypeKeyword: "class", + TypeModifiers: "partial", + Methods: new List + { + new DummyMethodData( + AccessibilityKeyword: "public", + StaticModifier: "", + ReturnTypeName: "string", + MethodName: "GetValue", + ParameterList: "", + BodyStatement: "return \"dummy\";") + }) + }; + + string result = DummyImplementationEmitter.Emit(groups); + + Assert.That(result, Does.Contain("public partial string GetValue() {")); + } + + [Test] + public void Emit_StaticMethod_ContainsStaticModifier() + { + List groups = new() + { + new DummyTypeGroupData( + NamespaceName: null, + TypeName: "MyClass", + TypeKeyword: "class", + TypeModifiers: "partial", + Methods: new List + { + new DummyMethodData( + AccessibilityKeyword: "public", + StaticModifier: "static ", + ReturnTypeName: "int", + MethodName: "Compute", + ParameterList: "int x", + BodyStatement: "return 0;") + }) + }; + + string result = DummyImplementationEmitter.Emit(groups); + + Assert.That(result, Does.Contain("public static partial int Compute(int x) {")); + } + + [Test] + public void Emit_ContainsBodyStatement() + { + string expectedBody = "throw new global::MyException(\"test\");"; + List groups = new() + { + new DummyTypeGroupData( + NamespaceName: null, + TypeName: "MyClass", + TypeKeyword: "class", + TypeModifiers: "partial", + Methods: new List + { + new DummyMethodData( + AccessibilityKeyword: "public", + StaticModifier: "", + ReturnTypeName: "void", + MethodName: "DoWork", + ParameterList: "", + BodyStatement: expectedBody) + }) + }; + + string result = DummyImplementationEmitter.Emit(groups); + + Assert.That(result, Does.Contain(expectedBody)); + } + + [Test] + public void Emit_MultipleMethodsInSameType_ContainsBothMethods() + { + List groups = new() + { + new DummyTypeGroupData( + NamespaceName: "NS", + TypeName: "MyClass", + TypeKeyword: "class", + TypeModifiers: "partial", + Methods: new List + { + new DummyMethodData( + AccessibilityKeyword: "public", + StaticModifier: "", + ReturnTypeName: "string", + MethodName: "First", + ParameterList: "", + BodyStatement: "throw new System.Exception();"), + new DummyMethodData( + AccessibilityKeyword: "internal", + StaticModifier: "", + ReturnTypeName: "int", + MethodName: "Second", + ParameterList: "int x", + BodyStatement: "throw new System.Exception();") + }) + }; + + string result = DummyImplementationEmitter.Emit(groups); + + Assert.That(result, Does.Contain("partial string First()")); + Assert.That(result, Does.Contain("partial int Second(int x)")); + } + + [Test] + public void Emit_EmptyGroups_ReturnsEmptyString() + { + string result = DummyImplementationEmitter.Emit(new List()); + + Assert.That(result, Is.EqualTo("")); + } + + [Test] + public void Emit_StructType_ContainsStructKeyword() + { + List groups = new() + { + new DummyTypeGroupData( + NamespaceName: null, + TypeName: "MyStruct", + TypeKeyword: "struct", + TypeModifiers: "partial", + Methods: new List + { + new DummyMethodData( + AccessibilityKeyword: "public", + StaticModifier: "", + ReturnTypeName: "int", + MethodName: "GetValue", + ParameterList: "", + BodyStatement: "return 0;") + }) + }; + + string result = DummyImplementationEmitter.Emit(groups); + + Assert.That(result, Does.Contain("partial struct MyStruct {")); + } +} diff --git a/EasySourceGenerators.GeneratorTests/PartialMethodSourceEmitterTests.cs b/EasySourceGenerators.GeneratorTests/PartialMethodSourceEmitterTests.cs new file mode 100644 index 0000000..185f838 --- /dev/null +++ b/EasySourceGenerators.GeneratorTests/PartialMethodSourceEmitterTests.cs @@ -0,0 +1,164 @@ +using EasySourceGenerators.Generators.SourceEmitting; + +namespace EasySourceGenerators.GeneratorTests; + +[TestFixture] +public class PartialMethodSourceEmitterTests +{ + private static PartialMethodEmitData CreateBasicEmitData( + string? namespaceName = "TestNamespace", + string typeName = "MyClass", + string typeKeyword = "class", + string typeModifiers = "partial", + string accessibilityKeyword = "public", + string methodModifiers = "partial", + string returnTypeName = "string", + string methodName = "GetValue", + string parameterList = "", + bool returnsVoid = false) + { + return new PartialMethodEmitData( + GeneratorFullName: "TestGenerator", + NamespaceName: namespaceName, + TypeName: typeName, + TypeKeyword: typeKeyword, + TypeModifiers: typeModifiers, + AccessibilityKeyword: accessibilityKeyword, + MethodModifiers: methodModifiers, + ReturnTypeName: returnTypeName, + MethodName: methodName, + ParameterList: parameterList, + ReturnsVoid: returnsVoid); + } + + [Test] + public void Emit_WithReturnValue_ContainsReturnStatement() + { + PartialMethodEmitData data = CreateBasicEmitData(); + + string result = PartialMethodSourceEmitter.Emit(data, "\"hello\""); + + Assert.That(result, Does.Contain("return \"hello\";")); + } + + [Test] + public void Emit_WithNullReturnValue_ReturnsDefault() + { + PartialMethodEmitData data = CreateBasicEmitData(); + + string result = PartialMethodSourceEmitter.Emit(data, null); + + Assert.That(result, Does.Contain("return default;")); + } + + [Test] + public void Emit_VoidReturn_DoesNotContainReturnStatement() + { + PartialMethodEmitData data = CreateBasicEmitData(returnTypeName: "void", returnsVoid: true); + + string result = PartialMethodSourceEmitter.Emit(data, null); + + Assert.That(result, Does.Not.Contain("return")); + } + + [Test] + public void Emit_ContainsAutoGeneratedHeader() + { + PartialMethodEmitData data = CreateBasicEmitData(); + + string result = PartialMethodSourceEmitter.Emit(data, "42"); + + Assert.That(result, Does.Contain("// ")); + } + + [Test] + public void Emit_ContainsGeneratorNameInComment() + { + PartialMethodEmitData data = CreateBasicEmitData(methodName: "MyMethod"); + + string result = PartialMethodSourceEmitter.Emit(data, "42"); + + Assert.That(result, Does.Contain("TestGenerator")); + Assert.That(result, Does.Contain("MyMethod")); + } + + [Test] + public void Emit_ContainsPragmaWarningDisable() + { + PartialMethodEmitData data = CreateBasicEmitData(); + + string result = PartialMethodSourceEmitter.Emit(data, "42"); + + Assert.That(result, Does.Contain("#pragma warning disable")); + } + + [Test] + public void Emit_WithNamespace_ContainsNamespaceDeclaration() + { + PartialMethodEmitData data = CreateBasicEmitData(namespaceName: "MyApp.Models"); + + string result = PartialMethodSourceEmitter.Emit(data, "42"); + + Assert.That(result, Does.Contain("namespace MyApp.Models;")); + } + + [Test] + public void Emit_WithoutNamespace_DoesNotContainNamespaceDeclaration() + { + PartialMethodEmitData data = CreateBasicEmitData(namespaceName: null); + + string result = PartialMethodSourceEmitter.Emit(data, "42"); + + Assert.That(result, Does.Not.Contain("namespace")); + } + + [Test] + public void Emit_ContainsTypeDeclaration() + { + PartialMethodEmitData data = CreateBasicEmitData( + typeModifiers: "static partial", + typeKeyword: "class", + typeName: "Helper"); + + string result = PartialMethodSourceEmitter.Emit(data, "42"); + + Assert.That(result, Does.Contain("static partial class Helper")); + } + + [Test] + public void Emit_ContainsMethodSignature() + { + PartialMethodEmitData data = CreateBasicEmitData( + accessibilityKeyword: "internal", + methodModifiers: "static partial", + returnTypeName: "int", + methodName: "Calculate", + parameterList: "int x, string y"); + + string result = PartialMethodSourceEmitter.Emit(data, "42"); + + Assert.That(result, Does.Contain("internal static partial int Calculate(int x, string y)")); + } + + [Test] + public void Emit_ContainsClosingBraces() + { + PartialMethodEmitData data = CreateBasicEmitData(); + + string result = PartialMethodSourceEmitter.Emit(data, "42"); + + // Should end with method closing brace and type closing brace + Assert.That(result, Does.Contain(" }")); + Assert.That(result, Does.Contain("}")); + } + + [Test] + public void Emit_StructType_ContainsStructKeyword() + { + PartialMethodEmitData data = CreateBasicEmitData(typeKeyword: "struct"); + + string result = PartialMethodSourceEmitter.Emit(data, "42"); + + Assert.That(result, Does.Contain("partial struct MyClass")); + } +} From 804f21647f61cbfd4e8096bc7c4fe220626f0288 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:01:50 +0000 Subject: [PATCH 4/6] Address review: replace fragile StaticModifier with MethodModifiers in DummyMethodData Use consistent MethodModifiers pattern ("partial" / "static partial") in DummyMethodData, matching PartialMethodEmitData approach and eliminating the fragile trailing-space convention. Co-authored-by: dex3r <3155725+dex3r@users.noreply.github.com> Agent-Logs-Url: https://github.com/dex3r/EasySourceGenerators/sessions/cb205769-20ee-4e1e-9b44-3ef890163968 --- .../DummyImplementationEmitterTests.cs | 20 +++++++++---------- .../DummyImplementationEmitter.cs | 4 ++-- .../SourceEmitting/RoslynSymbolDataMapper.cs | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/EasySourceGenerators.GeneratorTests/DummyImplementationEmitterTests.cs b/EasySourceGenerators.GeneratorTests/DummyImplementationEmitterTests.cs index 2996408..0b19c46 100644 --- a/EasySourceGenerators.GeneratorTests/DummyImplementationEmitterTests.cs +++ b/EasySourceGenerators.GeneratorTests/DummyImplementationEmitterTests.cs @@ -19,7 +19,7 @@ public void Emit_SingleMethodWithNamespace_ContainsNamespaceDeclaration() { new DummyMethodData( AccessibilityKeyword: "public", - StaticModifier: "", + MethodModifiers: "partial", ReturnTypeName: "string", MethodName: "GetValue", ParameterList: "", @@ -46,7 +46,7 @@ public void Emit_SingleMethodWithNamespace_ContainsClosingNamespaceBrace() { new DummyMethodData( AccessibilityKeyword: "public", - StaticModifier: "", + MethodModifiers: "partial", ReturnTypeName: "string", MethodName: "GetValue", ParameterList: "", @@ -75,7 +75,7 @@ public void Emit_WithoutNamespace_DoesNotContainNamespaceDeclaration() { new DummyMethodData( AccessibilityKeyword: "public", - StaticModifier: "", + MethodModifiers: "partial", ReturnTypeName: "void", MethodName: "DoWork", ParameterList: "", @@ -102,7 +102,7 @@ public void Emit_ContainsTypeDeclaration() { new DummyMethodData( AccessibilityKeyword: "public", - StaticModifier: "static ", + MethodModifiers: "static partial", ReturnTypeName: "int", MethodName: "Calculate", ParameterList: "", @@ -129,7 +129,7 @@ public void Emit_ContainsMethodWithPartialKeyword() { new DummyMethodData( AccessibilityKeyword: "public", - StaticModifier: "", + MethodModifiers: "partial", ReturnTypeName: "string", MethodName: "GetValue", ParameterList: "", @@ -156,7 +156,7 @@ public void Emit_StaticMethod_ContainsStaticModifier() { new DummyMethodData( AccessibilityKeyword: "public", - StaticModifier: "static ", + MethodModifiers: "static partial", ReturnTypeName: "int", MethodName: "Compute", ParameterList: "int x", @@ -184,7 +184,7 @@ public void Emit_ContainsBodyStatement() { new DummyMethodData( AccessibilityKeyword: "public", - StaticModifier: "", + MethodModifiers: "partial", ReturnTypeName: "void", MethodName: "DoWork", ParameterList: "", @@ -211,14 +211,14 @@ public void Emit_MultipleMethodsInSameType_ContainsBothMethods() { new DummyMethodData( AccessibilityKeyword: "public", - StaticModifier: "", + MethodModifiers: "partial", ReturnTypeName: "string", MethodName: "First", ParameterList: "", BodyStatement: "throw new System.Exception();"), new DummyMethodData( AccessibilityKeyword: "internal", - StaticModifier: "", + MethodModifiers: "partial", ReturnTypeName: "int", MethodName: "Second", ParameterList: "int x", @@ -254,7 +254,7 @@ public void Emit_StructType_ContainsStructKeyword() { new DummyMethodData( AccessibilityKeyword: "public", - StaticModifier: "", + MethodModifiers: "partial", ReturnTypeName: "int", MethodName: "GetValue", ParameterList: "", diff --git a/EasySourceGenerators.Generators/SourceEmitting/DummyImplementationEmitter.cs b/EasySourceGenerators.Generators/SourceEmitting/DummyImplementationEmitter.cs index 494b595..e7c913f 100644 --- a/EasySourceGenerators.Generators/SourceEmitting/DummyImplementationEmitter.cs +++ b/EasySourceGenerators.Generators/SourceEmitting/DummyImplementationEmitter.cs @@ -18,7 +18,7 @@ internal sealed record DummyTypeGroupData( /// internal sealed record DummyMethodData( string AccessibilityKeyword, - string StaticModifier, + string MethodModifiers, string ReturnTypeName, string MethodName, string ParameterList, @@ -50,7 +50,7 @@ internal static string Emit(IEnumerable typeGroups) foreach (DummyMethodData method in typeGroup.Methods) { - builder.AppendLine($"{method.AccessibilityKeyword} {method.StaticModifier}partial {method.ReturnTypeName} {method.MethodName}({method.ParameterList}) {{"); + builder.AppendLine($"{method.AccessibilityKeyword} {method.MethodModifiers} {method.ReturnTypeName} {method.MethodName}({method.ParameterList}) {{"); builder.AppendLine(method.BodyStatement); builder.AppendLine("}"); } diff --git a/EasySourceGenerators.Generators/SourceEmitting/RoslynSymbolDataMapper.cs b/EasySourceGenerators.Generators/SourceEmitting/RoslynSymbolDataMapper.cs index fbddd75..71e4df5 100644 --- a/EasySourceGenerators.Generators/SourceEmitting/RoslynSymbolDataMapper.cs +++ b/EasySourceGenerators.Generators/SourceEmitting/RoslynSymbolDataMapper.cs @@ -73,7 +73,7 @@ internal static IReadOnlyList ToDummyTypeGroups(IEnumerable< foreach (IMethodSymbol partialMethod in typeGroup) { string accessibility = CSharpAccessibilityKeyword.FromOrEmpty(partialMethod.DeclaredAccessibility); - string staticModifier = partialMethod.IsStatic ? "static " : ""; + string methodModifiers = partialMethod.IsStatic ? "static partial" : "partial"; string returnTypeName = partialMethod.ReturnType.ToDisplayString(); string parameterList = string.Join(", ", partialMethod.Parameters.Select( parameter => $"{parameter.Type.ToDisplayString()} {parameter.Name}")); @@ -83,7 +83,7 @@ internal static IReadOnlyList ToDummyTypeGroups(IEnumerable< methods.Add(new DummyMethodData( AccessibilityKeyword: accessibility, - StaticModifier: staticModifier, + MethodModifiers: methodModifiers, ReturnTypeName: returnTypeName, MethodName: partialMethod.Name, ParameterList: parameterList, From 174a3e2d3863e5c09836b3f612ab4fe8d6143a92 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:15:41 +0000 Subject: [PATCH 5/6] Address review: split files, extract helpers, rename CSharpAccessibilityKeyword - Move LoadedAssemblyContext into its own file - Extract DiagnosticMessageHelper for testable error-joining - Extract AbstractionsAssemblyResolver from GeneratesMethodExecutionRuntime - Extract DataGeneratorsFactorySetup from GeneratesMethodExecutionRuntime - Merge FromOrEmpty into ToKeyword with defaultToPrivate parameter - Add DiagnosticMessageHelperTests (5 tests) - Update CSharpAccessibilityKeywordTests for renamed API (12 tests) Co-authored-by: dex3r <3155725+dex3r@users.noreply.github.com> Agent-Logs-Url: https://github.com/dex3r/EasySourceGenerators/sessions/7c930db8-e3f7-42d9-98cd-41cf86cdc442 --- .../CSharpAccessibilityKeywordTests.cs | 52 +++---- .../DiagnosticMessageHelperTests.cs | 85 ++++++++++++ .../AbstractionsAssemblyResolver.cs | 123 ++++++++++++++++ .../DataGeneratorsFactorySetup.cs | 37 +++++ .../DiagnosticMessageHelper.cs | 23 +++ .../GeneratesMethodExecutionRuntime.cs | 131 +++--------------- .../GeneratorAssemblyExecutor.cs | 46 +----- .../LoadedAssemblyContext.cs | 44 ++++++ .../CSharpAccessibilityKeyword.cs | 27 +--- .../SourceEmitting/RoslynSymbolDataMapper.cs | 4 +- 10 files changed, 372 insertions(+), 200 deletions(-) create mode 100644 EasySourceGenerators.GeneratorTests/DiagnosticMessageHelperTests.cs create mode 100644 EasySourceGenerators.Generators/IncrementalGenerators/AbstractionsAssemblyResolver.cs create mode 100644 EasySourceGenerators.Generators/IncrementalGenerators/DataGeneratorsFactorySetup.cs create mode 100644 EasySourceGenerators.Generators/IncrementalGenerators/DiagnosticMessageHelper.cs create mode 100644 EasySourceGenerators.Generators/IncrementalGenerators/LoadedAssemblyContext.cs diff --git a/EasySourceGenerators.GeneratorTests/CSharpAccessibilityKeywordTests.cs b/EasySourceGenerators.GeneratorTests/CSharpAccessibilityKeywordTests.cs index e98033b..eacca95 100644 --- a/EasySourceGenerators.GeneratorTests/CSharpAccessibilityKeywordTests.cs +++ b/EasySourceGenerators.GeneratorTests/CSharpAccessibilityKeywordTests.cs @@ -7,105 +7,105 @@ namespace EasySourceGenerators.GeneratorTests; public class CSharpAccessibilityKeywordTests { // ----------------------------------------------------------------------- - // From (returns "private" as default) + // ToKeyword with defaultToPrivate = true (default) // ----------------------------------------------------------------------- [Test] - public void From_Public_ReturnsPublic() + public void ToKeyword_Public_ReturnsPublic() { - string result = CSharpAccessibilityKeyword.From(Accessibility.Public); + string result = CSharpAccessibilityKeyword.ToKeyword(Accessibility.Public); Assert.That(result, Is.EqualTo("public")); } [Test] - public void From_Protected_ReturnsProtected() + public void ToKeyword_Protected_ReturnsProtected() { - string result = CSharpAccessibilityKeyword.From(Accessibility.Protected); + string result = CSharpAccessibilityKeyword.ToKeyword(Accessibility.Protected); Assert.That(result, Is.EqualTo("protected")); } [Test] - public void From_Internal_ReturnsInternal() + public void ToKeyword_Internal_ReturnsInternal() { - string result = CSharpAccessibilityKeyword.From(Accessibility.Internal); + string result = CSharpAccessibilityKeyword.ToKeyword(Accessibility.Internal); Assert.That(result, Is.EqualTo("internal")); } [Test] - public void From_ProtectedOrInternal_ReturnsProtectedInternal() + public void ToKeyword_ProtectedOrInternal_ReturnsProtectedInternal() { - string result = CSharpAccessibilityKeyword.From(Accessibility.ProtectedOrInternal); + string result = CSharpAccessibilityKeyword.ToKeyword(Accessibility.ProtectedOrInternal); Assert.That(result, Is.EqualTo("protected internal")); } [Test] - public void From_ProtectedAndInternal_ReturnsPrivateProtected() + public void ToKeyword_ProtectedAndInternal_ReturnsPrivateProtected() { - string result = CSharpAccessibilityKeyword.From(Accessibility.ProtectedAndInternal); + string result = CSharpAccessibilityKeyword.ToKeyword(Accessibility.ProtectedAndInternal); Assert.That(result, Is.EqualTo("private protected")); } [Test] - public void From_Private_ReturnsPrivate() + public void ToKeyword_Private_ReturnsPrivate() { - string result = CSharpAccessibilityKeyword.From(Accessibility.Private); + string result = CSharpAccessibilityKeyword.ToKeyword(Accessibility.Private); Assert.That(result, Is.EqualTo("private")); } [Test] - public void From_NotApplicable_ReturnsPrivate() + public void ToKeyword_NotApplicable_ReturnsPrivate() { - string result = CSharpAccessibilityKeyword.From(Accessibility.NotApplicable); + string result = CSharpAccessibilityKeyword.ToKeyword(Accessibility.NotApplicable); Assert.That(result, Is.EqualTo("private")); } // ----------------------------------------------------------------------- - // FromOrEmpty (returns "" as default) + // ToKeyword with defaultToPrivate = false // ----------------------------------------------------------------------- [Test] - public void FromOrEmpty_Public_ReturnsPublic() + public void ToKeyword_DefaultToPrivateFalse_Public_ReturnsPublic() { - string result = CSharpAccessibilityKeyword.FromOrEmpty(Accessibility.Public); + string result = CSharpAccessibilityKeyword.ToKeyword(Accessibility.Public, defaultToPrivate: false); Assert.That(result, Is.EqualTo("public")); } [Test] - public void FromOrEmpty_Protected_ReturnsProtected() + public void ToKeyword_DefaultToPrivateFalse_Protected_ReturnsProtected() { - string result = CSharpAccessibilityKeyword.FromOrEmpty(Accessibility.Protected); + string result = CSharpAccessibilityKeyword.ToKeyword(Accessibility.Protected, defaultToPrivate: false); Assert.That(result, Is.EqualTo("protected")); } [Test] - public void FromOrEmpty_Internal_ReturnsInternal() + public void ToKeyword_DefaultToPrivateFalse_Internal_ReturnsInternal() { - string result = CSharpAccessibilityKeyword.FromOrEmpty(Accessibility.Internal); + string result = CSharpAccessibilityKeyword.ToKeyword(Accessibility.Internal, defaultToPrivate: false); Assert.That(result, Is.EqualTo("internal")); } [Test] - public void FromOrEmpty_Private_ReturnsEmptyString() + public void ToKeyword_DefaultToPrivateFalse_Private_ReturnsEmptyString() { - string result = CSharpAccessibilityKeyword.FromOrEmpty(Accessibility.Private); + string result = CSharpAccessibilityKeyword.ToKeyword(Accessibility.Private, defaultToPrivate: false); Assert.That(result, Is.EqualTo("")); } [Test] - public void FromOrEmpty_NotApplicable_ReturnsEmptyString() + public void ToKeyword_DefaultToPrivateFalse_NotApplicable_ReturnsEmptyString() { - string result = CSharpAccessibilityKeyword.FromOrEmpty(Accessibility.NotApplicable); + string result = CSharpAccessibilityKeyword.ToKeyword(Accessibility.NotApplicable, defaultToPrivate: false); Assert.That(result, Is.EqualTo("")); } diff --git a/EasySourceGenerators.GeneratorTests/DiagnosticMessageHelperTests.cs b/EasySourceGenerators.GeneratorTests/DiagnosticMessageHelperTests.cs new file mode 100644 index 0000000..d39d45f --- /dev/null +++ b/EasySourceGenerators.GeneratorTests/DiagnosticMessageHelperTests.cs @@ -0,0 +1,85 @@ +using EasySourceGenerators.Generators.IncrementalGenerators; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace EasySourceGenerators.GeneratorTests; + +[TestFixture] +public class DiagnosticMessageHelperTests +{ + [Test] + public void JoinErrorDiagnostics_EmptyList_ReturnsEmptyString() + { + string result = DiagnosticMessageHelper.JoinErrorDiagnostics(Array.Empty()); + + Assert.That(result, Is.EqualTo("")); + } + + [Test] + public void JoinErrorDiagnostics_OnlyWarnings_ReturnsEmptyString() + { + Diagnostic[] diagnostics = new[] + { + CreateDiagnostic(DiagnosticSeverity.Warning, "This is a warning") + }; + + string result = DiagnosticMessageHelper.JoinErrorDiagnostics(diagnostics); + + Assert.That(result, Is.EqualTo("")); + } + + [Test] + public void JoinErrorDiagnostics_SingleError_ReturnsSingleMessage() + { + Diagnostic[] diagnostics = new[] + { + CreateDiagnostic(DiagnosticSeverity.Error, "Something went wrong") + }; + + string result = DiagnosticMessageHelper.JoinErrorDiagnostics(diagnostics); + + Assert.That(result, Is.EqualTo("Something went wrong")); + } + + [Test] + public void JoinErrorDiagnostics_MultipleErrors_JoinsWithSemicolon() + { + Diagnostic[] diagnostics = new[] + { + CreateDiagnostic(DiagnosticSeverity.Error, "First error"), + CreateDiagnostic(DiagnosticSeverity.Error, "Second error") + }; + + string result = DiagnosticMessageHelper.JoinErrorDiagnostics(diagnostics); + + Assert.That(result, Is.EqualTo("First error; Second error")); + } + + [Test] + public void JoinErrorDiagnostics_MixedSeverities_ReturnsOnlyErrors() + { + Diagnostic[] diagnostics = new[] + { + CreateDiagnostic(DiagnosticSeverity.Warning, "A warning"), + CreateDiagnostic(DiagnosticSeverity.Error, "An error"), + CreateDiagnostic(DiagnosticSeverity.Info, "An info") + }; + + string result = DiagnosticMessageHelper.JoinErrorDiagnostics(diagnostics); + + Assert.That(result, Is.EqualTo("An error")); + } + + private static Diagnostic CreateDiagnostic(DiagnosticSeverity severity, string message) + { + DiagnosticDescriptor descriptor = new( + id: "TEST001", + title: "Test", + messageFormat: message, + category: "Test", + defaultSeverity: severity, + isEnabledByDefault: true); + + return Diagnostic.Create(descriptor, Location.None); + } +} diff --git a/EasySourceGenerators.Generators/IncrementalGenerators/AbstractionsAssemblyResolver.cs b/EasySourceGenerators.Generators/IncrementalGenerators/AbstractionsAssemblyResolver.cs new file mode 100644 index 0000000..7bd6a4f --- /dev/null +++ b/EasySourceGenerators.Generators/IncrementalGenerators/AbstractionsAssemblyResolver.cs @@ -0,0 +1,123 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.CodeAnalysis; + +namespace EasySourceGenerators.Generators.IncrementalGenerators; + +/// +/// Resolves the EasySourceGenerators.Abstractions assembly from compilation references. +/// Handles both file-based () and in-memory +/// () references, such as those provided by Rider's code inspector. +/// +internal static class AbstractionsAssemblyResolver +{ + /// + /// Resolves the abstractions assembly from the compilation references into the given + /// . + /// + internal static (Assembly? assembly, string? error) Resolve( + LoadedAssemblyContext context, + Compilation compilation) + { + MetadataReference[] matchingReferences = FindAbstractionsReferences(compilation); + + if (matchingReferences.Length == 0) + { + return (null, BuildNoMatchError(compilation)); + } + + PortableExecutableReference[] peReferences = + matchingReferences.OfType().ToArray(); + CompilationReference[] compilationReferences = + matchingReferences.OfType().ToArray(); + + if (peReferences.Length > 0) + { + return LoadFromPortableExecutableReference(peReferences.First(), context); + } + + if (compilationReferences.Length > 0) + { + return LoadFromCompilationReference(context); + } + + string matchesString = string.Join(", ", + matchingReferences.Select(reference => + $"{reference.Display} (type: {reference.GetType().Name})")); + return (null, + $"Found references matching '{Consts.AbstractionsAssemblyName}' but none were PortableExecutableReference or CompilationReference with valid file paths. \nMatching references: {matchesString}"); + } + + /// + /// Finds all compilation references that match the abstractions assembly name. + /// + private static MetadataReference[] FindAbstractionsReferences(Compilation compilation) + { + return compilation.References.Where(reference => + reference.Display is not null && ( + reference.Display.Equals(Consts.AbstractionsAssemblyName, StringComparison.OrdinalIgnoreCase) + || (reference is PortableExecutableReference peRef && peRef.FilePath is not null && + Path.GetFileNameWithoutExtension(peRef.FilePath) + .Equals(Consts.AbstractionsAssemblyName, StringComparison.OrdinalIgnoreCase)))) + .ToArray(); + } + + /// + /// Builds an error message when no matching abstractions reference is found. + /// + private static string BuildNoMatchError(Compilation compilation) + { + MetadataReference[] closestMatches = compilation.References.Where(reference => + reference.Display is not null + && reference.Display.Contains(Consts.SolutionNamespace, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + string closestMatchesString = string.Join(", ", closestMatches.Select(reference => reference.Display)); + + return $"Could not find any reference matching '{Consts.AbstractionsAssemblyName}' in compilation references.\n" + + $" Found total references: {compilation.References.Count()}. \nMatching references: {closestMatches.Length}: \n{closestMatchesString}"; + } + + /// + /// Loads the abstractions assembly from a file-based . + /// + private static (Assembly? assembly, string? error) LoadFromPortableExecutableReference( + PortableExecutableReference reference, + LoadedAssemblyContext context) + { + if (string.IsNullOrEmpty(reference.FilePath)) + { + return (null, + $"The reference matching '{Consts.AbstractionsAssemblyName}' does not have a valid file path."); + } + + string assemblyPath = GeneratorAssemblyExecutor.ResolveImplementationAssemblyPath(reference.FilePath); + Assembly assembly = context.LoadContext.LoadFromAssemblyPath(assemblyPath); + return (assembly, null); + } + + /// + /// Loads the abstractions assembly from an in-memory . + /// Uses the captured assembly from the load context if available, otherwise emits from bytes. + /// + private static (Assembly? assembly, string? error) LoadFromCompilationReference( + LoadedAssemblyContext context) + { + if (context.CapturedAbstractionsAssembly != null) + { + return (context.CapturedAbstractionsAssembly, null); + } + + if (context.CompilationReferenceBytes.TryGetValue(Consts.AbstractionsAssemblyName, + out byte[]? abstractionBytes)) + { + Assembly assembly = context.LoadContext.LoadFromStream(new MemoryStream(abstractionBytes)); + return (assembly, null); + } + + return (null, + $"Found reference matching '{Consts.AbstractionsAssemblyName}' as a CompilationReference, but failed to emit it to a loadable assembly."); + } +} diff --git a/EasySourceGenerators.Generators/IncrementalGenerators/DataGeneratorsFactorySetup.cs b/EasySourceGenerators.Generators/IncrementalGenerators/DataGeneratorsFactorySetup.cs new file mode 100644 index 0000000..ff31313 --- /dev/null +++ b/EasySourceGenerators.Generators/IncrementalGenerators/DataGeneratorsFactorySetup.cs @@ -0,0 +1,37 @@ +using System; +using System.Reflection; + +namespace EasySourceGenerators.Generators.IncrementalGenerators; + +/// +/// Sets up the DataGeneratorsFactory in the loaded execution assembly and +/// assigns it to Generate.CurrentGenerator in the abstractions assembly, +/// enabling fluent API usage during generator execution. +/// +internal static class DataGeneratorsFactorySetup +{ + /// + /// Creates a DataGeneratorsFactory instance and wires it to the + /// Generate.CurrentGenerator static property. Returns an error message + /// if the required types or properties cannot be found. + /// + internal static string? Setup( + Assembly executionAssembly, + Assembly abstractionsAssembly) + { + Type? generatorStaticType = abstractionsAssembly.GetType(Consts.GenerateTypeFullName); + Type? dataGeneratorsFactoryType = executionAssembly.GetType(Consts.DataGeneratorsFactoryTypeFullName); + if (generatorStaticType == null || dataGeneratorsFactoryType == null) + { + return + $"Could not find {Consts.GenerateTypeFullName} or {Consts.DataGeneratorsFactoryTypeFullName} types in compiled assembly"; + } + + object? dataGeneratorsFactory = Activator.CreateInstance(dataGeneratorsFactoryType); + PropertyInfo? currentGeneratorProperty = generatorStaticType.GetProperty( + Consts.CurrentGeneratorPropertyName, BindingFlags.NonPublic | BindingFlags.Static); + currentGeneratorProperty?.SetValue(null, dataGeneratorsFactory); + + return null; + } +} diff --git a/EasySourceGenerators.Generators/IncrementalGenerators/DiagnosticMessageHelper.cs b/EasySourceGenerators.Generators/IncrementalGenerators/DiagnosticMessageHelper.cs new file mode 100644 index 0000000..d04c596 --- /dev/null +++ b/EasySourceGenerators.Generators/IncrementalGenerators/DiagnosticMessageHelper.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace EasySourceGenerators.Generators.IncrementalGenerators; + +/// +/// Provides helper methods for formatting diagnostic and error messages +/// used during generator execution. +/// +internal static class DiagnosticMessageHelper +{ + /// + /// Joins error diagnostics from a compilation result into a single semicolon-separated string. + /// Only includes diagnostics with severity. + /// + internal static string JoinErrorDiagnostics(IEnumerable diagnostics) + { + return string.Join("; ", diagnostics + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(diagnostic => diagnostic.GetMessage())); + } +} diff --git a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodExecutionRuntime.cs b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodExecutionRuntime.cs index 4c8d5cb..cb8add1 100644 --- a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodExecutionRuntime.cs +++ b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratesMethodExecutionRuntime.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Reflection; using Microsoft.CodeAnalysis; @@ -22,7 +21,9 @@ internal sealed record FluentBodyResult( /// /// Orchestrates the execution of generator methods at compile time. -/// Delegates compilation and assembly loading to , +/// Delegates compilation to , +/// abstractions resolution to , +/// factory setup to , /// and data extraction to . /// internal static class GeneratesMethodExecutionRuntime @@ -61,33 +62,25 @@ internal static (FluentBodyResult? result, string? error) ExecuteFluentBodyGener try { (Assembly? abstractionsAssembly, string? abstractionsError) = - ResolveAbstractionsAssembly(context, compilation); + AbstractionsAssemblyResolver.Resolve(context, compilation); if (abstractionsError != null) { return (null, abstractionsError); } - string? setupError = SetupDataGeneratorsFactory(context.Assembly, abstractionsAssembly!, context); + string? setupError = DataGeneratorsFactorySetup.Setup(context.Assembly, abstractionsAssembly!); if (setupError != null) { return (null, setupError); } - string typeName = generatorMethod.ContainingType.ToDisplayString(); - (Type? loadedType, string? typeError) = GeneratorAssemblyExecutor.FindType(context.Assembly, typeName); - if (typeError != null) + (object? methodResult, string? invokeError) = + InvokeStaticMethod(context.Assembly, generatorMethod); + if (invokeError != null) { - return (null, typeError); + return (null, invokeError); } - (MethodInfo? methodInfo, string? methodError) = - GeneratorAssemblyExecutor.FindStaticMethod(loadedType!, generatorMethod.Name, typeName); - if (methodError != null) - { - return (null, methodError); - } - - object? methodResult = methodInfo!.Invoke(null, null); if (methodResult == null) { return (null, "Fluent body generator method returned null"); @@ -172,105 +165,27 @@ internal static IReadOnlyList GetAllUnimplementedPartialMethods(C } /// - /// Resolves the abstractions assembly from the compilation references. - /// Handles both (file-based) and - /// (in-memory, e.g., from Rider's code inspector). + /// Locates and invokes a static generator method in the loaded assembly. /// - private static (Assembly? assembly, string? error) ResolveAbstractionsAssembly( - LoadedAssemblyContext context, - Compilation compilation) + private static (object? result, string? error) InvokeStaticMethod( + Assembly assembly, + IMethodSymbol generatorMethod) { - MetadataReference[] abstractionsMatchingReferences = compilation.References.Where(reference => - reference.Display is not null && ( - reference.Display.Equals(Consts.AbstractionsAssemblyName, StringComparison.OrdinalIgnoreCase) - || (reference is PortableExecutableReference peRef && peRef.FilePath is not null && - Path.GetFileNameWithoutExtension(peRef.FilePath) - .Equals(Consts.AbstractionsAssemblyName, StringComparison.OrdinalIgnoreCase)))) - .ToArray(); - - if (abstractionsMatchingReferences.Length == 0) - { - MetadataReference[] closestMatches = compilation.References.Where(reference => - reference.Display is not null - && reference.Display.Contains(Consts.SolutionNamespace, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - - string closestMatchesString = string.Join(", ", closestMatches.Select(reference => reference.Display)); - - return (null, - $"Could not find any reference matching '{Consts.AbstractionsAssemblyName}' in compilation references.\n" + - $" Found total references: {compilation.References.Count()}. \nMatching references: {closestMatches.Length}: \n{closestMatchesString}"); - } - - PortableExecutableReference[] peMatchingReferences = - abstractionsMatchingReferences.OfType().ToArray(); - CompilationReference[] csharpCompilationReference = - abstractionsMatchingReferences.OfType().ToArray(); - - if (peMatchingReferences.Length > 0) + string typeName = generatorMethod.ContainingType.ToDisplayString(); + (Type? loadedType, string? typeError) = GeneratorAssemblyExecutor.FindType(assembly, typeName); + if (typeError != null) { - PortableExecutableReference abstractionsReference = peMatchingReferences.First(); - - if (string.IsNullOrEmpty(abstractionsReference.FilePath)) - { - return (null, - $"The reference matching '{Consts.AbstractionsAssemblyName}' does not have a valid file path."); - } - - string abstractionsAssemblyPath = - GeneratorAssemblyExecutor.ResolveImplementationAssemblyPath(abstractionsReference.FilePath); - Assembly abstractionsAssembly = context.LoadContext.LoadFromAssemblyPath(abstractionsAssemblyPath); - return (abstractionsAssembly, null); + return (null, typeError); } - if (csharpCompilationReference.Length > 0) + (MethodInfo? methodInfo, string? methodError) = + GeneratorAssemblyExecutor.FindStaticMethod(loadedType!, generatorMethod.Name, typeName); + if (methodError != null) { - if (context.CapturedAbstractionsAssembly != null) - { - return (context.CapturedAbstractionsAssembly, null); - } - - if (context.CompilationReferenceBytes.TryGetValue(Consts.AbstractionsAssemblyName, - out byte[]? abstractionBytes)) - { - Assembly abstractionsAssembly = - context.LoadContext.LoadFromStream(new MemoryStream(abstractionBytes)); - return (abstractionsAssembly, null); - } - - return (null, - $"Found reference matching '{Consts.AbstractionsAssemblyName}' as a CompilationReference, but failed to emit it to a loadable assembly."); + return (null, methodError); } - string matchesString = string.Join(", ", - abstractionsMatchingReferences.Select(reference => - $"{reference.Display} (type: {reference.GetType().Name})")); - return (null, - $"Found references matching '{Consts.AbstractionsAssemblyName}' but none were PortableExecutableReference or CompilationReference with valid file paths. \nMatching references: {matchesString}"); - } - - /// - /// Sets up the DataGeneratorsFactory and assigns it to Generate.CurrentGenerator - /// in the loaded abstractions assembly, enabling fluent API usage during generator execution. - /// - private static string? SetupDataGeneratorsFactory( - Assembly executionAssembly, - Assembly abstractionsAssembly, - LoadedAssemblyContext context) - { - Type? generatorStaticType = abstractionsAssembly.GetType(Consts.GenerateTypeFullName); - Type? dataGeneratorsFactoryType = executionAssembly.GetType(Consts.DataGeneratorsFactoryTypeFullName); - if (generatorStaticType == null || dataGeneratorsFactoryType == null) - { - return - $"Could not find {Consts.GenerateTypeFullName} or {Consts.DataGeneratorsFactoryTypeFullName} types in compiled assembly"; - } - - object? dataGeneratorsFactory = Activator.CreateInstance(dataGeneratorsFactoryType); - PropertyInfo? currentGeneratorProperty = generatorStaticType.GetProperty( - Consts.CurrentGeneratorPropertyName, BindingFlags.NonPublic | BindingFlags.Static); - currentGeneratorProperty?.SetValue(null, dataGeneratorsFactory); - - return null; + object? result = methodInfo!.Invoke(null, null); + return (result, null); } } diff --git a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratorAssemblyExecutor.cs b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratorAssemblyExecutor.cs index 86839f7..1582a9c 100644 --- a/EasySourceGenerators.Generators/IncrementalGenerators/GeneratorAssemblyExecutor.cs +++ b/EasySourceGenerators.Generators/IncrementalGenerators/GeneratorAssemblyExecutor.cs @@ -12,46 +12,8 @@ namespace EasySourceGenerators.Generators.IncrementalGenerators; /// -/// Holds the result of compiling and loading a generator assembly in an isolated context. -/// Implements to ensure the is unloaded. -/// -internal sealed class LoadedAssemblyContext : IDisposable -{ - internal Assembly Assembly { get; } - internal AssemblyLoadContext LoadContext { get; } - internal Dictionary CompilationReferenceBytes { get; } - - /// - /// The abstractions assembly loaded during assembly resolution, if it was resolved - /// from a . May be null if the abstractions - /// assembly was loaded from a file path instead. - /// - internal Assembly? CapturedAbstractionsAssembly { get; } - - internal LoadedAssemblyContext( - Assembly assembly, - AssemblyLoadContext loadContext, - Dictionary compilationReferenceBytes, - Assembly? capturedAbstractionsAssembly) - { - Assembly = assembly; - LoadContext = loadContext; - CompilationReferenceBytes = compilationReferenceBytes; - CapturedAbstractionsAssembly = capturedAbstractionsAssembly; - } - - /// - /// Unloads the isolated and all assemblies loaded within it. - /// - public void Dispose() - { - LoadContext.Unload(); - } -} - -/// -/// Handles the compilation, assembly loading, and method invocation steps -/// required to execute generator methods at compile time in an isolated context. +/// Compiles generator source code into an executable assembly and loads it +/// in an isolated for compile-time execution. /// internal static class GeneratorAssemblyExecutor { @@ -70,9 +32,7 @@ internal static (LoadedAssemblyContext? context, string? error) CompileAndLoadAs EmitResult emitResult = executableCompilation.Emit(stream); if (!emitResult.Success) { - string errors = string.Join("; ", emitResult.Diagnostics - .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) - .Select(diagnostic => diagnostic.GetMessage())); + string errors = DiagnosticMessageHelper.JoinErrorDiagnostics(emitResult.Diagnostics); return (null, $"Compilation failed: {errors}"); } diff --git a/EasySourceGenerators.Generators/IncrementalGenerators/LoadedAssemblyContext.cs b/EasySourceGenerators.Generators/IncrementalGenerators/LoadedAssemblyContext.cs new file mode 100644 index 0000000..63065f1 --- /dev/null +++ b/EasySourceGenerators.Generators/IncrementalGenerators/LoadedAssemblyContext.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Runtime.Loader; + +namespace EasySourceGenerators.Generators.IncrementalGenerators; + +/// +/// Holds the result of compiling and loading a generator assembly in an isolated context. +/// Implements to ensure the is unloaded. +/// +internal sealed class LoadedAssemblyContext : IDisposable +{ + internal Assembly Assembly { get; } + internal AssemblyLoadContext LoadContext { get; } + internal Dictionary CompilationReferenceBytes { get; } + + /// + /// The abstractions assembly loaded during assembly resolution, if it was resolved + /// from a . May be null if the abstractions + /// assembly was loaded from a file path instead. + /// + internal Assembly? CapturedAbstractionsAssembly { get; } + + internal LoadedAssemblyContext( + Assembly assembly, + AssemblyLoadContext loadContext, + Dictionary compilationReferenceBytes, + Assembly? capturedAbstractionsAssembly) + { + Assembly = assembly; + LoadContext = loadContext; + CompilationReferenceBytes = compilationReferenceBytes; + CapturedAbstractionsAssembly = capturedAbstractionsAssembly; + } + + /// + /// Unloads the isolated and all assemblies loaded within it. + /// + public void Dispose() + { + LoadContext.Unload(); + } +} diff --git a/EasySourceGenerators.Generators/SourceEmitting/CSharpAccessibilityKeyword.cs b/EasySourceGenerators.Generators/SourceEmitting/CSharpAccessibilityKeyword.cs index 9ea6518..4ce6151 100644 --- a/EasySourceGenerators.Generators/SourceEmitting/CSharpAccessibilityKeyword.cs +++ b/EasySourceGenerators.Generators/SourceEmitting/CSharpAccessibilityKeyword.cs @@ -8,28 +8,13 @@ namespace EasySourceGenerators.Generators.SourceEmitting; internal static class CSharpAccessibilityKeyword { /// - /// Returns the C# keyword for the given accessibility level. - /// Returns "private" for and unrecognized values. - /// - internal static string From(Accessibility accessibility) - { - return accessibility switch - { - Accessibility.Public => "public", - Accessibility.Protected => "protected", - Accessibility.Internal => "internal", - Accessibility.ProtectedOrInternal => "protected internal", - Accessibility.ProtectedAndInternal => "private protected", - _ => "private" - }; - } - - /// - /// Returns the C# keyword for the given accessibility level, or an empty string + /// Converts a Roslyn value to its C# keyword representation. + /// When is true (the default), returns "private" /// for and unrecognized values. - /// Used in contexts where the private keyword is implicit (e.g., dummy implementations). + /// When false, returns an empty string instead — useful in contexts where + /// the private keyword is implicit (e.g., dummy implementations). /// - internal static string FromOrEmpty(Accessibility accessibility) + internal static string ToKeyword(Accessibility accessibility, bool defaultToPrivate = true) { return accessibility switch { @@ -38,7 +23,7 @@ internal static string FromOrEmpty(Accessibility accessibility) Accessibility.Internal => "internal", Accessibility.ProtectedOrInternal => "protected internal", Accessibility.ProtectedAndInternal => "private protected", - _ => "" + _ => defaultToPrivate ? "private" : "" }; } } diff --git a/EasySourceGenerators.Generators/SourceEmitting/RoslynSymbolDataMapper.cs b/EasySourceGenerators.Generators/SourceEmitting/RoslynSymbolDataMapper.cs index 71e4df5..c5f171d 100644 --- a/EasySourceGenerators.Generators/SourceEmitting/RoslynSymbolDataMapper.cs +++ b/EasySourceGenerators.Generators/SourceEmitting/RoslynSymbolDataMapper.cs @@ -27,7 +27,7 @@ internal static PartialMethodEmitData ToPartialMethodEmitData( string typeKeyword = CSharpTypeKeyword.From(containingType.TypeKind); string typeModifiers = containingType.IsStatic ? "static partial" : "partial"; - string accessibility = CSharpAccessibilityKeyword.From(partialMethod.DeclaredAccessibility); + string accessibility = CSharpAccessibilityKeyword.ToKeyword(partialMethod.DeclaredAccessibility); string methodModifiers = partialMethod.IsStatic ? "static partial" : "partial"; string returnTypeName = partialMethod.ReturnType.ToDisplayString(); string parameterList = string.Join(", ", partialMethod.Parameters.Select( @@ -72,7 +72,7 @@ internal static IReadOnlyList ToDummyTypeGroups(IEnumerable< List methods = new(); foreach (IMethodSymbol partialMethod in typeGroup) { - string accessibility = CSharpAccessibilityKeyword.FromOrEmpty(partialMethod.DeclaredAccessibility); + string accessibility = CSharpAccessibilityKeyword.ToKeyword(partialMethod.DeclaredAccessibility, defaultToPrivate: false); string methodModifiers = partialMethod.IsStatic ? "static partial" : "partial"; string returnTypeName = partialMethod.ReturnType.ToDisplayString(); string parameterList = string.Join(", ", partialMethod.Parameters.Select( From bccb0c233b62b97e64a77cb6ac946a51c87c0326 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:18:40 +0000 Subject: [PATCH 6/6] Fix CSharpAccessibilityKeyword doc to list all defaulting cases Co-authored-by: dex3r <3155725+dex3r@users.noreply.github.com> Agent-Logs-Url: https://github.com/dex3r/EasySourceGenerators/sessions/7c930db8-e3f7-42d9-98cd-41cf86cdc442 --- .../SourceEmitting/CSharpAccessibilityKeyword.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/EasySourceGenerators.Generators/SourceEmitting/CSharpAccessibilityKeyword.cs b/EasySourceGenerators.Generators/SourceEmitting/CSharpAccessibilityKeyword.cs index 4ce6151..76bc43b 100644 --- a/EasySourceGenerators.Generators/SourceEmitting/CSharpAccessibilityKeyword.cs +++ b/EasySourceGenerators.Generators/SourceEmitting/CSharpAccessibilityKeyword.cs @@ -10,7 +10,8 @@ internal static class CSharpAccessibilityKeyword /// /// Converts a Roslyn value to its C# keyword representation. /// When is true (the default), returns "private" - /// for and unrecognized values. + /// for , , + /// and any unrecognized values. /// When false, returns an empty string instead — useful in contexts where /// the private keyword is implicit (e.g., dummy implementations). ///