diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4bbac3b8..ea169edf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -317,6 +317,9 @@ jobs: - name: 🧪 dotnet build sdk-roslyn5 (extensionReceiver) run: dotnet build working-directory: ${{ runner.temp }}/integration-tests/sdk-roslyn5 + - name: 🧪 dotnet build sdk-roslyn5 (extensionReceiver, build task) + run: dotnet build -t:Rebuild -p:CsWin32RunAsBuildTask=true + working-directory: ${{ runner.temp }}/integration-tests/sdk-roslyn5 - name: 🧪 VS 2022 MSBuild buildtask-sdk-style run: | $msbuild = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -version '[17.0,18.0)' -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe | Select-Object -First 1 diff --git a/azure-pipelines/integration-test.yml b/azure-pipelines/integration-test.yml index 965e247c..491f3533 100644 --- a/azure-pipelines/integration-test.yml +++ b/azure-pipelines/integration-test.yml @@ -52,6 +52,10 @@ jobs: displayName: 🧪 dotnet build sdk-roslyn5 (extensionReceiver) workingDirectory: $(Pipeline.Workspace)\integration-tests\sdk-roslyn5 condition: eq(variables.currentSdk, 'true') + - pwsh: dotnet build -t:Rebuild -p:CsWin32RunAsBuildTask=true + displayName: 🧪 dotnet build sdk-roslyn5 (extensionReceiver, build task) + workingDirectory: $(Pipeline.Workspace)\integration-tests\sdk-roslyn5 + condition: eq(variables.currentSdk, 'true') - task: MSBuild@1 displayName: 🧪 MSBuild buildtask-sdk-style inputs: diff --git a/docfx/docs/composition.md b/docfx/docs/composition.md index 634467a9..4d9a4b12 100644 --- a/docfx/docs/composition.md +++ b/docfx/docs/composition.md @@ -23,6 +23,12 @@ C# 14 or later in every project that consumes the generated extension members. T default when targeting .NET 10 or later; otherwise set `14` (or `Preview` / `Latest` / `LatestMajor`) in the project file. +Composition also supports `true`. +When source-generated marshalling is enabled, CsWin32 places `LibraryImport` declarations +in a private nested partial class and emits forwarding methods in the extension block, +because `LibraryImport` cannot generate implementations inside extension blocks. +Callers still use `PInvoke.X()`; source-generated marshalling remains enabled. + ## Two roles Every assembly that runs CsWin32 plays exactly one role: @@ -30,7 +36,7 @@ Every assembly that runs CsWin32 plays exactly one role: | Role | `NativeMethods.json` shape | What the generator emits | |---|---|---| | **Owner** | `extensionReceiver` *not* set | A plain `partial class ` with members declared directly on it. | -| **Extender** | `extensionReceiver: ""` set | A `partial class ` whose members live inside an `extension() { … }` block. | +| **Extender** | `extensionReceiver: ""` set | A `partial class ` whose exposed members live inside an `extension() { … }` block. | In a layered stack, the lowest assembly is the owner; every higher assembly is an extender. Each extender must use a `className` that differs from the owner's and from every other extender's. diff --git a/integration-tests/sdk-roslyn5/app/NativeMethods.txt b/integration-tests/sdk-roslyn5/app/NativeMethods.txt index a7653a3c..b65747bb 100644 --- a/integration-tests/sdk-roslyn5/app/NativeMethods.txt +++ b/integration-tests/sdk-roslyn5/app/NativeMethods.txt @@ -1 +1,4 @@ GetForegroundWindow +GetCurrentThreadId +GetWindowThreadProcessId +SetLastError diff --git a/integration-tests/sdk-roslyn5/app/Program.cs b/integration-tests/sdk-roslyn5/app/Program.cs index 3e9d3f76..5367e2f0 100644 --- a/integration-tests/sdk-roslyn5/app/Program.cs +++ b/integration-tests/sdk-roslyn5/app/Program.cs @@ -1,7 +1,8 @@ // This test verifies that the Roslyn 5.0 analyzer leg is loaded and the // extensionReceiver feature works. The base project generates PInvoke with // GetTickCount. This app project uses extensionReceiver to attach -// GetForegroundWindow as an extension member of the base PInvoke class. +// native APIs as extension members of the base PInvoke class, using either +// analyzer or build-task generation. // // If the Roslyn 4.11 leg were loaded instead, PInvoke013 would reject // extensionReceiver, GetForegroundWindow would land on AppPInvokes, and @@ -18,5 +19,8 @@ static void Main(string[] args) // From this project via extensionReceiver (C# 14 extension member) HWND hwnd = PInvoke.GetForegroundWindow(); + uint threadId = PInvoke.GetCurrentThreadId(); + PInvoke.GetWindowThreadProcessId(hwnd, out uint processId); + PInvoke.SetLastError(0); } } diff --git a/src/Microsoft.Windows.CsWin32/Generator.cs b/src/Microsoft.Windows.CsWin32/Generator.cs index 1197c9ef..273fe0ce 100644 --- a/src/Microsoft.Windows.CsWin32/Generator.cs +++ b/src/Microsoft.Windows.CsWin32/Generator.cs @@ -1450,7 +1450,7 @@ private IReadOnlyList FindTypeSymbolsIfAlreadyAvailable(string fullyQua /// Wraps a list of members in an extension (Receiver) { ... } block when is configured; otherwise returns the input unchanged. Under the Roslyn 4 leg the wrap is a no-op (the feature is gated to Roslyn 5 + C# 14). /// /// The members that would otherwise be added directly to the host static class. - /// A new member list (length 1) containing an extension block, or the original list when no extension receiver is configured, the input is empty, or the analyzer is the Roslyn 4 leg. + /// An extension block and, when needed, a nested partial class for source-generated imports; otherwise the original list when no extension receiver is configured, the input is empty, or the analyzer is the Roslyn 4 leg. private SyntaxList WrapAsExtensionMembers(SyntaxList members) { #if ROSLYN5 @@ -1467,10 +1467,51 @@ private SyntaxList WrapAsExtensionMembers(SyntaxList imports = default; + SyntaxList extensionMembers = default; + foreach (MemberDeclarationSyntax member in members) + { + if (member is MethodDeclarationSyntax method && method.Modifiers.Any(SyntaxKind.PartialKeyword)) + { + // LibraryImport cannot generate implementations inside an extension block. + // Keep the import in a partial type and expose a forwarding extension member instead. + imports = imports.Add(method); + InvocationExpressionSyntax invocation = InvocationExpression( + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + ParseName($"global::{this.Namespace}.{this.options.ClassName}.{importsClassName}"), + IdentifierName(method.Identifier)), + [.. method.ParameterList.Parameters.Select(parameter => Argument(IdentifierName(parameter.Identifier)) + .WithRefKindKeyword(parameter.Modifiers.FirstOrDefault(modifier => modifier.IsKind(SyntaxKind.RefKeyword) || modifier.IsKind(SyntaxKind.OutKeyword) || modifier.IsKind(SyntaxKind.InKeyword))))]); + SyntaxList attributes = [.. method.AttributeLists + .Select(list => list.WithAttributes([.. list.Attributes.Where(attribute => attribute.Name.ToString() is not ("LibraryImport" or "DefaultDllImportSearchPaths"))])) + .Where(list => list.Attributes.Count > 0)]; + extensionMembers = extensionMembers.Add(method + .WithAttributeLists(attributes) + .WithModifiers(SyntaxFactory.TokenList(method.Modifiers.Where(modifier => !modifier.IsKind(SyntaxKind.PartialKeyword)))) + .WithBody(null) + .WithExpressionBody(ArrowExpressionClause(invocation)) + .WithSemicolonToken(SemicolonWithLineFeed) + .WithLeadingTrivia(method.GetLeadingTrivia())); + } + else + { + extensionMembers = extensionMembers.Add(member); + } + } + // Use a global-qualified name so the receiver is unambiguous regardless of `using`s in the consuming file. TypeSyntax receiverType = ParseName($"global::{this.Namespace}.{this.options.ExtensionReceiver}"); - ExtensionBlockDeclarationSyntax extensionBlock = ExtensionBlock(receiverType, members); - return SyntaxFactory.SingletonList(extensionBlock); + ExtensionBlockDeclarationSyntax extensionBlock = ExtensionBlock(receiverType, extensionMembers); + SyntaxList result = SyntaxFactory.SingletonList(extensionBlock); + if (imports.Count > 0) + { + result = result.Add(ClassDeclaration(Identifier(importsClassName), imports) + .AddModifiers(TokenWithSpace(SyntaxKind.PrivateKeyword), TokenWithSpace(SyntaxKind.StaticKeyword), TokenWithSpace(SyntaxKind.PartialKeyword))); + } + + return result; #else return members; #endif