Skip to content

Commit bc17aea

Browse files
committed
Support variables in #:project directives
1 parent a73f745 commit bc17aea

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

@@ -169,17 +175,43 @@ ImmutableArray<CSharpDirective> UpdateDirectives(ImmutableArray<CSharpDirective>
169175

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

185217
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,
@@ -1589,6 +1619,28 @@ static bool Fill(ref WhiteSpaceInfo info, in SyntaxTriviaList triviaList, int in
15891619
}
15901620
}
15911621

1622+
/// <summary>
1623+
/// If there are any <c>#:project</c> <paramref name="directives"/>, expand <c>$()</c> in them and then resolve the project paths.
1624+
/// </summary>
1625+
public static ImmutableArray<CSharpDirective> EvaluateDirectives(
1626+
ProjectInstance? project,
1627+
ImmutableArray<CSharpDirective> directives,
1628+
SourceFile sourceFile,
1629+
DiagnosticBag diagnostics)
1630+
{
1631+
if (directives.OfType<CSharpDirective.Project>().Any())
1632+
{
1633+
return directives
1634+
.Select(d => d is CSharpDirective.Project p
1635+
? (project is null ? p : p.WithName(project.ExpandString(p.Name)))
1636+
.ResolveProjectPath(sourceFile, diagnostics)
1637+
: d)
1638+
.ToImmutableArray();
1639+
}
1640+
1641+
return directives;
1642+
}
1643+
15921644
public static SourceText? RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text)
15931645
{
15941646
if (directives.Length == 0)
@@ -1867,8 +1919,26 @@ public sealed class Package(in ParseInfo info) : Named(info)
18671919
/// <summary>
18681920
/// <c>#:project</c> directive.
18691921
/// </summary>
1870-
public sealed class Project(in ParseInfo info) : Named(info)
1922+
public sealed class Project : Named
18711923
{
1924+
[SetsRequiredMembers]
1925+
public Project(in ParseInfo info, string name) : base(info)
1926+
{
1927+
Name = name;
1928+
OriginalName = name;
1929+
UnresolvedName = name;
1930+
}
1931+
1932+
/// <summary>
1933+
/// Preserved across <see cref="WithName"/> calls.
1934+
/// </summary>
1935+
public required string OriginalName { get; init; }
1936+
1937+
/// <summary>
1938+
/// Preserved across <see cref="ResolveProjectPath"/> calls.
1939+
/// </summary>
1940+
public required string UnresolvedName { get; init; }
1941+
18721942
public static new Project? Parse(in ParseContext context)
18731943
{
18741944
var directiveText = context.DirectiveText;
@@ -1878,11 +1948,32 @@ public sealed class Project(in ParseInfo info) : Named(info)
18781948
return context.Diagnostics.AddError<Project?>(context.SourceFile, context.Info.Span, string.Format(CliCommandStrings.MissingDirectiveName, directiveKind));
18791949
}
18801950

1951+
return new Project(context.Info, directiveText);
1952+
}
1953+
1954+
public Project WithName(string name, bool preserveUnresolvedName = false)
1955+
{
1956+
return name == Name
1957+
? this
1958+
: new Project(Info, name)
1959+
{
1960+
OriginalName = OriginalName,
1961+
UnresolvedName = preserveUnresolvedName ? UnresolvedName : name,
1962+
};
1963+
}
1964+
1965+
/// <summary>
1966+
/// If the directive points to a directory, returns a new directive pointing to the corresponding project file.
1967+
/// </summary>
1968+
public Project ResolveProjectPath(SourceFile sourceFile, DiagnosticBag diagnostics)
1969+
{
1970+
var directiveText = Name;
1971+
18811972
try
18821973
{
18831974
// If the path is a directory like '../lib', transform it to a project file path like '../lib/lib.csproj'.
18841975
// Also normalize blackslashes to forward slashes to ensure the directive works on all platforms.
1885-
var sourceDirectory = Path.GetDirectoryName(context.SourceFile.Path) ?? ".";
1976+
var sourceDirectory = Path.GetDirectoryName(sourceFile.Path) ?? ".";
18861977
var resolvedProjectPath = Path.Combine(sourceDirectory, directiveText.Replace('\\', '/'));
18871978
if (Directory.Exists(resolvedProjectPath))
18881979
{
@@ -1900,18 +1991,10 @@ public sealed class Project(in ParseInfo info) : Named(info)
19001991
}
19011992
catch (GracefulException e)
19021993
{
1903-
context.Diagnostics.AddError(context.SourceFile, context.Info.Span, string.Format(CliCommandStrings.InvalidProjectDirective, e.Message), e);
1994+
diagnostics.AddError(sourceFile, Info.Span, string.Format(CliCommandStrings.InvalidProjectDirective, e.Message), e);
19041995
}
19051996

1906-
return new Project(context.Info)
1907-
{
1908-
Name = directiveText,
1909-
};
1910-
}
1911-
1912-
public Project WithName(string name)
1913-
{
1914-
return new Project(Info) { Name = name };
1997+
return WithName(directiveText, preserveUnresolvedName: true);
19151998
}
19161999

19172000
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)