From 79008a3fc34128ac5dcee0f108ebbf19502fd7fb Mon Sep 17 00:00:00 2001 From: ronimizy Date: Wed, 3 Apr 2024 22:43:47 +0300 Subject: [PATCH] feat: added RequiredValue attribute --- Directory.Build.props | 3 + SourceKit.Sample/.editorconfig | 1 + .../Generators/ArrayQueryUsage.cs | 52 ++++++++ SourceKit.sln | 4 +- .../Extensions/AttributeDataExtensions.cs | 12 ++ .../InitializesPropertyAttribute.cs | 4 + .../RequiredValueAttribute.cs | 4 + ...eKit.Generators.Builder.Annotations.csproj | 1 - .../Analyzers/RequiredValueAnalyzer.cs | 118 ++++++++++++++++++ .../CollectionMethodBuilderTypeBuilder.cs | 6 +- .../ValueMethodBuilderTypeBuilder.cs | 3 +- .../Builders/FileBuilders/UsingBuilder.cs | 1 + .../SourceKit.Generators.Builder/Constants.cs | 10 +- .../Generators/BuilderSourceGenerator.cs | 1 - .../InitializesPropertyAttributeBuilder.cs | 28 +++++ .../SourceKit.Generators.Builder/README.md | 16 +++ .../BuilderAttributeSyntaxContextReceiver.cs | 1 - .../SourceKit.Generators.Builder.csproj | 2 +- .../Generators/RequiredValueAnalyzerTests.cs | 40 ++++++ 19 files changed, 297 insertions(+), 10 deletions(-) create mode 100644 SourceKit.Sample/Generators/ArrayQueryUsage.cs create mode 100644 src/SourceKit/Extensions/AttributeDataExtensions.cs create mode 100644 src/generators/SourceKit.Generators.Builder.Annotations/InitializesPropertyAttribute.cs create mode 100644 src/generators/SourceKit.Generators.Builder.Annotations/RequiredValueAttribute.cs create mode 100644 src/generators/SourceKit.Generators.Builder/Analyzers/RequiredValueAnalyzer.cs create mode 100644 src/generators/SourceKit.Generators.Builder/Models/InitializesPropertyAttributeBuilder.cs create mode 100644 tests/SourceKit.Tests/Generators/RequiredValueAnalyzerTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 00f9eb6..5d35fb2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,4 +1,7 @@ + + 12 + 0 diff --git a/SourceKit.Sample/.editorconfig b/SourceKit.Sample/.editorconfig index f277119..c514901 100644 --- a/SourceKit.Sample/.editorconfig +++ b/SourceKit.Sample/.editorconfig @@ -7,4 +7,5 @@ dotnet_diagnostic.SK1301.severity = warning dotnet_diagnostic.SK1300.severity = warning dotnet_diagnostic.SK1500.severity = warning dotnet_diagnostic.SK2000.severity = warning +dotnet_diagnostic.SK2100.severity = warning diff --git a/SourceKit.Sample/Generators/ArrayQueryUsage.cs b/SourceKit.Sample/Generators/ArrayQueryUsage.cs new file mode 100644 index 0000000..3a037a2 --- /dev/null +++ b/SourceKit.Sample/Generators/ArrayQueryUsage.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SourceKit.Generators.Builder.Annotations; + +namespace SourceKit.Sample.Generators; + +public class ArrayQueryUsage +{ + public void A() + { + var query = ArrayQuery1.Build(x => x.WithId(Guid.NewGuid())); + } +} + +[GenerateBuilder] +public record ArrayQuery1(Guid[] Ids, [RequiredValue] string Value, string NotRequiredValue) +{ + public static ArrayQuery1 Build(Func action) + { + return action(new Builder()).Build(); + } + + public sealed class Builder + { + private readonly List _ids; + + public Builder() + { + _ids = new List(); + } + + [InitializesProperty(nameof(Ids))] + public Builder WithId(Guid element) + { + _ids.Add(element); + return this; + } + + [InitializesPropertyAttribute(nameof(Ids))] + public Builder WithIds(IEnumerable elements) + { + _ids.AddRange(elements); + return this; + } + + public ArrayQuery1 Build() + { + return new ArrayQuery1(_ids.Distinct().ToArray(), string.Empty, string.Empty); + } + } +} \ No newline at end of file diff --git a/SourceKit.sln b/SourceKit.sln index 77c8a55..fd7b4f8 100644 --- a/SourceKit.sln +++ b/SourceKit.sln @@ -128,10 +128,10 @@ Global {76AEC735-94F8-4904-9B60-4E53A142C812}.Release|Any CPU.Build.0 = Release|Any CPU {76AEC735-94F8-4904-9B60-4E53A142C812}.Debug|Any CPU.ActiveCfg = Release|Any CPU {76AEC735-94F8-4904-9B60-4E53A142C812}.Debug|Any CPU.Build.0 = Release|Any CPU - {21EE8956-B00B-45B7-ACD9-53CD1648ADC6}.Debug|Any CPU.ActiveCfg = Release|Any CPU - {21EE8956-B00B-45B7-ACD9-53CD1648ADC6}.Debug|Any CPU.Build.0 = Release|Any CPU {21EE8956-B00B-45B7-ACD9-53CD1648ADC6}.Release|Any CPU.ActiveCfg = Release|Any CPU {21EE8956-B00B-45B7-ACD9-53CD1648ADC6}.Release|Any CPU.Build.0 = Release|Any CPU + {21EE8956-B00B-45B7-ACD9-53CD1648ADC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21EE8956-B00B-45B7-ACD9-53CD1648ADC6}.Debug|Any CPU.Build.0 = Debug|Any CPU {AAE68EF8-EC92-42CA-A554-6733F28C250C}.Release|Any CPU.ActiveCfg = Release|Any CPU {AAE68EF8-EC92-42CA-A554-6733F28C250C}.Release|Any CPU.Build.0 = Release|Any CPU {AAE68EF8-EC92-42CA-A554-6733F28C250C}.Debug|Any CPU.ActiveCfg = Release|Any CPU diff --git a/src/SourceKit/Extensions/AttributeDataExtensions.cs b/src/SourceKit/Extensions/AttributeDataExtensions.cs new file mode 100644 index 0000000..9e0c721 --- /dev/null +++ b/src/SourceKit/Extensions/AttributeDataExtensions.cs @@ -0,0 +1,12 @@ +using Microsoft.CodeAnalysis; + +namespace SourceKit.Extensions; + +public static class AttributeDataExtensions +{ + public static bool IsAttribute(this AttributeData data, INamedTypeSymbol attribute) + => data.AttributeClass?.Equals(attribute, SymbolEqualityComparer.Default) is true; + + public static bool HasAttribute(this IEnumerable attributes, INamedTypeSymbol attribute) + => attributes.Any(x => x.IsAttribute(attribute)); +} \ No newline at end of file diff --git a/src/generators/SourceKit.Generators.Builder.Annotations/InitializesPropertyAttribute.cs b/src/generators/SourceKit.Generators.Builder.Annotations/InitializesPropertyAttribute.cs new file mode 100644 index 0000000..949c0f0 --- /dev/null +++ b/src/generators/SourceKit.Generators.Builder.Annotations/InitializesPropertyAttribute.cs @@ -0,0 +1,4 @@ +namespace SourceKit.Generators.Builder.Annotations; + +[AttributeUsage(AttributeTargets.Method)] +public class InitializesPropertyAttribute(string PropertyName) : Attribute; \ No newline at end of file diff --git a/src/generators/SourceKit.Generators.Builder.Annotations/RequiredValueAttribute.cs b/src/generators/SourceKit.Generators.Builder.Annotations/RequiredValueAttribute.cs new file mode 100644 index 0000000..113977c --- /dev/null +++ b/src/generators/SourceKit.Generators.Builder.Annotations/RequiredValueAttribute.cs @@ -0,0 +1,4 @@ +namespace SourceKit.Generators.Builder.Annotations; + +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)] +public class RequiredValueAttribute : Attribute; \ No newline at end of file diff --git a/src/generators/SourceKit.Generators.Builder.Annotations/SourceKit.Generators.Builder.Annotations.csproj b/src/generators/SourceKit.Generators.Builder.Annotations/SourceKit.Generators.Builder.Annotations.csproj index c663c99..e2a0e20 100644 --- a/src/generators/SourceKit.Generators.Builder.Annotations/SourceKit.Generators.Builder.Annotations.csproj +++ b/src/generators/SourceKit.Generators.Builder.Annotations/SourceKit.Generators.Builder.Annotations.csproj @@ -4,7 +4,6 @@ netstandard2.0 enable enable - 11 diff --git a/src/generators/SourceKit.Generators.Builder/Analyzers/RequiredValueAnalyzer.cs b/src/generators/SourceKit.Generators.Builder/Analyzers/RequiredValueAnalyzer.cs new file mode 100644 index 0000000..51df955 --- /dev/null +++ b/src/generators/SourceKit.Generators.Builder/Analyzers/RequiredValueAnalyzer.cs @@ -0,0 +1,118 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; +using SourceKit.Extensions; + +namespace SourceKit.Generators.Builder.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class RequiredValueAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "SK2100"; + public const string Title = nameof(RequiredValueAnalyzer); + + public const string Format = """Reqired properties {0} must be initialized"""; + + public static readonly DiagnosticDescriptor Descriptor = new DiagnosticDescriptor( + DiagnosticId, + Title, + Format, + "Usage", + DiagnosticSeverity.Error, + true); + + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Descriptor); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | + GeneratedCodeAnalysisFlags.ReportDiagnostics); + + context.RegisterOperationAction(AnalyzeOperation, OperationKind.Invocation); + } + + private void AnalyzeOperation(OperationAnalysisContext context) + { + var operation = (IInvocationOperation)context.Operation; + + if (operation.Instance is not null) + return; + + var generateBuilderAttribute = context.Compilation.GetTypeByMetadataName( + Constants.GenerateBuilderAttributeFullyQualifiedName); + + var requiredValueAttribute = context.Compilation.GetTypeByMetadataName( + Constants.RequiredValueAttributeFullyQualifiedName); + + var initializesValueAttribute = context.Compilation.GetTypeByMetadataName( + Constants.InitializesPropertyAttributeFullyQualifiedName); + + if (generateBuilderAttribute is null + || requiredValueAttribute is null + || initializesValueAttribute is null) + { + return; + } + + if (operation.Type is not INamedTypeSymbol modelType) + return; + + var hasBuilderAttribute = operation.Type + .GetAttributes() + .Any(x => x.AttributeClass?.Equals(generateBuilderAttribute, SymbolEqualityComparer.Default) is true); + + if (hasBuilderAttribute is false) + return; + + if (operation.TargetMethod.Name is not "Build") + return; + + ImmutableArray modelTypeMembers = modelType.GetMembers(); + + IEnumerable requiredProperties = modelTypeMembers + .OfType() + .Where(property => property.GetAttributes().HasAttribute(requiredValueAttribute)) + .Select(x => x.Name); + + IEnumerable requiredParameters = modelType.Constructors + .SelectMany(x => x.Parameters) + .Where(x => x.GetAttributes().HasAttribute(requiredValueAttribute)) + .Select(x => x.Name); + + IEnumerable descendantInvocations = modelTypeMembers + .OfType(); + + IEnumerable initializedPropertyNames = GetInitializedPropertyNames(descendantInvocations); + + var unintializedPropertyNames = requiredProperties + .Union(requiredParameters) + .Except(initializedPropertyNames) + .ToArray(); + + if (unintializedPropertyNames is []) + return; + + var location = operation.Syntax.GetLocation(); + + context.ReportDiagnostic( + Diagnostic.Create(Descriptor, location, messageArgs: string.Join(", ", unintializedPropertyNames))); + + return; + + IEnumerable GetInitializedPropertyNames(IEnumerable invocations) + { + foreach (var invocation in invocations) + { + var attribute = invocation.TargetMethod + .GetAttributes() + .SingleOrDefault(x => x.IsAttribute(initializesValueAttribute)); + + if (attribute?.ConstructorArguments is [{ Value: string parameterName }]) + yield return parameterName; + } + } + } +} \ No newline at end of file diff --git a/src/generators/SourceKit.Generators.Builder/Builders/BuilderTypeBuilders/CollectionMethodBuilderTypeBuilder.cs b/src/generators/SourceKit.Generators.Builder/Builders/BuilderTypeBuilders/CollectionMethodBuilderTypeBuilder.cs index 061fff6..7337389 100644 --- a/src/generators/SourceKit.Generators.Builder/Builders/BuilderTypeBuilders/CollectionMethodBuilderTypeBuilder.cs +++ b/src/generators/SourceKit.Generators.Builder/Builders/BuilderTypeBuilders/CollectionMethodBuilderTypeBuilder.cs @@ -68,7 +68,8 @@ private MemberDeclarationSyntax GenerateAddSingleMethod( return MethodDeclaration(IdentifierName(returnType), name) .AddModifiers(Token(SyntaxKind.PublicKeyword)) .AddParameterListParameters(parameter) - .AddBodyStatements(ExpressionStatement(invocation), returnStatement); + .AddBodyStatements(ExpressionStatement(invocation), returnStatement) + .AddAttributeLists(new InitializesPropertyAttributeBuilder(property.Symbol.Name)); } private MemberDeclarationSyntax GenerateAddRangeMethod( @@ -96,6 +97,7 @@ private MemberDeclarationSyntax GenerateAddRangeMethod( return MethodDeclaration(IdentifierName(returnType), name) .AddModifiers(Token(SyntaxKind.PublicKeyword)) .AddParameterListParameters(parameter) - .AddBodyStatements(ExpressionStatement(invocation), returnStatement); + .AddBodyStatements(ExpressionStatement(invocation), returnStatement) + .AddAttributeLists(new InitializesPropertyAttributeBuilder(property.Name)); } } \ No newline at end of file diff --git a/src/generators/SourceKit.Generators.Builder/Builders/BuilderTypeBuilders/ValueMethodBuilderTypeBuilder.cs b/src/generators/SourceKit.Generators.Builder/Builders/BuilderTypeBuilders/ValueMethodBuilderTypeBuilder.cs index f58feb2..743b705 100644 --- a/src/generators/SourceKit.Generators.Builder/Builders/BuilderTypeBuilders/ValueMethodBuilderTypeBuilder.cs +++ b/src/generators/SourceKit.Generators.Builder/Builders/BuilderTypeBuilders/ValueMethodBuilderTypeBuilder.cs @@ -49,7 +49,8 @@ private IEnumerable GenerateMethods(BuilderTypeBuilding yield return MethodDeclaration(IdentifierName(returnType), name) .AddModifiers(Token(SyntaxKind.PublicKeyword)) .AddParameterListParameters(parameter) - .AddBodyStatements(ExpressionStatement(assignment), returnStatement); + .AddBodyStatements(ExpressionStatement(assignment), returnStatement) + .AddAttributeLists(new InitializesPropertyAttributeBuilder(property.Symbol.Name)); } } } \ No newline at end of file diff --git a/src/generators/SourceKit.Generators.Builder/Builders/FileBuilders/UsingBuilder.cs b/src/generators/SourceKit.Generators.Builder/Builders/FileBuilders/UsingBuilder.cs index 139905d..5c1991d 100644 --- a/src/generators/SourceKit.Generators.Builder/Builders/FileBuilders/UsingBuilder.cs +++ b/src/generators/SourceKit.Generators.Builder/Builders/FileBuilders/UsingBuilder.cs @@ -39,6 +39,7 @@ public CompilationUnitSyntax Process( .Append(UsingDirective(IdentifierName("System"))) .Append(UsingDirective(IdentifierName("System.Linq"))) .Append(UsingDirective(IdentifierName("System.Collections.Generic"))) + .Append(UsingDirective(IdentifierName(Constants.AnnotationsNamespace))) .Concat(propertyUsingDirectives) .Distinct(Comparer) .OrderBy(x => x.Name.ToString()) diff --git a/src/generators/SourceKit.Generators.Builder/Constants.cs b/src/generators/SourceKit.Generators.Builder/Constants.cs index 625a04f..cf7cd9c 100644 --- a/src/generators/SourceKit.Generators.Builder/Constants.cs +++ b/src/generators/SourceKit.Generators.Builder/Constants.cs @@ -1,16 +1,24 @@ using SourceKit.Generators.Builder.Annotations; -namespace SourceKit.Generators.Builder.Tools; +namespace SourceKit.Generators.Builder; public static class Constants { public const string AnnotationsNamespace = "SourceKit.Generators.Builder.Annotations"; public const string GenerateBuilderAttributeName = nameof(GenerateBuilderAttribute); + public const string InitializesPropertyAttributeName = nameof(InitializesPropertyAttribute); + public const string RequiredValueAttributeName = nameof(RequiredValueAttribute); public const string GenerateBuilderAttributeFullyQualifiedName = $"{AnnotationsNamespace}.{GenerateBuilderAttributeName}"; + public const string InitializesPropertyAttributeFullyQualifiedName = + $"{AnnotationsNamespace}.{InitializesPropertyAttributeName}"; + + public const string RequiredValueAttributeFullyQualifiedName = + $"{AnnotationsNamespace}.{RequiredValueAttributeName}"; + public const string EnumerableFullyQualifiedName = "System.Collections.IEnumerable"; public const string GenericEnumerableFullyQualifiedName = "System.Collections.Generic.IEnumerable`1"; diff --git a/src/generators/SourceKit.Generators.Builder/Generators/BuilderSourceGenerator.cs b/src/generators/SourceKit.Generators.Builder/Generators/BuilderSourceGenerator.cs index 5597a80..eed16bd 100644 --- a/src/generators/SourceKit.Generators.Builder/Generators/BuilderSourceGenerator.cs +++ b/src/generators/SourceKit.Generators.Builder/Generators/BuilderSourceGenerator.cs @@ -11,7 +11,6 @@ using SourceKit.Generators.Builder.Commands; using SourceKit.Generators.Builder.Models; using SourceKit.Generators.Builder.Receivers; -using SourceKit.Generators.Builder.Tools; using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; namespace SourceKit.Generators.Builder.Generators; diff --git a/src/generators/SourceKit.Generators.Builder/Models/InitializesPropertyAttributeBuilder.cs b/src/generators/SourceKit.Generators.Builder/Models/InitializesPropertyAttributeBuilder.cs new file mode 100644 index 0000000..84f478a --- /dev/null +++ b/src/generators/SourceKit.Generators.Builder/Models/InitializesPropertyAttributeBuilder.cs @@ -0,0 +1,28 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace SourceKit.Generators.Builder.Models; + +public readonly record struct InitializesPropertyAttributeBuilder(string PropertyName) +{ + private static readonly AttributeSyntax AttributeValue = Attribute( + IdentifierName(Constants.InitializesPropertyAttributeName)); + + public static implicit operator AttributeListSyntax(InitializesPropertyAttributeBuilder builder) + { + var propertyName = Argument(IdentifierName(builder.PropertyName)); + + var nameofSyntax = IdentifierName(Identifier( + TriviaList(), + SyntaxKind.NameOfKeyword, + "nameof", + "nameof", + TriviaList())); + + var argument = AttributeArgument(InvocationExpression(nameofSyntax).AddArgumentListArguments(propertyName)); + var attribute = AttributeValue.AddArgumentListArguments(argument); + + return AttributeList(SingletonSeparatedList(attribute)); + } +} \ No newline at end of file diff --git a/src/generators/SourceKit.Generators.Builder/README.md b/src/generators/SourceKit.Generators.Builder/README.md index 7ddbba0..f1d4f83 100644 --- a/src/generators/SourceKit.Generators.Builder/README.md +++ b/src/generators/SourceKit.Generators.Builder/README.md @@ -18,4 +18,20 @@ public partial record SomeQuery(IReadOnlyCollection Ids, int Count); ... var query = SomeQuery.Build(x => x.WithCount(2).WithId(Guid.NewGuid()); +``` + +## Required properties + +You can annotate property with `[RequiredValue]` attribute to force compile time error +when it is not initialized withing `Build` method of model. + +```csharp +[GenerateBuilder] +public partial record SomeQuery(long[] Ids, [RequiredValue] int PageSize); +``` + +The following code will produce an error. + +```csharp +var query = SomeQuery.Build(x => x.WithId(1)); ``` \ No newline at end of file diff --git a/src/generators/SourceKit.Generators.Builder/Receivers/BuilderAttributeSyntaxContextReceiver.cs b/src/generators/SourceKit.Generators.Builder/Receivers/BuilderAttributeSyntaxContextReceiver.cs index 5364f92..3672992 100644 --- a/src/generators/SourceKit.Generators.Builder/Receivers/BuilderAttributeSyntaxContextReceiver.cs +++ b/src/generators/SourceKit.Generators.Builder/Receivers/BuilderAttributeSyntaxContextReceiver.cs @@ -1,6 +1,5 @@ using Microsoft.CodeAnalysis; using SourceKit.Extensions; -using SourceKit.Generators.Builder.Tools; namespace SourceKit.Generators.Builder.Receivers; diff --git a/src/generators/SourceKit.Generators.Builder/SourceKit.Generators.Builder.csproj b/src/generators/SourceKit.Generators.Builder/SourceKit.Generators.Builder.csproj index 42abf2b..3888563 100644 --- a/src/generators/SourceKit.Generators.Builder/SourceKit.Generators.Builder.csproj +++ b/src/generators/SourceKit.Generators.Builder/SourceKit.Generators.Builder.csproj @@ -27,7 +27,7 @@ 1.1.$(PatchVersion) - Added pragma warning diable for CS1591 + Added RequiredValue attribute diff --git a/tests/SourceKit.Tests/Generators/RequiredValueAnalyzerTests.cs b/tests/SourceKit.Tests/Generators/RequiredValueAnalyzerTests.cs new file mode 100644 index 0000000..35c693e --- /dev/null +++ b/tests/SourceKit.Tests/Generators/RequiredValueAnalyzerTests.cs @@ -0,0 +1,40 @@ +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Testing.Verifiers; +using SourceKit.Generators.Builder.Analyzers; +using SourceKit.Generators.Builder.Annotations; +using SourceKit.Tests.Tools; +using Xunit; +using AnalyzerVerifier = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier< + SourceKit.Generators.Builder.Analyzers.RequiredValueAnalyzer>; + +namespace SourceKit.Tests.Generators; + +public class RequiredValueAnalyzerTests +{ + [Fact] + public async Task A() + { + var usageFile = await SourceFile.LoadAsync("SourceKit.Sample/Generators/ArrayQueryUsage.cs"); + + var diagnostic = AnalyzerVerifier.Diagnostic(RequiredValueAnalyzer.Descriptor) + .WithLocation(usageFile.Name, 12, 21) + .WithArguments("Value"); + + var test = new CSharpAnalyzerTest + { + TestState = + { + Sources = { usageFile }, + AdditionalReferences = + { + typeof(GenerateBuilderAttribute).Assembly, + }, + ReferenceAssemblies = ReferenceAssemblies.Net.Net60, + }, + ExpectedDiagnostics = { diagnostic }, + }; + + await test.RunAsync(); + } +} \ No newline at end of file