diff --git a/documentation/specs/build-nonexistent-projects-by-default.md b/documentation/specs/build-nonexistent-projects-by-default.md index 45d1a4f8175..e6f4511f8ea 100644 --- a/documentation/specs/build-nonexistent-projects-by-default.md +++ b/documentation/specs/build-nonexistent-projects-by-default.md @@ -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 @@ -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 @@ -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 + { + { "_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. diff --git a/src/Build.UnitTests/BackEnd/MSBuild_Tests.cs b/src/Build.UnitTests/BackEnd/MSBuild_Tests.cs index cefd001921f..ff500550260 100644 --- a/src/Build.UnitTests/BackEnd/MSBuild_Tests.cs +++ b/src/Build.UnitTests/BackEnd/MSBuild_Tests.cs @@ -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; @@ -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. } + + /// + /// Verifies that a virtual (in-memory) project can be resolved via <ProjectReference> + /// through the real _SplitProjectReferencesByFileExistence target from + /// Microsoft.Common.CurrentVersion.targets when _BuildNonexistentProjectsByDefault is set. + /// This is used by file-based apps (dotnet run file.cs) to support + /// #:ref directives that create virtual project references. + /// + [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(""" + + + + + + + + """)); + var referencedRoot = ProjectRootElement.Create(referencedReader, collection); + referencedRoot.FullPath = referencedProjectPath; + + // Create the main project ON DISK with and import of real targets. + string mainProjectPath = Path.Combine(projectDir, "main.csproj"); + File.WriteAllText(mainProjectPath, """ + + + + + + + + + + + """); + + var globalProperties = new Dictionary(); + 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); + + if (buildNonexistentProjectsByDefault) + { + logger.AssertLogContains("Existent: referenced.csproj"); + logger.AssertLogDoesntContain("Nonexistent: referenced.csproj"); + } + else + { + logger.AssertLogDoesntContain("Existent: referenced.csproj"); + logger.AssertLogContains("Nonexistent: referenced.csproj"); + } + } + + /// + /// End-to-end: a virtual project referenced via <ProjectReference> is actually + /// built through the real ResolveProjectReferences target when + /// _BuildNonexistentProjectsByDefault is set. Parameterized to verify + /// that the main project can also be virtual (not on disk). + /// + [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 + { + { 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(""" + + + + + + + + + """)); + var referencedRoot = ProjectRootElement.Create(referencedReader, collection); + referencedRoot.FullPath = referencedProjectPath; + + // Create the main project with and import of real targets. + string mainProjectPath = Path.Combine(projectDir, "main.csproj"); + string mainProjectXml = """ + + + + + + + """; + + 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"); + } } } diff --git a/src/Tasks/Microsoft.Common.CurrentVersion.targets b/src/Tasks/Microsoft.Common.CurrentVersion.targets index 37105064117..184e922ec49 100644 --- a/src/Tasks/Microsoft.Common.CurrentVersion.targets +++ b/src/Tasks/Microsoft.Common.CurrentVersion.targets @@ -1667,10 +1667,12 @@ Copyright (C) Microsoft Corporation. All rights reserved. <_MSBuildProjectReference Include="@(ProjectReferenceWithConfiguration)" Condition="'$(BuildingInsideVisualStudio)'!='true' and '@(ProjectReferenceWithConfiguration)'!=''"/> - + - <_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)')"/>