Skip to content
Merged
57 changes: 57 additions & 0 deletions src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ public Parser(KnownTypeSymbols knownSymbols)
return null;
}

// When a context class is split across multiple partial declarations with
// [JsonSerializable] attributes on different partials, we only want to
// generate code once (from the canonical partial) to avoid duplicate hintNames.
if (!IsCanonicalPartialDeclaration(contextTypeSymbol, contextClassDeclaration))
{
_contextClassLocation = null;
return null;
}

ParseJsonSerializerContextAttributes(contextTypeSymbol,
out List<TypeToGenerate>? rootSerializableTypes,
out SourceGenerationOptionsSpec? options);
Expand Down Expand Up @@ -258,6 +267,54 @@ private void ParseJsonSerializerContextAttributes(
}
}

/// <summary>
/// Determines if the given class declaration is the canonical partial declaration
/// for the context type. When a context class is split across multiple partial
/// declarations with [JsonSerializable] attributes on different partials, we only
/// want to generate code once (from the canonical partial) to avoid duplicate hintNames.
/// The canonical partial is determined by picking the first syntax tree alphabetically
/// by file path among all trees that have at least one [JsonSerializable] attribute.
/// If file paths are empty or identical, comparison falls back to ordinal string order
/// which provides deterministic behavior. If no attributes are found (edge case that
/// shouldn't occur since this method is called from a context triggered by the attribute),
/// the current partial is treated as canonical.
/// </summary>
private bool IsCanonicalPartialDeclaration(INamedTypeSymbol contextTypeSymbol, ClassDeclarationSyntax contextClassDeclaration)
{
Debug.Assert(_knownSymbols.JsonSerializableAttributeType != null);

// Collect all distinct syntax trees that have [JsonSerializable] attributes for this type
SyntaxTree? canonicalTree = null;

foreach (AttributeData attributeData in contextTypeSymbol.GetAttributes())
{
if (!SymbolEqualityComparer.Default.Equals(attributeData.AttributeClass, _knownSymbols.JsonSerializableAttributeType))
{
continue;
}

SyntaxTree? attributeTree = attributeData.ApplicationSyntaxReference?.SyntaxTree;
if (attributeTree is null)
{
continue;
}

// Pick the first tree alphabetically by file path.
// Empty file paths compare as less than non-empty paths with ordinal comparison.
if (canonicalTree is null ||
string.Compare(attributeTree.FilePath, canonicalTree.FilePath, StringComparison.Ordinal) < 0)
{
canonicalTree = attributeTree;
}
}

// This partial is canonical if its syntax tree is the canonical tree.
// If canonicalTree is null (no attributes found), treat current partial as canonical.
// This is a fallback that shouldn't normally occur since this method is called
// from a context triggered by ForAttributeWithMetadataName for JsonSerializableAttribute.
return canonicalTree is null || canonicalTree == contextClassDeclaration.SyntaxTree;
}

Comment thread
stephentoub marked this conversation as resolved.
private SourceGenerationOptionsSpec ParseJsonSourceGenerationOptionsAttribute(INamedTypeSymbol contextType, AttributeData attributeData)
{
JsonSourceGenerationMode? generationMode = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,14 @@ public partial class NestedGenericInNestedGenericContainerContext<T2> : JsonSeri
[JsonSerializable(typeof(MyContainingGenericClass<int>.MyNestedGenericClass<int>.MyNestedGenericNestedGenericClass<int>))]
[JsonSerializable(typeof(MyContainingGenericClass<MyContainingGenericClass<int>.MyNestedGenericClass<int>.MyNestedGenericNestedGenericClass<int>>.MyNestedGenericClass<int>.MyNestedGenericNestedGenericClass<int>))]
internal partial class NestedGenericTypesContext : JsonSerializerContext { }

// Test classes for partial context with attributes on multiple declarations
public class PartialClassFromFirstFile
{
public int Value { get; set; }
}

// First partial declaration of the context - declares PartialClassFromFirstFile
[JsonSerializable(typeof(PartialClassFromFirstFile))]
internal partial class MultiplePartialDeclarationsContext : JsonSerializerContext { }
Comment thread
stephentoub marked this conversation as resolved.
Outdated
}
Original file line number Diff line number Diff line change
Expand Up @@ -1035,5 +1035,90 @@ public partial class MyContext : JsonSerializerContext
Assert.Empty(errors);
}
}

[Fact]
public void PartialContextClassWithAttributesOnMultipleDeclarations()
{
// Test for https://github.com/dotnet/runtime/issues/97460
Comment thread
stephentoub marked this conversation as resolved.
Outdated
// When a JsonSerializerContext is defined across multiple partial class declarations
// with [JsonSerializable] attributes on different declarations, the generator should
// successfully generate code without duplicate hintName errors.

string source1 = """
using System.Text.Json.Serialization;

namespace HelloWorld
{
public class MyClass1
{
public int Value { get; set; }
}

public class MyClass2
{
public string Name { get; set; }
}

[JsonSerializable(typeof(MyClass1))]
internal partial class SerializerContext : JsonSerializerContext
{
}
}
""";

string source2 = """
using System.Text.Json.Serialization;

namespace HelloWorld
{
[JsonSerializable(typeof(MyClass2))]
internal partial class SerializerContext
{
}
}
""";

// Create compilation with multiple syntax trees to simulate partial class declarations in different files
Compilation compilation = CSharpCompilation.Create(
"TestAssembly",
syntaxTrees:
[
CSharpSyntaxTree.ParseText(source1, CompilationHelper.CreateParseOptions()),
CSharpSyntaxTree.ParseText(source2, CompilationHelper.CreateParseOptions()),
Comment thread
stephentoub marked this conversation as resolved.
Outdated
],
references:
[
MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
MetadataReference.CreateFromFile(typeof(JsonSerializerOptions).Assembly.Location),
MetadataReference.CreateFromFile(typeof(System.Text.Encodings.Web.JavaScriptEncoder).Assembly.Location),
#if NET
MetadataReference.CreateFromFile(typeof(System.Collections.Generic.LinkedList<>).Assembly.Location),
MetadataReference.CreateFromFile(System.Reflection.Assembly.Load(new System.Reflection.AssemblyName("System.Runtime")).Location),
#endif
],
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

JsonSourceGeneratorResult result = CompilationHelper.RunJsonSourceGenerator(compilation, logger: logger);

// Verify no errors
var errors = result.Diagnostics
.Where(d => d.Severity == DiagnosticSeverity.Error)
.ToList();

Assert.Empty(errors);

// Verify a single combined context was generated containing both types
// (not two separate contexts, which would cause duplicate hintName errors)
Assert.Equal(1, result.ContextGenerationSpecs.Length);
result.AssertContainsType("global::HelloWorld.MyClass1");
result.AssertContainsType("global::HelloWorld.MyClass2");

// Verify the generated code compiles without errors
var compilationErrors = result.NewCompilation.GetDiagnostics()
.Where(d => d.Severity == DiagnosticSeverity.Error)
.ToList();

Assert.Empty(compilationErrors);
}
}
}
Loading