diff --git a/src/Pure.DI/InterfaceGeneration/GeneratedInterfaceDetails.cs b/src/Pure.DI/InterfaceGeneration/GeneratedInterfaceDetails.cs new file mode 100644 index 000000000..305b88049 --- /dev/null +++ b/src/Pure.DI/InterfaceGeneration/GeneratedInterfaceDetails.cs @@ -0,0 +1,47 @@ +namespace Pure.DI; + +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +internal sealed class GeneratedInterfaceDetails( + AttributeData? generationAttribute, + ITypeSymbol typeSymbol, + ClassDeclarationSyntax classSyntax) +{ + public string NamespaceName { get; } = PrepareNamespaceValue(generationAttribute, typeSymbol.ContainingNamespace.ToDisplayString()); + + public string InterfaceName { get; } = PrepareValue( + generationAttribute, + InterfaceGenerator.InterfaceParameterName, + $"I{classSyntax.Identifier.Text}"); + + public string AccessLevel { get; } = PrepareValue( + generationAttribute, + InterfaceGenerator.AsInternalParameterName, + false) + ? "internal" + : "public"; + + private static string PrepareNamespaceValue(AttributeData? generationAttribute, string defaultValue) + { + var value = PrepareValue(generationAttribute, InterfaceGenerator.NamespaceParameterName, defaultValue); + return value ?? defaultValue; + } + + private static T PrepareValue(AttributeData? generationAttribute, string key, T defaultValue) + { + var parameterSymbol = generationAttribute?.AttributeConstructor?.Parameters.SingleOrDefault(x => x.Name == key); + if (parameterSymbol != null) + { + var index = generationAttribute!.AttributeConstructor!.Parameters.IndexOf(parameterSymbol); + var result = generationAttribute.ConstructorArguments[index].Value; + if (result != null) + { + return (T)result; + } + } + + return defaultValue; + } +} diff --git a/src/Pure.DI/InterfaceGeneration/InterfaceBuilder.cs b/src/Pure.DI/InterfaceGeneration/InterfaceBuilder.cs new file mode 100644 index 000000000..022903b03 --- /dev/null +++ b/src/Pure.DI/InterfaceGeneration/InterfaceBuilder.cs @@ -0,0 +1,446 @@ +namespace Pure.DI; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +internal static class InterfaceBuilder +{ + private static readonly SymbolDisplayFormat FullyQualifiedDisplayFormat = new( + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + memberOptions: SymbolDisplayMemberOptions.IncludeParameters | SymbolDisplayMemberOptions.IncludeContainingType, + parameterOptions: SymbolDisplayParameterOptions.IncludeType | SymbolDisplayParameterOptions.IncludeParamsRefOut | SymbolDisplayParameterOptions.IncludeDefaultValue | SymbolDisplayParameterOptions.IncludeName, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers); + + private static readonly SymbolDisplayFormat FullyQualifiedDisplayFormatForGrouping = new( + genericsOptions: FullyQualifiedDisplayFormat.GenericsOptions, + memberOptions: FullyQualifiedDisplayFormat.MemberOptions & ~SymbolDisplayMemberOptions.IncludeContainingType, + parameterOptions: FullyQualifiedDisplayFormat.ParameterOptions, + typeQualificationStyle: FullyQualifiedDisplayFormat.TypeQualificationStyle, + globalNamespaceStyle: FullyQualifiedDisplayFormat.GlobalNamespaceStyle, + miscellaneousOptions: FullyQualifiedDisplayFormat.MiscellaneousOptions); + + public static string BuildInterfaceFor(ITypeSymbol typeSymbol, ClassDeclarationSyntax classSyntax) + { + if (typeSymbol is not INamedTypeSymbol namedTypeSymbol) + { + return string.Empty; + } + + var generationAttribute = typeSymbol.GetAttributes().FirstOrDefault(x => + x.AttributeClass != null && x.AttributeClass.Name.Contains(InterfaceGenerator.GenerateInterfaceAttributeName)); + + var symbolDetails = new GeneratedInterfaceDetails(generationAttribute, typeSymbol, classSyntax); + var interfaceGenerator = new InterfaceCodeBuilder(symbolDetails.NamespaceName, symbolDetails.InterfaceName, symbolDetails.AccessLevel); + + interfaceGenerator.AddClassDocumentation(GetDocumentationForClass(classSyntax)); + interfaceGenerator.AddGeneric(GetGeneric(classSyntax, namedTypeSymbol)); + + var members = typeSymbol.GetAllMembers() + .Where(x => x.DeclaredAccessibility == Accessibility.Public) + .Where(x => !x.IsStatic) + .Where(x => !HasIgnoreAttribute(x)) + .ToList(); + + AddPropertiesToInterface(members, interfaceGenerator); + AddMethodsToInterface(members, interfaceGenerator); + AddEventsToInterface(members, interfaceGenerator); + + return interfaceGenerator.Build(); + } + + private static void AddMethodsToInterface(List members, InterfaceCodeBuilder codeGenerator) + { + members.Where(x => x.Kind == SymbolKind.Method) + .OfType() + .Where(x => x.MethodKind == MethodKind.Ordinary) + .Where(x => x.ContainingType.Name != nameof(System.Object)) + .Where(x => !HasIgnoreAttribute(x)) + .GroupBy(x => x.ToDisplayString(FullyQualifiedDisplayFormatForGrouping)) + .Select(g => g.First()) + .ToList() + .ForEach(method => AddMethod(codeGenerator, method)); + } + + private static void AddMethod(InterfaceCodeBuilder codeGenerator, IMethodSymbol method) + { + ActivateNullableIfNeeded(codeGenerator, method); + + var paramResult = new HashSet(); + foreach (var parameter in method.Parameters.Select(p => GetParameterDisplayString(p, codeGenerator.HasNullable))) + { + paramResult.Add(parameter); + } + + var typedArgs = method.TypeParameters.Select(arg => (arg.ToDisplayString(FullyQualifiedDisplayFormat), arg.GetWhereStatement(FullyQualifiedDisplayFormat))).ToList(); + + codeGenerator.AddMethodToInterface( + method.Name, + GetMethodReturnType(method), + InheritDoc(method), + paramResult, + typedArgs); + } + + private static string GetMethodReturnType(IMethodSymbol method) + { + var prefix = method.ReturnsByRefReadonly ? "ref readonly " : method.ReturnsByRef ? "ref " : string.Empty; + return prefix + method.ReturnType.ToDisplayString(FullyQualifiedDisplayFormat); + } + + private static void ActivateNullableIfNeeded(InterfaceCodeBuilder codeGenerator, ITypeSymbol typeSymbol) + { + if (IsNullable(typeSymbol)) + { + codeGenerator.HasNullable = true; + } + } + + private static void ActivateNullableIfNeeded(InterfaceCodeBuilder codeGenerator, IMethodSymbol method) + { + if (method.Parameters.Any(x => IsNullable(x.Type)) || IsNullable(method.ReturnType)) + { + codeGenerator.HasNullable = true; + } + } + + private static bool IsNullable(ITypeSymbol typeSymbol) + { + if (typeSymbol.NullableAnnotation == NullableAnnotation.Annotated) + { + return true; + } + + if (typeSymbol is not INamedTypeSymbol named) + { + return false; + } + + foreach (var param in named.TypeArguments) + { + if (IsNullable(param)) + { + return true; + } + } + + return false; + } + + private static string GetParameterDisplayString(IParameterSymbol param, bool nullableContextEnabled) + { + var paramParts = param.ToDisplayParts(FullyQualifiedDisplayFormat); + var typeSb = new StringBuilder(); + var restSb = new StringBuilder(); + var isInsideType = true; + foreach (var part in paramParts) + { + if (isInsideType && part.Kind == SymbolDisplayPartKind.Space) + { + isInsideType = false; + } + + if (isInsideType) + { + typeSb.Append(part.ToString()); + } + else + { + restSb.Append(part.ToString()); + } + } + + if (param.HasExplicitDefaultValue + && param.ExplicitDefaultValue is null + && param.NullableAnnotation != NullableAnnotation.Annotated + && param.Type.IsReferenceType + && nullableContextEnabled) + { + typeSb.Append('?'); + } + + return typeSb.Append(restSb).ToString(); + } + + private static void AddEventsToInterface(List members, InterfaceCodeBuilder codeGenerator) + { + members.Where(x => x.Kind == SymbolKind.Event) + .OfType() + .GroupBy(x => x.ToDisplayString(FullyQualifiedDisplayFormatForGrouping)) + .Select(g => g.First()) + .ToList() + .ForEach(evt => + { + ActivateNullableIfNeeded(codeGenerator, evt.Type); + codeGenerator.AddEventToInterface(evt.Name, evt.Type.ToDisplayString(FullyQualifiedDisplayFormat), InheritDoc(evt)); + }); + } + + private static void AddPropertiesToInterface(List members, InterfaceCodeBuilder interfaceGenerator) + { + members.Where(x => x.Kind == SymbolKind.Property) + .OfType() + .Where(x => !x.IsIndexer) + .GroupBy(x => x.Name) + .Select(g => g.First()) + .ToList() + .ForEach(prop => + { + ActivateNullableIfNeeded(interfaceGenerator, prop.Type); + + interfaceGenerator.AddPropertyToInterface( + prop.Name, + prop.Type.ToDisplayString(FullyQualifiedDisplayFormat), + prop.GetMethod?.DeclaredAccessibility == Accessibility.Public, + GetSetKind(prop.SetMethod), + prop.ReturnsByRef, + InheritDoc(prop)); + }); + } + + private static PropertySetKind GetSetKind(IMethodSymbol? setMethodSymbol) => + setMethodSymbol switch + { + null => PropertySetKind.NoSet, + { IsInitOnly: true, DeclaredAccessibility: Accessibility.Public } => PropertySetKind.Init, + _ => setMethodSymbol is { DeclaredAccessibility: Accessibility.Public } ? PropertySetKind.Always : PropertySetKind.NoSet, + }; + + private static bool HasIgnoreAttribute(ISymbol x) => + x.GetAttributes().Any(a => a.AttributeClass != null && a.AttributeClass.Name.Contains(InterfaceGenerator.IgnoreInterfaceAttributeName)); + + private static string GetDocumentationForClass(CSharpSyntaxNode classSyntax) + { + if (!classSyntax.HasLeadingTrivia) + { + return string.Empty; + } + + SyntaxKind[] docSyntax = + [ + SyntaxKind.DocumentationCommentExteriorTrivia, + SyntaxKind.EndOfDocumentationCommentToken, + SyntaxKind.MultiLineDocumentationCommentTrivia, + SyntaxKind.SingleLineDocumentationCommentTrivia, + ]; + + var trivia = classSyntax.GetLeadingTrivia() + .Where(x => docSyntax.Contains(x.Kind())) + .FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.ToFullString())); + + return trivia.ToFullString().Trim(); + } + + private static string GetGeneric(TypeDeclarationSyntax classSyntax, INamedTypeSymbol typeSymbol) + { + var whereStatements = typeSymbol.TypeParameters.Select(typeParameter => typeParameter.GetWhereStatement(FullyQualifiedDisplayFormat)).Where(constraint => !string.IsNullOrEmpty(constraint)); + return $"{classSyntax.TypeParameterList?.ToFullString().Trim()} {string.Join(" ", whereStatements)}".Trim(); + } + + private static string InheritDoc(ISymbol source) => + $"/// ", "}").Replace("params ", string.Empty)}\" />"; +} + +internal sealed class InterfaceCodeBuilder(string namespaceName, string interfaceName, string accessLevel) +{ + private const string Autogenerated = """ + //-------------------------------------------------------------------------------------------------- + // + // This code was generated by a tool. + // + // Changes to this file may cause incorrect behavior and will be lost if the code is regenerated. + // + //-------------------------------------------------------------------------------------------------- + + + """; + + private readonly List propertyInfos = []; + private readonly List methodInfos = []; + private readonly List events = []; + private string classDocumentation = string.Empty; + private string genericType = string.Empty; + + public bool HasNullable { get; set; } + + public void AddPropertyToInterface(string name, string ttype, bool hasGet, PropertySetKind hasSet, bool isRef, string documentation) => + propertyInfos.Add(new(name, ttype, hasGet, hasSet, isRef, documentation)); + + public void AddGeneric(string v) => genericType = v; + + public void AddClassDocumentation(string documentation) => classDocumentation = documentation; + + public void AddMethodToInterface(string name, string returnType, string documentation, HashSet parameters, List<(string Arg, string WhereConstraint)> genericArgs) => + methodInfos.Add(new(name, returnType, documentation, parameters, genericArgs)); + + public void AddEventToInterface(string name, string type, string documentation) => + events.Add(new(name, type, documentation)); + + public string Build() + { + var cb = new CodeBuilder(); + cb.Append(Autogenerated); + + if (HasNullable) + { + cb.AppendLine("#nullable enable"); + } + + cb.AppendLine("using System;"); + cb.AppendLine(string.Empty); + + if (!string.IsNullOrWhiteSpace(namespaceName)) + { + cb.AppendLine($"namespace {namespaceName}"); + cb.AppendLine("{"); + cb.Indent(); + } + + cb.AppendAndNormalizeMultipleLines(classDocumentation); + cb.AppendLine("[global::System.CodeDom.Compiler.GeneratedCode(\"Pure.DI\", \"\")]"); + cb.AppendLine($"{accessLevel} partial interface {interfaceName}{genericType}"); + cb.AppendLine("{"); + cb.Indent(); + + foreach (var prop in propertyInfos) + { + cb.AppendAndNormalizeMultipleLines(prop.Documentation); + var @ref = prop.IsRef ? "ref " : string.Empty; + var get = prop.HasGet ? "get; " : string.Empty; + var set = GetSet(prop.SetKind); + cb.AppendLine($"{@ref}{prop.Ttype} {prop.Name} {{ {get}{set}}}"); + cb.AppendLine(string.Empty); + } + + foreach (var method in methodInfos) + { + BuildMethod(cb, method); + } + + foreach (var evt in events) + { + cb.AppendAndNormalizeMultipleLines(evt.Documentation); + cb.AppendLine($"event {evt.Type} {evt.Name};"); + cb.AppendLine(string.Empty); + } + + cb.Dedent(); + cb.AppendLine("}"); + + if (!string.IsNullOrWhiteSpace(namespaceName)) + { + cb.Dedent(); + cb.AppendLine("}"); + } + + if (HasNullable) + { + cb.AppendLine("#nullable restore"); + } + + return cb.Build(); + } + + private static string GetSet(PropertySetKind propSetKind) => + propSetKind switch + { + PropertySetKind.NoSet => string.Empty, + PropertySetKind.Always => "set; ", + PropertySetKind.Init => "init; ", + _ => throw new ArgumentOutOfRangeException(nameof(propSetKind), propSetKind, null), + }; + + private static void BuildMethod(CodeBuilder cb, MethodInfo method) + { + cb.AppendAndNormalizeMultipleLines(method.Documentation); + cb.AppendIndented($"{method.ReturnType} {method.Name}"); + + if (method.GenericArgs.Count != 0) + { + cb.Append($"<{string.Join(", ", method.GenericArgs.Select(a => a.Arg))}>"); + } + + cb.Append($"({string.Join(", ", method.Parameters)})"); + + if (method.GenericArgs.Count != 0) + { + var constraints = method.GenericArgs.Where(a => !string.IsNullOrWhiteSpace(a.WhereConstraint)).Select(a => a.WhereConstraint); + cb.Append($" {string.Join(" ", constraints)}"); + } + + cb.Append(";"); + cb.BreakLine(); + cb.AppendLine(string.Empty); + } +} + +internal sealed record PropertyInfo(string Name, string Ttype, bool HasGet, PropertySetKind SetKind, bool IsRef, string Documentation); + +internal sealed record MethodInfo(string Name, string ReturnType, string Documentation, HashSet Parameters, List<(string Arg, string WhereConstraint)> GenericArgs); + +internal sealed record EventInfo(string Name, string Type, string Documentation); + +internal enum PropertySetKind +{ + NoSet = 0, + Always = 1, + Init = 2, +} + +internal sealed class CodeBuilder +{ + private readonly StringBuilder sb = new(); + private int indent; + private string currentIndent = string.Empty; + + public void Indent() + { + indent += 4; + currentIndent = new(' ', indent); + } + + public void Dedent() + { + indent -= 4; + currentIndent = new(' ', indent); + } + + public void BreakLine() => sb.AppendLine(); + + public void AppendIndented(string str) + { + sb.Append(' ', indent); + sb.Append(str); + } + + public void AppendLine(string str) + { + sb.Append(' ', indent); + sb.AppendLine(str); + } + + public void Append(string str) => sb.Append(str); + + public void AppendAndNormalizeMultipleLines(string doc) + { + if (string.IsNullOrWhiteSpace(doc)) + { + return; + } + + foreach (var line in doc.Split(new[] { Environment.NewLine }, StringSplitOptions.None)) + { + sb.AppendLine(IndentStr(line)); + } + } + + private string IndentStr(string str) => str.TrimStart().Insert(0, currentIndent); + + public string Build() => sb.ToString(); +} diff --git a/src/Pure.DI/InterfaceGeneration/InterfaceGenerator.cs b/src/Pure.DI/InterfaceGeneration/InterfaceGenerator.cs new file mode 100644 index 000000000..b0eef170e --- /dev/null +++ b/src/Pure.DI/InterfaceGeneration/InterfaceGenerator.cs @@ -0,0 +1,64 @@ +namespace Pure.DI; + +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +[Generator] +public sealed class InterfaceGenerator +{ + public const string GenerateInterfaceAttributeName = "GenerateInterface"; + public const string IgnoreInterfaceAttributeName = "IgnoreInterface"; + public const string NamespaceParameterName = "namespaceName"; + public const string InterfaceParameterName = "interfaceName"; + public const string AsInternalParameterName = "asInternal"; + + public IEnumerable Api => new Source[] + { + new($"{GenerateInterfaceAttributeName}.Attribute.g.cs", SourceText.From(RegisterAttributesExtensions.GenerateInterfaceAttributeSource, System.Text.Encoding.UTF8)), + new($"{IgnoreInterfaceAttributeName}.Attribute.g.cs", SourceText.From(RegisterAttributesExtensions.IgnoreInterfaceAttributeSource, System.Text.Encoding.UTF8)), + }; + + public void Generate(SourceProductionContext context, ImmutableArray syntaxContexts) + { + if (syntaxContexts.IsDefaultOrEmpty) + { + return; + } + + foreach (var syntaxContext in syntaxContexts) + { + if (syntaxContext.Node is not Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax classSyntax) + { + continue; + } + + if (syntaxContext.SemanticModel.GetDeclaredSymbol(classSyntax) is not INamedTypeSymbol typeSymbol) + { + continue; + } + + var code = InterfaceBuilder.BuildInterfaceFor(typeSymbol, classSyntax); + if (string.IsNullOrWhiteSpace(code)) + { + continue; + } + + context.AddSource(GetHintName(typeSymbol), SourceText.From(code, System.Text.Encoding.UTF8)); + } + } + + private static string GetHintName(INamedTypeSymbol typeSymbol) + { + var fullName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + .Replace("global::", string.Empty) + .Replace('<', '{') + .Replace('>', '}') + .Replace(',', '_') + .Replace(' ', '_') + .Replace(':', '_'); + + return $"{fullName}.Interface.g.cs"; + } +} diff --git a/src/Pure.DI/InterfaceGeneration/RegisterInterfaceAttributesExtensions.cs b/src/Pure.DI/InterfaceGeneration/RegisterInterfaceAttributesExtensions.cs new file mode 100644 index 000000000..d85939b07 --- /dev/null +++ b/src/Pure.DI/InterfaceGeneration/RegisterInterfaceAttributesExtensions.cs @@ -0,0 +1,54 @@ +namespace Pure.DI; + +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +internal static class RegisterAttributesExtensions +{ + public static string GenerateInterfaceAttributeSource { get; } = + $$""" + // + using System; + + namespace Pure.DI + { + /// + /// Generates an interface from the attributed class. + /// + [AttributeUsage(AttributeTargets.Class)] + [global::System.CodeDom.Compiler.GeneratedCode("Pure.DI", "")] + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal sealed class {{InterfaceGenerator.GenerateInterfaceAttributeName}}Attribute : Attribute + { + /// + /// Generates an interface from the attributed class. + /// + internal {{InterfaceGenerator.GenerateInterfaceAttributeName}}Attribute( + string {{InterfaceGenerator.NamespaceParameterName}} = default(string), + string {{InterfaceGenerator.InterfaceParameterName}} = default(string), + bool {{InterfaceGenerator.AsInternalParameterName}} = false) { } + } + } + """; + + public static string IgnoreInterfaceAttributeSource { get; } = + $$$""" + // + using System; + + namespace Pure.DI + { + /// + /// Ignores a class member when generating an interface. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Event)] + [global::System.CodeDom.Compiler.GeneratedCode("Pure.DI", "")] + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal sealed class {{{InterfaceGenerator.IgnoreInterfaceAttributeName}}}Attribute : Attribute + { + internal {{{InterfaceGenerator.IgnoreInterfaceAttributeName}}}Attribute() { } + } + } + """; +} diff --git a/src/Pure.DI/InterfaceGeneration/RoslynExtensions.cs b/src/Pure.DI/InterfaceGeneration/RoslynExtensions.cs new file mode 100644 index 000000000..77f75b7f1 --- /dev/null +++ b/src/Pure.DI/InterfaceGeneration/RoslynExtensions.cs @@ -0,0 +1,51 @@ +namespace Pure.DI; + +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; + +internal static class RoslynExtensions +{ + public static IEnumerable GetAllMembers(this ITypeSymbol type) + { + var current = type; + while (current != null) + { + foreach (var member in current.GetMembers()) + { + yield return member; + } + + current = current.BaseType; + } + } + + public static string GetWhereStatement(this ITypeParameterSymbol typeParameterSymbol, SymbolDisplayFormat typeDisplayFormat) + { + var constraints = new List(); + + if (typeParameterSymbol.HasReferenceTypeConstraint) + { + constraints.Add("class"); + } + + if (typeParameterSymbol.HasValueTypeConstraint) + { + constraints.Add("struct"); + } + + if (typeParameterSymbol.HasNotNullConstraint) + { + constraints.Add("notnull"); + } + + constraints.AddRange(typeParameterSymbol.ConstraintTypes.Select(t => t.ToDisplayString(typeDisplayFormat))); + + if (typeParameterSymbol.HasConstructorConstraint) + { + constraints.Add("new()"); + } + + return constraints.Count == 0 ? string.Empty : $"where {typeParameterSymbol.Name} : {string.Join(", ", constraints)}"; + } +} diff --git a/src/Pure.DI/SourceGenerator.cs b/src/Pure.DI/SourceGenerator.cs index 3919646c5..63c18c295 100644 --- a/src/Pure.DI/SourceGenerator.cs +++ b/src/Pure.DI/SourceGenerator.cs @@ -4,6 +4,7 @@ namespace Pure.DI; using System.Diagnostics.CodeAnalysis; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; [Generator(LanguageNames.CSharp)] [SuppressMessage("MicrosoftCodeAnalysisCorrectness", "RS1036:Specify analyzer banned API enforcement setting")] @@ -15,12 +16,18 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Run Rider as administrator DebugHelper.DebugIfNeeded(); var generator = new Generator(); + var interfaceGenerator = new InterfaceGenerator(); context.RegisterPostInitializationOutput(initializationContext => { foreach (var apiSource in generator.Api) { initializationContext.AddSource(apiSource.HintName, apiSource.SourceText); } + + foreach (var apiSource in interfaceGenerator.Api) + { + initializationContext.AddSource(apiSource.HintName, apiSource.SourceText); + } }); var setupContexts = context.SyntaxProvider @@ -47,5 +54,22 @@ public void Initialize(IncrementalGeneratorInitializationContext context) updates, sourceProductionContext.CancellationToken); }); + + var interfaceUpdates = context.SyntaxProvider + .CreateSyntaxProvider( + static (node, _) => node is ClassDeclarationSyntax { AttributeLists.Count: > 0 }, + static (syntaxContext, _) => syntaxContext) + .Where(static syntaxContext => HasGenerateInterfaceAttribute((ClassDeclarationSyntax)syntaxContext.Node)) + .Collect(); + + context.RegisterSourceOutput(interfaceUpdates, (sourceProductionContext, updates) => + { + interfaceGenerator.Generate(sourceProductionContext, updates); + }); } + + private static bool HasGenerateInterfaceAttribute(ClassDeclarationSyntax classSyntax) => + classSyntax.AttributeLists + .SelectMany(list => list.Attributes) + .Any(attribute => attribute.Name.ToString().Contains(InterfaceGenerator.GenerateInterfaceAttributeName)); } diff --git a/tests/Pure.DI.IntegrationTests/InterfaceGenerationTests.cs b/tests/Pure.DI.IntegrationTests/InterfaceGenerationTests.cs new file mode 100644 index 000000000..bd3799499 --- /dev/null +++ b/tests/Pure.DI.IntegrationTests/InterfaceGenerationTests.cs @@ -0,0 +1,127 @@ +namespace Pure.DI.IntegrationTests; + +using System; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +public class InterfaceGenerationTests +{ + [Fact] + public void ShouldGenerateAnInterfaceFromAnnotatedClass() + { + var generated = GenerateInterfaceSource(""" + using Pure.DI; + + namespace Demo; + + public partial interface IService; + + [GenerateInterface] + public partial class Service + { + public string Name { get; set; } = string.Empty; + + public event EventHandler? Changed; + + [IgnoreInterface] + public void Hidden() { } + + public string GetText(string? value) + where T : class + => value ?? string.Empty; + } + """); + + generated.ShouldContain("public partial interface IService"); + generated.ShouldContain("string Name { get; set; }"); + generated.ShouldContain("Changed"); + generated.ShouldContain("GetText"); + generated.ShouldNotContain("Hidden"); + } + + [Fact] + public async Task ShouldUseGeneratedInterfaceWithPureDi() + { + var code = """ + using Pure.DI; + + namespace Demo; + + public partial interface IService; + + [GenerateInterface] + public partial class Service : IService + { + public string Message => "ok"; + } + + public partial class Consumer(IService service) + { + public string Message { get; } = service.Message; + } + + partial class Setup + { + private static void Configure() => + DI.Setup(nameof(Composition)) + .Bind().To() + .Root(nameof(Consumer)); + } + + public class Program + { + public static void Main() + { + var composition = new Composition(); + var consumer = composition.Consumer; + System.Console.WriteLine(consumer.Message); + } + } + """; + + var interfaceGenerator = new InterfaceGenerator(); + var generatedInterface = GenerateInterfaceSource(code); + var result = await interfaceGenerator.Api + .Select(source => source.SourceText.ToString()) + .Append(code) + .Append(generatedInterface) + .RunAsync(new Options(LanguageVersion.CSharp12, CheckCompilationErrors: false)); + + result.Errors.Count.ShouldBe(0, result); + result.Warnings.Count.ShouldBe(0, result); + result.Success.ShouldBeTrue(result); + result.StdOut.ShouldContain("ok"); + } + + private static string GenerateInterfaceSource(string code) + { + var syntaxTree = CSharpSyntaxTree.ParseText(code); + var references = AppDomain + .CurrentDomain + .GetAssemblies() + .Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location)) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Cast(); + + var compilation = CSharpCompilation.Create( + "InterfaceGenerationTests", + [syntaxTree], + references, + new(OutputKind.DynamicallyLinkedLibrary)); + + var sourceGenerator = new SourceGenerator(); + CSharpGeneratorDriver.Create(sourceGenerator).RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var diagnostics); + + diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error).ShouldBeEmpty(); + + return outputCompilation.SyntaxTrees + .Select(tree => tree.ToString()) + .FirstOrDefault(text => text.Contains("[global::System.CodeDom.Compiler.GeneratedCode(\"Pure.DI\"" ) && text.Contains("partial interface IService")) + ?? throw new InvalidOperationException(string.Join(Environment.NewLine + "---" + Environment.NewLine, + outputCompilation.SyntaxTrees.Select(tree => tree.ToString()))); + } +} diff --git a/tests/Pure.DI.UsageTests/Interface/GenerateInterfaceCustomizationScenario.cs b/tests/Pure.DI.UsageTests/Interface/GenerateInterfaceCustomizationScenario.cs new file mode 100644 index 000000000..3c4e4481a --- /dev/null +++ b/tests/Pure.DI.UsageTests/Interface/GenerateInterfaceCustomizationScenario.cs @@ -0,0 +1,67 @@ +/* +$v=true +$p=9 +$d=Customize the generated interface +$h=This example shows how to change the generated interface name, namespace, and accessibility. +$f=The example shows how to: +$f=- Generate an interface into a custom namespace +$f=- Rename the generated interface +$f=- Make the generated interface internal +$r=Shouldly +*/ + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable CheckNamespace +// ReSharper disable UnusedType.Global +// ReSharper disable UnusedMember.Global + +using Pure.DI; + +namespace Contracts +{ + public partial interface IWorker; + + [GenerateInterface(namespaceName: "Contracts", interfaceName: "IWorker")] + public partial class Worker : IWorker + { + public string Message => "custom"; + } +} + +namespace Pure.DI.UsageTests.Interface.GenerateInterfaceCustomizationScenario +{ + using Pure.DI.UsageTests; + using Contracts; + using Pure.DI; + using Shouldly; + using Xunit; + + // { + //# using Pure.DI; + // } + + public class Scenario + { + [Fact] + public void Run() + { + // { + DI.Setup(nameof(Composition)) + .Bind().To() + .Root(nameof(App)); + + var composition = new Composition(); + var app = composition.App; + + app.Message.ShouldBe("custom"); + // } + + composition.SaveClassDiagram(); + } + } + + public class App(IWorker worker) + { + public string Message { get; } = worker.Message; + } +} diff --git a/tests/Pure.DI.UsageTests/Interface/GenerateInterfaceGenericsScenario.cs b/tests/Pure.DI.UsageTests/Interface/GenerateInterfaceGenericsScenario.cs new file mode 100644 index 000000000..a6bf98d83 --- /dev/null +++ b/tests/Pure.DI.UsageTests/Interface/GenerateInterfaceGenericsScenario.cs @@ -0,0 +1,78 @@ +/* +$v=true +$p=9 +$d=Generate interfaces with generics +$h=This example shows that generic members, nullable annotations, and events are preserved in the generated interface. +$f=The example shows how to: +$f=- Generate an interface for generic members +$f=- Preserve nullable annotations +$f=- Preserve events and generic constraints +$r=Shouldly +*/ + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable CheckNamespace +// ReSharper disable UnusedType.Global +// ReSharper disable UnusedMember.Global + +namespace Pure.DI.UsageTests.Interface.GenerateInterfaceGenericsScenario; + +using System; +using Pure.DI.UsageTests; +using Pure.DI; +using Shouldly; +using Xunit; + +// { +//# using Pure.DI; +// } + +public class Scenario +{ + [Fact] + public void Run() + { + // { + DI.Setup(nameof(Composition)) + .Bind().To() + .Bind().To() + .Root(nameof(App)); + + var composition = new Composition(); + var app = composition.App; + + app.Formatted.ShouldBe("demo"); + app.Title.ShouldBe("demo"); + // } + + composition.SaveClassDiagram(); + } +} + +// { +public partial interface IFormatter; + +[GenerateInterface] +public partial class Formatter : IFormatter +{ + public string? Title { get; set; } = "demo"; + +#pragma warning disable CS0067 + public event EventHandler? Changed; +#pragma warning restore CS0067 + + public string? Format(T value) + where T : class + => value?.ToString(); + + [IgnoreInterface] + public void Hidden() { } +} + +public class App(IFormatter formatter) +{ + public string Title { get; } = formatter.Title ?? string.Empty; + + public string Formatted { get; } = formatter.Format("demo") ?? string.Empty; +} +// } diff --git a/tests/Pure.DI.UsageTests/Interface/GenerateInterfaceScenario.cs b/tests/Pure.DI.UsageTests/Interface/GenerateInterfaceScenario.cs new file mode 100644 index 000000000..451eb290d --- /dev/null +++ b/tests/Pure.DI.UsageTests/Interface/GenerateInterfaceScenario.cs @@ -0,0 +1,67 @@ +/* +$v=true +$p=9 +$d=Generate an interface from a class +$h=This example shows how a public class can generate a matching interface and be used through Pure.DI. +$f=The example shows how to: +$f=- Generate an interface from a class +$f=- Bind the generated contract in Pure.DI +$f=- Resolve the consumer from a composition root +$r=Shouldly +*/ + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable CheckNamespace +// ReSharper disable UnusedType.Global +// ReSharper disable UnusedMember.Global + +namespace Pure.DI.UsageTests.Interface.GenerateInterfaceScenario; + +using Pure.DI.UsageTests; +using Pure.DI; +using Shouldly; +using Xunit; + +// { +//# using Pure.DI; +// } + +public class Scenario +{ + [Fact] + public void Run() + { + // { + DI.Setup(nameof(Composition)) + .Bind().To() + .Root(nameof(App)); + + var composition = new Composition(); + var app = composition.App; + + app.Message.ShouldBe("ok"); + app.Text.ShouldBe("pong"); + // } + + composition.SaveClassDiagram(); + } +} + +// { +public partial interface IService; + +[GenerateInterface] +public partial class Service : IService +{ + public string Message => "ok"; + + public string Ping() => "pong"; +} + +public class App(IService service) +{ + public string Message { get; } = service.Message; + + public string Text { get; } = service.Ping(); +} +// } diff --git a/tests/Pure.DI.UsageTests/Interface/IgnoreInterfaceScenario.cs b/tests/Pure.DI.UsageTests/Interface/IgnoreInterfaceScenario.cs new file mode 100644 index 000000000..9cc1d4400 --- /dev/null +++ b/tests/Pure.DI.UsageTests/Interface/IgnoreInterfaceScenario.cs @@ -0,0 +1,65 @@ +/* +$v=true +$p=9 +$d=Ignore members in generated interface +$h=This example shows how to exclude selected members from the generated interface. +$f=The example shows how to: +$f=- Mark members with IgnoreInterface +$f=- Keep only the public contract surface +$f=- Use the generated interface in Pure.DI +$r=Shouldly +*/ + +// ReSharper disable ClassNeverInstantiated.Global +// ReSharper disable CheckNamespace +// ReSharper disable UnusedType.Global +// ReSharper disable UnusedMember.Global + +namespace Pure.DI.UsageTests.Interface.IgnoreInterfaceScenario; + +using Pure.DI.UsageTests; +using Pure.DI; +using Shouldly; +using Xunit; + +// { +//# using Pure.DI; +// } + +public class Scenario +{ + [Fact] + public void Run() + { + // { + DI.Setup(nameof(Composition)) + .Bind().To() + .Root(nameof(App)); + + var composition = new Composition(); + var app = composition.App; + + app.Name.ShouldBe("visible"); + // } + + composition.SaveClassDiagram(); + } +} + +// { +public partial interface IService; + +[GenerateInterface] +public partial class Service : IService +{ + public string Name => "visible"; + + [IgnoreInterface] + public string Secret() => "hidden"; +} + +public class App(IService service) +{ + public string Name { get; } = service.Name; +} +// }