Skip to content

Commit 3bd0d7c

Browse files
committed
sg: handle collections without count/indexer
1 parent d0b7fa1 commit 3bd0d7c

File tree

7 files changed

+261
-35
lines changed

7 files changed

+261
-35
lines changed

src/FastCloner.Benchmark/FastCloner.Benchmark.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<PackageReference Include="DeepCopy.Expression" Version="1.5.0" />
1919
<PackageReference Include="Dolly" Version="0.0.9" />
2020
<PackageReference Include="FastDeepCloner" Version="1.3.6" />
21-
<PackageReference Include="IDeepCloneable" Version="0.0.27" />
21+
<PackageReference Include="IDeepCloneable" Version="0.1.4" />
2222
<PackageReference Include="MessagePack" Version="3.1.4" />
2323
<PackageReference Include="NClone" Version="1.2.0" />
2424
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />

src/FastCloner.SourceGenerator/CollectionHelperGenerator.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,10 @@ private static void WriteCollectionCloneMethod(CloneGeneratorContext context, Me
177177
else if (isStack) addMethod = "Push";
178178
else if (isLinkedList) addMethod = "AddLast";
179179

180-
// Determine if capacity can be passed to constructor
181-
// Only standard List, HashSet, Queue, Stack support new T(int capacity)
182-
bool supportsCapacity = kind == CollectionKind.List ||
180+
// Determine if capacity can be passed to constructor.
181+
// Only standard List, HashSet, Queue, Stack support new T(int capacity).
182+
// For CollectionKind.List, the source must also have .Count (e.g. IEnumerable<T> does not).
183+
bool supportsCapacity = (kind == CollectionKind.List && member.CollectionHasCount) ||
183184
kind == CollectionKind.HashSet ||
184185
kind == CollectionKind.Queue ||
185186
kind == CollectionKind.Stack;
@@ -247,7 +248,7 @@ private static void WriteCollectionCloneMethod(CloneGeneratorContext context, Me
247248
sb.AppendLine($" result.Push(({member.ElementTypeName})temp[i]!);");
248249
sb.AppendLine(" }");
249250
}
250-
else if (kind == CollectionKind.List)
251+
else if (kind == CollectionKind.List && member.CollectionHasIndexer)
251252
{
252253
if (context.TargetFramework >= TargetFramework.Net5)
253254
{

src/FastCloner.SourceGenerator/MemberModel.cs

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -85,21 +85,23 @@ internal readonly record struct MemberModel(
8585
bool HasSetter, // Whether the property has a setter (regular, not init-only)
8686
bool SetterIsAccessible, // Whether the setter is publicly accessible (not private/protected)
8787
MemberCloneBehavior MemberBehavior, // The clone behavior for this member (Clone, Reference, Shallow, Ignore)
88-
bool? PreserveIdentity = null // null=inherit from type, true=preserve identity for this member's subgraph, false=disabled
88+
bool? PreserveIdentity = null, // null=inherit from type, true=preserve identity for this member's subgraph, false=disabled
89+
bool CollectionHasCount = true, // Whether the source collection type has Count property
90+
bool CollectionHasIndexer = true // Whether the source collection type supports [i] indexing
8991
) : IEquatable<MemberModel>
9092
{
9193
/// <summary>
9294
/// Returns true if the member should have its reference copied directly without deep cloning.
9395
/// This applies to both Shallow and Reference behaviors.
9496
/// </summary>
95-
public bool ShouldCopyReference => MemberBehavior == MemberCloneBehavior.Shallow || MemberBehavior == MemberCloneBehavior.Reference;
97+
private bool ShouldCopyReference => MemberBehavior is MemberCloneBehavior.Shallow or MemberCloneBehavior.Reference;
9698

9799
// Legacy property for backward compatibility
98100
public bool IsShallowClone => ShouldCopyReference;
99101

100102
public static MemberModel Create(IPropertySymbol property, bool nullabilityEnabled, Compilation compilation, MemberCloneBehavior memberBehavior = MemberCloneBehavior.Clone)
101103
{
102-
(MemberTypeKind typeKind, string? elementName, string? keyName, string? valueName, bool elementSafe, bool elementClonable, bool keySafe, bool keyClonable, bool valSafe, bool valClonable, bool requiresFastCloner, CollectionKind collectionKind, string? concreteType, int arrayRank)
104+
(MemberTypeKind typeKind, string? elementName, string? keyName, string? valueName, bool elementSafe, bool elementClonable, bool keySafe, bool keyClonable, bool valSafe, bool valClonable, bool requiresFastCloner, CollectionKind collectionKind, string? concreteType, int arrayRank, bool collHasCount, bool collHasIndexer)
103105
= AnalyzeType(property.Type, compilation);
104106

105107
// Check if the property has an init-only setter (C# 9+)
@@ -147,12 +149,14 @@ public static MemberModel Create(IPropertySymbol property, bool nullabilityEnabl
147149
hasSetter,
148150
setterIsAccessible,
149151
memberBehavior,
150-
preserveIdentity);
152+
preserveIdentity,
153+
collHasCount,
154+
collHasIndexer);
151155
}
152156

153157
public static MemberModel Create(IFieldSymbol field, bool nullabilityEnabled, Compilation compilation, MemberCloneBehavior memberBehavior = MemberCloneBehavior.Clone)
154158
{
155-
(MemberTypeKind typeKind, string? elementName, string? keyName, string? valueName, bool elementSafe, bool elementClonable, bool keySafe, bool keyClonable, bool valSafe, bool valClonable, bool requiresFastCloner, CollectionKind collectionKind, string? concreteType, int arrayRank)
159+
(MemberTypeKind typeKind, string? elementName, string? keyName, string? valueName, bool elementSafe, bool elementClonable, bool keySafe, bool keyClonable, bool valSafe, bool valClonable, bool requiresFastCloner, CollectionKind collectionKind, string? concreteType, int arrayRank, bool collHasCount, bool collHasIndexer)
156160
= AnalyzeType(field.Type, compilation);
157161

158162
// Check nullability
@@ -194,7 +198,9 @@ public static MemberModel Create(IFieldSymbol field, bool nullabilityEnabled, Co
194198
hasSetter,
195199
setterIsAccessible,
196200
memberBehavior,
197-
preserveIdentity);
201+
preserveIdentity,
202+
collHasCount,
203+
collHasIndexer);
198204
}
199205

200206
/// <summary>
@@ -209,7 +215,7 @@ public static MemberModel Create(IFieldSymbol field, bool nullabilityEnabled, Co
209215
// Check named argument first (Enabled = true/false)
210216
foreach (KeyValuePair<string, TypedConstant> namedArg in attr.NamedArguments)
211217
{
212-
if (namedArg.Key == "Enabled" && namedArg.Value.Value is bool namedEnabled)
218+
if (namedArg is { Key: "Enabled", Value.Value: bool namedEnabled })
213219
return namedEnabled;
214220
}
215221

@@ -224,33 +230,33 @@ public static MemberModel Create(IFieldSymbol field, bool nullabilityEnabled, Co
224230
return null; // No attribute found
225231
}
226232

227-
private static (MemberTypeKind kind, string? elem, string? key, string? val, bool elemSafe, bool elemClon, bool keySafe, bool keyClon, bool valSafe, bool valClon, bool requiresFastCloner, CollectionKind collKind, string? concreteType, int arrayRank)
233+
private static (MemberTypeKind kind, string? elem, string? key, string? val, bool elemSafe, bool elemClon, bool keySafe, bool keyClon, bool valSafe, bool valClon, bool requiresFastCloner, CollectionKind collKind, string? concreteType, int arrayRank, bool collHasCount, bool collHasIndexer)
228234
AnalyzeType(ITypeSymbol type, Compilation compilation)
229235
{
230236
// Check if safe type (primitives, strings, etc.)
231237
if (TypeAnalyzer.IsSafeType(type, compilation))
232-
return (MemberTypeKind.Safe, null, null, null, false, false, false, false, false, false, false, CollectionKind.None, null, 0);
238+
return (MemberTypeKind.Safe, null, null, null, false, false, false, false, false, false, false, CollectionKind.None, null, 0, true, true);
233239

234240
// Check if this is a "do not clone" type (delegates, Lazy, Task, etc.)
235241
// These are treated as Safe to prevent deep cloning (shallow copy semantics)
236242
if (TypeAnalyzer.IsDoNotCloneType(type))
237-
return (MemberTypeKind.Safe, null, null, null, false, false, false, false, false, false, false, CollectionKind.None, null, 0);
243+
return (MemberTypeKind.Safe, null, null, null, false, false, false, false, false, false, false, CollectionKind.None, null, 0, true, true);
238244

239245
// Check if this is a ref struct type (Span<T>, ReadOnlySpan<T>, etc.)
240246
// Ref structs cannot be boxed and cannot be used with state tracking dictionary.
241247
// Treat them as Safe to use shallow copy (which is the correct semantics for ref structs anyway).
242248
if (TypeAnalyzer.IsRefStructType(type))
243-
return (MemberTypeKind.Safe, null, null, null, false, false, false, false, false, false, false, CollectionKind.None, null, 0);
249+
return (MemberTypeKind.Safe, null, null, null, false, false, false, false, false, false, false, CollectionKind.None, null, 0, true, true);
244250

245251
// Check if has clonable attribute
246252
if (TypeAnalyzer.HasClonableAttribute(type))
247-
return (MemberTypeKind.Clonable, null, null, null, false, false, false, false, false, false, false, CollectionKind.None, null, 0);
253+
return (MemberTypeKind.Clonable, null, null, null, false, false, false, false, false, false, false, CollectionKind.None, null, 0, true, true);
248254

249255
// Check if System.Object or Type Parameter (generic T)
250256
// For generics, we don't know at compile time if it's clonable.
251257
// We generate a smart fallback that handles safe types at runtime.
252258
if (type.SpecialType == SpecialType.System_Object || type.TypeKind == Microsoft.CodeAnalysis.TypeKind.TypeParameter)
253-
return (MemberTypeKind.Object, null, null, null, false, false, false, false, false, false, false, CollectionKind.None, null, 0);
259+
return (MemberTypeKind.Object, null, null, null, false, false, false, false, false, false, false, CollectionKind.None, null, 0, true, true);
254260

255261
// IMPORTANT: Check array BEFORE collection (arrays implement ICollection<T>)
256262
if (type is IArrayTypeSymbol arrayType)
@@ -265,12 +271,12 @@ private static (MemberTypeKind kind, string? elem, string? key, string? val, boo
265271
{
266272
// Multi-dimensional arrays: if element is not safe and not clonable, we need FastCloner to deep clone elements
267273
bool requiresFastCloner = !elemSafe && !elemClon;
268-
return (MemberTypeKind.MultiDimArray, elemName, null, null, elemSafe, elemClon, false, false, false, false, requiresFastCloner, CollectionKind.None, null, rank);
274+
return (MemberTypeKind.MultiDimArray, elemName, null, null, elemSafe, elemClon, false, false, false, false, requiresFastCloner, CollectionKind.None, null, rank, true, true);
269275
}
270276

271277
// Single-dimensional arrays: if element is not safe and not clonable, we need FastCloner to deep clone it
272278
bool requiresFastClonerSingle = !elemSafe && !elemClon;
273-
return (MemberTypeKind.Array, elemName, null, null, elemSafe, elemClon, false, false, false, false, requiresFastClonerSingle, CollectionKind.None, null, 1);
279+
return (MemberTypeKind.Array, elemName, null, null, elemSafe, elemClon, false, false, false, false, requiresFastClonerSingle, CollectionKind.None, null, 1, true, true);
274280
}
275281

276282
// Check dictionary BEFORE collection (dictionaries implement ICollection<KeyValuePair<K,V>>)
@@ -293,7 +299,7 @@ private static (MemberTypeKind kind, string? elem, string? key, string? val, boo
293299
CollectionKind collKind = TypeAnalyzer.GetCollectionKind(type);
294300
string concreteType = TypeAnalyzer.GetConcreteTypeForCollection(type, collKind, $"{keyName}, {valName}");
295301

296-
return (MemberTypeKind.Dictionary, null, keyName, valName, false, false, keySafe, keyClon, valSafe, valClon, requiresFastCloner, collKind, concreteType, 0);
302+
return (MemberTypeKind.Dictionary, null, keyName, valName, false, false, keySafe, keyClon, valSafe, valClon, requiresFastCloner, collKind, concreteType, 0, true, true);
297303
}
298304
}
299305

@@ -310,18 +316,21 @@ private static (MemberTypeKind kind, string? elem, string? key, string? val, boo
310316
CollectionKind collKind = TypeAnalyzer.GetCollectionKind(type);
311317
string concreteType = TypeAnalyzer.GetConcreteTypeForCollection(type, collKind, elemName!);
312318

313-
return (MemberTypeKind.Collection, elemName, null, null, elemSafe, elemClon, false, false, false, false, requiresFastCloner, collKind, concreteType, 0);
319+
bool collHasCount = TypeAnalyzer.CollectionHasCountProperty(type);
320+
bool collHasIndexer = TypeAnalyzer.CollectionHasIndexer(type);
321+
322+
return (MemberTypeKind.Collection, elemName, null, null, elemSafe, elemClon, false, false, false, false, requiresFastCloner, collKind, concreteType, 0, collHasCount, collHasIndexer);
314323
}
315324

316325
// Check for implicit candidate (must be after collection)
317326
if (TypeAnalyzer.IsImplicitCandidate(type))
318327
{
319328
// It's a candidate for implicit cloning (generated recursively)
320-
return (MemberTypeKind.Implicit, null, null, null, false, false, false, false, false, false, false, CollectionKind.None, null, 0);
329+
return (MemberTypeKind.Implicit, null, null, null, false, false, false, false, false, false, false, CollectionKind.None, null, 0, true, true);
321330
}
322331

323332
// Everything else - shallow copy fallback
324333
// If it's "Other", it's an unknown type. We definitely need FastCloner to deep clone it.
325-
return (MemberTypeKind.Other, null, null, null, false, false, false, false, false, false, true, CollectionKind.None, null, 0);
334+
return (MemberTypeKind.Other, null, null, null, false, false, false, false, false, false, true, CollectionKind.None, null, 0, true, true);
326335
}
327336
}

src/FastCloner.SourceGenerator/NestedTypeCollector.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ public static void Collect(
122122
}
123123
}
124124

125+
bool collHasCount = kind == MemberTypeKind.Collection && TypeAnalyzer.CollectionHasCountProperty(type);
126+
bool collHasIndexer = kind == MemberTypeKind.Collection && TypeAnalyzer.CollectionHasIndexer(type);
127+
125128
MemberModel model = new MemberModel(
126129
"NestedHelper",
127130
type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
@@ -142,7 +145,10 @@ public static void Collect(
142145
true, // HasGetter - helper methods always have access
143146
true, // HasSetter - helper methods always have access
144147
true, // SetterIsAccessible - helper methods always have access
145-
MemberCloneBehavior.Clone // MemberBehavior - helper methods use default cloning
148+
MemberCloneBehavior.Clone, // MemberBehavior - helper methods use default cloning
149+
null, // PreserveIdentity
150+
collHasCount,
151+
collHasIndexer
146152
);
147153

148154
if (!nestedTypes.ContainsKey(model.TypeFullName))

src/FastCloner.SourceGenerator/TypeAnalyzer.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,46 @@ public static bool HasParameterlessConstructor(INamedTypeSymbol symbol)
458458
c.DeclaredAccessibility == Accessibility.Public);
459459
}
460460

461+
/// <summary>
462+
/// Checks if a collection type has a Count property.
463+
/// </summary>
464+
public static bool CollectionHasCountProperty(ITypeSymbol type)
465+
{
466+
if (type.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_ICollection_T ||
467+
type.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IReadOnlyCollection_T ||
468+
type.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IReadOnlyList_T)
469+
return true;
470+
471+
if (type is INamedTypeSymbol named &&
472+
named.MetadataName == "IList`1" &&
473+
named.ContainingNamespace?.ToDisplayString() == "System.Collections.Generic")
474+
return true;
475+
476+
return type.AllInterfaces.Any(i =>
477+
i.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_ICollection_T ||
478+
i.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IReadOnlyCollection_T);
479+
}
480+
481+
/// <summary>
482+
/// Checks if a collection type supports indexed access via this[int] (IList&lt;T&gt; or IReadOnlyList&lt;T&gt;).
483+
/// </summary>
484+
public static bool CollectionHasIndexer(ITypeSymbol type)
485+
{
486+
if (type is INamedTypeSymbol named)
487+
{
488+
if (named.MetadataName == "IList`1" &&
489+
named.ContainingNamespace?.ToDisplayString() == "System.Collections.Generic")
490+
return true;
491+
492+
if (named.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IReadOnlyList_T)
493+
return true;
494+
}
495+
496+
return type.AllInterfaces.Any(i =>
497+
(i.MetadataName == "IList`1" && i.ContainingNamespace?.ToDisplayString() == "System.Collections.Generic") ||
498+
i.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IReadOnlyList_T);
499+
}
500+
461501
/// <summary>
462502
/// Identifies the kind of collection (List, Set, Queue, etc.) for optimized code generation.
463503
/// </summary>

src/FastCloner.Tests/FastCloner.Tests.csproj

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,21 @@
1515
<ItemGroup>
1616
<PackageReference Include="FluentNHibernate" Version="3.4.1" />
1717
<PackageReference Include="FluentValidation" Version="12.1.1" />
18-
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
18+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
1919
<PackageReference Include="NHibernate" Version="5.6.0" />
20-
<PackageReference Include="NUnit" Version="4.4.0" />
20+
<PackageReference Include="NUnit" Version="4.5.0" />
2121
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
2222
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
2323
<PrivateAssets>all</PrivateAssets>
2424
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
2525
</PackageReference>
26-
<PackageReference Include="coverlet.collector" Version="6.0.4">
27-
<PrivateAssets>all</PrivateAssets>
28-
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
29-
</PackageReference>
30-
<PackageReference Include="ObjectDumper.NET" Version="4.4.7-pre" />
26+
<PackageReference Include="ObjectDumper.NET" Version="4.4.10-pre" />
3127
<PackageReference Include="System.Collections.Immutable" Version="10.0.1" />
32-
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
28+
<PackageReference Include="System.Data.SqlClient" Version="4.9.1" />
3329
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
3430
<PackageReference Include="System.Drawing.Common" Version="10.0.1" />
35-
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
36-
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.2" />
31+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.3" />
32+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.3" />
3733
<PackageReference Include="System.Threading.Tasks.Dataflow" Version="10.0.1" />
3834
</ItemGroup>
3935

0 commit comments

Comments
 (0)