From 5225d3eee549c85cbfc0f3e676c98645268c1989 Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Wed, 10 Jun 2026 18:22:18 -0700 Subject: [PATCH 1/6] Flatten anonymous nested struct/union fields (#408) Add an opt-in FlattenNestedAnonymousTypes generator option that surfaces fields nested within anonymous structs and unions as [UnscopedRef] ref-returning properties on the declaring struct, so callers can write value.field instead of value.Anonymous.Anonymous.field. The accessors support read, write, and pointer use, and inherit documentation from the underlying field via . Gated on C# 11+ (UnscopedRefAttribute). Named nested types are left alone; only fields reached exclusively through Anonymous holders are flattened. Leaf fields in nested anonymous types now receive documentation propagated from the declaring type's API docs so the inherited docs resolve. --- .../Generator.ApiDocs.cs | 281 +++++++++-------- .../Generator.Struct.cs | 287 ++++++++++++++++++ .../GeneratorOptions.cs | 14 + .../settings.schema.json | 5 + test/GenerationSandbox.Tests/BasicTests.cs | 28 ++ .../NativeMethods.json | 1 + .../GenerationSandbox.Tests/NativeMethods.txt | 1 + .../StructTests.cs | 88 ++++++ 8 files changed, 571 insertions(+), 134 deletions(-) diff --git a/src/Microsoft.Windows.CsWin32/Generator.ApiDocs.cs b/src/Microsoft.Windows.CsWin32/Generator.ApiDocs.cs index 25980bd8..d36d20d1 100644 --- a/src/Microsoft.Windows.CsWin32/Generator.ApiDocs.cs +++ b/src/Microsoft.Windows.CsWin32/Generator.ApiDocs.cs @@ -7,6 +7,111 @@ public partial class Generator { internal Docs? ApiDocs { get; } + private static void EmitLine(StringBuilder stringBuilder, string yamlDocSrc) + { + stringBuilder.Append(yamlDocSrc.Trim()); + } + + private static void EmitDoc(string yamlDocSrc, StringBuilder docCommentsBuilder, ApiDetails? docs, string docsAnchor) + { + if (yamlDocSrc.Contains('\n')) + { + docCommentsBuilder.AppendLine(); + var docReader = new StringReader(yamlDocSrc); + string? paramDocLine; + + bool inParagraph = false; + bool inComment = false; + int blankLineCounter = 0; + while ((paramDocLine = docReader.ReadLine()) is object) + { + if (string.IsNullOrWhiteSpace(paramDocLine)) + { + if (++blankLineCounter >= 2 && inParagraph) + { + docCommentsBuilder.AppendLine(""); + inParagraph = false; + inComment = false; + } + + continue; + } + else if (blankLineCounter > 0) + { + blankLineCounter = 0; + } + else if (docCommentsBuilder.Length > 0 && docCommentsBuilder[docCommentsBuilder.Length - 1] != '\n') + { + docCommentsBuilder.Append(' '); + } + + if (inParagraph) + { + if (docCommentsBuilder.Length > 0 && docCommentsBuilder[docCommentsBuilder.Length - 1] is not (' ' or '\n')) + { + docCommentsBuilder.Append(' '); + } + } + else + { + docCommentsBuilder.Append("/// "); + inParagraph = true; + inComment = true; + } + + if (!inComment) + { + docCommentsBuilder.Append("/// "); + } + + if (paramDocLine.IndexOf("= 0 || + paramDocLine.IndexOf("= 0 || + paramDocLine.IndexOf("= 0 || + paramDocLine.IndexOf("= 0 || + paramDocLine.IndexOf("```", StringComparison.OrdinalIgnoreCase) >= 0 || + paramDocLine.IndexOf("<<", StringComparison.OrdinalIgnoreCase) >= 0) + { + // We don't try to format tables, so truncate at this point. + if (inParagraph) + { + docCommentsBuilder.AppendLine(""); + inParagraph = false; + inComment = false; + } + + docCommentsBuilder.AppendLine($@"/// This doc was truncated."); + + break; // is this the right way? + } + + EmitLine(docCommentsBuilder, paramDocLine); + } + + if (inParagraph) + { + if (!inComment) + { + docCommentsBuilder.Append("/// "); + } + + docCommentsBuilder.AppendLine(""); + inParagraph = false; + inComment = false; + } + + if (docs is object) + { + docCommentsBuilder.AppendLine($@"/// Read more on learn.microsoft.com."); + } + + docCommentsBuilder.Append("/// "); + } + else + { + EmitLine(docCommentsBuilder, yamlDocSrc); + } + } + private T AddApiDocumentation(string api, T memberDeclaration) where T : MemberDeclarationSyntax { @@ -41,50 +146,7 @@ private T AddApiDocumentation(string api, T memberDeclaration) if (docs.Fields is object) { - var fieldsDocBuilder = new StringBuilder(); - switch (memberDeclaration) - { - case StructDeclarationSyntax structDeclaration: - memberDeclaration = memberDeclaration.ReplaceNodes( - structDeclaration.Members.OfType(), - (_, field) => - { - VariableDeclaratorSyntax? variable = field.Declaration.Variables.Single(); - if (docs.Fields.TryGetValue(variable.Identifier.ValueText, out string? fieldDoc)) - { - fieldsDocBuilder.Append("/// "); - EmitDoc(fieldDoc, fieldsDocBuilder, docs, "members"); - fieldsDocBuilder.AppendLine(""); - if (field.Declaration.Type.HasAnnotations(OriginalDelegateAnnotation)) - { - fieldsDocBuilder.AppendLine(@$"/// See the delegate for more about this function."); - } - - field = field.WithLeadingTrivia(ParseLeadingTrivia(fieldsDocBuilder.ToString().Replace("\r\n", "\n"))); - fieldsDocBuilder.Clear(); - } - - return field; - }); - break; - case EnumDeclarationSyntax enumDeclaration: - memberDeclaration = memberDeclaration.ReplaceNodes( - enumDeclaration.Members, - (_, field) => - { - if (docs.Fields.TryGetValue(field.Identifier.ValueText, out string? fieldDoc)) - { - fieldsDocBuilder.Append($@"/// "); - EmitDoc(fieldDoc, fieldsDocBuilder, docs, "members"); - fieldsDocBuilder.AppendLine(""); - field = field.WithLeadingTrivia(ParseLeadingTrivia(fieldsDocBuilder.ToString().Replace("\r\n", "\n"))); - fieldsDocBuilder.Clear(); - } - - return field; - }); - break; - } + memberDeclaration = this.ApplyFieldDocs(memberDeclaration, docs); } if (docs.ReturnValue is object) @@ -116,110 +178,61 @@ private T AddApiDocumentation(string api, T memberDeclaration) } return memberDeclaration; + } - static void EmitLine(StringBuilder stringBuilder, string yamlDocSrc) + private T ApplyFieldDocs(T memberDeclaration, ApiDetails docs) + where T : MemberDeclarationSyntax + { + if (docs.Fields is null) { - stringBuilder.Append(yamlDocSrc.Trim()); + return memberDeclaration; } - static void EmitDoc(string yamlDocSrc, StringBuilder docCommentsBuilder, ApiDetails? docs, string docsAnchor) + var fieldsDocBuilder = new StringBuilder(); + switch (memberDeclaration) { - if (yamlDocSrc.Contains('\n')) - { - docCommentsBuilder.AppendLine(); - var docReader = new StringReader(yamlDocSrc); - string? paramDocLine; - - bool inParagraph = false; - bool inComment = false; - int blankLineCounter = 0; - while ((paramDocLine = docReader.ReadLine()) is object) - { - if (string.IsNullOrWhiteSpace(paramDocLine)) + case StructDeclarationSyntax structDeclaration: + memberDeclaration = memberDeclaration.ReplaceNodes( + structDeclaration.Members.OfType(), + (_, field) => { - if (++blankLineCounter >= 2 && inParagraph) + VariableDeclaratorSyntax? variable = field.Declaration.Variables.Single(); + if (docs.Fields.TryGetValue(variable.Identifier.ValueText, out string? fieldDoc)) { - docCommentsBuilder.AppendLine(""); - inParagraph = false; - inComment = false; - } - - continue; - } - else if (blankLineCounter > 0) - { - blankLineCounter = 0; - } - else if (docCommentsBuilder.Length > 0 && docCommentsBuilder[docCommentsBuilder.Length - 1] != '\n') - { - docCommentsBuilder.Append(' '); - } + fieldsDocBuilder.Append("/// "); + EmitDoc(fieldDoc, fieldsDocBuilder, docs, "members"); + fieldsDocBuilder.AppendLine(""); + if (field.Declaration.Type.HasAnnotations(OriginalDelegateAnnotation)) + { + fieldsDocBuilder.AppendLine(@$"/// See the delegate for more about this function."); + } - if (inParagraph) - { - if (docCommentsBuilder.Length > 0 && docCommentsBuilder[docCommentsBuilder.Length - 1] is not (' ' or '\n')) - { - docCommentsBuilder.Append(' '); + field = field.WithLeadingTrivia(ParseLeadingTrivia(fieldsDocBuilder.ToString().Replace("\r\n", "\n"))); + fieldsDocBuilder.Clear(); } - } - else - { - docCommentsBuilder.Append("/// "); - inParagraph = true; - inComment = true; - } - if (!inComment) + return field; + }); + break; + case EnumDeclarationSyntax enumDeclaration: + memberDeclaration = memberDeclaration.ReplaceNodes( + enumDeclaration.Members, + (_, field) => { - docCommentsBuilder.Append("/// "); - } - - if (paramDocLine.IndexOf("= 0 || - paramDocLine.IndexOf("= 0 || - paramDocLine.IndexOf("= 0 || - paramDocLine.IndexOf("= 0 || - paramDocLine.IndexOf("```", StringComparison.OrdinalIgnoreCase) >= 0 || - paramDocLine.IndexOf("<<", StringComparison.OrdinalIgnoreCase) >= 0) - { - // We don't try to format tables, so truncate at this point. - if (inParagraph) + if (docs.Fields.TryGetValue(field.Identifier.ValueText, out string? fieldDoc)) { - docCommentsBuilder.AppendLine(""); - inParagraph = false; - inComment = false; + fieldsDocBuilder.Append($@"/// "); + EmitDoc(fieldDoc, fieldsDocBuilder, docs, "members"); + fieldsDocBuilder.AppendLine(""); + field = field.WithLeadingTrivia(ParseLeadingTrivia(fieldsDocBuilder.ToString().Replace("\r\n", "\n"))); + fieldsDocBuilder.Clear(); } - docCommentsBuilder.AppendLine($@"/// This doc was truncated."); - - break; // is this the right way? - } - - EmitLine(docCommentsBuilder, paramDocLine); - } - - if (inParagraph) - { - if (!inComment) - { - docCommentsBuilder.Append("/// "); - } - - docCommentsBuilder.AppendLine(""); - inParagraph = false; - inComment = false; - } - - if (docs is object) - { - docCommentsBuilder.AppendLine($@"/// Read more on learn.microsoft.com."); - } - - docCommentsBuilder.Append("/// "); - } - else - { - EmitLine(docCommentsBuilder, yamlDocSrc); - } + return field; + }); + break; } + + return memberDeclaration; } } diff --git a/src/Microsoft.Windows.CsWin32/Generator.Struct.cs b/src/Microsoft.Windows.CsWin32/Generator.Struct.cs index dcf70993..a1256d2e 100644 --- a/src/Microsoft.Windows.CsWin32/Generator.Struct.cs +++ b/src/Microsoft.Windows.CsWin32/Generator.Struct.cs @@ -15,6 +15,29 @@ public partial class Generator _ => throw new NotSupportedException($"Unsupported primitive type code: {code}"), }; + /// + /// Gets a value indicating whether the given field name is the generated holder for an anonymous nested struct or union + /// (e.g. Anonymous, Anonymous1, Anonymous2). + /// + private static bool IsAnonymousFieldName(string name) + { + const string Prefix = "Anonymous"; + if (!name.StartsWith(Prefix, StringComparison.Ordinal)) + { + return false; + } + + for (int i = Prefix.Length; i < name.Length; i++) + { + if (!char.IsDigit(name[i])) + { + return false; + } + } + + return true; + } + private StructDeclarationSyntax DeclareStruct(TypeDefinitionHandle typeDefHandle, Context context) { TypeDefinition typeDef = this.Reader.GetTypeDefinition(typeDefHandle); @@ -388,6 +411,9 @@ private StructDeclarationSyntax DeclareStruct(TypeDefinitionHandle typeDefHandle break; } + // Surface fields nested within anonymous structs and unions as ref-returning properties on this struct. + this.AddFlattenedAnonymousMembers(typeDef, context, members); + StructDeclarationSyntax result = StructDeclaration(name.Identifier, [.. members]) .WithModifiers([TokenWithSpace(this.Visibility), TokenWithSpace(SyntaxKind.PartialKeyword)]); @@ -405,6 +431,23 @@ private StructDeclarationSyntax DeclareStruct(TypeDefinitionHandle typeDefHandle result = this.AddApiDocumentation(name.Identifier.ValueText, result); + // When flattening anonymous nested types, give the leaf fields documentation inherited from the + // root declaring type's API docs so that the generated ref accessors (which from them) + // surface meaningful IntelliSense. + if (this.options.FlattenNestedAnonymousTypes && this.canUseUnscopedRef && typeDef.IsNested && this.ApiDocs is not null) + { + TypeDefinition rootDef = typeDef; + while (rootDef.GetDeclaringType() is { IsNil: false } declaringHandle) + { + rootDef = this.Reader.GetTypeDefinition(declaringHandle); + } + + if (this.ApiDocs.TryGetApiDocs(this.Reader.GetString(rootDef.Name), out ApiDetails? rootDocs)) + { + result = this.ApplyFieldDocs(result, rootDocs); + } + } + return result; } @@ -572,4 +615,248 @@ private void RequestVariableLengthInlineArrayHelper2(Context context) }); } } + + /// + /// Surfaces fields nested within anonymous structs and unions as [UnscopedRef] ref properties on the declaring struct + /// so they can be read, written, and pointed to directly (e.g. value.field instead of value.Anonymous.Anonymous.field). + /// + /// The definition of the struct being generated. + /// The context the struct is being generated with. + /// The member list being built for the struct. Generated accessors are appended to the end so that the indices of existing members are preserved. + private void AddFlattenedAnonymousMembers(TypeDefinition typeDef, Context context, List members) + { + // The accessors return a ref into a field, which requires [UnscopedRef] (C# 11+). + if (!this.options.FlattenNestedAnonymousTypes || !this.canUseUnscopedRef) + { + return; + } + + // Collect the names already declared directly on this struct so we never collide with them. + HashSet reservedNames = new(StringComparer.Ordinal); + foreach (MemberDeclarationSyntax member in members) + { + switch (member) + { + case FieldDeclarationSyntax field: + foreach (VariableDeclaratorSyntax variable in field.Declaration.Variables) + { + reservedNames.Add(variable.Identifier.ValueText); + } + + break; + case PropertyDeclarationSyntax property: + reservedNames.Add(property.Identifier.ValueText); + break; + case MethodDeclarationSyntax method: + reservedNames.Add(method.Identifier.ValueText); + break; + } + } + + List accessors = new(); + HashSet surfacedNames = new(StringComparer.Ordinal); + + foreach (FieldDefinitionHandle fieldDefHandle in typeDef.GetFields()) + { + FieldDefinition fieldDef = this.Reader.GetFieldDefinition(fieldDefHandle); + if (fieldDef.Attributes.HasFlag(FieldAttributes.Static)) + { + continue; + } + + string fieldName = this.Reader.GetString(fieldDef.Name); + if (!IsAnonymousFieldName(fieldName) || !this.TryGetNestedTypeForAnonymousField(typeDef, fieldDef, out TypeDefinitionHandle nestedTypeHandle)) + { + continue; + } + + TypeDefinition nestedTypeDef = this.Reader.GetTypeDefinition(nestedTypeHandle); + ExpressionSyntax accessPrefix = MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, ThisExpression(), IdentifierName(SafeIdentifier(fieldName))); + NameSyntax crefPrefix = IdentifierName(this.GetMangledIdentifier(this.Reader.GetString(nestedTypeDef.Name), context.AllowMarshaling, this.IsManagedType(nestedTypeHandle))); + this.GatherFlattenedAnonymousAccessors(nestedTypeHandle, accessPrefix, crefPrefix, context, accessors, surfacedNames, reservedNames, depth: 0); + } + + members.AddRange(accessors); + } + + /// + /// Recursively gathers [UnscopedRef] ref accessors for the leaf fields reachable through anonymous holders. + /// + /// The anonymous nested type currently being walked. + /// The expression that accesses an instance of from the outer struct (e.g. this.Anonymous.Anonymous). + /// The qualified type-name chain (relative to the outer struct) used to build the cref for inherited documentation. + /// The context that is generated with. + /// The list that generated accessors are appended to. + /// The set of leaf names already surfaced, used to skip duplicates. + /// The set of names already declared on the outer struct, used to skip collisions. + /// The current recursion depth, used as a guard against unexpectedly deep nesting. + private void GatherFlattenedAnonymousAccessors( + TypeDefinitionHandle containingTypeHandle, + ExpressionSyntax accessPrefix, + NameSyntax crefTypePrefix, + Context containingContext, + List accessors, + HashSet surfacedNames, + HashSet reservedNames, + int depth) + { + // Guard against unexpectedly deep or cyclic nesting. + if (depth > 8) + { + return; + } + + TypeDefinition containingType = this.Reader.GetTypeDefinition(containingTypeHandle); + + // An explicit-layout type (such as a union) forces its fields to be generated without marshaling, + // so decode this type's fields with the same context its own members were generated with. + bool explicitLayout = (containingType.Attributes & TypeAttributes.ExplicitLayout) == TypeAttributes.ExplicitLayout; + Context bodyContext = containingContext.AllowMarshaling && explicitLayout ? containingContext with { AllowMarshaling = false } : containingContext; + TypeSyntaxSettings typeSettings = bodyContext.Filter(this.fieldTypeSettings); + + foreach (FieldDefinitionHandle fieldDefHandle in containingType.GetFields()) + { + FieldDefinition fieldDef = this.Reader.GetFieldDefinition(fieldDefHandle); + if (fieldDef.Attributes.HasFlag(FieldAttributes.Static)) + { + continue; + } + + string fieldName = this.Reader.GetString(fieldDef.Name); + CustomAttributeHandleCollection fieldAttributes = fieldDef.GetCustomAttributes(); + + // Recurse through anonymous holder fields; named nested members are not flattened. + if (IsAnonymousFieldName(fieldName) && this.TryGetNestedTypeForAnonymousField(containingType, fieldDef, out TypeDefinitionHandle deeperTypeHandle)) + { + TypeDefinition deeperTypeDef = this.Reader.GetTypeDefinition(deeperTypeHandle); + ExpressionSyntax deeperAccess = MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, accessPrefix, IdentifierName(SafeIdentifier(fieldName))); + NameSyntax deeperCref = QualifiedName(crefTypePrefix, IdentifierName(this.GetMangledIdentifier(this.Reader.GetString(deeperTypeDef.Name), bodyContext.AllowMarshaling, this.IsManagedType(deeperTypeHandle)))); + this.GatherFlattenedAnonymousAccessors(deeperTypeHandle, deeperAccess, deeperCref, bodyContext, accessors, surfacedNames, reservedNames, depth + 1); + continue; + } + + // Skip fields whose generated representation is not a plain, ref-returnable field of the decoded type. + if (this.FindAssociatedEnum(fieldAttributes) is not null) + { + // Surfaced as a by-value property rather than a field. + continue; + } + + if (this.FindAttribute(fieldAttributes, SystemRuntimeCompilerServices, nameof(FixedBufferAttribute)).HasValue) + { + continue; + } + + if (MetadataUtilities.FindAttribute(this.Reader, fieldAttributes, InteropDecorationNamespace, FlexibleArrayAttribute) is not null) + { + continue; + } + + TypeHandleInfo fieldTypeInfo = fieldDef.DecodeSignature(this.SignatureHandleProvider, null); + + // Skip field types that are reinterpreted during struct generation (delegates become function pointers, + // COM interface pointers become arrays, marshaled managed types become unmanaged pointers); the generated + // member would not be a simple ref-returnable field of the decoded type. + if (this.IsDelegateReference(fieldTypeInfo, out _)) + { + continue; + } + + if (bodyContext.AllowMarshaling && fieldTypeInfo is PointerTypeHandleInfo pointerInfo && this.IsInterface(pointerInfo.ElementType)) + { + continue; + } + + if (bodyContext.AllowMarshaling && this.useSourceGenerators && this.IsManagedType(fieldTypeInfo)) + { + continue; + } + + TypeSyntax fieldType = fieldTypeInfo.ToTypeSyntax(typeSettings, GeneratingElement.StructMember, fieldAttributes.QualifyWith(this)).Type; + + // Fixed-length arrays are reinterpreted into a helper struct field, which is not a simple ref target. + if (fieldType is ArrayTypeSyntax) + { + continue; + } + + // Skip collisions with an existing member or another flattened accessor. + if (reservedNames.Contains(fieldName) || !surfacedNames.Add(fieldName)) + { + continue; + } + + accessors.Add(this.CreateFlattenedAnonymousAccessor(fieldName, fieldType, accessPrefix, crefTypePrefix)); + this.DeclareUnscopedRefAttributeIfNecessary(); + } + } + + /// + /// Creates a single [UnscopedRef] ref property that forwards to a field reached through anonymous holders, + /// with documentation inherited from the underlying field. + /// + private PropertyDeclarationSyntax CreateFlattenedAnonymousAccessor(string fieldName, TypeSyntax fieldType, ExpressionSyntax accessPrefix, NameSyntax crefTypePrefix) + { + // ref => ref .; + ExpressionSyntax leafAccess = MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, accessPrefix, IdentifierName(SafeIdentifier(fieldName))); + PropertyDeclarationSyntax accessor = PropertyDeclaration(RefType(fieldType.WithoutTrailingTrivia()).WithTrailingTrivia(Space), SafeIdentifier(fieldName)) + .AddModifiers(TokenWithSpace(this.Visibility)) + .WithExpressionBody(ArrowExpressionClause(RefExpression(leafAccess))) + .WithSemicolonToken(SemicolonWithLineFeed) + .AddAttributeLists(AttributeList(UnscopedRefAttributeSyntax)); + + if (RequiresUnsafe(fieldType)) + { + accessor = accessor.AddModifiers(TokenWithSpace(SyntaxKind.UnsafeKeyword)); + } + + // /// + CrefSyntax cref = SyntaxFactory.NameMemberCref(QualifiedName(crefTypePrefix, SafeIdentifierName(fieldName))); + SyntaxTrivia inheritDoc = Trivia(DocumentationCommentTrivia( + SyntaxKind.SingleLineDocumentationCommentTrivia, + [ + XmlText("/// "), + XmlEmptyElement("inheritdoc", [XmlCrefAttribute(cref)]), + XmlText(XmlTextNewLine("\n", continueXmlDocumentationComment: false)), + ])); + + return accessor.WithLeadingTrivia(inheritDoc); + } + + /// + /// Resolves the nested type definition referenced by an anonymous holder field, if the field's type is in fact + /// a type nested within . + /// + private bool TryGetNestedTypeForAnonymousField(TypeDefinition containingType, FieldDefinition fieldDef, out TypeDefinitionHandle nestedTypeHandle) + { + nestedTypeHandle = default; + if (fieldDef.DecodeSignature(this.SignatureHandleProvider, null) is not HandleTypeHandleInfo fieldTypeInfo || !this.IsNestedType(fieldTypeInfo.Handle)) + { + return false; + } + + TypeDefinitionHandle candidate; + switch (fieldTypeInfo.Handle.Kind) + { + case HandleKind.TypeDefinition: + candidate = (TypeDefinitionHandle)fieldTypeInfo.Handle; + break; + case HandleKind.TypeReference when this.TryGetTypeDefHandle((TypeReferenceHandle)fieldTypeInfo.Handle, out QualifiedTypeDefinitionHandle qualifiedHandle) && qualifiedHandle.Generator == this: + candidate = qualifiedHandle.DefinitionHandle; + break; + default: + return false; + } + + foreach (TypeDefinitionHandle nested in containingType.GetNestedTypes()) + { + if (nested == candidate) + { + nestedTypeHandle = nested; + return true; + } + } + + return false; + } } diff --git a/src/Microsoft.Windows.CsWin32/GeneratorOptions.cs b/src/Microsoft.Windows.CsWin32/GeneratorOptions.cs index 7dc7fab9..9a5f7991 100644 --- a/src/Microsoft.Windows.CsWin32/GeneratorOptions.cs +++ b/src/Microsoft.Windows.CsWin32/GeneratorOptions.cs @@ -66,6 +66,20 @@ public record GeneratorOptions /// public bool MultiTargetingFriendlyAPIs { get; set; } + /// + /// Gets or sets a value indicating whether fields nested within anonymous structs and unions are surfaced as ref-returning properties on the declaring struct. + /// + /// + /// + /// Windows metadata models anonymous nested structs and unions as named nested types (e.g. _Anonymous_e__Union) reached through a generated holder field named Anonymous (or Anonymous1, Anonymous2, etc.). This forces awkward access such as value.Anonymous.Anonymous.field. When this option is enabled, a [UnscopedRef] ref property is generated on the declaring struct for each such nested field so the field may be read, written, and pointed to directly as value.field. + /// + /// + /// Only fields reached exclusively through anonymous holders are flattened; named nested members are left alone. The generated accessors require C# 11 or later (for ); when an older language version is in use, no accessors are generated and the Anonymous holder remains the only access path. + /// + /// + /// The default value is . + public bool FlattenNestedAnonymousTypes { get; set; } + /// /// Gets or sets a value indicating whether friendly overloads should use safe handles. /// diff --git a/src/Microsoft.Windows.CsWin32/settings.schema.json b/src/Microsoft.Windows.CsWin32/settings.schema.json index e7dde71e..69ab3163 100644 --- a/src/Microsoft.Windows.CsWin32/settings.schema.json +++ b/src/Microsoft.Windows.CsWin32/settings.schema.json @@ -63,6 +63,11 @@ "type": "boolean", "default": false }, + "flattenNestedAnonymousTypes": { + "description": "Surface fields nested within anonymous structs and unions as ref-returning properties on the declaring struct, so they can be accessed directly (e.g. value.field) instead of through the generated Anonymous holder (e.g. value.Anonymous.Anonymous.field). The generated accessors require C# 11 or later; otherwise no accessors are generated.", + "type": "boolean", + "default": false + }, "useSafeHandles": { "description": "A value indicating whether friendly overloads should use safe handles.", "type": "boolean", diff --git a/test/GenerationSandbox.Tests/BasicTests.cs b/test/GenerationSandbox.Tests/BasicTests.cs index 624d2827..6dec0ac1 100644 --- a/test/GenerationSandbox.Tests/BasicTests.cs +++ b/test/GenerationSandbox.Tests/BasicTests.cs @@ -514,6 +514,34 @@ public void LoadTypeWithOverlappedRefAndValueTypes_VARDESC() }; } + [Fact] + public void FlattenedAnonymousFieldsAliasNestedFields() + { + Windows.Win32.System.SystemInformation.SYSTEM_INFO si = default; + + // Writing through the flattened accessor writes through to the nested union field. + si.dwOemId = 0x12345678; + Assert.Equal(0x12345678u, si.Anonymous.dwOemId); + + // A field two levels deep (through the union, then the nested struct) is aliased in both directions. + si.Anonymous.Anonymous.wReserved = 0x4321; + Assert.Equal((ushort)0x4321, si.wReserved); + } + + [Fact] + public unsafe void FlattenedAnonymousFieldSupportsPointers() + { + Windows.Win32.System.SystemInformation.SYSTEM_INFO si = default; + + // The ref-returning accessor is addressable, so callers can take a pointer to the nested field. + fixed (uint* p = &si.dwOemId) + { + *p = 0xABCDEF01; + } + + Assert.Equal(0xABCDEF01u, si.Anonymous.dwOemId); + } + [Fact] public void FieldWithAssociatedEnum() { diff --git a/test/GenerationSandbox.Tests/NativeMethods.json b/test/GenerationSandbox.Tests/NativeMethods.json index d3bb18b5..b40f6379 100644 --- a/test/GenerationSandbox.Tests/NativeMethods.json +++ b/test/GenerationSandbox.Tests/NativeMethods.json @@ -2,6 +2,7 @@ "$schema": "..\\..\\src\\Microsoft.Windows.CsWin32\\settings.schema.json", "emitSingleFile": true, "multiTargetingFriendlyAPIs": true, + "flattenNestedAnonymousTypes": true, "comInterop": { "preserveSigMethods": [ "IEnumDebugPropertyInfo.Next" diff --git a/test/GenerationSandbox.Tests/NativeMethods.txt b/test/GenerationSandbox.Tests/NativeMethods.txt index bf66d4c4..a872dab7 100644 --- a/test/GenerationSandbox.Tests/NativeMethods.txt +++ b/test/GenerationSandbox.Tests/NativeMethods.txt @@ -80,6 +80,7 @@ ShellWindows ShellWindowTypeConstants SHFILEOPSTRUCTW SIZE +SYSTEM_INFO VARDESC WER_REPORT_INFORMATION wglGetProcAddress diff --git a/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs b/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs index 06255de0..9c4d4460 100644 --- a/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs +++ b/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs @@ -283,4 +283,92 @@ public void InterestingStructs( this.generator = this.CreateGenerator(options); this.GenerateApi(name); } + + [Theory, PairwiseData] + public void FlattenNestedAnonymousTypes_GeneratesRefAccessors(bool allowMarshaling) + { + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { AllowMarshaling = allowMarshaling, FlattenNestedAnonymousTypes = true }); + this.GenerateApi("SYSTEM_INFO"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("SYSTEM_INFO")); + + // A field one level deep (directly in the anonymous union) is surfaced. + AssertFlattenedAccessor(FindProperty(structDecl, "dwOemId"), "this.Anonymous.dwOemId"); + + // Fields two levels deep (through the union, then the nested anonymous struct) are surfaced. + AssertFlattenedAccessor(FindProperty(structDecl, "wProcessorArchitecture"), "this.Anonymous.Anonymous.wProcessorArchitecture"); + AssertFlattenedAccessor(FindProperty(structDecl, "wReserved"), "this.Anonymous.Anonymous.wReserved"); + } + + [Fact] + public void FlattenNestedAnonymousTypes_DisabledByDefault() + { + this.GenerateApi("SYSTEM_INFO"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("SYSTEM_INFO")); + Assert.DoesNotContain(structDecl.Members.OfType(), p => p.Identifier.ValueText == "wProcessorArchitecture"); + } + + [Fact] + public void FlattenNestedAnonymousTypes_RequiresCSharp11() + { + this.parseOptions = this.parseOptions.WithLanguageVersion(LanguageVersion.CSharp10); + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { FlattenNestedAnonymousTypes = true }); + this.GenerateApi("SYSTEM_INFO"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("SYSTEM_INFO")); + Assert.DoesNotContain(structDecl.Members.OfType(), p => p.Identifier.ValueText == "wProcessorArchitecture"); + } + + [Fact] + public void FlattenNestedAnonymousTypes_LeavesNamedNestedTypesAlone() + { + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { FlattenNestedAnonymousTypes = true }); + this.GenerateApi("KEY_EVENT_RECORD"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("KEY_EVENT_RECORD")); + + // uChar is a *named* nested union (reached as KEY_EVENT_RECORD.uChar), so its fields are not flattened. + Assert.DoesNotContain(structDecl.Members.OfType(), p => p.Identifier.ValueText is "UnicodeChar" or "AsciiChar"); + } + + [Fact] + public void FlattenNestedAnonymousTypes_InheritsFieldDocumentation() + { + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { FlattenNestedAnonymousTypes = true }, includeDocs: true); + this.GenerateApi("SYSTEM_INFO"); + + // The leaf field deep in the nested struct receives a summary inherited from SYSTEM_INFO's API docs. + var nestedStruct = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("_Anonymous_e__Struct")); + (FieldDeclarationSyntax Field, VariableDeclaratorSyntax Variable)? leafField = this.FindFieldDeclaration(nestedStruct, "wProcessorArchitecture"); + Assert.NotNull(leafField); + Assert.Contains( + leafField!.Value.Field.GetLeadingTrivia().Select(t => t.GetStructure()).OfType().SelectMany(d => d.Content.OfType()), + e => e.StartTag.Name.ToString() == "summary"); + + // The flattened accessor inherits documentation from that field. + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("SYSTEM_INFO")); + PropertyDeclarationSyntax accessor = FindProperty(structDecl, "wProcessorArchitecture"); + Assert.Contains( + accessor.GetLeadingTrivia().Select(t => t.GetStructure()).OfType().SelectMany(d => d.Content.OfType()), + e => e.Name.ToString() == "inheritdoc" && e.Attributes.OfType().Any()); + } + + private static PropertyDeclarationSyntax FindProperty(StructDeclarationSyntax structDecl, string name) => + Assert.Single(structDecl.Members.OfType(), p => p.Identifier.ValueText == name); + + private static void AssertFlattenedAccessor(PropertyDeclarationSyntax property, string expectedRefTarget) + { + // It returns by ref. + Assert.IsType(property.Type); + + // It is annotated with [UnscopedRef]. + Assert.Contains(property.AttributeLists.SelectMany(al => al.Attributes), a => a.Name.ToString() == "UnscopedRef"); + + // The expression body forwards a ref to the nested field. + ArrowExpressionClauseSyntax body = Assert.IsType(property.ExpressionBody); + RefExpressionSyntax refExpr = Assert.IsType(body.Expression); + Assert.Equal(expectedRefTarget, refExpr.Expression.ToString()); + + // It inherits documentation via . + Assert.Contains( + property.GetLeadingTrivia().Select(t => t.GetStructure()).OfType().SelectMany(d => d.Content.OfType()), + e => e.Name.ToString() == "inheritdoc" && e.Attributes.OfType().Any()); + } } From 713faf0965ad6807dc6343ccbf1266475a77a0b5 Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Wed, 10 Jun 2026 20:11:02 -0700 Subject: [PATCH 2/6] Add tests for flattened anonymous struct accessors (#408) Cover numbered/multiple anonymous holders (DECIMAL), unsafe pointer leaves (VARDESC), public visibility, and regression guards for the explicit-layout field-type context (ELEMDESC/CS8151) and the deeply-nested managed-union inheritdoc cref suffix (PROPVARIANT/CS1574). --- .../StructTests.cs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs b/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs index 9c4d4460..b77d8bd0 100644 --- a/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs +++ b/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs @@ -350,6 +350,87 @@ public void FlattenNestedAnonymousTypes_InheritsFieldDocumentation() e => e.Name.ToString() == "inheritdoc" && e.Attributes.OfType().Any()); } + [Fact] + public void FlattenNestedAnonymousTypes_SupportsNumberedAndMultipleHolders() + { + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { FlattenNestedAnonymousTypes = true }); + this.GenerateApi("DECIMAL"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("DECIMAL")); + + // Fields reached through the first numbered holder (Anonymous1) and its inner struct. + AssertFlattenedAccessor(FindProperty(structDecl, "signscale"), "this.Anonymous1.signscale"); + AssertFlattenedAccessor(FindProperty(structDecl, "scale"), "this.Anonymous1.Anonymous.scale"); + AssertFlattenedAccessor(FindProperty(structDecl, "sign"), "this.Anonymous1.Anonymous.sign"); + + // Fields reached through the second numbered holder (Anonymous2) and its inner struct. + AssertFlattenedAccessor(FindProperty(structDecl, "Lo64"), "this.Anonymous2.Lo64"); + AssertFlattenedAccessor(FindProperty(structDecl, "Lo32"), "this.Anonymous2.Anonymous.Lo32"); + AssertFlattenedAccessor(FindProperty(structDecl, "Mid32"), "this.Anonymous2.Anonymous.Mid32"); + + // No flattened accessor name is emitted more than once. + List accessorNames = structDecl.Members.OfType() + .Where(p => p.AttributeLists.SelectMany(al => al.Attributes).Any(a => a.Name.ToString() == "UnscopedRef")) + .Select(p => p.Identifier.ValueText) + .ToList(); + Assert.Equal(accessorNames.Count, accessorNames.Distinct().Count()); + } + + [Fact] + public void FlattenNestedAnonymousTypes_PointerLeafProducesUnsafeAccessor() + { + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { FlattenNestedAnonymousTypes = true }); + this.GenerateApi("VARDESC"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("VARDESC")); + + // A non-pointer leaf is surfaced without the 'unsafe' modifier. + PropertyDeclarationSyntax oInst = FindProperty(structDecl, "oInst"); + AssertFlattenedAccessor(oInst, "this.Anonymous.oInst"); + Assert.DoesNotContain(oInst.Modifiers, m => m.IsKind(SyntaxKind.UnsafeKeyword)); + + // A pointer-typed leaf is surfaced as an 'unsafe' ref to the pointer type. + PropertyDeclarationSyntax lpvarValue = FindProperty(structDecl, "lpvarValue"); + AssertFlattenedAccessor(lpvarValue, "this.Anonymous.lpvarValue"); + Assert.Contains(lpvarValue.Modifiers, m => m.IsKind(SyntaxKind.UnsafeKeyword)); + RefTypeSyntax refType = Assert.IsType(lpvarValue.Type); + Assert.IsType(refType.Type); + } + + [Fact] + public void FlattenNestedAnonymousTypes_RespectsPublicVisibility() + { + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { Public = true, FlattenNestedAnonymousTypes = true }); + this.GenerateApi("SYSTEM_INFO"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("SYSTEM_INFO")); + PropertyDeclarationSyntax accessor = FindProperty(structDecl, "wProcessorArchitecture"); + Assert.Contains(accessor.Modifiers, m => m.IsKind(SyntaxKind.PublicKeyword)); + } + + [Theory, PairwiseData] + public void FlattenNestedAnonymousTypes_ExplicitLayoutManagedUnionGeneratesValidCode(bool allowMarshaling) + { + // Regression: an explicit-layout (union) nested type forces its fields to be generated without + // marshaling. The flattened accessor must decode the leaf field with that same context, or the + // ref-return type won't match the underlying field's type (CS8151). GenerateApi asserts no diagnostics. + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { AllowMarshaling = allowMarshaling, FlattenNestedAnonymousTypes = true }); + this.GenerateApi("ELEMDESC"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("ELEMDESC")); + AssertFlattenedAccessor(FindProperty(structDecl, "paramdesc"), "this.Anonymous.paramdesc"); + } + + [Theory, PairwiseData] + public void FlattenNestedAnonymousTypes_DeepNestedManagedUnionCrefResolves(bool allowMarshaling) + { + // Regression: in marshaling mode, managed nested types receive the "_unmanaged" suffix. The + // on deeply nested accessors must use the mangled type name or the cref + // fails to resolve (CS1574). GenerateApi asserts no diagnostics, which catches an unresolved cref. + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { AllowMarshaling = allowMarshaling, FlattenNestedAnonymousTypes = true }); + this.GenerateApi("PROPVARIANT"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("PROPVARIANT")); + + // A field three levels deep (union -> struct -> union) is surfaced on the outer struct. + AssertFlattenedAccessor(FindProperty(structDecl, "intVal"), "this.Anonymous.Anonymous.Anonymous.intVal"); + } + private static PropertyDeclarationSyntax FindProperty(StructDeclarationSyntax structDecl, string name) => Assert.Single(structDecl.Members.OfType(), p => p.Identifier.ValueText == name); From 79267ce7f16ddf998c7ba6c6924f74e6e86550bd Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Wed, 10 Jun 2026 20:19:36 -0700 Subject: [PATCH 3/6] Add no-op and skip-reinterpreted tests for anonymous flattening (#408) Verify the option is a no-op for a struct without anonymous members (LUID), and that a fixed-length array leaf inside an anonymous union (BLUETOOTH_ADDRESS.rgBytes) is not surfaced as a ref accessor while its scalar sibling (ullLong) is. --- .../StructTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs b/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs index b77d8bd0..92cd4180 100644 --- a/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs +++ b/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs @@ -431,6 +431,38 @@ public void FlattenNestedAnonymousTypes_DeepNestedManagedUnionCrefResolves(bool AssertFlattenedAccessor(FindProperty(structDecl, "intVal"), "this.Anonymous.Anonymous.Anonymous.intVal"); } + [Fact] + public void FlattenNestedAnonymousTypes_NoOpForStructWithoutAnonymousMembers() + { + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { FlattenNestedAnonymousTypes = true }); + this.GenerateApi("LUID"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("LUID")); + + // The struct has no anonymous holder fields, so the option produces no flattening accessors. + Assert.DoesNotContain( + structDecl.Members.OfType(), + p => p.AttributeLists.SelectMany(al => al.Attributes).Any(a => a.Name.ToString() == "UnscopedRef")); + } + + [Fact] + public void FlattenNestedAnonymousTypes_SkipsReinterpretedArrayLeaf() + { + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { FlattenNestedAnonymousTypes = true }); + this.GenerateApi("BLUETOOTH_ADDRESS"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("BLUETOOTH_ADDRESS")); + + // The scalar member of the anonymous union is flattened. + AssertFlattenedAccessor(FindProperty(structDecl, "ullLong"), "this.Anonymous.ullLong"); + + // The fixed-length array member (reinterpreted into a helper struct, not a plain ref-returnable + // field) is NOT surfaced as a flattened accessor on the outer struct. + Assert.DoesNotContain(structDecl.Members.OfType(), p => p.Identifier.ValueText == "rgBytes"); + + // The array field is still present inside the nested union itself. + var nestedUnion = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("_Anonymous_e__Union")); + Assert.NotNull(this.FindFieldDeclaration(nestedUnion, "rgBytes")); + } + private static PropertyDeclarationSyntax FindProperty(StructDeclarationSyntax structDecl, string name) => Assert.Single(structDecl.Members.OfType(), p => p.Identifier.ValueText == name); From ff071b71c5be3b9db0cb89f39139544f6012361d Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Thu, 11 Jun 2026 09:17:12 -0700 Subject: [PATCH 4/6] Add tests guarding against over-flattening of nested struct values Flattening must hoist only anonymous union/struct grouping fields, never the members of a nested struct *value* reached through them. - INPUT: union of struct values (mi/ki/hi) are surfaced as whole refs; their inner fields (dx/dy/...) are not flattened onto INPUT. - PROPVARIANT: the DECIMAL value (decVal) is surfaced as a single ref; DECIMAL's own union members (Lo64/scale/...) are flattened only onto DECIMAL, not transitively onto PROPVARIANT. --- .../StructTests.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs b/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs index 92cd4180..c97bd8ed 100644 --- a/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs +++ b/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs @@ -463,6 +463,58 @@ public void FlattenNestedAnonymousTypes_SkipsReinterpretedArrayLeaf() Assert.NotNull(this.FindFieldDeclaration(nestedUnion, "rgBytes")); } + [Theory, PairwiseData] + public void FlattenNestedAnonymousTypes_DoesNotFlattenNestedStructValues(bool allowMarshaling) + { + // INPUT's anonymous union overlaps three whole struct *values* (MOUSEINPUT, KEYBDINPUT, HARDWAREINPUT). + // Flattening the union surfaces each struct value as a single ref, but the *fields inside* those nested + // structs must NOT be hoisted onto INPUT: we flatten the union, never the struct values reached through it. + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { AllowMarshaling = allowMarshaling, FlattenNestedAnonymousTypes = true }); + this.GenerateApi("INPUT"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("INPUT")); + + // The union's members are surfaced, each as a ref to the whole nested struct value (not its inner fields). + PropertyDeclarationSyntax mi = FindProperty(structDecl, "mi"); + AssertFlattenedAccessor(mi, "this.Anonymous.mi"); + Assert.EndsWith("MOUSEINPUT", ((RefTypeSyntax)mi.Type).Type.ToString(), StringComparison.Ordinal); + AssertFlattenedAccessor(FindProperty(structDecl, "ki"), "this.Anonymous.ki"); + AssertFlattenedAccessor(FindProperty(structDecl, "hi"), "this.Anonymous.hi"); + + // The fields declared *inside* those nested struct values are not flattened onto INPUT. + string[] nestedStructFields = ["dx", "dy", "mouseData", "wVk", "wScan", "uMsg", "wParamL", "wParamH"]; + Assert.DoesNotContain( + structDecl.Members.OfType(), + p => nestedStructFields.Contains(p.Identifier.ValueText)); + } + + [Theory, PairwiseData] + public void FlattenNestedAnonymousTypes_DoesNotFlattenIntoNestedStructContainingUnions(bool allowMarshaling) + { + // PROPVARIANT reaches a DECIMAL struct *value* (decVal) through its anonymous union, and DECIMAL itself + // contains anonymous unions. Flattening surfaces decVal as a single ref to the whole struct, but DECIMAL's + // own union members must NOT be hoisted onto PROPVARIANT: flattening stops at the nested struct-value + // boundary rather than digging through it into the inner unions. + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { AllowMarshaling = allowMarshaling, FlattenNestedAnonymousTypes = true }); + this.GenerateApi("PROPVARIANT"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("PROPVARIANT")); + + // The DECIMAL struct value is surfaced as a single ref to the whole struct. + PropertyDeclarationSyntax decVal = FindProperty(structDecl, "decVal"); + AssertFlattenedAccessor(decVal, "this.Anonymous.decVal"); + Assert.EndsWith("DECIMAL", ((RefTypeSyntax)decVal.Type).Type.ToString(), StringComparison.Ordinal); + + // DECIMAL's union-derived members are flattened onto DECIMAL itself, never onto PROPVARIANT. + string[] decimalUnionMembers = ["Lo64", "Lo32", "Mid32", "scale", "sign", "signscale"]; + Assert.DoesNotContain( + structDecl.Members.OfType(), + p => decimalUnionMembers.Contains(p.Identifier.ValueText)); + + // Sanity check: those members really are surfaced on DECIMAL itself, so the assertion above is meaningful + // (the members exist and are flattened, but only onto DECIMAL — not transitively onto PROPVARIANT). + var decimalDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("DECIMAL")); + AssertFlattenedAccessor(FindProperty(decimalDecl, "Lo64"), "this.Anonymous2.Lo64"); + } + private static PropertyDeclarationSyntax FindProperty(StructDeclarationSyntax structDecl, string name) => Assert.Single(structDecl.Members.OfType(), p => p.Identifier.ValueText == name); From 30268788600c4a427bea98dad4caf5ad435b2b09 Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Thu, 11 Jun 2026 14:25:16 -0700 Subject: [PATCH 5/6] Enable anonymous-type flattening by default and fix two codegen bugs it exposed Flip GeneratorOptions.FlattenNestedAnonymousTypes (and settings.schema.json) default to true and update the option/schema docs accordingly. Turning the option on across the full metadata surface revealed two latent bugs in the flattened-accessor generator, both now fixed: - CS8151: a nested *managed* struct value reached through an anonymous union (e.g. MSP_EVENT_INFO) was given a fully-qualified ref-return type that applied the _unmanaged suffix to every ancestor, naming the unmanaged twin instead of the type actually reachable through this.Anonymous. The leaf type is now referenced relative to the declaring struct's container chain. - CS0612: a flattened accessor whose body references an [Obsolete] leaf field (e.g. PEER_GROUP_EVENT_DATA) is now itself marked [Obsolete], matching how the existing field/property generation handles obsolete members. Adds explicit regression tests for both cases (option set explicitly so they survive a future default change). --- .../Generator.Struct.cs | 36 ++++++++++-- .../GeneratorOptions.cs | 9 ++- .../settings.schema.json | 4 +- .../StructTests.cs | 55 ++++++++++++++++++- 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/src/Microsoft.Windows.CsWin32/Generator.Struct.cs b/src/Microsoft.Windows.CsWin32/Generator.Struct.cs index a1256d2e..49344d0e 100644 --- a/src/Microsoft.Windows.CsWin32/Generator.Struct.cs +++ b/src/Microsoft.Windows.CsWin32/Generator.Struct.cs @@ -673,7 +673,7 @@ private void AddFlattenedAnonymousMembers(TypeDefinition typeDef, Context contex TypeDefinition nestedTypeDef = this.Reader.GetTypeDefinition(nestedTypeHandle); ExpressionSyntax accessPrefix = MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, ThisExpression(), IdentifierName(SafeIdentifier(fieldName))); NameSyntax crefPrefix = IdentifierName(this.GetMangledIdentifier(this.Reader.GetString(nestedTypeDef.Name), context.AllowMarshaling, this.IsManagedType(nestedTypeHandle))); - this.GatherFlattenedAnonymousAccessors(nestedTypeHandle, accessPrefix, crefPrefix, context, accessors, surfacedNames, reservedNames, depth: 0); + this.GatherFlattenedAnonymousAccessors(nestedTypeHandle, accessPrefix, crefPrefix, context, accessors, surfacedNames, reservedNames, ancestorObsolete: this.HasObsoleteAttribute(fieldDef.GetCustomAttributes()), depth: 0); } members.AddRange(accessors); @@ -689,6 +689,7 @@ private void AddFlattenedAnonymousMembers(TypeDefinition typeDef, Context contex /// The list that generated accessors are appended to. /// The set of leaf names already surfaced, used to skip duplicates. /// The set of names already declared on the outer struct, used to skip collisions. + /// if any anonymous holder field along the path to this type was marked [Obsolete]; such accessors must themselves be marked obsolete to legally reference the obsolete member in their body. /// The current recursion depth, used as a guard against unexpectedly deep nesting. private void GatherFlattenedAnonymousAccessors( TypeDefinitionHandle containingTypeHandle, @@ -698,6 +699,7 @@ private void GatherFlattenedAnonymousAccessors( List accessors, HashSet surfacedNames, HashSet reservedNames, + bool ancestorObsolete, int depth) { // Guard against unexpectedly deep or cyclic nesting. @@ -731,7 +733,7 @@ private void GatherFlattenedAnonymousAccessors( TypeDefinition deeperTypeDef = this.Reader.GetTypeDefinition(deeperTypeHandle); ExpressionSyntax deeperAccess = MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, accessPrefix, IdentifierName(SafeIdentifier(fieldName))); NameSyntax deeperCref = QualifiedName(crefTypePrefix, IdentifierName(this.GetMangledIdentifier(this.Reader.GetString(deeperTypeDef.Name), bodyContext.AllowMarshaling, this.IsManagedType(deeperTypeHandle)))); - this.GatherFlattenedAnonymousAccessors(deeperTypeHandle, deeperAccess, deeperCref, bodyContext, accessors, surfacedNames, reservedNames, depth + 1); + this.GatherFlattenedAnonymousAccessors(deeperTypeHandle, deeperAccess, deeperCref, bodyContext, accessors, surfacedNames, reservedNames, ancestorObsolete || this.HasObsoleteAttribute(fieldAttributes), depth + 1); continue; } @@ -772,7 +774,23 @@ private void GatherFlattenedAnonymousAccessors( continue; } - TypeSyntax fieldType = fieldTypeInfo.ToTypeSyntax(typeSettings, GeneratingElement.StructMember, fieldAttributes.QualifyWith(this)).Type; + TypeSyntax fieldType; + if (this.TryGetNestedTypeForAnonymousField(containingType, fieldDef, out TypeDefinitionHandle leafNestedTypeHandle)) + { + // The leaf is a struct/union *value* nested directly within this container (e.g. an anonymous + // _X_e__Struct). Reference it relative to the outer struct through the same per-twin container + // chain already used for the cref. The fully qualified form produced by ToTypeSyntax applies the + // "_unmanaged" suffix uniformly to every ancestor, which names the unmanaged twin + // (e.g. MSP_EVENT_INFO_unmanaged._Anonymous_e__Union_unmanaged...) rather than the type actually + // reachable through this.Anonymous on the declaring struct, producing a ref-return mismatch (CS8151). + TypeDefinition leafNestedType = this.Reader.GetTypeDefinition(leafNestedTypeHandle); + string leafTypeName = this.GetMangledIdentifier(this.Reader.GetString(leafNestedType.Name), bodyContext.AllowMarshaling, this.IsManagedType(leafNestedTypeHandle)); + fieldType = QualifiedName(crefTypePrefix, IdentifierName(leafTypeName)); + } + else + { + fieldType = fieldTypeInfo.ToTypeSyntax(typeSettings, GeneratingElement.StructMember, fieldAttributes.QualifyWith(this)).Type; + } // Fixed-length arrays are reinterpreted into a helper struct field, which is not a simple ref target. if (fieldType is ArrayTypeSyntax) @@ -786,7 +804,10 @@ private void GatherFlattenedAnonymousAccessors( continue; } - accessors.Add(this.CreateFlattenedAnonymousAccessor(fieldName, fieldType, accessPrefix, crefTypePrefix)); + // Mark the accessor obsolete when the leaf field (or any holder along its access path) is obsolete, + // so its body may legally reference the obsolete member without triggering CS0612. + bool isObsolete = ancestorObsolete || this.HasObsoleteAttribute(fieldAttributes); + accessors.Add(this.CreateFlattenedAnonymousAccessor(fieldName, fieldType, accessPrefix, crefTypePrefix, isObsolete)); this.DeclareUnscopedRefAttributeIfNecessary(); } } @@ -795,7 +816,7 @@ private void GatherFlattenedAnonymousAccessors( /// Creates a single [UnscopedRef] ref property that forwards to a field reached through anonymous holders, /// with documentation inherited from the underlying field. /// - private PropertyDeclarationSyntax CreateFlattenedAnonymousAccessor(string fieldName, TypeSyntax fieldType, ExpressionSyntax accessPrefix, NameSyntax crefTypePrefix) + private PropertyDeclarationSyntax CreateFlattenedAnonymousAccessor(string fieldName, TypeSyntax fieldType, ExpressionSyntax accessPrefix, NameSyntax crefTypePrefix, bool isObsolete) { // ref => ref .; ExpressionSyntax leafAccess = MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, accessPrefix, IdentifierName(SafeIdentifier(fieldName))); @@ -810,6 +831,11 @@ private PropertyDeclarationSyntax CreateFlattenedAnonymousAccessor(string fieldN accessor = accessor.AddModifiers(TokenWithSpace(SyntaxKind.UnsafeKeyword)); } + if (isObsolete) + { + accessor = accessor.AddAttributeLists(AttributeList(ObsoleteAttributeSyntax)); + } + // /// CrefSyntax cref = SyntaxFactory.NameMemberCref(QualifiedName(crefTypePrefix, SafeIdentifierName(fieldName))); SyntaxTrivia inheritDoc = Trivia(DocumentationCommentTrivia( diff --git a/src/Microsoft.Windows.CsWin32/GeneratorOptions.cs b/src/Microsoft.Windows.CsWin32/GeneratorOptions.cs index 9a5f7991..e77ddfb6 100644 --- a/src/Microsoft.Windows.CsWin32/GeneratorOptions.cs +++ b/src/Microsoft.Windows.CsWin32/GeneratorOptions.cs @@ -74,11 +74,14 @@ public record GeneratorOptions /// Windows metadata models anonymous nested structs and unions as named nested types (e.g. _Anonymous_e__Union) reached through a generated holder field named Anonymous (or Anonymous1, Anonymous2, etc.). This forces awkward access such as value.Anonymous.Anonymous.field. When this option is enabled, a [UnscopedRef] ref property is generated on the declaring struct for each such nested field so the field may be read, written, and pointed to directly as value.field. /// /// - /// Only fields reached exclusively through anonymous holders are flattened; named nested members are left alone. The generated accessors require C# 11 or later (for ); when an older language version is in use, no accessors are generated and the Anonymous holder remains the only access path. + /// Only fields reached exclusively through anonymous holders are flattened. A named nested struct or union (e.g. KEY_EVENT_RECORD.uChar) is left alone, and a nested struct value reached through an anonymous holder is surfaced as a single ref to the whole value rather than having its own members hoisted; flattening never digs through a nested struct value into its fields. + /// + /// + /// The generated accessors require C# 11 or later (for ); when an older language version is in use, no accessors are generated and the Anonymous holder remains the only access path. The original Anonymous holder fields are always retained, so the flattened accessors are purely additive. /// /// - /// The default value is . - public bool FlattenNestedAnonymousTypes { get; set; } + /// The default value is . + public bool FlattenNestedAnonymousTypes { get; set; } = true; /// /// Gets or sets a value indicating whether friendly overloads should use safe handles. diff --git a/src/Microsoft.Windows.CsWin32/settings.schema.json b/src/Microsoft.Windows.CsWin32/settings.schema.json index 69ab3163..4a5c66d7 100644 --- a/src/Microsoft.Windows.CsWin32/settings.schema.json +++ b/src/Microsoft.Windows.CsWin32/settings.schema.json @@ -64,9 +64,9 @@ "default": false }, "flattenNestedAnonymousTypes": { - "description": "Surface fields nested within anonymous structs and unions as ref-returning properties on the declaring struct, so they can be accessed directly (e.g. value.field) instead of through the generated Anonymous holder (e.g. value.Anonymous.Anonymous.field). The generated accessors require C# 11 or later; otherwise no accessors are generated.", + "description": "Surface fields nested within anonymous structs and unions as ref-returning properties on the declaring struct, so they can be accessed directly (e.g. value.field) instead of through the generated Anonymous holder (e.g. value.Anonymous.Anonymous.field). Only fields reached exclusively through anonymous holders are flattened; named nested types and the members of a nested struct value are left alone. The generated accessors require C# 11 or later; otherwise no accessors are generated.", "type": "boolean", - "default": false + "default": true }, "useSafeHandles": { "description": "A value indicating whether friendly overloads should use safe handles.", diff --git a/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs b/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs index c97bd8ed..763f6866 100644 --- a/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs +++ b/test/Microsoft.Windows.CsWin32.Tests/StructTests.cs @@ -300,8 +300,18 @@ public void FlattenNestedAnonymousTypes_GeneratesRefAccessors(bool allowMarshali } [Fact] - public void FlattenNestedAnonymousTypes_DisabledByDefault() + public void FlattenNestedAnonymousTypes_EnabledByDefault() { + // The DefaultTestGeneratorOptions leave FlattenNestedAnonymousTypes unset, so this exercises the shipping default (true). + this.GenerateApi("SYSTEM_INFO"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("SYSTEM_INFO")); + AssertFlattenedAccessor(FindProperty(structDecl, "wProcessorArchitecture"), "this.Anonymous.Anonymous.wProcessorArchitecture"); + } + + [Fact] + public void FlattenNestedAnonymousTypes_CanBeDisabled() + { + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { FlattenNestedAnonymousTypes = false }); this.GenerateApi("SYSTEM_INFO"); var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("SYSTEM_INFO")); Assert.DoesNotContain(structDecl.Members.OfType(), p => p.Identifier.ValueText == "wProcessorArchitecture"); @@ -431,6 +441,49 @@ public void FlattenNestedAnonymousTypes_DeepNestedManagedUnionCrefResolves(bool AssertFlattenedAccessor(FindProperty(structDecl, "intVal"), "this.Anonymous.Anonymous.Anonymous.intVal"); } + [Theory, PairwiseData] + public void FlattenNestedAnonymousTypes_NestedManagedStructValueRefTypeMatchesField(bool allowMarshaling) + { + // Regression (CS8151): MSP_EVENT_INFO's anonymous union overlaps several *managed* nested struct + // values (they contain BSTR/COM-pointer fields). In marshaling mode such a leaf struct is emitted + // with the "_unmanaged" suffix on the leaf type only, while its holder (_Anonymous_e__Union) keeps + // its managed name. The flattened ref accessor must therefore reference the leaf type *relative* to + // the declaring struct (matching the type actually reached through this.Anonymous). Fully qualifying + // it applied the suffix to every ancestor and named the unmanaged twin + // (MSP_EVENT_INFO_unmanaged._Anonymous_e__Union_unmanaged...), which does not match the field reached + // through this.Anonymous, so the ref-return type mismatched (CS8151). GenerateApi asserts no + // diagnostics. The option is set explicitly here so this guard survives even if the default flips. + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { AllowMarshaling = allowMarshaling, FlattenNestedAnonymousTypes = true }); + this.GenerateApi("MSP_EVENT_INFO"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("MSP_EVENT_INFO")); + + // The managed nested struct value is surfaced as a single ref to the whole value. + PropertyDeclarationSyntax accessor = FindProperty(structDecl, "MSP_ADDRESS_EVENT_INFO"); + AssertFlattenedAccessor(accessor, "this.Anonymous.MSP_ADDRESS_EVENT_INFO"); + + // The ref-return type must be relative to the declaring struct, never the mis-qualified + // "_unmanaged" twin chain that names a type the field doesn't actually have. + string refReturnType = ((RefTypeSyntax)accessor.Type).Type.ToString(); + Assert.DoesNotContain("MSP_EVENT_INFO_unmanaged", refReturnType, StringComparison.Ordinal); + } + + [Theory, PairwiseData] + public void FlattenNestedAnonymousTypes_ObsoleteLeafProducesObsoleteAccessor(bool allowMarshaling) + { + // Regression (CS0612): PEER_GROUP_EVENT_DATA's anonymous union has [Obsolete] fields. The flattened + // accessor's body references one of those fields, so the accessor itself must be marked [Obsolete] or + // the reference is an error under warnings-as-errors. GenerateApi asserts no diagnostics. The option is + // set explicitly here so this guard survives even if the default flips. + this.generator = this.CreateGenerator(DefaultTestGeneratorOptions with { AllowMarshaling = allowMarshaling, FlattenNestedAnonymousTypes = true }); + this.GenerateApi("PEER_GROUP_EVENT_DATA"); + var structDecl = (StructDeclarationSyntax)Assert.Single(this.FindGeneratedType("PEER_GROUP_EVENT_DATA")); + + // The accessor for an obsolete union field is surfaced and carries [Obsolete]. + PropertyDeclarationSyntax accessor = FindProperty(structDecl, "dwStatus"); + AssertFlattenedAccessor(accessor, "this.Anonymous.dwStatus"); + Assert.Contains(accessor.AttributeLists.SelectMany(al => al.Attributes), a => a.Name.ToString() == "Obsolete"); + } + [Fact] public void FlattenNestedAnonymousTypes_NoOpForStructWithoutAnonymousMembers() { From a0e936d971ee2d28a3f31243065268368aa44af2 Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Thu, 11 Jun 2026 17:00:23 -0700 Subject: [PATCH 6/6] Fix CS8151 for flattened accessors in the source-generator path The CLI/source-generator generation path (CsWin32Generator.Tests, exercised by the Heavy FullGen CI jobs) defaults UseComSourceGenerators=true. In that mode an anonymous holder field on a managed struct is typed as the *unmanaged* twin of its nested union (e.g. MSP_EVENT_INFO_unmanaged._Anonymous_e__Union_unmanaged), and each twin declares its own nested leaf types. The previous fix reconstructed the leaf ref-return type from the managed-twin container chain, which did not match the type actually reached through this.Anonymous in source-generator mode, reintroducing CS8151 (MSP_EVENT_INFO, VDS_ASYNC_OUTPUT, SSVARIANT, etc.). Type each flattened accessor relative to the holder field's *actual* declared type (captured from the already-generated field declaration) so the ref-return type always selects the correct managed/unmanaged twin regardless of generation mode. Adds a source-generator regression test covering these structs. --- .../Generator.Struct.cs | 41 +++++++++++++++---- .../CsWin32GeneratorTests.cs | 20 +++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/Microsoft.Windows.CsWin32/Generator.Struct.cs b/src/Microsoft.Windows.CsWin32/Generator.Struct.cs index 49344d0e..80e83d1f 100644 --- a/src/Microsoft.Windows.CsWin32/Generator.Struct.cs +++ b/src/Microsoft.Windows.CsWin32/Generator.Struct.cs @@ -632,7 +632,12 @@ private void AddFlattenedAnonymousMembers(TypeDefinition typeDef, Context contex } // Collect the names already declared directly on this struct so we never collide with them. + // Also capture each holder field's *actual* declared type syntax, so flattened accessors can be typed + // relative to it. This matters because the holder field may be typed as the "_unmanaged" twin of a + // nested type (e.g. when COM source generators force a struct blittable), and the accessor's ref-return + // type must match the type actually reached through that field rather than a reconstructed name. HashSet reservedNames = new(StringComparer.Ordinal); + Dictionary holderFieldTypes = new(StringComparer.Ordinal); foreach (MemberDeclarationSyntax member in members) { switch (member) @@ -641,6 +646,10 @@ private void AddFlattenedAnonymousMembers(TypeDefinition typeDef, Context contex foreach (VariableDeclaratorSyntax variable in field.Declaration.Variables) { reservedNames.Add(variable.Identifier.ValueText); + if (field.Declaration.Variables.Count == 1 && field.Declaration.Type is NameSyntax holderName) + { + holderFieldTypes[variable.Identifier.ValueText] = holderName; + } } break; @@ -670,10 +679,17 @@ private void AddFlattenedAnonymousMembers(TypeDefinition typeDef, Context contex continue; } + // The holder field's generated type is the authoritative root for typing accessors that forward + // through it. If we couldn't recover it (e.g. the field was emitted in an unexpected shape), skip. + if (!holderFieldTypes.TryGetValue(SafeIdentifier(fieldName).ValueText, out NameSyntax? holderType)) + { + continue; + } + TypeDefinition nestedTypeDef = this.Reader.GetTypeDefinition(nestedTypeHandle); ExpressionSyntax accessPrefix = MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, ThisExpression(), IdentifierName(SafeIdentifier(fieldName))); NameSyntax crefPrefix = IdentifierName(this.GetMangledIdentifier(this.Reader.GetString(nestedTypeDef.Name), context.AllowMarshaling, this.IsManagedType(nestedTypeHandle))); - this.GatherFlattenedAnonymousAccessors(nestedTypeHandle, accessPrefix, crefPrefix, context, accessors, surfacedNames, reservedNames, ancestorObsolete: this.HasObsoleteAttribute(fieldDef.GetCustomAttributes()), depth: 0); + this.GatherFlattenedAnonymousAccessors(nestedTypeHandle, accessPrefix, crefPrefix, (NameSyntax)holderType.WithoutTrailingTrivia(), context, accessors, surfacedNames, reservedNames, ancestorObsolete: this.HasObsoleteAttribute(fieldDef.GetCustomAttributes()), depth: 0); } members.AddRange(accessors); @@ -685,6 +701,7 @@ private void AddFlattenedAnonymousMembers(TypeDefinition typeDef, Context contex /// The anonymous nested type currently being walked. /// The expression that accesses an instance of from the outer struct (e.g. this.Anonymous.Anonymous). /// The qualified type-name chain (relative to the outer struct) used to build the cref for inherited documentation. + /// The actual type syntax of as reached from the outer struct (i.e. the holder field's declared type, with deeper holder names appended). Used to type accessors so their ref-return type matches the field actually reached. /// The context that is generated with. /// The list that generated accessors are appended to. /// The set of leaf names already surfaced, used to skip duplicates. @@ -695,6 +712,7 @@ private void GatherFlattenedAnonymousAccessors( TypeDefinitionHandle containingTypeHandle, ExpressionSyntax accessPrefix, NameSyntax crefTypePrefix, + NameSyntax containingTypeSyntax, Context containingContext, List accessors, HashSet surfacedNames, @@ -732,8 +750,14 @@ private void GatherFlattenedAnonymousAccessors( { TypeDefinition deeperTypeDef = this.Reader.GetTypeDefinition(deeperTypeHandle); ExpressionSyntax deeperAccess = MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, accessPrefix, IdentifierName(SafeIdentifier(fieldName))); - NameSyntax deeperCref = QualifiedName(crefTypePrefix, IdentifierName(this.GetMangledIdentifier(this.Reader.GetString(deeperTypeDef.Name), bodyContext.AllowMarshaling, this.IsManagedType(deeperTypeHandle)))); - this.GatherFlattenedAnonymousAccessors(deeperTypeHandle, deeperAccess, deeperCref, bodyContext, accessors, surfacedNames, reservedNames, ancestorObsolete || this.HasObsoleteAttribute(fieldAttributes), depth + 1); + IdentifierNameSyntax deeperTypeName = IdentifierName(this.GetMangledIdentifier(this.Reader.GetString(deeperTypeDef.Name), bodyContext.AllowMarshaling, this.IsManagedType(deeperTypeHandle))); + NameSyntax deeperCref = QualifiedName(crefTypePrefix, deeperTypeName); + + // Extend the container type by the deeper holder's nested type name. Because the root of the chain + // is the outer holder field's *actual* type (which already selects the correct managed/unmanaged twin), + // appending the simple nested names keeps the whole chain resolving to the types actually reached. + NameSyntax deeperContainingType = QualifiedName(containingTypeSyntax, deeperTypeName); + this.GatherFlattenedAnonymousAccessors(deeperTypeHandle, deeperAccess, deeperCref, deeperContainingType, bodyContext, accessors, surfacedNames, reservedNames, ancestorObsolete || this.HasObsoleteAttribute(fieldAttributes), depth + 1); continue; } @@ -778,14 +802,13 @@ private void GatherFlattenedAnonymousAccessors( if (this.TryGetNestedTypeForAnonymousField(containingType, fieldDef, out TypeDefinitionHandle leafNestedTypeHandle)) { // The leaf is a struct/union *value* nested directly within this container (e.g. an anonymous - // _X_e__Struct). Reference it relative to the outer struct through the same per-twin container - // chain already used for the cref. The fully qualified form produced by ToTypeSyntax applies the - // "_unmanaged" suffix uniformly to every ancestor, which names the unmanaged twin - // (e.g. MSP_EVENT_INFO_unmanaged._Anonymous_e__Union_unmanaged...) rather than the type actually - // reachable through this.Anonymous on the declaring struct, producing a ref-return mismatch (CS8151). + // _X_e__Struct). Qualify it onto the container's *actual* type syntax (rooted at the holder field's + // declared type) so the ref-return type matches the field actually reached through this.Anonymous. + // The fully qualified form produced by ToTypeSyntax instead applies the "_unmanaged" suffix uniformly + // to every ancestor, which may name the wrong twin and produce a ref-return mismatch (CS8151). TypeDefinition leafNestedType = this.Reader.GetTypeDefinition(leafNestedTypeHandle); string leafTypeName = this.GetMangledIdentifier(this.Reader.GetString(leafNestedType.Name), bodyContext.AllowMarshaling, this.IsManagedType(leafNestedTypeHandle)); - fieldType = QualifiedName(crefTypePrefix, IdentifierName(leafTypeName)); + fieldType = QualifiedName(containingTypeSyntax, IdentifierName(leafTypeName)); } else { diff --git a/test/CsWin32Generator.Tests/CsWin32GeneratorTests.cs b/test/CsWin32Generator.Tests/CsWin32GeneratorTests.cs index 0b6faf3f..586064f3 100644 --- a/test/CsWin32Generator.Tests/CsWin32GeneratorTests.cs +++ b/test/CsWin32Generator.Tests/CsWin32GeneratorTests.cs @@ -17,6 +17,26 @@ public CsWin32GeneratorTests(ITestOutputHelper logger) { } + [Fact] + public async Task FlattenedAnonymousAccessorsCompileWithComSourceGenerators() + { + // Regression: the CLI/source-generator path defaults UseComSourceGenerators=true, which can type an + // anonymous holder field as the "_unmanaged" twin of its nested union. Flattened ref accessors must be + // typed relative to that holder field's actual type, or the ref-return type mismatches the field reached + // through it (CS8151). These structs each expose anonymous unions of managed nested struct values + // (MSP_EVENT_INFO, VDS_ASYNC_OUTPUT, SSVARIANT) and an obsolete-field union (PEER_GROUP_EVENT_DATA). + // InvokeGeneratorAndCompile compiles the generated code and fails on any diagnostic, including CS8151/CS0612. + this.nativeMethods.Add("MSP_EVENT_INFO"); + this.nativeMethods.Add("PEER_GROUP_EVENT_DATA"); + this.nativeMethods.Add("VDS_ASYNC_OUTPUT"); + this.nativeMethods.Add("SSVARIANT"); + await this.InvokeGeneratorAndCompileFromFact(); + + // The flattened accessors are surfaced on the declaring struct. + var mspType = Assert.Single(this.FindGeneratedType("MSP_EVENT_INFO").OfType()); + Assert.Contains(mspType.Members.OfType(), p => p.Identifier.ValueText == "MSP_ADDRESS_EVENT_INFO"); + } + [Fact] public async Task TestGenerateIDispatch() {