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 a1f282c..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(); @@ -69,10 +73,97 @@ 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) - .WithAccessModifier(typeSymbol.DeclaredAccessibility) - .OfType(typeSymbol.TypeKind); + 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); + } + + return AddClass(typeSymbol); + + ClassBuilder AddClass(ITypeSymbol symbol) + { + if (classBuilder != null) + { + classBuilder = classBuilder.AddNestedClass(symbol.Name, true); + } + else + { + 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) 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(); + } + } +}