diff --git a/src/DataverseAnalyzer/DuplicateConstructorParameterTypeAnalyzer.cs b/src/DataverseAnalyzer/DuplicateConstructorParameterTypeAnalyzer.cs new file mode 100644 index 0000000..1f7f1e4 --- /dev/null +++ b/src/DataverseAnalyzer/DuplicateConstructorParameterTypeAnalyzer.cs @@ -0,0 +1,127 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DataverseAnalyzer; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DuplicateConstructorParameterTypeAnalyzer : DiagnosticAnalyzer +{ + private static readonly Lazy LazyRule = new(() => new DiagnosticDescriptor( + "CT0005", + Resources.CT0005_Title, + Resources.CT0005_MessageFormat, + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: Resources.CT0005_Description)); + + public static DiagnosticDescriptor Rule => LazyRule.Value; + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeConstructor, SyntaxKind.ConstructorDeclaration); + context.RegisterSyntaxNodeAction( + AnalyzePrimaryConstructor, + SyntaxKind.ClassDeclaration, + SyntaxKind.StructDeclaration, + SyntaxKind.RecordDeclaration, + SyntaxKind.RecordStructDeclaration); + } + + private static void AnalyzeConstructor(SyntaxNodeAnalysisContext context) + { + var constructor = (ConstructorDeclarationSyntax)context.Node; + if (constructor.ParameterList is null) + return; + + AnalyzeParameterList(context, constructor.ParameterList); + } + + private static void AnalyzePrimaryConstructor(SyntaxNodeAnalysisContext context) + { + var typeDeclaration = (TypeDeclarationSyntax)context.Node; + if (typeDeclaration.ParameterList is null) + return; + + AnalyzeParameterList(context, typeDeclaration.ParameterList); + } + + private static void AnalyzeParameterList(SyntaxNodeAnalysisContext context, ParameterListSyntax parameterList) + { + var parameters = parameterList.Parameters; + if (parameters.Count < 2) + return; + + var parametersByType = new Dictionary>(SymbolEqualityComparer.Default); + + foreach (var parameter in parameters) + { + if (parameter.Type is null) + continue; + + var typeInfo = context.SemanticModel.GetTypeInfo(parameter.Type); + var typeSymbol = typeInfo.Type; + + if (typeSymbol is null) + continue; + + if (!IsDependencyInjectionType(typeSymbol)) + continue; + + var paramName = parameter.Identifier.ValueText; + + if (!parametersByType.TryGetValue(typeSymbol, out var paramNames)) + { + paramNames = new List(); + parametersByType[typeSymbol] = paramNames; + } + + paramNames.Add(paramName); + } + + foreach (var kvp in parametersByType) + { + if (kvp.Value.Count < 2) + continue; + + var typeSymbol = kvp.Key; + var paramNames = kvp.Value; + + var typeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); + var paramNamesJoined = string.Join(", ", paramNames); + + var diagnostic = Diagnostic.Create( + Rule, + parameterList.GetLocation(), + typeName, + paramNamesJoined); + + context.ReportDiagnostic(diagnostic); + } + } + + private static bool IsDependencyInjectionType(ITypeSymbol typeSymbol) + { + var typeName = typeSymbol.Name; + return typeName.EndsWith("Service", StringComparison.Ordinal) || + typeName.EndsWith("Repository", StringComparison.Ordinal) || + typeName.EndsWith("Handler", StringComparison.Ordinal) || + typeName.EndsWith("Provider", StringComparison.Ordinal) || + typeName.EndsWith("Factory", StringComparison.Ordinal) || + typeName.EndsWith("Manager", StringComparison.Ordinal) || + typeName.EndsWith("Client", StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/src/DataverseAnalyzer/Resources.Designer.cs b/src/DataverseAnalyzer/Resources.Designer.cs index d879aab..47abdf9 100644 --- a/src/DataverseAnalyzer/Resources.Designer.cs +++ b/src/DataverseAnalyzer/Resources.Designer.cs @@ -35,5 +35,11 @@ internal static class Resources internal static string CT0004_CodeFix_Title => GetString(nameof(CT0004_CodeFix_Title)); + internal static string CT0005_Title => GetString(nameof(CT0005_Title)); + + internal static string CT0005_MessageFormat => GetString(nameof(CT0005_MessageFormat)); + + internal static string CT0005_Description => GetString(nameof(CT0005_Description)); + private static string GetString(string name) => ResourceManager.GetString(name, CultureInfo.InvariantCulture) ?? name; } \ No newline at end of file diff --git a/src/DataverseAnalyzer/Resources.resx b/src/DataverseAnalyzer/Resources.resx index 0a5cbe7..aa5c8cc 100644 --- a/src/DataverseAnalyzer/Resources.resx +++ b/src/DataverseAnalyzer/Resources.resx @@ -100,4 +100,13 @@ Remove braces + + Constructor has duplicate dependency injection parameter types + + + Constructor has multiple parameters of type '{0}': {1} + + + Having multiple constructor parameters of the same dependency injection type (Service, Repository, Handler, Provider, Factory, Manager, Client) can lead to confusion and accidental parameter swapping. + \ No newline at end of file diff --git a/tests/DataverseAnalyzer.Tests/DuplicateConstructorParameterTypeAnalyzerTests.cs b/tests/DataverseAnalyzer.Tests/DuplicateConstructorParameterTypeAnalyzerTests.cs new file mode 100644 index 0000000..43a11e0 --- /dev/null +++ b/tests/DataverseAnalyzer.Tests/DuplicateConstructorParameterTypeAnalyzerTests.cs @@ -0,0 +1,354 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DataverseAnalyzer.Tests; + +public sealed class DuplicateConstructorParameterTypeAnalyzerTests +{ + [Fact] + public async Task RegularConstructorWithDuplicateServiceTypeShouldTrigger() + { + var source = """ + interface IMyService { } + + class TestService + { + public TestService(IMyService a, IMyService b) + { + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0005", diagnostics[0].Id); + } + + [Fact] + public async Task PrimaryConstructorWithDuplicateServiceTypeShouldTrigger() + { + var source = """ + interface IMyService { } + + class TestService(IMyService a, IMyService b); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0005", diagnostics[0].Id); + } + + [Fact] + public async Task RecordWithDuplicateServiceTypeShouldTrigger() + { + var source = """ + class MyService { } + + record TestRecord(MyService A, MyService B); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0005", diagnostics[0].Id); + } + + [Fact] + public async Task ThreeSameServiceTypesShouldTriggerOnce() + { + var source = """ + interface IMyService { } + + class TestService(IMyService a, IMyService b, IMyService c); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0005", diagnostics[0].Id); + } + + [Fact] + public async Task TwoDifferentDuplicateServiceTypesShouldTriggerTwice() + { + var source = """ + interface IUserService { } + interface IOrderService { } + + class TestClass(IUserService a1, IUserService a2, IOrderService b1, IOrderService b2); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Equal(2, diagnostics.Length); + Assert.All(diagnostics, d => Assert.Equal("CT0005", d.Id)); + } + + [Fact] + public async Task UniqueServiceTypesShouldNotTrigger() + { + var source = """ + interface IUserService { } + interface IOrderService { } + interface IProductService { } + + class TestClass(IUserService a, IOrderService b, IProductService c); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task SingleParameterShouldNotTrigger() + { + var source = """ + interface IMyService { } + + class TestService(IMyService a); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task EmptyConstructorShouldNotTrigger() + { + var source = """ + class TestService + { + public TestService() + { + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task DifferentGenericTypeArgumentsShouldNotTrigger() + { + var source = """ + using System.Collections.Generic; + + class TestService(List a, List b); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task DuplicateCollectionTypesShouldNotTrigger() + { + var source = """ + using System.Collections.Generic; + + class TestService(List a, List b); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task DuplicateNonDiCustomTypesShouldNotTrigger() + { + var source = """ + class MyDto { } + + class TestClass(MyDto a, MyDto b); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task DuplicateRepositoryTypeShouldTrigger() + { + var source = """ + class UserRepository { } + + class TestClass(UserRepository a, UserRepository b); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0005", diagnostics[0].Id); + } + + [Fact] + public async Task DuplicateHandlerTypeShouldTrigger() + { + var source = """ + class CommandHandler { } + + class TestClass(CommandHandler a, CommandHandler b); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0005", diagnostics[0].Id); + } + + [Fact] + public async Task DuplicateClientTypeShouldTrigger() + { + var source = """ + class HttpClient { } + + class TestClass(HttpClient a, HttpClient b); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0005", diagnostics[0].Id); + } + + [Fact] + public async Task InterfaceAndImplementationShouldNotTrigger() + { + var source = """ + interface IUserService { } + class UserService : IUserService { } + + class TestClass(IUserService a, UserService b); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task NonDiInterfaceShouldNotTrigger() + { + var source = """ + interface IComparable { } + + class TestClass(IComparable a, IComparable b); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task StructWithDuplicateServiceTypeShouldTrigger() + { + var source = """ + interface IMyService { } + + struct TestStruct(IMyService a, IMyService b); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0005", diagnostics[0].Id); + } + + [Fact] + public async Task RecordStructWithDuplicateServiceTypeShouldTrigger() + { + var source = """ + interface IMyService { } + + record struct TestRecordStruct(IMyService A, IMyService B); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0005", diagnostics[0].Id); + } + + [Fact] + public async Task DuplicatePrimitiveStringShouldNotTrigger() + { + var source = """ + class TestService(string firstName, string lastName); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task DuplicatePrimitiveIntShouldNotTrigger() + { + var source = """ + class TestService(int width, int height); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task DuplicatePrimitiveBoolShouldNotTrigger() + { + var source = """ + class TestService(bool isEnabled, bool isVisible); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task MixedPrimitivesAndServicesShouldOnlyTriggerForServices() + { + var source = """ + interface IMyService { } + + class TestService(string name, IMyService a, int count, IMyService b); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0005", diagnostics[0].Id); + } + + [Fact] + public async Task DuplicateDictionaryTypesShouldNotTrigger() + { + var source = """ + using System.Collections.Generic; + + class TestService(Dictionary a, Dictionary b); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task DuplicateIEnumerableTypesShouldNotTrigger() + { + var source = """ + using System.Collections.Generic; + + class TestService(IEnumerable a, IEnumerable b); + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + private static async Task GetDiagnosticsAsync(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest)); + var compilation = CSharpCompilation.Create( + "TestAssembly", + new[] { syntaxTree }, + new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) }, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var analyzer = new DuplicateConstructorParameterTypeAnalyzer(); + var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + + var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + return diagnostics.Where(d => d.Id == "CT0005").ToArray(); + } +} \ No newline at end of file