Skip to content

Commit f2dced0

Browse files
committed
sg: fix collections without setters
1 parent 86c95f7 commit f2dced0

22 files changed

+1211
-925
lines changed

src/FastCloner.SourceGenerator/CircularReferenceAnalyzer.cs

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ public static bool Analyze(INamedTypeSymbol rootType, Compilation compilation, L
1818
}
1919

2020
// Build a set of all reference types that can be reached from this type's MEMBERS
21-
var reachableTypes = new HashSet<ITypeSymbol>(SymbolEqualityComparer.Default);
22-
var visited = new HashSet<ITypeSymbol>(SymbolEqualityComparer.Default);
21+
HashSet<ITypeSymbol> reachableTypes = new HashSet<ITypeSymbol>(SymbolEqualityComparer.Default);
22+
HashSet<ITypeSymbol> visited = new HashSet<ITypeSymbol>(SymbolEqualityComparer.Default);
2323

2424
log.Add($" -> Collecting reachable reference types from {rootType.Name} members...");
2525

@@ -30,7 +30,7 @@ public static bool Analyze(INamedTypeSymbol rootType, Compilation compilation, L
3030
CollectReachableReferenceTypesFromMembers(rootType, reachableTypes, visited, compilation, log, rootType);
3131

3232
log.Add($" -> Found {reachableTypes.Count} reachable reference types:");
33-
foreach (var type in reachableTypes)
33+
foreach (ITypeSymbol? type in reachableTypes)
3434
{
3535
log.Add($" - {type.ToDisplayString()}");
3636
}
@@ -49,24 +49,24 @@ public static bool Analyze(INamedTypeSymbol rootType, Compilation compilation, L
4949

5050
private static bool HasDirectSelfReference(INamedTypeSymbol type, INamedTypeSymbol rootType, Compilation compilation, List<string> log)
5151
{
52-
foreach (var member in type.GetMembers())
52+
foreach (ISymbol? member in type.GetMembers())
5353
{
5454
if (member.IsStatic || member.IsImplicitlyDeclared)
5555
continue;
5656

5757
ITypeSymbol? memberType = null;
58-
if (member is IPropertySymbol property && property.GetMethod != null && property.SetMethod != null)
58+
if (member is IPropertySymbol { GetMethod: not null, SetMethod: not null } property)
5959
{
6060
memberType = property.Type;
6161
}
62-
else if (member is IFieldSymbol field && !field.IsConst && !field.IsStatic)
62+
else if (member is IFieldSymbol { IsConst: false, IsStatic: false } field)
6363
{
6464
memberType = field.Type;
6565
}
6666

6767
if (memberType != null)
6868
{
69-
var underlyingMemberType = memberType.WithNullableAnnotation(NullableAnnotation.None);
69+
ITypeSymbol underlyingMemberType = memberType.WithNullableAnnotation(NullableAnnotation.None);
7070
if (SymbolEqualityComparer.Default.Equals(underlyingMemberType, rootType))
7171
{
7272
log.Add($" -> Found direct self-reference: {member.Name} of type {rootType.Name}");
@@ -86,18 +86,18 @@ private static void CollectReachableReferenceTypesFromMembers(
8686
List<string> log,
8787
INamedTypeSymbol rootType)
8888
{
89-
foreach (var member in type.GetMembers())
89+
foreach (ISymbol? member in type.GetMembers())
9090
{
9191
if (member.IsStatic || member.IsImplicitlyDeclared)
9292
continue;
9393

9494
ITypeSymbol? memberType = null;
95-
if (member is IPropertySymbol property && property.GetMethod != null && property.SetMethod != null)
95+
if (member is IPropertySymbol { GetMethod: not null, SetMethod: not null } property)
9696
{
9797
memberType = property.Type;
9898
log.Add($" Analyzing property {member.Name}: {memberType.ToDisplayString()}");
9999
}
100-
else if (member is IFieldSymbol field && !field.IsConst && !field.IsStatic)
100+
else if (member is IFieldSymbol { IsConst: false, IsStatic: false } field)
101101
{
102102
memberType = field.Type;
103103
log.Add($" Analyzing field {member.Name}: {memberType.ToDisplayString()}");
@@ -118,7 +118,7 @@ private static void CollectReachableReferenceTypes(
118118
List<string> log,
119119
INamedTypeSymbol rootType)
120120
{
121-
var typeDisplayName = type.ToDisplayString();
121+
string typeDisplayName = type.ToDisplayString();
122122
if (!visited.Add(type))
123123
{
124124
log.Add($" [SKIP] {typeDisplayName} (already visited)");
@@ -137,7 +137,7 @@ private static void CollectReachableReferenceTypes(
137137
return;
138138
}
139139

140-
var underlyingType = type.WithNullableAnnotation(NullableAnnotation.None);
140+
ITypeSymbol underlyingType = type.WithNullableAnnotation(NullableAnnotation.None);
141141

142142
if (underlyingType is INamedTypeSymbol namedType)
143143
{
@@ -159,7 +159,7 @@ private static void CollectReachableReferenceTypes(
159159
}
160160
else if (TypeAnalyzer.IsCollectionType(type))
161161
{
162-
var elementType = TypeAnalyzer.GetCollectionElementType(type, compilation);
162+
ITypeSymbol? elementType = TypeAnalyzer.GetCollectionElementType(type, compilation);
163163
if (elementType != null)
164164
{
165165
log.Add($" -> Collection element type: {elementType.ToDisplayString()}");
@@ -168,7 +168,7 @@ private static void CollectReachableReferenceTypes(
168168
}
169169
else if (TypeAnalyzer.IsDictionaryType(type))
170170
{
171-
var dictTypes = TypeAnalyzer.GetDictionaryTypes(type, compilation);
171+
(ITypeSymbol KeyType, ITypeSymbol ValueType)? dictTypes = TypeAnalyzer.GetDictionaryTypes(type, compilation);
172172
if (dictTypes.HasValue)
173173
{
174174
log.Add($" -> Dictionary key type: {dictTypes.Value.KeyType.ToDisplayString()}");
@@ -179,7 +179,7 @@ private static void CollectReachableReferenceTypes(
179179
}
180180
else if (type is IArrayTypeSymbol arrayType)
181181
{
182-
var elementType = arrayType.ElementType;
182+
ITypeSymbol elementType = arrayType.ElementType;
183183
log.Add($" -> Array element type: {elementType.ToDisplayString()}");
184184
CollectReachableReferenceTypes(elementType, reachableTypes, visited, compilation, log, rootType);
185185
}
@@ -194,24 +194,24 @@ private static bool CanReferenceType(ITypeSymbol type, INamedTypeSymbol target,
194194
return false;
195195
}
196196

197-
foreach (var member in namedType.GetMembers())
197+
foreach (ISymbol? member in namedType.GetMembers())
198198
{
199199
if (member.IsStatic || member.IsImplicitlyDeclared)
200200
continue;
201201

202202
ITypeSymbol? memberType = null;
203-
if (member is IPropertySymbol property && property.GetMethod != null && property.SetMethod != null)
203+
if (member is IPropertySymbol { GetMethod: not null, SetMethod: not null } property)
204204
{
205205
memberType = property.Type;
206206
}
207-
else if (member is IFieldSymbol field && !field.IsConst && !field.IsStatic)
207+
else if (member is IFieldSymbol { IsConst: false, IsStatic: false } field)
208208
{
209209
memberType = field.Type;
210210
}
211211

212212
if (memberType != null)
213213
{
214-
var underlyingType = memberType.WithNullableAnnotation(NullableAnnotation.None);
214+
ITypeSymbol underlyingType = memberType.WithNullableAnnotation(NullableAnnotation.None);
215215
if (SymbolEqualityComparer.Default.Equals(underlyingType, target))
216216
{
217217
log.Add($" [MATCH] {member.Name} references {target.ToDisplayString()}");
@@ -220,10 +220,10 @@ private static bool CanReferenceType(ITypeSymbol type, INamedTypeSymbol target,
220220

221221
if (TypeAnalyzer.IsCollectionType(memberType))
222222
{
223-
var elementType = TypeAnalyzer.GetCollectionElementType(memberType, compilation);
223+
ITypeSymbol? elementType = TypeAnalyzer.GetCollectionElementType(memberType, compilation);
224224
if (elementType != null)
225225
{
226-
var underlyingElementType = elementType.WithNullableAnnotation(NullableAnnotation.None);
226+
ITypeSymbol underlyingElementType = elementType.WithNullableAnnotation(NullableAnnotation.None);
227227
if (SymbolEqualityComparer.Default.Equals(underlyingElementType, target))
228228
{
229229
log.Add($" [MATCH] Collection {member.Name} contains {target.ToDisplayString()}");

src/FastCloner.SourceGenerator/ClassCloneBodyGenerator.cs

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@ internal static class ClassCloneBodyGenerator
1515
/// </summary>
1616
public static bool NeedsFormatterServices(TypeModel model)
1717
{
18-
return !model.HasParameterlessConstructor && !model.IsStruct && !model.IsRecord;
18+
return model is { HasParameterlessConstructor: false, IsStruct: false, IsRecord: false };
1919
}
2020

2121
/// <summary>
2222
/// Checks if any of the given types need FormatterServices.
2323
/// </summary>
2424
public static bool NeedsFormatterServices(IEnumerable<TypeModel> types)
2525
{
26-
foreach (var type in types)
26+
foreach (TypeModel? type in types)
2727
{
2828
if (NeedsFormatterServices(type))
2929
{
@@ -50,9 +50,9 @@ public static void WriteClassCloneBody(
5050
bool useNullConditional = false,
5151
string sourceVarName = "source")
5252
{
53-
var sb = ctx.Source;
54-
var hasParameterlessConstructor = ctx.Model.HasParameterlessConstructor;
55-
var isRecord = ctx.Model.IsRecord;
53+
StringBuilder sb = ctx.Source;
54+
bool hasParameterlessConstructor = ctx.Model.HasParameterlessConstructor;
55+
bool isRecord = ctx.Model.IsRecord;
5656

5757
// For records without circular references, use the idiomatic 'with' expression
5858
if (isRecord && !useState)
@@ -68,12 +68,12 @@ public static void WriteClassCloneBody(
6868
// This requires us to instantiate first, then register, then assign members.
6969
WriteInstanceCreation(ctx, sb, typeName, hasParameterlessConstructor, isRecord, sourceVarName);
7070

71-
var stateVarForAdd = stateVarName ?? "state";
72-
var nullConditional = useNullConditional ? "?" : "";
71+
string stateVarForAdd = stateVarName ?? "state";
72+
string nullConditional = useNullConditional ? "?" : "";
7373
sb.AppendLine($" {stateVarForAdd}{nullConditional}.AddKnownRef({sourceVarName}, result);");
7474
sb.AppendLine();
7575

76-
foreach (var member in ctx.Model.Members)
76+
foreach (MemberModel member in ctx.Model.Members)
7777
{
7878
MemberCloneGenerator.WriteMemberCloning(ctx, member, "result", sourceVarName, stateVarForAdd);
7979
}
@@ -90,12 +90,12 @@ public static void WriteClassCloneBody(
9090
sb.Append($" var result = new {typeName}");
9191

9292
// Collect init-only and required properties for object initializer
93-
var initOnlyMembers = new List<string>();
94-
foreach (var member in ctx.Model.Members)
93+
List<string> initOnlyMembers = [];
94+
foreach (MemberModel member in ctx.Model.Members)
9595
{
96-
if ((member.IsProperty && member.IsInitOnly) || member.IsRequired)
96+
if (member is { IsProperty: true, IsInitOnly: true } || member.IsRequired)
9797
{
98-
var assignment = MemberCloneGenerator.GetMemberAssignment(ctx, member, sourceVarName, "null", " ");
98+
string assignment = MemberCloneGenerator.GetMemberAssignment(ctx, member, sourceVarName, "null", " ");
9999
if (!string.IsNullOrEmpty(assignment))
100100
{
101101
initOnlyMembers.Add($" {assignment}");
@@ -116,10 +116,10 @@ public static void WriteClassCloneBody(
116116
}
117117

118118
// Use statements for everything else (better for JIT and null handling)
119-
foreach (var member in ctx.Model.Members)
119+
foreach (MemberModel member in ctx.Model.Members)
120120
{
121121
// Skip if already handled in initializer (init-only or required)
122-
if ((member.IsProperty && member.IsInitOnly) || member.IsRequired)
122+
if (member is { IsProperty: true, IsInitOnly: true } || member.IsRequired)
123123
continue;
124124

125125
if (!member.IsProperty || !member.IsInitOnly)
@@ -134,7 +134,7 @@ public static void WriteClassCloneBody(
134134
WriteInstanceCreation(ctx, sb, typeName, hasParameterlessConstructor, isRecord, sourceVarName);
135135

136136
// Then assign members individually (no state needed for non-circular types)
137-
foreach (var member in ctx.Model.Members)
137+
foreach (MemberModel member in ctx.Model.Members)
138138
{
139139
MemberCloneGenerator.WriteMemberCloning(ctx, member, "result", sourceVarName, "null");
140140
}
@@ -160,8 +160,8 @@ private static void WriteInstanceCreation(CloneGeneratorContext ctx, StringBuild
160160
else if (hasParameterlessConstructor)
161161
{
162162
// Check for required members
163-
var requiredMembers = new List<string>();
164-
foreach (var member in ctx.Model.Members)
163+
List<string> requiredMembers = [];
164+
foreach (MemberModel member in ctx.Model.Members)
165165
{
166166
if (member.IsRequired)
167167
{
@@ -193,32 +193,72 @@ private static void WriteInstanceCreation(CloneGeneratorContext ctx, StringBuild
193193
/// <summary>
194194
/// Writes the clone body for records using the 'with' expression.
195195
/// Only includes members that need deep cloning in the 'with' expression.
196+
/// Getter-only collections are handled separately via population.
196197
/// </summary>
197198
/// <param name="sourceVarName">The name of the source variable (usually "source" for classes, "src" for structs after null check)</param>
198199
private static void WriteRecordCloneBody(CloneGeneratorContext ctx, string typeName, string sourceVarName = "source")
199200
{
200-
var sb = ctx.Source;
201+
StringBuilder sb = ctx.Source;
201202

202-
// Collect members that need deep cloning (not safe types)
203-
var deepCloneAssignments = new List<string>();
204-
foreach (var member in ctx.Model.Members)
203+
// Collect members that need deep cloning (not safe types) and can be assigned
204+
List<string> deepCloneAssignments = [];
205+
// Collect getter-only collections that need population
206+
List<MemberModel> getterOnlyCollections = [];
207+
208+
foreach (MemberModel member in ctx.Model.Members)
205209
{
210+
// Check for getter-only collection properties
211+
if (member is { IsProperty: true, HasGetter: true, HasSetter: false, IsInitOnly: false })
212+
{
213+
// These need to be handled via population, not 'with' expression
214+
if (member.TypeKind == MemberTypeKind.Collection || member.TypeKind == MemberTypeKind.Dictionary)
215+
{
216+
getterOnlyCollections.Add(member);
217+
}
218+
continue;
219+
}
220+
206221
// Skip safe types - they're already shallow copied by 'with'
207222
if (member.TypeKind == MemberTypeKind.Safe)
208223
continue;
209224

210-
// Skip read-only members
225+
// Skip read-only members (that aren't getter-only collections, which we handled above)
211226
if (member.IsReadOnly)
212227
continue;
213228

214-
var assignment = MemberCloneGenerator.GetMemberAssignment(ctx, member, sourceVarName, "null", " ");
229+
string assignment = MemberCloneGenerator.GetMemberAssignment(ctx, member, sourceVarName, "null", " ");
215230
if (!string.IsNullOrEmpty(assignment))
216231
{
217232
deepCloneAssignments.Add($" {assignment}");
218233
}
219234
}
220235

221-
if (deepCloneAssignments.Count == 0)
236+
// If we have getter-only collections, we need to use statement-based approach
237+
if (getterOnlyCollections.Count > 0)
238+
{
239+
// Create result using 'with' expression
240+
if (deepCloneAssignments.Count == 0)
241+
{
242+
sb.AppendLine($" var result = {sourceVarName} with {{ }};");
243+
}
244+
else
245+
{
246+
sb.AppendLine($" var result = {sourceVarName} with");
247+
sb.AppendLine(" {");
248+
sb.AppendLine(string.Join(",\n", deepCloneAssignments));
249+
sb.AppendLine(" };");
250+
}
251+
252+
// Populate getter-only collections
253+
foreach (MemberModel member in getterOnlyCollections)
254+
{
255+
MemberCloneGenerator.WriteMemberCloning(ctx, member, "result", sourceVarName, "null");
256+
}
257+
258+
sb.AppendLine();
259+
sb.AppendLine(" return result;");
260+
}
261+
else if (deepCloneAssignments.Count == 0)
222262
{
223263
// All members are safe - simple shallow copy
224264
sb.AppendLine($" return {sourceVarName} with {{ }};");

0 commit comments

Comments
 (0)