Skip to content
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

Process C# directives in file-based programs #47702

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

jjonescz
Copy link
Member

@jjonescz jjonescz commented Mar 18, 2025

@jjonescz jjonescz added the Area-run-file Items related to the "dotnet run <file>" effort label Mar 18, 2025
@dotnet-issue-labeler dotnet-issue-labeler bot added the untriaged Request triage from a team member label Mar 18, 2025
if (virtualProjectFile)
{
writer.WriteLine($"""
<Project>
Copy link
Member Author

Choose a reason for hiding this comment

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

I've tried using XmlWriter for this, but couldn't make it write indented XML with blank lines between sections. MSBuild APIs also seem unable to do that. So I ended up building the project file as string.

@@ -35,13 +37,29 @@ public override int Execute()
throw new GracefulException(LocalizableStrings.DirectoryAlreadyExists, targetDirectory);
}

// Find directives (this can fail, so do this before creating the target directory).
Copy link
Member

Choose a reason for hiding this comment

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

This is a stupid example, but if you make .cs (a C# file with no name) then call this on it, I think it would fail? Not sure we really care. More relevant, though, I think it would be good to have some error handling logic including reverting state where relevant.

Copy link
Member Author

@jjonescz jjonescz Mar 19, 2025

Choose a reason for hiding this comment

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

This is a stupid example, but if you make .cs (a C# file with no name) then call this on it, I think it would fail?

You're right, good catch. It will fail with an error saying the target directory already exists because the target directory will be the current directory. I guess we could add a dedicated error message for this case, but it doesn't seem necessary. Users will be able to work around by specifying --output. Although the generated project will also have an empty name, so probably won't work (but users can rename it and then it will work).

More relevant, though, I think it would be good to have some error handling logic including reverting state where relevant.

That shouldn't be needed in normal scenarios. That's why all the checks that can fail are done up front, and only then we do the file modifications (creating target directory, moving files over). If any of the checks fail, we fail early. If the checks succeed but any of those file operations fail for some reason, I guess we could abort and try to cleanup. The cleanup could also fail though. I think it might be easier to leave the revert to the user in the rare(?) case it's needed.

"sdk" => Sdk.Parse(sourceFile, span, name, value),
"property" => Property.Parse(sourceFile, span, name, value),
"package" => Package.Parse(sourceFile, span, name, value),
_ => throw new GracefulException(LocalizableStrings.UnrecognizedDirective, name, sourceFile.GetLocationString(span)),
Copy link
Member

Choose a reason for hiding this comment

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

There are other directives that can just stay in the C# file, so I think it'd be best to just not make a directive in this case (and not remove it from the C# file)

Copy link
Member Author

@jjonescz jjonescz Mar 19, 2025

Choose a reason for hiding this comment

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

This is only handling #: directives and there shouldn't be any other than those three. (I.e., this code path is not reachable for existing C# directives like #pragma.) We want to error on unknown #: directives to reserve them for future use.


public static void RemoveDirectivesFromFile(ImmutableArray<CSharpDirective> directives, SourceText text, string filePath)
{
if (RemoveDirectivesFromFile(directives, text) is { } modifiedText)
Copy link
Member

Choose a reason for hiding this comment

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

In what case is this false? And should it mention to the caller that it failed?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is false if RemoveDirectivesFromFile(directives, text) returns null which happens if there are no directives, i.e., no changes to the file are necessary. The method could return bool, sure, but currently the caller doesn't need it. It is not a failure, it is just a no-op.

_directives = FindDirectives(sourceFile);

// If there were any `#:` directives, remove them from the file.
// (This is temporary until Roslyn is updated to ignore them.)
Copy link
Member

Choose a reason for hiding this comment

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

Is there any reason not to remove them if roslyn would ignore them anyway? Just wondering why this wouldn't be permanent

Copy link
Member Author

@jjonescz jjonescz Mar 19, 2025

Choose a reason for hiding this comment

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

Because removing them changes line numbers, so for example location of errors reported by Roslyn is off. That's just one example, there are probably others where inconsistencies between the original file and the file passed to Roslyn would lead to unexpected behavior, like #pragma checksum.

Another reason is performance - now during build we need to create a copy of the file with the directives stripped out and pass that to the compiler instead of the original file, that's just an unnecessary perf hit.

if (_directives.Length != 0)
{
var targetDirectory = Path.Join(Path.GetDirectoryName(_targetFilePath), "obj");
Directory.CreateDirectory(targetDirectory);
Copy link
Member

Choose a reason for hiding this comment

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

Is the idea to make a .targets holding the directives information? That seems an odd choice to me compared with just putting them in the csproj

Copy link
Member Author

Choose a reason for hiding this comment

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

No, this has nothing to do with .targets. I'm assuming you are referring to targetDirectory. Given dotnet run ./app/file.cs, targetDirectory will be ./app/obj/ where we will place ./app/obj/file.cs that has the directives stripped out so we can pass it to Roslyn. This is just temporary, we will skip all this when Roslyn recognizes the directives (i.e., doesn't error on them).

""");
}

foreach (var sdk in sdkDirectives.Skip(1))
Copy link
Member

Choose a reason for hiding this comment

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

Would it be bad design-wise to just say that you can't have multiple sdks in a file? I can't think of a normal use case where you shouldn't just upgrade to a project file

Copy link
Member Author

Choose a reason for hiding this comment

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

That was my original intention but Damian pointed out that multiple sdks are common for example in aspire projects:

<Project Sdk="Microsoft.NET.Sdk">

    <Sdk Name="Aspire.AppHost.Sdk" Version="9.0.0" />

</Project>

and those can be otherwise very simple project files so it seems unnecessary to restrict them from file-based design.


""");

foreach (var sdk in sdkDirectives)
Copy link
Member

Choose a reason for hiding this comment

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

You don't have to Skip(1)? And perhaps this is nitpicky, but shouldn't you go in reverse order?

Copy link
Member Author

Choose a reason for hiding this comment

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

You don't have to Skip(1)?

No, that's only needed when the first Sdk is put into <Project Sdk> attribute but that never happens for virtual project files. Instead they get all sdks emitted as <Import Project="Sdk.props" /> elements at the beginning and <Import Project="Sdk.targets" /> at the end.

shouldn't you go in reverse order?

I considered that but as far as I can tell, the following

<Project>
<Sdk Name="1" />
<Sdk Name="2" />
</Project>

is equivalent to

<Project>
<Import Project="Sdk.props" Sdk="1" />
<Import Project="Sdk.props" Sdk="2" />
<Import Project="Sdk.targets" Sdk="1" />
<Import Project="Sdk.targets" Sdk="2" />
</Project>

Copy link
Member

Choose a reason for hiding this comment

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

here's the MSBuild logic for these implicit imports: https://github.com/dotnet/msbuild/blob/f1b335d0aa9423c304b5cb186bad40935a6e2f2b/src/Build/Construction/ProjectRootElement.cs#L1903-L1932

followed by https://github.com/dotnet/msbuild/blob/f1b335d0aa9423c304b5cb186bad40935a6e2f2b/src/Build/Definition/Project.cs#L2860-L2883 for the actual loading.

Short version: @jjonescz is correct here. it's not a nesting-doll model, it's an in-order Import for both the Sdk.props and Sdk.targets entrypoints.

""");
}

writer.WriteLine("""
Copy link
Member

Choose a reason for hiding this comment

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

Why do you still need this part if we're making a real project file on disk?

Copy link
Member Author

@jjonescz jjonescz Mar 19, 2025

Choose a reason for hiding this comment

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

This part is inside if (virtualProjectFile), it won't be emitted for real project files.

Note that this method (WriteProjectFile) is reused for both virtual project files (dotnet run file scenarios) and physical project files (dotnet project convert scenarios) to avoid duplicating the logic. Of course, there are some conditions like if (virtualProjectFile), so the sharing isn't 100%, but that should all go away when NuGet targets are fixed to handle virtual project files and we don't need the hacky Target overrides anymore.

@jjonescz jjonescz marked this pull request as ready for review March 19, 2025 12:41
Copy link
Member

@DamianEdwards DamianEdwards left a comment

Choose a reason for hiding this comment

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

This is looking good from my point of view 👍

@jjonescz
Copy link
Member Author

@RikkiGibson @chsienki @MiYanni for reviews, thanks

@RikkiGibson RikkiGibson self-assigned this Mar 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area-run-file Items related to the "dotnet run <file>" effort untriaged Request triage from a team member
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants