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