Skip to content

Commit

Permalink
ProjectReferences Negotiate SetPlatform Metadata (#6655)
Browse files Browse the repository at this point in the history
* GetPlatforms target added

* Add _GetProjectReferencePlatformProperties

* Add GetNearestPlatformTask v1

* Changes to GetNearestPlatformTask, Platform items
flow into TargetFramework logic.
  • Loading branch information
benvillalobos authored Jul 30, 2021
1 parent 9128adb commit b6e7d60
Show file tree
Hide file tree
Showing 24 changed files with 1,150 additions and 9 deletions.
66 changes: 64 additions & 2 deletions documentation/ProjectReference-Protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ If implementing a project with an “outer” (determine what properties to pass
* `TargetFrameworks` indicating what TargetFrameworks are available in the project
* `TargetFrameworkMonikers` and `TargetPlatformMonikers` indicating what framework / platform the `TargetFrameworks` map to. This is to support implicitly setting the target platform version (for example inferring that `net5.0-windows` means the same as `net5.0-windows7.0`) as well as treating the `TargetFramework` values [as aliases](https://github.com/NuGet/Home/issues/5154)
* Boolean metadata for `HasSingleTargetFramework` and `IsRidAgnostic`.
* `Platforms` indicating what platforms are available for the project to build as, and boolean metadata `IsVcxOrNativeProj` (used for [SetPlatform Negotiation](#setplatform-negotiation))
* The `GetReferenceNearestTargetFrameworkTask` (provided by NuGet) is responsible for selecting the best matching `TargetFramework` of the referenced project
* This target is _optional_. If not present, the reference will be built with no additional properties.
* **New** in MSBuild 15.5. (`TargetFrameworkMonikers` and `TargetPlatformMonikers` metadata is new in MSBuild 16.8)
Expand All @@ -84,7 +85,6 @@ If implementing a project with an “outer” (determine what properties to pass
* As of 15.7, this is _optional_. If a project does not contain a `GetCopyToOutputDirectoryItems` target, projects that reference it will not copy any of its outputs to their own output folders, but the build can succeed.
* `Clean` should delete all outputs of the project.
* It is not called during a normal build, only during "Clean" and "Rebuild".

## Other protocol requirements

As with all MSBuild logic, targets can be added to do other work with `ProjectReference`s.
Expand Down Expand Up @@ -119,4 +119,66 @@ These properties will then be gathered via the `GetTargetFrameworks` call. They
</AdditionalProjectProperties>
```

The `NearestTargetFramework` metadata will be the target framework which was selected as the best one to use for the reference (via `GetReferenceNearestTargetFrameworkTask`). This can be used to select which set of properties were used in the target framework that was active for the reference.
The `NearestTargetFramework` metadata will be the target framework which was selected as the best one to use for the reference (via `GetReferenceNearestTargetFrameworkTask`). This can be used to select which set of properties were used in the target framework that was active for the reference.

## SetPlatform Negotiation
As of version 17.0, MSBuild can now dynamically figure out what platform a `ProjectReference` should build as. This includes a new target and task to determine what the `SetPlatform` metadata should be, or whether to undefine the platform so the referenced project builds with its default platform.

* `_GetProjectReferenceTargetFrameworkProperties` target performs the majority of the work for assigning `SetPlatform` metadata to project references.
* Calls the `GetCompatiblePlatform` task, which is responsible for negotiating between the current project's platform and the platforms of the referenced project to assign a `NearestPlatform` metadata to the item.
* Sets or undefines `SetPlatform` based on the `NearestPlatform` assignment from `GetCompatiblePlatform`
* This target explicitly runs after `_GetProjectReferenceTargetFrameworkProperties` because it needs to use the `IsVcxOrNativeProj` and `Platforms` properties returned by the `GetTargetFrameworks` call.

Note: If a `ProjectReference` has `SetPlatform` metadata defined already, the negotiation logic is skipped over.
### Impact on the build
In addition to the above task and target, `.vcxproj` and `.nativeproj` projects will receive an extra MSBuild call to the `GetTargetFrameworks` target. Previously, TargetFramework negotiation skipped over these projects because they could not multi-target in the first place. Because SetPlatform negotiation needs information given from the `GetTargetFrameworks` target, it is required that the `_GetProjectReferenceTargetFrameworkProperties` target calls the MSBuild task on the ProjectReference.

This means most projects will see an evaluation with no global properties defined, unless set by the user.

### How To Opt In
First, set the properties `EnableDynamicPlatformResolution` and `DisableTransitiveProjectReferences` to `true` for **every project** in your solution. The easiest way to do this is by creating a `Directory.Build.props` file and placing it at the root of your project directory:

```xml
<Project>
<PropertyGroup>
<EnableDynamicPlatformResolution>true</EnableDynamicPlatformResolution>
<DisableTransitiveProjectReferences>true</DisableTransitiveProjectReferences>
</PropertyGroup>
</Project>
```

If only set in one project, the `SetPlatform` metadata will carry forward to every consecutive project reference.

Next, every referenced project is required to define a `Platforms` property, where `Platforms` is a semicolon-delimited list of platforms that project could build as. For `.vcxproj` or `.nativeproj` projects, `Platforms` is constructed from the `ProjectConfiguration` items that already exist in the project. For managed SDK projects, the default is `AnyCPU`. Managed non-SDK projects need to define this manually.

Lastly, a `PlatformLookupTable` may need to be defined for more complex scenarios. A `PlatformLookupTable` is a semicolon-delimited list of mappings between platforms. `<PlatformLookupTable>Win32=x86</PlatformLookupTable>`, for example. This means that when the current project is building as `Win32`, it will attempt to build the referenced project as x86. This property is **required** when a managed AnyCPU project references an unmanaged project because `AnyCPU` does not directly map to an architecture-specific platform. You can define the table in two ways:

1. A standard property within the current project, in a Directory.Build.props/targets
2. Metadata on the `ProjectReference` item. This option takes priority over the first to allow customizations per `ProjectReference`.

### References between managed and unmanaged projects
Some cases of `ProjectReference`s require a `$(PlatformLookupTable)` to correctly determine what a referenced project should build as. References between managed and unmanaged projects also get a default lookup table that can be opted out of by setting the property `UseDefaultPlatformLookupTables` to false. See the table below for details.

Note: Defining a `PlatformLookupTable` overrides the default mapping.
| Project Reference Type | `PlatformLookupTable` Required? | Notes |
| :-- | :-: | :-: |
| Unmanaged -> Unmanaged | No | |
| Managed -> Managed | No | |
| Unmanaged -> Managed | Optional | Uses default mapping: `Win32=x86` |
| Managed -> Unmanaged | **Yes** when the project is AnyCPU | Uses default mapping: `x86=Win32` |

Example:
Project A: Managed, building as `AnyCPU`, has a `ProjectReference` on Project B.
Project B: Unmanaged, has `$(Platforms)` constructed from its `Platform` metadata from its `ProjectConfiguration` items, defined as `x64;Win32`.

Because `AnyCPU` does not map to anything architecture-specific, a custom mapping must be defined. Project A can either:
1. Define `PlatformLookupTable` in its project or a Directory.Build.props as `AnyCPU=x64` or `AnyCPU=Win32`.
2. Define `PlatformLookupTable` as metadata on the `ProjectReference` item, which would take priority over a lookup table defined elsewhere.
* When only one mapping is valid, you could also directly define `SetPlatform` metadata as `Platform=foo` (for unmanaged) or `PlatformTarget=bar` (for managed). This would skip over most negotiation logic.

Example of project A defining a lookup table directly on the `ProjectReference`:
```xml
<ItemGroup>
<ProjectReference Include="B.csproj" PlatformLookupTable="AnyCPU=Win32">
</ItemGroup>
```
12 changes: 12 additions & 0 deletions ref/Microsoft.Build.Tasks.Core/net/Microsoft.Build.Tasks.Core.cs
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,18 @@ public GetAssemblyIdentity() { }
public Microsoft.Build.Framework.ITaskItem[] AssemblyFiles { get { throw null; } set { } }
public override bool Execute() { throw null; }
}
public partial class GetCompatiblePlatform : Microsoft.Build.Tasks.TaskExtension
{
public GetCompatiblePlatform() { }
[Microsoft.Build.Framework.RequiredAttribute]
public Microsoft.Build.Framework.ITaskItem[] AnnotatedProjects { get { throw null; } set { } }
[Microsoft.Build.Framework.OutputAttribute]
public Microsoft.Build.Framework.ITaskItem[] AssignedProjectsWithPlatform { get { throw null; } set { } }
[Microsoft.Build.Framework.RequiredAttribute]
public string CurrentProjectPlatform { get { throw null; } set { } }
public string PlatformLookupTable { get { throw null; } set { } }
public override bool Execute() { throw null; }
}
public sealed partial class GetFileHash : Microsoft.Build.Tasks.TaskExtension
{
public GetFileHash() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,18 @@ public GetAssemblyIdentity() { }
public Microsoft.Build.Framework.ITaskItem[] AssemblyFiles { get { throw null; } set { } }
public override bool Execute() { throw null; }
}
public partial class GetCompatiblePlatform : Microsoft.Build.Tasks.TaskExtension
{
public GetCompatiblePlatform() { }
[Microsoft.Build.Framework.RequiredAttribute]
public Microsoft.Build.Framework.ITaskItem[] AnnotatedProjects { get { throw null; } set { } }
[Microsoft.Build.Framework.OutputAttribute]
public Microsoft.Build.Framework.ITaskItem[] AssignedProjectsWithPlatform { get { throw null; } set { } }
[Microsoft.Build.Framework.RequiredAttribute]
public string CurrentProjectPlatform { get { throw null; } set { } }
public string PlatformLookupTable { get { throw null; } set { } }
public override bool Execute() { throw null; }
}
public sealed partial class GetFileHash : Microsoft.Build.Tasks.TaskExtension
{
public GetFileHash() { }
Expand Down
203 changes: 203 additions & 0 deletions src/Tasks.UnitTests/GetCompatiblePlatform_Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.Build.UnitTests;
using Microsoft.Build.Utilities;
using Shouldly;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.Build.Tasks.UnitTests
{
sealed public class GetCompatiblePlatform_Tests
{
private readonly ITestOutputHelper _output;

public GetCompatiblePlatform_Tests(ITestOutputHelper output)
{
_output = output;
}

[Fact]
public void ResolvesViaPlatformLookupTable()
{
// PlatformLookupTable always takes priority. It is typically user-defined.
TaskItem projectReference = new TaskItem("foo.bar");
projectReference.SetMetadata("Platforms", "x64;x86;AnyCPU");

GetCompatiblePlatform task = new GetCompatiblePlatform()
{
BuildEngine = new MockEngine(_output),
CurrentProjectPlatform = "win32",
PlatformLookupTable = "win32=x64",
AnnotatedProjects = new TaskItem[] { projectReference }
};

task.Execute().ShouldBeTrue();

task.AssignedProjectsWithPlatform[0].GetMetadata("NearestPlatform").ShouldBe("x64");
}

[Fact]
public void ResolvesViaProjectReferencesPlatformLookupTable()
{
// A ProjectReference's PlatformLookupTable takes priority over the current project's table.
// This allows overrides on a per-ProjectItem basis.
TaskItem projectReference = new TaskItem("foo.bar");
projectReference.SetMetadata("Platforms", "x64;x86;AnyCPU");

// ProjectReference will be assigned x86 because its table takes priority
projectReference.SetMetadata("PlatformLookupTable", "win32=x86");

GetCompatiblePlatform task = new GetCompatiblePlatform()
{
BuildEngine = new MockEngine(_output),
CurrentProjectPlatform = "win32",
PlatformLookupTable = "win32=x64",
AnnotatedProjects = new TaskItem[] { projectReference }
};

task.Execute().ShouldBeTrue();

task.AssignedProjectsWithPlatform[0].GetMetadata("NearestPlatform").ShouldBe("x86");
}

[Fact]
public void ResolvesViaAnyCPUDefault()
{
// No valid mapping via the lookup table, should default to AnyCPU when the current project
// and ProjectReference platforms don't match.
TaskItem projectReference = new TaskItem("foo.bar");
projectReference.SetMetadata("Platforms", "x64;AnyCPU");

GetCompatiblePlatform task = new GetCompatiblePlatform()
{
BuildEngine = new MockEngine(_output),
CurrentProjectPlatform = "x86",
PlatformLookupTable = "AnyCPU=x64",
AnnotatedProjects = new TaskItem[] { projectReference }
};

task.Execute().ShouldBeTrue();

task.AssignedProjectsWithPlatform[0].GetMetadata("NearestPlatform").ShouldBe("AnyCPU");
}

[Fact]
public void ResolvesViaSamePlatform()
{
// No valid mapping via the lookup table. If the ProjectReference's platform
// matches the current project's platform, it takes priority over AnyCPU default.
TaskItem projectReference = new TaskItem("foo.bar");
projectReference.SetMetadata("Platforms", "x86;x64;AnyCPU");
projectReference.SetMetadata("PlatformLookupTable", "x86=AnyCPU"); // matching platform takes priority over lookup tables

GetCompatiblePlatform task = new GetCompatiblePlatform()
{
BuildEngine = new MockEngine(_output),
CurrentProjectPlatform = "x86",
PlatformLookupTable = "x86=AnyCPU",
AnnotatedProjects = new TaskItem[] { projectReference }
};

task.Execute().ShouldBeTrue();

task.AssignedProjectsWithPlatform[0].GetMetadata("NearestPlatform").ShouldBe("x86");
}

[Fact]
public void FailsToResolve()
{
// No valid mapping via the lookup table, ProjectReference can't default to AnyCPU,
// it also can't match with current project, log a warning.
TaskItem projectReference = new TaskItem("foo.bar");
projectReference.SetMetadata("Platforms", "x64");

GetCompatiblePlatform task = new GetCompatiblePlatform()
{
BuildEngine = new MockEngine(_output),
CurrentProjectPlatform = "x86",
PlatformLookupTable = "AnyCPU=x64",
AnnotatedProjects = new TaskItem[] { projectReference },
};

task.Execute().ShouldBeTrue();
// When the task logs a warning, it does not set NearestPlatform
task.AssignedProjectsWithPlatform[0].GetMetadata("NearestPlatform").ShouldBe(string.Empty);
((MockEngine)task.BuildEngine).AssertLogContains("MSB3981");
}

[Fact]
public void WarnsWhenProjectReferenceHasNoPlatformOptions()
{
// Task should log a warning when a ProjectReference has no options to build as.
// It will continue and have no NearestPlatform metadata.
TaskItem projectReference = new TaskItem("foo.bar");
projectReference.SetMetadata("Platforms", string.Empty);

GetCompatiblePlatform task = new GetCompatiblePlatform()
{
BuildEngine = new MockEngine(_output),
CurrentProjectPlatform = "x86",
PlatformLookupTable = "AnyCPU=x64",
AnnotatedProjects = new TaskItem[] { projectReference },
};

task.Execute().ShouldBeTrue();
// When the task logs a warning, it does not set NearestPlatform
task.AssignedProjectsWithPlatform[0].GetMetadata("NearestPlatform").ShouldBe(string.Empty);
((MockEngine)task.BuildEngine).AssertLogContains("MSB3982");
}

/// <summary>
/// Invalid format on PlatformLookupTable results in an exception being thrown.
/// </summary>
[Fact]
public void WarnsOnInvalidFormatLookupTable()
{
TaskItem projectReference = new TaskItem("foo.bar");
projectReference.SetMetadata("Platforms", "x64");

GetCompatiblePlatform task = new GetCompatiblePlatform()
{
BuildEngine = new MockEngine(_output),
CurrentProjectPlatform = "AnyCPU",
PlatformLookupTable = "AnyCPU=;A=B", // invalid format
AnnotatedProjects = new TaskItem[] { projectReference },
};

task.Execute().ShouldBeTrue();
// When the platformlookuptable is in an invalid format, it is discarded.
// There shouldn't have been a translation found from AnyCPU to anything.
// Meaning the projectreference would not have NearestPlatform set.
task.AssignedProjectsWithPlatform[0].GetMetadata("NearestPlatform").ShouldBe(string.Empty);
((MockEngine)task.BuildEngine).AssertLogContains("MSB3983");
}

/// <summary>
/// Invalid format on PlatformLookupTable from the projectreference results in an exception being thrown.
/// </summary>
[Fact]
public void WarnsOnInvalidFormatProjectReferenceLookupTable()
{
TaskItem projectReference = new TaskItem("foo.bar");
projectReference.SetMetadata("Platforms", "x64;x86");
projectReference.SetMetadata("PlatformLookupTable", "x86=;b=d");

GetCompatiblePlatform task = new GetCompatiblePlatform()
{
BuildEngine = new MockEngine(_output),
CurrentProjectPlatform = "AnyCPU",
PlatformLookupTable = "AnyCPU=x86;A=B", // invalid format
AnnotatedProjects = new TaskItem[] { projectReference },
};

task.Execute().ShouldBeTrue();

// A ProjectReference PlatformLookupTable should take priority, but is thrown away when
// it has an invalid format. The current project's PLT should be the next priority.
task.AssignedProjectsWithPlatform[0].GetMetadata("NearestPlatform").ShouldBe("x86");
((MockEngine)task.BuildEngine).AssertLogContains("MSB3983");
}
}
}
Loading

0 comments on commit b6e7d60

Please sign in to comment.