Skip to content

Commit 0f7f8dd

Browse files
authored
Allow referencing virtual projects (#13389)
1 parent 1b923e2 commit 0f7f8dd

3 files changed

Lines changed: 178 additions & 3 deletions

File tree

documentation/specs/build-nonexistent-projects-by-default.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ When `_BuildNonexistentProjectsByDefault` is set to `true`:
4949
1. **MSBuild tasks** that don't explicitly specify `SkipNonexistentProjects` will default to `SkipNonexistentProjects="Build"` instead of `SkipNonexistentProjects="False"`
5050
2. **In-memory projects** with a valid `FullPath` can be built even when no physical file exists on disk
5151
3. **Existing explicit settings** are preserved - if `SkipNonexistentProjects` is explicitly set on the MSBuild task, that takes precedence
52+
4. **`ProjectReference` items** are treated as existent by the `_SplitProjectReferencesByFileExistence` target, bypassing the `Exists()` file-system check. This allows virtual project references between in-memory projects.
5253

5354
### Implementation Details
5455

@@ -57,6 +58,10 @@ The property is checked in two MSBuild task implementations:
5758
1. **`src/Tasks/MSBuild.cs`** - The standard MSBuild task implementation
5859
2. **`src/Build/BackEnd/Components/RequestBuilder/IntrinsicTasks/MSBuild.cs`** - The backend intrinsic task implementation
5960

61+
And in the common targets:
62+
63+
3. **`src/Tasks/Microsoft.Common.CurrentVersion.targets`** - The `_SplitProjectReferencesByFileExistence` target, which classifies `ProjectReference` items as existent or non-existent
64+
6065
The logic follows this precedence order:
6166

6267
1. If `SkipNonexistentProjects` is explicitly set on the MSBuild task → use that value
@@ -80,6 +85,35 @@ bool result = project.Build();
8085

8186
The .NET SDK will use this property to enable building file-based applications without workarounds when calling MSBuild tasks that reference the current project.
8287

88+
### Virtual ProjectReference
89+
90+
When file-based applications need to reference other files (e.g., via `#:ref file.cs`), the SDK creates multiple in-memory projects with `ProjectReference` items between them. With `_BuildNonexistentProjectsByDefault=true`, these virtual references are resolved from the `ProjectRootElementCache` without requiring files on disk. Both the main project and its references can be virtual:
91+
92+
```csharp
93+
var collection = new ProjectCollection(
94+
globalProperties: new Dictionary<string, string>
95+
{
96+
{ "_BuildNonexistentProjectsByDefault", "true" },
97+
},
98+
loggers: null,
99+
ToolsetDefinitionLocations.Default);
100+
101+
// Create referenced project in memory.
102+
var referencedRoot = ProjectRootElement.Create(XmlReader.Create(new StringReader(referencedXml)), collection);
103+
referencedRoot.FullPath = Path.Join(projectDir, "referenced.csproj");
104+
105+
// Create main project with a ProjectReference to the referenced project.
106+
var mainRoot = ProjectRootElement.Create(XmlReader.Create(new StringReader(mainXml)), collection);
107+
mainRoot.FullPath = Path.Join(projectDir, "main.csproj");
108+
109+
// Both projects are in the same ProjectRootElementCache.
110+
// The build engine resolves "referenced.csproj" from the cache, not from disk.
111+
```
112+
113+
## Limitations
114+
115+
Virtual projects only work with in-process builds (single node). Out-of-proc build nodes have their own `ProjectRootElementCache` and cannot see virtual projects from the caller's collection. This applies to all scenarios: self-referencing MSBuild tasks, virtual `ProjectReference` items, and any other target that loads a virtual project by path.
116+
83117
## Breaking Changes
84118

85119
**None.** This is an opt-in feature with an internal property name (prefixed with `_`). Existing behavior is preserved when the property is not set.

src/Build.UnitTests/BackEnd/MSBuild_Tests.cs

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
using System;
55
using System.Collections.Generic;
66
using System.IO;
7+
using System.Xml;
8+
using Microsoft.Build.Construction;
79
using Microsoft.Build.Evaluation;
810
using Microsoft.Build.Execution;
911
using Microsoft.Build.Framework;
@@ -1997,5 +1999,142 @@ public void NonExistentProject(bool? buildNonexistentProjectsByDefault)
19971999
? "MSB4025" // error MSB4025: The project file could not be loaded.
19982000
: "MSB3202"); // error MSB3202: The project file was not found.
19992001
}
2002+
2003+
/// <summary>
2004+
/// Verifies that a virtual (in-memory) project can be resolved via <c>&lt;ProjectReference&gt;</c>
2005+
/// through the real <c>_SplitProjectReferencesByFileExistence</c> target from
2006+
/// <c>Microsoft.Common.CurrentVersion.targets</c> when <c>_BuildNonexistentProjectsByDefault</c> is set.
2007+
/// This is used by file-based apps (<c>dotnet run file.cs</c>) to support
2008+
/// <c>#:ref</c> directives that create virtual project references.
2009+
/// </summary>
2010+
[Theory]
2011+
[InlineData(false)]
2012+
[InlineData(true)]
2013+
public void VirtualProjectReference_SplitByFileExistence(bool buildNonexistentProjectsByDefault)
2014+
{
2015+
using TestEnvironment env = TestEnvironment.Create(_testOutput);
2016+
string projectDir = env.CreateFolder().Path;
2017+
2018+
using var collection = new ProjectCollection();
2019+
2020+
// Create the referenced virtual project (NOT on disk).
2021+
string referencedProjectPath = Path.Combine(projectDir, "referenced.csproj");
2022+
using var referencedReader = XmlReader.Create(new StringReader("""
2023+
<Project>
2024+
<Target Name="GetTargetPath" Returns="@(TargetPathItem)">
2025+
<ItemGroup>
2026+
<TargetPathItem Include="referenced_output.dll" />
2027+
</ItemGroup>
2028+
</Target>
2029+
</Project>
2030+
"""));
2031+
var referencedRoot = ProjectRootElement.Create(referencedReader, collection);
2032+
referencedRoot.FullPath = referencedProjectPath;
2033+
2034+
// Create the main project ON DISK with <ProjectReference> and import of real targets.
2035+
string mainProjectPath = Path.Combine(projectDir, "main.csproj");
2036+
File.WriteAllText(mainProjectPath, """
2037+
<Project>
2038+
<Import Project="$(MSBuildBinPath)\Microsoft.Common.CurrentVersion.targets" />
2039+
<ItemGroup>
2040+
<ProjectReference Include="referenced.csproj" />
2041+
</ItemGroup>
2042+
<Target Name="CheckSplit" DependsOnTargets="AssignProjectConfiguration;_SplitProjectReferencesByFileExistence">
2043+
<Message Text="Existent: @(_MSBuildProjectReferenceExistent)" Importance="High" />
2044+
<Message Text="Nonexistent: @(_MSBuildProjectReferenceNonexistent)" Importance="High" />
2045+
</Target>
2046+
</Project>
2047+
""");
2048+
2049+
var globalProperties = new Dictionary<string, string>();
2050+
if (buildNonexistentProjectsByDefault)
2051+
{
2052+
globalProperties[PropertyNames.BuildNonexistentProjectsByDefault] = bool.TrueString;
2053+
}
2054+
2055+
var project = new Project(mainProjectPath, globalProperties, null, collection);
2056+
var logger = new MockLogger(_testOutput);
2057+
bool result = project.Build("CheckSplit", [logger]);
2058+
_testOutput.WriteLine(logger.FullLog);
2059+
Assert.True(result);
2060+
2061+
if (buildNonexistentProjectsByDefault)
2062+
{
2063+
logger.AssertLogContains("Existent: referenced.csproj");
2064+
logger.AssertLogDoesntContain("Nonexistent: referenced.csproj");
2065+
}
2066+
else
2067+
{
2068+
logger.AssertLogDoesntContain("Existent: referenced.csproj");
2069+
logger.AssertLogContains("Nonexistent: referenced.csproj");
2070+
}
2071+
}
2072+
2073+
/// <summary>
2074+
/// End-to-end: a virtual project referenced via <c>&lt;ProjectReference&gt;</c> is actually
2075+
/// built through the real <c>ResolveProjectReferences</c> target when
2076+
/// <c>_BuildNonexistentProjectsByDefault</c> is set. Parameterized to verify
2077+
/// that the main project can also be virtual (not on disk).
2078+
/// </summary>
2079+
[Theory]
2080+
[InlineData(false)]
2081+
[InlineData(true)]
2082+
public void VirtualProjectReference_EndToEnd(bool mainProjectVirtual)
2083+
{
2084+
using TestEnvironment env = TestEnvironment.Create(_testOutput);
2085+
string projectDir = env.CreateFolder().Path;
2086+
2087+
using var collection = new ProjectCollection(
2088+
globalProperties: new Dictionary<string, string>
2089+
{
2090+
{ PropertyNames.BuildNonexistentProjectsByDefault, bool.TrueString },
2091+
});
2092+
2093+
// Create the referenced virtual project (NOT on disk).
2094+
string referencedProjectPath = Path.Combine(projectDir, "referenced.csproj");
2095+
using var referencedReader = XmlReader.Create(new StringReader("""
2096+
<Project>
2097+
<Target Name="GetTargetPath" Returns="@(TargetPathItem)">
2098+
<ItemGroup>
2099+
<TargetPathItem Include="referenced_output.dll" />
2100+
</ItemGroup>
2101+
<Message Text="message from referenced project" Importance="High" />
2102+
</Target>
2103+
</Project>
2104+
"""));
2105+
var referencedRoot = ProjectRootElement.Create(referencedReader, collection);
2106+
referencedRoot.FullPath = referencedProjectPath;
2107+
2108+
// Create the main project with <ProjectReference> and import of real targets.
2109+
string mainProjectPath = Path.Combine(projectDir, "main.csproj");
2110+
string mainProjectXml = """
2111+
<Project>
2112+
<Import Project="$(MSBuildBinPath)\Microsoft.Common.CurrentVersion.targets" />
2113+
<ItemGroup>
2114+
<ProjectReference Include="referenced.csproj" SkipGetTargetFrameworkProperties="true" />
2115+
</ItemGroup>
2116+
</Project>
2117+
""";
2118+
2119+
Project project;
2120+
if (mainProjectVirtual)
2121+
{
2122+
using var mainReader = XmlReader.Create(new StringReader(mainProjectXml));
2123+
var mainRoot = ProjectRootElement.Create(mainReader, collection);
2124+
mainRoot.FullPath = mainProjectPath;
2125+
project = new Project(mainRoot, null, null, collection);
2126+
}
2127+
else
2128+
{
2129+
File.WriteAllText(mainProjectPath, mainProjectXml);
2130+
project = new Project(mainProjectPath, null, null, collection);
2131+
}
2132+
2133+
var logger = new MockLogger(_testOutput);
2134+
bool result = project.Build("ResolveProjectReferences", [logger]);
2135+
_testOutput.WriteLine(logger.FullLog);
2136+
Assert.True(result);
2137+
logger.AssertLogContains("message from referenced project");
2138+
}
20002139
}
20012140
}

src/Tasks/Microsoft.Common.CurrentVersion.targets

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1667,10 +1667,12 @@ Copyright (C) Microsoft Corporation. All rights reserved.
16671667
<_MSBuildProjectReference Include="@(ProjectReferenceWithConfiguration)" Condition="'$(BuildingInsideVisualStudio)'!='true' and '@(ProjectReferenceWithConfiguration)'!=''"/>
16681668
</ItemGroup>
16691669

1670-
<!-- Break the project list into two lists: those that exist on disk and those that don't. -->
1670+
<!-- Break the project list into two lists: those that exist on disk and those that don't.
1671+
When _BuildNonexistentProjectsByDefault is true, treat all references as existent
1672+
because they may be virtual (in-memory) projects in the ProjectRootElementCache. -->
16711673
<ItemGroup>
1672-
<_MSBuildProjectReferenceExistent Include="@(_MSBuildProjectReference)" Condition="Exists('%(Identity)')"/>
1673-
<_MSBuildProjectReferenceNonexistent Include="@(_MSBuildProjectReference)" Condition="!Exists('%(Identity)')"/>
1674+
<_MSBuildProjectReferenceExistent Include="@(_MSBuildProjectReference)" Condition="'$(_BuildNonexistentProjectsByDefault)' == 'true' or Exists('%(Identity)')"/>
1675+
<_MSBuildProjectReferenceNonexistent Include="@(_MSBuildProjectReference)" Condition="'$(_BuildNonexistentProjectsByDefault)' != 'true' and !Exists('%(Identity)')"/>
16741676
</ItemGroup>
16751677

16761678
</Target>

0 commit comments

Comments
 (0)