Skip to content

Commit eec2753

Browse files
authored
Preserve initializers in deserialization (#312)
Right now initializers are effectively overwritten. For required members this doesn't matter much as they will have to be initialized anyway. But for optional members, or when DenyMissingMembers is false, this is problematic. The current behavior is conservative -- if an initializer isn't known to be "invariant" across initialization, we fall back to default value. This is probably not the right long-term plan, but it is a fine first step.
1 parent 7acc5ee commit eec2753

18 files changed

+527
-19
lines changed

src/generator/DataMemberSymbol.cs

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11

22
using System;
3+
using System.Collections.Generic;
34
using System.Collections.Immutable;
45
using System.Diagnostics;
56
using System.Linq;
67
using System.Text;
78
using Microsoft.CodeAnalysis;
9+
using Microsoft.CodeAnalysis.CSharp;
10+
using Microsoft.CodeAnalysis.CSharp.Syntax;
811

912
namespace Serde
1013
{
@@ -185,5 +188,292 @@ void AddWordAndClear()
185188
}
186189
}
187190
}
191+
192+
/// <summary>
193+
/// Returns the initializer expression text for the field/property with all symbols fully qualified,
194+
/// or null if there is no initializer or the initializer cannot be safely preserved.
195+
/// </summary>
196+
public string? GetInitializer(Compilation compilation)
197+
{
198+
foreach (var syntaxRef in Symbol.DeclaringSyntaxReferences)
199+
{
200+
var syntax = syntaxRef.GetSyntax();
201+
EqualsValueClauseSyntax? initializer = syntax switch
202+
{
203+
VariableDeclaratorSyntax v => v.Initializer,
204+
PropertyDeclarationSyntax p => p.Initializer,
205+
_ => null
206+
};
207+
if (initializer is not null)
208+
{
209+
var semanticModel = compilation.GetSemanticModel(syntax.SyntaxTree);
210+
var qualifiedExpr = QualifyExpression(initializer.Value, semanticModel);
211+
// If the rewriter returned null, the expression can't be safely preserved
212+
if (qualifiedExpr is null)
213+
{
214+
return null;
215+
}
216+
return qualifiedExpr.ToFullString();
217+
}
218+
}
219+
return null;
220+
}
221+
222+
/// <summary>
223+
/// Rewrites an expression to fully qualify all type references.
224+
/// Returns null if the expression cannot be safely preserved.
225+
/// </summary>
226+
private static ExpressionSyntax? QualifyExpression(ExpressionSyntax expr, SemanticModel semanticModel)
227+
{
228+
var rewriter = new FullyQualifyingRewriter(semanticModel);
229+
var result = rewriter.Visit(expr);
230+
if (rewriter.Failed)
231+
{
232+
return null;
233+
}
234+
return (ExpressionSyntax?)result;
235+
}
236+
237+
/// <summary>
238+
/// Format for fully qualified symbol names with global:: prefix.
239+
/// </summary>
240+
private static readonly SymbolDisplayFormat s_fullyQualifiedFormat = SymbolDisplayFormat.FullyQualifiedFormat
241+
.WithMemberOptions(SymbolDisplayMemberOptions.IncludeContainingType);
242+
243+
/// <summary>
244+
/// A syntax rewriter that fully qualifies all type and member references.
245+
/// Sets Failed to true if the expression cannot be safely preserved.
246+
/// </summary>
247+
private sealed class FullyQualifyingRewriter : CSharpSyntaxRewriter
248+
{
249+
private readonly SemanticModel _semanticModel;
250+
251+
public bool Failed { get; private set; }
252+
253+
public FullyQualifyingRewriter(SemanticModel semanticModel)
254+
{
255+
_semanticModel = semanticModel;
256+
}
257+
258+
public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node)
259+
{
260+
var symbolInfo = _semanticModel.GetSymbolInfo(node);
261+
if (symbolInfo.Symbol is ITypeSymbol typeSymbol)
262+
{
263+
var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
264+
return SyntaxFactory.ParseTypeName(fqn).WithTriviaFrom(node);
265+
}
266+
return base.VisitIdentifierName(node);
267+
}
268+
269+
public override SyntaxNode? VisitPredefinedType(PredefinedTypeSyntax node)
270+
{
271+
// Predefined types like 'string', 'int' are always in scope - no need to qualify
272+
return base.VisitPredefinedType(node);
273+
}
274+
275+
public override SyntaxNode? VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
276+
{
277+
var symbolInfo = _semanticModel.GetSymbolInfo(node);
278+
var symbol = symbolInfo.Symbol;
279+
280+
// Handle static field/property/method access like string.Empty or Console.WriteLine
281+
if (symbol is IFieldSymbol { IsStatic: true } or
282+
IPropertySymbol { IsStatic: true } or
283+
IMethodSymbol { IsStatic: true })
284+
{
285+
// Use ToDisplayString to get fully qualified member access
286+
var fqn = symbol.ToDisplayString(s_fullyQualifiedFormat);
287+
return SyntaxFactory.ParseExpression(fqn).WithTriviaFrom(node);
288+
}
289+
290+
// For instance member access, just qualify the expression part
291+
return base.VisitMemberAccessExpression(node);
292+
}
293+
294+
public override SyntaxNode? VisitInvocationExpression(InvocationExpressionSyntax node)
295+
{
296+
var symbolInfo = _semanticModel.GetSymbolInfo(node);
297+
298+
// Check if this is an extension method call
299+
if (symbolInfo.Symbol is IMethodSymbol { IsExtensionMethod: true } method)
300+
{
301+
// Rewrite extension method call to explicit static call
302+
// e.g., list.ToImmutableArray() -> ImmutableArray.ToImmutableArray(list)
303+
var containingType = method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
304+
var methodName = method.Name;
305+
306+
// Get the receiver (the 'this' argument) from the member access
307+
ExpressionSyntax? receiver = null;
308+
if (node.Expression is MemberAccessExpressionSyntax memberAccess)
309+
{
310+
receiver = (ExpressionSyntax?)Visit(memberAccess.Expression);
311+
}
312+
313+
// Build the argument list: receiver + original arguments
314+
var arguments = new List<ArgumentSyntax>();
315+
if (receiver is not null)
316+
{
317+
arguments.Add(SyntaxFactory.Argument(receiver));
318+
}
319+
foreach (var arg in node.ArgumentList.Arguments)
320+
{
321+
var visitedArg = (ArgumentSyntax?)Visit(arg) ?? arg;
322+
arguments.Add(visitedArg);
323+
}
324+
325+
// Handle generic method type arguments
326+
SimpleNameSyntax methodNameSyntax;
327+
if (method.TypeArguments.Length > 0)
328+
{
329+
var typeArgs = method.TypeArguments
330+
.Select(t => SyntaxFactory.ParseTypeName(t.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)))
331+
.ToArray();
332+
methodNameSyntax = SyntaxFactory.GenericName(
333+
SyntaxFactory.Identifier(methodName),
334+
SyntaxFactory.TypeArgumentList(SyntaxFactory.SeparatedList(typeArgs)));
335+
}
336+
else
337+
{
338+
methodNameSyntax = SyntaxFactory.IdentifierName(methodName);
339+
}
340+
341+
// Build: ContainingType.MethodName(receiver, args...)
342+
var qualifiedMethod = SyntaxFactory.MemberAccessExpression(
343+
SyntaxKind.SimpleMemberAccessExpression,
344+
SyntaxFactory.ParseTypeName(containingType),
345+
methodNameSyntax);
346+
347+
return SyntaxFactory.InvocationExpression(
348+
qualifiedMethod,
349+
SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments)))
350+
.WithTriviaFrom(node);
351+
}
352+
353+
return base.VisitInvocationExpression(node);
354+
}
355+
356+
public override SyntaxNode? VisitObjectCreationExpression(ObjectCreationExpressionSyntax node)
357+
{
358+
// If there's a complex initializer (collection init, etc.), don't preserve
359+
if (node.Initializer is not null)
360+
{
361+
Failed = true;
362+
return node;
363+
}
364+
365+
var typeInfo = _semanticModel.GetTypeInfo(node);
366+
if (typeInfo.Type is INamedTypeSymbol namedType)
367+
{
368+
// Only preserve object creation if:
369+
// 1. It has arguments (constructor with params)
370+
// 2. It's a value type (structs always have parameterless constructors)
371+
// 3. It has a parameterless constructor
372+
bool hasArgs = node.ArgumentList?.Arguments.Count > 0;
373+
bool isValueType = namedType.IsValueType;
374+
bool hasParameterlessCtor = namedType.InstanceConstructors
375+
.Any(c => c.Parameters.Length == 0 && c.DeclaredAccessibility == Accessibility.Public);
376+
377+
if (!hasArgs && !isValueType && !hasParameterlessCtor)
378+
{
379+
// Can't safely preserve this - the type doesn't have a parameterless constructor
380+
Failed = true;
381+
return node;
382+
}
383+
384+
var fqn = namedType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
385+
var qualifiedType = SyntaxFactory.ParseTypeName(fqn)
386+
.WithLeadingTrivia(node.Type.GetLeadingTrivia());
387+
388+
var newArgs = node.ArgumentList is not null
389+
? (ArgumentListSyntax?)Visit(node.ArgumentList)
390+
: null;
391+
392+
return SyntaxFactory.ObjectCreationExpression(
393+
node.NewKeyword,
394+
qualifiedType,
395+
newArgs,
396+
null);
397+
}
398+
return base.VisitObjectCreationExpression(node);
399+
}
400+
401+
public override SyntaxNode? VisitImplicitObjectCreationExpression(ImplicitObjectCreationExpressionSyntax node)
402+
{
403+
// Target-typed new like: new() { ... } or new(args)
404+
// These are complex to handle correctly - fail for now
405+
Failed = true;
406+
return node;
407+
}
408+
409+
public override SyntaxNode? VisitImplicitArrayCreationExpression(ImplicitArrayCreationExpressionSyntax node)
410+
{
411+
// Implicit array like: new[] { 1, 2, 3 } is complex - don't preserve
412+
Failed = true;
413+
return node;
414+
}
415+
416+
public override SyntaxNode? VisitGenericName(GenericNameSyntax node)
417+
{
418+
var symbolInfo = _semanticModel.GetSymbolInfo(node);
419+
if (symbolInfo.Symbol is ITypeSymbol typeSymbol)
420+
{
421+
var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
422+
return SyntaxFactory.ParseTypeName(fqn).WithTriviaFrom(node);
423+
}
424+
return base.VisitGenericName(node);
425+
}
426+
427+
public override SyntaxNode? VisitArrayCreationExpression(ArrayCreationExpressionSyntax node)
428+
{
429+
// Array creation with initializer is complex - don't preserve
430+
if (node.Initializer is not null)
431+
{
432+
Failed = true;
433+
return node;
434+
}
435+
return base.VisitArrayCreationExpression(node);
436+
}
437+
438+
public override SyntaxNode? VisitTypeOfExpression(TypeOfExpressionSyntax node)
439+
{
440+
var typeInfo = _semanticModel.GetTypeInfo(node.Type);
441+
if (typeInfo.Type is not null)
442+
{
443+
var fqn = typeInfo.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
444+
return SyntaxFactory.TypeOfExpression(SyntaxFactory.ParseTypeName(fqn))
445+
.WithTriviaFrom(node);
446+
}
447+
return base.VisitTypeOfExpression(node);
448+
}
449+
450+
public override SyntaxNode? VisitDefaultExpression(DefaultExpressionSyntax node)
451+
{
452+
if (node.Type is not null)
453+
{
454+
var typeInfo = _semanticModel.GetTypeInfo(node.Type);
455+
if (typeInfo.Type is not null)
456+
{
457+
var fqn = typeInfo.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
458+
return SyntaxFactory.DefaultExpression(SyntaxFactory.ParseTypeName(fqn))
459+
.WithTriviaFrom(node);
460+
}
461+
}
462+
return base.VisitDefaultExpression(node);
463+
}
464+
465+
public override SyntaxNode? VisitCastExpression(CastExpressionSyntax node)
466+
{
467+
var typeInfo = _semanticModel.GetTypeInfo(node.Type);
468+
if (typeInfo.Type is not null)
469+
{
470+
var fqn = typeInfo.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
471+
var newExpr = (ExpressionSyntax?)Visit(node.Expression) ?? node.Expression;
472+
return SyntaxFactory.CastExpression(SyntaxFactory.ParseTypeName(fqn), newExpr)
473+
.WithTriviaFrom(node);
474+
}
475+
return base.VisitCastExpression(node);
476+
}
477+
}
188478
}
189479
}

src/generator/DeserializeImplGen.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,8 @@ private static SourceBuilder GenerateCustomDeserializeMethod(
302302
readValueCall = $"ReadValue<{memberType}, {memberType}>";
303303
}
304304
var localName = GetLocalName(m);
305-
localsBuilder.AppendLine($"{memberType} {localName} = default!;");
305+
var initializer = m.GetInitializer(context.Compilation) ?? "default!";
306+
localsBuilder.AppendLine($"{memberType} {localName} = {initializer};");
306307

307308
var typeOptions = SymbolUtilities.GetTypeOptions(type);
308309
var duplicateKeyCheck = !typeOptions.AllowDuplicateKeys

test/Serde.Generation.Test/DeserializeTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,45 @@ partial class ArrayField
191191
return VerifyDeserialize(src);
192192
}
193193

194+
[Fact]
195+
public Task FieldInitializers()
196+
{
197+
var src = """
198+
using Serde;
199+
[GenerateDeserialize]
200+
partial class C
201+
{
202+
// Safe initializers - should be preserved
203+
public string Str = "hello";
204+
public int Num = 42;
205+
public bool Flag = true;
206+
public string? Nullable = null;
207+
208+
// Initializers with predefined type static members
209+
public string FromMethod = string.Empty;
210+
}
211+
""";
212+
return VerifyDeserialize(src);
213+
}
214+
215+
[Fact]
216+
public Task ExtensionMethodInitializer()
217+
{
218+
var src = """
219+
using Serde;
220+
using System.Collections.Immutable;
221+
using System.Linq;
222+
223+
[GenerateDeserialize]
224+
partial class C
225+
{
226+
// Extension method - should be rewritten to static call
227+
public ImmutableArray<int> Arr = new[] { 1, 2, 3 }.ToImmutableArray();
228+
}
229+
""";
230+
return VerifyDeserialize(src);
231+
}
232+
194233
[Fact]
195234
public Task EnumMember()
196235
{

test/Serde.Generation.Test/test_output/AllInOneTest.GeneratorTest/Serde.Test.AllInOne.IDeserialize.g.verified.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,17 @@ sealed partial class _DeObj : Serde.IDeserialize<Serde.Test.AllInOne>
2727
int _l_intfield = default!;
2828
long _l_longfield = default!;
2929
System.Int128 _l_int128field = default!;
30-
string _l_stringfield = default!;
30+
string _l_stringfield = "StringValue";
3131
System.DateTimeOffset _l_datetimeoffsetfield = default!;
3232
System.DateTime _l_datetimefield = default!;
3333
System.DateOnly _l_dateonlyfield = default!;
3434
System.TimeOnly _l_timeonlyfield = default!;
3535
System.Guid _l_guidfield = default!;
3636
string _l_escapedstringfield = default!;
37-
string? _l_nullstringfield = default!;
38-
uint[] _l_uintarr = default!;
39-
int[][] _l_nestedarr = default!;
40-
byte[] _l_bytearr = default!;
37+
string? _l_nullstringfield = null;
38+
uint[] _l_uintarr = null!;
39+
int[][] _l_nestedarr = null!;
40+
byte[] _l_bytearr = null!;
4141
System.Collections.Immutable.ImmutableArray<int> _l_intimm = default!;
4242
Serde.Test.AllInOne.ColorEnum _l_color = default!;
4343

0 commit comments

Comments
 (0)