Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions documentation/specs/build-nonexistent-projects-by-default.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ When `_BuildNonexistentProjectsByDefault` is set to `true`:
1. **MSBuild tasks** that don't explicitly specify `SkipNonexistentProjects` will default to `SkipNonexistentProjects="Build"` instead of `SkipNonexistentProjects="False"`
2. **In-memory projects** with a valid `FullPath` can be built even when no physical file exists on disk
3. **Existing explicit settings** are preserved - if `SkipNonexistentProjects` is explicitly set on the MSBuild task, that takes precedence
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.

### Implementation Details

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

And in the common targets:

3. **`src/Tasks/Microsoft.Common.CurrentVersion.targets`** - The `_SplitProjectReferencesByFileExistence` target, which classifies `ProjectReference` items as existent or non-existent

The logic follows this precedence order:

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

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

### Virtual ProjectReference

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:

```csharp
var collection = new ProjectCollection(
globalProperties: new Dictionary<string, string>
{
{ "_BuildNonexistentProjectsByDefault", "true" },
},
loggers: null,
ToolsetDefinitionLocations.Default);

// Create referenced project in memory.
var referencedRoot = ProjectRootElement.Create(XmlReader.Create(new StringReader(referencedXml)), collection);
referencedRoot.FullPath = Path.Join(projectDir, "referenced.csproj");

// Create main project with a ProjectReference to the referenced project.
var mainRoot = ProjectRootElement.Create(XmlReader.Create(new StringReader(mainXml)), collection);
mainRoot.FullPath = Path.Join(projectDir, "main.csproj");

// Both projects are in the same ProjectRootElementCache.
// The build engine resolves "referenced.csproj" from the cache, not from disk.
```

## Limitations

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.

## Breaking Changes

**None.** This is an opt-in feature with an internal property name (prefixed with `_`). Existing behavior is preserved when the property is not set.
Expand Down
139 changes: 139 additions & 0 deletions src/Build.UnitTests/BackEnd/MSBuild_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Xml;
using Microsoft.Build.Construction;
using Microsoft.Build.Evaluation;
using Microsoft.Build.Execution;
using Microsoft.Build.Framework;
Expand Down Expand Up @@ -1997,5 +1999,142 @@ public void NonExistentProject(bool? buildNonexistentProjectsByDefault)
? "MSB4025" // error MSB4025: The project file could not be loaded.
: "MSB3202"); // error MSB3202: The project file was not found.
}

/// <summary>
/// Verifies that a virtual (in-memory) project can be resolved via <c>&lt;ProjectReference&gt;</c>
/// through the real <c>_SplitProjectReferencesByFileExistence</c> target from
/// <c>Microsoft.Common.CurrentVersion.targets</c> when <c>_BuildNonexistentProjectsByDefault</c> is set.
/// This is used by file-based apps (<c>dotnet run file.cs</c>) to support
/// <c>#:ref</c> directives that create virtual project references.
/// </summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public void VirtualProjectReference_SplitByFileExistence(bool buildNonexistentProjectsByDefault)
{
using TestEnvironment env = TestEnvironment.Create(_testOutput);
string projectDir = env.CreateFolder().Path;

using var collection = new ProjectCollection();

// Create the referenced virtual project (NOT on disk).
string referencedProjectPath = Path.Combine(projectDir, "referenced.csproj");
using var referencedReader = XmlReader.Create(new StringReader("""
<Project>
<Target Name="GetTargetPath" Returns="@(TargetPathItem)">
<ItemGroup>
<TargetPathItem Include="referenced_output.dll" />
</ItemGroup>
</Target>
</Project>
"""));
var referencedRoot = ProjectRootElement.Create(referencedReader, collection);
referencedRoot.FullPath = referencedProjectPath;

// Create the main project ON DISK with <ProjectReference> and import of real targets.
string mainProjectPath = Path.Combine(projectDir, "main.csproj");
File.WriteAllText(mainProjectPath, """
<Project>
<Import Project="$(MSBuildBinPath)\Microsoft.Common.CurrentVersion.targets" />
<ItemGroup>
<ProjectReference Include="referenced.csproj" />
</ItemGroup>
<Target Name="CheckSplit" DependsOnTargets="AssignProjectConfiguration;_SplitProjectReferencesByFileExistence">
<Message Text="Existent: @(_MSBuildProjectReferenceExistent)" Importance="High" />
<Message Text="Nonexistent: @(_MSBuildProjectReferenceNonexistent)" Importance="High" />
</Target>
</Project>
""");

var globalProperties = new Dictionary<string, string>();
if (buildNonexistentProjectsByDefault)
{
globalProperties[PropertyNames.BuildNonexistentProjectsByDefault] = bool.TrueString;
}

var project = new Project(mainProjectPath, globalProperties, null, collection);
var logger = new MockLogger(_testOutput);
bool result = project.Build("CheckSplit", [logger]);
_testOutput.WriteLine(logger.FullLog);
Assert.True(result);

Comment thread
jjonescz marked this conversation as resolved.
if (buildNonexistentProjectsByDefault)
{
logger.AssertLogContains("Existent: referenced.csproj");
logger.AssertLogDoesntContain("Nonexistent: referenced.csproj");
}
else
{
logger.AssertLogDoesntContain("Existent: referenced.csproj");
logger.AssertLogContains("Nonexistent: referenced.csproj");
}
}

/// <summary>
/// End-to-end: a virtual project referenced via <c>&lt;ProjectReference&gt;</c> is actually
/// built through the real <c>ResolveProjectReferences</c> target when
/// <c>_BuildNonexistentProjectsByDefault</c> is set. Parameterized to verify
/// that the main project can also be virtual (not on disk).
/// </summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public void VirtualProjectReference_EndToEnd(bool mainProjectVirtual)
{
using TestEnvironment env = TestEnvironment.Create(_testOutput);
string projectDir = env.CreateFolder().Path;

using var collection = new ProjectCollection(
globalProperties: new Dictionary<string, string>
{
{ PropertyNames.BuildNonexistentProjectsByDefault, bool.TrueString },
});

// Create the referenced virtual project (NOT on disk).
string referencedProjectPath = Path.Combine(projectDir, "referenced.csproj");
using var referencedReader = XmlReader.Create(new StringReader("""
<Project>
<Target Name="GetTargetPath" Returns="@(TargetPathItem)">
<ItemGroup>
<TargetPathItem Include="referenced_output.dll" />
</ItemGroup>
<Message Text="message from referenced project" Importance="High" />
</Target>
</Project>
"""));
var referencedRoot = ProjectRootElement.Create(referencedReader, collection);
referencedRoot.FullPath = referencedProjectPath;

// Create the main project with <ProjectReference> and import of real targets.
string mainProjectPath = Path.Combine(projectDir, "main.csproj");
string mainProjectXml = """
<Project>
<Import Project="$(MSBuildBinPath)\Microsoft.Common.CurrentVersion.targets" />
<ItemGroup>
<ProjectReference Include="referenced.csproj" SkipGetTargetFrameworkProperties="true" />
</ItemGroup>
</Project>
""";

Project project;
if (mainProjectVirtual)
{
using var mainReader = XmlReader.Create(new StringReader(mainProjectXml));
var mainRoot = ProjectRootElement.Create(mainReader, collection);
mainRoot.FullPath = mainProjectPath;
project = new Project(mainRoot, null, null, collection);
}
else
{
File.WriteAllText(mainProjectPath, mainProjectXml);
project = new Project(mainProjectPath, null, null, collection);
}

var logger = new MockLogger(_testOutput);
bool result = project.Build("ResolveProjectReferences", [logger]);
_testOutput.WriteLine(logger.FullLog);
Assert.True(result);
logger.AssertLogContains("message from referenced project");
Comment thread
jjonescz marked this conversation as resolved.
}
}
}
8 changes: 5 additions & 3 deletions src/Tasks/Microsoft.Common.CurrentVersion.targets
Original file line number Diff line number Diff line change
Expand Up @@ -1667,10 +1667,12 @@ Copyright (C) Microsoft Corporation. All rights reserved.
<_MSBuildProjectReference Include="@(ProjectReferenceWithConfiguration)" Condition="'$(BuildingInsideVisualStudio)'!='true' and '@(ProjectReferenceWithConfiguration)'!=''"/>
</ItemGroup>

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

</Target>
Expand Down