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
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