From a6dd615dfbff8b6240eb164355156b6ae277aee9 Mon Sep 17 00:00:00 2001 From: Liam Thompson Date: Fri, 27 Feb 2026 14:01:31 +1000 Subject: [PATCH 1/2] Introduce AOM202 diagnostic, analyzer, and code fix to enforce the use of `nameof()` over string literals. --- .../Attrbutes/ForMemberAttribute.cs | 2 +- .../Attrbutes/MapToAttribute.cs | 2 +- .../Attrbutes/PreferNameOfAttribute.cs | 11 ++ src/AotObjectMapper.Mapper/AOMDiagnostics.cs | 4 +- .../AnalyzerReleases.Shipped.md | 13 ++- .../AnalyzerReleases.Unshipped.md | 1 - .../Analyzers/PreferNameOfAnalyzer.cs | 106 ++++++++++++++++++ .../CodeFixes/Aom202Fix.cs | 69 ++++++++++++ 8 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 src/AotObjectMapper.Abstractions/Attrbutes/PreferNameOfAttribute.cs create mode 100644 src/AotObjectMapper.Mapper/Analyzers/PreferNameOfAnalyzer.cs create mode 100644 src/AotObjectMapper.Mapper/CodeFixes/Aom202Fix.cs diff --git a/src/AotObjectMapper.Abstractions/Attrbutes/ForMemberAttribute.cs b/src/AotObjectMapper.Abstractions/Attrbutes/ForMemberAttribute.cs index 5604bd6..8ec2779 100644 --- a/src/AotObjectMapper.Abstractions/Attrbutes/ForMemberAttribute.cs +++ b/src/AotObjectMapper.Abstractions/Attrbutes/ForMemberAttribute.cs @@ -7,7 +7,7 @@ /// The source type from which the mapping originates. /// The destination type to which the mapping is applied. [AttributeUsage(AttributeTargets.Method)] -public sealed class ForMemberAttribute(string memberName) : Attribute +public sealed class ForMemberAttribute([PreferNameOf(nameof(TDestination))] string memberName) : Attribute { /// Gets the name of the specific member that is the target of the mapping transformation. public string MemberName { get; } = memberName; diff --git a/src/AotObjectMapper.Abstractions/Attrbutes/MapToAttribute.cs b/src/AotObjectMapper.Abstractions/Attrbutes/MapToAttribute.cs index 2d98f28..beaa037 100644 --- a/src/AotObjectMapper.Abstractions/Attrbutes/MapToAttribute.cs +++ b/src/AotObjectMapper.Abstractions/Attrbutes/MapToAttribute.cs @@ -9,7 +9,7 @@ /// The name of the property or field in the source class. /// The name of the property or field in the destination class. [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] -public class MapToAttribute(string sourcePropertyName, string destinationPropertyName) : Attribute +public class MapToAttribute([PreferNameOf(nameof(TSource))] string sourcePropertyName, [PreferNameOf(nameof(TDestination))] string destinationPropertyName) : Attribute { /// Gets the name of the property or field in the source class that is mapped to a corresponding property or field in the destination class. public string SourcePropertyName { get; } = sourcePropertyName; diff --git a/src/AotObjectMapper.Abstractions/Attrbutes/PreferNameOfAttribute.cs b/src/AotObjectMapper.Abstractions/Attrbutes/PreferNameOfAttribute.cs new file mode 100644 index 0000000..7983321 --- /dev/null +++ b/src/AotObjectMapper.Abstractions/Attrbutes/PreferNameOfAttribute.cs @@ -0,0 +1,11 @@ +namespace AotObjectMapper.Abstractions.Attributes; + +/// +/// Indicates that the parameter should prefer the use of the nameof() operator for referring to identifiers. +/// +[AttributeUsage(AttributeTargets.Parameter)] +internal sealed class PreferNameOfAttribute(string targetType) : Attribute +{ + /// Gets the type preferred for usage. This property can be used to indicate the target type in scenarios where nameof() is being employed. + public string TargetType { get; } = targetType; +} \ No newline at end of file diff --git a/src/AotObjectMapper.Mapper/AOMDiagnostics.cs b/src/AotObjectMapper.Mapper/AOMDiagnostics.cs index 2b5e9bc..5b95bef 100644 --- a/src/AotObjectMapper.Mapper/AOMDiagnostics.cs +++ b/src/AotObjectMapper.Mapper/AOMDiagnostics.cs @@ -120,8 +120,8 @@ public static class DiagnosticCategories public const string PreferNameOfId = "AOM202"; public static readonly DiagnosticDescriptor AOM202_PreferNameOf = new ( id: PreferNameOfId, - title: "Prefer using `nameof()` over raw string", - messageFormat: "`{0}` is a valid member name, but nameof({1}) is preferred", + title: "Prefer using nameof() over literal string", + messageFormat: "Prefer using nameof({0}.{1}) over \"{1}\"", category: DiagnosticCategories.Design, defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); diff --git a/src/AotObjectMapper.Mapper/AnalyzerReleases.Shipped.md b/src/AotObjectMapper.Mapper/AnalyzerReleases.Shipped.md index d9ecae4..4b621dc 100644 --- a/src/AotObjectMapper.Mapper/AnalyzerReleases.Shipped.md +++ b/src/AotObjectMapper.Mapper/AnalyzerReleases.Shipped.md @@ -19,9 +19,10 @@ ### New Rules - Rule ID | Category | Severity | Notes ------------|---------------|----------|---------------------------------------- - AOM100 | Usage | Error | Method has incorrect return type - AOM101 | Usage | Error | Method has incorrect parameter type - AOM104 | Usage | Error | Method must be static - AOM201 | Configuration | Error | Member names should be valid \ No newline at end of file + Rule ID | Category | Severity | Notes +-----------|---------------|----------|------------------------------------------- + AOM100 | Usage | Error | Method has incorrect return type + AOM101 | Usage | Error | Method has incorrect parameter type + AOM104 | Usage | Error | Method must be static + AOM201 | Configuration | Error | Member names should be valid + AOM202 | Design | Warning | Prefer using nameof() over literal string \ No newline at end of file diff --git a/src/AotObjectMapper.Mapper/AnalyzerReleases.Unshipped.md b/src/AotObjectMapper.Mapper/AnalyzerReleases.Unshipped.md index b4b13d8..836f776 100644 --- a/src/AotObjectMapper.Mapper/AnalyzerReleases.Unshipped.md +++ b/src/AotObjectMapper.Mapper/AnalyzerReleases.Unshipped.md @@ -4,7 +4,6 @@ -----------|---------------|----------|--------------------------------------------------------------------------- AOM103 | Performance | Warning | Method does not require MapperContext and the parameter should be removed AOM200 | Configuration | Error | Maps must be distinct - AOM202 | Design | Warning | Prefer using nameof() over raw string AOM203 | Usage | Error | UseFormatProvider destination type should be valid type AOM204 | Design | Warning | Potential recursive mapping detected AOM205 | Usage | Error | Mapper should only have one default FormatProvider diff --git a/src/AotObjectMapper.Mapper/Analyzers/PreferNameOfAnalyzer.cs b/src/AotObjectMapper.Mapper/Analyzers/PreferNameOfAnalyzer.cs new file mode 100644 index 0000000..732f58e --- /dev/null +++ b/src/AotObjectMapper.Mapper/Analyzers/PreferNameOfAnalyzer.cs @@ -0,0 +1,106 @@ +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace AotObjectMapper.Mapper.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class PreferNameOfAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics { get; } = [AOMDiagnostics.AOM202_PreferNameOf, ]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterOperationAction(AnalyzeArgumentOperation, OperationKind.Argument); + } + + private static void AnalyzeArgumentOperation(OperationAnalysisContext context) + { + var argumentOperation = (IArgumentOperation)context.Operation; + var parameter = argumentOperation.Parameter; + + if (parameter is null) + return; + + var preferNameOfAttr = parameter.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.Name is "PreferNameOfAttribute"); + + if (preferNameOfAttr is null) + return; + + var value = argumentOperation.Value; + + if (value is ILiteralOperation literal && literal.ConstantValue.HasValue && literal.ConstantValue.Value is string memberName) + { + var targetType = ResolveTargetType(preferNameOfAttr, parameter); + + if (targetType is null) + return; + + if (targetType.GetMembers(memberName).Count(x => context.Compilation.IsSymbolAccessibleWithin(x, context.ContainingSymbol.ContainingAssembly)) > 0) + { + var properties = ImmutableDictionary.Empty + .Add("Type", targetType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) + .Add("MemberName", memberName); + + context.ReportDiagnostic(Diagnostic.Create(AOMDiagnostics.AOM202_PreferNameOf, value.Syntax.GetLocation(), properties, targetType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), memberName)); + } + } + } + + private static ITypeSymbol? ResolveTargetType(AttributeData preferNameOfAttr, IParameterSymbol parameter) + { + var arg = preferNameOfAttr.ConstructorArguments[0]; + + if (arg.Value is not string targetTypeName) + return null; + + var containingSymbol = parameter.ContainingSymbol; + + if (containingSymbol is IMethodSymbol methodSymbol) + { + for (int i = 0; i < methodSymbol.TypeParameters.Length; i++) + { + if (methodSymbol.TypeParameters[i].Name == targetTypeName) + { + return methodSymbol.TypeArguments[i]; + } + } + + var containingType = methodSymbol.ContainingType; + if (containingType is not null) + { + return ResolveFromContainingType(containingType, targetTypeName); + } + } + + if (containingSymbol is INamedTypeSymbol typeSymbol) + { + return ResolveFromContainingType(typeSymbol, targetTypeName); + } + + return null; + } + + private static ITypeSymbol? ResolveFromContainingType(INamedTypeSymbol typeSymbol, string targetTypeName) + { + while (typeSymbol is not null) + { + for (int i = 0; i < typeSymbol.TypeParameters.Length; i++) + { + if (typeSymbol.TypeParameters[i].Name == targetTypeName) + { + return typeSymbol.TypeArguments[i]; + } + } + + typeSymbol = typeSymbol.ContainingType; + } + + return null; + } +} \ No newline at end of file diff --git a/src/AotObjectMapper.Mapper/CodeFixes/Aom202Fix.cs b/src/AotObjectMapper.Mapper/CodeFixes/Aom202Fix.cs new file mode 100644 index 0000000..b0bb8c1 --- /dev/null +++ b/src/AotObjectMapper.Mapper/CodeFixes/Aom202Fix.cs @@ -0,0 +1,69 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace AotObjectMapper.Mapper.CodeFixes; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(Aom202Fix)), Shared] +public sealed class Aom202Fix : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds => [AOMDiagnostics.PreferNameOfId]; + + public override FixAllProvider GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var diagnostic = context.Diagnostics[0]; + + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken); + + if (root is null) + return; + + var token = root.FindToken(diagnostic.Location.SourceSpan.Start); + + var literalNode = token.Parent? + .AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + if (literalNode is null) + return; + + context.RegisterCodeFix( + CodeAction.Create( + title: "Replace with nameof(...)", + createChangedDocument: ct => ReplaceWithNameOfAsync(root, context.Document, literalNode, diagnostic, ct), + equivalenceKey: "ReplaceWithNameOf"), + diagnostic); + } + + private static Task ReplaceWithNameOfAsync(SyntaxNode root, Document document, ExpressionSyntax literalNode, Diagnostic diagnostic, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + return Task.FromResult(document); + + var targetTypeName = diagnostic.Properties["Type"]; + var memberName = diagnostic.Properties["MemberName"]; + + if (targetTypeName is null || memberName is null) + return Task.FromResult(document); + + var nameofExpression = SyntaxFactory.ParseExpression($"nameof({targetTypeName}.{memberName})") + .WithTriviaFrom(literalNode); + + var newRoot = root.ReplaceNode(literalNode, nameofExpression); + + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } +} \ No newline at end of file From 852a14d8617e758df8779505206c4c73b883393f Mon Sep 17 00:00:00 2001 From: Liam Thompson Date: Fri, 27 Feb 2026 15:28:36 +1000 Subject: [PATCH 2/2] Add AOM202 analyzer and code fix tests --- .../AOMVerifierBase.cs | 35 +++++ .../AotObjectMapper.Mapper.Tests.csproj | 3 + .../DiagnosticTests/NameOfAnalyzerTests.cs | 138 ++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 test/AotObjectMapper.Mapper.Tests/DiagnosticTests/NameOfAnalyzerTests.cs diff --git a/test/AotObjectMapper.Mapper.Tests/AOMVerifierBase.cs b/test/AotObjectMapper.Mapper.Tests/AOMVerifierBase.cs index 92a2994..8b4923b 100644 --- a/test/AotObjectMapper.Mapper.Tests/AOMVerifierBase.cs +++ b/test/AotObjectMapper.Mapper.Tests/AOMVerifierBase.cs @@ -3,7 +3,9 @@ using System.Threading.Tasks; using AotObjectMapper.Abstractions.Attributes; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Testing; namespace AotObjectMapper.Mapper.Tests; @@ -28,4 +30,37 @@ public static async Task VerifyGeneratorDiagnosticsAsync(string code, IEnumerabl await test.RunAsync(cancellationToken); } + + public static async Task VerifyAnalyzerAsync(string source, IEnumerable? expected = null, CancellationToken cancellationToken = default) where TAnalyzer : DiagnosticAnalyzer, new() + { + var test = new CSharpAnalyzerTest + { + TestCode = source, + }; + + test.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(typeof(GenerateMapperAttribute).Assembly.Location)); + + if(expected is not null) + test.ExpectedDiagnostics.AddRange(expected); + + await test.RunAsync(cancellationToken); + } + + public static async Task VerifyCodeFixAsync(string source, string fix, IEnumerable? expected = null, CancellationToken cancellationToken = default) + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFixProvider : CodeFixProvider, new() + { + var test = new CSharpCodeFixTest + { + TestCode = source, + FixedCode = fix, + }; + + test.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(typeof(GenerateMapperAttribute).Assembly.Location)); + + if(expected is not null) + test.ExpectedDiagnostics.AddRange(expected); + + await test.RunAsync(cancellationToken); + } } \ No newline at end of file diff --git a/test/AotObjectMapper.Mapper.Tests/AotObjectMapper.Mapper.Tests.csproj b/test/AotObjectMapper.Mapper.Tests/AotObjectMapper.Mapper.Tests.csproj index 6d35d15..0d88e2f 100644 --- a/test/AotObjectMapper.Mapper.Tests/AotObjectMapper.Mapper.Tests.csproj +++ b/test/AotObjectMapper.Mapper.Tests/AotObjectMapper.Mapper.Tests.csproj @@ -12,7 +12,10 @@ + + + diff --git a/test/AotObjectMapper.Mapper.Tests/DiagnosticTests/NameOfAnalyzerTests.cs b/test/AotObjectMapper.Mapper.Tests/DiagnosticTests/NameOfAnalyzerTests.cs new file mode 100644 index 0000000..03c2a57 --- /dev/null +++ b/test/AotObjectMapper.Mapper.Tests/DiagnosticTests/NameOfAnalyzerTests.cs @@ -0,0 +1,138 @@ +using System.Threading.Tasks; +using AotObjectMapper.Mapper.Analyzers; +using AotObjectMapper.Mapper.CodeFixes; +using Microsoft.CodeAnalysis.Testing; +using Xunit; + +namespace AotObjectMapper.Mapper.Tests.DiagnosticTests; + +public class NameOfAnalyzerTests : AOMVerifierBase +{ + DiagnosticResult ExpectedDiagnostic(string type, string member, int location) + => DiagnosticResult.CompilerWarning(AOMDiagnostics.PreferNameOfId) + .WithMessageFormat(AOMDiagnostics.AOM202_PreferNameOf.MessageFormat) + .WithArguments(type, member) + .WithLocation(location); + + [Fact] + public async Task PreferNameOf_StringArg_AOM202() + { + const string code = + """ + using System; + using AotObjectMapper.Abstractions.Attributes; + using AotObjectMapper.Abstractions.Enums; + using AotObjectMapper.Abstractions.Models; + + public class T1 { public int Id { get; set; } } + public class T2 { public int Id { get; set; } } + + [GenerateMapper] + [Map] + public partial class TMapper + { + [ForMember({|#0:"Id"|})] + private static int GetId(T1 src) => 0; + } + """; + + await VerifyAnalyzerAsync(code, [ExpectedDiagnostic("T2", "Id", 0)], TestContext.Current.CancellationToken); + } + + [Fact] + public async Task PreferNameOf_ConstArg_NoDiagnostic() + { + const string code = + """ + using System; + using AotObjectMapper.Abstractions.Attributes; + using AotObjectMapper.Abstractions.Enums; + using AotObjectMapper.Abstractions.Models; + + public class T1 { public int Id { get; set; } } + public class T2 { public int Id { get; set; } } + + + [GenerateMapper] + [Map] + public partial class TMapper + { + public const string Arg = "Id"; + + [ForMember(Arg)] + private static int GetId(T1 src) => 0; + } + """; + + await VerifyAnalyzerAsync(code, [], TestContext.Current.CancellationToken); + } + + [Fact] + public async Task PreferNameOf_NameOf_NoDiagnostic() + { + const string code = + """ + using System; + using AotObjectMapper.Abstractions.Attributes; + using AotObjectMapper.Abstractions.Enums; + using AotObjectMapper.Abstractions.Models; + + public class T1 { public int Id { get; set; } } + public class T2 { public int Id { get; set; } } + + [GenerateMapper] + [Map] + public partial class TMapper + { + [ForMember(nameof(T2.Id))] + private static int GetId(T1 src) => 0; + } + """; + + await VerifyAnalyzerAsync(code, [], TestContext.Current.CancellationToken); + } + + [Fact] + public async Task PreferNameOf_CodeFix_ReplacesString() + { + const string code = + """ + using System; + using AotObjectMapper.Abstractions.Attributes; + using AotObjectMapper.Abstractions.Enums; + using AotObjectMapper.Abstractions.Models; + + public class T1 { public int Id { get; set; } } + public class T2 { public int Id { get; set; } } + + [GenerateMapper] + [Map] + public partial class TMapper + { + [ForMember({|#0:"Id"|})] + private static int GetId(T1 src) => 0; + } + """; + + const string codeFix = + """ + using System; + using AotObjectMapper.Abstractions.Attributes; + using AotObjectMapper.Abstractions.Enums; + using AotObjectMapper.Abstractions.Models; + + public class T1 { public int Id { get; set; } } + public class T2 { public int Id { get; set; } } + + [GenerateMapper] + [Map] + public partial class TMapper + { + [ForMember(nameof(T2.Id))] + private static int GetId(T1 src) => 0; + } + """; + + await VerifyCodeFixAsync(code, codeFix, [ExpectedDiagnostic("T2", "Id", 0)], TestContext.Current.CancellationToken); + } +} \ No newline at end of file