Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions documentation/general/dotnet-run-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ Similarly, implicit build files like `Directory.Build.props` or `Directory.Packa
> [!CAUTION]
> Multi-file support is postponed for .NET 11.
> In .NET 10, only the single file passed as the command-line argument to `dotnet run` is part of the compilation.
> Specifically, the virtual project has properties `EnableDefaultCompileItems=false` and `EnableDefaultEmbeddedResourceItems=false`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How did we arrive at disabling this particular set of items? Do we have a reason for believing that if the directory contains some embedded resource, that we don't want to include it in the file-based app?

@jjonescz jjonescz Jul 2, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Compile items are disabled since we are doing single file apps.

Embedded resources were discussed in the internal chat and I think the decision was to also exclude them, right, @baronfel @DamianEdwards? I'm not sure there is a strong reason for that. I think I would also prefer to include everything unless explicitly disabled (would make it easier to reason about this).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Keeping embedded resources seems fine to me. @baronfel what was your reasoning for excluding them from single-file apps?

> (which can be customized via `#:property` directives), and a `Compile` item for the entry point file.
> During [conversion](#grow-up), any `Content`, `None`, `Compile`, and `EmbeddedResource` items that do not have metadata `ExcludeFromFileBasedAppConversion=true`
Comment thread
jjonescz marked this conversation as resolved.
> and that are files inside the entry point file's directory tree are copied to the converted directory.

### Nested files

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using Microsoft.Build.Evaluation;
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.TemplateEngine.Cli.Commands;
Expand Down Expand Up @@ -32,6 +33,9 @@ public override int Execute()
var sourceFile = VirtualProjectBuildingCommand.LoadSourceFile(file);
var directives = VirtualProjectBuildingCommand.FindDirectives(sourceFile, reportAllErrors: !_force, errors: null);

// Find other items to copy over, e.g., default Content items like JSON files in Web apps.
var includeItems = FindIncludedItems().ToList();

Directory.CreateDirectory(targetDirectory);

var targetFile = Path.Join(targetDirectory, Path.GetFileName(file));
Expand All @@ -47,11 +51,71 @@ public override int Execute()
File.Move(file, targetFile);
}

// Create project file.
string projectFile = Path.Join(targetDirectory, Path.GetFileNameWithoutExtension(file) + ".csproj");
using var stream = File.Open(projectFile, FileMode.Create, FileAccess.Write);
using var writer = new StreamWriter(stream, Encoding.UTF8);
VirtualProjectBuildingCommand.WriteProjectFile(writer, directives, isVirtualProject: false);

// Copy over included items.
foreach (var item in includeItems)
{
string targetItemFullPath = Path.Combine(targetDirectory, item.RelativePath);

// Ignore already-copied files.
if (File.Exists(targetItemFullPath))
{
continue;
}

string targetItemDirectory = Path.GetDirectoryName(targetItemFullPath)!;
Directory.CreateDirectory(targetItemDirectory);
File.Copy(item.FullPath, targetItemFullPath);
}

return 0;

IEnumerable<(string FullPath, string RelativePath)> FindIncludedItems()
{
string entryPointFileDirectory = PathUtility.EnsureTrailingSlash(Path.GetDirectoryName(file)!);
var projectCollection = new ProjectCollection();
var command = new VirtualProjectBuildingCommand(
entryPointFileFullPath: file,
msbuildArgs: [])
{
Directives = directives,
};
var projectInstance = command.CreateProjectInstance(projectCollection);

// Include only items we know are files.
string[] itemTypes = ["Content", "None", "Compile", "EmbeddedResource"];
var items = itemTypes.SelectMany(t => projectInstance.GetItems(t));

foreach (var item in items)
{
// Escape hatch - exclude items that have metadata `ExcludeFromFileBasedAppConversion` set to `true`.
string include = item.GetMetadataValue("ExcludeFromFileBasedAppConversion");
Comment thread
jjonescz marked this conversation as resolved.
if (string.Equals(include, bool.TrueString, StringComparison.OrdinalIgnoreCase))
{
continue;
}

// Exclude items that are not contained within the entry point file directory.
string itemFullPath = Path.GetFullPath(path: item.GetMetadataValue("FullPath"), basePath: entryPointFileDirectory);
if (!itemFullPath.StartsWith(entryPointFileDirectory, StringComparison.OrdinalIgnoreCase))
{
continue;
}

// Exclude items that do not exist.
if (!File.Exists(itemFullPath))
{
continue;
}

string itemRelativePath = Path.GetRelativePath(relativeTo: entryPointFileDirectory, path: itemFullPath);
yield return (FullPath: itemFullPath, RelativePath: itemRelativePath);
}
}
}
}
2 changes: 0 additions & 2 deletions src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,6 @@ public override RunApiOutput Execute()
CustomArtifactsPath = ArtifactsPath,
};

buildCommand.PrepareProjectInstance();

var runCommand = new RunCommand(
noBuild: false,
projectFileFullPath: null,
Expand Down
2 changes: 1 addition & 1 deletion src/Cli/dotnet/Commands/Run/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ public int Execute()
if (EntryPointFileFullPath is not null)
{
Debug.Assert(!ReadCodeFromStdin);
projectFactory = CreateVirtualCommand().PrepareProjectInstance().CreateProjectInstance;
projectFactory = CreateVirtualCommand().CreateProjectInstance;
}
}

Expand Down
43 changes: 20 additions & 23 deletions src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,6 @@ Override targets which don't work with project files that are not present on dis
</Target>
""";

private ImmutableArray<CSharpDirective> _directives;

public VirtualProjectBuildingCommand(
string entryPointFileFullPath,
string[] msbuildArgs)
Expand All @@ -131,6 +129,23 @@ public VirtualProjectBuildingCommand(
public bool NoBuild { get; init; }
public string BuildTarget { get; init; } = "Build";

public ImmutableArray<CSharpDirective> Directives
{
get
{
if (field.IsDefault)
Comment thread
jjonescz marked this conversation as resolved.
{
var sourceFile = LoadSourceFile(EntryPointFileFullPath);
field = FindDirectives(sourceFile, reportAllErrors: false, errors: null);
Debug.Assert(!field.IsDefault);
}

return field;
}

init;
}

public override int Execute()
{
Debug.Assert(!(NoRestore && NoBuild));
Expand All @@ -152,8 +167,6 @@ public override int Execute()
Reporter.Output.WriteLine(CliCommandStrings.NoBinaryLogBecauseUpToDate.Yellow());
}

PrepareProjectInstance();

return 0;
}

Expand Down Expand Up @@ -183,8 +196,6 @@ public override int Execute()
};
BuildManager.DefaultBuildManager.BeginBuild(parameters);

PrepareProjectInstance();

// Do a restore first (equivalent to MSBuild's "implicit restore", i.e., `/restore`).
// See https://github.com/dotnet/msbuild/blob/a1c2e7402ef0abe36bf493e395b04dd2cb1b3540/src/MSBuild/XMake.cs#L1838
// and https://github.com/dotnet/msbuild/issues/11519.
Expand Down Expand Up @@ -426,19 +437,6 @@ private void MarkBuildSuccess(RunFileBuildCacheEntry cacheEntry)
JsonSerializer.Serialize(stream, cacheEntry, RunFileJsonSerializerContext.Default.RunFileBuildCacheEntry);
}

/// <summary>
/// Needs to be called before the first call to <see cref="CreateProjectInstance(ProjectCollection)"/>.
/// </summary>
public VirtualProjectBuildingCommand PrepareProjectInstance()
{
Debug.Assert(_directives.IsDefault, $"{nameof(PrepareProjectInstance)} should not be called multiple times.");

var sourceFile = LoadSourceFile(EntryPointFileFullPath);
_directives = FindDirectives(sourceFile, reportAllErrors: false, errors: null);

return this;
}

public ProjectInstance CreateProjectInstance(ProjectCollection projectCollection)
{
return CreateProjectInstance(projectCollection, addGlobalProperties: null);
Expand All @@ -464,13 +462,11 @@ private ProjectInstance CreateProjectInstance(

ProjectRootElement CreateProjectRootElement(ProjectCollection projectCollection)
{
Debug.Assert(!_directives.IsDefault, $"{nameof(PrepareProjectInstance)} should have been called first.");

var projectFileFullPath = Path.ChangeExtension(EntryPointFileFullPath, ".csproj");
var projectFileWriter = new StringWriter();
WriteProjectFile(
projectFileWriter,
_directives,
Directives,
isVirtualProject: true,
targetFilePath: EntryPointFileFullPath,
artifactsPath: GetArtifactsPath(),
Expand Down Expand Up @@ -594,7 +590,8 @@ public static void WriteProjectFile(
writer.WriteLine("""

<PropertyGroup>
<EnableDefaultItems>false</EnableDefaultItems>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems>
</PropertyGroup>
""");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,93 @@ public void NestedDirectory()
.Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
}

/// <summary>
/// Default items like <c>None</c> or <c>Content</c> are copied over.
/// </summary>
[Fact]
public void DefaultItems()
{
var testInstance = _testAssetsManager.CreateTestDirectory();
File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
Console.WriteLine();
""");
File.WriteAllText(Path.Join(testInstance.Path, "my.json"), "");
File.WriteAllText(Path.Join(testInstance.Path, "Resources.resx"), "");
File.WriteAllText(Path.Join(testInstance.Path, "Util.cs"), "");
Directory.CreateDirectory(Path.Join(testInstance.Path, "subdir"));
File.WriteAllText(Path.Join(testInstance.Path, "subdir", "second.json"), "");

new DotnetCommand(Log, "project", "convert", "Program.cs")
.WithWorkingDirectory(testInstance.Path)
.Execute()
.Should().Pass();

new DirectoryInfo(testInstance.Path)
.EnumerateFileSystemInfos().Select(f => f.Name).Order()
.Should().BeEquivalentTo(["Program", "Resources.resx", "Util.cs", "my.json", "subdir"]);

new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
.EnumerateFileSystemInfos().Select(f => f.Name).Order()
.Should().BeEquivalentTo(["Program.csproj", "Program.cs", "my.json", "subdir"]);

new DirectoryInfo(Path.Join(testInstance.Path, "Program", "subdir"))
.EnumerateFileSystemInfos().Select(f => f.Name).Order()
.Should().BeEquivalentTo(["second.json"]);
}

[Fact]
public void DefaultItems_MoreIncluded()
{
var testInstance = _testAssetsManager.CreateTestDirectory();
File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
#:property EnableDefaultCompileItems=true
#:property EnableDefaultEmbeddedResourceItems=true
Console.WriteLine();
""");
File.WriteAllText(Path.Join(testInstance.Path, "my.json"), "");
File.WriteAllText(Path.Join(testInstance.Path, "Resources.resx"), "");
File.WriteAllText(Path.Join(testInstance.Path, "Util.cs"), "");

new DotnetCommand(Log, "project", "convert", "Program.cs")
.WithWorkingDirectory(testInstance.Path)
.Execute()
.Should().Pass();

new DirectoryInfo(testInstance.Path)
.EnumerateFileSystemInfos().Select(f => f.Name).Order()
.Should().BeEquivalentTo(["Program", "Resources.resx", "Util.cs", "my.json"]);

new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
.EnumerateFileSystemInfos().Select(f => f.Name).Order()
.Should().BeEquivalentTo(["Program.csproj", "Program.cs", "Resources.resx", "Util.cs", "my.json"]);
}

[Fact]
public void DefaultItems_MoreExcluded()
{
var testInstance = _testAssetsManager.CreateTestDirectory();
File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
#:property EnableDefaultItems=false
Console.WriteLine();
""");
File.WriteAllText(Path.Join(testInstance.Path, "my.json"), "");
File.WriteAllText(Path.Join(testInstance.Path, "Resources.resx"), "");
File.WriteAllText(Path.Join(testInstance.Path, "Util.cs"), "");

new DotnetCommand(Log, "project", "convert", "Program.cs")
.WithWorkingDirectory(testInstance.Path)
.Execute()
.Should().Pass();

new DirectoryInfo(testInstance.Path)
.EnumerateFileSystemInfos().Select(f => f.Name).Order()
.Should().BeEquivalentTo(["Program", "Resources.resx", "Util.cs", "my.json"]);

new DirectoryInfo(Path.Join(testInstance.Path, "Program"))
.EnumerateFileSystemInfos().Select(f => f.Name).Order()
.Should().BeEquivalentTo(["Program.csproj", "Program.cs"]);
}
Comment thread
jjonescz marked this conversation as resolved.

/// <summary>
/// When processing fails due to invalid directives, no conversion should be performed
/// (e.g., the target directory should not be created).
Expand All @@ -283,6 +370,28 @@ public void ProcessingFails()
.EnumerateDirectories().Should().BeEmpty();
}

/// <summary>
/// Since we perform MSBuild evaluation during the conversion (to find included items to copy over),
/// the conversion can fail when the specified SDK does not exist.
/// </summary>
[Fact]
public void ProcessingFails_Evaluation()
{
var testInstance = _testAssetsManager.CreateTestDirectory();
var filePath = Path.Join(testInstance.Path, "Program.cs");
File.WriteAllText(filePath, "#:sdk Microsoft.ThisSdkDoesNotExist");

new DotnetCommand(Log, "project", "convert", "Program.cs")
.WithWorkingDirectory(testInstance.Path)
.Execute()
.Should().Fail()
// The SDK 'Microsoft.ThisSdkDoesNotExist' specified could not be found.
.And.HaveStdErrContaining("Microsoft.ThisSdkDoesNotExist");

new DirectoryInfo(Path.Join(testInstance.Path))
.EnumerateDirectories().Should().BeEmpty();
}

/// <summary>
/// End-to-end test of directive processing. More cases are covered by faster unit tests below.
/// </summary>
Expand All @@ -291,7 +400,7 @@ public void ProcessingSucceeds()
{
var testInstance = _testAssetsManager.CreateTestDirectory();
File.WriteAllText(Path.Join(testInstance.Path, "Program.cs"), """
#:sdk Aspire.Hosting.Sdk@9.1.0
#:package Humanizer@2.14.1
Console.WriteLine();
""");

Expand All @@ -313,7 +422,7 @@ public void ProcessingSucceeds()

File.ReadAllText(Path.Join(testInstance.Path, "Program", "Program.csproj"))
.Should().Be($"""
<Project Sdk="Aspire.Hosting.Sdk/9.1.0">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
Expand All @@ -323,6 +432,10 @@ public void ProcessingSucceeds()
<PublishAot>true</PublishAot>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Humanizer" Version="2.14.1" />
</ItemGroup>

</Project>

""");
Expand Down
Loading