diff --git a/documentation/general/dotnet-run-file.md b/documentation/general/dotnet-run-file.md index a14132092ee1..77ea47410b0f 100644 --- a/documentation/general/dotnet-run-file.md +++ b/documentation/general/dotnet-run-file.md @@ -248,6 +248,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/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs index 22fd4000c0ff..1157bc586313 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs @@ -6,12 +6,14 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using System.Xml; +using Microsoft.Build.Execution; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -225,6 +227,30 @@ static bool Fill(ref WhiteSpaceInfo info, in SyntaxTriviaList triviaList, int in } } } + + /// + /// If there are any #:project , expands $() in them and ensures they point to project files (not directories). + /// + 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), CSharpDirective.Project.NameKind.Expanded)) + .EnsureProjectFilePath(sourceFile, diagnostics) + : d) + .ToImmutableArray(); + } + + return directives; + } } internal readonly record struct SourceFile(string Path, SourceText Text) @@ -457,8 +483,32 @@ 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; + } + + /// + /// Preserved across calls, i.e., + /// this is the original directive text as entered by the user. + /// + public string OriginalName { get; init; } + + /// + /// This is the with MSBuild $(..) vars expanded (via ). + /// + public string? ExpandedName { get; init; } + + /// + /// This is the resolved via + /// (i.e., this is a file path if the original text pointed to a directory). + /// + public string? ProjectFilePath { get; init; } + public static new Project? Parse(in ParseContext context) { var directiveText = context.DirectiveText; @@ -468,19 +518,57 @@ public sealed class Project(in ParseInfo info) : Named(info) return context.Diagnostics.AddError(context.SourceFile, context.Info.Span, string.Format(FileBasedProgramsResources.MissingDirectiveName, directiveKind)); } + return new Project(context.Info, directiveText); + } + + public enum NameKind + { + /// + /// Change and . + /// + Expanded = 1, + + /// + /// Change and . + /// + ProjectFilePath = 2, + + /// + /// Change only . + /// + Final = 3, + } + + public Project WithName(string name, NameKind kind) + { + return new Project(Info, name) + { + OriginalName = OriginalName, + ExpandedName = kind == NameKind.Expanded ? name : ExpandedName, + ProjectFilePath = kind == NameKind.ProjectFilePath ? name : ProjectFilePath, + }; + } + + /// + /// If the directive points to a directory, returns a new directive pointing to the corresponding project file. + /// + public Project EnsureProjectFilePath(SourceFile sourceFile, DiagnosticBag diagnostics) + { + var resolvedName = 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. - // https://github.com/dotnet/sdk/issues/51487: Behavior should not depend on process current directory - var sourceDirectory = Path.GetDirectoryName(context.SourceFile.Path) ?? "."; - var resolvedProjectPath = Path.Combine(sourceDirectory, directiveText.Replace('\\', '/')); + // Also normalize backslashes to forward slashes to ensure the directive works on all platforms. + var sourceDirectory = Path.GetDirectoryName(sourceFile.Path) + ?? throw new InvalidOperationException($"Source file path '{sourceFile.Path}' does not have a containing directory."); + var resolvedProjectPath = Path.Combine(sourceDirectory, resolvedName.Replace('\\', '/')); if (Directory.Exists(resolvedProjectPath)) { var fullFilePath = GetProjectFileFromDirectory(resolvedProjectPath).FullName; // Keep a relative path only if the original directive was a relative path. - directiveText = ExternalHelpers.IsPathFullyQualified(directiveText) + resolvedName = ExternalHelpers.IsPathFullyQualified(resolvedName) ? fullFilePath : ExternalHelpers.GetRelativePath(relativeTo: sourceDirectory, fullFilePath); } @@ -491,22 +579,14 @@ public sealed class Project(in ParseInfo info) : Named(info) } catch (GracefulException e) { - context.Diagnostics.AddError(context.SourceFile, context.Info.Span, string.Format(FileBasedProgramsResources.InvalidProjectDirective, e.Message), e); + diagnostics.AddError(sourceFile, Info.Span, string.Format(FileBasedProgramsResources.InvalidProjectDirective, e.Message), e); } - return new Project(context.Info) - { - Name = directiveText, - }; - } - - public Project WithName(string name) - { - return new Project(Info) { Name = name }; + return WithName(resolvedName, NameKind.ProjectFilePath); } // https://github.com/dotnet/sdk/issues/51487: Delete copies of methods from MsbuildProject and MSBuildUtilities from the source package, sharing the original method(s) under src/Cli instead. - public static FileInfo GetProjectFileFromDirectory(string projectDirectory) + private static FileInfo GetProjectFileFromDirectory(string projectDirectory) { DirectoryInfo dir; try diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt b/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt index 84565c88284c..fad31237acf9 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt @@ -30,8 +30,19 @@ Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.Span.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.TrailingWhiteSpace.get -> Microsoft.DotNet.FileBasedPrograms.WhiteSpaceInfo Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.TrailingWhiteSpace.init -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project -Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.Project(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void -Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.WithName(string! name) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.EnsureProjectFilePath(Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, Microsoft.DotNet.FileBasedPrograms.DiagnosticBag diagnostics) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ExpandedName.get -> string? +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ExpandedName.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind.Expanded = 1 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind.Final = 3 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind.ProjectFilePath = 2 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.OriginalName.get -> string! +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.OriginalName.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.Project(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info, string! name) -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ProjectFilePath.get -> string? +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ProjectFilePath.init -> void +Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.WithName(string! name, Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind kind) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project! Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Property(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Value.get -> string! @@ -102,7 +113,6 @@ override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Shebang.ToString() - override Microsoft.DotNet.FileBasedPrograms.SourceFile.GetHashCode() -> int static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package? static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named? -static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.GetProjectFileFromDirectory(string! projectDirectory) -> System.IO.FileInfo! static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project? static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property? static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Sdk? @@ -113,6 +123,7 @@ static Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.CombineHashCodes(int v static Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.GetRelativePath(string! relativeTo, string! path) -> string! static Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.IsPathFullyQualified(string! path) -> bool static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.CreateTokenizer(Microsoft.CodeAnalysis.Text.SourceText! text) -> Microsoft.CodeAnalysis.CSharp.SyntaxTokenParser! +static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.EvaluateDirectives(Microsoft.Build.Execution.ProjectInstance? project, System.Collections.Immutable.ImmutableArray directives, Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, Microsoft.DotNet.FileBasedPrograms.DiagnosticBag diagnostics) -> System.Collections.Immutable.ImmutableArray static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.FindDirectives(Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, bool reportAllErrors, Microsoft.DotNet.FileBasedPrograms.DiagnosticBag diagnostics) -> System.Collections.Immutable.ImmutableArray static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.FindLeadingDirectives(Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, Microsoft.CodeAnalysis.SyntaxTriviaList triviaList, Microsoft.DotNet.FileBasedPrograms.DiagnosticBag diagnostics, System.Collections.Immutable.ImmutableArray.Builder? builder) -> void static Microsoft.DotNet.FileBasedPrograms.MSBuildUtilities.ConvertStringToBool(string? parameterValue, bool defaultValue = false) -> bool diff --git a/src/Cli/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.Package.csproj b/src/Cli/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.Package.csproj index ead3a31bed97..c0578c734abe 100644 --- a/src/Cli/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.Package.csproj +++ b/src/Cli/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.Package.csproj @@ -26,7 +26,8 @@ - + + diff --git a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs index e41367a34fd0..6acf2b7cc63b 100644 --- a/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs +++ b/src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.CommandLine; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Microsoft.Build.Evaluation; using Microsoft.DotNet.Cli.Commands.Run; @@ -31,7 +32,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 = FileLevelDirectiveHelpers.FindDirectives(sourceFile, reportAllErrors: !_force, DiagnosticBag.ThrowOnFirst()); + var diagnostics = DiagnosticBag.ThrowOnFirst(); + var directives = FileLevelDirectiveHelpers.FindDirectives(sourceFile, reportAllErrors: !_force, diagnostics); // Create a project instance for evaluation. var projectCollection = new ProjectCollection(); @@ -43,6 +45,11 @@ public override int Execute() }; var projectInstance = command.CreateProjectInstance(projectCollection); + // Evaluate directives. + directives = FileLevelDirectiveHelpers.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(); @@ -167,21 +174,58 @@ void CopyFile(string source, string target) ImmutableArray UpdateDirectives(ImmutableArray directives) { + var sourceDirectory = Path.GetDirectoryName(file)!; + var result = ImmutableArray.CreateBuilder(directives.Length); 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); + Debug.Assert(project.ExpandedName != null && project.ProjectFilePath != null && project.ProjectFilePath == project.Name); + + if (Path.IsPathFullyQualified(project.Name)) + { + // If the path is absolute and has no `$(..)` vars, just keep it. + if (project.ExpandedName == 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), CSharpDirective.Project.NameKind.Final); + 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(Path.Combine(sourceDirectory, project.ExpandedName))) + { + var projectFileName = Path.GetFileName(project.Name); + project = project.WithName(Path.Join(project.OriginalName, projectFileName), CSharpDirective.Project.NameKind.Final); + } + + project = project.WithName(Path.GetRelativePath(relativeTo: targetDirectory, path: Path.Combine(sourceDirectory, project.Name)), CSharpDirective.Project.NameKind.Final); + 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 c6ab42bbc498..ddd68ad109f5 100644 --- a/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs +++ b/src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Frozen; using System.Collections.Immutable; using System.Collections.ObjectModel; @@ -9,7 +8,6 @@ using System.Security; using System.Text.Json; using System.Text.Json.Serialization; -using System.Text.RegularExpressions; using System.Xml; using Microsoft.Build.Construction; using Microsoft.Build.Definition; @@ -19,8 +17,6 @@ using Microsoft.Build.Logging; using Microsoft.Build.Logging.SimpleErrorLogger; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; using Microsoft.DotNet.Cli.Commands.Clean.FileBasedAppArtifacts; using Microsoft.DotNet.Cli.Commands.Restore; @@ -165,14 +161,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 = FileLevelDirectiveHelpers.FindDirectives(sourceFile, reportAllErrors: false, DiagnosticBag.ThrowOnFirst()); + field = FileLevelDirectiveHelpers.FindDirectives(EntryPointSourceFile, reportAllErrors: false, DiagnosticBag.ThrowOnFirst()); Debug.Assert(!field.IsDefault); } @@ -1050,6 +1058,23 @@ public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection private ProjectInstance CreateProjectInstance( ProjectCollection projectCollection, Action>? addGlobalProperties) + { + var project = CreateProjectInstance(projectCollection, Directives, addGlobalProperties); + + var directives = FileLevelDirectiveHelpers.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); @@ -1072,7 +1097,7 @@ ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection) var projectFileWriter = new StringWriter(); WriteProjectFile( projectFileWriter, - Directives, + directives, isVirtualProject: true, targetFilePath: EntryPointFileFullPath, artifactsPath: ArtifactsPath, diff --git a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs index 8532c6231b9f..14beaed209a7 100644 --- a/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs +++ b/test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs @@ -75,12 +75,17 @@ 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")] - public void ProjectReference_RelativePaths(string fileDir, string libraryDir, string reference, string outputDir, string convertedReference) + [InlineData("File", "File", "Lib", "../Lib", "Project", "..{/}Lib{/}lib.csproj")] + [InlineData(".", ".", "Lib", "./Lib", "Project", "..{/}Lib{/}lib.csproj")] + [InlineData(".", ".", "Lib", "Lib/../Lib", "Project", "..{/}Lib{/}lib.csproj")] + [InlineData("File", "File", "Lib", "../Lib", "File/Project", "..{/}..{/}Lib{/}lib.csproj")] + [InlineData(".", "File", "Lib", "../Lib", "File/Project", "..{/}..{/}Lib{/}lib.csproj")] + [InlineData("File", "File", "Lib", @"..\Lib", "File/Project", @"..{/}..\Lib{/}lib.csproj")] + [InlineData("File", "File", "Lib", "../$(LibProjectName)", "File/Project", "..{/}..{/}$(LibProjectName){/}lib.csproj")] + [InlineData(".", "File", "Lib", "../$(LibProjectName)", "File/Project", "..{/}..{/}$(LibProjectName){/}lib.csproj")] + [InlineData("File", "File", "Lib", @"..\$(LibProjectName)", "File/Project", @"..{/}..\$(LibProjectName){/}lib.csproj")] + [InlineData("File", "File", "Lib", "$(MSBuildProjectDirectory)/../$(LibProjectName)", "File/Project", "..{/}..{/}Lib{/}lib.csproj")] + public void ProjectReference_RelativePaths(string workingDir, string fileDir, string libraryDir, string reference, string outputDir, string convertedReference) { var testInstance = _testAssetsManager.CreateTestDirectory(); @@ -105,22 +110,26 @@ public static void M() var fileDirFullPath = Path.Join(testInstance.Path, fileDir); Directory.CreateDirectory(fileDirFullPath); - File.WriteAllText(Path.Join(fileDirFullPath, "app.cs"), $""" + var fileFullPath = Path.Join(fileDirFullPath, "app.cs"); + File.WriteAllText(fileFullPath, $""" #:project {reference} + #:property LibProjectName=Lib C.M(); """); var expectedOutput = "Hello from library"; + var workingDirFullPath = Path.Join(testInstance.Path, workingDir); + var fileRelativePath = Path.GetRelativePath(relativeTo: workingDirFullPath, path: fileFullPath); - new DotnetCommand(Log, "run", "app.cs") - .WithWorkingDirectory(fileDirFullPath) + new DotnetCommand(Log, "run", fileRelativePath) + .WithWorkingDirectory(workingDirFullPath) .Execute() .Should().Pass() .And.HaveStdOut(expectedOutput); var outputDirFullPath = Path.Join(testInstance.Path, outputDir); - new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath) - .WithWorkingDirectory(fileDirFullPath) + new DotnetCommand(Log, "project", "convert", fileRelativePath, "-o", outputDirFullPath) + .WithWorkingDirectory(workingDirFullPath) .Execute() .Should().Pass(); @@ -132,7 +141,7 @@ public static void M() File.ReadAllText(Path.Join(outputDirFullPath, "app.csproj")) .Should().Contain($""" - + """); } @@ -193,6 +202,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() { @@ -1631,6 +1698,7 @@ private static void Convert(string inputCSharp, out string actualProject, out st actualDiagnostics = null; var diagnosticBag = collectDiagnostics ? DiagnosticBag.Collect(out actualDiagnostics) : DiagnosticBag.ThrowOnFirst(); var directives = FileLevelDirectiveHelpers.FindDirectives(sourceFile, reportAllErrors: !force, diagnosticBag); + directives = FileLevelDirectiveHelpers.EvaluateDirectives(project: null, directives, sourceFile, diagnosticBag); 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 5d7b53c65a3e..1eebfddaef95 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunFileTests.cs @@ -2488,6 +2488,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(); @@ -2516,64 +2518,148 @@ public class LibClass File.WriteAllText(Path.Join(appDir, "Program.cs"), $""" #:project {arg} + #:property LibProjectName=Lib Console.WriteLine(Lib.LibClass.GetMessage()); """); + var expectedOutput = "Hello from Lib"; + new DotnetCommand(Log, "run", "Program.cs") .WithWorkingDirectory(appDir) .Execute() .Should().Pass() - .And.HaveStdOut("Hello from Lib"); + .And.HaveStdOut(expectedOutput); + + // Running from a different working directory shouldn't affect handling of the relative project paths. + new DotnetCommand(Log, "run", "App/Program.cs") + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut(expectedOutput); } - [Fact] - public void ProjectReference_Errors() + [Theory] + [InlineData(null)] + [InlineData("app")] + public void ProjectReference_Errors(string? subdir) { var testInstance = _testAssetsManager.CreateTestDirectory(); - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + var relativeFilePath = Path.Join(subdir, "Program.cs"); + var filePath = Path.Join(testInstance.Path, relativeFilePath); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, """ #:project wrong.csproj """); // Project file does not exist. - new DotnetCommand(Log, "run", "Program.cs") + new DotnetCommand(Log, "run", relativeFilePath) .WithWorkingDirectory(testInstance.Path) .Execute() .Should().Fail() - .And.HaveStdErrContaining(DirectiveError(Path.Join(testInstance.Path, "Program.cs"), 1, FileBasedProgramsResources.InvalidProjectDirective, - string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, Path.Join(testInstance.Path, "wrong.csproj")))); + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, + string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, Path.Join(testInstance.Path, subdir, "wrong.csproj")))); - File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """ + File.WriteAllText(filePath, """ #:project dir/ """); // Project directory does not exist. - new DotnetCommand(Log, "run", "Program.cs") + new DotnetCommand(Log, "run", relativeFilePath) .WithWorkingDirectory(testInstance.Path) .Execute() .Should().Fail() - .And.HaveStdErrContaining(DirectiveError(Path.Join(testInstance.Path, "Program.cs"), 1, FileBasedProgramsResources.InvalidProjectDirective, - string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, Path.Join(testInstance.Path, "dir/")))); + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, + string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, Path.Join(testInstance.Path, subdir, "dir/")))); - Directory.CreateDirectory(Path.Join(testInstance.Path, "dir")); + Directory.CreateDirectory(Path.Join(testInstance.Path, subdir, "dir")); // Directory exists but has no project file. - new DotnetCommand(Log, "run", "Program.cs") + new DotnetCommand(Log, "run", relativeFilePath) .WithWorkingDirectory(testInstance.Path) .Execute() .Should().Fail() - .And.HaveStdErrContaining(DirectiveError(Path.Join(testInstance.Path, "Program.cs"), 1, FileBasedProgramsResources.InvalidProjectDirective, - string.Format(FileBasedProgramsResources.CouldNotFindAnyProjectInDirectory, Path.Join(testInstance.Path, "dir/")))); + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, + string.Format(FileBasedProgramsResources.CouldNotFindAnyProjectInDirectory, Path.Join(testInstance.Path, subdir, "dir/")))); - File.WriteAllText(Path.Join(testInstance.Path, "dir", "proj1.csproj"), ""); - File.WriteAllText(Path.Join(testInstance.Path, "dir", "proj2.csproj"), ""); + File.WriteAllText(Path.Join(testInstance.Path, subdir, "dir", "proj1.csproj"), ""); + File.WriteAllText(Path.Join(testInstance.Path, subdir, "dir", "proj2.csproj"), ""); // Directory exists but has multiple project files. - new DotnetCommand(Log, "run", "Program.cs") + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, + string.Format(FileBasedProgramsResources.MoreThanOneProjectInDirectory, Path.Join(testInstance.Path, subdir, "dir/")))); + + // Malformed MSBuild variable syntax. + File.WriteAllText(filePath, """ + #:project $(Test + """); + + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Fail() + .And.HaveStdErrContaining(DirectiveError(filePath, 1, FileBasedProgramsResources.InvalidProjectDirective, + string.Format(FileBasedProgramsResources.CouldNotFindProjectOrDirectory, Path.Join(testInstance.Path, subdir, "$(Test")))); + } + + [Theory] + [InlineData(null)] + [InlineData("app")] + public void ProjectReference_Duplicate(string? subdir) + { + var testInstance = _testAssetsManager.CreateTestDirectory(); + var relativeFilePath = Path.Join(subdir, "Program.cs"); + var filePath = Path.Join(testInstance.Path, relativeFilePath); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + Directory.CreateDirectory(Path.Join(testInstance.Path, subdir, "dir")); + File.WriteAllText(Path.Join(testInstance.Path, subdir, "dir", "proj1.csproj"), $""" + + + {ToolsetInfo.CurrentTargetFramework} + + + """); + + File.WriteAllText(filePath, """ + #:project dir/ + #:project dir/ + Console.WriteLine("Hello"); + """); + + new DotnetCommand(Log, "run", relativeFilePath) .WithWorkingDirectory(testInstance.Path) .Execute() .Should().Fail() - .And.HaveStdErrContaining(DirectiveError(Path.Join(testInstance.Path, "Program.cs"), 1, FileBasedProgramsResources.InvalidProjectDirective, - string.Format(FileBasedProgramsResources.MoreThanOneProjectInDirectory, Path.Join(testInstance.Path, "dir/")))); + .And.HaveStdErrContaining(DirectiveError(filePath, 2, FileBasedProgramsResources.DuplicateDirective, "#:project dir/")); + + File.WriteAllText(filePath, """ + #:project dir/ + #:project dir/proj1.csproj + Console.WriteLine("Hello"); + """); + + // https://github.com/dotnet/sdk/issues/51139: we should detect the duplicate project reference + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello"); + + File.WriteAllText(filePath, """ + #:project dir/ + #:project $(MSBuildProjectDirectory)/dir/ + Console.WriteLine("Hello"); + """); + + // https://github.com/dotnet/sdk/issues/51139: we should detect the duplicate project reference + new DotnetCommand(Log, "run", relativeFilePath) + .WithWorkingDirectory(testInstance.Path) + .Execute() + .Should().Pass() + .And.HaveStdOut("Hello"); } [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 edaf986abdfa..db164163b90c 100644 --- a/test/dotnet.Tests/CommandTests/Run/RunTelemetryTests.cs +++ b/test/dotnet.Tests/CommandTests/Run/RunTelemetryTests.cs @@ -115,8 +115,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