diff --git a/Src/CSharpier.Cli/CommandLineFormatter.cs b/Src/CSharpier.Cli/CommandLineFormatter.cs index fadb4ccb6..8b8ce97af 100644 --- a/Src/CSharpier.Cli/CommandLineFormatter.cs +++ b/Src/CSharpier.Cli/CommandLineFormatter.cs @@ -178,6 +178,8 @@ CancellationToken cancellationToken writer = new FileSystemFormattedFileWriter(fileSystem); } + var pendingFiles = new List<(string DirectoryName, string ActualPath, string OriginalPath)>(); + for (var x = 0; x < commandLineOptions.DirectoryOrFilePaths.Length; x++) { var directoryOrFilePath = commandLineOptions.DirectoryOrFilePaths[x]; @@ -193,11 +195,26 @@ CancellationToken cancellationToken return 1; } - var directoryName = isFile - ? fileSystem.Path.GetDirectoryName(directoryOrFilePath) - : directoryOrFilePath; + var originalDirectoryOrFile = commandLineOptions.OriginalDirectoryOrFilePaths[x]; + + if (!Path.IsPathRooted(originalDirectoryOrFile)) + { + if (!originalDirectoryOrFile.StartsWith('.')) + { + originalDirectoryOrFile = + "." + Path.DirectorySeparatorChar + originalDirectoryOrFile; + } + } + + if (isFile) + { + var fileDirectoryName = fileSystem.Path.GetDirectoryName(directoryOrFilePath); + ArgumentNullException.ThrowIfNull(fileDirectoryName); + pendingFiles.Add((fileDirectoryName, directoryOrFilePath, originalDirectoryOrFile)); + continue; + } - ArgumentNullException.ThrowIfNull(directoryName); + var directoryName = directoryOrFilePath; var optionsProvider = await OptionsProvider.Create( directoryName, @@ -208,8 +225,6 @@ CancellationToken cancellationToken cancellationToken ); - var originalDirectoryOrFile = commandLineOptions.OriginalDirectoryOrFilePaths[x]; - var formattingCache = await FormattingCacheFactory.InitializeAsync( commandLineOptions, optionsProvider, @@ -217,15 +232,6 @@ CancellationToken cancellationToken cancellationToken ); - if (!Path.IsPathRooted(originalDirectoryOrFile)) - { - if (!originalDirectoryOrFile.StartsWith('.')) - { - originalDirectoryOrFile = - "." + Path.DirectorySeparatorChar + originalDirectoryOrFile; - } - } - async IAsyncEnumerable EnumerateNonignoredFiles(string directory) { foreach (var file in fileSystem.Directory.EnumerateFiles(directory)) @@ -295,11 +301,7 @@ await FormatPhysicalFile( } } - if (isFile) - { - await FormatFile(directoryOrFilePath, originalDirectoryOrFile, true); - } - else if (isDirectory) + if (isDirectory) { if ( !commandLineOptions.NoMSBuildCheck @@ -340,7 +342,111 @@ var file in EnumerateNonignoredFiles(directoryOrFilePath) await formattingCache.ResolveAsync(cancellationToken); } + if (pendingFiles.Count == 0) + { + return 0; + } + + var commonRoot = pendingFiles[0].DirectoryName; + for (var i = 1; i < pendingFiles.Count; i++) + { + commonRoot = GetCommonAncestor(commonRoot, pendingFiles[i].DirectoryName); + } + + var batchOptionsProvider = await OptionsProvider.Create( + commonRoot, + commandLineOptions.ConfigPath, + commandLineOptions.IgnorePath, + fileSystem, + logger, + cancellationToken + ); + + var batchFormattingCache = await FormattingCacheFactory.InitializeAsync( + commandLineOptions, + batchOptionsProvider, + fileSystem, + cancellationToken + ); + + var pendingTasks = new List(); + + foreach (var (_, actualPath, originalPath) in pendingFiles) + { + pendingTasks.Add( + FormatPendingFile( + actualPath, + originalPath, + batchOptionsProvider, + batchFormattingCache + ) + ); + } + + try + { + await Task.WhenAll(pendingTasks).WaitAsync(cancellationToken); + } + catch (OperationCanceledException ex) + { + if (ex.CancellationToken != cancellationToken) + { + throw; + } + } + + await batchFormattingCache.ResolveAsync(cancellationToken); + return 0; + + async Task FormatPendingFile( + string actualFilePath, + string originalFilePath, + OptionsProvider optionsProvider, + IFormattingCache formattingCache + ) + { + if ( + ( + !commandLineOptions.IncludeGenerated + && GeneratedCodeUtilities.IsGeneratedCodeFile(actualFilePath) + ) || await optionsProvider.IsIgnoredAsync(actualFilePath, cancellationToken) + ) + { + return; + } + + var printerOptions = await optionsProvider.GetPrinterOptionsForAsync( + actualFilePath, + cancellationToken + ); + + if (printerOptions is { Formatter: not Formatter.Unknown }) + { + printerOptions.IncludeGenerated = commandLineOptions.IncludeGenerated; + await FormatPhysicalFile( + actualFilePath, + originalFilePath, + fileSystem, + logger, + commandLineFormatterResult, + writer, + commandLineOptions, + printerOptions, + formattingCache, + cancellationToken + ); + } + else + { + var fileIssueLogger = new FileIssueLogger( + originalFilePath, + logger, + logFormat: LogFormat.Console + ); + fileIssueLogger.WriteWarning("Is an unsupported file type."); + } + } } private static async Task FormatPhysicalFile( @@ -386,6 +492,29 @@ await PerformFormattingSteps( ); } + internal static string GetCommonAncestor(string pathA, string pathB) + { + var separators = new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }; + var partsA = pathA.Split(separators); + var partsB = pathB.Split(separators); + var commonLength = 0; + for (var i = 0; i < Math.Min(partsA.Length, partsB.Length); i++) + { + if (string.Equals(partsA[i], partsB[i], StringComparison.Ordinal)) + { + commonLength = i + 1; + } + else + { + break; + } + } + + return commonLength > 0 + ? string.Join(Path.DirectorySeparatorChar, partsA.Take(commonLength)) + : pathA; + } + private static int ReturnExitCode( CommandLineOptions commandLineOptions, CommandLineFormatterResult result diff --git a/Src/CSharpier.Tests/CommandLineFormatterTests.cs b/Src/CSharpier.Tests/CommandLineFormatterTests.cs index 288cd353b..cbe88f47f 100644 --- a/Src/CSharpier.Tests/CommandLineFormatterTests.cs +++ b/Src/CSharpier.Tests/CommandLineFormatterTests.cs @@ -562,6 +562,75 @@ public void Multiple_Files_Should_Use_Root_Ignore() result.OutputLines.FirstOrDefault().Should().StartWith("Formatted 0 files in "); } + [Test] + public void Multiple_Files_Should_Be_Formatted() + { + var context = new TestContext(); + context.WhenAFileExists("SubFolder/1/File1.cs", UnformattedClassContent); + context.WhenAFileExists("SubFolder/2/File2.cs", UnformattedClassContent); + + var result = Format( + context, + directoryOrFilePaths: ["SubFolder/1/File1.cs", "SubFolder/2/File2.cs"] + ); + + result.OutputLines.FirstOrDefault().Should().StartWith("Formatted 2 files in "); + context.GetFileContent("SubFolder/1/File1.cs").Should().Be(FormattedClassContent); + context.GetFileContent("SubFolder/2/File2.cs").Should().Be(FormattedClassContent); + } + + [Test] + public void Multiple_Files_Should_Respect_Per_Directory_Config() + { + var context = new TestContext(); + // file1 has a narrow printWidth, file2 uses default + context.WhenAFileExists("SubFolder/1/.csharpierrc", "printWidth: 10"); + context.WhenAFileExists( + "SubFolder/1/File1.cs", + "var myVariable = someLongValue;" + ); + context.WhenAFileExists( + "SubFolder/2/File2.cs", + "var myVariable = someLongValue;" + ); + + var result = Format( + context, + directoryOrFilePaths: ["SubFolder/1/File1.cs", "SubFolder/2/File2.cs"] + ); + + result.OutputLines.FirstOrDefault().Should().StartWith("Formatted 2 files in "); + // file1 should wrap due to printWidth: 10 + context + .GetFileContent("SubFolder/1/File1.cs") + .Should() + .Be("var myVariable =\n someLongValue;\n"); + // file2 should not wrap (default printWidth: 100) + context + .GetFileContent("SubFolder/2/File2.cs") + .Should() + .Be("var myVariable = someLongValue;\n"); + } + + [Test] + public void Mixed_Files_And_Directory_Should_Format_All() + { + var context = new TestContext(); + context.WhenAFileExists("DirA/File1.cs", UnformattedClassContent); + context.WhenAFileExists("DirA/File2.cs", UnformattedClassContent); + context.WhenAFileExists("SingleFile.cs", UnformattedClassContent); + + var result = Format( + context, + directoryOrFilePaths: ["DirA", "SingleFile.cs"] + ); + + result.OutputLines.FirstOrDefault().Should().StartWith("Formatted 3 files in "); + context.GetFileContent("DirA/File1.cs").Should().Be(FormattedClassContent); + context.GetFileContent("DirA/File2.cs").Should().Be(FormattedClassContent); + context.GetFileContent("SingleFile.cs").Should().Be(FormattedClassContent); + } + [Test] public void Multiple_Files_Should_Use_Multiple_Ignores() { @@ -1091,6 +1160,26 @@ public string GetFileContent(string path) } } + [Test] + [Arguments("/a/b/c", "/a/b/d", "/a/b")] + [Arguments("/a/b", "/a/b", "/a/b")] + [Arguments("/a/b/c/d", "/a/b", "/a/b")] + [Arguments("/a/x/y", "/a/z/w", "/a")] + [Arguments("/x/y", "/z/w", "")] + public void GetCommonAncestor_Returns_Common_Path( + string pathA, + string pathB, + string expected + ) + { + var sep = Path.DirectorySeparatorChar; + var result = CommandLineFormatter.GetCommonAncestor( + pathA.Replace('/', sep), + pathB.Replace('/', sep) + ); + result.Should().Be(expected.Replace('/', sep)); + } + private sealed record FormatResult( int ExitCode, IList OutputLines,