diff --git a/Source/Analyzers/AttributeIdentityAnalyzer.cs b/Source/Analyzers/AttributeIdentityAnalyzer.cs index ccee7535..755170ea 100644 --- a/Source/Analyzers/AttributeIdentityAnalyzer.cs +++ b/Source/Analyzers/AttributeIdentityAnalyzer.cs @@ -18,6 +18,14 @@ namespace Dolittle.SDK.Analyzers; [DiagnosticAnalyzer(LanguageNames.CSharp)] public class AttributeIdentityAnalyzer : DiagnosticAnalyzer { + static readonly ImmutableDictionary _missingProjectionBaseClassProperties = + ImmutableDictionary.Empty + .Add("baseClass", DolittleTypes.ReadModelClass); + + static readonly ImmutableDictionary _missingAggregateBaseClassProperties = + ImmutableDictionary.Empty + .Add("baseClass", DolittleTypes.AggregateRootBaseClass); + readonly ConcurrentDictionary<(string type, Guid id), AttributeSyntax> _identities = new(); /// @@ -49,24 +57,25 @@ void CheckAttribute(SyntaxNodeAnalysisContext context) break; case "AggregateRootAttribute": CheckAttributeIdentity(attribute, symbol, context); - CheckHasBaseClass(context, DolittleTypes.AggregateRootBaseClass); + CheckHasBaseClass(context, DolittleTypes.AggregateRootBaseClass, _missingAggregateBaseClassProperties); break; case "ProjectionAttribute": CheckAttributeIdentity(attribute, symbol, context); - CheckHasBaseClass(context, DolittleTypes.ReadModelClass); + CheckHasBaseClass(context, DolittleTypes.ReadModelClass, _missingProjectionBaseClassProperties); break; } } - void CheckHasBaseClass(SyntaxNodeAnalysisContext context, string expectedBaseClass) + void CheckHasBaseClass(SyntaxNodeAnalysisContext context, string expectedBaseClass, ImmutableDictionary properties) { if (context.Node.FirstAncestorOrSelf() is not { } classDeclaration) return; if (classDeclaration.BaseList is null || classDeclaration.BaseList.Types.Count == 0 || !TypeExtends(classDeclaration, expectedBaseClass, context)) { var className = classDeclaration.Identifier.ToString(); - context.ReportDiagnostic(Diagnostic.Create(DescriptorRules.MissingBaseClass, classDeclaration.GetLocation(), className, expectedBaseClass)); + context.ReportDiagnostic(Diagnostic.Create(DescriptorRules.MissingBaseClass, classDeclaration.GetLocation(), properties, className, + expectedBaseClass)); } } @@ -81,7 +90,7 @@ static bool TypeExtends(ClassDeclarationSyntax type, string expectedBaseClass, S { var typeSymbol = context.SemanticModel.GetDeclaredSymbol(type); var baseClassType = context.SemanticModel.Compilation.GetTypeByMetadataName(expectedBaseClass); - + return TypeExtends(typeSymbol, baseClassType); } diff --git a/Source/Analyzers/CodeFixes/MissingBaseClassCodeFixProvider.cs b/Source/Analyzers/CodeFixes/MissingBaseClassCodeFixProvider.cs new file mode 100644 index 00000000..0bab2c7a --- /dev/null +++ b/Source/Analyzers/CodeFixes/MissingBaseClassCodeFixProvider.cs @@ -0,0 +1,79 @@ +// Copyright (c) Dolittle. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Dolittle.SDK.Analyzers.CodeFixes; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MethodVisibilityCodeFixProvider))] +public class MissingBaseClassCodeFixProvider : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticIds.MissingBaseClassRuleId); + + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + var diagnostic = context.Diagnostics.First(); + var missingClass = diagnostic.Properties["baseClass"]; + if (missingClass is null) + { + return Task.CompletedTask; + } + + var title = $"Add base class: '{missingClass}'"; + context.RegisterCodeFix( + CodeAction.Create( + title: title, + createChangedDocument: c => AddBaseClassAsync(context.Document, diagnostic, missingClass, c), + equivalenceKey: title), + diagnostic); + + return Task.CompletedTask; + } + + + + async Task AddBaseClassAsync(Document document, Diagnostic diagnostic, string missingClass, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (root is null || semanticModel is null) return document; + + var node = root.FindNode(diagnostic.Location.SourceSpan); + var classDeclaration = node.FirstAncestorOrSelf(); + if (classDeclaration is null) return document; + + var baseClassType = semanticModel.Compilation.GetTypeByMetadataName(missingClass); + if (baseClassType is null) return document; + + var newClassDeclaration = classDeclaration.AddBaseListTypes(SyntaxFactory.SimpleBaseType(SyntaxFactory.ParseTypeName(baseClassType.Name))).NormalizeWhitespace(); + + // Add using directive for the namespace if it's not already there + var namespaceToAdd = baseClassType?.ContainingNamespace?.ToDisplayString(); + if (!string.IsNullOrEmpty(namespaceToAdd) && root is CompilationUnitSyntax compilationUnitSyntax && + !NamespaceImported(compilationUnitSyntax, namespaceToAdd!)) + { + var usingDirective = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceToAdd!)) + .NormalizeWhitespace(); // Ensure proper formatting + compilationUnitSyntax = compilationUnitSyntax.AddUsings(usingDirective); + root = compilationUnitSyntax; + } + + var newRoot = root.ReplaceNode(classDeclaration, newClassDeclaration); + return document.WithSyntaxRoot(newRoot); + } + + + + bool NamespaceImported(CompilationUnitSyntax root, string namespaceName) + { + return root.Usings.Any(usingDirective => usingDirective.Name?.ToString() == namespaceName); + } +} diff --git a/Tests/Analyzers/CodeFixes/MissingBaseClassCodeFixProviderTests.cs b/Tests/Analyzers/CodeFixes/MissingBaseClassCodeFixProviderTests.cs new file mode 100644 index 00000000..586f9848 --- /dev/null +++ b/Tests/Analyzers/CodeFixes/MissingBaseClassCodeFixProviderTests.cs @@ -0,0 +1,58 @@ +// // Copyright (c) Dolittle. All rights reserved. +// // Licensed under the MIT license. See LICENSE file in the project root for full license information. +// +// using System.Threading.Tasks; +// +// namespace Dolittle.SDK.Analyzers.CodeFixes; +// +// public class MissingBaseClassCodeFixProviderTests : CodeFixProviderTests +// { +// [Fact] +// public async Task ShouldFixAggregateBaseClass() +// { +// var test = @" +// [Dolittle.SDK.Aggregates.AggregateRoot(""c6f87322-be67-4aaf-a9f4-fdc24ac4f0fb"")] +// class SomeAggregateRoot +// { +// public string Name { get; set; } +// }"; +// +// var expected = @"using Dolittle.SDK.Aggregates; +// [Dolittle.SDK.Aggregates.AggregateRoot(""c6f87322-be67-4aaf-a9f4-fdc24ac4f0fb"")] +// class SomeAggregateRoot : AggregateRoot +// { +// public string Name { get; set; } +// }"; +// +// var diagnosticResult = Diagnostic(DescriptorRules.MissingBaseClass) +// .WithSpan(2, 1, 6, 2) +// .WithArguments("SomeAggregateRoot", "Dolittle.SDK.Aggregates.AggregateRoot"); +// +// +// await VerifyCodeFixAsync(test, expected, diagnosticResult); +// } +// +// [Fact] +// public async Task ShouldFixProjectionBaseClass() +// { +// var test = @" +// [Dolittle.SDK.Projections.Projection(""c6f87322-be67-4aaf-a9f4-fdc24ac4f0fb"")] +// class SomeProjection +// { +// public string Name { get; set; } +// }"; +// +// var expected = @"using Dolittle.SDK.Projections; +// [Dolittle.SDK.Projections.Projection(""c6f87322-be67-4aaf-a9f4-fdc24ac4f0fb"")] +// class SomeProjection : ReadModel +// { +// public string Name { get; set; } +// }"; +// +// var diagnosticResult = Diagnostic(DescriptorRules.MissingBaseClass) +// .WithSpan(2, 1, 6, 2) +// .WithArguments("SomeProjection", "Dolittle.SDK.Projections.ReadModel"); +// +// await VerifyCodeFixAsync(test, expected, diagnosticResult); +// } +// }