diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md
index 41bb96b5f6a2..a0fd5e9273aa 100644
--- a/documentation/general/dotnet-run-file.md
+++ b/documentation/general/dotnet-run-file.md
@@ -245,6 +245,16 @@ The directives are processed as follows:
(because `ProjectReference` items don't support directory paths).
An error is reported if zero or more than one projects are found in the directory, just like `dotnet reference add` would do.
+Directive values support MSBuild variables (like `$(..)`) normally as they are translated literally and left to MSBuild engine to process.
+However, in `#:project` directives, variables might not be preserved during [grow up](#grow-up),
+because there is additional processing of those directives that makes it technically challenging to preserve variables in all cases
+(project directive values need to be resolved to be relative to the target directory
+and also to point to a project file rather than a directory).
+Note that it is not expected that variables inside the path change their meaning during the conversion,
+so for example `#:project ../$(LibName)` is translated to `` (i.e., the variable is preserved).
+However, variables at the start can change, so for example `#:project $(ProjectDir)../Lib` is translated to `` (i.e., the variable is expanded).
+In other directives, all variables are preserved during conversion.
+
Because these directives are limited by the C# language to only appear before the first "C# token" and any `#if`,
dotnet CLI can look for them via a regex or Roslyn lexer without any knowledge of defined conditional symbols
and can do that efficiently by stopping the search when it sees the first "C# token".
diff --git a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs
index 76bba800ff4a..2e0287a1f891 100644
--- a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs
+++ b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs
@@ -30,7 +30,8 @@ public override int Execute()
// Find directives (this can fail, so do this before creating the target directory).
var sourceFile = SourceFile.Load(file);
- var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile, reportAllErrors: !_force, DiagnosticBag.ThrowOnFirst());
+ var diagnostics = DiagnosticBag.ThrowOnFirst();
+ var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile, reportAllErrors: !_force, diagnostics);
// Create a project instance for evaluation.
var projectCollection = new ProjectCollection();
@@ -42,6 +43,11 @@ public override int Execute()
};
var projectInstance = command.CreateProjectInstance(projectCollection);
+ // Evaluate directives.
+ directives = VirtualProjectBuildingCommand.EvaluateDirectives(projectInstance, directives, sourceFile, diagnostics);
+ command.Directives = directives;
+ projectInstance = command.CreateProjectInstance(projectCollection);
+
// Find other items to copy over, e.g., default Content items like JSON files in Web apps.
var includeItems = FindIncludedItems().ToList();
@@ -169,17 +175,50 @@ ImmutableArray UpdateDirectives(ImmutableArray
foreach (var directive in directives)
{
- // Fixup relative project reference paths (they need to be relative to the output directory instead of the source directory).
- if (directive is CSharpDirective.Project project &&
- !Path.IsPathFullyQualified(project.Name))
- {
- var modified = project.WithName(Path.GetRelativePath(relativeTo: targetDirectory, path: project.Name));
- result.Add(modified);
- }
- else
+ // Fixup relative project reference paths (they need to be relative to the output directory instead of the source directory,
+ // and preserve MSBuild interpolation variables like `$(..)`
+ // while also pointing to the project file rather than a directory).
+ if (directive is CSharpDirective.Project project)
{
- result.Add(directive);
+ if (Path.IsPathFullyQualified(project.Name))
+ {
+ // If the path is absolute and has no `$(..)` vars, just keep it.
+ if (project.UnresolvedName == project.OriginalName)
+ {
+ result.Add(project);
+ continue;
+ }
+
+ // If the path is absolute and it *starts* with some `$(..)` vars,
+ // turn it into a relative path (it might be in the form `$(ProjectDir)/../Lib`
+ // and we don't want that to be turned into an absolute path in the converted project).
+ //
+ // If the path is absolute but the `$(..)` vars are *inside* of it (like `C:\$(..)\Lib`),
+ // instead of at the start, we can keep those vars, i.e., skip this `if` block.
+ //
+ // The `OriginalName` is absolute if there are no `$(..)` vars at the start.
+ if (!Path.IsPathFullyQualified(project.OriginalName))
+ {
+ project = project.WithName(Path.GetRelativePath(relativeTo: targetDirectory, path: project.Name));
+ result.Add(project);
+ continue;
+ }
+ }
+
+ // If the original path is to a directory, just append the resolved file name
+ // but preserve the variables from the original, e.g., `../$(..)/Directory/Project.csproj`.
+ if (Directory.Exists(project.UnresolvedName))
+ {
+ var projectFileName = Path.GetFileName(project.Name);
+ project = project.WithName(Path.Join(project.OriginalName, projectFileName));
+ }
+
+ project = project.WithName(Path.GetRelativePath(relativeTo: targetDirectory, path: project.Name));
+ result.Add(project);
+ continue;
}
+
+ result.Add(directive);
}
return result.DrainToImmutable();
diff --git a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
index 8da06cc0a47f..ed84b0eedf3b 100644
--- a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
+++ b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
@@ -6,6 +6,7 @@
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
using System.Security;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -164,14 +165,26 @@ public VirtualProjectBuildingCommand(
///
public bool NoWriteBuildMarkers { get; init; }
+ private SourceFile EntryPointSourceFile
+ {
+ get
+ {
+ if (field == default)
+ {
+ field = SourceFile.Load(EntryPointFileFullPath);
+ }
+
+ return field;
+ }
+ }
+
public ImmutableArray Directives
{
get
{
if (field.IsDefault)
{
- var sourceFile = SourceFile.Load(EntryPointFileFullPath);
- field = FindDirectives(sourceFile, reportAllErrors: false, DiagnosticBag.ThrowOnFirst());
+ field = FindDirectives(EntryPointSourceFile, reportAllErrors: false, DiagnosticBag.ThrowOnFirst());
Debug.Assert(!field.IsDefault);
}
@@ -1047,6 +1060,23 @@ public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection
private ProjectInstance CreateProjectInstance(
ProjectCollection projectCollection,
Action>? addGlobalProperties)
+ {
+ var project = CreateProjectInstance(projectCollection, Directives, addGlobalProperties);
+
+ var directives = EvaluateDirectives(project, Directives, EntryPointSourceFile, DiagnosticBag.ThrowOnFirst());
+ if (directives != Directives)
+ {
+ Directives = directives;
+ project = CreateProjectInstance(projectCollection, directives, addGlobalProperties);
+ }
+
+ return project;
+ }
+
+ private ProjectInstance CreateProjectInstance(
+ ProjectCollection projectCollection,
+ ImmutableArray directives,
+ Action>? addGlobalProperties)
{
var projectRoot = CreateProjectRootElement(projectCollection);
@@ -1069,7 +1099,7 @@ ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
var projectFileWriter = new StringWriter();
WriteProjectFile(
projectFileWriter,
- Directives,
+ directives,
isVirtualProject: true,
targetFilePath: EntryPointFileFullPath,
artifactsPath: ArtifactsPath,
@@ -1589,6 +1619,28 @@ static bool Fill(ref WhiteSpaceInfo info, in SyntaxTriviaList triviaList, int in
}
}
+ ///
+ /// If there are any #:project , expand $() in them and then resolve the project paths.
+ ///
+ public static ImmutableArray EvaluateDirectives(
+ ProjectInstance? project,
+ ImmutableArray directives,
+ SourceFile sourceFile,
+ DiagnosticBag diagnostics)
+ {
+ if (directives.OfType().Any())
+ {
+ return directives
+ .Select(d => d is CSharpDirective.Project p
+ ? (project is null ? p : p.WithName(project.ExpandString(p.Name)))
+ .ResolveProjectPath(sourceFile, diagnostics)
+ : d)
+ .ToImmutableArray();
+ }
+
+ return directives;
+ }
+
public static SourceText? RemoveDirectivesFromFile(ImmutableArray directives, SourceText text)
{
if (directives.Length == 0)
@@ -1867,8 +1919,31 @@ public sealed class Package(in ParseInfo info) : Named(info)
///
/// #:project directive.
///
- public sealed class Project(in ParseInfo info) : Named(info)
+ public sealed class Project : Named
{
+ [SetsRequiredMembers]
+ public Project(in ParseInfo info, string name) : base(info)
+ {
+ Name = name;
+ OriginalName = name;
+ UnresolvedName = name;
+ }
+
+ ///
+ /// Preserved across calls.
+ ///
+ public string OriginalName { get; init; }
+
+ ///
+ /// Preserved across calls.
+ ///
+ ///
+ /// When MSBuild $(..) vars are expanded via ,
+ /// the will be expanded, but not resolved
+ /// (i.e., can be pointing to project directory or file).
+ ///
+ public string UnresolvedName { get; init; }
+
public static new Project? Parse(in ParseContext context)
{
var directiveText = context.DirectiveText;
@@ -1878,11 +1953,32 @@ public sealed class Project(in ParseInfo info) : Named(info)
return context.Diagnostics.AddError(context.SourceFile, context.Info.Span, string.Format(CliCommandStrings.MissingDirectiveName, directiveKind));
}
+ return new Project(context.Info, directiveText);
+ }
+
+ public Project WithName(string name, bool preserveUnresolvedName = false)
+ {
+ return name == Name
+ ? this
+ : new Project(Info, name)
+ {
+ OriginalName = OriginalName,
+ UnresolvedName = preserveUnresolvedName ? UnresolvedName : name,
+ };
+ }
+
+ ///
+ /// If the directive points to a directory, returns a new directive pointing to the corresponding project file.
+ ///
+ public Project ResolveProjectPath(SourceFile sourceFile, DiagnosticBag diagnostics)
+ {
+ var directiveText = Name;
+
try
{
// If the path is a directory like '../lib', transform it to a project file path like '../lib/lib.csproj'.
- // Also normalize blackslashes to forward slashes to ensure the directive works on all platforms.
- var sourceDirectory = Path.GetDirectoryName(context.SourceFile.Path) ?? ".";
+ // Also normalize backslashes to forward slashes to ensure the directive works on all platforms.
+ var sourceDirectory = Path.GetDirectoryName(sourceFile.Path) ?? ".";
var resolvedProjectPath = Path.Combine(sourceDirectory, directiveText.Replace('\\', '/'));
if (Directory.Exists(resolvedProjectPath))
{
@@ -1900,18 +1996,10 @@ public sealed class Project(in ParseInfo info) : Named(info)
}
catch (GracefulException e)
{
- context.Diagnostics.AddError(context.SourceFile, context.Info.Span, string.Format(CliCommandStrings.InvalidProjectDirective, e.Message), e);
+ diagnostics.AddError(sourceFile, Info.Span, string.Format(CliCommandStrings.InvalidProjectDirective, e.Message), e);
}
- return new Project(context.Info)
- {
- Name = directiveText,
- };
- }
-
- public Project WithName(string name)
- {
- return new Project(Info) { Name = name };
+ return WithName(directiveText, preserveUnresolvedName: true);
}
public override string ToString() => $"#:project {Name}";
diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs
index cb7fd4331429..7849a3e25e36 100644
--- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs
+++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs
@@ -73,11 +73,14 @@ public void SameAsTemplate()
}
[Theory] // https://github.com/dotnet/sdk/issues/50832
- [InlineData("File", "Lib", "../Lib", "Project", "../Lib/lib.csproj")]
- [InlineData(".", "Lib", "./Lib", "Project", "../Lib/lib.csproj")]
- [InlineData(".", "Lib", "Lib/../Lib", "Project", "../Lib/lib.csproj")]
- [InlineData("File", "Lib", "../Lib", "File/Project", "../../Lib/lib.csproj")]
- [InlineData("File", "Lib", "..\\Lib", "File/Project", "../../Lib/lib.csproj")]
+ [InlineData("File", "Lib", "../Lib", "Project", "..{/}Lib{/}lib.csproj")]
+ [InlineData(".", "Lib", "./Lib", "Project", "..{/}Lib{/}lib.csproj")]
+ [InlineData(".", "Lib", "Lib/../Lib", "Project", "..{/}Lib{/}lib.csproj")]
+ [InlineData("File", "Lib", "../Lib", "File/Project", "..{/}..{/}Lib{/}lib.csproj")]
+ [InlineData("File", "Lib", @"..\Lib", "File/Project", @"..{/}..\Lib{/}lib.csproj")]
+ [InlineData("File", "Lib", "../$(LibProjectName)", "File/Project", "..{/}..{/}$(LibProjectName){/}lib.csproj")]
+ [InlineData("File", "Lib", @"..\$(LibProjectName)", "File/Project", @"..{/}..\$(LibProjectName){/}lib.csproj")]
+ [InlineData("File", "Lib", "$(MSBuildProjectDirectory)/../$(LibProjectName)", "File/Project", "..{/}..{/}Lib{/}lib.csproj")]
public void ProjectReference_RelativePaths(string fileDir, string libraryDir, string reference, string outputDir, string convertedReference)
{
var testInstance = _testAssetsManager.CreateTestDirectory();
@@ -105,6 +108,7 @@ public static void M()
Directory.CreateDirectory(fileDirFullPath);
File.WriteAllText(Path.Join(fileDirFullPath, "app.cs"), $"""
#:project {reference}
+ #:property LibProjectName=Lib
C.M();
""");
@@ -130,7 +134,7 @@ public static void M()
File.ReadAllText(Path.Join(outputDirFullPath, "app.csproj"))
.Should().Contain($"""
-
+
""");
}
@@ -191,6 +195,64 @@ public static void M()
""");
}
+ [Fact]
+ public void ProjectReference_FullPath_WithVars()
+ {
+ var testInstance = _testAssetsManager.CreateTestDirectory();
+
+ var libraryDirFullPath = Path.Join(testInstance.Path, "Lib");
+ Directory.CreateDirectory(libraryDirFullPath);
+ File.WriteAllText(Path.Join(libraryDirFullPath, "lib.cs"), """
+ public static class C
+ {
+ public static void M()
+ {
+ System.Console.WriteLine("Hello from library");
+ }
+ }
+ """);
+ File.WriteAllText(Path.Join(libraryDirFullPath, "lib.csproj"), $"""
+
+
+ {ToolsetInfo.CurrentTargetFramework}
+
+
+ """);
+
+ var fileDirFullPath = Path.Join(testInstance.Path, "File");
+ Directory.CreateDirectory(fileDirFullPath);
+ File.WriteAllText(Path.Join(fileDirFullPath, "app.cs"), $"""
+ #:project {fileDirFullPath}/../$(LibProjectName)
+ #:property LibProjectName=Lib
+ C.M();
+ """);
+
+ var expectedOutput = "Hello from library";
+
+ new DotnetCommand(Log, "run", "app.cs")
+ .WithWorkingDirectory(fileDirFullPath)
+ .Execute()
+ .Should().Pass()
+ .And.HaveStdOut(expectedOutput);
+
+ var outputDirFullPath = Path.Join(testInstance.Path, "File/Project");
+ new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath)
+ .WithWorkingDirectory(fileDirFullPath)
+ .Execute()
+ .Should().Pass();
+
+ new DotnetCommand(Log, "run")
+ .WithWorkingDirectory(outputDirFullPath)
+ .Execute()
+ .Should().Pass()
+ .And.HaveStdOut(expectedOutput);
+
+ File.ReadAllText(Path.Join(outputDirFullPath, "app.csproj"))
+ .Should().Contain($"""
+
+ """);
+ }
+
[Fact]
public void DirectoryAlreadyExists()
{
@@ -1551,7 +1613,9 @@ public void Directives_VersionedSdkFirst()
private static void Convert(string inputCSharp, out string actualProject, out string? actualCSharp, bool force, string? filePath)
{
var sourceFile = new SourceFile(filePath ?? "/app/Program.cs", SourceText.From(inputCSharp, Encoding.UTF8));
- var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile, reportAllErrors: !force, DiagnosticBag.ThrowOnFirst());
+ var diagnostics = DiagnosticBag.ThrowOnFirst();
+ var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile, reportAllErrors: !force, diagnostics);
+ directives = VirtualProjectBuildingCommand.EvaluateDirectives(project: null, directives, sourceFile, diagnostics);
var projectWriter = new StringWriter();
VirtualProjectBuildingCommand.WriteProjectFile(projectWriter, directives, isVirtualProject: false);
actualProject = projectWriter.ToString();
diff --git a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs
index 5cdf670159a3..f8383a81f389 100644
--- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs
+++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs
@@ -2109,6 +2109,8 @@ public void SdkReference_VersionedSdkFirst()
[InlineData("../Lib")]
[InlineData(@"..\Lib\Lib.csproj")]
[InlineData(@"..\Lib")]
+ [InlineData("$(MSBuildProjectDirectory)/../$(LibProjectName)")]
+ [InlineData(@"$(MSBuildProjectDirectory)/../Lib\$(LibProjectName).csproj")]
public void ProjectReference(string arg)
{
var testInstance = _testAssetsManager.CreateTestDirectory();
@@ -2137,6 +2139,7 @@ public class LibClass
File.WriteAllText(Path.Join(appDir, "Program.cs"), $"""
#:project {arg}
+ #:property LibProjectName=Lib
Console.WriteLine(Lib.LibClass.GetMessage());
""");
@@ -2195,6 +2198,18 @@ public void ProjectReference_Errors()
.Should().Fail()
.And.HaveStdErrContaining(DirectiveError(Path.Join(testInstance.Path, "Program.cs"), 1, CliCommandStrings.InvalidProjectDirective,
string.Format(CliStrings.MoreThanOneProjectInDirectory, Path.Join(testInstance.Path, "dir/"))));
+
+ // Malformed MSBuild variable syntax.
+ File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
+ #:project $(Test
+ """);
+
+ new DotnetCommand(Log, "run", "Program.cs")
+ .WithWorkingDirectory(testInstance.Path)
+ .Execute()
+ .Should().Fail()
+ .And.HaveStdErrContaining(DirectiveError(Path.Join(testInstance.Path, "Program.cs"), 1, CliCommandStrings.InvalidProjectDirective,
+ string.Format(CliStrings.CouldNotFindProjectOrDirectory, Path.Join(testInstance.Path, "$(Test"))));
}
[Theory] // https://github.com/dotnet/aspnetcore/issues/63440
diff --git a/test/dotnet.Tests/CommandTests/Run/RunTelemetryTests.cs b/test/dotnet.Tests/CommandTests/Run/RunTelemetryTests.cs
index 8ced55de7342..92eecb53f73c 100644
--- a/test/dotnet.Tests/CommandTests/Run/RunTelemetryTests.cs
+++ b/test/dotnet.Tests/CommandTests/Run/RunTelemetryTests.cs
@@ -114,8 +114,8 @@ public void CountProjectReferences_FileBasedApp_CountsDirectives()
{
// Arrange
var directives = ImmutableArray.Create(
- new CSharpDirective.Project(default) { Name = "../lib/Library.csproj" },
- new CSharpDirective.Project(default) { Name = "../common/Common.csproj" }
+ new CSharpDirective.Project(default, "../lib/Library.csproj"),
+ new CSharpDirective.Project(default, "../common/Common.csproj")
);
// Act