Skip to content

Commit f8f4792

Browse files
committed
sg: improve nullability annotations, respect [FastClonerShallow] on get only collections
1 parent 18947db commit f8f4792

14 files changed

+230
-54
lines changed

src/FastCloner.SourceGenerator/CloneCodeGenerator.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,10 @@ private void WritePublicFastDeepCloneMethod(string typeName, string fullTypeName
189189
string returnTypeSuffix = isStruct ? "" : "?";
190190
string paramTypeSuffix = (isStruct || trustNullability) ? "" : "?";
191191

192+
// Add NotNullIfNotNull attribute for reference types (improves nullability flow analysis)
193+
string notNullAttr = CloneGeneratorContext.NotNullIfNotNullAttr(_context.Model.CodeAnalysisAvailable && !isStruct);
194+
if (!string.IsNullOrEmpty(notNullAttr))
195+
sb.AppendLine($" {notNullAttr}");
192196
sb.AppendLine($" public static {typeName}{returnTypeSuffix} FastDeepClone{typeParams}(this {typeName}{paramTypeSuffix} source){constraints}");
193197
sb.AppendLine(" {");
194198

@@ -204,7 +208,7 @@ private void WritePublicFastDeepCloneMethod(string typeName, string fullTypeName
204208
sb.AppendLine(" // Fallback to runtime cloning due to complex language features:");
205209
if (hasInitOnlyWithCycles) sb.AppendLine(" // - Init-only members with circular reference tracking");
206210
if (structWithReadonlyRefs) sb.AppendLine(" // - Struct with readonly reference fields");
207-
sb.AppendLine(" return FastCloner.DeepClone(source);");
211+
sb.AppendLine($" return {CloneGeneratorContext.FastClonerDeepCloneCall("source")};");
208212
sb.AppendLine(" }");
209213
sb.AppendLine();
210214
return;
@@ -275,7 +279,7 @@ private void WritePrivateFastDeepCloneMethod(string typeName, string fullTypeNam
275279
{
276280
sb.AppendLine(" // Fallback to runtime cloning due to complex language features.");
277281
sb.AppendLine(" // Note: State is ignored here as the runtime handles its own circular reference tracking.");
278-
sb.AppendLine(" return FastCloner.DeepClone(source);");
282+
sb.AppendLine($" return {CloneGeneratorContext.FastClonerDeepCloneCall("source")};");
279283
sb.AppendLine(" }");
280284
sb.AppendLine();
281285
return;
@@ -362,7 +366,7 @@ private void WriteAbstractTypeDispatcher(string typeName)
362366
if (_context.IsFastClonerAvailable)
363367
{
364368
sb.AppendLine(" // Fallback for unknown derived types - use runtime cloner");
365-
sb.AppendLine($" return ({typeName})FastCloner.DeepClone(source);");
369+
sb.AppendLine($" return ({typeName}){CloneGeneratorContext.FastClonerDeepCloneCall("source")};");
366370
}
367371
else
368372
{
@@ -769,7 +773,7 @@ private void WriteClonerClass()
769773
if (_context.IsFastClonerAvailable)
770774
{
771775
sb.AppendLine(" // If FastCloner is available, delegate to it for deep cloning");
772-
sb.AppendLine($" return ({fallbackCastTypeParam})FastCloner.DeepClone(source);");
776+
sb.AppendLine($" return ({fallbackCastTypeParam}){CloneGeneratorContext.FastClonerDeepCloneCall("source")};");
773777
}
774778
else
775779
{

src/FastCloner.SourceGenerator/CloneGeneratorContext.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,4 +223,18 @@ public bool ShouldInline(string typeFullName)
223223

224224
private int _variableCounter = 0;
225225
public int GetNextVariableId() => System.Threading.Interlocked.Increment(ref _variableCounter);
226+
227+
/// <summary>
228+
/// Gets the fully-qualified call to FastCloner.DeepClone for use in generated code.
229+
/// Uses global:: prefix to avoid namespace/class ambiguity (both are named "FastCloner").
230+
/// </summary>
231+
public static string FastClonerDeepCloneCall(string expression) => $"global::FastCloner.FastCloner.DeepClone({expression})";
232+
233+
/// <summary>
234+
/// Gets the NotNullIfNotNull attribute string for generated methods, or empty if unavailable.
235+
/// </summary>
236+
public static string NotNullIfNotNullAttr(bool isAvailable, string paramName = "source")
237+
=> isAvailable
238+
? $"[return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull(\"{paramName}\")]"
239+
: "";
226240
}

src/FastCloner.SourceGenerator/CollectionHelperGenerator.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ private static string GetItemCloneExpression(CloneGeneratorContext context, Memb
427427
if (context.IsFastClonerAvailable)
428428
{
429429
// Use runtime FastCloner for elements that require it (e.g. generics, unclonable types)
430-
return $"({member.ElementTypeName}?)FastCloner.DeepClone({itemVar})";
430+
return $"({member.ElementTypeName}?){CloneGeneratorContext.FastClonerDeepCloneCall(itemVar)}";
431431
}
432432

433433
// Fallback to shallow copy (should be caught by diagnostics if it was required)
@@ -659,7 +659,7 @@ private static (string keyExpr, string valExpr) GetKeyValueExpressions(CloneGene
659659
}
660660
else if (context.IsFastClonerAvailable)
661661
{
662-
keyExpr = $"({member.KeyTypeName})FastCloner.DeepClone(kvp.Key)";
662+
keyExpr = $"({member.KeyTypeName}){CloneGeneratorContext.FastClonerDeepCloneCall("kvp.Key")}";
663663
}
664664
}
665665

@@ -696,7 +696,7 @@ private static (string keyExpr, string valExpr) GetKeyValueExpressions(CloneGene
696696
}
697697
else if (context.IsFastClonerAvailable)
698698
{
699-
valExpr = $"({member.ValueTypeName})FastCloner.DeepClone(kvp.Value)";
699+
valExpr = $"({member.ValueTypeName}){CloneGeneratorContext.FastClonerDeepCloneCall("kvp.Value")}";
700700
}
701701
}
702702

@@ -787,7 +787,7 @@ private static void WriteArrayCloneMethod(CloneGeneratorContext context, MemberM
787787
else if (context.IsFastClonerAvailable)
788788
{
789789
// Use runtime FastCloner for elements that require it
790-
itemExpr = $"({member.ElementTypeName})FastCloner.DeepClone(source[i])";
790+
itemExpr = $"({member.ElementTypeName}){CloneGeneratorContext.FastClonerDeepCloneCall("source[i]")}";
791791
}
792792
else
793793
{
@@ -909,7 +909,7 @@ private static void WriteMultiDimArrayCloneMethod(CloneGeneratorContext context,
909909
else if (context.IsFastClonerAvailable)
910910
{
911911
// Use runtime FastCloner for elements that require it
912-
itemExpr = $"({member.ElementTypeName})FastCloner.DeepClone(source[{indexList}])";
912+
itemExpr = $"({member.ElementTypeName}){CloneGeneratorContext.FastClonerDeepCloneCall($"source[{indexList}]")}";
913913
}
914914
else
915915
{

src/FastCloner.SourceGenerator/ContextCodeGenerator.cs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,9 @@ private void GenerateTypeCloner(TypeModel model)
110110
bool needsState = _needsState.ContainsKey(model.FullyQualifiedName) && _needsState[model.FullyQualifiedName];
111111

112112
// Only generate NotNullIfNotNull attribute if it's available in the runtime (not from polyfill)
113-
if (_model.HasNotNullIfNotNullAttribute)
114-
{
115-
_sb.AppendLine($" [return: global::System.Diagnostics.CodeAnalysis.NotNullIfNotNull(\"source\")]");
116-
}
113+
string notNullAttr = CloneGeneratorContext.NotNullIfNotNullAttr(_model.CodeAnalysisAvailable);
114+
if (!string.IsNullOrEmpty(notNullAttr))
115+
_sb.AppendLine($" {notNullAttr}");
117116
_sb.AppendLine($" public {typeName}? Clone({typeName}? source)");
118117
_sb.AppendLine(" {");
119118

@@ -203,7 +202,7 @@ private void GenerateDispatcher()
203202
// Fallback to reflection-based FastCloner if available, otherwise throw
204203
if (_model.IsFastClonerAvailable)
205204
{
206-
_sb.AppendLine(" return FastCloner.DeepClone(input);");
205+
_sb.AppendLine($" return {CloneGeneratorContext.FastClonerDeepCloneCall("input")};");
207206
}
208207
else
209208
{
@@ -230,7 +229,7 @@ private void GenerateIsHandled()
230229
private void GenerateTryClone()
231230
{
232231
// Only generate NotNullWhen attribute if NotNullIfNotNull is available (they're in the same namespace)
233-
if (_model.HasNotNullIfNotNullAttribute)
232+
if (_model.CodeAnalysisAvailable)
234233
{
235234
_sb.AppendLine(" public override bool TryClone(object input, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out object? clone)");
236235
}

src/FastCloner.SourceGenerator/ContextCollector.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,17 +105,16 @@ public static Result<ContextModel> Collect(GeneratorAttributeSyntaxContext conte
105105
isFastClonerAvailable = false;
106106
}
107107

108-
// Check if NotNullIfNotNullAttribute exists in System.Diagnostics.CodeAnalysis
109-
// Generate attributes only if the built-in attribute is available
110-
bool hasNotNullIfNotNullAttribute = compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null;
108+
// Check if System.Diagnostics.CodeAnalysis attributes are available
109+
bool codeAnalysisAvailable = compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null;
111110

112111
return Result<ContextModel>.Success(new ContextModel(
113112
symbol.Name,
114113
TypeAnalyzer.GetNamespace(symbol),
115114
symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
116115
new EquatableArray<TypeModel>(uniqueTypes),
117116
isFastClonerAvailable,
118-
hasNotNullIfNotNullAttribute
117+
codeAnalysisAvailable
119118
));
120119
}
121120
}

src/FastCloner.SourceGenerator/ContextModel.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ internal record ContextModel(
66
string FullyQualifiedName,
77
EquatableArray<TypeModel> RegisteredTypes,
88
bool IsFastClonerAvailable,
9-
bool HasNotNullIfNotNullAttribute
9+
bool CodeAnalysisAvailable
1010
);

src/FastCloner.SourceGenerator/DerivedTypeCollector.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@ private static TypeModel CreateMinimalTypeModel(
158158
nullabilityEnabled,
159159
trustNullability,
160160
IsRefLikeType: false, // Derived types from abstract base cannot be ref structs
161-
hasParameterlessConstructor);
161+
hasParameterlessConstructor,
162+
CodeAnalysisAvailable: compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null);
162163
}
163164

164165
private static TypeModel? CreateFullTypeModel(
@@ -348,6 +349,7 @@ private static TypeModel CreateMinimalTypeModel(
348349
trustNullability,
349350
IsRefLikeType: false, // Derived types from abstract base cannot be ref structs
350351
hasParameterlessConstructor,
352+
CodeAnalysisAvailable: compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null,
351353
new EquatableArray<string>(circRefLog.ToArray()));
352354
}
353355

src/FastCloner.SourceGenerator/ImplicitTypeAnalyzer.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,9 @@ bool TryHandleComponent(ITypeSymbol componentType, out MemberModel? componentMem
215215
EquatableArray<TypeModel>.Empty, // Implicit types don't track derived types
216216
nullabilityEnabled,
217217
trustNullability,
218-
hasParameterlessConstructor);
218+
IsRefLikeType: false,
219+
hasParameterlessConstructor,
220+
CodeAnalysisAvailable: compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null);
219221

220222
cache[type] = implicitModel;
221223
return true;

src/FastCloner.SourceGenerator/MemberCloneGenerator.cs

Lines changed: 55 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -212,47 +212,72 @@ private static void WriteGetterOnlyCollectionPopulation(CloneGeneratorContext co
212212
if (member.TypeKind != MemberTypeKind.Collection && member.TypeKind != MemberTypeKind.Dictionary)
213213
return;
214214

215-
// Get the helper method that clones this collection type
216-
string helperMethodName = context.GetOrCreateHelperMethodName(member);
217-
bool memberNeedsState = MemberNeedsCircularRefTracking(context, member);
218-
string actualStateVar = memberNeedsState ? stateVar : "null";
219-
220-
// Generate unique variable name for the cloned collection
221-
string clonedVar = $"cloned_{context.GetNextVariableId()}";
222-
223215
// Check if source collection is null
224216
sb.AppendLine($" if ({sourceVar}.{memberName} != null)");
225217
sb.AppendLine(" {");
226218

227-
// Clone the source collection using the existing helper (handles all the complexity)
228-
string helperCall = GetHelperMethodCall(context, helperMethodName, $"{sourceVar}.{memberName}", memberNeedsState, actualStateVar);
229-
sb.AppendLine($" var {clonedVar} = {helperCall};");
230-
sb.AppendLine($" if ({clonedVar} != null)");
231-
sb.AppendLine(" {");
232-
233-
// Clear target and copy from cloned collection
234-
sb.AppendLine($" {resultVar}.{memberName}.Clear();");
219+
// Clear target collection first
220+
sb.AppendLine($" {resultVar}.{memberName}.Clear();");
235221

236-
if (member.TypeKind == MemberTypeKind.Dictionary)
222+
// When [FastClonerShallow] is applied to getter-only collection, shallow clone items (just copy references)
223+
if (member.IsShallowClone)
237224
{
238-
// For dictionaries, copy key-value pairs
239-
sb.AppendLine($" foreach (var kvp in {clonedVar})");
240-
sb.AppendLine(" {");
241-
// Use indexer - works for all dictionary types including ConcurrentDictionary
242-
sb.AppendLine($" {resultVar}.{memberName}[kvp.Key] = kvp.Value;");
243-
sb.AppendLine(" }");
225+
if (member.TypeKind == MemberTypeKind.Dictionary)
226+
{
227+
// For dictionaries, copy key-value pairs directly (shallow)
228+
sb.AppendLine($" foreach (var kvp in {sourceVar}.{memberName})");
229+
sb.AppendLine(" {");
230+
sb.AppendLine($" {resultVar}.{memberName}[kvp.Key] = kvp.Value;");
231+
sb.AppendLine(" }");
232+
}
233+
else
234+
{
235+
// For collections, add items directly (shallow)
236+
string addMethod = GetAddMethodForCollection(member.CollectionKind);
237+
sb.AppendLine($" foreach (var item in {sourceVar}.{memberName})");
238+
sb.AppendLine(" {");
239+
sb.AppendLine($" {resultVar}.{memberName}.{addMethod}(item);");
240+
sb.AppendLine(" }");
241+
}
244242
}
245243
else
246244
{
247-
// For collections, use the appropriate add method based on collection kind
248-
string addMethod = GetAddMethodForCollection(member.CollectionKind);
249-
sb.AppendLine($" foreach (var item in {clonedVar})");
250-
sb.AppendLine(" {");
251-
sb.AppendLine($" {resultVar}.{memberName}.{addMethod}(item);");
252-
sb.AppendLine(" }");
245+
// Deep clone: Get the helper method that clones this collection type
246+
string helperMethodName = context.GetOrCreateHelperMethodName(member);
247+
bool memberNeedsState = MemberNeedsCircularRefTracking(context, member);
248+
string actualStateVar = memberNeedsState ? stateVar : "null";
249+
250+
// Generate unique variable name for the cloned collection
251+
string clonedVar = $"cloned_{context.GetNextVariableId()}";
252+
253+
// Clone the source collection using the existing helper (handles all the complexity)
254+
string helperCall = GetHelperMethodCall(context, helperMethodName, $"{sourceVar}.{memberName}", memberNeedsState, actualStateVar);
255+
sb.AppendLine($" var {clonedVar} = {helperCall};");
256+
sb.AppendLine($" if ({clonedVar} != null)");
257+
sb.AppendLine(" {");
258+
259+
if (member.TypeKind == MemberTypeKind.Dictionary)
260+
{
261+
// For dictionaries, copy key-value pairs
262+
sb.AppendLine($" foreach (var kvp in {clonedVar})");
263+
sb.AppendLine(" {");
264+
// Use indexer - works for all dictionary types including ConcurrentDictionary
265+
sb.AppendLine($" {resultVar}.{memberName}[kvp.Key] = kvp.Value;");
266+
sb.AppendLine(" }");
267+
}
268+
else
269+
{
270+
// For collections, use the appropriate add method based on collection kind
271+
string addMethod = GetAddMethodForCollection(member.CollectionKind);
272+
sb.AppendLine($" foreach (var item in {clonedVar})");
273+
sb.AppendLine(" {");
274+
sb.AppendLine($" {resultVar}.{memberName}.{addMethod}(item);");
275+
sb.AppendLine(" }");
276+
}
277+
278+
sb.AppendLine(" }");
253279
}
254280

255-
sb.AppendLine(" }");
256281
sb.AppendLine(" }");
257282
}
258283

src/FastCloner.SourceGenerator/TypeAnalyzer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ public static (ITypeSymbol KeyType, ITypeSymbol ValueType)? GetDictionaryTypes(I
296296
public static bool HasClonableAttribute(ITypeSymbol type)
297297
{
298298
return type.GetAttributes()
299-
.Any(a => GetFullMetadataName(a.AttributeClass) == "FastCloner.SourceGenerator.Shared.FastClonerClonableAttribute");
299+
.Any(a => a.AttributeClass is not null && GetFullMetadataName(a.AttributeClass) == "FastCloner.SourceGenerator.Shared.FastClonerClonableAttribute");
300300
}
301301

302302
/// <summary>

0 commit comments

Comments
 (0)