diff --git a/README.md b/README.md index d9f479fc9..9d50f72b3 100755 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ If you are already using other analyzers, you can check [which rules are duplica |[MA0184](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0184.md)|Style|Do not use interpolated string without parameters|đŸ‘ģ|âœ”ī¸|âœ”ī¸| |[MA0185](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0185.md)|Performance|Simplify string.Create when all parameters are culture invariant|â„šī¸|âœ”ī¸|âœ”ī¸| |[MA0186](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0186.md)|Design|Equals method should use \[NotNullWhen(true)\] on the parameter|â„šī¸|❌|❌| +|[MA0187](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0187.md)|Design|Use constructor injection instead of \[Inject\] attribute|â„šī¸|❌|âœ”ī¸| diff --git a/docs/README.md b/docs/README.md index 67803cce7..832ad560f 100755 --- a/docs/README.md +++ b/docs/README.md @@ -185,6 +185,7 @@ |[MA0184](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0184.md)|Style|Do not use interpolated string without parameters|đŸ‘ģ|âœ”ī¸|âœ”ī¸| |[MA0185](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0185.md)|Performance|Simplify string.Create when all parameters are culture invariant|â„šī¸|âœ”ī¸|âœ”ī¸| |[MA0186](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0186.md)|Design|Equals method should use \[NotNullWhen(true)\] on the parameter|â„šī¸|❌|❌| +|[MA0187](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0187.md)|Design|Use constructor injection instead of \[Inject\] attribute|â„šī¸|❌|âœ”ī¸| |Id|Suppressed rule|Justification| |--|---------------|-------------| @@ -755,6 +756,9 @@ dotnet_diagnostic.MA0185.severity = suggestion # MA0186: Equals method should use [NotNullWhen(true)] on the parameter dotnet_diagnostic.MA0186.severity = none + +# MA0187: Use constructor injection instead of [Inject] attribute +dotnet_diagnostic.MA0187.severity = none ``` # .editorconfig - all rules disabled @@ -1311,4 +1315,7 @@ dotnet_diagnostic.MA0185.severity = none # MA0186: Equals method should use [NotNullWhen(true)] on the parameter dotnet_diagnostic.MA0186.severity = none + +# MA0187: Use constructor injection instead of [Inject] attribute +dotnet_diagnostic.MA0187.severity = none ``` diff --git a/docs/Rules/MA0187.md b/docs/Rules/MA0187.md new file mode 100644 index 000000000..ff0ab5461 --- /dev/null +++ b/docs/Rules/MA0187.md @@ -0,0 +1,51 @@ +# MA0187 - Use constructor injection instead of \[Inject\] attribute + +Sources: [BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzer.cs), [BlazorPropertyInjectionShouldUseConstructorInjectionFixer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionFixer.cs) + + +Since .NET 9, Blazor components support constructor injection. This is preferred over property injection via the `[Inject]` attribute as it avoids the need for `= default!` initializers and makes dependencies explicit. + +This rule only applies when: +- The ASP.NET Core version is 9.0 or greater +- The C# language version is 12 or greater (required for primary constructors) +- The class does not have explicit non-primary constructors + +## Non-compliant code + +```csharp +using Microsoft.AspNetCore.Components; + +class MyComponent : ComponentBase +{ + [Inject] + protected NavigationManager Navigation { get; set; } = default!; + + private void HandleClick() + { + Navigation.NavigateTo("/counter"); + } +} +``` + +## Compliant code + +```csharp +using Microsoft.AspNetCore.Components; + +class MyComponent(NavigationManager navigation) : ComponentBase +{ + private void HandleClick() + { + navigation.NavigateTo("/counter"); + } +} +``` + +## Configuration + +This rule is disabled by default. You can enable it by setting the severity in your `.editorconfig` file: + +```editorconfig +# MA0187: Use constructor injection instead of [Inject] attribute +dotnet_diagnostic.MA0187.severity = suggestion +``` diff --git a/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs b/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs new file mode 100644 index 000000000..b14439060 --- /dev/null +++ b/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs @@ -0,0 +1,65 @@ +#if CSHARP12_OR_GREATER +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; + +namespace Meziantou.Analyzer.Rules; + +internal sealed class BlazorPropertyInjectionFixAllProvider : FixAllProvider +{ + public static readonly BlazorPropertyInjectionFixAllProvider Instance = new(); + + public override async Task GetFixAsync(FixAllContext fixAllContext) + { + var diagnosticsToFix = await fixAllContext.GetAllDiagnosticsAsync(fixAllContext.Project).ConfigureAwait(false); + if (diagnosticsToFix.IsEmpty) + return null; + + return CodeAction.Create( + "Use constructor injection", + ct => FixAllAsync(fixAllContext, diagnosticsToFix, ct), + equivalenceKey: "Use constructor injection"); + } + + private static async Task FixAllAsync(FixAllContext fixAllContext, ImmutableArray diagnostics, CancellationToken cancellationToken) + { + var solution = fixAllContext.Project.Solution; + + // Group diagnostics by document + var diagnosticsByDocument = new Dictionary>(); + foreach (var diagnostic in diagnostics) + { + if (diagnostic.Location.IsInSource) + { + var document = solution.GetDocument(diagnostic.Location.SourceTree); + if (document is not null) + { + if (!diagnosticsByDocument.TryGetValue(document.Id, out var list)) + { + list = []; + diagnosticsByDocument[document.Id] = list; + } + + list.Add(diagnostic); + } + } + } + + // Process each document + foreach (var (documentId, documentDiagnostics) in diagnosticsByDocument) + { + var document = solution.GetDocument(documentId); + if (document is null) + continue; + + solution = await BlazorPropertyInjectionShouldUseConstructorInjectionFixer.FixDocumentAsync( + document, + ImmutableArray.CreateRange(documentDiagnostics), + cancellationToken).ConfigureAwait(false); + } + + return solution; + } +} +#endif diff --git a/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionFixer.cs b/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionFixer.cs new file mode 100644 index 000000000..201a429d4 --- /dev/null +++ b/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionFixer.cs @@ -0,0 +1,237 @@ +#if CSHARP12_OR_GREATER +using System.Collections.Immutable; +using System.Composition; +using System.Text; +using Meziantou.Analyzer.Internals; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Rename; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace Meziantou.Analyzer.Rules; + +[ExportCodeFixProvider(LanguageNames.CSharp), Shared] +public sealed class BlazorPropertyInjectionShouldUseConstructorInjectionFixer : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(RuleIdentifiers.BlazorPropertyInjectionShouldUseConstructorInjection); + + public override FixAllProvider GetFixAllProvider() => + BlazorPropertyInjectionFixAllProvider.Instance; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + var nodeToFix = root?.FindNode(context.Span, getInnermostNodeForTie: true); + if (nodeToFix is null) + return; + + var title = "Use constructor injection"; + context.RegisterCodeFix( + CodeAction.Create( + title, + ct => FixDocumentAsync(context.Document, context.Diagnostics, ct), + equivalenceKey: title), + context.Diagnostics); + } + + internal static async Task FixDocumentAsync(Document document, ImmutableArray diagnostics, CancellationToken cancellationToken) + { + var solution = document.Project.Solution; + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (root is null || semanticModel is null) + return solution; + + // Collect all (property symbol, parameter name) pairs from the diagnostics + var propertiesToFix = new List<(IPropertySymbol Symbol, string ParameterName, TypeSyntax PropertyType)>(); + foreach (var diagnostic in diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + var propertyDecl = node?.AncestorsAndSelf().OfType().FirstOrDefault(); + if (propertyDecl is null) + continue; + + var propertySymbol = semanticModel.GetDeclaredSymbol(propertyDecl, cancellationToken) as IPropertySymbol; + if (propertySymbol is null) + continue; + + var classDecl = propertyDecl.Ancestors().OfType().FirstOrDefault(); + if (classDecl is null || HasExplicitNonPrimaryConstructors(classDecl)) + continue; + + var parameterName = ComputeParameterName(propertySymbol.Name); + propertiesToFix.Add((propertySymbol, parameterName, propertyDecl.Type.WithoutTrivia())); + } + + if (propertiesToFix.Count == 0) + return solution; + + // Group by containing class (to handle multiple properties in the same class) + var byClass = propertiesToFix.GroupBy(p => p.Symbol.ContainingType, SymbolEqualityComparer.Default).ToList(); + + foreach (var classGroup in byClass) + { + var properties = classGroup.ToList(); + var firstClassDecl = await GetClassDeclarationAsync(document, solution, properties[0].Symbol, cancellationToken).ConfigureAwait(false); + if (firstClassDecl is null) + continue; + + // Annotate the class so we can find it after all renames + document = solution.GetDocument(document.Id)!; + root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + continue; + + var classAnnotation = new SyntaxAnnotation(); + var classDeclNode = root.DescendantNodesAndSelf().OfType() + .FirstOrDefault(t => t.Identifier.ValueText == firstClassDecl.Identifier.ValueText); + if (classDeclNode is null) + continue; + + root = root.ReplaceNode(classDeclNode, classDeclNode.WithAdditionalAnnotations(classAnnotation)); + document = document.WithSyntaxRoot(root); + solution = document.Project.Solution; + + // Rename each property sequentially; find each by its current identifier + // After each rename, the property name changes but the type stays the same + var parameterNames = properties.Select(p => p.ParameterName).ToHashSet(StringComparer.Ordinal); + + foreach (var (propSymbol, paramName, _) in properties) + { + // Get fresh state for each rename + document = solution.GetDocument(document.Id)!; + root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (root is null || semanticModel is null) + continue; + + // Find the class by annotation + var currentClassDecl = root.GetAnnotatedNodes(classAnnotation).OfType().FirstOrDefault(); + if (currentClassDecl is null) + continue; + + // Find the property by original name (propSymbol.Name is the original name) + var currentPropDecl = currentClassDecl.Members + .OfType() + .FirstOrDefault(p => p.Identifier.ValueText == propSymbol.Name); + if (currentPropDecl is null) + continue; + + var currentPropSymbol = semanticModel.GetDeclaredSymbol(currentPropDecl, cancellationToken) as IPropertySymbol; + if (currentPropSymbol is null) + continue; + + // Rename using Renamer + solution = await Renamer.RenameSymbolAsync(solution, currentPropSymbol, new SymbolRenameOptions(), paramName, cancellationToken).ConfigureAwait(false); + } + + // After all renames, apply structural changes to the class + document = solution.GetDocument(document.Id)!; + root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + continue; + + var updatedClassDecl = root.GetAnnotatedNodes(classAnnotation).OfType().FirstOrDefault(); + if (updatedClassDecl is null) + continue; + + // Find all renamed [Inject] properties (now with camelCase identifiers) + var renamedProperties = updatedClassDecl.Members + .OfType() + .Where(p => parameterNames.Contains(p.Identifier.ValueText)) + .ToList(); + + // Build parameter list from original type info (ordered same as diagnostics) + var newParams = properties + .Select(p => Parameter( + List(), + TokenList(), + p.PropertyType, + Identifier(p.ParameterName), + null)) + .ToList(); + + TypeDeclarationSyntax newClassDecl; + if (updatedClassDecl.ParameterList is not null) + { + var existingParams = updatedClassDecl.ParameterList.Parameters; + ParameterListSyntax newParamList; + if (existingParams.Count > 0) + { + var paramsWithSeparator = newParams.Select(p => p.WithLeadingTrivia(Space)); + newParamList = updatedClassDecl.ParameterList.AddParameters([.. paramsWithSeparator]); + } + else + { + newParamList = updatedClassDecl.ParameterList.WithParameters( + SeparatedList(newParams, Enumerable.Repeat(Token(SyntaxKind.CommaToken).WithTrailingTrivia(Space), newParams.Count - 1))); + } + + newClassDecl = updatedClassDecl.WithParameterList(newParamList); + } + else + { + var newParamList = ParameterList( + SeparatedList(newParams, Enumerable.Repeat(Token(SyntaxKind.CommaToken).WithTrailingTrivia(Space), newParams.Count - 1))); + newClassDecl = updatedClassDecl.WithParameterList(newParamList); + } + + // Remove all renamed [Inject] properties + foreach (var renamedProp in renamedProperties) + { + var propToRemove = newClassDecl.Members + .OfType() + .FirstOrDefault(p => p.Identifier.ValueText == renamedProp.Identifier.ValueText); + if (propToRemove is not null) + { + newClassDecl = newClassDecl.RemoveNode(propToRemove, SyntaxRemoveOptions.KeepNoTrivia)!; + } + } + + root = root.ReplaceNode(updatedClassDecl, newClassDecl); + document = document.WithSyntaxRoot(root); + solution = document.Project.Solution; + } + + return solution; + } + + private static async Task GetClassDeclarationAsync(Document document, Solution solution, IPropertySymbol propertySymbol, CancellationToken cancellationToken) + { + var doc = solution.GetDocument(document.Id)!; + var root = await doc.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + return null; + + var semanticModel = await doc.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (semanticModel is null) + return null; + + return root.DescendantNodes().OfType() + .FirstOrDefault(t => + { + var symbol = semanticModel.GetDeclaredSymbol(t, cancellationToken); + return SymbolEqualityComparer.Default.Equals(symbol, propertySymbol.ContainingType); + }); + } + + private static bool HasExplicitNonPrimaryConstructors(TypeDeclarationSyntax typeDeclaration) + { + return typeDeclaration.Members.OfType().Any(); + } + + internal static string ComputeParameterName(string propertyName) + { + if (string.IsNullOrEmpty(propertyName)) + return propertyName; + + var sb = new StringBuilder(propertyName); + sb[0] = char.ToLowerInvariant(sb[0]); + return sb.ToString(); + } +} +#endif diff --git a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig index dc1a96a46..cc2a14297 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig @@ -553,3 +553,6 @@ dotnet_diagnostic.MA0185.severity = suggestion # MA0186: Equals method should use [NotNullWhen(true)] on the parameter dotnet_diagnostic.MA0186.severity = none + +# MA0187: Use constructor injection instead of [Inject] attribute +dotnet_diagnostic.MA0187.severity = none diff --git a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig index 1c12ff8e3..cca6726d6 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig @@ -553,3 +553,6 @@ dotnet_diagnostic.MA0185.severity = none # MA0186: Equals method should use [NotNullWhen(true)] on the parameter dotnet_diagnostic.MA0186.severity = none + +# MA0187: Use constructor injection instead of [Inject] attribute +dotnet_diagnostic.MA0187.severity = none diff --git a/src/Meziantou.Analyzer/Internals/LanguageVersionExtensions.cs b/src/Meziantou.Analyzer/Internals/LanguageVersionExtensions.cs index c20152426..9002e5ddd 100644 --- a/src/Meziantou.Analyzer/Internals/LanguageVersionExtensions.cs +++ b/src/Meziantou.Analyzer/Internals/LanguageVersionExtensions.cs @@ -14,6 +14,11 @@ public static bool IsCSharp11OrAbove(this LanguageVersion languageVersion) return languageVersion >= (LanguageVersion)1100; } + public static bool IsCSharp12OrAbove(this LanguageVersion languageVersion) + { + return languageVersion >= (LanguageVersion)1200; + } + public static bool IsCSharp13OrAbove(this LanguageVersion languageVersion) { return languageVersion >= (LanguageVersion)1300; diff --git a/src/Meziantou.Analyzer/RuleIdentifiers.cs b/src/Meziantou.Analyzer/RuleIdentifiers.cs index 314aa7f8f..95da6fa0a 100755 --- a/src/Meziantou.Analyzer/RuleIdentifiers.cs +++ b/src/Meziantou.Analyzer/RuleIdentifiers.cs @@ -186,6 +186,7 @@ internal static class RuleIdentifiers public const string DoNotUseInterpolatedStringWithoutParameters = "MA0184"; public const string SimplifyStringCreateWhenAllParametersAreCultureInvariant = "MA0185"; public const string MissingNotNullWhenAttributeOnEquals = "MA0186"; + public const string BlazorPropertyInjectionShouldUseConstructorInjection = "MA0187"; public static string GetHelpUri(string identifier) { diff --git a/src/Meziantou.Analyzer/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzer.cs b/src/Meziantou.Analyzer/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzer.cs new file mode 100644 index 000000000..2ded39cfe --- /dev/null +++ b/src/Meziantou.Analyzer/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzer.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Immutable; +using Meziantou.Analyzer.Internals; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Meziantou.Analyzer.Rules; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor Rule = new( + RuleIdentifiers.BlazorPropertyInjectionShouldUseConstructorInjection, + title: "Use constructor injection instead of [Inject] attribute", + messageFormat: "Use constructor injection instead of [Inject] attribute", + RuleCategories.Design, + DiagnosticSeverity.Info, + isEnabledByDefault: false, + description: "", + helpLinkUri: RuleIdentifiers.GetHelpUri(RuleIdentifiers.BlazorPropertyInjectionShouldUseConstructorInjection)); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(ctx => + { + var analyzerContext = new AnalyzerContext(ctx.Compilation); + if (analyzerContext.IsValid) + { + ctx.RegisterSymbolAction(analyzerContext.AnalyzeProperty, SymbolKind.Property); + } + }); + } + + private sealed class AnalyzerContext + { + private static readonly Version Version9 = new(9, 0, 0, 0); + + public AnalyzerContext(Compilation compilation) + { + InjectAttributeSymbol = compilation.GetBestTypeByMetadataName("Microsoft.AspNetCore.Components.InjectAttribute"); + IComponentSymbol = compilation.GetBestTypeByMetadataName("Microsoft.AspNetCore.Components.IComponent"); + } + + public INamedTypeSymbol? InjectAttributeSymbol { get; } + public INamedTypeSymbol? IComponentSymbol { get; } + + public bool IsValid => + InjectAttributeSymbol is not null && + IComponentSymbol is not null && + InjectAttributeSymbol.ContainingAssembly.Identity.Version >= Version9; + + public void AnalyzeProperty(SymbolAnalysisContext context) + { + if (!context.Compilation.GetCSharpLanguageVersion().IsCSharp12OrAbove()) + return; + + var property = (IPropertySymbol)context.Symbol; + + if (!property.HasAttribute(InjectAttributeSymbol, inherits: false)) + return; + + if (!property.ContainingType.IsOrImplements(IComponentSymbol)) + return; + + context.ReportDiagnostic(Rule, property); + } + } +} diff --git a/tests/Meziantou.Analyzer.Test/Helpers/ProjectBuilder.Validation.cs b/tests/Meziantou.Analyzer.Test/Helpers/ProjectBuilder.Validation.cs index eabba2696..828b63f29 100755 --- a/tests/Meziantou.Analyzer.Test/Helpers/ProjectBuilder.Validation.cs +++ b/tests/Meziantou.Analyzer.Test/Helpers/ProjectBuilder.Validation.cs @@ -223,6 +223,11 @@ private Task CreateProject() AddNuGetReference("Microsoft.AspNetCore.App.Ref", "8.0.0", "ref/net8.0/"); break; + case TargetFramework.AspNetCore9_0: + AddNuGetReference("Microsoft.NETCore.App.Ref", "9.0.0", "ref/net9.0/"); + AddNuGetReference("Microsoft.AspNetCore.App.Ref", "9.0.0", "ref/net9.0/"); + break; + case TargetFramework.WindowsDesktop5_0: AddNuGetReference("Microsoft.WindowsDesktop.App.Ref", "5.0.0", "ref/net5.0/"); break; @@ -242,6 +247,10 @@ private Task CreateProject() WithSourceGeneratorsFromNuGet("Microsoft.NETCore.App.Ref", "8.0.0", "analyzers/dotnet/cs/"); break; + case TargetFramework.AspNetCore9_0: + WithSourceGeneratorsFromNuGet("Microsoft.NETCore.App.Ref", "9.0.0", "analyzers/dotnet/cs/"); + break; + case TargetFramework.Net9_0: WithSourceGeneratorsFromNuGet("Microsoft.NETCore.App.Ref", "9.0.0", "analyzers/dotnet/cs/"); break; @@ -253,7 +262,7 @@ private Task CreateProject() } - if (TargetFramework is not TargetFramework.Net7_0 and not TargetFramework.Net8_0 and not TargetFramework.Net9_0 and not TargetFramework.Net10_0) + if (TargetFramework is not TargetFramework.Net7_0 and not TargetFramework.Net8_0 and not TargetFramework.Net9_0 and not TargetFramework.Net10_0 and not TargetFramework.AspNetCore9_0) { AddNuGetReference("System.Collections.Immutable", "1.5.0", "lib/netstandard2.0/"); AddNuGetReference("System.Numerics.Vectors", "4.5.0", "ref/netstandard2.0/"); diff --git a/tests/Meziantou.Analyzer.Test/Helpers/TargetFramework.cs b/tests/Meziantou.Analyzer.Test/Helpers/TargetFramework.cs index 001971195..29c3364dd 100644 --- a/tests/Meziantou.Analyzer.Test/Helpers/TargetFramework.cs +++ b/tests/Meziantou.Analyzer.Test/Helpers/TargetFramework.cs @@ -17,5 +17,6 @@ public enum TargetFramework AspNetCore6_0, AspNetCore7_0, AspNetCore8_0, + AspNetCore9_0, WindowsDesktop5_0, } diff --git a/tests/Meziantou.Analyzer.Test/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzerTests.cs new file mode 100644 index 000000000..eb80c0d8c --- /dev/null +++ b/tests/Meziantou.Analyzer.Test/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzerTests.cs @@ -0,0 +1,258 @@ +#if CSHARP12_OR_GREATER +using Meziantou.Analyzer.Rules; +using Meziantou.Analyzer.Test.Helpers; +using Microsoft.CodeAnalysis.CSharp; +using TestHelper; + +namespace Meziantou.Analyzer.Test.Rules; + +public sealed class BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzerTests +{ + private static ProjectBuilder CreateProjectBuilder() + { + return new ProjectBuilder() + .WithAnalyzer() + .WithCodeFixProvider() + .WithTargetFramework(TargetFramework.AspNetCore9_0) + .WithLanguageVersion(LanguageVersion.CSharp12); + } + + [Fact] + public async Task InjectProperty_IComponent_ReportsDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" +using Microsoft.AspNetCore.Components; + +class MyComponent : IComponent +{ + [Inject] + protected NavigationManager [||]Navigation { get; set; } = default!; + + public void Attach(RenderHandle renderHandle) { } + public System.Threading.Tasks.Task SetParametersAsync(ParameterView parameters) => System.Threading.Tasks.Task.CompletedTask; +} +""") + .ShouldFixCodeWith(""" +using Microsoft.AspNetCore.Components; + +class MyComponent(NavigationManager navigation) : IComponent +{ + + public void Attach(RenderHandle renderHandle) { } + public System.Threading.Tasks.Task SetParametersAsync(ParameterView parameters) => System.Threading.Tasks.Task.CompletedTask; +} +""") + .ValidateAsync(); + } + + [Fact] + public async Task InjectProperty_ComponentBase_ReportsDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" +using Microsoft.AspNetCore.Components; + +class MyComponent : ComponentBase +{ + [Inject] + protected NavigationManager [||]Navigation { get; set; } = default!; +} +""") + .ShouldFixCodeWith(""" +using Microsoft.AspNetCore.Components; + +class MyComponent(NavigationManager navigation) : ComponentBase +{ +} +""") + .ValidateAsync(); + } + + [Fact] + public async Task InjectProperty_ExistingPrimaryConstructor_AddsParameter() + { + await CreateProjectBuilder() + .WithSourceCode(""" +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; + +class MyComponent(ILogger logger) : ComponentBase +{ + [Inject] + protected NavigationManager [||]Navigation { get; set; } = default!; +} +""") + .ShouldFixCodeWith(""" +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; + +class MyComponent(ILogger logger, NavigationManager navigation) : ComponentBase +{ +} +""") + .ValidateAsync(); + } + + [Fact] + public async Task InjectProperty_WithExplicitConstructor_NoDiagnosticFix() + { + // Analyzer still reports, but no code fix when explicit non-primary constructor exists + await CreateProjectBuilder() + .WithSourceCode(""" +using Microsoft.AspNetCore.Components; + +class MyComponent : ComponentBase +{ + public MyComponent() { } + + [Inject] + protected NavigationManager [||]Navigation { get; set; } = default!; +} +""") + .ValidateAsync(); + } + + [Fact] + public async Task NoInjectAttribute_NoDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" +using Microsoft.AspNetCore.Components; + +class MyComponent : ComponentBase +{ + protected NavigationManager Navigation { get; set; } = default!; +} +""") + .ValidateAsync(); + } + + [Fact] + public async Task InjectProperty_NotBlazorComponent_NoDiagnostic() + { + await CreateProjectBuilder() + .WithSourceCode(""" +using Microsoft.AspNetCore.Components; + +class NotAComponent +{ + [Inject] + protected NavigationManager Navigation { get; set; } = default!; +} +""") + .ValidateAsync(); + } + + [Fact] + public async Task InjectProperty_CSharp11_NoDiagnostic() + { + await new ProjectBuilder() + .WithAnalyzer() + .WithTargetFramework(TargetFramework.AspNetCore9_0) + .WithLanguageVersion(LanguageVersion.CSharp11) + .WithSourceCode(""" +using Microsoft.AspNetCore.Components; + +class MyComponent : ComponentBase +{ + [Inject] + protected NavigationManager Navigation { get; set; } = default!; +} +""") + .ValidateAsync(); + } + + [Fact] + public async Task InjectProperty_AspNetCore8_NoDiagnostic() + { + await new ProjectBuilder() + .WithAnalyzer() + .WithTargetFramework(TargetFramework.AspNetCore8_0) + .WithLanguageVersion(LanguageVersion.CSharp12) + .WithSourceCode(""" +using Microsoft.AspNetCore.Components; + +class MyComponent : ComponentBase +{ + [Inject] + protected NavigationManager Navigation { get; set; } = default!; +} +""") + .ValidateAsync(); + } + + [Fact] + public async Task InjectProperty_UpdatesUsages() + { + await CreateProjectBuilder() + .WithSourceCode(""" +using Microsoft.AspNetCore.Components; + +class MyComponent : ComponentBase +{ + [Inject] + protected NavigationManager [||]Navigation { get; set; } = default!; + + private void HandleClick() + { + Navigation.NavigateTo("/counter"); + } +} +""") + .ShouldFixCodeWith(""" +using Microsoft.AspNetCore.Components; + +class MyComponent(NavigationManager navigation) : ComponentBase +{ + + private void HandleClick() + { + navigation.NavigateTo("/counter"); + } +} +""") + .ValidateAsync(); + } + + [Fact] + public async Task MultipleInjectProperties_BatchFix() + { + await CreateProjectBuilder() + .WithSourceCode(""" +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; + +class MyComponent : ComponentBase +{ + [Inject] + protected NavigationManager [||]Navigation { get; set; } = default!; + + [Inject] + protected ILogger [||]Logger { get; set; } = default!; + + private void HandleClick() + { + Navigation.NavigateTo("/counter"); + Logger.LogInformation("Clicked"); + } +} +""") + .ShouldBatchFixCodeWith(""" +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; + +class MyComponent(NavigationManager navigation, ILogger logger) : ComponentBase +{ + + private void HandleClick() + { + navigation.NavigateTo("/counter"); + logger.LogInformation("Clicked"); + } +} +""") + .ValidateAsync(); + } +} +#endif