8000 Support multiple files in file-based programs by jjonescz · Pull Request #48782 · dotnet/sdk · GitHub
[go: up one dir, main page]

Skip to content

Support multiple files in file-based programs #48782

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

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
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
Next Next commit
Support multiple files in file-based programs
  • Loading branch information
jjonescz committed Apr 30, 2025
commit 500d1cee03ed221361619048b18c63f73b10d0e4
56 changes: 56 additions & 0 deletions src/Cli/Microsoft.DotNet.Cli.Utils/PathUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -416,4 +416,60 @@ public static string GetDirectorySeparatorChar()

return null;
}

public static void SafeRenameDirectory(string sourceDir, string destDir)
Copy link
Member

Choose a reason for hiding this comment

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

I haven't had time yet to review the implementation in detail, or to see how this is used, but, this looks like something difficult to get right. File systems can do weird stuff--hard links, mount points, etc.

Is this really the kind of thing where we want to automatically figure out what to do, rather than just complaining that the file system isn't in a state where we can do it automatically, and user needs to make the appropriate change to allow the tool to work?

Copy link
Member Author
@jjonescz jjonescz May 14, 2025

Choose a reason for hiding this comment

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

I'm not opposed to failing more instead of trying to be smart. In this case, I see these options:

  1. Simply call Directory.Move without any checks. That will fail with an exception in case we are trying to move a directory into itself. And you are left in half-migrated state. We don't want that.
  2. Do some checks up front and fail early. So we still need some logic to detect that Directory.Move won't work. Since we need to detect this situation anyway, why not do the automatic thing (moving to temp directory as a middle step)? I'm not sure, just curious what you think.
  3. Have this SafeRenameDirectory utility. It seems it might be useful in other cases for the CLI - basically if you have a directory like /a/b, you should be able to "rename" it to /a/b/c as programmer and this utility allows that. For example, vscode file explorer can do this as well.

{
if (Directory.Exists(destDir))
{
throw new IOException($"Cannot rename directory: destination '{destDir}' already exists");
}

// Check if destination is a subdirectory of the source
if (IsChildOfDirectory(sourceDir, destDir))
{
// Create a temporary location outside of the source directory
string tempDir = Path.Combine(
Path.GetDirectoryName(sourceDir) ?? Path.GetTempPath(),
Path.GetRandomFileName());

try
{
// First move to temp location
Directory.Move(sourceDir, tempDir);

// Create parent directories of destination if needed
EnsureDirectoryExists(Path.GetDirectoryName(destDir));

// Move from temp to final destination
Directory.Move(tempDir, destDir);
}
catch (Exception)
{
// Try to restore original directory if possible
if (Directory.Exists(tempDir) && !Directory.Exists(sourceDir))
{
try
{
Directory.Move(tempDir, sourceDir);
}
catch
{
// Last resort, if we can't move back, at least don't lose the data
// Leave it in the temp dir and report the location
throw new IOException(
$"Failed to rename directory '{sourceDir}' to '{destDir}'. " +
$"Data has been moved to temporary location: {tempDir}");
}
}

throw;
}
}
else
{
// For non-nested moves, we can just use Directory.Move directly
EnsureDirectoryExists(Path.GetDirectoryName(destDir));
Directory.Move(sourceDir, destDir);
}
}
}
36 changes: 31 additions & 5 deletions src/Cli/dotnet/Commands/CliCommandStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -364,12 +364,18 @@ For MSTest before 2.2.4, the timeout is used for all testcases.</value>
<data name="CmdExpressionName" xml:space="preserve">
<value>EXPRESSION</value>
</data>
<data name="CmdFileDescription" xml:space="preserve">
<value>Path to the file-based program.</value>
<data name="FileOrDirectoryArgumentName" xml:space="preserve">
<value>FILE | DIRECTORY</value>
</data>
<data name="FileOrDirectoryArgumentDescription" xml:space="preserve">
<value>Path to the file-based program's entry point or directory of multiple entry points.</value>
</data>
<data name="CmdOptionForceDescription" xml:space="preserve">
<value>Force conversion even if there are malformed directives.</value>
</data>
<data name="CmdOptionSharedDirectoryNameDescription" xml:space="preserve">
<value>Name of the directory where non-entry-point files should be placed.</value>
</data>
<data name="CmdForceRestoreOptionDescription" xml:space="preserve">
<value>Force all dependencies to be resolved even if the last restore was successful.
This is equivalent to deleting project.assets.json.</value>
Expand Down Expand Up @@ -972,9 +978,21 @@ Make the profile names distinct.</value>
<data name="IntermediateWorkingDirOptionDescription" xml:space="preserve">
<value>The working directory used by the command to execute.</value>
</data>
<data name="InvalidFilePath" xml:space="preserve">
<value>The specified file must exist and have '.cs' file extension: '{0}'</value>
<comment>{Locked=".cs"}</comment>
<data name="InvalidFileOrDirectoryPath" xml:space="preserve">
<value>The specified path must be an existing file with '.cs' extension or an existing directory: '{0}'</value>
<comment>{Locked=".cs"}. {0} is file or directory path</comment>
</data>
<data name="DirectoryMustBeSpecified" xml:space="preserve">
<value>Since there are multiple entry points, a directory must be specified rather than a file (and all entry points will be converted together): '{0}'</value>
<comment>{0} is file path</comment>
</data>
<data name="NoEntryPoints" xml:space="preserve">
<value>Cannot convert, no entry points found: '{0}'</value>
<comment>{0} is directory path</comment>
</data>
<data name="SharedDirectoryNameConflicts" xml:space="preserve">
<value>Cannot put shared files into directory '{0}' because it conflicts with one of the entry points. Use option '--shared-directory-name' to specify a different value.</value>
<comment>{Locked="--shared-directory-name"}. {0} is file name without extension</comment>
</data>
<data name="InvalidSemVerVersionString" xml:space="preserve">
<value>Failed to parse "{0}" as a semantic version.</value>
Expand Down Expand Up @@ -1492,6 +1510,14 @@ Tool '{1}' (version '{2}') was successfully installed. Entry is added to the man
<data name="ProjectConvertAppFullName" xml:space="preserve">
<value>Convert a file-based program to a project-based program.</value>
</data>
<data name="NoTopLevelStatements" xml:space="preserve">
<value>The entry point of a file-based program must have top-level statements: '{0}'</value>
<comment>{0} is file path</comment>
</data>
<data name="EntryPointInNestedFolder" xml:space="preserve">
<value>Entry-point files cannot be in nested folders: '{0}'</value>
<comment>{0} is file path</comment>
</data>
<data name="ProjectManifest" xml:space="preserve">
<value>PROJECT_MANIFEST</value>
</data>
Expand Down
210 changes: 189 additions & 21 deletions src/Cli/dotnet/Commands/Project/Convert/ProjectConvertCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#nullable enable

using System.CommandLine;
using System.Diagnostics;
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.TemplateEngine.Cli.Commands;
Expand All @@ -12,48 +13,215 @@ namespace Microsoft.DotNet.Cli.Commands.Project.Convert;

internal sealed class ProjectConvertCommand(ParseResult parseResult) : CommandBase(parseResult)
{
private readonly string _file = parseResult.GetValue(ProjectConvertCommandParser.FileArgument) ?? string.Empty;
private readonly string _fileOrDirectory = parseResult.GetValue(ProjectConvertCommandParser.FileOrDirectoryArgument)!;
private readonly string? _outputDirectory = parseResult.GetValue(SharedOptions.OutputOption)?.FullName;
private readonly bool _force = parseResult.GetValue(ProjectConvertCommandParser.ForceOption);
private readonly string _sharedDirectoryName = parseResult.GetValue(ProjectConvertCommandParser.SharedDirectoryNameOption)!;

public override int Execute()
{
string file = Path.GetFullPath(_file);
if (!VirtualProjectBuildingCommand.IsValidEntryPointPath(file))
// Check target directory.
if (_outputDirectory != null && Directory.Exists(_outputDirectory))
{
throw new GracefulException(CliCommandStrings.InvalidFilePath, file);
throw new GracefulException(CliCommandStrings.DirectoryAlreadyExists, _outputDirectory);
}

string targetDirectory = _outputDirectory ?? Path.ChangeExtension(file, null);
if (Directory.Exists(targetDirectory))
// Check entry-point file path.
string fileOrDirectory = Path.GetFullPath(_fileOrDirectory);
bool isFile = VirtualProjectBuildingCommand.IsValidEntryPointPath(fileOrDirectory);
if (!isFile && (File.Exists(fileOrDirectory) || !Directory.Exists(fileOrDirectory)))
Copy link
Member

Choose a reason for hiding this comment

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

I didn't fully understand the File.Exists(fileOrDirectory) || !Directory.Exists(fileOrDirectory) check. Is there a case that would behave incorrectly if this were replaced with !Directory.Exists(fileOrDirectory)?

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, will simplify, thanks.

{
throw new GracefulException(CliCommandStrings.DirectoryAlreadyExists, targetDirectory);
throw new GracefulException(CliCommandStrings.InvalidFileOrDirectoryPath, fileOrDirectory);
}

// Find directives (this can fail, so do this before creating the target directory).
var sourceFile = VirtualProjectBuildingCommand.LoadSourceFile(file);
var directives = VirtualProjectBuildingCommand.FindDirectivesForConversion(sourceFile, force: _force);
// Discover other C# files.
SourceFile? entryPointSourceFile = isFile ? VirtualProjectBuildingCommand.LoadSourceFile(fileOrDirectory) : null;
VirtualProjectBuildingCommand.DiscoverOtherFiles(
entryPointFile: entryPointSourceFile,
entryDirectory: isFile ? null : new DirectoryInfo(fileOrDirectory),
parseDirectivesFromOtherEntryPoints: true,
reportAllDirectiveErrors: !_force,
otherEntryPoints: out var otherEntryPoints,
parsedFiles: out var parsedFiles);

Directory.CreateDirectory(targetDirectory);
// If there are other entry points, a directory must be specified (so it's clear that we convert all the entry points, not just the specified one).
if (isFile && otherEntryPoints.Length != 0)
{
throw new GracefulException(CliCommandStrings.DirectoryMustBeSpecified, fileOrDirectory);
}

ReadOnlySpan<string> currentEntryPoint = entryPointSourceFile is { } file ? [file.Path] : [];
ReadOnlySpan<string> allEntryPoints = [.. currentEntryPoint, .. otherEntryPoints];

// Check there are some entry points.
if (allEntryPoints.Length == 0)
{
throw new GracefulException(CliCommandStrings.NoEntryPoints, fileOrDirectory);
}

// Discover other non-C# files and directories at the top level.
string sourceDirectory = isFile ? Path.GetDirectoryName(fileOrDirectory)! : fileOrDirectory;
string[] nonCSharpTopLevelFiles = Directory.EnumerateFiles(sourceDirectory)
.Where(f => !f.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
.ToArray();
string[] topLevelDirs = Directory.GetDirectories(sourceDirectory);

// We create a plan of what to do first. No changes are done here so we don't fail in an intermediate state.
// First we need to create Shared folder and copy all existing folders and non-entry-point or non-C# files to it.
// Then we convert the C# files (remove directives from them and create csproj files for the entry-point ones).
// That way we re-create the source directory structure inside the Shared folder and also handle a situation where user has a folder with the same name as one of the entry points
// (we need to move the folder first to Shared and then convert the entry point which will re-create the folder and copy the converted entry point into it).
var actions = new List<Action>();

var targetFile = Path.Join(targetDirectory, Path.GetFileName(file));
// Determine the base target directory.
string baseTargetDirectory;
if (_outputDirectory != null)
{
baseTargetDirectory = _outputDirectory;
actions.Add(() => Directory.CreateDirectory(baseTargetDirectory));
}
else
{
baseTargetDirectory = sourceDirectory;
}

string? sharedDirectory;
bool creatingSharedDirectory;

// If there were any directives, remove them from the file.
if (directives.Length != 0)
// If there are multiple entry points and some non-C# or non-entry-point files/dirs, we need a Shared folder.
Debug.Assert(parsedFiles.Count >= allEntryPoints.Length);
if (allEntryPoints.Length > 1 && (nonCSharpTopLevelFiles.Length > 0 || topLevelDirs.Length > 0 || parsedFiles.Count > allEntryPoints.Length))
{
VirtualProjectBuildingCommand.RemoveDirectivesFromFile(directives, sourceFile.Text, targetFile);
File.Delete(file);
sharedDirectory = Path.Join(baseTargetDirectory, _sharedDirectoryName);
actions.Add(() => Directory.CreateDirectory(sharedDirectory));
creatingSharedDirectory = true;
}
else
{
File.Move(file, targetFile);
// We also need to move other files to the target folder if it's specified.
sharedDirectory = _outputDirectory != null ? baseTargetDirectory : null;
creatingSharedDirectory = false;
}

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);
// Move non-C# files and directories.
if (nonCSharpTopLevelFiles.Length > 0 || topLevelDirs.Length > 0)
{
if (sharedDirectory != null)
{
actions.Add(() =>
{
foreach (var dir in topLevelDirs)
{
string target = GetTargetTopLevelPath(sharedDirectory, dir);
PathUtility.SafeRenameDirectory(dir, target);
}

foreach (var file in nonCSharpTopLevelFiles)
{
string target = GetTargetTopLevelPath(sharedDirectory, file);
File.Move(file, target);
}
});
}
}

// Process C# files.
foreach (var parsed in parsedFiles.Values)
{
string targetDirectory;
bool deleteSourceFiles;

if (parsed.IsEntryPoint)
{
Debug.Assert(string.IsNullOrEmpty(Path.GetDirectoryName(Path.GetRelativePath(relativeTo: sourceDirectory, path: parsed.File.Path))),
"Entry points are expected to be at the top level.");

string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(parsed.File.Path);

if (creatingSharedDirectory && string.Equals(fileNameWithoutExtension, _sharedDirectoryName, StringComparison.OrdinalIgnoreCase))
{
throw new GracefulException(CliCommandStrings.SharedDirectoryNameConflicts, _sharedDirectoryName);
}

// If there is a single entry point, generate the project directly in the output folder, otherwise create a subfolder.
if (allEntryPoints.Length > 1)
{
targetDirectory = Path.Join(baseTargetDirectory, fileNameWithoutExtension);
actions.Add(() => Directory.CreateDirectory(targetDirectory));
deleteSourceFiles = true;
}
else
{
targetDirectory = baseTargetDirectory;
deleteSourceFiles = _outputDirectory != null;
}

// Generate a project file.
string projectFile = Path.Join(targetDirectory, fileNameWithoutExtension + ".csproj");
actions.Add(() =>
{
using (var csprojStream = File.Open(projectFile, FileMode.Create, FileAccess.Write))
using (var csprojWriter = new StreamWriter(csprojStream, Encoding.UTF8))
{
VirtualProjectBuildingCommand.WriteProjectFile(csprojWriter, parsed.SortedDirectives, options: new ProjectWritingOptions.Converted
{
SharedDirectoryName = creatingSharedDirectory ? _sharedDirectoryName : null,
});
}
});
}
else
{
// If the file is nested, we have already moved it to the shared folder, so process it in place.
string? relativeDirectoryPath = Path.GetDirectoryName(Path.GetRelativePath(relativeTo: sourceDirectory, path: parsed.File.Path));
if (!string.IsNullOrEmpty(relativeDirectoryPath))
{
targetDirectory = Path.Join(sharedDirectory, relativeDirectoryPath);
deleteSourceFiles = false;
}
else if (sharedDirectory != null)
{
targetDirectory = sharedDirectory;
deleteSourceFiles = true;
}
else
{
Debug.Assert(_outputDirectory == null);
targetDirectory = baseTargetDirectory;
deleteSourceFiles = false;
}
}

// Remove directives. Write the converted file or move it if no conversion is needed.
string targetFilePath = GetTargetTopLevelPath(targetDirectory, parsed.File.Path);
actions.Add(() =>
{
if (VirtualProjectBuildingCommand.RemoveDirectivesFromFile(parsed.Directives, parsed.File.Text) is { } convertedEntryPointFileText)
{
using var stream = File.Open(targetFilePath, FileMode.Create, FileAccess.Write);
using var writer = new StreamWriter(stream, Encoding.UTF8);
convertedEntryPointFileText.Write(writer);

if (deleteSourceFiles)
{
File.Delete(parsed.File.Path);
}
}
else if (deleteSourceFiles)
{
File.Move(parsed.File.Path, targetFilePath);
}
});
}

// Execute actions.
actions.ForEach(static action => action());

return 0;

static string GetTargetTopLevelPath(string targetDirectory, string sourceFilePath)
{
return Path.Join(targetDirectory, Path.GetFileName(sourceFilePath));
}
}
}
Loading
Loading
0