From f2134a8fb37f4e9abd002bd2423f6c2a5ccd7cae Mon Sep 17 00:00:00 2001 From: Flero Date: Sat, 7 Nov 2026 20:33:05 +1030 Subject: [PATCH 1/2] Create declaring types when creating a CodeBuilder for ITypeSymbol. --- src/CodeGenHelpers/CodeBuilder.cs | 40 ++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/CodeGenHelpers/CodeBuilder.cs b/src/CodeGenHelpers/CodeBuilder.cs index a1f282c..ee1b838 100644 --- a/src/CodeGenHelpers/CodeBuilder.cs +++ b/src/CodeGenHelpers/CodeBuilder.cs @@ -69,10 +69,48 @@ public static CodeBuilder Create(INamespaceSymbol namespaceSymbol, IndentStyle i public static ClassBuilder Create(ITypeSymbol typeSymbol, IndentStyle indentStyle = IndentStyle.Spaces) { + var containingTypes = new List(); + + if (typeSymbol.ContainingType != null) + { + var currentType = typeSymbol; + + while (currentType.ContainingType is { } containingType) + { + containingTypes.Add(containingType); + currentType = containingType; + } + + containingTypes.Reverse(); + } + var builder = Create(typeSymbol.ContainingNamespace, indentStyle); - return builder.AddClass(typeSymbol.Name) + ClassBuilder? classBuilder = null; + + foreach (var containingType in containingTypes) + { + classBuilder = AddClass(containingType.Name) + .WithAccessModifier(containingType.DeclaredAccessibility) + .OfType(containingType.TypeKind); + } + + return AddClass(typeSymbol.Name) .WithAccessModifier(typeSymbol.DeclaredAccessibility) .OfType(typeSymbol.TypeKind); + + ClassBuilder AddClass(string name) + { + if (classBuilder != null) + { + classBuilder = classBuilder.AddNestedClass(name, true); + } + else + { + classBuilder = builder.AddClass(name); + } + + return classBuilder; + } } public CodeBuilder AddNamespaceImport(string importedNamespace) From a6746f47a575a91c82a78731360534421466cdf9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Nov 2026 16:17:54 +0000 Subject: [PATCH 2/2] Add tests for nested partials created from an ITypeSymbol. Copy type parameters, constraints, and class modifiers onto those partials so nested symbols compile with their containing types. Emit struct instead of structure. Co-authored-by: Dan Siegel --- src/CodeGenHelpers/ClassBuilder.cs | 5 +- src/CodeGenHelpers/CodeBuilder.cs | 71 ++++- .../ClassNestedInStructFromTypeSymbol.cs | 18 ++ .../GenericNestedClassFromTypeSymbol.cs | 24 ++ .../MultiLevelNestedClassFromTypeSymbol.cs | 21 ++ .../NonNestedClassFromTypeSymbol.cs | 15 + .../SingleNestedClassFromTypeSymbol.cs | 18 ++ .../CodeBuilderCreateFromTypeSymbolTests.cs | 282 ++++++++++++++++++ 8 files changed, 444 insertions(+), 10 deletions(-) create mode 100644 tests/CodeGenHelpers.Tests/SampleCode/ClassNestedInStructFromTypeSymbol.cs create mode 100644 tests/CodeGenHelpers.Tests/SampleCode/GenericNestedClassFromTypeSymbol.cs create mode 100644 tests/CodeGenHelpers.Tests/SampleCode/MultiLevelNestedClassFromTypeSymbol.cs create mode 100644 tests/CodeGenHelpers.Tests/SampleCode/NonNestedClassFromTypeSymbol.cs create mode 100644 tests/CodeGenHelpers.Tests/SampleCode/SingleNestedClassFromTypeSymbol.cs create mode 100644 tests/CodeGenHelpers.Tests/Tests/CodeBuilderCreateFromTypeSymbolTests.cs diff --git a/src/CodeGenHelpers/ClassBuilder.cs b/src/CodeGenHelpers/ClassBuilder.cs index e5ccaa3..9f74d31 100644 --- a/src/CodeGenHelpers/ClassBuilder.cs +++ b/src/CodeGenHelpers/ClassBuilder.cs @@ -343,7 +343,7 @@ internal override void Write(in CodeWriter writer) IsSealed ? "sealed" : null, IsAbstract ? "abstract" : null, _isPartial ? "partial" : null, - Kind.ToString().ToLowerInvariant(), + KindKeyword(Kind), $"{Name}{_generics}", extra }; @@ -393,6 +393,9 @@ internal override void Write(in CodeWriter writer) } } + private static string KindKeyword(TypeKind kind) => + kind == TypeKind.Struct ? "struct" : kind.ToString().ToLowerInvariant(); + private static void WriteClassAttributes(IEnumerable classAttributes, in CodeWriter writer) { foreach (var attr in classAttributes.Distinct().OrderBy(x => x)) diff --git a/src/CodeGenHelpers/CodeBuilder.cs b/src/CodeGenHelpers/CodeBuilder.cs index ee1b838..9800ffe 100644 --- a/src/CodeGenHelpers/CodeBuilder.cs +++ b/src/CodeGenHelpers/CodeBuilder.cs @@ -16,6 +16,10 @@ public sealed class CodeBuilder Changes to this file may cause incorrect behavior and will be lost if the code is regenerated."; + private static readonly SymbolDisplayFormat ConstraintDisplayFormat = new SymbolDisplayFormat( + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes); private string? _autoGeneratedMessage; private readonly List _namespaceImports = new List(); private readonly List _assemblyAttributes = new List(); @@ -86,33 +90,82 @@ public static ClassBuilder Create(ITypeSymbol typeSymbol, IndentStyle indentStyl var builder = Create(typeSymbol.ContainingNamespace, indentStyle); ClassBuilder? classBuilder = null; + // Top-level symbols keep the historical name, accessibility, and kind. + // Nested symbols also repeat generics and modifiers on each partial. + var copyDeclarationDetails = containingTypes.Count > 0; foreach (var containingType in containingTypes) { - classBuilder = AddClass(containingType.Name) - .WithAccessModifier(containingType.DeclaredAccessibility) - .OfType(containingType.TypeKind); + classBuilder = AddClass(containingType); } - return AddClass(typeSymbol.Name) - .WithAccessModifier(typeSymbol.DeclaredAccessibility) - .OfType(typeSymbol.TypeKind); + return AddClass(typeSymbol); - ClassBuilder AddClass(string name) + ClassBuilder AddClass(ITypeSymbol symbol) { if (classBuilder != null) { - classBuilder = classBuilder.AddNestedClass(name, true); + classBuilder = classBuilder.AddNestedClass(symbol.Name, true); } else { - classBuilder = builder.AddClass(name); + classBuilder = builder.AddClass(symbol.Name); } + classBuilder + .WithAccessModifier(symbol.DeclaredAccessibility) + .OfType(symbol.TypeKind); + + if (copyDeclarationDetails) + CopyDeclarationDetails(classBuilder, symbol); + return classBuilder; } } + private static void CopyDeclarationDetails(ClassBuilder classBuilder, ITypeSymbol symbol) + { + if (symbol.IsStatic) + { + classBuilder.MakeStaticClass(); + } + else if (symbol.TypeKind == TypeKind.Class) + { + // Structs report IsSealed, but `sealed struct` does not compile. + // Static classes are handled above and must not also be abstract or sealed. + if (symbol.IsAbstract) + classBuilder.Abstract(); + + if (symbol.IsSealed) + classBuilder.Sealed(); + } + + if (symbol is not INamedTypeSymbol namedType) + return; + + foreach (var typeParameter in namedType.TypeParameters) + { + classBuilder.AddGeneric(typeParameter.Name, generic => + { + if (typeParameter.HasReferenceTypeConstraint) + generic.Class(); + else if (typeParameter.HasUnmanagedTypeConstraint) + generic.AddConstraint("unmanaged"); + else if (typeParameter.HasValueTypeConstraint) + generic.AddConstraint("struct"); + + if (typeParameter.HasNotNullConstraint) + generic.AddConstraint("notnull"); + + foreach (var constraintType in typeParameter.ConstraintTypes) + generic.AddConstraint(constraintType.ToDisplayString(ConstraintDisplayFormat)); + + if (typeParameter.HasConstructorConstraint) + generic.New(); + }); + } + } + public CodeBuilder AddNamespaceImport(string importedNamespace) { var value = Regex.Replace(importedNamespace, @"^using\w+", string.Empty).Replace(";", string.Empty); diff --git a/tests/CodeGenHelpers.Tests/SampleCode/ClassNestedInStructFromTypeSymbol.cs b/tests/CodeGenHelpers.Tests/SampleCode/ClassNestedInStructFromTypeSymbol.cs new file mode 100644 index 0000000..4a581a0 --- /dev/null +++ b/tests/CodeGenHelpers.Tests/SampleCode/ClassNestedInStructFromTypeSymbol.cs @@ -0,0 +1,18 @@ +//------------------------------------------------------------------------------ +// +// This code was generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Demo.Containers +{ + internal partial struct OuterStruct + { + public partial class NestedValue + { + } + } +} diff --git a/tests/CodeGenHelpers.Tests/SampleCode/GenericNestedClassFromTypeSymbol.cs b/tests/CodeGenHelpers.Tests/SampleCode/GenericNestedClassFromTypeSymbol.cs new file mode 100644 index 0000000..dcc24c0 --- /dev/null +++ b/tests/CodeGenHelpers.Tests/SampleCode/GenericNestedClassFromTypeSymbol.cs @@ -0,0 +1,24 @@ +//------------------------------------------------------------------------------ +// +// This code was generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Demo.Containers +{ + internal static partial class Outer + where T : class + { + protected internal abstract partial class Middle + where TItem : struct + { + private protected sealed partial class Inner + where TResult : class, System.IComparable + { + } + } + } +} diff --git a/tests/CodeGenHelpers.Tests/SampleCode/MultiLevelNestedClassFromTypeSymbol.cs b/tests/CodeGenHelpers.Tests/SampleCode/MultiLevelNestedClassFromTypeSymbol.cs new file mode 100644 index 0000000..c8abed8 --- /dev/null +++ b/tests/CodeGenHelpers.Tests/SampleCode/MultiLevelNestedClassFromTypeSymbol.cs @@ -0,0 +1,21 @@ +//------------------------------------------------------------------------------ +// +// This code was generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Demo.Containers +{ + internal partial class Outer + { + protected internal partial class Middle + { + private protected partial class Inner + { + } + } + } +} diff --git a/tests/CodeGenHelpers.Tests/SampleCode/NonNestedClassFromTypeSymbol.cs b/tests/CodeGenHelpers.Tests/SampleCode/NonNestedClassFromTypeSymbol.cs new file mode 100644 index 0000000..89ae165 --- /dev/null +++ b/tests/CodeGenHelpers.Tests/SampleCode/NonNestedClassFromTypeSymbol.cs @@ -0,0 +1,15 @@ +//------------------------------------------------------------------------------ +// +// This code was generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Demo +{ + public partial class Widget + { + } +} diff --git a/tests/CodeGenHelpers.Tests/SampleCode/SingleNestedClassFromTypeSymbol.cs b/tests/CodeGenHelpers.Tests/SampleCode/SingleNestedClassFromTypeSymbol.cs new file mode 100644 index 0000000..60d655c --- /dev/null +++ b/tests/CodeGenHelpers.Tests/SampleCode/SingleNestedClassFromTypeSymbol.cs @@ -0,0 +1,18 @@ +//------------------------------------------------------------------------------ +// +// This code was generated. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Demo.Containers +{ + public partial class Outer + { + internal partial class Inner + { + } + } +} diff --git a/tests/CodeGenHelpers.Tests/Tests/CodeBuilderCreateFromTypeSymbolTests.cs b/tests/CodeGenHelpers.Tests/Tests/CodeBuilderCreateFromTypeSymbolTests.cs new file mode 100644 index 0000000..bf542aa --- /dev/null +++ b/tests/CodeGenHelpers.Tests/Tests/CodeBuilderCreateFromTypeSymbolTests.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; +using Xunit.Abstractions; + +namespace CodeGenHelpers.Tests +{ + public class CodeBuilderCreateFromTypeSymbolTests : TestBase + { + private const string NonNestedSource = @" +namespace Demo +{ + public partial class Widget + { + } +}"; + + private const string SingleNestedSource = @" +namespace Demo.Containers +{ + public partial class Outer + { + internal partial class Inner + { + } + } +}"; + + private const string MultiLevelSource = @" +namespace Demo.Containers +{ + internal partial class Outer + { + protected internal partial class Middle + { + private protected partial class Inner + { + } + } + } +}"; + + private const string GenericNestedSource = @" +using System; + +namespace Demo.Containers +{ + internal static partial class Outer + where T : class + { + protected internal abstract partial class Middle + where TItem : struct + { + private protected sealed partial class Inner + where TResult : class, IComparable + { + } + } + } +}"; + + private const string StructContainerSource = @" +namespace Demo.Containers +{ + internal partial struct OuterStruct + { + public partial class NestedValue + { + } + } +}"; + + public CodeBuilderCreateFromTypeSymbolTests(ITestOutputHelper testOutput) + : base(testOutput) + { + } + + [Fact] + public void Create_FromNonNestedType_KeepsSinglePartialClass() + { + var symbol = GetNamedType(NonNestedSource, "Demo.Widget"); + + var builder = CodeBuilder.Create(symbol); + + AssertNesting(builder, "Widget"); + AssertGenerated(builder, "NonNestedClassFromTypeSymbol"); + AssertPartialMerges(NonNestedSource, "Demo.Widget", symbol); + } + + [Fact] + public void Create_FromNonNestedGenericType_MatchesHistoricalDeclaration() + { + var symbol = GetNamedType(@" +namespace Demo +{ + public abstract partial class Widget + where T : class + { + } +}", "Demo.Widget`1"); + + var builder = CodeBuilder.Create(symbol); + + AssertNesting(builder, "Widget"); + Assert.False(builder.IsAbstract); + Assert.False(builder.IsSealed); + Assert.False(builder.IsStatic); + AssertGenerated(builder, "NonNestedClassFromTypeSymbol"); + } + + [Fact] + public void Create_FromNestedType_WrapsInPartialContainingType() + { + var symbol = GetNamedType(SingleNestedSource, "Demo.Containers.Outer+Inner"); + + var builder = CodeBuilder.Create(symbol); + + AssertNesting(builder, "Outer", "Inner"); + Assert.Equal(Accessibility.Public, builder.Builder.Classes.Single().AccessModifier); + Assert.Equal(Accessibility.Internal, builder.AccessModifier); + AssertGenerated(builder, "SingleNestedClassFromTypeSymbol"); + AssertPartialMerges(SingleNestedSource, "Demo.Containers.Outer+Inner", symbol); + } + + [Fact] + public void Create_FromMultiLevelNestedType_PreservesOrderNamespaceAndAccessibility() + { + var symbol = GetNamedType(MultiLevelSource, "Demo.Containers.Outer+Middle+Inner"); + + var builder = CodeBuilder.Create(symbol); + var outer = builder.Builder.Classes.Single(); + var middle = Assert.Single(outer.NestedClasses); + + AssertNesting(builder, "Outer", "Middle", "Inner"); + Assert.Equal(Accessibility.Internal, outer.AccessModifier); + Assert.Equal(Accessibility.ProtectedOrInternal, middle.AccessModifier); + Assert.Equal(Accessibility.ProtectedAndInternal, builder.AccessModifier); + AssertGenerated(builder, "MultiLevelNestedClassFromTypeSymbol"); + AssertPartialMerges(MultiLevelSource, "Demo.Containers.Outer+Middle+Inner", symbol); + } + + [Fact] + public void Create_FromNestedGenericType_PreservesModifiersGenericsAndConstraints() + { + const string metadataName = "Demo.Containers.Outer`1+Middle`1+Inner`1"; + var symbol = GetNamedType(GenericNestedSource, metadataName); + + var builder = CodeBuilder.Create(symbol); + var outer = builder.Builder.Classes.Single(); + var middle = Assert.Single(outer.NestedClasses); + + AssertNesting(builder, "Outer", "Middle", "Inner"); + Assert.True(outer.IsStatic); + Assert.False(outer.IsAbstract); + Assert.False(outer.IsSealed); + Assert.True(middle.IsAbstract); + Assert.False(middle.IsSealed); + Assert.True(builder.IsSealed); + Assert.False(builder.IsStatic); + AssertGenerated(builder, "GenericNestedClassFromTypeSymbol"); + AssertPartialMerges(GenericNestedSource, metadataName, symbol); + } + + [Fact] + public void Create_FromClassNestedInStruct_EmitsPartialStructContainer() + { + var symbol = GetNamedType(StructContainerSource, "Demo.Containers.OuterStruct+NestedValue"); + + var builder = CodeBuilder.Create(symbol); + var outer = builder.Builder.Classes.Single(); + + AssertNesting(builder, "OuterStruct", "NestedValue"); + Assert.Equal(TypeKind.Struct, outer.Kind); + Assert.False(outer.IsSealed); + Assert.Equal(TypeKind.Class, builder.Kind); + AssertGenerated(builder, "ClassNestedInStructFromTypeSymbol"); + AssertPartialMerges(StructContainerSource, "Demo.Containers.OuterStruct+NestedValue", symbol); + } + + private void AssertGenerated(ClassBuilder builder, string sampleName) + { + var actual = builder.Build(); + var expected = File.ReadAllText(Path.Combine("SampleCode", sampleName + ".cs")); + + testOutputHelper.WriteLine("*** ACTUAL OUTPUT ***"); + testOutputHelper.WriteLine(actual); + Assert.Equal(expected, actual, ignoreLineEndingDifferences: true); + AssertCompiles(actual); + } + + private void AssertPartialMerges(string originalSource, string metadataName, INamedTypeSymbol sourceSymbol) + { + var builder = CodeBuilder.Create(sourceSymbol); + builder.AddMethod("FromGenerator").MakePublicMethod().WithReturnType("void"); + var generated = builder.Build(); + + testOutputHelper.WriteLine("*** MERGED GENERATED OUTPUT ***"); + testOutputHelper.WriteLine(generated); + + var compilation = CreateCompilation(originalSource, generated); + var errors = compilation.GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + Assert.True(errors.Length == 0, string.Join(Environment.NewLine, errors.Select(error => error.ToString()))); + + var merged = compilation.GetTypeByMetadataName(metadataName); + Assert.NotNull(merged); + Assert.NotEmpty(merged!.GetMembers("FromGenerator")); + } + + private static void AssertNesting(ClassBuilder leaf, params string[] namesFromOuterToInner) + { + var chain = new List(); + var current = Assert.Single(leaf.Builder.Classes); + ClassBuilder deepest = current; + + while (true) + { + chain.Add(current.Name); + if (current.NestedClasses.Count == 0) + { + deepest = current; + break; + } + + current = Assert.Single(current.NestedClasses); + } + + Assert.Equal(namesFromOuterToInner, chain.ToArray()); + Assert.Same(leaf, deepest); + } + + private static void AssertCompiles(string source) + { + var compilation = CreateCompilation(source); + var errors = compilation.GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + Assert.True(errors.Length == 0, string.Join(Environment.NewLine, errors.Select(error => error.ToString()))); + } + + private static INamedTypeSymbol GetNamedType(string source, string metadataName) + { + var compilation = CreateCompilation(source); + var errors = compilation.GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + if (errors.Length > 0) + throw new InvalidOperationException(string.Join(Environment.NewLine, errors.Select(error => error.ToString()))); + + return compilation.GetTypeByMetadataName(metadataName) + ?? throw new InvalidOperationException($"Type '{metadataName}' was not found."); + } + + private static CSharpCompilation CreateCompilation(params string[] sources) + { + return CSharpCompilation.Create( + "SymbolCompilation", + sources.Select(source => CSharpSyntaxTree.ParseText(source)), + MetadataReferences, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + private static readonly IReadOnlyList MetadataReferences = CreateMetadataReferences(); + + private static IReadOnlyList CreateMetadataReferences() + { + var trusted = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; + if (string.IsNullOrEmpty(trusted)) + return new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) }; + + return trusted + .Split(Path.PathSeparator) + .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)) + .ToArray(); + } + } +}