Skip to content

Commit d04687c

Browse files
jjonesczCopilot
andauthored
Support variables in #:project directives (#51108)
Co-authored-by: Copilot <[email protected]>
1 parent 3290040 commit d04687c

File tree

9 files changed

+397
-72
lines changed

9 files changed

+397
-72
lines changed

documentation/general/dotnet-run-file.md

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

251+
Directive values support MSBuild variables (like `$(..)`) normally as they are translated literally and left to MSBuild engine to process.
252+
However, in `#:project` directives, variables might not be preserved during [grow up](#grow-up),
253+
because there is additional processing of those directives that makes it technically challenging to preserve variables in all cases
254+
(project directive values need to be resolved to be relative to the target directory
255+
and also to point to a project file rather than a directory).
256+
Note that it is not expected that variables inside the path change their meaning during the conversion,
257+
so for example `#:project ../$(LibName)` is translated to `<ProjectReference Include="../../$(LibName)/Lib.csproj" />` (i.e., the variable is preserved).
258+
However, variables at the start can change, so for example `#:project $(ProjectDir)../Lib` is translated to `<ProjectReference Include="../../Lib/Lib.csproj" />` (i.e., the variable is expanded).
259+
In other directives, all variables are preserved during conversion.
260+
251261
Because these directives are limited by the C# language to only appear before the first "C# token" and any `#if`,
252262
dotnet CLI can look for them via a regex or Roslyn lexer without any knowledge of defined conditional symbols
253263
and can do that efficiently by stopping the search when it sees the first "C# token".

src/Cli/Microsoft.DotNet.FileBasedPrograms/FileLevelDirectiveHelpers.cs

Lines changed: 97 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
using System.Collections.Generic;
77
using System.Collections.Immutable;
88
using System.Diagnostics;
9+
using System.Diagnostics.CodeAnalysis;
910
using System.IO;
1011
using System.Linq;
1112
using System.Text;
1213
using System.Text.Json.Serialization;
1314
using System.Text.RegularExpressions;
1415
using System.Xml;
16+
using Microsoft.Build.Execution;
1517
using Microsoft.CodeAnalysis;
1618
using Microsoft.CodeAnalysis.CSharp;
1719
using Microsoft.CodeAnalysis.CSharp.Syntax;
@@ -225,6 +227,30 @@ static bool Fill(ref WhiteSpaceInfo info, in SyntaxTriviaList triviaList, int in
225227
}
226228
}
227229
}
230+
231+
/// <summary>
232+
/// If there are any <c>#:project</c> <paramref name="directives"/>, expands <c>$()</c> in them and ensures they point to project files (not directories).
233+
/// </summary>
234+
public static ImmutableArray<CSharpDirective> EvaluateDirectives(
235+
ProjectInstance? project,
236+
ImmutableArray<CSharpDirective> directives,
237+
SourceFile sourceFile,
238+
DiagnosticBag diagnostics)
239+
{
240+
if (directives.OfType<CSharpDirective.Project>().Any())
241+
{
242+
return directives
243+
.Select(d => d is CSharpDirective.Project p
244+
? (project is null
245+
? p
246+
: p.WithName(project.ExpandString(p.Name), CSharpDirective.Project.NameKind.Expanded))
247+
.EnsureProjectFilePath(sourceFile, diagnostics)
248+
: d)
249+
.ToImmutableArray();
250+
}
251+
252+
return directives;
253+
}
228254
}
229255

230256
internal readonly record struct SourceFile(string Path, SourceText Text)
@@ -457,8 +483,32 @@ public sealed class Package(in ParseInfo info) : Named(info)
457483
/// <summary>
458484
/// <c>#:project</c> directive.
459485
/// </summary>
460-
public sealed class Project(in ParseInfo info) : Named(info)
486+
public sealed class Project : Named
461487
{
488+
[SetsRequiredMembers]
489+
public Project(in ParseInfo info, string name) : base(info)
490+
{
491+
Name = name;
492+
OriginalName = name;
493+
}
494+
495+
/// <summary>
496+
/// Preserved across <see cref="WithName"/> calls, i.e.,
497+
/// this is the original directive text as entered by the user.
498+
/// </summary>
499+
public string OriginalName { get; init; }
500+
501+
/// <summary>
502+
/// This is the <see cref="OriginalName"/> with MSBuild <c>$(..)</c> vars expanded (via <see cref="ProjectInstance.ExpandString"/>).
503+
/// </summary>
504+
public string? ExpandedName { get; init; }
505+
506+
/// <summary>
507+
/// This is the <see cref="ExpandedName"/> resolved via <see cref="EnsureProjectFilePath"/>
508+
/// (i.e., this is a file path if the original text pointed to a directory).
509+
/// </summary>
510+
public string? ProjectFilePath { get; init; }
511+
462512
public static new Project? Parse(in ParseContext context)
463513
{
464514
var directiveText = context.DirectiveText;
@@ -468,19 +518,57 @@ public sealed class Project(in ParseInfo info) : Named(info)
468518
return context.Diagnostics.AddError<Project?>(context.SourceFile, context.Info.Span, string.Format(FileBasedProgramsResources.MissingDirectiveName, directiveKind));
469519
}
470520

521+
return new Project(context.Info, directiveText);
522+
}
523+
524+
public enum NameKind
525+
{
526+
/// <summary>
527+
/// Change <see cref="Named.Name"/> and <see cref="ExpandedName"/>.
528+
/// </summary>
529+
Expanded = 1,
530+
531+
/// <summary>
532+
/// Change <see cref="Named.Name"/> and <see cref="Project.ProjectFilePath"/>.
533+
/// </summary>
534+
ProjectFilePath = 2,
535+
536+
/// <summary>
537+
/// Change only <see cref="Named.Name"/>.
538+
/// </summary>
539+
Final = 3,
540+
}
541+
542+
public Project WithName(string name, NameKind kind)
543+
{
544+
return new Project(Info, name)
545+
{
546+
OriginalName = OriginalName,
547+
ExpandedName = kind == NameKind.Expanded ? name : ExpandedName,
548+
ProjectFilePath = kind == NameKind.ProjectFilePath ? name : ProjectFilePath,
549+
};
550+
}
551+
552+
/// <summary>
553+
/// If the directive points to a directory, returns a new directive pointing to the corresponding project file.
554+
/// </summary>
555+
public Project EnsureProjectFilePath(SourceFile sourceFile, DiagnosticBag diagnostics)
556+
{
557+
var resolvedName = Name;
558+
471559
try
472560
{
473561
// If the path is a directory like '../lib', transform it to a project file path like '../lib/lib.csproj'.
474-
// Also normalize blackslashes to forward slashes to ensure the directive works on all platforms.
475-
// https://github.com/dotnet/sdk/issues/51487: Behavior should not depend on process current directory
476-
var sourceDirectory = Path.GetDirectoryName(context.SourceFile.Path) ?? ".";
477-
var resolvedProjectPath = Path.Combine(sourceDirectory, directiveText.Replace('\\', '/'));
562+
// Also normalize backslashes to forward slashes to ensure the directive works on all platforms.
563+
var sourceDirectory = Path.GetDirectoryName(sourceFile.Path)
564+
?? throw new InvalidOperationException($"Source file path '{sourceFile.Path}' does not have a containing directory.");
565+
var resolvedProjectPath = Path.Combine(sourceDirectory, resolvedName.Replace('\\', '/'));
478566
if (Directory.Exists(resolvedProjectPath))
479567
{
480568
var fullFilePath = GetProjectFileFromDirectory(resolvedProjectPath).FullName;
481569

482570
// Keep a relative path only if the original directive was a relative path.
483-
directiveText = ExternalHelpers.IsPathFullyQualified(directiveText)
571+
resolvedName = ExternalHelpers.IsPathFullyQualified(resolvedName)
484572
? fullFilePath
485573
: ExternalHelpers.GetRelativePath(relativeTo: sourceDirectory, fullFilePath);
486574
}
@@ -491,22 +579,14 @@ public sealed class Project(in ParseInfo info) : Named(info)
491579
}
492580
catch (GracefulException e)
493581
{
494-
context.Diagnostics.AddError(context.SourceFile, context.Info.Span, string.Format(FileBasedProgramsResources.InvalidProjectDirective, e.Message), e);
582+
diagnostics.AddError(sourceFile, Info.Span, string.Format(FileBasedProgramsResources.InvalidProjectDirective, e.Message), e);
495583
}
496584

497-
return new Project(context.Info)
498-
{
499-
Name = directiveText,
500-
};
501-
}
502-
503-
public Project WithName(string name)
504-
{
505-
return new Project(Info) { Name = name };
585+
return WithName(resolvedName, NameKind.ProjectFilePath);
506586
}
507587

508588
// 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.
509-
public static FileInfo GetProjectFileFromDirectory(string projectDirectory)
589+
private static FileInfo GetProjectFileFromDirectory(string projectDirectory)
510590
{
511591
DirectoryInfo dir;
512592
try

src/Cli/Microsoft.DotNet.FileBasedPrograms/InternalAPI.Unshipped.txt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,19 @@ Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.Span.init -> void
3030
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.TrailingWhiteSpace.get -> Microsoft.DotNet.FileBasedPrograms.WhiteSpaceInfo
3131
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo.TrailingWhiteSpace.init -> void
3232
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project
33-
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.Project(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void
34-
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.WithName(string! name) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project!
33+
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.EnsureProjectFilePath(Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, Microsoft.DotNet.FileBasedPrograms.DiagnosticBag diagnostics) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project!
34+
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ExpandedName.get -> string?
35+
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ExpandedName.init -> void
36+
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind
37+
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind.Expanded = 1 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind
38+
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind.Final = 3 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind
39+
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind.ProjectFilePath = 2 -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind
40+
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.OriginalName.get -> string!
41+
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.OriginalName.init -> void
42+
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.Project(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info, string! name) -> void
43+
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ProjectFilePath.get -> string?
44+
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.ProjectFilePath.init -> void
45+
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.WithName(string! name, Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.NameKind kind) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project!
3546
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property
3647
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Property(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseInfo info) -> void
3748
Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Value.get -> string!
@@ -102,7 +113,6 @@ override Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Shebang.ToString() -
102113
override Microsoft.DotNet.FileBasedPrograms.SourceFile.GetHashCode() -> int
103114
static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Package?
104115
static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Named?
105-
static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.GetProjectFileFromDirectory(string! projectDirectory) -> System.IO.FileInfo!
106116
static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Project?
107117
static Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property.Parse(in Microsoft.DotNet.FileBasedPrograms.CSharpDirective.ParseContext context) -> Microsoft.DotNet.FileBasedPrograms.CSharpDirective.Property?
108118
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
113123
static Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.GetRelativePath(string! relativeTo, string! path) -> string!
114124
static Microsoft.DotNet.FileBasedPrograms.ExternalHelpers.IsPathFullyQualified(string! path) -> bool
115125
static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.CreateTokenizer(Microsoft.CodeAnalysis.Text.SourceText! text) -> Microsoft.CodeAnalysis.CSharp.SyntaxTokenParser!
126+
static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.EvaluateDirectives(Microsoft.Build.Execution.ProjectInstance? project, System.Collections.Immutable.ImmutableArray<Microsoft.DotNet.FileBasedPrograms.CSharpDirective!> directives, Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, Microsoft.DotNet.FileBasedPrograms.DiagnosticBag diagnostics) -> System.Collections.Immutable.ImmutableArray<Microsoft.DotNet.FileBasedPrograms.CSharpDirective!>
116127
static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.FindDirectives(Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, bool reportAllErrors, Microsoft.DotNet.FileBasedPrograms.DiagnosticBag diagnostics) -> System.Collections.Immutable.ImmutableArray<Microsoft.DotNet.FileBasedPrograms.CSharpDirective!>
117128
static Microsoft.DotNet.FileBasedPrograms.FileLevelDirectiveHelpers.FindLeadingDirectives(Microsoft.DotNet.FileBasedPrograms.SourceFile sourceFile, Microsoft.CodeAnalysis.SyntaxTriviaList triviaList, Microsoft.DotNet.FileBasedPrograms.DiagnosticBag diagnostics, System.Collections.Immutable.ImmutableArray<Microsoft.DotNet.FileBasedPrograms.CSharpDirective!>.Builder? builder) -> void
118129
static Microsoft.DotNet.FileBasedPrograms.MSBuildUtilities.ConvertStringToBool(string? parameterValue, bool defaultValue = false) -> bool

src/Cli/Microsoft.DotNet.FileBasedPrograms/Microsoft.DotNet.FileBasedPrograms.Package.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
</PropertyGroup>
2727

2828
<ItemGroup>
29-
<PackageReference Include="Microsoft.CodeAnalysis.Contracts" />
29+
<PackageReference Include="Microsoft.Build" />
30+
<PackageReference Include="Microsoft.CodeAnalysis.Contracts" PrivateAssets="all" />
3031
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
3132
<PackageReference Include="System.Text.Json" VersionOverride="$(SystemTextJsonToolsetPackageVersion)" />
3233
<PackageReference Include="System.Text.Encoding.CodePages" VersionOverride="$(SystemTextEncodingCodePagesToolsetPackageVersion)" />

0 commit comments

Comments
 (0)