Skip to content

Commit 33557be

Browse files
authored
MA0187: Migrate Blazor [Inject] property injection to constructor injection (#1048)
1 parent 0e63c84 commit 33557be

File tree

13 files changed

+716
-1
lines changed

13 files changed

+716
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ If you are already using other analyzers, you can check [which rules are duplica
201201
|[MA0184](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0184.md)|Style|Do not use interpolated string without parameters|👻|✔️|✔️|
202202
|[MA0185](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0185.md)|Performance|Simplify string.Create when all parameters are culture invariant|ℹ️|✔️|✔️|
203203
|[MA0186](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0186.md)|Design|Equals method should use \[NotNullWhen(true)\] on the parameter|ℹ️|||
204+
|[MA0187](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0187.md)|Design|Use constructor injection instead of \[Inject\] attribute|ℹ️||✔️|
204205

205206
<!-- rules -->
206207

docs/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@
185185
|[MA0184](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0184.md)|Style|Do not use interpolated string without parameters|<span title='Hidden'>👻</span>|✔️|✔️|
186186
|[MA0185](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0185.md)|Performance|Simplify string.Create when all parameters are culture invariant|<span title='Info'>ℹ️</span>|✔️|✔️|
187187
|[MA0186](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0186.md)|Design|Equals method should use \[NotNullWhen(true)\] on the parameter|<span title='Info'>ℹ️</span>|||
188+
|[MA0187](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0187.md)|Design|Use constructor injection instead of \[Inject\] attribute|<span title='Info'>ℹ️</span>||✔️|
188189

189190
|Id|Suppressed rule|Justification|
190191
|--|---------------|-------------|
@@ -755,6 +756,9 @@ dotnet_diagnostic.MA0185.severity = suggestion
755756
756757
# MA0186: Equals method should use [NotNullWhen(true)] on the parameter
757758
dotnet_diagnostic.MA0186.severity = none
759+
760+
# MA0187: Use constructor injection instead of [Inject] attribute
761+
dotnet_diagnostic.MA0187.severity = none
758762
```
759763

760764
# .editorconfig - all rules disabled
@@ -1311,4 +1315,7 @@ dotnet_diagnostic.MA0185.severity = none
13111315
13121316
# MA0186: Equals method should use [NotNullWhen(true)] on the parameter
13131317
dotnet_diagnostic.MA0186.severity = none
1318+
1319+
# MA0187: Use constructor injection instead of [Inject] attribute
1320+
dotnet_diagnostic.MA0187.severity = none
13141321
```

docs/Rules/MA0187.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# MA0187 - Use constructor injection instead of \[Inject\] attribute
2+
<!-- sources -->
3+
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)
4+
<!-- sources -->
5+
6+
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.
7+
8+
This rule only applies when:
9+
- The ASP.NET Core version is 9.0 or greater
10+
- The C# language version is 12 or greater (required for primary constructors)
11+
- The class does not have explicit non-primary constructors
12+
13+
## Non-compliant code
14+
15+
```csharp
16+
using Microsoft.AspNetCore.Components;
17+
18+
class MyComponent : ComponentBase
19+
{
20+
[Inject]
21+
protected NavigationManager Navigation { get; set; } = default!;
22+
23+
private void HandleClick()
24+
{
25+
Navigation.NavigateTo("/counter");
26+
}
27+
}
28+
```
29+
30+
## Compliant code
31+
32+
```csharp
33+
using Microsoft.AspNetCore.Components;
34+
35+
class MyComponent(NavigationManager navigation) : ComponentBase
36+
{
37+
private void HandleClick()
38+
{
39+
navigation.NavigateTo("/counter");
40+
}
41+
}
42+
```
43+
44+
## Configuration
45+
46+
This rule is disabled by default. You can enable it by setting the severity in your `.editorconfig` file:
47+
48+
```editorconfig
49+
# MA0187: Use constructor injection instead of [Inject] attribute
50+
dotnet_diagnostic.MA0187.severity = suggestion
51+
```
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#if CSHARP12_OR_GREATER
2+
using System.Collections.Immutable;
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CodeActions;
5+
using Microsoft.CodeAnalysis.CodeFixes;
6+
7+
namespace Meziantou.Analyzer.Rules;
8+
9+
internal sealed class BlazorPropertyInjectionFixAllProvider : FixAllProvider
10+
{
11+
public static readonly BlazorPropertyInjectionFixAllProvider Instance = new();
12+
13+
public override async Task<CodeAction?> GetFixAsync(FixAllContext fixAllContext)
14+
{
15+
var diagnosticsToFix = await fixAllContext.GetAllDiagnosticsAsync(fixAllContext.Project).ConfigureAwait(false);
16+
if (diagnosticsToFix.IsEmpty)
17+
return null;
18+
19+
return CodeAction.Create(
20+
"Use constructor injection",
21+
ct => FixAllAsync(fixAllContext, diagnosticsToFix, ct),
22+
equivalenceKey: "Use constructor injection");
23+
}
24+
25+
private static async Task<Solution> FixAllAsync(FixAllContext fixAllContext, ImmutableArray<Diagnostic> diagnostics, CancellationToken cancellationToken)
26+
{
27+
var solution = fixAllContext.Project.Solution;
28+
29+
// Group diagnostics by document
30+
var diagnosticsByDocument = new Dictionary<DocumentId, List<Diagnostic>>();
31+
foreach (var diagnostic in diagnostics)
32+
{
33+
if (diagnostic.Location.IsInSource)
34+
{
35+
var document = solution.GetDocument(diagnostic.Location.SourceTree);
36+
if (document is not null)
37+
{
38+
if (!diagnosticsByDocument.TryGetValue(document.Id, out var list))
39+
{
40+
list = [];
41+
diagnosticsByDocument[document.Id] = list;
42+
}
43+
44+
list.Add(diagnostic);
45+
}
46+
}
47+
}
48+
49+
// Process each document
50+
foreach (var (documentId, documentDiagnostics) in diagnosticsByDocument)
51+
{
52+
var document = solution.GetDocument(documentId);
53+
if (document is null)
54+
continue;
55+
56+
solution = await BlazorPropertyInjectionShouldUseConstructorInjectionFixer.FixDocumentAsync(
57+
document,
58+
ImmutableArray.CreateRange(documentDiagnostics),
59+
cancellationToken).ConfigureAwait(false);
60+
}
61+
62+
return solution;
63+
}
64+
}
65+
#endif
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
#if CSHARP12_OR_GREATER
2+
using System.Collections.Immutable;
3+
using System.Composition;
4+
using System.Text;
5+
using Meziantou.Analyzer.Internals;
6+
using Microsoft.CodeAnalysis;
7+
using Microsoft.CodeAnalysis.CodeActions;
8+
using Microsoft.CodeAnalysis.CodeFixes;
9+
using Microsoft.CodeAnalysis.CSharp;
10+
using Microsoft.CodeAnalysis.CSharp.Syntax;
11+
using Microsoft.CodeAnalysis.Rename;
12+
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
13+
14+
namespace Meziantou.Analyzer.Rules;
15+
16+
[ExportCodeFixProvider(LanguageNames.CSharp), Shared]
17+
public sealed class BlazorPropertyInjectionShouldUseConstructorInjectionFixer : CodeFixProvider
18+
{
19+
public override ImmutableArray<string> FixableDiagnosticIds =>
20+
ImmutableArray.Create(RuleIdentifiers.BlazorPropertyInjectionShouldUseConstructorInjection);
21+
22+
public override FixAllProvider GetFixAllProvider() =>
23+
BlazorPropertyInjectionFixAllProvider.Instance;
24+
25+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
26+
{
27+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
28+
var nodeToFix = root?.FindNode(context.Span, getInnermostNodeForTie: true);
29+
if (nodeToFix is null)
30+
return;
31+
32+
var title = "Use constructor injection";
33+
context.RegisterCodeFix(
34+
CodeAction.Create(
35+
title,
36+
ct => FixDocumentAsync(context.Document, context.Diagnostics, ct),
37+
equivalenceKey: title),
38+
context.Diagnostics);
39+
}
40+
41+
internal static async Task<Solution> FixDocumentAsync(Document document, ImmutableArray<Diagnostic> diagnostics, CancellationToken cancellationToken)
42+
{
43+
var solution = document.Project.Solution;
44+
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
45+
var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
46+
if (root is null || semanticModel is null)
47+
return solution;
48+
49+
// Collect all (property symbol, parameter name) pairs from the diagnostics
50+
var propertiesToFix = new List<(IPropertySymbol Symbol, string ParameterName, TypeSyntax PropertyType)>();
51+
foreach (var diagnostic in diagnostics)
52+
{
53+
var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true);
54+
var propertyDecl = node?.AncestorsAndSelf().OfType<PropertyDeclarationSyntax>().FirstOrDefault();
55+
if (propertyDecl is null)
56+
continue;
57+
58+
var propertySymbol = semanticModel.GetDeclaredSymbol(propertyDecl, cancellationToken) as IPropertySymbol;
59+
if (propertySymbol is null)
60+
continue;
61+
62+
var classDecl = propertyDecl.Ancestors().OfType<TypeDeclarationSyntax>().FirstOrDefault();
63+
if (classDecl is null || HasExplicitNonPrimaryConstructors(classDecl))
64+
continue;
65+
66+
var parameterName = ComputeParameterName(propertySymbol.Name);
67+
propertiesToFix.Add((propertySymbol, parameterName, propertyDecl.Type.WithoutTrivia()));
68+
}
69+
70+
if (propertiesToFix.Count == 0)
71+
return solution;
72+
73+
// Group by containing class (to handle multiple properties in the same class)
74+
var byClass = propertiesToFix.GroupBy(p => p.Symbol.ContainingType, SymbolEqualityComparer.Default).ToList();
75+
76+
foreach (var classGroup in byClass)
77+
{
78+
var properties = classGroup.ToList();
79+
var firstClassDecl = await GetClassDeclarationAsync(document, solution, properties[0].Symbol, cancellationToken).ConfigureAwait(false);
80+
if (firstClassDecl is null)
81+
continue;
82+
83+
// Annotate the class so we can find it after all renames
84+
document = solution.GetDocument(document.Id)!;
85+
root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
86+
if (root is null)
87+
continue;
88+
89+
var classAnnotation = new SyntaxAnnotation();
90+
var classDeclNode = root.DescendantNodesAndSelf().OfType<TypeDeclarationSyntax>()
91+
.FirstOrDefault(t => t.Identifier.ValueText == firstClassDecl.Identifier.ValueText);
92+
if (classDeclNode is null)
93+
continue;
94+
95+
root = root.ReplaceNode(classDeclNode, classDeclNode.WithAdditionalAnnotations(classAnnotation));
96+
document = document.WithSyntaxRoot(root);
97+
solution = document.Project.Solution;
98+
99+
// Rename each property sequentially; find each by its current identifier
100+
// After each rename, the property name changes but the type stays the same
101+
var parameterNames = properties.Select(p => p.ParameterName).ToHashSet(StringComparer.Ordinal);
102+
103+
foreach (var (propSymbol, paramName, _) in properties)
104+
{
105+
// Get fresh state for each rename
106+
document = solution.GetDocument(document.Id)!;
107+
root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
108+
semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
109+
if (root is null || semanticModel is null)
110+
continue;
111+
112+
// Find the class by annotation
113+
var currentClassDecl = root.GetAnnotatedNodes(classAnnotation).OfType<TypeDeclarationSyntax>().FirstOrDefault();
114+
if (currentClassDecl is null)
115+
continue;
116+
117+
// Find the property by original name (propSymbol.Name is the original name)
118+
var currentPropDecl = currentClassDecl.Members
119+
.OfType<PropertyDeclarationSyntax>()
120+
.FirstOrDefault(p => p.Identifier.ValueText == propSymbol.Name);
121+
if (currentPropDecl is null)
122+
continue;
123+
124+
var currentPropSymbol = semanticModel.GetDeclaredSymbol(currentPropDecl, cancellationToken) as IPropertySymbol;
125+
if (currentPropSymbol is null)
126+
continue;
127+
128+
// Rename using Renamer
129+
solution = await Renamer.RenameSymbolAsync(solution, currentPropSymbol, new SymbolRenameOptions(), paramName, cancellationToken).ConfigureAwait(false);
130+
}
131+
132+
// After all renames, apply structural changes to the class
133+
document = solution.GetDocument(document.Id)!;
134+
root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
135+
if (root is null)
136+
continue;
137+
138+
var updatedClassDecl = root.GetAnnotatedNodes(classAnnotation).OfType<TypeDeclarationSyntax>().FirstOrDefault();
139+
if (updatedClassDecl is null)
140+
continue;
141+
142+
// Find all renamed [Inject] properties (now with camelCase identifiers)
143+
var renamedProperties = updatedClassDecl.Members
144+
.OfType<PropertyDeclarationSyntax>()
145+
.Where(p => parameterNames.Contains(p.Identifier.ValueText))
146+
.ToList();
147+
148+
// Build parameter list from original type info (ordered same as diagnostics)
149+
var newParams = properties
150+
.Select(p => Parameter(
151+
List<AttributeListSyntax>(),
152+
TokenList(),
153+
p.PropertyType,
154+
Identifier(p.ParameterName),
155+
null))
156+
.ToList();
157+
158+
TypeDeclarationSyntax newClassDecl;
159+
if (updatedClassDecl.ParameterList is not null)
160+
{
161+
var existingParams = updatedClassDecl.ParameterList.Parameters;
162+
ParameterListSyntax newParamList;
163+
if (existingParams.Count > 0)
164+
{
165+
var paramsWithSeparator = newParams.Select(p => p.WithLeadingTrivia(Space));
166+
newParamList = updatedClassDecl.ParameterList.AddParameters([.. paramsWithSeparator]);
167+
}
168+
else
169+
{
170+
newParamList = updatedClassDecl.ParameterList.WithParameters(
171+
SeparatedList(newParams, Enumerable.Repeat(Token(SyntaxKind.CommaToken).WithTrailingTrivia(Space), newParams.Count - 1)));
172+
}
173+
174+
newClassDecl = updatedClassDecl.WithParameterList(newParamList);
175+
}
176+
else
177+
{
178+
var newParamList = ParameterList(
179+
SeparatedList(newParams, Enumerable.Repeat(Token(SyntaxKind.CommaToken).WithTrailingTrivia(Space), newParams.Count - 1)));
180+
newClassDecl = updatedClassDecl.WithParameterList(newParamList);
181+
}
182+
183+
// Remove all renamed [Inject] properties
184+
foreach (var renamedProp in renamedProperties)
185+
{
186+
var propToRemove = newClassDecl.Members
187+
.OfType<PropertyDeclarationSyntax>()
188+
.FirstOrDefault(p => p.Identifier.ValueText == renamedProp.Identifier.ValueText);
189+
if (propToRemove is not null)
190+
{
191+
newClassDecl = newClassDecl.RemoveNode(propToRemove, SyntaxRemoveOptions.KeepNoTrivia)!;
192+
}
193+
}
194+
195+
root = root.ReplaceNode(updatedClassDecl, newClassDecl);
196+
document = document.WithSyntaxRoot(root);
197+
solution = document.Project.Solution;
198+
}
199+
200+
return solution;
201+
}
202+
203+
private static async Task<TypeDeclarationSyntax?> GetClassDeclarationAsync(Document document, Solution solution, IPropertySymbol propertySymbol, CancellationToken cancellationToken)
204+
{
205+
var doc = solution.GetDocument(document.Id)!;
206+
var root = await doc.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
207+
if (root is null)
208+
return null;
209+
210+
var semanticModel = await doc.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
211+
if (semanticModel is null)
212+
return null;
213+
214+
return root.DescendantNodes().OfType<TypeDeclarationSyntax>()
215+
.FirstOrDefault(t =>
216+
{
217+
var symbol = semanticModel.GetDeclaredSymbol(t, cancellationToken);
218+
return SymbolEqualityComparer.Default.Equals(symbol, propertySymbol.ContainingType);
219+
});
220+
}
221+
222+
private static bool HasExplicitNonPrimaryConstructors(TypeDeclarationSyntax typeDeclaration)
223+
{
224+
return typeDeclaration.Members.OfType<ConstructorDeclarationSyntax>().Any();
225+
}
226+
227+
internal static string ComputeParameterName(string propertyName)
228+
{
229+
if (string.IsNullOrEmpty(propertyName))
230+
return propertyName;
231+
232+
var sb = new StringBuilder(propertyName);
233+
sb[0] = char.ToLowerInvariant(sb[0]);
234+
return sb.ToString();
235+
}
236+
}
237+
#endif

src/Meziantou.Analyzer.Pack/configuration/default.editorconfig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,3 +553,6 @@ dotnet_diagnostic.MA0185.severity = suggestion
553553

554554
# MA0186: Equals method should use [NotNullWhen(true)] on the parameter
555555
dotnet_diagnostic.MA0186.severity = none
556+
557+
# MA0187: Use constructor injection instead of [Inject] attribute
558+
dotnet_diagnostic.MA0187.severity = none

0 commit comments

Comments
 (0)