Skip to content

Add #:project directive #49311

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jun 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions documentation/general/dotnet-run-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,14 +161,16 @@ They are not cleaned immediately because they can be re-used on subsequent runs

It is possible to specify some project metadata via *file-level directives*
which are [ignored][ignored-directives] by the C# language but recognized by the SDK CLI.
Directives `sdk`, `package`, and `property` are translated into `<Project Sdk="...">`, `<PackageReference>`, and `<Property>` project elements, respectively.
Directives `sdk`, `package`, `property`, and `project` are translated into
`<Project Sdk="...">`, `<PackageReference>`, `<PropertyGroup>`, and `<ProjectReference>` project elements, respectively.
Other directives result in an error, reserving them for future use.

```cs
#:sdk Microsoft.NET.Sdk.Web
#:property TargetFramework=net11.0
#:property LangVersion=preview
#:package System.CommandLine@2.0.0-*
#:project ../MyLibrary
```

The value must be separated from the kind (`package`/`sdk`/`property`) of the directive by whitespace
Expand All @@ -182,6 +184,9 @@ The value of `#:property` is split by the separator and injected as `<{0}>{1}</{
It is an error if no separator appears in the value or if the first part (property name) is empty (the property value is allowed to be empty) or contains invalid characters.
The value of `#:package` is split by the separator and injected as `<PackageReference Include="{0}" Version="{1}">` (or without the `Version` attribute if there is no separator) in an `<ItemGroup>`.
It is an error if the first part (package name) is empty (the package version is allowed to be empty, but that results in empty `Version=""`).
The value of `#:project` is injected as `<ProjectReference Include="{0}" />` in an `<ItemGroup>`.
If the value points to an existing directory, a project file is found inside that directory and its path is used instead
(because `ProjectReference` items don't support directory paths).

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
Expand Down Expand Up @@ -332,8 +337,8 @@ We could also add `dotnet compile` command that would be the equivalent of `dotn
e.g., via `dotnet clean --file-based-program <path-to-entry-point>`
or `dotnet clean --all-file-based-programs`.

Adding package references via `dotnet package add` could be supported for file-based programs as well,
i.e., the command would add a `#:package` directive to the top of a `.cs` file.
Adding references via `dotnet package add`/`dotnet reference add` could be supported for file-based programs as well,
i.e., the command would add a `#:package`/`#:project` directive to the top of a `.cs` file.

### Explicit importing

Expand Down
4 changes: 4 additions & 0 deletions src/Cli/dotnet/Commands/CliCommandStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,10 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man
<value>Minimum expected tests policy violation, tests ran {0}, minimum expected {1}</value>
<comment>{0}, {1} number of tests</comment>
</data>
<data name="InvalidProjectDirective" xml:space="preserve">
<value>The '#:project' directive at '{0}' is invalid: {1}</value>
<comment>{0} is the file path and line number. {1} is the inner error message.</comment>
</data>
<data name="MissingDirectiveName" xml:space="preserve">
<value>Missing name of '{0}' at {1}.</value>
<comment>{0} is the directive name like 'package' or 'sdk', {1} is the file path and line number.</comment>
Expand Down
147 changes: 117 additions & 30 deletions src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,56 @@ internal sealed class VirtualProjectBuildingCommand : CommandBase
"MSBuild.rsp",
];

internal static readonly string TargetOverrides = """
<!--
Override targets which don't work with project files that are not present on disk.
See https://github.com/NuGet/Home/issues/14148.
-->
<Target Name="_FilterRestoreGraphProjectInputItems"
DependsOnTargets="_LoadRestoreGraphEntryPoints">
<!-- No-op, the original output is not needed by the overwritten targets. -->
</Target>
<Target Name="_GetAllRestoreProjectPathItems"
DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GenerateRestoreProjectPathWalk"
Returns="@(_RestoreProjectPathItems)">
<!-- Output from dependency _GenerateRestoreProjectPathWalk. -->
</Target>
<Target Name="_GenerateRestoreGraph"
DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
Returns="@(_RestoreGraphEntry)">
<!-- Output partly from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph. -->
<ItemGroup>
<_GenerateRestoreGraphProjectEntryInput Include="@(_RestoreProjectPathItems)" Exclude="$(MSBuildProjectFullPath)" />
</ItemGroup>
<MSBuild
BuildInParallel="$(RestoreBuildInParallel)"
Projects="@(_GenerateRestoreGraphProjectEntryInput)"
Targets="_GenerateRestoreGraphProjectEntry"
Properties="$(_GenerateRestoreGraphProjectEntryInputProperties)">
<Output
TaskParameter="TargetOutputs"
ItemName="_RestoreGraphEntry" />
</MSBuild>
<MSBuild
BuildInParallel="$(RestoreBuildInParallel)"
Projects="@(_GenerateRestoreGraphProjectEntryInput)"
Targets="_GenerateProjectRestoreGraph"
Properties="$(_GenerateRestoreGraphProjectEntryInputProperties)">
<Output
TaskParameter="TargetOutputs"
ItemName="_RestoreGraphEntry" />
</MSBuild>
</Target>
""";

private ImmutableArray<CSharpDirective> _directives;

public VirtualProjectBuildingCommand(
Expand Down Expand Up @@ -468,6 +518,7 @@ public static void WriteProjectFile(
var sdkDirectives = directives.OfType<CSharpDirective.Sdk>();
var propertyDirectives = directives.OfType<CSharpDirective.Property>();
var packageDirectives = directives.OfType<CSharpDirective.Package>();
var projectDirectives = directives.OfType<CSharpDirective.Project>();

string sdkValue = "Microsoft.NET.Sdk";

Expand Down Expand Up @@ -607,6 +658,25 @@ public static void WriteProjectFile(
writer.WriteLine(" </ItemGroup>");
}

if (projectDirectives.Any())
{
writer.WriteLine("""
<ItemGroup>
""");

foreach (var projectReference in projectDirectives)
{
writer.WriteLine($"""
<ProjectReference Include="{EscapeValue(projectReference.Name)}" />
""");

processedDirectives++;
}

writer.WriteLine(" </ItemGroup>");
}

Debug.Assert(processedDirectives + directives.OfType<CSharpDirective.Shebang>().Count() == directives.Length);

if (isVirtualProject)
Expand Down Expand Up @@ -643,35 +713,8 @@ public static void WriteProjectFile(
""");
}

writer.WriteLine("""
<!--
Override targets which don't work with project files that are not present on disk.
See https://github.com/NuGet/Home/issues/14148.
-->
<Target Name="_FilterRestoreGraphProjectInputItems"
DependsOnTargets="_LoadRestoreGraphEntryPoints"
Returns="@(FilteredRestoreGraphProjectInputItems)">
<ItemGroup>
<FilteredRestoreGraphProjectInputItems Include="@(RestoreGraphProjectInputItems)" />
</ItemGroup>
</Target>
<Target Name="_GetAllRestoreProjectPathItems"
DependsOnTargets="_FilterRestoreGraphProjectInputItems"
Returns="@(_RestoreProjectPathItems)">
<ItemGroup>
<_RestoreProjectPathItems Include="@(FilteredRestoreGraphProjectInputItems)" />
</ItemGroup>
</Target>
<Target Name="_GenerateRestoreGraph"
DependsOnTargets="_FilterRestoreGraphProjectInputItems;_GetAllRestoreProjectPathItems;_GenerateRestoreGraphProjectEntry;_GenerateProjectRestoreGraph"
Returns="@(_RestoreGraphEntry)">
<!-- Output from dependency _GenerateRestoreGraphProjectEntry and _GenerateProjectRestoreGraph -->
</Target>
""");
writer.WriteLine();
writer.WriteLine(TargetOverrides);
}

writer.WriteLine("""
Expand Down Expand Up @@ -943,16 +986,22 @@ private CSharpDirective() { }
"sdk" => Sdk.Parse(errors, sourceFile, span, directiveKind, directiveText),
"property" => Property.Parse(errors, sourceFile, span, directiveKind, directiveText),
"package" => Package.Parse(errors, sourceFile, span, directiveKind, directiveText),
"project" => Project.Parse(errors, sourceFile, span, directiveText),
_ => ReportError<Named>(errors, sourceFile, span, string.Format(CliCommandStrings.UnrecognizedDirective, directiveKind, sourceFile.GetLocationString(span))),
};
}

private static T? ReportError<T>(ImmutableArray<SimpleDiagnostic>.Builder? errors, SourceFile sourceFile, TextSpan span, string message, Exception? inner = null)
{
ReportError(errors, sourceFile, span, message, inner);
return default;
}

private static void ReportError(ImmutableArray<SimpleDiagnostic>.Builder? errors, SourceFile sourceFile, TextSpan span, string message, Exception? inner = null)
{
if (errors != null)
{
errors.Add(new SimpleDiagnostic { Location = sourceFile.GetFileLinePositionSpan(span), Message = message });
return default;
}
else
{
Expand Down Expand Up @@ -1088,6 +1137,44 @@ private Package() { }
};
}
}

/// <summary>
/// <c>#:project</c> directive.
/// </summary>
public sealed class Project : Named
{
private Project() { }

public static Project Parse(ImmutableArray<SimpleDiagnostic>.Builder? errors, SourceFile sourceFile, TextSpan span, string directiveText)
{
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(sourceFile.Path) ?? ".";
var resolvedProjectPath = Path.Combine(sourceDirectory, directiveText.Replace('\\', '/'));
if (Directory.Exists(resolvedProjectPath))
{
var fullFilePath = MsbuildProject.GetProjectFileFromDirectory(resolvedProjectPath).FullName;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few lines above we normalize \\ to / but FullName on windows will still have \\ in the path. Should we re-normalize here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider user writing #:project ..\lib on Windows and another user executing that on Linux. The normalization above makes sure that's possible.

Normalization here isn't necessary for that to work, but I guess we could normalize for other reasons. But the current behavior (without normalization) should be consistent with what dotnet reference add is doing (since we use the same utility MsbuildProject.GetProjectFileFromDirectory as that command).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not obvious to me that #:project ..\lib should work on Linux. Do you happen to know the behavior of dotnet run --project ..\lib?

Copy link
Member Author

@jjonescz jjonescz Jun 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dotnet run --project ..\lib probably wouldn't work. But <ProjectReference Include="..\lib\lib.csproj" /> would. A general rule I take from that is that using \ on command line won't work but in a source code (as #:project or <ProjectReference/>) works. I guess the reason it works in <ProjectReference/> is that msbuild started as Windows-only and they wanted it to be portable. We don't need to do that for #:project I guess. So I'm not against not normalizing at all and in effect forcing users to use forward slashes if they want to write cross-platform code. Let me know what you think.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep MSBuild "solves" this for us as paths there are normalized. We can decide to simply pass through what the user types in the directive directly I think.

directiveText = Path.GetRelativePath(relativeTo: sourceDirectory, fullFilePath);
}
else if (!File.Exists(resolvedProjectPath))
{
throw new GracefulException(CliStrings.CouldNotFindProjectOrDirectory, resolvedProjectPath);
}
}
catch (GracefulException e)
{
ReportError(errors, sourceFile, span, string.Format(CliCommandStrings.InvalidProjectDirective, sourceFile.GetLocationString(span), e.Message), e);
}

return new Project
{
Span = span,
Name = directiveText,
};
}
}
}

/// <summary>
Expand Down
5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.fr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.it.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.ja.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.ko.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.pl.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.pt-BR.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Cli/dotnet/Commands/xlf/CliCommandStrings.ru.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading