-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
base: main
Are you sure you want to change the base?
Changes from 1 commit
500d1ce
d3f6d2f
d9c7f53
2422ec7
40ca51d
b2c5cd9
358575f
76d829c
e2cbf47
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
|
@@ -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))) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't fully understand the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
jjonescz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
// 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); | ||
jjonescz marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} | ||
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)); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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:
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.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.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.