diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index 41bb96b5f6a2..be523b0a129e 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -245,6 +245,12 @@ 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). + 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..d0453249cec0 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,42 @@ 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 the path is absolute and it has some `$(..)` vars in it, + // 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 (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; + } + + 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..631706136192 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,26 @@ 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 required string OriginalName { get; init; } + + /// + /// Preserved across calls. + /// + public required string UnresolvedName { get; init; } + public static new Project? Parse(in ParseContext context) { var directiveText = context.DirectiveText; @@ -1878,11 +1948,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 +1991,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..301403b4542d 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