Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
/// <typeparam name="TSource">The source type from which the mapping originates.</typeparam>
/// <typeparam name="TDestination">The destination type to which the mapping is applied.</typeparam>
[AttributeUsage(AttributeTargets.Method)]
public sealed class ForMemberAttribute<TSource, TDestination>(string memberName) : Attribute
public sealed class ForMemberAttribute<TSource, TDestination>([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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
/// <param name="sourcePropertyName">The name of the property or field in the source class.</param>
/// <param name="destinationPropertyName">The name of the property or field in the destination class.</param>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class MapToAttribute<TSource, TDestination>(string sourcePropertyName, string destinationPropertyName) : Attribute
public class MapToAttribute<TSource, TDestination>([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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace AotObjectMapper.Abstractions.Attributes;

/// <summary>
/// Indicates that the parameter should prefer the use of the <c>nameof()</c> operator for referring to identifiers.
/// </summary>
[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 <c>nameof()</c> is being employed.
public string TargetType { get; } = targetType;
}
4 changes: 2 additions & 2 deletions src/AotObjectMapper.Mapper/AOMDiagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 7 additions & 6 deletions src/AotObjectMapper.Mapper/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
1 change: 0 additions & 1 deletion src/AotObjectMapper.Mapper/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions src/AotObjectMapper.Mapper/Analyzers/PreferNameOfAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -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)]

Check warning on line 9 in src/AotObjectMapper.Mapper/Analyzers/PreferNameOfAnalyzer.cs

View workflow job for this annotation

GitHub Actions / build

This compiler extension should not be implemented in an assembly containing a reference to Microsoft.CodeAnalysis.Workspaces. The Microsoft.CodeAnalysis.Workspaces assembly is not provided during command line compilation scenarios, so references to it could cause the compiler extension to behave unpredictably. (https://github.com/dotnet/roslyn-analyzers/blob/main/docs/rules/RS1038.md)
public class PreferNameOfAnalyzer : DiagnosticAnalyzer
{
public override ImmutableArray<DiagnosticDescriptor> 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<string, string?>.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;
}
}
69 changes: 69 additions & 0 deletions src/AotObjectMapper.Mapper/CodeFixes/Aom202Fix.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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<LiteralExpressionSyntax>()
.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<Document> 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));
}
}
35 changes: 35 additions & 0 deletions test/AotObjectMapper.Mapper.Tests/AOMVerifierBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,4 +30,37 @@ public static async Task VerifyGeneratorDiagnosticsAsync(string code, IEnumerabl

await test.RunAsync(cancellationToken);
}

public static async Task VerifyAnalyzerAsync<TAnalyzer>(string source, IEnumerable<DiagnosticResult>? expected = null, CancellationToken cancellationToken = default) where TAnalyzer : DiagnosticAnalyzer, new()
{
var test = new CSharpAnalyzerTest<TAnalyzer, DefaultVerifier>
{
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<TAnalyzer, TCodeFixProvider>(string source, string fix, IEnumerable<DiagnosticResult>? expected = null, CancellationToken cancellationToken = default)
where TAnalyzer : DiagnosticAnalyzer, new()
where TCodeFixProvider : CodeFixProvider, new()
{
var test = new CSharpCodeFixTest<TAnalyzer, TCodeFixProvider, DefaultVerifier>
{
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
<ItemGroup>
<PackageReference Include="Liamth99.Utils" Version="1.0.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.3" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.3" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing" Version="1.1.3" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
Expand Down
Loading
Loading