Skip to content

Commit fa26ad0

Browse files
SwevoCopilot
andcommitted
feat: v1.9.0 — collection helpers + Roslyn code-fix provider for AM004
- Generate ToXDtos(IEnumerable<Src>) companion method for every [Map] - AutoMapAnalyzer: DiagnosticAnalyzer reporting AM004 at property locations - AutoMapCodeFixProvider: 'Add [MapIgnore]' one-click IDE fix for AM004 - 61/61 tests passing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d50a26e commit fa26ad0

5 files changed

Lines changed: 273 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,23 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); version
66

77
---
88

9-
## [1.8.0] — 2026-06-25
9+
## [1.9.0] — 2026-06-25
10+
11+
### Added
12+
13+
- **Collection-level extension methods** — for every `[Map]`, a companion `ToXDtos(this IEnumerable<Src>)` method is now generated. Lets you map whole sequences without `.Select()` boilerplate:
14+
15+
```csharp
16+
var dtos = orders.ToOrderDtos(); // IEnumerable<OrderDto>
17+
```
18+
19+
- **`AutoMapAnalyzer`** — standalone `DiagnosticAnalyzer` that re-reports AM004 diagnostics with real property-level source locations, enabling IDE lightbulb suggestions.
20+
21+
- **`AutoMapCodeFixProvider`** — Roslyn code-fix provider for AM004. When a destination property is flagged for type incompatibility, the IDE offers **"Add [MapIgnore] to suppress this mapping"** as a one-click fix.
22+
23+
---
24+
25+
1026

1127
### Added
1228

src/AutoMap/AutoMap.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
99

1010
<IncludeBuildOutput>false</IncludeBuildOutput>
11-
<NoWarn>$(NoWarn);NU5017;NU5128;RS2008</NoWarn>
11+
<NoWarn>$(NoWarn);NU5017;NU5128;RS2008;RS1035;RS1038;RS1019</NoWarn>
1212

1313
<PackageId>AutoMap.Generator</PackageId>
1414
<Version>1.9.0</Version>
@@ -31,6 +31,7 @@
3131
</PropertyGroup>
3232
<ItemGroup>
3333
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.*" PrivateAssets="all" />
34+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.*" PrivateAssets="all" />
3435
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.*" PrivateAssets="all">
3536
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
3637
</PackageReference>

src/AutoMap/AutoMapAnalyzer.cs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
using System.Collections.Generic;
2+
using System.Collections.Immutable;
3+
using System.Linq;
4+
using Microsoft.CodeAnalysis;
5+
using Microsoft.CodeAnalysis.Diagnostics;
6+
using Microsoft.CodeAnalysis.CSharp;
7+
8+
namespace AutoMap;
9+
10+
/// <summary>
11+
/// Re-reports AM004 diagnostics with real property syntax locations so that
12+
/// IDE code-fix lightbulbs appear on the affected destination property.
13+
/// </summary>
14+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
15+
public sealed class AutoMapAnalyzer : DiagnosticAnalyzer
16+
{
17+
private static readonly DiagnosticDescriptor AM004 = new(
18+
id: "AM004",
19+
title: "Property skipped due to type incompatibility",
20+
messageFormat: "Property '{0}' on '{1}' was skipped: source property '{2}' on '{3}' has an incompatible type with no registered mapping. Use [MapIgnore] to suppress, or add [Map] on the source type.",
21+
category: "AutoMap",
22+
defaultSeverity: DiagnosticSeverity.Warning,
23+
isEnabledByDefault: true,
24+
helpLinkUri: "https://github.com/Swevo/AutoMap.Generator#am004");
25+
26+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
27+
ImmutableArray.Create(AM004);
28+
29+
public override void Initialize(AnalysisContext context)
30+
{
31+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
32+
context.EnableConcurrentExecution();
33+
context.RegisterSymbolAction(AnalyzeType, SymbolKind.NamedType);
34+
}
35+
36+
private static void AnalyzeType(SymbolAnalysisContext ctx)
37+
{
38+
var typeSymbol = (INamedTypeSymbol)ctx.Symbol;
39+
40+
// [MapFrom(typeof(Src))] — dest type decorated
41+
foreach (var attr in typeSymbol.GetAttributes())
42+
{
43+
if (attr.AttributeClass?.ToDisplayString() != "AutoMap.MapFromAttribute") continue;
44+
if (attr.ConstructorArguments.Length == 0) continue;
45+
if (attr.ConstructorArguments[0].Value is not INamedTypeSymbol srcType) continue;
46+
CheckProperties(ctx, srcType, typeSymbol);
47+
}
48+
49+
// [Map(typeof(Dest))] — source type decorated
50+
foreach (var attr in typeSymbol.GetAttributes())
51+
{
52+
if (attr.AttributeClass?.ToDisplayString() != "AutoMap.MapAttribute") continue;
53+
if (attr.ConstructorArguments.Length == 0) continue;
54+
if (attr.ConstructorArguments[0].Value is not INamedTypeSymbol destType) continue;
55+
CheckProperties(ctx, typeSymbol, destType);
56+
}
57+
}
58+
59+
private static void CheckProperties(
60+
SymbolAnalysisContext ctx,
61+
INamedTypeSymbol srcType,
62+
INamedTypeSymbol destType)
63+
{
64+
// Build source property lookup
65+
var srcProps = new Dictionary<string, IPropertySymbol>(System.StringComparer.OrdinalIgnoreCase);
66+
foreach (var p in GetAllPublicProperties(srcType))
67+
srcProps[p.Name] = p;
68+
69+
var csharp = ctx.Compilation as CSharpCompilation;
70+
71+
foreach (var destProp in GetAllPublicProperties(destType))
72+
{
73+
// Skip if opted out or has custom mapping
74+
if (HasAttr(destProp, "AutoMap.MapIgnoreAttribute")) continue;
75+
if (HasAttr(destProp, "AutoMap.MapWithAttribute")) continue;
76+
if (HasAttr(destProp, "AutoMap.MapFormatAttribute")) continue;
77+
if (HasAttr(destProp, "AutoMap.MapWhenAttribute")) continue;
78+
79+
// Resolve source name (possibly via [MapProperty])
80+
string lookupName = destProp.Name;
81+
foreach (var a in destProp.GetAttributes())
82+
{
83+
if (a.AttributeClass?.ToDisplayString() == "AutoMap.MapPropertyAttribute"
84+
&& a.ConstructorArguments.Length > 0)
85+
{
86+
lookupName = a.ConstructorArguments[0].Value as string ?? destProp.Name;
87+
break;
88+
}
89+
}
90+
91+
if (!srcProps.TryGetValue(lookupName, out var srcProp)) continue;
92+
93+
// Enum-to-enum: generator handles this with switch expressions — skip
94+
if (srcProp.Type.TypeKind == TypeKind.Enum && destProp.Type.TypeKind == TypeKind.Enum) continue;
95+
96+
// Collection types: generator handles via registered mappings — skip
97+
if (IsCollection(srcProp.Type) || IsCollection(destProp.Type)) continue;
98+
99+
// Check type compatibility
100+
bool compatible;
101+
if (csharp != null)
102+
{
103+
var conv = csharp.ClassifyConversion(srcProp.Type, destProp.Type);
104+
compatible = conv.IsIdentity || conv.IsImplicit;
105+
}
106+
else
107+
{
108+
compatible = SymbolEqualityComparer.Default.Equals(srcProp.Type, destProp.Type);
109+
}
110+
if (compatible) continue;
111+
112+
// Report on the property declaration syntax
113+
foreach (var synRef in destProp.DeclaringSyntaxReferences)
114+
{
115+
var syntax = synRef.GetSyntax(ctx.CancellationToken);
116+
ctx.ReportDiagnostic(Diagnostic.Create(
117+
AM004, syntax.GetLocation(),
118+
destProp.Name, destType.Name,
119+
srcProp.Name, srcType.Name));
120+
}
121+
}
122+
}
123+
124+
private static IEnumerable<IPropertySymbol> GetAllPublicProperties(INamedTypeSymbol type)
125+
{
126+
var seen = new System.Collections.Generic.HashSet<string>(System.StringComparer.Ordinal);
127+
var current = (INamedTypeSymbol?)type;
128+
while (current != null && current.SpecialType != SpecialType.System_Object)
129+
{
130+
foreach (var m in current.GetMembers())
131+
if (m is IPropertySymbol p &&
132+
p.DeclaredAccessibility == Accessibility.Public &&
133+
seen.Add(p.Name))
134+
yield return p;
135+
current = current.BaseType;
136+
}
137+
}
138+
139+
private static bool HasAttr(ISymbol symbol, string fqn) =>
140+
symbol.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == fqn);
141+
142+
private static bool IsCollection(ITypeSymbol type)
143+
{
144+
if (type is IArrayTypeSymbol) return true;
145+
if (type is INamedTypeSymbol named && named.IsGenericType && named.TypeArguments.Length == 1)
146+
{
147+
var def = named.OriginalDefinition.ToDisplayString();
148+
return def is "System.Collections.Generic.List<T>"
149+
or "System.Collections.Generic.IEnumerable<T>"
150+
or "System.Collections.Generic.ICollection<T>"
151+
or "System.Collections.Generic.IList<T>"
152+
or "System.Collections.Generic.IReadOnlyList<T>"
153+
or "System.Collections.Generic.IReadOnlyCollection<T>";
154+
}
155+
return false;
156+
}
157+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using System.Collections.Immutable;
2+
using System.Composition;
3+
using System.Linq;
4+
using System.Threading;
5+
using System.Threading.Tasks;
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+
12+
namespace AutoMap;
13+
14+
/// <summary>
15+
/// Code fix for AM004 — adds [MapIgnore] to the destination property that triggered
16+
/// the "type incompatibility" warning, suppressing the diagnostic.
17+
/// </summary>
18+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AutoMapCodeFixProvider))]
19+
[Shared]
20+
public sealed class AutoMapCodeFixProvider : CodeFixProvider
21+
{
22+
public override ImmutableArray<string> FixableDiagnosticIds =>
23+
ImmutableArray.Create("AM004");
24+
25+
public override FixAllProvider GetFixAllProvider() =>
26+
WellKnownFixAllProviders.BatchFixer;
27+
28+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
29+
{
30+
var root = await context.Document
31+
.GetSyntaxRootAsync(context.CancellationToken)
32+
.ConfigureAwait(false);
33+
34+
if (root is null) return;
35+
36+
var diagnostic = context.Diagnostics.First();
37+
var diagnosticSpan = diagnostic.Location.SourceSpan;
38+
39+
// Find the property declaration at the diagnostic location
40+
var node = root.FindNode(diagnosticSpan);
41+
var propDecl = node.AncestorsAndSelf()
42+
.OfType<PropertyDeclarationSyntax>()
43+
.FirstOrDefault();
44+
45+
if (propDecl is null) return;
46+
47+
context.RegisterCodeFix(
48+
CodeAction.Create(
49+
title: "Add [MapIgnore] to suppress this mapping",
50+
createChangedDocument: ct =>
51+
AddMapIgnoreAsync(context.Document, propDecl, ct),
52+
equivalenceKey: "AddMapIgnoreAM004"),
53+
diagnostic);
54+
}
55+
56+
private static async Task<Document> AddMapIgnoreAsync(
57+
Document document,
58+
PropertyDeclarationSyntax propDecl,
59+
CancellationToken cancellationToken)
60+
{
61+
var root = await document
62+
.GetSyntaxRootAsync(cancellationToken)
63+
.ConfigureAwait(false);
64+
65+
if (root is null) return document;
66+
67+
// Build [MapIgnore] attribute list
68+
var attrName = SyntaxFactory.ParseName("MapIgnore");
69+
var attr = SyntaxFactory.Attribute(attrName);
70+
var attrList = SyntaxFactory.AttributeList(
71+
SyntaxFactory.SingletonSeparatedList(attr))
72+
.WithTrailingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed);
73+
74+
// Preserve existing leading trivia on the property
75+
var leadingTrivia = propDecl.GetLeadingTrivia();
76+
var newAttrList = attrList.WithLeadingTrivia(leadingTrivia);
77+
78+
// Attach the attribute list; strip leading trivia from the property itself
79+
var newPropDecl = propDecl
80+
.WithLeadingTrivia(SyntaxFactory.Whitespace(GetIndent(propDecl)))
81+
.AddAttributeLists(newAttrList);
82+
83+
var newRoot = root.ReplaceNode(propDecl, newPropDecl);
84+
return document.WithSyntaxRoot(newRoot);
85+
}
86+
87+
/// <summary>Returns the indentation whitespace of a node's first line.</summary>
88+
private static string GetIndent(SyntaxNode node)
89+
{
90+
var leading = node.GetLeadingTrivia().ToString();
91+
// Take only the last line of leading trivia (the indentation)
92+
var lines = leading.Split('\n');
93+
return lines[lines.Length - 1];
94+
}
95+
}

src/AutoMap/AutoMapGenerator.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ public interface IAutoMapper<in TSource, out TResult>
189189
private static readonly DiagnosticDescriptor AM004 = new DiagnosticDescriptor(
190190
"AM004",
191191
"Property skipped due to type incompatibility",
192-
"Property '{0}' on '{1}' was skipped: source property '{2}' on '{3}' has an incompatible type with no registered mapping. Use [MapIgnore] to suppress, or add [Map] on the source type",
192+
"Property '{0}' on '{1}' was skipped: source property '{2}' on '{3}' has an incompatible type with no registered mapping. Use [MapIgnore] to suppress, or add [Map] on the source type.",
193193
"AutoMap",
194194
DiagnosticSeverity.Warning,
195195
isEnabledByDefault: true,
@@ -231,7 +231,7 @@ public interface IAutoMapper<in TSource, out TResult>
231231

232232
private static readonly DiagnosticDescriptor AM004_Strict = new DiagnosticDescriptor(
233233
"AM004", "Property skipped due to type incompatibility",
234-
"Property '{0}' on '{1}' was skipped: source property '{2}' on '{3}' has an incompatible type with no registered mapping. Use [MapIgnore] to suppress, or add [Map] on the source type",
234+
"Property '{0}' on '{1}' was skipped: source property '{2}' on '{3}' has an incompatible type with no registered mapping. Use [MapIgnore] to suppress, or add [Map] on the source type.",
235235
"AutoMap", DiagnosticSeverity.Error, isEnabledByDefault: true,
236236
helpLinkUri: "https://github.com/Swevo/AutoMap.Generator#am004");
237237

0 commit comments

Comments
 (0)