Skip to content

Commit 9ad9ae2

Browse files
committed
Support variables in #:project directives
1 parent 35066e5 commit 9ad9ae2

File tree

6 files changed

+229
-29
lines changed

6 files changed

+229
-29
lines changed

documentation/general/dotnet-run-file.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,12 @@ The directives are processed as follows:
245245
(because `ProjectReference` items don't support directory paths).
246246
An error is reported if zero or more than one projects are found in the directory, just like `dotnet reference add` would do.
247247

248+
Directive values support MSBuild variables (like `$(..)`) normally as they are translated literally and left to MSBuild engine to process.
249+
However, in `#:project` directives, variables might not be preserved during [grow up](#grow-up),
250+
because there is additional processing of those directives that makes it technically challenging to preserve variables in all cases
251+
(project directive values need to be resolved to be relative to the target directory
252+
and also to point to a project file rather than a directory).
253+
248254
Because these directives are limited by the C# language to only appear before the first "C# token" and any `#if`,
249255
dotnet CLI can look for them via a regex or Roslyn lexer without any knowledge of defined conditional symbols
250256
and can do that efficiently by stopping the search when it sees the first "C# token".

src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ public override int Execute()
3030

3131
// Find directives (this can fail, so do this before creating the target directory).
3232
var sourceFile = SourceFile.Load(file);
33-
var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile, reportAllErrors: !_force, DiagnosticBag.ThrowOnFirst());
33+
var diagnostics = DiagnosticBag.ThrowOnFirst();
34+
var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile, reportAllErrors: !_force, diagnostics);
3435

3536
// Create a project instance for evaluation.
3637
var projectCollection = new ProjectCollection();
@@ -42,6 +43,11 @@ public override int Execute()
4243
};
4344
var projectInstance = command.CreateProjectInstance(projectCollection);
4445

46+
// Evaluate directives.
47+
directives = VirtualProjectBuildingCommand.EvaluateDirectives(projectInstance, directives, sourceFile, diagnostics);
48+
command.Directives = directives;
49+
projectInstance = command.CreateProjectInstance(projectCollection);
50+
4551
// Find other items to copy over, e.g., default Content items like JSON files in Web apps.
4652
var includeItems = FindIncludedItems().ToList();
4753

@@ -170,17 +176,43 @@ ImmutableArray<CSharpDirective> UpdateDirectives(ImmutableArray<CSharpDirective>
170176

171177
foreach (var directive in directives)
172178
{
173-
// Fixup relative project reference paths (they need to be relative to the output directory instead of the source directory).
174-
if (directive is CSharpDirective.Project project &&
175-
!Path.IsPathFullyQualified(project.Name))
176-
{
177-
var modified = project.WithName(Path.GetRelativePath(relativeTo: targetDirectory, path: project.Name));
178-
result.Add(modified);
179-
}
180-
else
179+
// Fixup relative project reference paths (they need to be relative to the output directory instead of the source directory,
180+
// and preserve MSBuild interpolation variables like `$(..)`
181+
// while also pointing to the project file rather than a directory).
182+
if (directive is CSharpDirective.Project project)
181183
{
182-
result.Add(directive);
184+
// If the path is absolute and it has some `$(..)` vars in it,
185+
// turn it into a relative path (it might be in the form `$(ProjectDir)/../Lib`
186+
// and we don't want that to be turned into an absolute path in the converted project).
187+
if (Path.IsPathFullyQualified(project.Name))
188+
{
189+
// If the path is absolute and has no `$(..)` vars, just keep it.
190+
if (project.UnresolvedName == project.OriginalName)
191+
{
192+
result.Add(project);
193+
continue;
194+
}
195+
196+
project = project.WithName(Path.GetRelativePath(relativeTo: targetDirectory, path: project.Name));
197+
result.Add(project);
198+
continue;
199+
}
200+
201+
// If the original path is to a directory, just append the resolved file name
202+
// but preserve the variables from the original, e.g., `../$(..)/Directory/Project.csproj`.
203+
if (Directory.Exists(project.UnresolvedName))
204+
{
205+
var projectFileName = Path.GetFileName(project.Name);
206+
var slash = project.OriginalName.Where(c => c is '/' or '\\').DefaultIfEmpty(Path.DirectorySeparatorChar).First();
207+
project = project.WithName(project.OriginalName + slash + projectFileName);
208+
}
209+
210+
project = project.WithName(Path.GetRelativePath(relativeTo: targetDirectory, path: project.Name));
211+
result.Add(project);
212+
continue;
183213
}
214+
215+
result.Add(directive);
184216
}
185217

186218
return result.DrainToImmutable();

src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs

Lines changed: 98 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Collections.Immutable;
77
using System.Collections.ObjectModel;
88
using System.Diagnostics;
9+
using System.Diagnostics.CodeAnalysis;
910
using System.Security;
1011
using System.Text.Json;
1112
using System.Text.Json.Serialization;
@@ -164,14 +165,26 @@ public VirtualProjectBuildingCommand(
164165
/// </summary>
165166
public bool NoWriteBuildMarkers { get; init; }
166167

168+
private SourceFile EntryPointSourceFile
169+
{
170+
get
171+
{
172+
if (field == default)
173+
{
174+
field = SourceFile.Load(EntryPointFileFullPath);
175+
}
176+
177+
return field;
178+
}
179+
}
180+
167181
public ImmutableArray<CSharpDirective> Directives
168182
{
169183
get
170184
{
171185
if (field.IsDefault)
172186
{
173-
var sourceFile = SourceFile.Load(EntryPointFileFullPath);
174-
field = FindDirectives(sourceFile, reportAllErrors: false, DiagnosticBag.ThrowOnFirst());
187+
field = FindDirectives(EntryPointSourceFile, reportAllErrors: false, DiagnosticBag.ThrowOnFirst());
175188
Debug.Assert(!field.IsDefault);
176189
}
177190

@@ -1047,6 +1060,23 @@ public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection
10471060
private ProjectInstance CreateProjectInstance(
10481061
ProjectCollection projectCollection,
10491062
Action<IDictionary<string, string>>? addGlobalProperties)
1063+
{
1064+
var project = CreateProjectInstance(projectCollection, Directives, addGlobalProperties);
1065+
1066+
var directives = EvaluateDirectives(project, Directives, EntryPointSourceFile, DiagnosticBag.ThrowOnFirst());
1067+
if (directives != Directives)
1068+
{
1069+
Directives = directives;
1070+
project = CreateProjectInstance(projectCollection, directives, addGlobalProperties);
1071+
}
1072+
1073+
return project;
1074+
}
1075+
1076+
private ProjectInstance CreateProjectInstance(
1077+
ProjectCollection projectCollection,
1078+
ImmutableArray<CSharpDirective> directives,
1079+
Action<IDictionary<string, string>>? addGlobalProperties)
10501080
{
10511081
var projectRoot = CreateProjectRootElement(projectCollection);
10521082

@@ -1069,7 +1099,7 @@ ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
10691099
var projectFileWriter = new StringWriter();
10701100
WriteProjectFile(
10711101
projectFileWriter,
1072-
Directives,
1102+
directives,
10731103
isVirtualProject: true,
10741104
targetFilePath: EntryPointFileFullPath,
10751105
artifactsPath: ArtifactsPath,
@@ -1604,6 +1634,28 @@ static bool Fill(ref WhiteSpaceInfo info, in SyntaxTriviaList triviaList, int in
16041634
}
16051635
}
16061636

1637+
/// <summary>
1638+
/// If there are any <c>#:project</c> <paramref name="directives"/>, expand <c>$()</c> in them and then resolve the project paths.
1639+
/// </summary>
1640+
public static ImmutableArray<CSharpDirective> EvaluateDirectives(
1641+
ProjectInstance? project,
1642+
ImmutableArray<CSharpDirective> directives,
1643+
SourceFile sourceFile,
1644+
DiagnosticBag diagnostics)
1645+
{
1646+
if (directives.OfType<CSharpDirective.Project>().Any())
1647+
{
1648+
return directives
1649+
.Select(d => d is CSharpDirective.Project p
1650+
? (project is null ? p : p.WithName(project.ExpandString(p.Name)))
1651+
.ResolveProjectPath(sourceFile, diagnostics)
1652+
: d)
1653+
.ToImmutableArray();
1654+
}
1655+
1656+
return directives;
1657+
}
1658+
16071659
public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
16081660
{
16091661
if (directives.Length == 0)
@@ -1882,8 +1934,26 @@ public sealed class Package(in ParseInfo info) : Named(info)
18821934
/// <summary>
18831935
/// <c>#:project</c> directive.
18841936
/// </summary>
1885-
public sealed class Project(in ParseInfo info) : Named(info)
1937+
public sealed class Project : Named
18861938
{
1939+
[SetsRequiredMembers]
1940+
public Project(in ParseInfo info, string name) : base(info)
1941+
{
1942+
Name = name;
1943+
OriginalName = name;
1944+
UnresolvedName = name;
1945+
}
1946+
1947+
/// <summary>
1948+
/// Preserved across <see cref="WithName"/> calls.
1949+
/// </summary>
1950+
public required string OriginalName { get; init; }
1951+
1952+
/// <summary>
1953+
/// Preserved across <see cref="ResolveProjectPath"/> calls.
1954+
/// </summary>
1955+
public required string UnresolvedName { get; init; }
1956+
18871957
public static new Project? Parse(in ParseContext context)
18881958
{
18891959
var directiveText = context.DirectiveText;
@@ -1893,11 +1963,32 @@ public sealed class Project(in ParseInfo info) : Named(info)
18931963
return context.Diagnostics.AddError<Project?>(context.SourceFile, context.Info.Span, string.Format(CliCommandStrings.MissingDirectiveName, directiveKind));
18941964
}
18951965

1966+
return new Project(context.Info, directiveText);
1967+
}
1968+
1969+
public Project WithName(string name, bool preserveUnresolvedName = false)
1970+
{
1971+
return name == Name
1972+
? this
1973+
: new Project(Info, name)
1974+
{
1975+
OriginalName = OriginalName,
1976+
UnresolvedName = preserveUnresolvedName ? UnresolvedName : name,
1977+
};
1978+
}
1979+
1980+
/// <summary>
1981+
/// If the directive points to a directory, returns a new directive pointing to the corresponding project file.
1982+
/// </summary>
1983+
public Project ResolveProjectPath(SourceFile sourceFile, DiagnosticBag diagnostics)
1984+
{
1985+
var directiveText = Name;
1986+
18961987
try
18971988
{
18981989
// If the path is a directory like '../lib', transform it to a project file path like '../lib/lib.csproj'.
18991990
// Also normalize blackslashes to forward slashes to ensure the directive works on all platforms.
1900-
var sourceDirectory = Path.GetDirectoryName(context.SourceFile.Path) ?? ".";
1991+
var sourceDirectory = Path.GetDirectoryName(sourceFile.Path) ?? ".";
19011992
var resolvedProjectPath = Path.Combine(sourceDirectory, directiveText.Replace('\\', '/'));
19021993
if (Directory.Exists(resolvedProjectPath))
19031994
{
@@ -1915,18 +2006,10 @@ public sealed class Project(in ParseInfo info) : Named(info)
19152006
}
19162007
catch (GracefulException e)
19172008
{
1918-
context.Diagnostics.AddError(context.SourceFile, context.Info.Span, string.Format(CliCommandStrings.InvalidProjectDirective, e.Message), e);
2009+
diagnostics.AddError(sourceFile, Info.Span, string.Format(CliCommandStrings.InvalidProjectDirective, e.Message), e);
19192010
}
19202011

1921-
return new Project(context.Info)
1922-
{
1923-
Name = directiveText,
1924-
};
1925-
}
1926-
1927-
public Project WithName(string name)
1928-
{
1929-
return new Project(Info) { Name = name };
2012+
return WithName(directiveText, preserveUnresolvedName: true);
19302013
}
19312014

19322015
public override string ToString() => $"#:project {Name}";

test/dotnet.Tests/CommandTests/Project/Convert/DotnetProjectConvertTests.cs

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ public void SameAsTemplate()
7777
[InlineData(".", "Lib", "./Lib", "Project", "../Lib/lib.csproj")]
7878
[InlineData(".", "Lib", "Lib/../Lib", "Project", "../Lib/lib.csproj")]
7979
[InlineData("File", "Lib", "../Lib", "File/Project", "../../Lib/lib.csproj")]
80-
[InlineData("File", "Lib", "..\\Lib", "File/Project", "../../Lib/lib.csproj")]
80+
[InlineData("File", "Lib", @"..\Lib", "File/Project", "../../Lib/lib.csproj")]
81+
[InlineData("File", "Lib", "../$(LibProjectName)", "File/Project", "../../$(LibProjectName)/lib.csproj")]
82+
[InlineData("File", "Lib", @"..\$(LibProjectName)", "File/Project", @"../../$(LibProjectName)\lib.csproj")]
83+
[InlineData("File", "Lib", "$(MSBuildProjectDirectory)/../$(LibProjectName)", "File/Project", "../../Lib/lib.csproj")]
8184
public void ProjectReference_RelativePaths(string fileDir, string libraryDir, string reference, string outputDir, string convertedReference)
8285
{
8386
var testInstance = _testAssetsManager.CreateTestDirectory();
@@ -105,6 +108,7 @@ public static void M()
105108
Directory.CreateDirectory(fileDirFullPath);
106109
File.WriteAllText(Path.Join(fileDirFullPath, "app.cs"), $"""
107110
#:project {reference}
111+
#:property LibProjectName=Lib
108112
C.M();
109113
""");
110114

@@ -191,6 +195,64 @@ public static void M()
191195
""");
192196
}
193197

198+
[Fact]
199+
public void ProjectReference_FullPath_WithVars()
200+
{
201+
var testInstance = _testAssetsManager.CreateTestDirectory();
202+
203+
var libraryDirFullPath = Path.Join(testInstance.Path, "Lib");
204+
Directory.CreateDirectory(libraryDirFullPath);
205+
File.WriteAllText(Path.Join(libraryDirFullPath, "lib.cs"), """
206+
public static class C
207+
{
208+
public static void M()
209+
{
210+
System.Console.WriteLine("Hello from library");
211+
}
212+
}
213+
""");
214+
File.WriteAllText(Path.Join(libraryDirFullPath, "lib.csproj"), $"""
215+
<Project Sdk="Microsoft.NET.Sdk">
216+
<PropertyGroup>
217+
<TargetFramework>{ToolsetInfo.CurrentTargetFramework}</TargetFramework>
218+
</PropertyGroup>
219+
</Project>
220+
""");
221+
222+
var fileDirFullPath = Path.Join(testInstance.Path, "File");
223+
Directory.CreateDirectory(fileDirFullPath);
224+
File.WriteAllText(Path.Join(fileDirFullPath, "app.cs"), $"""
225+
#:project {fileDirFullPath}/../$(LibProjectName)
226+
#:property LibProjectName=Lib
227+
C.M();
228+
""");
229+
230+
var expectedOutput = "Hello from library";
231+
232+
new DotnetCommand(Log, "run", "app.cs")
233+
.WithWorkingDirectory(fileDirFullPath)
234+
.Execute()
235+
.Should().Pass()
236+
.And.HaveStdOut(expectedOutput);
237+
238+
var outputDirFullPath = Path.Join(testInstance.Path, "File/Project");
239+
new DotnetCommand(Log, "project", "convert", "app.cs", "-o", outputDirFullPath)
240+
.WithWorkingDirectory(fileDirFullPath)
241+
.Execute()
242+
.Should().Pass();
243+
244+
new DotnetCommand(Log, "run")
245+
.WithWorkingDirectory(outputDirFullPath)
246+
.Execute()
247+
.Should().Pass()
248+
.And.HaveStdOut(expectedOutput);
249+
250+
File.ReadAllText(Path.Join(outputDirFullPath, "app.csproj"))
251+
.Should().Contain($"""
252+
<ProjectReference Include="{"../../Lib/lib.csproj".Replace('/', Path.DirectorySeparatorChar)}" />
253+
""");
254+
}
255+
194256
[Fact]
195257
public void DirectoryAlreadyExists()
196258
{
@@ -1551,7 +1613,9 @@ public void Directives_VersionedSdkFirst()
15511613
private static void Convert(string inputCSharp, out string actualProject, out string? actualCSharp, bool force, string? filePath)
15521614
{
15531615
var sourceFile = new SourceFile(filePath ?? "/app/Program.cs", SourceText.From(inputCSharp, Encoding.UTF8));
1554-
var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile, reportAllErrors: !force, DiagnosticBag.ThrowOnFirst());
1616+
var diagnostics = DiagnosticBag.ThrowOnFirst();
1617+
var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile, reportAllErrors: !force, diagnostics);
1618+
directives = VirtualProjectBuildingCommand.EvaluateDirectives(project: null, directives, sourceFile, diagnostics);
15551619
var projectWriter = new StringWriter();
15561620
VirtualProjectBuildingCommand.WriteProjectFile(projectWriter, directives, isVirtualProject: false);
15571621
actualProject = projectWriter.ToString();

0 commit comments

Comments
 (0)