Skip to content

Commit 043d4ae

Browse files
committed
perf: batch individual file inputs for parallel formatting
When multiple individual file paths are passed to CSharpier (e.g. from pre-commit hooks), each file was previously processed sequentially with its own OptionsProvider and FormattingCache. This caused severe performance degradation (~15x slower than directory input). Refactored FormatPhysicalFiles to collect individual file paths, compute their common ancestor directory, and create a single shared OptionsProvider and FormattingCache. Files are then formatted in parallel using Task.WhenAll, matching the directory input code path. Added tests for multi-file formatting, per-directory config resolution, mixed file/directory input, and the GetCommonAncestor helper method.
1 parent 189cdc0 commit 043d4ae

2 files changed

Lines changed: 285 additions & 91 deletions

File tree

Src/CSharpier.Cli/CommandLineFormatter.cs

Lines changed: 196 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,15 @@ CancellationToken cancellationToken
178178
writer = new FileSystemFormattedFileWriter(fileSystem);
179179
}
180180

181+
// Collect individual file paths so they can be processed in batches
182+
// instead of one-by-one (which re-creates OptionsProvider, reads/writes
183+
// the formatting cache, and loses parallelism for every single file).
184+
var pendingFiles = new List<(
185+
string DirectoryName,
186+
string ActualPath,
187+
string OriginalPath
188+
)>();
189+
181190
for (var x = 0; x < commandLineOptions.DirectoryOrFilePaths.Length; x++)
182191
{
183192
var directoryOrFilePath = commandLineOptions.DirectoryOrFilePaths[x];
@@ -193,30 +202,8 @@ CancellationToken cancellationToken
193202
return 1;
194203
}
195204

196-
var directoryName = isFile
197-
? fileSystem.Path.GetDirectoryName(directoryOrFilePath)
198-
: directoryOrFilePath;
199-
200-
ArgumentNullException.ThrowIfNull(directoryName);
201-
202-
var optionsProvider = await OptionsProvider.Create(
203-
directoryName,
204-
commandLineOptions.ConfigPath,
205-
commandLineOptions.IgnorePath,
206-
fileSystem,
207-
logger,
208-
cancellationToken
209-
);
210-
211205
var originalDirectoryOrFile = commandLineOptions.OriginalDirectoryOrFilePaths[x];
212206

213-
var formattingCache = await FormattingCacheFactory.InitializeAsync(
214-
commandLineOptions,
215-
optionsProvider,
216-
fileSystem,
217-
cancellationToken
218-
);
219-
220207
if (!Path.IsPathRooted(originalDirectoryOrFile))
221208
{
222209
if (!originalDirectoryOrFile.StartsWith('.'))
@@ -226,81 +213,32 @@ CancellationToken cancellationToken
226213
}
227214
}
228215

229-
async IAsyncEnumerable<string> EnumerateNonignoredFiles(string directory)
216+
if (isFile)
230217
{
231-
foreach (var file in fileSystem.Directory.EnumerateFiles(directory))
232-
{
233-
yield return file;
234-
}
235-
236-
foreach (var subdirectory in fileSystem.Directory.EnumerateDirectories(directory))
237-
{
238-
if (await optionsProvider.IsIgnoredAsync(subdirectory, cancellationToken))
239-
{
240-
continue;
241-
}
242-
243-
await foreach (var file in EnumerateNonignoredFiles(subdirectory))
244-
{
245-
yield return file;
246-
}
247-
}
218+
var directoryName = fileSystem.Path.GetDirectoryName(directoryOrFilePath);
219+
ArgumentNullException.ThrowIfNull(directoryName);
220+
pendingFiles.Add((directoryName, directoryOrFilePath, originalDirectoryOrFile));
248221
}
249-
250-
async Task FormatFile(
251-
string actualFilePath,
252-
string originalFilePath,
253-
bool warnForUnsupported = false
254-
)
222+
else if (isDirectory)
255223
{
256-
if (
257-
(
258-
!commandLineOptions.IncludeGenerated
259-
&& GeneratedCodeUtilities.IsGeneratedCodeFile(actualFilePath)
260-
) || await optionsProvider.IsIgnoredAsync(actualFilePath, cancellationToken)
261-
)
262-
{
263-
return;
264-
}
224+
var directoryName = directoryOrFilePath;
265225

266-
var printerOptions = await optionsProvider.GetPrinterOptionsForAsync(
267-
actualFilePath,
226+
var optionsProvider = await OptionsProvider.Create(
227+
directoryName,
228+
commandLineOptions.ConfigPath,
229+
commandLineOptions.IgnorePath,
230+
fileSystem,
231+
logger,
268232
cancellationToken
269233
);
270234

271-
if (printerOptions is { Formatter: not Formatter.Unknown })
272-
{
273-
printerOptions.IncludeGenerated = commandLineOptions.IncludeGenerated;
274-
await FormatPhysicalFile(
275-
actualFilePath,
276-
originalFilePath,
277-
fileSystem,
278-
logger,
279-
commandLineFormatterResult,
280-
writer,
281-
commandLineOptions,
282-
printerOptions,
283-
formattingCache,
284-
cancellationToken
285-
);
286-
}
287-
else if (warnForUnsupported)
288-
{
289-
var fileIssueLogger = new FileIssueLogger(
290-
originalFilePath,
291-
logger,
292-
logFormat: LogFormat.Console
293-
);
294-
fileIssueLogger.WriteWarning("Is an unsupported file type.");
295-
}
296-
}
235+
var formattingCache = await FormattingCacheFactory.InitializeAsync(
236+
commandLineOptions,
237+
optionsProvider,
238+
fileSystem,
239+
cancellationToken
240+
);
297241

298-
if (isFile)
299-
{
300-
await FormatFile(directoryOrFilePath, originalDirectoryOrFile, true);
301-
}
302-
else if (isDirectory)
303-
{
304242
if (
305243
!commandLineOptions.NoMSBuildCheck
306244
&& await HasMismatchedCliAndMsBuildVersions.Check(
@@ -314,14 +252,47 @@ await FormatPhysicalFile(
314252
return 1;
315253
}
316254

255+
async IAsyncEnumerable<string> EnumerateNonignoredFiles(string directory)
256+
{
257+
foreach (var file in fileSystem.Directory.EnumerateFiles(directory))
258+
{
259+
yield return file;
260+
}
261+
262+
foreach (
263+
var subdirectory in fileSystem.Directory.EnumerateDirectories(directory)
264+
)
265+
{
266+
if (
267+
await optionsProvider.IsIgnoredAsync(subdirectory, cancellationToken)
268+
)
269+
{
270+
continue;
271+
}
272+
273+
await foreach (var file in EnumerateNonignoredFiles(subdirectory))
274+
{
275+
yield return file;
276+
}
277+
}
278+
}
279+
317280
var tasks = new List<Task>();
318281
await foreach (
319282
var file in EnumerateNonignoredFiles(directoryOrFilePath)
320283
.WithCancellation(cancellationToken)
321284
)
322285
{
323286
var relativePath = file.Replace(directoryOrFilePath, originalDirectoryOrFile);
324-
tasks.Add(FormatFile(file, relativePath));
287+
tasks.Add(
288+
FormatFile(
289+
file,
290+
relativePath,
291+
optionsProvider,
292+
formattingCache,
293+
warnForUnsupported: false
294+
)
295+
);
325296
}
326297

327298
try
@@ -335,12 +306,123 @@ var file in EnumerateNonignoredFiles(directoryOrFilePath)
335306
throw;
336307
}
337308
}
309+
310+
await formattingCache.ResolveAsync(cancellationToken);
311+
}
312+
}
313+
314+
// Process individual files using a single shared OptionsProvider
315+
// rooted at their common ancestor directory. This mirrors the directory
316+
// path: one config lookup with internal caching, one formatting cache,
317+
// and parallel formatting.
318+
if (pendingFiles.Count > 0)
319+
{
320+
// Find the common ancestor directory of all pending files so we
321+
// create exactly one OptionsProvider whose internal caches cover
322+
// every subdirectory (same as the directory-input path).
323+
var commonRoot = pendingFiles[0].DirectoryName;
324+
for (var i = 1; i < pendingFiles.Count; i++)
325+
{
326+
commonRoot = GetCommonAncestor(commonRoot, pendingFiles[i].DirectoryName);
327+
}
328+
329+
var optionsProvider = await OptionsProvider.Create(
330+
commonRoot,
331+
commandLineOptions.ConfigPath,
332+
commandLineOptions.IgnorePath,
333+
fileSystem,
334+
logger,
335+
cancellationToken
336+
);
337+
338+
var sharedFormattingCache = await FormattingCacheFactory.InitializeAsync(
339+
commandLineOptions,
340+
optionsProvider,
341+
fileSystem,
342+
cancellationToken
343+
);
344+
345+
var tasks = new List<Task>();
346+
347+
foreach (var (_, actualPath, originalPath) in pendingFiles)
348+
{
349+
tasks.Add(
350+
FormatFile(
351+
actualPath,
352+
originalPath,
353+
optionsProvider,
354+
sharedFormattingCache,
355+
warnForUnsupported: true
356+
)
357+
);
358+
}
359+
360+
try
361+
{
362+
await Task.WhenAll(tasks).WaitAsync(cancellationToken);
363+
}
364+
catch (OperationCanceledException ex)
365+
{
366+
if (ex.CancellationToken != cancellationToken)
367+
{
368+
throw;
369+
}
338370
}
339371

340-
await formattingCache.ResolveAsync(cancellationToken);
372+
await sharedFormattingCache.ResolveAsync(cancellationToken);
341373
}
342374

343375
return 0;
376+
377+
async Task FormatFile(
378+
string actualFilePath,
379+
string originalFilePath,
380+
OptionsProvider optionsProvider,
381+
IFormattingCache formattingCache,
382+
bool warnForUnsupported
383+
)
384+
{
385+
if (
386+
(
387+
!commandLineOptions.IncludeGenerated
388+
&& GeneratedCodeUtilities.IsGeneratedCodeFile(actualFilePath)
389+
) || await optionsProvider.IsIgnoredAsync(actualFilePath, cancellationToken)
390+
)
391+
{
392+
return;
393+
}
394+
395+
var printerOptions = await optionsProvider.GetPrinterOptionsForAsync(
396+
actualFilePath,
397+
cancellationToken
398+
);
399+
400+
if (printerOptions is { Formatter: not Formatter.Unknown })
401+
{
402+
printerOptions.IncludeGenerated = commandLineOptions.IncludeGenerated;
403+
await FormatPhysicalFile(
404+
actualFilePath,
405+
originalFilePath,
406+
fileSystem,
407+
logger,
408+
commandLineFormatterResult,
409+
writer,
410+
commandLineOptions,
411+
printerOptions,
412+
formattingCache,
413+
cancellationToken
414+
);
415+
}
416+
else if (warnForUnsupported)
417+
{
418+
var fileIssueLogger = new FileIssueLogger(
419+
originalFilePath,
420+
logger,
421+
logFormat: LogFormat.Console
422+
);
423+
fileIssueLogger.WriteWarning("Is an unsupported file type.");
424+
}
425+
}
344426
}
345427

346428
private static async Task FormatPhysicalFile(
@@ -386,6 +468,29 @@ await PerformFormattingSteps(
386468
);
387469
}
388470

471+
internal static string GetCommonAncestor(string pathA, string pathB)
472+
{
473+
var separators = new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar };
474+
var partsA = pathA.Split(separators);
475+
var partsB = pathB.Split(separators);
476+
var commonLength = 0;
477+
for (var i = 0; i < Math.Min(partsA.Length, partsB.Length); i++)
478+
{
479+
if (string.Equals(partsA[i], partsB[i], StringComparison.Ordinal))
480+
{
481+
commonLength = i + 1;
482+
}
483+
else
484+
{
485+
break;
486+
}
487+
}
488+
489+
return commonLength > 0
490+
? string.Join(Path.DirectorySeparatorChar, partsA.Take(commonLength))
491+
: pathA;
492+
}
493+
389494
private static int ReturnExitCode(
390495
CommandLineOptions commandLineOptions,
391496
CommandLineFormatterResult result

0 commit comments

Comments
 (0)