Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0cd298b
Initial plan
Copilot Jan 6, 2026
185a1fe
Support constructors with byref (in/ref/out) parameters in System.Tex…
Copilot Jan 7, 2026
55d3b03
Add tests for constructors with byref (in) parameters in System.Text.…
Copilot Jan 7, 2026
eec5b88
Skip byref constructor parameter tests for source generation serializers
Copilot Jan 8, 2026
34a2088
Enable source generation support for byref constructor parameters
Copilot Jan 8, 2026
316c65d
Add tests for ref and out constructor parameters
Copilot Jan 9, 2026
2509f8c
Fix comments in out parameter test to be more accurate
Copilot Jan 9, 2026
c3a2b48
Add comprehensive byref parameter tests for all modifiers and types
Copilot Jan 12, 2026
7d164fd
Add source generator support for ref/out/ref readonly constructor par…
Copilot Jan 12, 2026
4f8089e
Merge branch 'main' into copilot/fix-json-serialization-issue-again
eiriktsarpalis Jan 12, 2026
7de1ab7
Use RefKind enum values instead of hardcoded numbers in source generator
Copilot Jan 12, 2026
45e9b72
Exclude out parameters from constructor parameter metadata
Copilot Jan 12, 2026
f544feb
Fix assertion failure when all constructor parameters are out parameters
Copilot Jan 13, 2026
73b1657
Merge branch 'main' into copilot/fix-json-serialization-issue-again
stephentoub Jan 29, 2026
17a17f3
Merge branch 'main' into copilot/fix-json-serialization-issue-again
stephentoub Mar 1, 2026
b61559f
Merge branch 'main' into copilot/fix-json-serialization-issue-again
stephentoub Mar 18, 2026
847ff63
Use raw string literals for JSON strings in byref parameter tests
Copilot Mar 18, 2026
c818eee
Merge branch 'main' into copilot/fix-json-serialization-issue-again
eiriktsarpalis Mar 19, 2026
5dcf226
Add source generator output baseline tests for System.Text.Json
eiriktsarpalis Mar 19, 2026
8efdd68
Update comment: RefKind uses int to keep model type independent of Ro…
Copilot Mar 19, 2026
b9c4960
Use RefKind enum directly instead of storing as int
Copilot Mar 19, 2026
139ba78
Restructure source gen baselines to TestId/TFM/HintName.cs.txt
eiriktsarpalis Mar 19, 2026
3134735
Skip type traversal/validation for out constructor parameters
eiriktsarpalis Mar 19, 2026
6213074
Merge branch 'main' into copilot/fix-json-serialization-issue-again
eiriktsarpalis Mar 19, 2026
74278b2
Move baseline infrastructure to bottom of test class
eiriktsarpalis Mar 19, 2026
5c246d5
Merge branch 'main' into copilot/fix-json-serialization-issue-again
eiriktsarpalis Mar 19, 2026
987c83a
Merge branch 'main' into copilot/fix-json-serialization-issue-again
eiriktsarpalis Mar 19, 2026
4372d6b
Merge branch 'main' into copilot/fix-json-serialization-issue-again
eiriktsarpalis Mar 19, 2026
6948784
Merge branch 'main' into copilot/fix-json-serialization-issue-again
eiriktsarpalis Mar 20, 2026
e870573
Fix crash in small constructor path when handling byref parameters
Copilot Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 60 additions & 9 deletions src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Linq;
using System.Reflection;
using System.Text.Json.Serialization;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
using SourceGenerators;
Expand Down Expand Up @@ -729,8 +730,11 @@ private static void GenerateCtorParamMetadataInitFunc(SourceWriter writer, strin
{
ImmutableEquatableArray<ParameterGenerationSpec> parameters = typeGenerationSpec.CtorParamGenSpecs;
ImmutableEquatableArray<PropertyInitializerGenerationSpec> propertyInitializers = typeGenerationSpec.PropertyInitializerSpecs;
int paramCount = parameters.Count + propertyInitializers.Count(propInit => !propInit.MatchesConstructorParameter);
Debug.Assert(paramCount > 0);

// out parameters don't appear in metadata - they don't receive values from JSON.
int nonOutParamCount = parameters.Count(p => p.RefKind != RefKind.Out);
int paramCount = nonOutParamCount + propertyInitializers.Count(propInit => !propInit.MatchesConstructorParameter);
Debug.Assert(paramCount > 0 || parameters.Any(p => p.RefKind == RefKind.Out));

writer.WriteLine($"private static {JsonParameterInfoValuesTypeRef}[] {ctorParamMetadataInitMethodName}() => new {JsonParameterInfoValuesTypeRef}[]");
writer.WriteLine('{');
Expand All @@ -739,12 +743,19 @@ private static void GenerateCtorParamMetadataInitFunc(SourceWriter writer, strin
int i = 0;
foreach (ParameterGenerationSpec spec in parameters)
{
// Skip out parameters - they don't receive values from JSON deserialization.
if (spec.RefKind == RefKind.Out)
{
continue;
}

Debug.Assert(spec.ArgsIndex >= 0);
writer.WriteLine($$"""
new()
{
Name = {{FormatStringLiteral(spec.Name)}},
ParameterType = typeof({{spec.ParameterType.FullyQualifiedName}}),
Position = {{spec.ParameterIndex}},
Position = {{spec.ArgsIndex}},
HasDefaultValue = {{FormatBoolLiteral(spec.HasDefaultValue)}},
DefaultValue = {{(spec.HasDefaultValue ? CSharpSyntaxUtilities.FormatLiteral(spec.DefaultValue, spec.ParameterType) : "null")}},
IsNullable = {{FormatBoolLiteral(spec.IsNullable)}},
Expand Down Expand Up @@ -928,21 +939,47 @@ static void ThrowPropertyNullException(string propertyName)
writer.WriteLine('}');
}

// RefKind.RefReadOnlyParameter was added in Roslyn 4.4
private const RefKind RefKindRefReadOnlyParameter = (RefKind)4;

private static string GetParameterizedCtorInvocationFunc(TypeGenerationSpec typeGenerationSpec)
{
ImmutableEquatableArray<ParameterGenerationSpec> parameters = typeGenerationSpec.CtorParamGenSpecs;
ImmutableEquatableArray<PropertyInitializerGenerationSpec> propertyInitializers = typeGenerationSpec.PropertyInitializerSpecs;

const string ArgsVarName = "args";

StringBuilder sb = new($"static {ArgsVarName} => new {typeGenerationSpec.TypeRef.FullyQualifiedName}(");
bool hasRefOrRefReadonlyParams = parameters.Any(p => p.RefKind == RefKind.Ref || p.RefKind == RefKindRefReadOnlyParameter);

StringBuilder sb;

if (hasRefOrRefReadonlyParams)
{
// For ref/ref readonly parameters, we need a block lambda with temp variables
sb = new($"static {ArgsVarName} => {{ ");

// Declare temp variables for ref and ref readonly parameters
foreach (ParameterGenerationSpec param in parameters)
{
if (param.RefKind == RefKind.Ref || param.RefKind == RefKindRefReadOnlyParameter)
{
// Use ArgsIndex to access the args array (out params don't have entries in args)
sb.Append($"var __temp{param.ParameterIndex} = ({param.ParameterType.FullyQualifiedName}){ArgsVarName}[{param.ArgsIndex}]; ");
}
}

sb.Append($"return new {typeGenerationSpec.TypeRef.FullyQualifiedName}(");
}
else
{
sb = new($"static {ArgsVarName} => new {typeGenerationSpec.TypeRef.FullyQualifiedName}(");
}

if (parameters.Count > 0)
{
foreach (ParameterGenerationSpec param in parameters)
{
int index = param.ParameterIndex;
sb.Append($"{GetParamUnboxing(param.ParameterType, index)}, ");
sb.Append($"{GetParamExpression(param, ArgsVarName)}, ");
}

sb.Length -= 2; // delete the last ", " token
Expand All @@ -955,17 +992,31 @@ private static string GetParameterizedCtorInvocationFunc(TypeGenerationSpec type
sb.Append("{ ");
foreach (PropertyInitializerGenerationSpec property in propertyInitializers)
{
sb.Append($"{property.Name} = {GetParamUnboxing(property.ParameterType, property.ParameterIndex)}, ");
sb.Append($"{property.Name} = ({property.ParameterType.FullyQualifiedName}){ArgsVarName}[{property.ParameterIndex}], ");
}

sb.Length -= 2; // delete the last ", " token
sb.Append(" }");
}

if (hasRefOrRefReadonlyParams)
{
sb.Append("; }");
}

return sb.ToString();

static string GetParamUnboxing(TypeRef type, int index)
=> $"({type.FullyQualifiedName}){ArgsVarName}[{index}]";
static string GetParamExpression(ParameterGenerationSpec param, string argsVarName)
{
return param.RefKind switch
{
RefKind.Ref => $"ref __temp{param.ParameterIndex}",
RefKind.Out => $"out var __discard{param.ParameterIndex}",
RefKindRefReadOnlyParameter => $"in __temp{param.ParameterIndex}",
// Use ArgsIndex to access the args array (out params don't have entries in args)
_ => $"({param.ParameterType.FullyQualifiedName}){argsVarName}[{param.ArgsIndex}]", // None or In (in doesn't require keyword at call site)
};
}
}

private static string? GetPrimitiveWriterMethod(TypeGenerationSpec type)
Expand Down
25 changes: 21 additions & 4 deletions src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1643,6 +1643,9 @@ private void ProcessMember(
constructionStrategy = ObjectConstructionStrategy.ParameterizedConstructor;
constructorParameters = new ParameterGenerationSpec[paramCount];

// Compute ArgsIndex for each parameter.
// out parameters don't have entries in the args array.
int argsIndex = 0;
for (int i = 0; i < paramCount; i++)
{
IParameterSymbol parameterInfo = constructor.Parameters[i];
Expand All @@ -1654,7 +1657,14 @@ private void ProcessMember(
continue;
}

TypeRef parameterTypeRef = EnqueueType(parameterInfo.Type, typeToGenerate.Mode);
// Don't enqueue out parameter types for JSON contract generation — they
// aren't deserialized and may reference unsupported types (e.g. Task).
TypeRef parameterTypeRef = parameterInfo.RefKind == RefKind.Out
? new TypeRef(parameterInfo.Type)
: EnqueueType(parameterInfo.Type, typeToGenerate.Mode);

// out parameters don't receive values from JSON, so they have ArgsIndex = -1.
int currentArgsIndex = parameterInfo.RefKind == RefKind.Out ? -1 : argsIndex++;

constructorParameters[i] = new ParameterGenerationSpec
{
Expand All @@ -1663,7 +1673,9 @@ private void ProcessMember(
HasDefaultValue = parameterInfo.HasExplicitDefaultValue,
DefaultValue = parameterInfo.HasExplicitDefaultValue ? parameterInfo.ExplicitDefaultValue : null,
ParameterIndex = i,
ArgsIndex = currentArgsIndex,
IsNullable = parameterInfo.IsNullable(),
RefKind = parameterInfo.RefKind,
};
}
}
Expand All @@ -1684,7 +1696,9 @@ private void ProcessMember(

HashSet<string>? memberInitializerNames = null;
List<PropertyInitializerGenerationSpec>? propertyInitializers = null;
int paramCount = constructorParameters?.Length ?? 0;

// Count non-out constructor parameters - out params don't have entries in the args array.
int paramCount = constructorParameters?.Count(p => p.RefKind != RefKind.Out) ?? 0;

// Determine potential init-only or required properties that need to be part of the constructor delegate signature.
foreach (PropertyGenerationSpec property in properties)
Expand Down Expand Up @@ -1724,7 +1738,8 @@ private void ProcessMember(
Name = property.NameSpecifiedInSourceCode,
ParameterType = property.PropertyType,
MatchesConstructorParameter = matchingConstructorParameter is not null,
ParameterIndex = matchingConstructorParameter?.ParameterIndex ?? paramCount++,
// Use ArgsIndex for matching ctor params (excludes out params), or paramCount++ for new ones
ParameterIndex = matchingConstructorParameter?.ArgsIndex ?? paramCount++,
IsNullable = property.PropertyType.CanBeNull && !property.IsSetterNonNullableAnnotation,
};

Expand All @@ -1736,7 +1751,9 @@ private void ProcessMember(
return paramGenSpecs?.FirstOrDefault(MatchesConstructorParameter);

bool MatchesConstructorParameter(ParameterGenerationSpec paramSpec)
=> propSpec.MemberName.Equals(paramSpec.Name, StringComparison.OrdinalIgnoreCase);
// Don't match out parameters - they don't receive values from JSON.
=> paramSpec.RefKind != RefKind.Out &&
propSpec.MemberName.Equals(paramSpec.Name, StringComparison.OrdinalIgnoreCase);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.CodeAnalysis;
using SourceGenerators;

namespace System.Text.Json.SourceGeneration
Expand Down Expand Up @@ -31,7 +32,23 @@ public sealed record ParameterGenerationSpec
// The default value of a constructor parameter can only be a constant
// so it always satisfies the structural equality requirement for the record.
public required object? DefaultValue { get; init; }

/// <summary>
/// The zero-based position of the parameter in the constructor's formal parameter list.
/// </summary>
public required int ParameterIndex { get; init; }

/// <summary>
/// The zero-based index into the args array for this parameter.
/// For out parameters, this is -1 since they don't receive values from the args array.
/// </summary>
public required int ArgsIndex { get; init; }

public required bool IsNullable { get; init; }

/// <summary>
/// The ref kind of the parameter (None, Ref, Out, In, or RefReadOnlyParameter).
/// </summary>
public required RefKind RefKind { get; init; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,22 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer

foreach (ParameterInfo parameter in parameters)
{
// Skip out parameters — they don't receive values from JSON
// and may reference unsupported types (e.g. Task).
if (parameter.IsOut)
{
continue;
}

// Every argument must be of supported type.
JsonTypeInfo.ValidateType(parameter.ParameterType);
// For byref parameters (in/ref), validate the underlying element type.
Type parameterType = parameter.ParameterType;
if (parameterType.IsByRef)
{
parameterType = parameterType.GetElementType()!;
}

JsonTypeInfo.ValidateType(parameterType);
}

if (parameterCount <= JsonConstants.UnboxedParameterCountThreshold)
Expand All @@ -75,7 +89,24 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer
{
if (i < parameterCount)
{
typeArguments[i + 1] = parameters[i].ParameterType;
// out parameters use placeholder type — they aren't deserialized
// and may reference types that can't be used as generic arguments.
if (parameters[i].IsOut)
{
typeArguments[i + 1] = placeHolderType;
}
else
{
// For byref parameters (in/ref), use the underlying element type
// since byref types cannot be used as generic type arguments.
Type parameterType = parameters[i].ParameterType;
if (parameterType.IsByRef)
{
parameterType = parameterType.GetElementType()!;
}

typeArguments[i + 1] = parameterType;
}
}
else
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,31 +319,56 @@ private static void PopulateParameterInfoValues(JsonTypeInfo typeInfo, Nullabili
{
Debug.Assert(typeInfo.Converter.ConstructorInfo != null);
ParameterInfo[] parameters = typeInfo.Converter.ConstructorInfo.GetParameters();
int parameterCount = parameters.Length;
JsonParameterInfoValues[] jsonParameters = new JsonParameterInfoValues[parameterCount];

for (int i = 0; i < parameterCount; i++)
// Count non-out parameters - out parameters don't receive values from JSON.
int nonOutParameterCount = 0;
foreach (ParameterInfo param in parameters)
{
if (!param.IsOut)
{
nonOutParameterCount++;
}
}

JsonParameterInfoValues[] jsonParameters = new JsonParameterInfoValues[nonOutParameterCount];

int jsonParamIndex = 0;
for (int i = 0; i < parameters.Length; i++)
{
ParameterInfo reflectionInfo = parameters[i];

// Skip out parameters - they don't receive values from JSON deserialization.
if (reflectionInfo.IsOut)
{
continue;
}

// Trimmed parameter names are reported as null in CoreCLR or "" in Mono.
if (string.IsNullOrEmpty(reflectionInfo.Name))
{
Debug.Assert(typeInfo.Converter.ConstructorInfo.DeclaringType != null);
ThrowHelper.ThrowNotSupportedException_ConstructorContainsNullParameterNames(typeInfo.Converter.ConstructorInfo.DeclaringType);
}

// For byref parameters (in/ref), use the underlying element type.
Type parameterType = reflectionInfo.ParameterType;
if (parameterType.IsByRef)
{
parameterType = parameterType.GetElementType()!;
}

JsonParameterInfoValues jsonInfo = new()
{
Name = reflectionInfo.Name,
ParameterType = reflectionInfo.ParameterType,
Position = reflectionInfo.Position,
ParameterType = parameterType,
Position = jsonParamIndex, // Use the position in the args array, not the constructor parameter index
HasDefaultValue = reflectionInfo.HasDefaultValue,
DefaultValue = reflectionInfo.GetDefaultValue(),
IsNullable = DetermineParameterNullability(reflectionInfo, nullabilityCtx) is not NullabilityState.NotNull,
};

jsonParameters[i] = jsonInfo;
jsonParameters[jsonParamIndex] = jsonInfo;
jsonParamIndex++;
}

typeInfo.PopulateParameterInfoValues(jsonParameters);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1145,11 +1145,6 @@ internal void ConfigureProperties()

internal void PopulateParameterInfoValues(JsonParameterInfoValues[] parameterInfoValues)
{
if (parameterInfoValues.Length == 0)
{
return;
}

Dictionary<ParameterLookupKey, JsonParameterInfoValues> parameterIndex = new(parameterInfoValues.Length);
foreach (JsonParameterInfoValues parameterInfoValue in parameterInfoValues)
{
Expand Down
Loading
Loading