From 552b72b1b98a09bb296c13809d0ec981dc0f4961 Mon Sep 17 00:00:00 2001 From: Alex Macocian Date: Wed, 28 Aug 2024 15:24:18 +0200 Subject: [PATCH] Introduce source generators package Update dependencies --- .github/workflows/cd.yaml | 6 + ...tStandard.DependencyInjection.Tests.csproj | 6 +- ...ons.NetStandard.DependencyInjection.csproj | 4 +- .../Diagnostics.cs | 79 ++++++++ .../NotifyPropertyChangedGenerator.cs | 173 ++++++++++++++++++ ...emExtensions.NetStandard.Generators.csproj | 32 ++++ ...tensions.NetStandard.Security.Tests.csproj | 6 +- .../SystemExtensions.NetStandard.Tests.csproj | 6 +- SystemExtensions.sln | 6 + 9 files changed, 307 insertions(+), 11 deletions(-) create mode 100644 SystemExtensions.NetStandard.Generators/Diagnostics.cs create mode 100644 SystemExtensions.NetStandard.Generators/NotifyPropertyChangedGenerator.cs create mode 100644 SystemExtensions.NetStandard.Generators/SystemExtensions.NetStandard.Generators.csproj diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 8a6bf39..cc99fec 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -55,6 +55,9 @@ jobs: - name: Build SystemExtensions.NetStandard.Security project run: dotnet build SystemExtensions.NetStandard.Security -c $env:Configuration + - name: Build SystemExtensions.NetStandard.Generators project + run: dotnet build SystemExtensions.NetStandard.Generators -c $env:Configuration + - name: Package SystemExtensions.NetStandard run: dotnet pack -c Release -o . SystemExtensions.NetStandard\SystemExtensions.NetStandard.csproj @@ -67,5 +70,8 @@ jobs: - name: Package SystemExtensions.NetStandard.Security run: dotnet pack -c Release -o . SystemExtensions.NetStandard.Security\SystemExtensions.NetStandard.Security.csproj + - name: Package SystemExtensions.NetStandard.Generators + run: dotnet pack -c Release -o . SystemExtensions.NetStandard.Generators\SystemExtensions.NetStandard.Generators.csproj + - name: Publish run: dotnet nuget push *.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate \ No newline at end of file diff --git a/SystemExtensions.DependencyInjection.Tests/SystemExtensions.NetStandard.DependencyInjection.Tests.csproj b/SystemExtensions.DependencyInjection.Tests/SystemExtensions.NetStandard.DependencyInjection.Tests.csproj index cd8dc0f..2cdb066 100644 --- a/SystemExtensions.DependencyInjection.Tests/SystemExtensions.NetStandard.DependencyInjection.Tests.csproj +++ b/SystemExtensions.DependencyInjection.Tests/SystemExtensions.NetStandard.DependencyInjection.Tests.csproj @@ -8,9 +8,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/SystemExtensions.NetStandard.DependencyInjection/SystemExtensions.NetStandard.DependencyInjection.csproj b/SystemExtensions.NetStandard.DependencyInjection/SystemExtensions.NetStandard.DependencyInjection.csproj index 4c9eef2..22e1998 100644 --- a/SystemExtensions.NetStandard.DependencyInjection/SystemExtensions.NetStandard.DependencyInjection.csproj +++ b/SystemExtensions.NetStandard.DependencyInjection/SystemExtensions.NetStandard.DependencyInjection.csproj @@ -6,7 +6,7 @@ LICENSE true latest - 1.4.0 + 1.4.1 Alexandru Macocian https://github.com/AlexMacocian/SystemExtensions Extensions for the Slim Dependency Injection engine @@ -23,7 +23,7 @@ - + diff --git a/SystemExtensions.NetStandard.Generators/Diagnostics.cs b/SystemExtensions.NetStandard.Generators/Diagnostics.cs new file mode 100644 index 0000000..5eb452f --- /dev/null +++ b/SystemExtensions.NetStandard.Generators/Diagnostics.cs @@ -0,0 +1,79 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace System.Extensions +{ + internal static class Diagnostics + { + public const string DependencyPropertyGenerator_NoAttributeFound = "DGD 0001"; + public const string DependencyPropertyGenerator_ClassMustBeTopLevel = "DGD 1001"; + public const string DependencyPropertyGenerator_ClassMustBePartial = "DGD 1002"; + public const string DependencyPropertyGenerator_ClassMustBeInNamespace = "DGD 1003"; + public const string DependencyPropertyGenerator_ClassMustImplementINotifyPropertyChanged = "DGD 1004"; + + public static Diagnostic MissingAttributeDiagnostic(string attributeName, string attributeNamespace) => Diagnostic.Create( + new DiagnosticDescriptor( + DependencyPropertyGenerator_NoAttributeFound, + $"{attributeNamespace}.{attributeName} not found", + "Could not find attribute with name {0} in namespace {1}.", + "WpfExtended.SourceGeneration", + DiagnosticSeverity.Error, + true, + $"This error occurs when the attribute generated by the {nameof(DependencyPropertyGenerator)} is not found", + null), + Location.None, + attributeName, attributeNamespace); + + public static Diagnostic ClassNotTopLevelDiagnostic(string className, string containingSymbol, SyntaxTree syntaxTree, TextSpan textSpan) => Diagnostic.Create( + new DiagnosticDescriptor( + DependencyPropertyGenerator_ClassMustBeTopLevel, + $"{className} must be top level", + "Class {0} must be top level. It is currently declared under {1}.", + "WpfExtended.SourceGeneration", + DiagnosticSeverity.Error, + true, + null, + null), + Location.Create(syntaxTree, textSpan), + className, containingSymbol); + + public static Diagnostic ClassNotPartial(string className) => Diagnostic.Create( + new DiagnosticDescriptor( + DependencyPropertyGenerator_ClassMustBePartial, + $"{className} is not partial", + "Class {0} must be marked as partial in order for the generator to work", + "WpfExtended.SourceGeneration", + DiagnosticSeverity.Error, + true, + null, + null), + Location.None, + className); + + public static Diagnostic ClassNotInNamespace(string className) => Diagnostic.Create( + new DiagnosticDescriptor( + DependencyPropertyGenerator_ClassMustBeInNamespace, + $"{className} is not defined in a namespace", + "Class {0} must be defined inside a namespace for the generator to work", + "WpfExtended.SourceGeneration", + DiagnosticSeverity.Error, + true, + null, + null), + Location.None, + className); + + public static Diagnostic ClassDoesNotImplementINotifyPropertyChanged(string className) => Diagnostic.Create( + new DiagnosticDescriptor( + DependencyPropertyGenerator_ClassMustImplementINotifyPropertyChanged, + $"{className} must implement INotifyPropertyChanged", + "Class {0} must implement INotifyPropertyChanged for the generator to work", + "WpfExtended.SourceGeneration", + DiagnosticSeverity.Error, + true, + null, + null), + Location.None, + className); + } +} diff --git a/SystemExtensions.NetStandard.Generators/NotifyPropertyChangedGenerator.cs b/SystemExtensions.NetStandard.Generators/NotifyPropertyChangedGenerator.cs new file mode 100644 index 0000000..0a0fa82 --- /dev/null +++ b/SystemExtensions.NetStandard.Generators/NotifyPropertyChangedGenerator.cs @@ -0,0 +1,173 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Sybil; +using System.Collections.Immutable; +using System.Linq; + +namespace System.Extensions; +#nullable enable +[Generator(LanguageNames.CSharp)] +public class NotifyPropertyChangedGenerator : IIncrementalGenerator +{ + private const string AttributeNamespace = "System.Windows.Extensions"; + private const string AttributeName = "GenerateNotifyPropertyChangedAttribute"; + private const string AttributeShortName = "GenerateNotifyPropertyChanged"; + private const string PropertyChangedEventHandler = "PropertyChangedEventHandler"; + private const string NullablePropertyChangedEventHandler = "PropertyChangedEventHandler?"; + private const string PropertyChanged = "PropertyChanged"; + private const string Partial = "partial"; + private const string Public = "public"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(context => + { + var compilationUnitBuilder = SyntaxBuilder.CreateCompilationUnit() + .WithNamespace( + SyntaxBuilder.CreateNamespace(AttributeNamespace) + .WithClass(SyntaxBuilder.CreateClass(AttributeName) + .WithModifier(Public) + .WithConstructor(SyntaxBuilder.CreateConstructor(AttributeName) + .WithModifier(Public)) + .WithAttribute(SyntaxBuilder.CreateAttribute("AttributeUsage") + .WithArgument(AttributeTargets.Field) + .WithArgument("Inherited", false) + .WithArgument("AllowMultiple", false)) + .WithBaseClass(nameof(Attribute)))); + var compilationUnitSyntax = compilationUnitBuilder.Build(); + var source = compilationUnitSyntax.ToFullString(); + context.AddSource($"{AttributeName}.g", source); + }); + + var classDeclarations = context.SyntaxProvider.CreateSyntaxProvider( + predicate: static (s, _) => s is FieldDeclarationSyntax, + transform: static (ctx, _) => GetFilteredFieldDeclarationSyntax(ctx)).Where(static c => c is not null); + + var compilationAndClasses = context.CompilationProvider.Combine(classDeclarations.Collect()); + context.RegisterSourceOutput(compilationAndClasses, (sourceProductionContext, tuple) => Execute(tuple.Left, tuple.Right, sourceProductionContext)); + } + + private static ClassDeclarationSyntax? GetFilteredFieldDeclarationSyntax(GeneratorSyntaxContext context) + { + var fieldDeclarationSyntax = (FieldDeclarationSyntax)context.Node; + if (fieldDeclarationSyntax.AttributeLists + .SelectMany(l => l.Attributes) + .OfType() + .Any(s => s.Name.ToString() == AttributeName || s.Name.ToString() == AttributeShortName)) + { + return fieldDeclarationSyntax.Parent as ClassDeclarationSyntax; + } + + return default; + } + + private static void Execute(Compilation compilation, ImmutableArray classes, SourceProductionContext sourceProductionContext) + { + if (classes.IsDefaultOrEmpty) + { + return; + } + + var maybeLanguageVersion = (compilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions)?.LanguageVersion; + if (!maybeLanguageVersion.HasValue) + { + return; + } + + var languageVersion = maybeLanguageVersion.Value; + var distinctClasses = classes.Distinct(); + foreach (var c in distinctClasses.OfType()) + { + var className = c.Identifier.ToFullString(); + if (!c.Modifiers.Any(m => m.ToString() == Partial)) + { + Diagnostics.ClassNotPartial(className); + continue; + } + + var originalNamespaceSyntax = GetParentOfType(c); + if (originalNamespaceSyntax is null) + { + Diagnostics.ClassNotInNamespace(className); + continue; + } + + if (!c.Members + .OfType() + .Any(eventField => + (eventField.Declaration.Variables.Any(variable => variable.Identifier.Text == PropertyChanged) && + eventField.Modifiers.Any(modifier => modifier.Text == Public) && + eventField.Declaration.Type.ToString() == PropertyChangedEventHandler) || + eventField.Declaration.Type.ToString() == NullablePropertyChangedEventHandler)) + { + Diagnostics.ClassDoesNotImplementINotifyPropertyChanged(className); + continue; + } + + var generatedClassBuilder = SyntaxBuilder.CreateClass(className) + .WithModifiers(string.Join(" ", c.Modifiers.Select(m => m.ToFullString()))); + var compilationUnitBuilder = SyntaxBuilder.CreateCompilationUnit() + .WithNamespace( + (languageVersion >= LanguageVersion.CSharp10 ? + SyntaxBuilder.CreateFileScopedNamespace(originalNamespaceSyntax.Name.ToFullString()) : + SyntaxBuilder.CreateNamespace(originalNamespaceSyntax.Name.ToFullString())) + .WithClass(generatedClassBuilder)); + + foreach (var field in c.DescendantNodes().OfType()) + { + if (field.AttributeLists + .SelectMany(l => l.Attributes) + .OfType() + .Any(s => s.Name.ToString() == AttributeName || + s.Name.ToString() == AttributeShortName) is false) + { + continue; + } + + var fieldName = field.Declaration.Variables.First().Identifier.ToFullString(); + var propertyName = fieldName.TrimStart('_'); + propertyName = $"{char.ToUpper(propertyName[0])}{propertyName.Substring(1)}"; + var propertyBuilder = SyntaxBuilder.CreateProperty(field.Declaration.Type.ToFullString(), propertyName) + .WithModifier(Public) + .WithAccessor(SyntaxBuilder.CreateGetter() + .WithBody($"return this.{fieldName};")) + .WithAccessor(SyntaxBuilder.CreateSetter() + .WithBody($"this.{fieldName} = value;\r\nthis.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof({propertyName})));")); + generatedClassBuilder.WithProperty(propertyBuilder); + } + + var generatedSyntax = compilationUnitBuilder.Build(); + if (originalNamespaceSyntax.Usings.Count != 0) + { + generatedSyntax = generatedSyntax.AddUsings([.. originalNamespaceSyntax.Usings]); + } + else + { + if (originalNamespaceSyntax.Parent is CompilationUnitSyntax compilationUnit) + { + generatedSyntax = generatedSyntax.AddUsings([.. compilationUnit.Usings]); + } + } + + var fileSource = generatedSyntax.ToFullString(); + sourceProductionContext.AddSource($"{className}.g", fileSource); + } + } + + private static T? GetParentOfType(SyntaxNode syntaxNode) + { + if (syntaxNode.Parent is null) + { + return default; + } + + if (syntaxNode.Parent is T parentNode) + { + return parentNode; + } + + return GetParentOfType(syntaxNode.Parent); + } +} +#nullable disable diff --git a/SystemExtensions.NetStandard.Generators/SystemExtensions.NetStandard.Generators.csproj b/SystemExtensions.NetStandard.Generators/SystemExtensions.NetStandard.Generators.csproj new file mode 100644 index 0000000..4d9e242 --- /dev/null +++ b/SystemExtensions.NetStandard.Generators/SystemExtensions.NetStandard.Generators.csproj @@ -0,0 +1,32 @@ + + + + netstandard2.0 + true + true + Alexandru Macocian + Source generators extensions for netstandard2.0. + LICENSE + 0.1.0 + latest + true + true + true + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/SystemExtensions.NetStandard.Security.Tests/SystemExtensions.NetStandard.Security.Tests.csproj b/SystemExtensions.NetStandard.Security.Tests/SystemExtensions.NetStandard.Security.Tests.csproj index e4eb01f..d7dd3ef 100644 --- a/SystemExtensions.NetStandard.Security.Tests/SystemExtensions.NetStandard.Security.Tests.csproj +++ b/SystemExtensions.NetStandard.Security.Tests/SystemExtensions.NetStandard.Security.Tests.csproj @@ -8,9 +8,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/SystemExtensions.Tests/SystemExtensions.NetStandard.Tests.csproj b/SystemExtensions.Tests/SystemExtensions.NetStandard.Tests.csproj index a3cc103..6e14d26 100644 --- a/SystemExtensions.Tests/SystemExtensions.NetStandard.Tests.csproj +++ b/SystemExtensions.Tests/SystemExtensions.NetStandard.Tests.csproj @@ -8,9 +8,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/SystemExtensions.sln b/SystemExtensions.sln index 9419f5c..d55929c 100644 --- a/SystemExtensions.sln +++ b/SystemExtensions.sln @@ -33,6 +33,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution .editorconfig = .editorconfig EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SystemExtensions.NetStandard.Generators", "SystemExtensions.NetStandard.Generators\SystemExtensions.NetStandard.Generators.csproj", "{BEF74563-A1D8-4159-B332-754A11151173}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -67,6 +69,10 @@ Global {9E6EBBF0-671B-4941-A72B-2CA60C882038}.Debug|Any CPU.Build.0 = Debug|Any CPU {9E6EBBF0-671B-4941-A72B-2CA60C882038}.Release|Any CPU.ActiveCfg = Release|Any CPU {9E6EBBF0-671B-4941-A72B-2CA60C882038}.Release|Any CPU.Build.0 = Release|Any CPU + {BEF74563-A1D8-4159-B332-754A11151173}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BEF74563-A1D8-4159-B332-754A11151173}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BEF74563-A1D8-4159-B332-754A11151173}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BEF74563-A1D8-4159-B332-754A11151173}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE