-
Notifications
You must be signed in to change notification settings - Fork 1.1k
sln-add: Refactor logic for autogenerating solution folders #48726
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
Changes from all commits
99a34cc
1e98530
e3aad95
c757d22
dd3b751
d90f7dc
8c01447
eeba9b4
2ea8c3b
2d94a1a
17e5798
d63a078
8f33509
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -15,12 +15,11 @@ namespace Microsoft.DotNet.Cli.Commands.Solution.Add; | |||||||
|
||||||||
internal class SolutionAddCommand : CommandBase | ||||||||
{ | ||||||||
private static readonly string[] _defaultPlatforms = ["Any CPU", "x64", "x86"]; | ||||||||
private static readonly string[] _defaultBuildTypes = ["Debug", "Release"]; | ||||||||
private readonly string _fileOrDirectory; | ||||||||
private readonly bool _inRoot; | ||||||||
private readonly IReadOnlyCollection<string> _projects; | ||||||||
private readonly string? _solutionFolderPath; | ||||||||
private string _solutionFileFullPath = string.Empty; | ||||||||
|
||||||||
private static string GetSolutionFolderPathWithForwardSlashes(string path) | ||||||||
{ | ||||||||
|
@@ -29,13 +28,21 @@ private static string GetSolutionFolderPathWithForwardSlashes(string path) | |||||||
return "/" + string.Join("/", PathUtility.GetPathWithDirectorySeparator(path).Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) + "/"; | ||||||||
} | ||||||||
|
||||||||
private static bool IsSolutionFolderPathInDirectoryScope(string relativePath) | ||||||||
{ | ||||||||
return !string.IsNullOrWhiteSpace(relativePath) | ||||||||
&& !Path.IsPathRooted(relativePath) // This means path is in a different volume | ||||||||
&& !relativePath.StartsWith(".."); // This means path is outside the solution directory | ||||||||
} | ||||||||
|
||||||||
public SolutionAddCommand(ParseResult parseResult) : base(parseResult) | ||||||||
{ | ||||||||
_fileOrDirectory = parseResult.GetValue(SolutionCommandParser.SlnArgument); | ||||||||
_projects = (IReadOnlyCollection<string>)(parseResult.GetValue(SolutionAddCommandParser.ProjectPathArgument) ?? []); | ||||||||
_inRoot = parseResult.GetValue(SolutionAddCommandParser.InRootOption); | ||||||||
_solutionFolderPath = parseResult.GetValue(SolutionAddCommandParser.SolutionFolderOption); | ||||||||
SolutionArgumentValidator.ParseAndValidateArguments(_fileOrDirectory, _projects, SolutionArgumentValidator.CommandType.Add, _inRoot, _solutionFolderPath); | ||||||||
_solutionFileFullPath = SlnFileFactory.GetSolutionFileFullPath(_fileOrDirectory); | ||||||||
} | ||||||||
|
||||||||
public override int Execute() | ||||||||
|
@@ -44,115 +51,135 @@ public override int Execute() | |||||||
{ | ||||||||
throw new GracefulException(CliStrings.SpecifyAtLeastOneProjectToAdd); | ||||||||
} | ||||||||
string solutionFileFullPath = SlnFileFactory.GetSolutionFileFullPath(_fileOrDirectory); | ||||||||
|
||||||||
try | ||||||||
// Get project paths from the command line arguments | ||||||||
PathUtility.EnsureAllPathsExist(_projects, CliStrings.CouldNotFindProjectOrDirectory, true); | ||||||||
|
||||||||
IEnumerable<string> fullProjectPaths = _projects.Select(project => | ||||||||
{ | ||||||||
PathUtility.EnsureAllPathsExist(_projects, CliStrings.CouldNotFindProjectOrDirectory, true); | ||||||||
IEnumerable<string> fullProjectPaths = _projects.Select(project => | ||||||||
{ | ||||||||
var fullPath = Path.GetFullPath(project); | ||||||||
return Directory.Exists(fullPath) ? MsbuildProject.GetProjectFileFromDirectory(fullPath).FullName : fullPath; | ||||||||
}); | ||||||||
AddProjectsToSolutionAsync(solutionFileFullPath, fullProjectPaths, CancellationToken.None).GetAwaiter().GetResult(); | ||||||||
return 0; | ||||||||
var fullPath = Path.GetFullPath(project); | ||||||||
return Directory.Exists(fullPath) ? MsbuildProject.GetProjectFileFromDirectory(fullPath).FullName : fullPath; | ||||||||
}); | ||||||||
|
||||||||
// Add projects to the solution | ||||||||
AddProjectsToSolutionAsync(fullProjectPaths, CancellationToken.None).GetAwaiter().GetResult(); | ||||||||
return 0; | ||||||||
} | ||||||||
|
||||||||
private SolutionFolderModel? GenerateIntermediateSolutionFoldersForProjectPath(SolutionModel solution, string relativeProjectPath) | ||||||||
{ | ||||||||
if (_inRoot) | ||||||||
{ | ||||||||
return null; | ||||||||
} | ||||||||
catch (Exception ex) when (ex is not GracefulException) | ||||||||
|
||||||||
string relativeSolutionFolderPath = string.Empty; | ||||||||
|
||||||||
if (string.IsNullOrEmpty(_solutionFolderPath)) | ||||||||
{ | ||||||||
// Generate the solution folder path based on the project path | ||||||||
relativeSolutionFolderPath = Path.GetDirectoryName(relativeProjectPath); | ||||||||
|
||||||||
// If the project is in a folder with the same name as the project, we need to go up one level | ||||||||
if (relativeSolutionFolderPath.Split(Path.DirectorySeparatorChar).LastOrDefault() == Path.GetFileNameWithoutExtension(relativeProjectPath)) | ||||||||
{ | ||||||||
relativeSolutionFolderPath = Path.Combine([.. relativeSolutionFolderPath.Split(Path.DirectorySeparatorChar).SkipLast(1)]); | ||||||||
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. [nitpick] Using a collection expression directly within Path.Combine may reduce code clarity. Consider extracting the computed array into a separate variable before passing it to Path.Combine.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||
} | ||||||||
|
||||||||
// If the generated path is outside the solution directory, we need to set it to empty | ||||||||
if (!IsSolutionFolderPathInDirectoryScope(relativeSolutionFolderPath)) | ||||||||
{ | ||||||||
if (ex is SolutionException || ex.InnerException is SolutionException) | ||||||||
{ | ||||||||
throw new GracefulException(CliStrings.InvalidSolutionFormatString, solutionFileFullPath, ex.Message); | ||||||||
} | ||||||||
throw new GracefulException(ex.Message, ex); | ||||||||
relativeSolutionFolderPath = string.Empty; | ||||||||
} | ||||||||
} | ||||||||
else | ||||||||
{ | ||||||||
// Use the provided solution folder path | ||||||||
relativeSolutionFolderPath = _solutionFolderPath; | ||||||||
} | ||||||||
|
||||||||
return string.IsNullOrEmpty(relativeSolutionFolderPath) | ||||||||
? null | ||||||||
: solution.AddFolder(GetSolutionFolderPathWithForwardSlashes(relativeSolutionFolderPath)); | ||||||||
} | ||||||||
|
||||||||
private async Task AddProjectsToSolutionAsync(string solutionFileFullPath, IEnumerable<string> projectPaths, CancellationToken cancellationToken) | ||||||||
private async Task AddProjectsToSolutionAsync(IEnumerable<string> projectPaths, CancellationToken cancellationToken) | ||||||||
{ | ||||||||
SolutionModel solution = SlnFileFactory.CreateFromFileOrDirectory(solutionFileFullPath); | ||||||||
SolutionModel solution = SlnFileFactory.CreateFromFileOrDirectory(_solutionFileFullPath); | ||||||||
ISolutionSerializer serializer = solution.SerializerExtension.Serializer; | ||||||||
|
||||||||
// set UTF8 BOM encoding for .sln | ||||||||
if (serializer is ISolutionSerializer<SlnV12SerializerSettings> v12Serializer) | ||||||||
{ | ||||||||
solution.SerializerExtension = v12Serializer.CreateModelExtension(new() | ||||||||
{ | ||||||||
Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true) | ||||||||
}); | ||||||||
|
||||||||
// Set default configurations and platforms for sln file | ||||||||
foreach (var platform in _defaultPlatforms) | ||||||||
foreach (var platform in SlnFileFactory.DefaultPlatforms) | ||||||||
{ | ||||||||
solution.AddPlatform(platform); | ||||||||
} | ||||||||
foreach (var buildType in _defaultBuildTypes) | ||||||||
|
||||||||
foreach (var buildType in SlnFileFactory.DefaultBuildTypes) | ||||||||
{ | ||||||||
solution.AddBuildType(buildType); | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
SolutionFolderModel? solutionFolder = !_inRoot && !string.IsNullOrEmpty(_solutionFolderPath) | ||||||||
? solution.AddFolder(GetSolutionFolderPathWithForwardSlashes(_solutionFolderPath)) | ||||||||
: null; | ||||||||
|
||||||||
foreach (var projectPath in projectPaths) | ||||||||
{ | ||||||||
string relativePath = Path.GetRelativePath(Path.GetDirectoryName(solutionFileFullPath), projectPath); | ||||||||
// Add fallback solution folder if relative path does not contain `..`. | ||||||||
string relativeSolutionFolder = relativePath.Split(Path.DirectorySeparatorChar).Any(p => p == "..") | ||||||||
? string.Empty : Path.GetDirectoryName(relativePath); | ||||||||
|
||||||||
if (!_inRoot && solutionFolder is null && !string.IsNullOrEmpty(relativeSolutionFolder)) | ||||||||
{ | ||||||||
if (relativeSolutionFolder.Split(Path.DirectorySeparatorChar).LastOrDefault() == Path.GetFileNameWithoutExtension(relativePath)) | ||||||||
{ | ||||||||
relativeSolutionFolder = Path.Combine([.. relativeSolutionFolder.Split(Path.DirectorySeparatorChar).SkipLast(1)]); | ||||||||
} | ||||||||
if (!string.IsNullOrEmpty(relativeSolutionFolder)) | ||||||||
{ | ||||||||
solutionFolder = solution.AddFolder(GetSolutionFolderPathWithForwardSlashes(relativeSolutionFolder)); | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
try | ||||||||
{ | ||||||||
AddProject(solution, relativePath, projectPath, solutionFolder, serializer); | ||||||||
} | ||||||||
catch (InvalidProjectFileException ex) | ||||||||
{ | ||||||||
Reporter.Error.WriteLine(string.Format(CliStrings.InvalidProjectWithExceptionMessage, projectPath, ex.Message)); | ||||||||
} | ||||||||
catch (SolutionArgumentException ex) when (solution.FindProject(relativePath) != null || ex.Type == SolutionErrorType.DuplicateProjectName) | ||||||||
{ | ||||||||
Reporter.Output.WriteLine(CliStrings.SolutionAlreadyContainsProject, solutionFileFullPath, relativePath); | ||||||||
} | ||||||||
AddProject(solution, projectPath, serializer); | ||||||||
} | ||||||||
await serializer.SaveAsync(solutionFileFullPath, solution, cancellationToken); | ||||||||
|
||||||||
await serializer.SaveAsync(_solutionFileFullPath, solution, cancellationToken); | ||||||||
} | ||||||||
|
||||||||
private static void AddProject(SolutionModel solution, string solutionRelativeProjectPath, string fullPath, SolutionFolderModel? solutionFolder, ISolutionSerializer serializer = null) | ||||||||
private void AddProject(SolutionModel solution, string fullProjectPath, ISolutionSerializer serializer = null) | ||||||||
{ | ||||||||
string solutionRelativeProjectPath = Path.GetRelativePath(Path.GetDirectoryName(_solutionFileFullPath), fullProjectPath); | ||||||||
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. [nitpick] The variable name 'solutionRelativeProjectPath' is used in multiple scopes with similar contexts. Renaming one occurrence (or scoping it more tightly) may improve code clarity and reduce potential confusion.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||
|
||||||||
// Open project instance to see if it is a valid project | ||||||||
ProjectRootElement projectRootElement = ProjectRootElement.Open(fullPath); | ||||||||
ProjectRootElement projectRootElement; | ||||||||
try | ||||||||
{ | ||||||||
projectRootElement = ProjectRootElement.Open(fullProjectPath); | ||||||||
} | ||||||||
catch (InvalidProjectFileException ex) | ||||||||
{ | ||||||||
Reporter.Error.WriteLine(string.Format(CliStrings.InvalidProjectWithExceptionMessage, fullProjectPath, ex.Message)); | ||||||||
return; | ||||||||
} | ||||||||
|
||||||||
ProjectInstance projectInstance = new ProjectInstance(projectRootElement); | ||||||||
|
||||||||
string projectTypeGuid = solution.ProjectTypes.FirstOrDefault(t => t.Extension == Path.GetExtension(fullProjectPath))?.ProjectTypeId.ToString() | ||||||||
?? projectRootElement.GetProjectTypeGuid() ?? projectInstance.GetDefaultProjectTypeGuid(); | ||||||||
|
||||||||
// Generate the solution folder path based on the project path | ||||||||
SolutionFolderModel? solutionFolder = GenerateIntermediateSolutionFoldersForProjectPath(solution, solutionRelativeProjectPath); | ||||||||
|
||||||||
SolutionProjectModel project; | ||||||||
|
||||||||
try | ||||||||
{ | ||||||||
project = solution.AddProject(solutionRelativeProjectPath, null, solutionFolder); | ||||||||
project = solution.AddProject(solutionRelativeProjectPath, projectTypeGuid, solutionFolder); | ||||||||
} | ||||||||
catch (SolutionArgumentException ex) when (ex.ParamName == "projectTypeName") | ||||||||
catch (SolutionArgumentException ex) when (ex.Type == SolutionErrorType.InvalidProjectTypeReference) | ||||||||
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. == is seldom best except for primitives |
||||||||
{ | ||||||||
// If guid is not identified by vs-solutionpersistence, check in project element itself | ||||||||
var guid = projectRootElement.GetProjectTypeGuid() ?? projectInstance.GetDefaultProjectTypeGuid(); | ||||||||
if (string.IsNullOrEmpty(guid)) | ||||||||
{ | ||||||||
Reporter.Error.WriteLine(CliStrings.UnsupportedProjectType, fullPath); | ||||||||
return; | ||||||||
} | ||||||||
project = solution.AddProject(solutionRelativeProjectPath, guid, solutionFolder); | ||||||||
Reporter.Error.WriteLine(CliStrings.UnsupportedProjectType, fullProjectPath); | ||||||||
return; | ||||||||
} | ||||||||
catch (SolutionArgumentException ex) when (ex.Type == SolutionErrorType.DuplicateProjectName || solution.FindProject(solutionRelativeProjectPath) is not null) | ||||||||
{ | ||||||||
Reporter.Output.WriteLine(CliStrings.SolutionAlreadyContainsProject, _solutionFileFullPath, solutionRelativeProjectPath); | ||||||||
return; | ||||||||
} | ||||||||
|
||||||||
// Add settings based on existing project instance | ||||||||
string projectInstanceId = projectInstance.GetProjectId(); | ||||||||
|
||||||||
if (!string.IsNullOrEmpty(projectInstanceId) && serializer is ISolutionSerializer<SlnV12SerializerSettings>) | ||||||||
{ | ||||||||
project.Id = new Guid(projectInstanceId); | ||||||||
|
@@ -164,7 +191,7 @@ private static void AddProject(SolutionModel solution, string solutionRelativePr | |||||||
foreach (var solutionPlatform in solution.Platforms) | ||||||||
{ | ||||||||
var projectPlatform = projectInstancePlatforms.FirstOrDefault( | ||||||||
platform => platform.Replace(" ", string.Empty) == solutionPlatform.Replace(" ", string.Empty), projectInstancePlatforms.FirstOrDefault()); | ||||||||
platform => platform.Replace(" ", string.Empty) == solutionPlatform.Replace(" ", string.Empty), projectInstancePlatforms.FirstOrDefault()); | ||||||||
project.AddProjectConfigurationRule(new ConfigurationRule(BuildDimension.Platform, "*", solutionPlatform, projectPlatform)); | ||||||||
} | ||||||||
|
||||||||
|
@@ -174,6 +201,7 @@ private static void AddProject(SolutionModel solution, string solutionRelativePr | |||||||
buildType => buildType.Replace(" ", string.Empty) == solutionBuildType.Replace(" ", string.Empty), projectInstanceBuildTypes.FirstOrDefault()); | ||||||||
project.AddProjectConfigurationRule(new ConfigurationRule(BuildDimension.BuildType, solutionBuildType, "*", projectBuildType)); | ||||||||
} | ||||||||
|
||||||||
Reporter.Output.WriteLine(CliStrings.ProjectAddedToTheSolution, solutionRelativeProjectPath); | ||||||||
} | ||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
|
||
using System; | ||
using System.Text.Json; | ||
using Microsoft.DotNet.Cli.Utils; | ||
using Microsoft.VisualStudio.SolutionPersistence; | ||
|
@@ -11,6 +12,9 @@ namespace Microsoft.DotNet.Cli; | |
|
||
public static class SlnFileFactory | ||
{ | ||
public static readonly string[] DefaultPlatforms = new[] { "Any CPU", "x64", "x86" }; | ||
joeloff marked this conversation as resolved.
Show resolved
Hide resolved
|
||
public static readonly string[] DefaultBuildTypes = new[] { "Debug", "Release" }; | ||
|
||
public static string GetSolutionFileFullPath(string slnFileOrDirectory, bool includeSolutionFilterFiles = false, bool includeSolutionXmlFiles = true) | ||
{ | ||
// Throw error if slnFileOrDirectory is an invalid path | ||
|
@@ -63,11 +67,17 @@ public static SolutionModel CreateFromFileOrDirectory(string fileOrDirectory, bo | |
{ | ||
return CreateFromFilteredSolutionFile(solutionPath); | ||
} | ||
ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionPath) ?? throw new GracefulException( | ||
CliStrings.CouldNotFindSolutionOrDirectory, | ||
solutionPath); | ||
|
||
return serializer.OpenAsync(solutionPath, CancellationToken.None).Result; | ||
try | ||
{ | ||
ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionPath)!; | ||
return serializer.OpenAsync(solutionPath, CancellationToken.None).Result; | ||
} | ||
catch (Exception ex) | ||
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. The catch block in CreateFromFileOrDirectory rethrows a new GracefulException using only ex.Message, which can lose the original stack trace. Consider including the exception object itself (e.g. passing 'ex' instead of 'ex.Message') to aid in debugging. Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||
{ | ||
throw new GracefulException( | ||
CliStrings.InvalidSolutionFormatString, | ||
solutionPath, ex.Message); | ||
} | ||
} | ||
|
||
public static SolutionModel CreateFromFilteredSolutionFile(string filteredSolutionPath) | ||
|
Uh oh!
There was an error while loading. Please reload this page.