From da98dcaf4daf854b282682df626e165101822223 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 02:55:10 +0000 Subject: [PATCH 1/5] Initial plan From b208f0afbec2a792c81f7b88afc5f707227af688 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 03:27:56 +0000 Subject: [PATCH 2/5] feat: Add MA0187 - Use constructor injection instead of [Inject] attribute in Blazor components Co-authored-by: meziantou <509220+meziantou@users.noreply.github.com> --- README.md | 1 + docs/README.md | 7 + docs/Rules/MA0187.md | 42 +++ .../BlazorPropertyInjectionFixAllProvider.cs | 63 +++++ ...ctionShouldUseConstructorInjectionFixer.cs | 235 ++++++++++++++++ .../configuration/default.editorconfig | 3 + .../configuration/none.editorconfig | 3 + .../Internals/LanguageVersionExtensions.cs | 5 + src/Meziantou.Analyzer/RuleIdentifiers.cs | 1 + ...onShouldUseConstructorInjectionAnalyzer.cs | 74 +++++ .../Helpers/ProjectBuilder.Validation.cs | 11 +- .../Helpers/TargetFramework.cs | 1 + ...uldUseConstructorInjectionAnalyzerTests.cs | 256 ++++++++++++++++++ 13 files changed, 701 insertions(+), 1 deletion(-) create mode 100644 docs/Rules/MA0187.md create mode 100644 src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs create mode 100644 src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionFixer.cs create mode 100644 src/Meziantou.Analyzer/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzer.cs create mode 100644 tests/Meziantou.Analyzer.Test/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzerTests.cs diff --git a/README.md b/README.md index d9f479fc9..7387c1f5e 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..6a954814d 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 = suggestion ``` # .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..05eb597b4 --- /dev/null +++ b/docs/Rules/MA0187.md @@ -0,0 +1,42 @@ +# 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"); + } +} +``` diff --git a/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs b/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs new file mode 100644 index 000000000..f9e0a7641 --- /dev/null +++ b/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs @@ -0,0 +1,63 @@ +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, + [.. documentDiagnostics], + cancellationToken).ConfigureAwait(false); + } + + return solution; + } +} diff --git a/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionFixer.cs b/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionFixer.cs new file mode 100644 index 000000000..e6d2114af --- /dev/null +++ b/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionFixer.cs @@ -0,0 +1,235 @@ +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(); + } +} diff --git a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig index dc1a96a46..b1307e84a 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 = suggestion 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..a4b3ac030 100644 --- a/src/Meziantou.Analyzer/Internals/LanguageVersionExtensions.cs +++ b/src/Meziantou.Analyzer/Internals/LanguageVersionExtensions.cs @@ -24,6 +24,11 @@ public static bool IsCSharp14OrAbove(this LanguageVersion languageVersion) return languageVersion >= (LanguageVersion)1400; } + public static bool IsCSharp12OrAbove(this LanguageVersion languageVersion) + { + return languageVersion >= LanguageVersion.CSharp12; + } + public static bool IsCSharp8OrAbove(this LanguageVersion languageVersion) { return languageVersion >= LanguageVersion.CSharp8; 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..5bfa371e1 --- /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: true, + 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() < LanguageVersion.CSharp12) + 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..a6b07a2db --- /dev/null +++ b/tests/Meziantou.Analyzer.Test/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzerTests.cs @@ -0,0 +1,256 @@ +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(); + } +} From 9779b7919b498379b293cc73682a7059f599e6ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 04:19:55 +0000 Subject: [PATCH 3/5] Address review feedback: move IsCSharp12OrAbove, use numeric version, disable by default Co-authored-by: meziantou <509220+meziantou@users.noreply.github.com> --- README.md | 2 +- docs/README.md | 4 ++-- docs/Rules/MA0187.md | 9 +++++++++ .../configuration/default.editorconfig | 2 +- .../Internals/LanguageVersionExtensions.cs | 10 +++++----- ...tyInjectionShouldUseConstructorInjectionAnalyzer.cs | 4 ++-- 6 files changed, 20 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7387c1f5e..9d50f72b3 100755 --- a/README.md +++ b/README.md @@ -201,7 +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|â„šī¸|âœ”ī¸|âœ”ī¸| +|[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 6a954814d..832ad560f 100755 --- a/docs/README.md +++ b/docs/README.md @@ -185,7 +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|â„šī¸|âœ”ī¸|âœ”ī¸| +|[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| |--|---------------|-------------| @@ -758,7 +758,7 @@ dotnet_diagnostic.MA0185.severity = suggestion dotnet_diagnostic.MA0186.severity = none # MA0187: Use constructor injection instead of [Inject] attribute -dotnet_diagnostic.MA0187.severity = suggestion +dotnet_diagnostic.MA0187.severity = none ``` # .editorconfig - all rules disabled diff --git a/docs/Rules/MA0187.md b/docs/Rules/MA0187.md index 05eb597b4..ff0ab5461 100644 --- a/docs/Rules/MA0187.md +++ b/docs/Rules/MA0187.md @@ -40,3 +40,12 @@ class MyComponent(NavigationManager navigation) : ComponentBase } } ``` + +## 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.Pack/configuration/default.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig index b1307e84a..cc2a14297 100644 --- a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig +++ b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig @@ -555,4 +555,4 @@ dotnet_diagnostic.MA0185.severity = suggestion dotnet_diagnostic.MA0186.severity = none # MA0187: Use constructor injection instead of [Inject] attribute -dotnet_diagnostic.MA0187.severity = suggestion +dotnet_diagnostic.MA0187.severity = none diff --git a/src/Meziantou.Analyzer/Internals/LanguageVersionExtensions.cs b/src/Meziantou.Analyzer/Internals/LanguageVersionExtensions.cs index a4b3ac030..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; @@ -24,11 +29,6 @@ public static bool IsCSharp14OrAbove(this LanguageVersion languageVersion) return languageVersion >= (LanguageVersion)1400; } - public static bool IsCSharp12OrAbove(this LanguageVersion languageVersion) - { - return languageVersion >= LanguageVersion.CSharp12; - } - public static bool IsCSharp8OrAbove(this LanguageVersion languageVersion) { return languageVersion >= LanguageVersion.CSharp8; diff --git a/src/Meziantou.Analyzer/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzer.cs b/src/Meziantou.Analyzer/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzer.cs index 5bfa371e1..2ded39cfe 100644 --- a/src/Meziantou.Analyzer/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzer.cs +++ b/src/Meziantou.Analyzer/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzer.cs @@ -16,7 +16,7 @@ public sealed class BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzer messageFormat: "Use constructor injection instead of [Inject] attribute", RuleCategories.Design, DiagnosticSeverity.Info, - isEnabledByDefault: true, + isEnabledByDefault: false, description: "", helpLinkUri: RuleIdentifiers.GetHelpUri(RuleIdentifiers.BlazorPropertyInjectionShouldUseConstructorInjection)); @@ -57,7 +57,7 @@ IComponentSymbol is not null && public void AnalyzeProperty(SymbolAnalysisContext context) { - if (context.Compilation.GetCSharpLanguageVersion() < LanguageVersion.CSharp12) + if (!context.Compilation.GetCSharpLanguageVersion().IsCSharp12OrAbove()) return; var property = (IPropertySymbol)context.Symbol; From 414314851b7a9adb62ca83fde3225532b63ac2b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 04:31:02 +0000 Subject: [PATCH 4/5] Fix CI: wrap fixer files with #if CSHARP12_OR_GREATER for Roslyn version compatibility Co-authored-by: meziantou <509220+meziantou@users.noreply.github.com> --- .../Rules/BlazorPropertyInjectionFixAllProvider.cs | 2 ++ ...BlazorPropertyInjectionShouldUseConstructorInjectionFixer.cs | 2 ++ ...opertyInjectionShouldUseConstructorInjectionAnalyzerTests.cs | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs b/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs index f9e0a7641..48c2d7e57 100644 --- a/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs +++ b/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs @@ -1,3 +1,4 @@ +#if CSHARP12_OR_GREATER using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; @@ -61,3 +62,4 @@ private static async Task FixAllAsync(FixAllContext fixAllContext, Imm return solution; } } +#endif diff --git a/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionFixer.cs b/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionFixer.cs index e6d2114af..201a429d4 100644 --- a/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionFixer.cs +++ b/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionFixer.cs @@ -1,3 +1,4 @@ +#if CSHARP12_OR_GREATER using System.Collections.Immutable; using System.Composition; using System.Text; @@ -233,3 +234,4 @@ internal static string ComputeParameterName(string propertyName) return sb.ToString(); } } +#endif diff --git a/tests/Meziantou.Analyzer.Test/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzerTests.cs index a6b07a2db..eb80c0d8c 100644 --- a/tests/Meziantou.Analyzer.Test/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzerTests.cs +++ b/tests/Meziantou.Analyzer.Test/Rules/BlazorPropertyInjectionShouldUseConstructorInjectionAnalyzerTests.cs @@ -1,3 +1,4 @@ +#if CSHARP12_OR_GREATER using Meziantou.Analyzer.Rules; using Meziantou.Analyzer.Test.Helpers; using Microsoft.CodeAnalysis.CSharp; @@ -254,3 +255,4 @@ private void HandleClick() .ValidateAsync(); } } +#endif From b5107669cb5cedca36b383e77f578b73bc7d1534 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 04:40:01 +0000 Subject: [PATCH 5/5] Fix CI roslyn4.8: replace collection expression with ImmutableArray.CreateRange Co-authored-by: meziantou <509220+meziantou@users.noreply.github.com> --- .../Rules/BlazorPropertyInjectionFixAllProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs b/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs index 48c2d7e57..b14439060 100644 --- a/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs +++ b/src/Meziantou.Analyzer.CodeFixers/Rules/BlazorPropertyInjectionFixAllProvider.cs @@ -55,7 +55,7 @@ private static async Task FixAllAsync(FixAllContext fixAllContext, Imm solution = await BlazorPropertyInjectionShouldUseConstructorInjectionFixer.FixDocumentAsync( document, - [.. documentDiagnostics], + ImmutableArray.CreateRange(documentDiagnostics), cancellationToken).ConfigureAwait(false); }