@@ -3,13 +3,17 @@ import { Linter } from "../linter.js"
33import { loadCustomRules } from "../loader.js"
44import { Config } from "@herb-tools/config"
55
6- import { readFileSync , writeFileSync } from "fs"
7- import { resolve } from "path"
6+ import { Worker } from "node:worker_threads"
7+ import { readFileSync , writeFileSync } from "node:fs"
8+ import { resolve , dirname , join } from "node:path"
9+ import { fileURLToPath } from "node:url"
10+ import { availableParallelism } from "node:os"
811import { colorize } from "@herb-tools/highlighter"
912
1013import type { Diagnostic } from "@herb-tools/core"
1114import type { FormatOption } from "./argument-parser.js"
1215import type { HerbConfigOptions } from "@herb-tools/config"
16+ import type { WorkerInput , WorkerResult } from "./lint-worker.js"
1317
1418export interface ProcessedFile {
1519 filename : string
@@ -20,13 +24,15 @@ export interface ProcessedFile {
2024
2125export interface ProcessingContext {
2226 projectPath ?: string
27+ configPath ?: string
2328 pattern ?: string
2429 fix ?: boolean
2530 fixUnsafe ?: boolean
2631 ignoreDisableComments ?: boolean
2732 linterConfig ?: HerbConfigOptions [ 'linter' ]
2833 config ?: Config
2934 loadCustomRules ?: boolean
35+ jobs ?: number
3036}
3137
3238export interface ProcessingResult {
@@ -44,6 +50,13 @@ export interface ProcessingResult {
4450 context ?: ProcessingContext
4551}
4652
53+ /**
54+ * Minimum number of files required to use parallel processing.
55+ * Below this threshold, sequential processing is faster due to
56+ * worker thread startup overhead (loading WASM, config, etc.).
57+ */
58+ const PARALLEL_FILE_THRESHOLD = 10
59+
4760export class FileProcessor {
4861 private linter : Linter | null = null
4962 private customRulesLoaded : boolean = false
@@ -61,6 +74,17 @@ export class FileProcessor {
6174 }
6275
6376 async processFiles ( files : string [ ] , formatOption : FormatOption = 'detailed' , context ?: ProcessingContext ) : Promise < ProcessingResult > {
77+ const jobs = context ?. jobs ?? 1
78+ const shouldParallelize = jobs > 1 && files . length >= PARALLEL_FILE_THRESHOLD
79+
80+ if ( shouldParallelize ) {
81+ return this . processFilesInParallel ( files , jobs , formatOption , context )
82+ }
83+
84+ return this . processFilesSequentially ( files , formatOption , context )
85+ }
86+
87+ private async processFilesSequentially ( files : string [ ] , formatOption : FormatOption = 'detailed' , context ?: ProcessingContext ) : Promise < ProcessingResult > {
6488 let totalErrors = 0
6589 let totalWarnings = 0
6690 let totalInfo = 0
@@ -223,4 +247,161 @@ export class FileProcessor {
223247
224248 return result
225249 }
250+
251+ private async processFilesInParallel ( files : string [ ] , jobs : number , formatOption : FormatOption , context ?: ProcessingContext ) : Promise < ProcessingResult > {
252+ const workerCount = Math . min ( jobs , files . length )
253+ const chunks = this . splitIntoChunks ( files , workerCount )
254+ const workerPath = this . resolveWorkerPath ( )
255+
256+ const workerPromises = chunks . map ( chunk => this . runWorker ( workerPath , chunk , context ) )
257+ const workerResults = await Promise . all ( workerPromises )
258+
259+ for ( const result of workerResults ) {
260+ if ( result . error ) {
261+ throw new Error ( `Worker error: ${ result . error } ` )
262+ }
263+ }
264+
265+ return this . aggregateWorkerResults ( workerResults , formatOption , context )
266+ }
267+
268+ private resolveWorkerPath ( ) : string {
269+ try {
270+ const currentDir = dirname ( fileURLToPath ( import . meta. url ) )
271+
272+ return join ( currentDir , "lint-worker.js" )
273+ } catch {
274+ return join ( __dirname , "lint-worker.js" )
275+ }
276+ }
277+
278+ private splitIntoChunks ( files : string [ ] , chunkCount : number ) : string [ ] [ ] {
279+ const chunks : string [ ] [ ] = Array . from ( { length : chunkCount } , ( ) => [ ] )
280+
281+ for ( let i = 0 ; i < files . length ; i ++ ) {
282+ chunks [ i % chunkCount ] . push ( files [ i ] )
283+ }
284+
285+ return chunks . filter ( chunk => chunk . length > 0 )
286+ }
287+
288+ private runWorker ( workerPath : string , files : string [ ] , context ?: ProcessingContext ) : Promise < WorkerResult > {
289+ return new Promise ( ( resolve , reject ) => {
290+ const workerData : WorkerInput = {
291+ files,
292+ projectPath : context ?. projectPath || process . cwd ( ) ,
293+ configPath : context ?. configPath ,
294+ fix : context ?. fix || false ,
295+ fixUnsafe : context ?. fixUnsafe || false ,
296+ ignoreDisableComments : context ?. ignoreDisableComments || false ,
297+ loadCustomRules : context ?. loadCustomRules || false ,
298+ }
299+
300+ const worker = new Worker ( workerPath , { workerData } )
301+
302+ worker . on ( "message" , ( result : WorkerResult ) => {
303+ resolve ( result )
304+ } )
305+
306+ worker . on ( "error" , ( error ) => {
307+ reject ( error )
308+ } )
309+
310+ worker . on ( "exit" , ( code ) => {
311+ if ( code !== 0 ) {
312+ reject ( new Error ( `Worker exited with code ${ code } ` ) )
313+ }
314+ } )
315+ } )
316+ }
317+
318+ private aggregateWorkerResults ( results : WorkerResult [ ] , formatOption : FormatOption , context ?: ProcessingContext ) : ProcessingResult {
319+ let totalErrors = 0
320+ let totalWarnings = 0
321+ let totalInfo = 0
322+ let totalHints = 0
323+ let totalIgnored = 0
324+ let totalWouldBeIgnored = 0
325+ let filesWithOffenses = 0
326+ let filesFixed = 0
327+ let ruleCount = 0
328+
329+ const allOffenses : ProcessedFile [ ] = [ ]
330+ const ruleOffenses = new Map < string , { count : number , files : Set < string > } > ( )
331+
332+ for ( const result of results ) {
333+ totalErrors += result . totalErrors
334+ totalWarnings += result . totalWarnings
335+ totalInfo += result . totalInfo
336+ totalHints += result . totalHints
337+ totalIgnored += result . totalIgnored
338+ totalWouldBeIgnored += result . totalWouldBeIgnored
339+ filesWithOffenses += result . filesWithOffenses
340+ filesFixed += result . filesFixed
341+
342+ if ( result . ruleCount > 0 ) {
343+ ruleCount = result . ruleCount
344+ }
345+
346+ for ( const offense of result . offenses ) {
347+ allOffenses . push ( {
348+ filename : offense . filename ,
349+ offense : offense . offense ,
350+ content : offense . content ,
351+ autocorrectable : offense . autocorrectable
352+ } )
353+ }
354+
355+ for ( const [ rule , data ] of result . ruleOffenses ) {
356+ const existing = ruleOffenses . get ( rule ) || { count : 0 , files : new Set < string > ( ) }
357+ existing . count += data . count
358+
359+ for ( const file of data . files ) {
360+ existing . files . add ( file )
361+ }
362+
363+ ruleOffenses . set ( rule , existing )
364+ }
365+
366+ if ( formatOption !== 'json' ) {
367+ for ( const fixMessage of result . fixMessages ) {
368+ const [ filename , countStr ] = fixMessage . split ( "\t" )
369+ const count = parseInt ( countStr , 10 )
370+ console . log ( `${ colorize ( "\u2713" , "brightGreen" ) } ${ colorize ( filename , "cyan" ) } - ${ colorize ( `Fixed ${ count } ${ count === 1 ? "offense" : "offenses" } ` , "green" ) } ` )
371+ }
372+ }
373+ }
374+
375+ const processingResult : ProcessingResult = {
376+ totalErrors,
377+ totalWarnings,
378+ totalInfo,
379+ totalHints,
380+ totalIgnored,
381+ filesWithOffenses,
382+ filesFixed,
383+ ruleCount,
384+ allOffenses,
385+ ruleOffenses,
386+ context
387+ }
388+
389+ if ( totalWouldBeIgnored > 0 ) {
390+ processingResult . totalWouldBeIgnored = totalWouldBeIgnored
391+ }
392+
393+ return processingResult
394+ }
395+
396+ /**
397+ * Returns the default number of parallel jobs based on available CPU cores.
398+ * Returns 1 if parallelism detection fails.
399+ */
400+ static defaultJobs ( ) : number {
401+ try {
402+ return availableParallelism ( )
403+ } catch {
404+ return 1
405+ }
406+ }
226407}
0 commit comments