From b41580758ba86180fd06fc0be447d46275912b18 Mon Sep 17 00:00:00 2001 From: Matt Warren Date: Wed, 16 Sep 2026 13:20:24 -0700 Subject: [PATCH] Use union comment property and partial constructors --- README.md | 123 +++---- src/Generators/CustomUnionGenerator.cs | 64 ++-- src/SourceGenerators.Package/ReadMe.Nuget.md | 22 +- .../CustomUnionSourceGeneratorTests.cs | 320 ++++++++++++------ src/SourceGenerators/CommentProperties.cs | 119 +++++++ .../CustomUnionSourceGenerator.cs | 204 +++++------ src/SourceGenerators/RoslynExtensions.cs | 260 +++++++------- 7 files changed, 672 insertions(+), 440 deletions(-) create mode 100644 src/SourceGenerators/CommentProperties.cs diff --git a/README.md b/README.md index 6a721ef..04e64bf 100644 --- a/README.md +++ b/README.md @@ -69,20 +69,20 @@ The generator is purely standalone; the generated union source does not depend o ## Declaring a Non-Boxing Custom Union Type -Declare a partial struct type with a partial void `Cases` method, whose parameters denote the case types for the union. -The names of the parameters are not used, so any name will do. +Declare a partial struct type with `@union` in its leading comments and a partial constructor for each case type for the generator to implement. + +The generator will layout the contents of the custom union so that the fields storing the different case types are overlapped with each other using the same memory space, if possible, or at least consume less space than simply having separate fields for each case type. By default, no case value is boxed. ```csharp +// @union public partial struct MyUnion { - partial void Cases( - int case1, - float case2, - string case3, - IManifest case4, - Coordinate case5, - Address case6 - ); + public partial MyUnion(int value); + public partial MyUnion(float value); + public partial MyUnion(string value); + public partial MyUnion(IManifest value); + public partial MyUnion(Coordinate value); + public partial MyUnion(Address value); } record struct Coordinate(float Longitude, float Latitude); @@ -90,13 +90,12 @@ record struct Address(int Id, string Name); interface IManifest { ... } ``` -The generator will layout the contents of the custom union so that the fields storing the different case types are overlapped with each other using the same memory space, if possible, or at least consume less space than simply having separate fields for each case type. By default, no case value is boxed. - In this example, there will be a single field storing a struct that contains enough space to store either an int, float, Coordinate or the address ID and a separate object field used to store either a string, IManifest or the address name. + ## Further Customization -You can override the layout algorithm per case by using annotations in the comments preceding each case type parameter declaration. +You can override the layout algorithm per case by using annotations in the comments preceding each case type's constructor. **@box** - store a boxed value-type in an object field. **@isolate** - use a separate strongly typed field. @@ -108,18 +107,19 @@ For example, if a struct cannot be overlapped with other cases because it contai If you don't want this to happen, you can use the @isolate annotation to keep the value whole by using an extra field to store it, increasing the memory footprint of the union, or use the @box annotation to box it instead and store it in the same field used to store the string or IManifest cases. ```csharp +// @union public partial struct MyUnion { - partial void Cases( - int case1, - float case2, - string case3, - IManifest case4, - // @box - Coordinate case5, // store this as a boxed object - // @isolate - Address case6 // use a separate field for this one - ); + public partial MyUnion(int value); + public partial MyUnion(flaot value); + public partial MyUnion(string value); + public partial MyUnion(IManifest value); + + // @box + public partial MyUnion(Coordinate value); // store this as a boxed object + + // @isolate + public partial MyUnion(Address value); // use a separate field for this one } record struct Coordinate(float Longitude, float Latitude); @@ -136,17 +136,19 @@ Typically, specifying the `@overlap` annotation does nothing since the case woul However, if you specify `@overlap` on a type known to not be overlappable, the generator will produce a warning and not overlap the type. ```csharp +// @union public partial struct MyUnion { - partial void Cases( - int case1, - // @overlap - string case2, // warning! not overlappable - // @overlap - GoodStruct case3, // overlappable, so this is a no-op - // @overlap - BadStruct case4 // warning! not overlappable - ); + public partial MyUnion(int value); + + // @overlap + public partial MyUnion(string value); // warning! not overlappable, is a reference type + + // @overlap + public partial MyUnion(GoodStruct value); // okay, was going to do this anyway + + // @overlap + public partial MyUnion(BadStruct value); // // warning! not overlappable, contains references types } public record struct GoodStruct(int x, float y); @@ -162,18 +164,21 @@ The generator will choose to decompose a struct if it is not overlappable and de If the generator does not choose to decompose your struct case, but you are confident it can be trusted you can specify the `@decompose` annotation to request it do so. ```csharp +// @union public partial struct MyUnion { - partial void Cases( - GoodStructA structA, // automatically decomposed - GoodStructB structB, // ... - GoodStructC structC, // ... - GoodStructD structD, // ... - // @decompose - BadStructE structE, // okay, if you say so, but Z will be lost - // @decompose - BadStructF structF // Warning! will not decompose - ); + public partial MyUnion(GoodStructA value); // automatically decomposed + public partial MyUnion(GoodStructB value); // automatically decomposed + public partial MyUnion(GoodStructC value); // automatically decomposed + + // @decompose + public partial MyUnion(GoodStructD value); // okay, but was already going to decompose + + // @decompose + public partial MyUnion(BadStructE value); // okay, if you say so, but Z will be lost + + // @decompose + public partial MyUnion(BadStructF value); // Warning! will not decompose } public record struct GoodStructA(int X, string Y); @@ -191,36 +196,38 @@ Some structs can still not be decomposed even if you request it. This may happen You can choose to have any case isolated into its own field. This happens automatically, if the case cannot be overlapped or decomposed. If neither overlapping or decomposition is suitable, you can specify the `@isolate` annotation to force it to be isolated as whole. ```csharp +// @union public partial struct MyUnion { - partial void Cases( - int case1, - float case2, - // @isolate - SketchyStruct case3 // good idea - ); + public partial MyUnion(int value); + + // @isolate + public partial MyUnion(float value); // okay, but unnecessary + + // @isolate + public partial MyUnion(SketchyStruct value); // okay, good idea, this struct may contain references or inaccessible members + + // @isolate + public partial MyUnion(string value); // ignored: will still use shared object field } ``` This can be a good idea if you have reasons to believe that metadata may not be accurate and the type may contain either non-overlappable members or contain data you don't wish to lose from decomposition. -> Note: If you attempt to isolate a reference type, it will still be stored using the boxed technique in an object field instead of a strongly-typed field. This is to improve field sharing across cases. - ### @box The source generator will never choose to box a struct value, but you can request it using the `@box` annotation. ```csharp +// @union public partial struct MyUnion { - partial void Cases( - // @box - int case1, - string case2 - ); + // @box + public partial MyUnion(int value); // okay + + // @box + public partial MyUnion(string value); // okay, was already going to store in object field anyway } ``` -*You can choose this an alternative to using @isolate when you believe the metadata for a case type is incomplete and should not be overlapped or decomposed.* - - +*You can choose this an alternative to using @isolate when you believe the metadata for a case type is incomplete and should not be overlapped or decomposed.* \ No newline at end of file diff --git a/src/Generators/CustomUnionGenerator.cs b/src/Generators/CustomUnionGenerator.cs index 4f6b3ee..f24613d 100644 --- a/src/Generators/CustomUnionGenerator.cs +++ b/src/Generators/CustomUnionGenerator.cs @@ -166,14 +166,16 @@ private void WriteCaseConstructors(UnionLayout layout) void WriteConstructor(CaseLayout caseLayout) { var caseType = caseLayout.Case.Type; - _writer.WriteLine($"public {layout.Union.SimpleName}({caseType.TypeName} value)"); + var parameterName = caseLayout.Case.ConstructorParameterName ?? "value"; + var partialMod = caseLayout.Case.HasPartialConstructorDefinition ? "partial " : ""; + _writer.WriteLine($"{caseLayout.Case.ConstructorAccessibility} {partialMod}{layout.Union.SimpleName}({caseType.TypeName} {parameterName})"); _writer.WriteBraceNested(() => { if (caseType.IsReference || caseType.MightBeNullable) { // null values become equivalent of default for the struct - _writer.WriteLine("if (value is {} v)"); + _writer.WriteLine($"if ({parameterName} is {{}} v)"); _writer.WriteBraceNested(() => { WriteBody("v"); @@ -181,10 +183,10 @@ void WriteConstructor(CaseLayout caseLayout) } else { - WriteBody("value"); + WriteBody(parameterName); } - void WriteBody(string valueName) + void WriteBody(string parameterName) { if (layout.TagField != null) { @@ -193,12 +195,12 @@ void WriteBody(string valueName) if (caseLayout.IsDecomposed) { - Decompose(caseLayout, valueName); + Decompose(caseLayout, parameterName); } else if (caseLayout.Field != null) { WriteFieldReference(caseLayout.Field); - _writer.WriteLine($" = {valueName};"); + _writer.WriteLine($" = {parameterName};"); } else { @@ -568,7 +570,7 @@ private void WriteTryGetValueMethods(UnionLayout layout) void WriteCaseTypeGetValue(CaseLayout caseLayout) { - _writer.WriteLine($"{caseLayout.Case.Accessibility} bool TryGetValue([NotNullWhen(true)] out {caseLayout.Case.Type.TypeName} value)"); + _writer.WriteLine($"{caseLayout.Case.MemberAccessibility} bool TryGetValue([NotNullWhen(true)] out {caseLayout.Case.Type.TypeName} value)"); _writer.WriteBraceNested(() => { if (caseLayout.Case.NonDisjointCases.Count > 0) @@ -1242,40 +1244,54 @@ public class CaseDesc : IEquatable /// public IReadOnlyList NonDisjointCases { get; } - public string Accessibility { get; } + /// + /// The accessibility for members generated for this type + /// + public string MemberAccessibility { get; } /// /// If true, a record struct type for the case will be generated from the members as a nested type within the union type. /// public bool GenerateType { get; } - private CaseDesc( + /// + /// The accessibility of the constructor (may be different than the general member accessibility) + /// + public string ConstructorAccessibility { get; } + + /// + /// The name of the constructor parameter associated with this case + /// + public string ConstructorParameterName { get; } + + /// + /// True if the constructor had a partial definition + /// + public bool HasPartialConstructorDefinition { get; } + + public CaseDesc( TypeDesc type, - IReadOnlyList? nonDisjointCases, - bool generateType, - string accessibility) + IReadOnlyList? nonDisjointCases = null, + string memberAccessibility = "public", + bool generateType = false, + string constructorAccessibility = "public", + string constructorParameterName = "value", + bool hasPartialConstructorDefinition = false) { this.Type = type; this.NonDisjointCases = nonDisjointCases ?? Array.Empty(); + this.MemberAccessibility = memberAccessibility; this.GenerateType = generateType; - this.Accessibility = accessibility; - } - - public CaseDesc(TypeDesc type, bool generateType, string accessibility = "public") - : this(type, null, generateType, accessibility) - { - } - - public CaseDesc(TypeDesc type, IReadOnlyList? nonDisjointCases = null, string accessibility = "public") - : this(type, nonDisjointCases, false, accessibility) - { + this.ConstructorAccessibility = constructorAccessibility; + this.ConstructorParameterName = constructorParameterName; + this.HasPartialConstructorDefinition = hasPartialConstructorDefinition; } public bool Equals(CaseDesc other) { if (this.Type.Equals(other.Type) && this.GenerateType == other.GenerateType - && this.Accessibility == other.Accessibility) + && this.MemberAccessibility == other.MemberAccessibility) { if (this.NonDisjointCases.Count != other.NonDisjointCases.Count) return false; diff --git a/src/SourceGenerators.Package/ReadMe.Nuget.md b/src/SourceGenerators.Package/ReadMe.Nuget.md index de12037..7f21d75 100644 --- a/src/SourceGenerators.Package/ReadMe.Nuget.md +++ b/src/SourceGenerators.Package/ReadMe.Nuget.md @@ -10,20 +10,20 @@ It may include additional generators in the future. ## Declaring a Non-Boxing Custom Union Type -Declare a partial struct type with a partial void `Cases` method, whose parameters denote the case types for the union. -The names of the parameters are not used, so any name will do. +Declare a partial struct type with `@Union` in its leading comments and a partial constructor for each case type for the generator to implement. + +The generator will layout the contents of the custom union so that the fields storing the different case types are overlapped with each other using the same memory space, if possible, or at least consume less space than simply having separate fields for each case type. No case value is boxed. ```csharp +// @union public partial struct MyUnion { - partial void Cases( - int case1, - float case2, - string case3, - IManifest case4, - Coordinate case5, - Address case6 - ); + public partial MyUnion(int value); + public partial MyUnion(float value); + public partial MyUnion(string value); + public partial MyUnion(IManifest value); + public partial MyUnion(Coordinate value); + public partial MyUnion(Address value); } record struct Coordinate(float Longitude, float Latitude); @@ -31,8 +31,6 @@ record struct Address(int Id, string Name); interface IManifest { ... } ``` -The generator will layout the contents of the custom union so that the fields storing the different case types are overlapped with each other using the same memory space, if possible, or at least consume less space than simply having separate fields for each case type. No case value is boxed. - In this example, there will be a single field storing a struct that contains enough space to store either an int, float, Coordinate or the address Id and a sparate object field used to store either a string, IManifest or the address Name. [Learn how to customize the union generation further](https://github.com/mattwar/UnionTypes.Toolkit) diff --git a/src/SourceGenerators.Tests/CustomUnionSourceGeneratorTests.cs b/src/SourceGenerators.Tests/CustomUnionSourceGeneratorTests.cs index 97e4c61..f7e7e34 100644 --- a/src/SourceGenerators.Tests/CustomUnionSourceGeneratorTests.cs +++ b/src/SourceGenerators.Tests/CustomUnionSourceGeneratorTests.cs @@ -15,6 +15,28 @@ public class CustomUnionSourceGeneratorTests { [TestMethod] public void TestOverlappableCases_Primitives() + { + TestGenerator( + """ + // @union + public partial struct MyUnion + { + public partial MyUnion(int value); + public partial MyUnion(float value); + } + """, + generatedText => + { + // prove that contents of the two decomposable cases got overlapped into the overlapped field + Assert.IsTrue(HasOverlappedField(generatedText)); + Assert.IsTrue(HasOverlappedCaseField(generatedText, 1, "int")); + Assert.IsTrue(HasOverlappedCaseField(generatedText, 2, "float")); + Assert.IsFalse(HasValueFields(generatedText)); // no value fields + }); + } + + [TestMethod] + public void TestCaseMethod_BackwardCompat() { TestGenerator( """ @@ -40,9 +62,11 @@ public void TestOverlappableCases_RecordStructs() // this can only happen if the record struct can be trusted (e.g. is defined in the same assembly as the union), otherwise it is not safe to overlap them. TestGenerator( """ + // @union public partial struct MyUnion { - partial void Cases(A case1, B case2); + public partial MyUnion(A value); + public partial MyUnion(B value); } public record struct A(int X); @@ -64,9 +88,11 @@ public void TestOverlappableCases_ArbitraryStructs() // this can only happen if the struct can be trusted (e.g. is defined in the same assembly as the union), otherwise it is not safe to overlap them. TestGenerator( """ + // @union public partial struct MyUnion { - partial void Cases(A case1, B case2); + public partial MyUnion(A value); + public partial MyUnion(B value); } public struct A { public int X { get; init; }} @@ -87,9 +113,11 @@ public void TestOverlappableCases_Tuples() // value tuples (structs) can be overlapped if they have only overlappable members (e.g. no reference types) TestGenerator( """ + // @union public partial struct MyUnion { - partial void Cases((int X, float Y) case1, (float X, int Y) case2); + public partial MyUnion((int X, float Y) value); + public partial MyUnion((float X, int Y) value); } """, generatedText => @@ -107,9 +135,11 @@ public void TestOverlappableCases_Enums() // numeric enums can be overlapped because they are represented as a numeric primitive TestGenerator( """ + // @union public partial struct MyUnion { - partial void Cases(E case1, F case2); + public partial MyUnion(E value); + public partial MyUnion(F value); } public enum E { A, B, C } @@ -130,9 +160,12 @@ public void TestDecomposableCases_RecordStructs() // record structs that cannot be overlapped (because they have reference type members) can still be decomposed into their primitive members, of which some may be overlapped. TestGenerator( """ + // @union public partial struct MyUnion { - partial void Cases(A case1, B case2, C case3); + public partial MyUnion(A value); + public partial MyUnion(B value); + public partial MyUnion(C value); } public record struct A(int Value1, string Value2); @@ -156,9 +189,11 @@ public void TestDecomposableCases_Tuples() // tuples with non-overlappable members cannot be overlapped, but can be decomposed into their members, some of which may be overlapped. TestGenerator( """ + // @union public partial struct MyUnion { - partial void Cases((int X, string Y) case1, (string X, float Y) case2); + public partial MyUnion((int X, string Y) value); + public partial MyUnion((string X, float Y) value); } """, generatedText => @@ -176,9 +211,11 @@ public void TestDecomposableCases_MulitpleOverlappableMembers() { TestGenerator( """ + // @union public partial struct MyUnion { - partial void Cases(A case1, (float X, int Y, string Z) case2); + public partial MyUnion(A value); + public partial MyUnion((float X, int Y, string Z) value); } public record struct A(int Value1, float Value2, string Value3); @@ -200,9 +237,11 @@ public void TestDecomposableCases_NestedDecomposableMembers() // Each overlappable decomposed member is stored in the same overlapped field as a tuple of the overlappable members. TestGenerator( """ + // @union public partial struct MyUnion { - partial void Cases(A case1, B case2); + public partial MyUnion(A value); + public partial MyUnion(B value); } public record struct A(int Value1, (float, string) Value2); @@ -228,13 +267,13 @@ public void TestCaseLayoutOverrides_Overlap() """ using System; + // @union public partial struct MyUnion { - partial void Cases( - int case1, - // @overlap - DateOnly case2 - ); + public partial MyUnion(int value); + + // @overlap + public partial MyUnion(DateOnly value); } """, generatedText => @@ -270,14 +309,14 @@ public void TestCaseLayoutOverrides_Box() """ using System; + // @union public partial struct MyUnion { - partial void Cases( - // @box - int case1, - // @box - (int, float) case2 - ); + // @box + public partial MyUnion(int value); + + // @box + public partial MyUnion((int, float) value); } """, generatedText => @@ -295,15 +334,16 @@ public void TestCaseLayoutOverrides_Isolate() """ using System; + // @union public partial struct MyUnion { - partial void Cases( - int case1, - // @isolate - float case2, - // @isolate -- but will store in object field since it is a reference type - string case3 - ); + public partial MyUnion(int value); + + // @isolate + public partial MyUnion(float value); + + // @isolate + public partial MyUnion(string value); // but will store in object field since it is a reference type } """, generatedText => @@ -324,14 +364,14 @@ public void TestCaseLayoutOverrides_Decompose() public record struct A(int X, float Y); public record struct B { public int X { get; init; } public float Y { get; init; } } + // @union public partial struct MyUnion { - partial void Cases( - // @decompose - A case1, - // @decompose - B case2 - ); + // @decompose + public partial MyUnion(A case1); + + // @decompose + public partial MyUnion(B case2); } """, generatedText => @@ -350,13 +390,12 @@ public void TestNonDisjointCases_Interfaces() public interface IA { } public interface IB { } + // @union public partial struct MyUnion { - partial void Cases( - IA case1, - IB case2, - int case3 // to keep if from switching to box layout - ); + public partial MyUnion(IA case1); + public partial MyUnion(IB case2); + public partial MyUnion(int case3); // case added to keep generator from switching to boxed layout } """, generatedText => @@ -377,9 +416,12 @@ public interface IA { } public struct B : IA { } public struct C { } + // @union public partial struct MyUnion { - partial void Cases(IA case1, B case2, C case3); + public partial MyUnion(IA value); + public partial MyUnion(B value); + public partial MyUnion(C value); } """, generatedText => @@ -403,9 +445,13 @@ public struct B { } // known to not implement IA public class C { } // not known to implement IA, but unsealed public sealed class D { } // known to not implement IA + // @union public partial struct MyUnion { - partial void Cases(IA case1, B case2, C case3, D case4); + public partial MyUnion(IA value); + public partial MyUnion(B value); + public partial MyUnion(C value); + public partial MyUnion(D value); } """, generatedText => @@ -425,9 +471,11 @@ public void TestNonDisjointCases_AnythingAndTypeParameter() { TestGenerator( """ + // @union public partial struct MyUnion { - partial void Cases(int case1, T case2); + public partial MyUnion(int case1); + public partial MyUnion(T case2); } """, generatedText => @@ -451,14 +499,13 @@ public class A { } public class B : A { } public class C : B { } + // @union public partial struct MyUnion { - partial void Cases( - A case1, - B case2, - C case3, - int case4 // to keep it from switching to box layout - ); + public partial MyUnion(A value); + public partial MyUnion(B value); + public partial MyUnion(C value); + public partial MyUnion(int value); // case added to keep generator from switching to boxed layout } """, generatedText => @@ -482,9 +529,11 @@ public void TestInNamespace() """ namespace MyNamespace { + // @union public partial struct MyUnion { - partial void Cases(int case1, string case2); + public partial MyUnion(int case1); + public partial MyUnion(string case2); } } """, @@ -504,9 +553,11 @@ public void TestUsings() using System.Collections.Generic; using X=System.Collections.Generic.List; + // @union public partial struct MyUnion { - partial void Cases(int case1, string case2); + public partial MyUnion(int value); + public partial MyUnion(string value); } """, generatedText => @@ -528,9 +579,11 @@ public struct A { public int X; } public struct B { public string Y; } } + // @union public partial struct MyUnion { - partial void Cases(OtherNamespace.A case1, OtherNamespace.B case2); + public partial MyUnion(OtherNamespace.A value); + public partial MyUnion(OtherNamespace.B value); } """, generatedText => @@ -551,9 +604,11 @@ public struct A { public int X; } public struct B { public string Y; } } + // @union public partial struct MyUnion { - partial void Cases(OtherType.A case1, OtherType.B case2); + public partial MyUnion(OtherType.A value); + public partial MyUnion(OtherType.B value); } """, generatedText => @@ -564,13 +619,16 @@ public partial struct MyUnion [TestMethod] - public void TestInternalUnion() + public void TestInternalUnion_PublicCases() { + // explicit accessibilty TestGenerator( """ + // @union internal partial struct MyUnion { - partial void Cases(int case1, string case2); + public partial MyUnion(int value); + public partial MyUnion(string value); } """, generatedText => @@ -578,11 +636,14 @@ internal partial struct MyUnion Assert.IsTrue(generatedText.Contains("internal partial struct MyUnion")); }); + // unspecifed accessibility, should default to internal TestGenerator( """ + // @union partial struct MyUnion { - partial void Cases(int case1, string case2); + public partial MyUnion(int value); + public partial MyUnion(string value); } """, generatedText => @@ -592,16 +653,39 @@ partial struct MyUnion } [TestMethod] - public void TestInternalUnion_WithInternalCases() + public void TestInternalUnion_InternalCases() { + // internal union with public constructors + TestGenerator( + """ + internal struct A { } + internal struct B { } + + // @union + internal partial struct MyUnion + { + public partial MyUnion(A value); + public partial MyUnion(B value); + } + """, + generatedText => + { + Assert.IsTrue(generatedText.Contains("internal partial struct MyUnion")); + Assert.IsTrue(generatedText.Contains("internal bool TryGetValue([NotNullWhen(true)] out global::A value)")); + Assert.IsTrue(generatedText.Contains("internal bool TryGetValue([NotNullWhen(true)] out global::B value)")); + }); + + // internal union with internal constructors TestGenerator( """ internal struct A { } internal struct B { } + // @union internal partial struct MyUnion { - partial void Cases(A case1, B case2); + internal partial MyUnion(A value); + internal partial MyUnion(B value); } """, generatedText => @@ -617,9 +701,11 @@ public void TestNullableCases_Primitives() { TestGenerator( """ - internal partial struct MyUnion + // @union + public partial struct MyUnion { - partial void Cases(int? case1, float? case2); + public partial MyUnion(int? value); + public partial MyUnion(float? value); } """, generatedText => @@ -635,9 +721,11 @@ public void TestNullableCases_ExplicitNullableTypes() { TestGenerator( """ - internal partial struct MyUnion + // @union + public partial struct MyUnion { - partial void Cases(System.Nullable case1, System.Nullable case2); + public partial MyUnion(System.Nullable value); + public partial MyUnion(System.Nullable value); } """, generatedText => @@ -656,9 +744,11 @@ public void TestNullableCases_ReferenceTypes() public class A { } public class B { } - internal partial struct MyUnion + // @union + public partial struct MyUnion { - partial void Cases(A? case1, B? case2); + public partial MyUnion(A? value); + public partial MyUnion(B? value); } """, generatedText => @@ -676,9 +766,11 @@ public void TestNullableCases_OverlappableStructs() public record struct A (int X, float Y); public record struct B { public required int X { get; init; } public required float Y { get; init; } } - internal partial struct MyUnion + // @union + public partial struct MyUnion { - partial void Cases(A? case1, B? case2); + public partial MyUnion(A? value); + public partial MyUnion(B? value); } """, generatedText => @@ -698,9 +790,11 @@ public void TestNullableCases_DecomposableStructs() public record struct A (int X, string Y); public record struct B { public required int X { get; init; } public required string Y { get; init; } } - internal partial struct MyUnion + // @union + public partial struct MyUnion { - partial void Cases(A? case1, B? case2); + public partial MyUnion(A? value); + public partial MyUnion(B? value); } """, generatedText => @@ -720,9 +814,11 @@ public void TestNullableCaseMembers_DecomposableStructs() public record struct A (int? X, string? Y); public record struct B { public int? X { get; init; } public string? Y { get; init; } public float? Z { get; init; } } - internal partial struct MyUnion + // @union + public partial struct MyUnion { - partial void Cases(A case1, B case2); + public partial MyUnion(A value); + public partial MyUnion(B value); } """, generatedText => @@ -740,15 +836,14 @@ public void TestDiagnostics_UnsupportedCaseTypes() // prove that unions with cases or case members that have unsupported types (e.g. pointers, spans, etc.) produce diagnostics. TestGenerator( """ - internal partial struct MyUnion + // @union + public partial struct MyUnion { - partial void Cases( - < case1>>, - < case2>>, - <>, - <>, - <> - ); + < value);>> + < value);>> + <> + <> + <> } public ref struct A { public int X; } @@ -763,15 +858,16 @@ public void TestDiagnostics_NonoverlappableCaseTypes() { TestGenerator( """ - internal partial struct MyUnion + // @union + public partial struct MyUnion { - partial void Cases( - int case1, - // @overlap - <>, - // @overlap - <> - ); + public partial MyUnion(int value); + + // @overlap + <> + + // @overlap + <> } struct AB { public int X; public string Y; } @@ -785,22 +881,24 @@ public void TestDiagnostics_NondecomposableCaseTypes() { TestGenerator( """ - internal partial struct MyUnion + // @union + public partial struct MyUnion { - partial void Cases( - // @decompose - <>, // no members to decompose - // @decompose, - <>, // not a struct - // @decompose - A case3, // okay?: has non-public field/properties but 'trust-me bro' - // @decompose // okay - B case4 - ); + // @decompose + <> // cannot decompose int + + // @decompose + <> // cannot decompose string + + // @decompose + public partial MyUnion(A value); // okay, if you say so, but z is lost + + // @decompose + public partial MyUnion(B value); // okay, was going to anyway } - struct A { public int X; public string Y; internal int z; } - struct B { public int X { get; set; } public string Y { get; init; } } + public struct A { public int X; public string Y; internal int z; } + public struct B { public int X { get; set; } public string Y { get; init; } } """, ["UT0003", "UT0003"] ); @@ -812,13 +910,12 @@ public void TestBoxLayout_InferredBoxedCases() // if all cases prefer box layout, then the union will be boxed layout. TestGenerator( """ - internal partial struct MyUnion + // @union + public partial struct MyUnion { - partial void Cases( - string case1, - A case2, - B case3 - ); + public partial MyUnion(string value); + public partial MyUnion(A value); + public partial MyUnion(B value); } record A(int X); @@ -839,15 +936,16 @@ public void TestBoxLayout_OverrideBoxedCases() // if all cases prefer box layout, then the union will be boxed layout. TestGenerator( """ - internal partial struct MyUnion + // @union + public partial struct MyUnion { - partial void Cases( - string case1, - // @box - int case2, - // @box - A case3 - ); + public partial MyUnion(string case1); + + // @box + public partial MyUnion(int case2); + + // @box + public partial MyUnion(A case3); } record struct A(int X); @@ -966,7 +1064,7 @@ private void TestGenerator(string markedText, Action? generatedTextCheck if (expectedDiagnosticCodes != null && markedRanges.Length != expectedDiagnosticCodes?.Length) { - Assert.Fail($"there were {expectedDiagnosticCodes?.Length} expected diagnostics, but only {markedRanges.Length} marked ranges in source text for the test."); + Assert.Fail($"there were {expectedDiagnosticCodes?.Length} expected diagnostics, but {markedRanges.Length} marked ranges in source text for the test."); } else if (actualDiagnostics.Length != markedRanges.Length) { diff --git a/src/SourceGenerators/CommentProperties.cs b/src/SourceGenerators/CommentProperties.cs new file mode 100644 index 0000000..cd5b71a --- /dev/null +++ b/src/SourceGenerators/CommentProperties.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace UnionTypes.Toolkit.Generators; + +public static class CommentPropertyExtensions +{ + /// + /// Returns true if the comment property exists in the node's leading trivia. + /// + public static bool HasCommentProperty(this SyntaxNode node, string propertyName) + { + return TryGetCommentProperty(node, propertyName, out _); + } + + /// + /// Returns true if the comment property exists in the symbol's declaration node's leading trivia. + /// + public static bool HasCommentProperty(this ISymbol symbol, string propertyName) + { + return TryGetCommentProperty(symbol, propertyName, out _); + } + + /// + /// Returns true if the comment property exists in the node's leading trivia, and outputs properties assigned value if present. + /// + public static bool TryGetCommentProperty(this SyntaxNode node, string propertyName, out string? value) + { + value = null; + var commentTrivia = node.GetLeadingTrivia().Where(t => t.IsKind(SyntaxKind.SingleLineCommentTrivia) || t.IsKind(SyntaxKind.MultiLineCommentTrivia)).ToArray(); + foreach (var trivia in commentTrivia) + { + var text = trivia.ToString(); + var prefix = "@" + propertyName; + var startIndex = text.IndexOf(prefix); + if (startIndex >= 0) + { + var endOfPrefix = startIndex + prefix.Length; + + if (endOfPrefix < text.Length && text[endOfPrefix] == '=') + { + startIndex = endOfPrefix + 1; + var endIndex = text.IndexOfAny(new[] { ' ', '\t', '\r', '\n' }, startIndex); + if (endIndex < 0) + endIndex = text.Length; + value = text.Substring(startIndex, endIndex - startIndex); + return true; + } + else if (endOfPrefix == text.Length + || text.IndexOfAny(new[] { ' ', '\t', '\r', '\n' }, endOfPrefix) >= endOfPrefix) + { + value = "true"; + return true; + } + } + } + return false; + } + + /// + /// Returns true if the comment property exists in the node's leading trivia, and outputs properties assigned value if present and convertible to the type T. + /// + public static bool TryGetCommentProperty(this SyntaxNode node, string propertyName, out T? value) + { + value = default; + if (TryGetCommentProperty(node, propertyName, out var strValue)) + { + try + { + value = (T)Convert.ChangeType(strValue, typeof(T)); + return true; + } + catch + { + // ignore conversion errors and just return false + } + } + return false; + } + + /// + /// Returns true if the comment property exists in the symbol's declaration node's leading trivia, and outputs the property's assigned value if present. + /// + public static bool TryGetCommentProperty(this ISymbol symbol, string propertyName, out string? value) + { + value = null; + foreach (var node in symbol.GetDeclarationNodes()) + { + if (TryGetCommentProperty(node, propertyName, out value)) + { + return true; + } + } + return false; + } + + /// + /// Returns true if the comment property exists in the symbol's declaration node's leading trivia, and outputs the property's assigned value if present and convertible to the type T. + /// + public static bool TryGetCommentProperty(this ISymbol symbol, string propertyName, out T? value) + { + value = default; + foreach (var node in symbol.GetDeclarationNodes()) + { + if (TryGetCommentProperty(node, propertyName, out value)) + { + return true; + } + } + return false; + } +} \ No newline at end of file diff --git a/src/SourceGenerators/CustomUnionSourceGenerator.cs b/src/SourceGenerators/CustomUnionSourceGenerator.cs index 26dc7f7..fcbe186 100644 --- a/src/SourceGenerators/CustomUnionSourceGenerator.cs +++ b/src/SourceGenerators/CustomUnionSourceGenerator.cs @@ -36,20 +36,34 @@ public void Initialize(IncrementalGeneratorInitializationContext context) public bool IsGenerationCandiate(SyntaxNode node, CancellationToken ct) { - // must be partial struct and have "Cases" method + // must be partial struct and have "Union" comment property or "Cases" method return node is StructDeclarationSyntax decl && decl.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)) - && HasCasesMethod(decl); + && (HasUnionProperty(decl) || HasCasesMethod(decl)); + } - static bool HasCasesMethod(StructDeclarationSyntax decl) - { - return decl.Members.Any(m => - m is MethodDeclarationSyntax method - && method.Identifier.Text == "Cases" - && method.Modifiers.Any(mod => mod.IsKind(SyntaxKind.PartialKeyword)) - && method.ReturnType is PredefinedTypeSyntax pts && pts.Keyword.IsKind(SyntaxKind.VoidKeyword) - && method.ParameterList.Parameters.Count > 0); - } + private static bool HasUnionProperty(StructDeclarationSyntax decl) + { + return decl.HasCommentProperty("union"); + } + + private static bool HasCasesMethod(StructDeclarationSyntax decl) + { + return decl.Members.Any(m => + m is MethodDeclarationSyntax method + && method.Identifier.Text == "Cases" + && method.Modifiers.Any(mod => mod.IsKind(SyntaxKind.PartialKeyword)) + && method.ReturnType is PredefinedTypeSyntax pts && pts.Keyword.IsKind(SyntaxKind.VoidKeyword) + && method.ParameterList.Parameters.Count > 0); + } + + private static bool HasCasesMethod(INamedTypeSymbol symbol) + { + return symbol.GetMembers().OfType().Any(method => + method.Name == "Cases" + && method.IsPartialDefinition + && method.ReturnsVoid + && method.Parameters.Length > 0); } /// @@ -187,7 +201,14 @@ private bool TryGetGenerationInfo(INamedTypeSymbol unionType, out GenerationInfo var accessibility = GetMemberAccessibilityForType(unionType); // get all cases declared for union type - GetTypeCasesFromPrivateCaseMethod(unionType, cases, diagnostics); + if (HasCasesMethod(unionType)) + { + GetTypeCasesFromPrivateCaseMethod(unionType, cases, diagnostics); + } + else + { + GetTypeCasesFromPartialConstructors(unionType, cases, diagnostics); + } if (cases.Count > 0) { @@ -231,6 +252,33 @@ private IReadOnlyList GetDeclaredUsings(INamedTypeSymbol t return Array.Empty(); } + private void GetTypeCasesFromPartialConstructors( + INamedTypeSymbol unionType, + List cases, + List diagnostics) + { + var partialConstructors = unionType.GetMembers() + .OfType() + .Where(m => m.MethodKind == MethodKind.Constructor + && m.IsPartialDefinition + && m.Parameters.Length == 1) + .ToList(); + + var caseTypes = new List(); + foreach (var pc in partialConstructors) + { + caseTypes.Add(pc.Parameters[0].Type); + } + + for (int i = 0; i < caseTypes.Count; i++) + { + var pc = partialConstructors[i]; + var declaringNode = pc.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax()!; + var caseDesc = GetCaseDesc(caseTypes, i, diagnostics, declaringNode, pc.Parameters[0].Name, hasPartialConstructorDefinition: true); + cases.Add(caseDesc); + } + } + private void GetTypeCasesFromPrivateCaseMethod( INamedTypeSymbol unionType, List cases, @@ -262,7 +310,7 @@ private void GetTypeCasesFromPrivateCaseMethod( { var caseType = caseTypes[i]; var declaringNode = caseParams[i].DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax()!; - var caseDesc = GetCaseDesc(caseTypes, i, diagnostics, declaringNode); + var caseDesc = GetCaseDesc(caseTypes, i, diagnostics, declaringNode, "value", hasPartialConstructorDefinition: false); cases.Add(caseDesc); } } @@ -271,7 +319,13 @@ private void GetTypeCasesFromPrivateCaseMethod( /// /// Builds a for the case type at the given index in the list of case types. /// - private CaseDesc GetCaseDesc(IReadOnlyList caseTypes, int caseIndex, List diagnostics, SyntaxNode caseDeclaration) + private CaseDesc GetCaseDesc( + IReadOnlyList caseTypes, + int caseIndex, + List diagnostics, + SyntaxNode caseDeclaration, + string constructorParameterName, + bool hasPartialConstructorDefinition) { var type = caseTypes[caseIndex]; var nnType = type.GetNonNullableType(); @@ -297,7 +351,14 @@ private CaseDesc GetCaseDesc(IReadOnlyList caseTypes, int caseIndex var accessibility = GetMemberAccessibilityForType(type); - return new CaseDesc(typeDesc, nonDisjointCases, accessibility); + return new CaseDesc( + typeDesc, + nonDisjointCases, + memberAccessibility: accessibility, + constructorAccessibility: accessibility, // use general accessiblity for now, this seems to not generate errors + constructorParameterName: constructorParameterName, + hasPartialConstructorDefinition: hasPartialConstructorDefinition + ); } /// @@ -573,19 +634,19 @@ private static string GetTuplePropertyName(IParameterSymbol parameter) private static StorageKind GetStorageOverride(ITypeSymbol type, SyntaxNode caseDeclaration) { - if (ContainsInTrivia(caseDeclaration, "box")) + if (caseDeclaration.HasCommentProperty("box")) { return StorageKind.Box; } - else if (ContainsInTrivia(caseDeclaration, "decompose")) + else if (caseDeclaration.HasCommentProperty("decompose")) { return StorageKind.Decompose; } - else if (ContainsInTrivia(caseDeclaration, "isolate")) + else if (caseDeclaration.HasCommentProperty("isolate")) { return StorageKind.Isolate; } - else if (ContainsInTrivia(caseDeclaration, "overlap")) + else if (caseDeclaration.HasCommentProperty("overlap")) { return StorageKind.Overlap; } @@ -1024,7 +1085,8 @@ private static string GetMemberAccessibilityForType(ITypeSymbol symbol) } /// - /// Gets the accessibility as C# text. + /// typeAets the accessibility as C# text. + /// var constructorAccessibility = GetMemberAccessibilityForType() /// private static string GetAccessibility(Accessibility acc) { @@ -1113,108 +1175,6 @@ private static string GetNamespaceName(INamespaceSymbol ns) return ns.Name; } - private static bool ContainsInTrivia(ISymbol symbol, string text) - { - return GetDeclarationNodes(symbol).Any(d => ContainsInTrivia(d, text)); - } - - private static bool ContainsInTrivia(SyntaxNode node, string text) - { - var commentTrivia = node.GetLeadingTrivia().Where(t => t.IsKind(SyntaxKind.SingleLineCommentTrivia) || t.IsKind(SyntaxKind.MultiLineCommentTrivia)).ToArray(); - return commentTrivia.Any(t => t.ToString().Contains(text)); - } - - private static bool TryGetCommentProperty(SyntaxNode node, string propertyName, out string? value) - { - value = null; - var commentTrivia = node.GetLeadingTrivia().Where(t => t.IsKind(SyntaxKind.SingleLineCommentTrivia) || t.IsKind(SyntaxKind.MultiLineCommentTrivia)).ToArray(); - foreach (var trivia in commentTrivia) - { - var text = trivia.ToString(); - var prefix = "@" + propertyName; - var startIndex = text.IndexOf(prefix); - if (startIndex >= 0) - { - var endOfPrefix = startIndex + prefix.Length; - - if (endOfPrefix < text.Length && text[endOfPrefix] == '=') - { - startIndex = endOfPrefix + 1; - var endIndex = text.IndexOfAny(new[] { ' ', '\t', '\r', '\n' }, startIndex); - if (endIndex < 0) - endIndex = text.Length; - value = text.Substring(startIndex, endIndex - startIndex); - return true; - } - else if (endOfPrefix == text.Length - || text.IndexOfAny(new[] { ' ', '\t', '\r', '\n' }, endOfPrefix) >= endOfPrefix) - { - value = "true"; - return true; - } - } - } - return false; - } - - private static bool TryGetCommentProperty(SyntaxNode node, string propertyName, out T? value) - { - value = default; - if (TryGetCommentProperty(node, propertyName, out var strValue)) - { - try - { - value = (T)Convert.ChangeType(strValue, typeof(T)); - return true; - } - catch - { - // ignore conversion errors and just return false - } - } - return false; - } - - private static bool TryGetCommentProperty(ISymbol symbol, string propertyName, out string? value) - { - value = null; - foreach (var node in GetDeclarationNodes(symbol)) - { - if (TryGetCommentProperty(node, propertyName, out value)) - { - return true; - } - } - return false; - } - - private static bool TryGetCommentProperty(ISymbol symbol, string propertyName, out T? value) - { - value = default; - foreach (var node in GetDeclarationNodes(symbol)) - { - if (TryGetCommentProperty(node, propertyName, out value)) - { - return true; - } - } - return false; - } - - private static IEnumerable GetDeclarationNodes(ISymbol symbol) - { - foreach (var location in symbol.Locations.Where(loc => loc.IsInSource)) - { - if (location.SourceTree is SyntaxTree sourceTree - && sourceTree.GetRoot() is SyntaxNode root) - { - var declaration = root.FindNode(location.SourceSpan); - if (declaration != null) - yield return declaration; - } - } - } - private static void ReportUnsupportedCaseTypes(ITypeSymbol type, List diagnostics, SyntaxNode? caseDeclaration) { var typeKind = GetTypeDescKind(type); diff --git a/src/SourceGenerators/RoslynExtensions.cs b/src/SourceGenerators/RoslynExtensions.cs index a618d9f..cf9510e 100644 --- a/src/SourceGenerators/RoslynExtensions.cs +++ b/src/SourceGenerators/RoslynExtensions.cs @@ -5,147 +5,181 @@ using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace UnionTypes.Toolkit.Generators +namespace UnionTypes.Toolkit.Generators; + +public static class RoslynExtensions { - public static class RoslynExtensions + public static bool IsDeclaredInSource(this ITypeSymbol symbol) { - public static bool IsDeclaredInSource(this ITypeSymbol symbol) - { - return symbol.Locations.Any(loc => loc.IsInSource); - } + return symbol.Locations.Any(loc => loc.IsInSource); + } - public static bool TryGetAttribute(this ISymbol symbol, string attributeName, out AttributeData attribute) - { - attribute = symbol.GetAttributes(attributeName).FirstOrDefault()!; - return attribute != null; - } + public static bool TryGetAttribute(this ISymbol symbol, string attributeName, out AttributeData attribute) + { + attribute = symbol.GetAttributes(attributeName).FirstOrDefault()!; + return attribute != null; + } - public static IEnumerable GetAttributes(this ISymbol symbol, string attributeName) - { - if (!attributeName.EndsWith("Attribute")) - attributeName += "Attribute"; + public static IEnumerable GetAttributes(this ISymbol symbol, string attributeName) + { + if (!attributeName.EndsWith("Attribute")) + attributeName += "Attribute"; - return symbol.GetAttributes().Where(symbol => symbol.AttributeClass?.Name == attributeName)!; - } + return symbol.GetAttributes().Where(symbol => symbol.AttributeClass?.Name == attributeName)!; + } - public static bool TryGetConstructorArgument(this AttributeData attribute, int position, out TypedConstant argument) + public static bool TryGetConstructorArgument(this AttributeData attribute, int position, out TypedConstant argument) + { + if (attribute.ConstructorArguments.Length > position) { - if (attribute.ConstructorArguments.Length > position) - { - argument = attribute.ConstructorArguments[position]; - return true; - } - - argument = default; - return false; + argument = attribute.ConstructorArguments[position]; + return true; } - public static bool TryGetNamedArgument(this AttributeData attribute, string name, out TypedConstant argument) - { - if (attribute.NamedArguments.Any(na => na.Key == name)) - { - argument = attribute.NamedArguments.First(na => na.Key == name).Value; - return true; - } + argument = default; + return false; + } - argument = default; - return false; + public static bool TryGetNamedArgument(this AttributeData attribute, string name, out TypedConstant argument) + { + if (attribute.NamedArguments.Any(na => na.Key == name)) + { + argument = attribute.NamedArguments.First(na => na.Key == name).Value; + return true; } - /// - /// True if the type is a nullable type (either a Nullable or a reference type with nullable annotation). - /// - public static bool IsNullable(this ITypeSymbol type) - { - if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }) - return true; + argument = default; + return false; + } - return type.NullableAnnotation == NullableAnnotation.Annotated; - } + /// + /// True if the type is a nullable type (either a Nullable or a reference type with nullable annotation). + /// + public static bool IsNullable(this ITypeSymbol type) + { + if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }) + return true; - /// - /// Returns the non-nullable version of the type. - /// If the type is a Nullable, returns T. - /// If the type is a reference type with nullable annotation, returns the same type with NotAnnotated. - /// Otherwise, returns the original type. - /// - public static ITypeSymbol GetNonNullableType(this ITypeSymbol type) - { - if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nt) - return nt.TypeArguments[0]; + return type.NullableAnnotation == NullableAnnotation.Annotated; + } - if (type.NullableAnnotation == NullableAnnotation.Annotated) - return type.WithNullableAnnotation(NullableAnnotation.NotAnnotated); + /// + /// Returns the non-nullable version of the type. + /// If the type is a Nullable, returns T. + /// If the type is a reference type with nullable annotation, returns the same type with NotAnnotated. + /// Otherwise, returns the original type. + /// + public static ITypeSymbol GetNonNullableType(this ITypeSymbol type) + { + if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nt) + return nt.TypeArguments[0]; - return type; - } + if (type.NullableAnnotation == NullableAnnotation.Annotated) + return type.WithNullableAnnotation(NullableAnnotation.NotAnnotated); - public static IMethodSymbol? GetRecordPrimaryConstructor(this INamedTypeSymbol recordSymbol) - { - if (!recordSymbol.IsRecord) - return null; + return type; + } - // 1. If declared in source, use DeclaringSyntaxReferences - var sourceCtor = recordSymbol.InstanceConstructors - .FirstOrDefault(ctor => ctor.DeclaringSyntaxReferences - .Any(r => r.GetSyntax() is TypeDeclarationSyntax)); + public static IMethodSymbol? GetRecordPrimaryConstructor(this INamedTypeSymbol recordSymbol) + { + if (!recordSymbol.IsRecord) + return null; - if (sourceCtor != null) - return sourceCtor; + // 1. If declared in source, use DeclaringSyntaxReferences + var sourceCtor = recordSymbol.InstanceConstructors + .FirstOrDefault(ctor => ctor.DeclaringSyntaxReferences + .Any(r => r.GetSyntax() is TypeDeclarationSyntax)); - // 2. If imported from metadata: - // Exclude the compiler-generated copy constructor (takes single parameter of the record type itself) - var candidates = recordSymbol.InstanceConstructors - .Where(ctor => !IsCopyConstructor(ctor, recordSymbol)) - .ToList(); + if (sourceCtor != null) + return sourceCtor; - if (candidates.Count == 0) - return null; + // 2. If imported from metadata: + // Exclude the compiler-generated copy constructor (takes single parameter of the record type itself) + var candidates = recordSymbol.InstanceConstructors + .Where(ctor => !IsCopyConstructor(ctor, recordSymbol)) + .ToList(); - if (candidates.Count == 1) - return candidates[0]; + if (candidates.Count == 0) + return null; - // Disambiguate using the compiler-generated Deconstruct method - var deconstruct = recordSymbol.GetMembers("Deconstruct") - .OfType() - .FirstOrDefault(m => !m.IsStatic); + if (candidates.Count == 1) + return candidates[0]; - if (deconstruct != null) - { - var match = candidates.FirstOrDefault(ctor => - ctor.Parameters.Length == deconstruct.Parameters.Length - && ctor.Parameters - .Zip(deconstruct.Parameters, - (cp, dp) => - SymbolEqualityComparer.Default.Equals(cp.Type, dp.Type) && - string.Equals(cp.Name, dp.Name, StringComparison.OrdinalIgnoreCase)) - .All(m => m)); - - if (match != null) - return match; - } + // Disambiguate using the compiler-generated Deconstruct method + var deconstruct = recordSymbol.GetMembers("Deconstruct") + .OfType() + .FirstOrDefault(m => !m.IsStatic); - // Fallback: match parameter names/types to init-only positional properties - var properties = recordSymbol.GetMembers() - .OfType() - .Where(p => !p.IsStatic && p.SetMethod != null && p.SetMethod.IsInitOnly) - .ToList(); - - return candidates.FirstOrDefault(ctor => - ctor.Parameters.Length <= properties.Count - && ctor.Parameters.All(param => - properties.Any(prop => - string.Equals(prop.Name, param.Name, StringComparison.OrdinalIgnoreCase) - && SymbolEqualityComparer.Default.Equals(prop.Type, param.Type) - ))); + if (deconstruct != null) + { + var match = candidates.FirstOrDefault(ctor => + ctor.Parameters.Length == deconstruct.Parameters.Length + && ctor.Parameters + .Zip(deconstruct.Parameters, + (cp, dp) => + SymbolEqualityComparer.Default.Equals(cp.Type, dp.Type) && + string.Equals(cp.Name, dp.Name, StringComparison.OrdinalIgnoreCase)) + .All(m => m)); + + if (match != null) + return match; } - private static bool IsCopyConstructor(IMethodSymbol ctor, INamedTypeSymbol recordSymbol) + // Fallback: match parameter names/types to init-only positional properties + var properties = recordSymbol.GetMembers() + .OfType() + .Where(p => !p.IsStatic && p.SetMethod != null && p.SetMethod.IsInitOnly) + .ToList(); + + return candidates.FirstOrDefault(ctor => + ctor.Parameters.Length <= properties.Count + && ctor.Parameters.All(param => + properties.Any(prop => + string.Equals(prop.Name, param.Name, StringComparison.OrdinalIgnoreCase) + && SymbolEqualityComparer.Default.Equals(prop.Type, param.Type) + ))); + } + + private static bool IsCopyConstructor(IMethodSymbol ctor, INamedTypeSymbol recordSymbol) + { + return ctor.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(ctor.Parameters[0].Type, recordSymbol); + } + + /// + /// Returns a list of all declaration syntax nodes for the given symbol. + /// + public static IEnumerable GetDeclarationNodes(this ISymbol symbol) + { + foreach (var location in symbol.Locations.Where(loc => loc.IsInSource)) { - return ctor.Parameters.Length == 1 && - SymbolEqualityComparer.Default.Equals(ctor.Parameters[0].Type, recordSymbol); - } + if (location.SourceTree is SyntaxTree sourceTree + && sourceTree.GetRoot() is SyntaxNode root) + { + var declaration = root.FindNode(location.SourceSpan); + if (declaration != null) + yield return declaration; + } + } + } + + /// + /// Returns true if the text appears in the leading trivia of the declaration syntax nodes of the symbol. + /// + public static bool ContainsInTrivia(this ISymbol symbol, string text) + { + return symbol.GetDeclarationNodes().Any(d => ContainsInTrivia(d, text)); + } + + /// + /// Returns true if the text appears in the leading trivia of the syntax node. + /// + public static bool ContainsInTrivia(this SyntaxNode node, string text) + { + var commentTrivia = node.GetLeadingTrivia().Where(t => t.IsKind(SyntaxKind.SingleLineCommentTrivia) || t.IsKind(SyntaxKind.MultiLineCommentTrivia)).ToArray(); + return commentTrivia.Any(t => t.ToString().Contains(text)); } -} +} \ No newline at end of file