From d78024d62c096116c15caf0e2af074997a5ac421 Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Fri, 25 Sep 2026 17:36:55 -0700 Subject: [PATCH 1/2] Fix XML documentation for colliding Win32 ABI types Copy native API docs to friendly overloads when referenced assemblies contain duplicate ABI type names. Use canonical documentation IDs for PWSTR member docs and cover the shared-ABI regression. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Generator.FriendlyOverloads.cs | 86 +++++++++++++++--- .../templates/PWSTR.cs | 4 +- .../FriendlyOverloadTests.cs | 90 +++++++++++++++++++ 3 files changed, 167 insertions(+), 13 deletions(-) diff --git a/src/Microsoft.Windows.CsWin32/Generator.FriendlyOverloads.cs b/src/Microsoft.Windows.CsWin32/Generator.FriendlyOverloads.cs index 8aea0c61..054af10c 100644 --- a/src/Microsoft.Windows.CsWin32/Generator.FriendlyOverloads.cs +++ b/src/Microsoft.Windows.CsWin32/Generator.FriendlyOverloads.cs @@ -22,6 +22,25 @@ private enum FriendlyOverloadOf private static ParameterSyntax StripAttributes(ParameterSyntax parameter) => parameter.WithAttributeLists(default); + private static SyntaxTriviaList CopyDocumentationForFriendlyOverload(MethodDeclarationSyntax method, IEnumerable parameters) + { + HashSet parameterNames = new(parameters.Select(parameter => parameter.Identifier.ValueText), StringComparer.Ordinal); + SyntaxTriviaList leadingTrivia = method.GetLeadingTrivia(); + foreach (SyntaxTrivia trivia in leadingTrivia) + { + if (trivia.GetStructure() is DocumentationCommentTriviaSyntax documentation) + { + SyntaxList content = SyntaxFactory.List(documentation.Content.Where(node => + node is not XmlElementSyntax { StartTag.Name.LocalName.ValueText: "param" } paramDoc || + paramDoc.StartTag.Attributes.OfType() + .Any(attribute => attribute.Name.LocalName.ValueText == "name" && parameterNames.Contains(attribute.Identifier.Identifier.ValueText)))); + leadingTrivia = leadingTrivia.Replace(trivia, Trivia(documentation.WithContent(content))); + } + } + + return leadingTrivia; + } + private static ExpressionSyntax GetSpanLength(ExpressionSyntax span, bool isRefType) => isRefType ? ParenthesizedExpression(BinaryExpression( SyntaxKind.CoalesceExpression, @@ -35,6 +54,41 @@ private static ExpressionSyntax GetIsSpanEmpty(ExpressionSyntax span, bool isRef LiteralExpression(SyntaxKind.NullLiteralExpression))) : MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, span, IdentifierName(nameof(Span<>.IsEmpty))); + private bool HasAmbiguousCrefParameterTypes(ParameterListSyntax parameterList) + { + if (this.compilation is null) + { + return false; + } + + foreach (ParameterSyntax parameter in parameterList.Parameters) + { + foreach (QualifiedNameSyntax typeName in parameter.Type!.DescendantNodesAndSelf().OfType()) + { + string metadataName = typeName.ToString(); + if (metadataName.StartsWith(GlobalWinmdRootNamespaceAlias + ".", StringComparison.Ordinal)) + { + metadataName = this.MetadataIndex.CommonNamespace + metadataName.Substring(GlobalWinmdRootNamespaceAlias.Length); + } + else if (metadataName.StartsWith(GlobalNamespacePrefix, StringComparison.Ordinal)) + { + metadataName = metadataName.Substring(GlobalNamespacePrefix.Length); + } + else + { + continue; + } + + if (this.compilation.GetTypesByMetadataName(metadataName).Length > 1) + { + return true; + } + } + } + + return false; + } + private ExpressionSyntax GetIntPtrFromTypeDef(ExpressionSyntax typedefValue, TypeHandleInfo typeDefTypeInfo) { ExpressionSyntax intPtrValue = typedefValue; @@ -1405,17 +1459,27 @@ bool TryHandleCountParam(TypeSyntax elementType, bool nullableSource) parameters.RemoveAt(indexToRemove); } - TypeSyntax docRefExternName = overloadOf == FriendlyOverloadOf.InterfaceMethod - ? QualifiedName(declaringTypeName, IdentifierName(externMethodDeclaration.Identifier)) - : IdentifierName(externMethodDeclaration.Identifier); - SyntaxTrivia leadingTrivia = Trivia( - DocumentationCommentTrivia( - SyntaxKind.SingleLineDocumentationCommentTrivia, - [ - XmlText($"/// "), - XmlEmptyElement("inheritdoc", [XmlCrefAttribute(NameMemberCref(docRefExternName, ToCref(externMethodDeclaration.ParameterList)))]), - XmlText(XmlTextNewLine("\n", continueXmlDocumentationComment: false)) - ])); + SyntaxTriviaList leadingTrivia; + if (this.HasAmbiguousCrefParameterTypes(externMethodDeclaration.ParameterList)) + { + // A hidden type with the same name in another reference can break cref binding, even when the method signature compiles. + leadingTrivia = CopyDocumentationForFriendlyOverload(externMethodDeclaration, parameters); + } + else + { + TypeSyntax docRefExternName = overloadOf == FriendlyOverloadOf.InterfaceMethod + ? QualifiedName(declaringTypeName, IdentifierName(externMethodDeclaration.Identifier)) + : IdentifierName(externMethodDeclaration.Identifier); + leadingTrivia = TriviaList(Trivia( + DocumentationCommentTrivia( + SyntaxKind.SingleLineDocumentationCommentTrivia, + [ + XmlText($"/// "), + XmlEmptyElement("inheritdoc", [XmlCrefAttribute(NameMemberCref(docRefExternName, ToCref(externMethodDeclaration.ParameterList)))]), + XmlText(XmlTextNewLine("\n", continueXmlDocumentationComment: false)) + ]))); + } + ExpressionSyntax externInvocation = InvocationExpression( overloadOf switch { diff --git a/src/Microsoft.Windows.CsWin32/templates/PWSTR.cs b/src/Microsoft.Windows.CsWin32/templates/PWSTR.cs index 8d3a669d..1a83916e 100644 --- a/src/Microsoft.Windows.CsWin32/templates/PWSTR.cs +++ b/src/Microsoft.Windows.CsWin32/templates/PWSTR.cs @@ -6,10 +6,10 @@ partial struct PWSTR { public static implicit operator PCWSTR(PWSTR value) => new PCWSTR(value.Value); - /// + /// internal int Length => new PCWSTR(this.Value).Length; - /// + /// public override string ToString() => new PCWSTR(this.Value).ToString(); #if canUseSpan diff --git a/test/Microsoft.Windows.CsWin32.Tests/FriendlyOverloadTests.cs b/test/Microsoft.Windows.CsWin32.Tests/FriendlyOverloadTests.cs index 1b4e4eb8..1878ec7a 100644 --- a/test/Microsoft.Windows.CsWin32.Tests/FriendlyOverloadTests.cs +++ b/test/Microsoft.Windows.CsWin32.Tests/FriendlyOverloadTests.cs @@ -70,6 +70,96 @@ public void OutPWSTR_Parameters_AsSpan() Assert.Equal("Span", friendlyOverload.ParameterList.Parameters[1].Type?.ToString()); } + /// + /// Verifies documentation with public ABI types from one assembly and, optionally, an internal copy in another. + /// + [Theory] + [InlineData("CreateWindowEx", false, LanguageVersion.CSharp12, true)] + [InlineData("CreateWindowEx", false, LanguageVersion.CSharp12, false)] + [InlineData("CreateWindowEx", false, LanguageVersion.CSharp14, false)] + [InlineData("CreateWindowEx", false, LanguageVersion.CSharp14, true)] + [InlineData("CreateWindowEx", true, LanguageVersion.CSharp14, false)] + [InlineData("CreateWindowEx", true, LanguageVersion.CSharp14, true)] + [InlineData("ConvertStringSecurityDescriptorToSecurityDescriptor", false, LanguageVersion.CSharp12, false)] + [InlineData("ConvertStringSecurityDescriptorToSecurityDescriptor", false, LanguageVersion.CSharp12, true)] + public void FriendlyOverloadDocumentationWithReferencedPCWSTR(string methodName, bool extensionReceiver, LanguageVersion languageVersion, bool includeHiddenPCWSTR) + { + this.parseOptions = this.parseOptions.WithLanguageVersion(languageVersion); + CSharpCompilationOptions options = this.starterCompilations["net8.0"].Options; + this.compilation = this.starterCompilations["net8.0"].WithOptions(options + .WithPlatform(Platform.X64) + .WithSpecificDiagnosticOptions(options.SpecificDiagnosticOptions + .SetItem("CS1574", ReportDiagnostic.Error) + .SetItem("CS1580", ReportDiagnostic.Error))); + CSharpCompilation referencedProject = this.compilation.WithAssemblyName("ReferencedInterop"); + using (var referencedGenerator = this.CreateGenerator(new GeneratorOptions { Public = true }, referencedProject)) + { + Assert.True(referencedGenerator.TryGenerate("FindWindow", TestContext.Current.CancellationToken)); + referencedProject = this.AddGeneratedCode(referencedProject, referencedGenerator); + } + + this.AssertNoDiagnostics(referencedProject, logAllGeneratedCode: false); + using var assemblyStream = new MemoryStream(); + Assert.True(referencedProject.Emit(assemblyStream, cancellationToken: TestContext.Current.CancellationToken).Success); + this.compilation = this.compilation.AddReferences(MetadataReference.CreateFromImage(assemblyStream.ToArray())); + if (includeHiddenPCWSTR) + { + // A dependency unrelated to CsWin32 can embed an internal ABI type with the same name. + CSharpCompilation hiddenProject = this.starterCompilations["net8.0"] + .WithAssemblyName("HiddenInterop") + .AddSyntaxTrees(CSharpSyntaxTree.ParseText( + "namespace Windows.Win32.Foundation { internal struct PCWSTR { } }", + this.parseOptions, + cancellationToken: TestContext.Current.CancellationToken)); + using var hiddenStream = new MemoryStream(); + Assert.True(hiddenProject.Emit(hiddenStream, cancellationToken: TestContext.Current.CancellationToken).Success); + this.compilation = this.compilation.AddReferences(MetadataReference.CreateFromImage(hiddenStream.ToArray())); + Assert.Equal(2, this.compilation.GetTypesByMetadataName("Windows.Win32.Foundation.PCWSTR").Length); + } + + this.generator = this.CreateGenerator( + new GeneratorOptions + { + EmitSingleFile = false, + ClassName = extensionReceiver ? "PInvokeExtensions" : "PInvoke", + ExtensionReceiver = extensionReceiver ? "PInvoke" : null, + }, + includeDocs: true); + this.GenerateApi(methodName); + Assert.Empty(this.FindGeneratedType("PCWSTR")); + IEnumerable friendlyOverloads = this.FindGeneratedMethod(methodName).Where(m => !IsOrContainsExternMethod(m)); + Assert.NotEmpty(friendlyOverloads); + Assert.All( + friendlyOverloads, + method => + { + string documentation = method.GetLeadingTrivia().ToString(); + if (includeHiddenPCWSTR) + { + Assert.Contains("", documentation); + Assert.Contains("", documentation); + } + } + else + { + Assert.Contains("(referencedProject.GetTypeByMetadataName("Windows.Win32.Foundation.PCWSTR")); + string pwstr = Assert.Single(this.FindGeneratedType("PWSTR")).ToFullString(); + Assert.Contains($"cref=\"{Assert.Single(pcwstr.GetMembers("Length")).GetDocumentationCommentId()}\"", pwstr); + Assert.Contains($"cref=\"{Assert.Single(pcwstr.GetMembers("ToString")).GetDocumentationCommentId()}\"", pwstr); + } + } + [Theory] [InlineData("WSManGetSessionOptionAsString")] // Uses the reserved keyword 'string' as a parameter name [InlineData("RmRegisterResources")] // Parameter with PCWSTR* (an array of native strings) From a2ee27a6232eb0329f4868fbfe2b13fd5d758baa Mon Sep 17 00:00:00 2001 From: Sergio Pedri Date: Wed, 7 Oct 2026 14:07:48 -0700 Subject: [PATCH 2/2] Keep documentation regressions diagnostic-focused Remove generated documentation text assertions while retaining compiler and XML emission checks and ABI reuse guards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FriendlyOverloadTests.cs | 31 +------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/test/Microsoft.Windows.CsWin32.Tests/FriendlyOverloadTests.cs b/test/Microsoft.Windows.CsWin32.Tests/FriendlyOverloadTests.cs index 1878ec7a..2f87901a 100644 --- a/test/Microsoft.Windows.CsWin32.Tests/FriendlyOverloadTests.cs +++ b/test/Microsoft.Windows.CsWin32.Tests/FriendlyOverloadTests.cs @@ -71,7 +71,7 @@ public void OutPWSTR_Parameters_AsSpan() } /// - /// Verifies documentation with public ABI types from one assembly and, optionally, an internal copy in another. + /// Verifies that documentation compiles with public ABI types from one assembly and, optionally, an internal copy in another. /// [Theory] [InlineData("CreateWindowEx", false, LanguageVersion.CSharp12, true)] @@ -129,35 +129,6 @@ public void FriendlyOverloadDocumentationWithReferencedPCWSTR(string methodName, Assert.Empty(this.FindGeneratedType("PCWSTR")); IEnumerable friendlyOverloads = this.FindGeneratedMethod(methodName).Where(m => !IsOrContainsExternMethod(m)); Assert.NotEmpty(friendlyOverloads); - Assert.All( - friendlyOverloads, - method => - { - string documentation = method.GetLeadingTrivia().ToString(); - if (includeHiddenPCWSTR) - { - Assert.Contains("", documentation); - Assert.Contains("", documentation); - } - } - else - { - Assert.Contains("(referencedProject.GetTypeByMetadataName("Windows.Win32.Foundation.PCWSTR")); - string pwstr = Assert.Single(this.FindGeneratedType("PWSTR")).ToFullString(); - Assert.Contains($"cref=\"{Assert.Single(pcwstr.GetMembers("Length")).GetDocumentationCommentId()}\"", pwstr); - Assert.Contains($"cref=\"{Assert.Single(pcwstr.GetMembers("ToString")).GetDocumentationCommentId()}\"", pwstr); - } } [Theory]