import type { CompilerOptions, Diagnostic, Program } from 'typescript'; import typescript from 'typescript'; import * as smartdelay from '@push.rocks/smartdelay'; import * as smartpromise from '@push.rocks/smartpromise'; import * as smartpath from '@push.rocks/smartpath'; import { TsConfig } from '../mod_config/index.js'; import { FsHelpers } from '../mod_fs/index.js'; import { performUnpack } from '../mod_unpack/index.js'; /** * Interface for error summary data */ export interface IErrorSummary { errorsByFile: Record; generalErrors: Diagnostic[]; totalErrors: number; totalFiles: number; } /** * Interface for task information */ export interface ITaskInfo { taskNumber: number; totalTasks: number; sourcePattern: string; destDir: string; fileCount: number; } /** * Interface for compilation result */ export interface ICompileResult { emittedFiles: string[]; errorSummary: IErrorSummary; } /** * TsCompiler handles TypeScript compilation with error tracking, * configuration management, and output unpacking. */ export class TsCompiler { private config: TsConfig; private cwd: string; private argvArg?: any; constructor(cwd: string = process.cwd(), argvArg?: any) { this.cwd = cwd; this.config = new TsConfig(cwd); this.argvArg = argvArg; } /** * Get the current working directory */ public getCwd(): string { return this.cwd; } /** * Get the TsConfig instance */ public getConfig(): TsConfig { return this.config; } /** * Create compiler options by merging defaults, tsconfig.json, and custom options */ public createOptions(customOptions: CompilerOptions = {}): CompilerOptions { return this.config.merge(customOptions, this.argvArg); } /** * Create a TypeScript program from file names and options */ private createProgram(fileNames: string[], options: CompilerOptions): Program { return typescript.createProgram(fileNames, options); } /** * Process TypeScript diagnostics and return error summary */ private processDiagnostics(diagnostics: readonly Diagnostic[]): IErrorSummary { const errorsByFile: Record = {}; const generalErrors: Diagnostic[] = []; diagnostics.forEach((diagnostic) => { if (diagnostic.file) { const fileName = diagnostic.file.fileName; if (!errorsByFile[fileName]) { errorsByFile[fileName] = []; } errorsByFile[fileName].push(diagnostic); } else { generalErrors.push(diagnostic); } }); return { errorsByFile, generalErrors, totalErrors: diagnostics.length, totalFiles: Object.keys(errorsByFile).length, }; } /** * Display error summary to console */ private displayErrorSummary(errorSummary: IErrorSummary): void { if (errorSummary.totalErrors === 0) { return; } const { errorsByFile, generalErrors, totalErrors, totalFiles } = errorSummary; // Print error summary header console.log('\n' + '='.repeat(80)); console.log( `❌ Found ${totalErrors} error${totalErrors !== 1 ? 's' : ''} in ${totalFiles} file${totalFiles !== 1 ? 's' : ''}:` ); console.log('='.repeat(80)); // Color codes for error formatting const colors = { reset: '\x1b[0m', red: '\x1b[31m', yellow: '\x1b[33m', cyan: '\x1b[36m', white: '\x1b[37m', brightRed: '\x1b[91m', }; // Print file-specific errors Object.entries(errorsByFile).forEach(([fileName, fileErrors]) => { // Show relative path if possible for cleaner output const displayPath = fileName.replace(process.cwd(), '').replace(/^\//, ''); console.log( `\n${colors.cyan}File: ${displayPath} ${colors.yellow}(${fileErrors.length} error${fileErrors.length !== 1 ? 's' : ''})${colors.reset}` ); console.log('-'.repeat(80)); fileErrors.forEach((diagnostic) => { if (diagnostic.file && diagnostic.start !== undefined) { const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start); const message = typescript.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); const errorCode = diagnostic.code ? `TS${diagnostic.code}` : 'Error'; console.log( `${colors.white}Line ${line + 1}, Col ${character + 1}${colors.reset}: ${colors.brightRed}${errorCode}${colors.reset} - ${message}` ); // Try to show the code snippet if possible try { const lineContent = diagnostic.file.text.split('\n')[line]; if (lineContent) { console.log(` ${lineContent.trimEnd()}`); const indicator = ' '.repeat(character) + `${colors.red}^${colors.reset}`; console.log(` ${indicator}`); } } catch { // Failed to get source text, skip showing the code snippet } } }); }); // Print general errors if (generalErrors.length > 0) { console.log(`\n${colors.yellow}General Errors:${colors.reset}`); console.log('-'.repeat(80)); generalErrors.forEach((diagnostic) => { const message = typescript.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); const errorCode = diagnostic.code ? `TS${diagnostic.code}` : 'Error'; console.log(`${colors.brightRed}${errorCode}${colors.reset}: ${message}`); }); } console.log('\n' + '='.repeat(80) + '\n'); } /** * Handle skipLibCheck warning display */ private async handleSkipLibCheckWarning(): Promise { if (this.argvArg?.confirmskiplibcheck) { console.log('\n⚠️ WARNING ⚠️'); console.log('You are skipping libcheck... Is that really wanted?'); console.log('Continuing in 5 seconds...\n'); await smartdelay.delayFor(5000); } else if (!this.argvArg?.quiet && !this.argvArg?.json) { console.log('⚠️ skipLibCheck enabled; use --confirmskiplibcheck to pause with warning.'); } } /** * Compile files with error tracking (returns result instead of throwing) */ public async compileFiles( fileNames: string[], customOptions: CompilerOptions = {}, taskInfo?: ITaskInfo ): Promise { const options = this.createOptions(customOptions); if (options.skipLibCheck) { await this.handleSkipLibCheckWarning(); } // Enhanced logging with task info const startTime = Date.now(); if (taskInfo) { const { taskNumber, totalTasks, sourcePattern, fileCount } = taskInfo; const relativeDestDir = taskInfo.destDir.replace(process.cwd(), '').replace(/^\//, ''); console.log( `\n🔨 [${taskNumber}/${totalTasks}] Compiling ${fileCount} file${fileCount !== 1 ? 's' : ''} from ${sourcePattern}` ); console.log(` 📁 Output: ${relativeDestDir}`); } else { console.log(`🔨 Compiling ${fileNames.length} files...`); } const done = smartpromise.defer(); const program = this.createProgram(fileNames, options); // Check for pre-emit diagnostics first const preEmitDiagnostics = typescript.getPreEmitDiagnostics(program); const preEmitErrorSummary = this.processDiagnostics(preEmitDiagnostics); // Only continue to emit phase if no pre-emit errors if (preEmitErrorSummary.totalErrors > 0) { this.displayErrorSummary(preEmitErrorSummary); console.error('\n❌ TypeScript pre-emit checks failed. Please fix the issues listed above before proceeding.'); console.error(' Type errors must be resolved before the compiler can emit output files.\n'); done.resolve({ emittedFiles: [], errorSummary: preEmitErrorSummary }); return done.promise; } // If no pre-emit errors, proceed with emit const emitResult = program.emit(); const emitErrorSummary = this.processDiagnostics(emitResult.diagnostics); // Combine error summaries const combinedErrorSummary: IErrorSummary = { errorsByFile: { ...preEmitErrorSummary.errorsByFile, ...emitErrorSummary.errorsByFile }, generalErrors: [...preEmitErrorSummary.generalErrors, ...emitErrorSummary.generalErrors], totalErrors: preEmitErrorSummary.totalErrors + emitErrorSummary.totalErrors, totalFiles: Object.keys({ ...preEmitErrorSummary.errorsByFile, ...emitErrorSummary.errorsByFile }).length, }; const exitCode = emitResult.emitSkipped ? 1 : 0; if (exitCode === 0) { const endTime = Date.now(); const duration = endTime - startTime; if (taskInfo) { const { taskNumber, totalTasks } = taskInfo; console.log(`✅ [${taskNumber}/${totalTasks}] Task completed in ${duration}ms`); } else { console.log(`✅ TypeScript emit succeeded! (${duration}ms)`); } // Get count of emitted files by type const jsFiles = emitResult.emittedFiles?.filter((f) => f.endsWith('.js')).length || 0; const dtsFiles = emitResult.emittedFiles?.filter((f) => f.endsWith('.d.ts')).length || 0; const mapFiles = emitResult.emittedFiles?.filter((f) => f.endsWith('.map')).length || 0; if (emitResult.emittedFiles && emitResult.emittedFiles.length > 0) { console.log( ` 📄 Generated ${emitResult.emittedFiles.length} files: ${jsFiles} .js, ${dtsFiles} .d.ts, ${mapFiles} source maps` ); } done.resolve({ emittedFiles: emitResult.emittedFiles || [], errorSummary: combinedErrorSummary }); } else { this.displayErrorSummary(combinedErrorSummary); console.error('\n❌ TypeScript emit failed. Please investigate the errors listed above!'); console.error(' No output files have been generated.\n'); done.resolve({ emittedFiles: [], errorSummary: combinedErrorSummary }); } return done.promise; } /** * Compile files (throws on error) */ public async compileFilesOrThrow(fileNames: string[], customOptions: CompilerOptions = {}): Promise { const result = await this.compileFiles(fileNames, customOptions); if (result.errorSummary.totalErrors > 0) { throw new Error('TypeScript compilation failed.'); } return result.emittedFiles; } /** * Compile glob patterns with automatic unpacking */ public async compileGlob( globPatterns: Record, customOptions: CompilerOptions = {} ): Promise { const emittedFiles: string[] = []; const errorSummaries: IErrorSummary[] = []; const totalTasks = Object.keys(globPatterns).length; let currentTask = 0; const isQuiet = this.argvArg?.quiet === true; const isJson = this.argvArg?.json === true; if (!isQuiet && !isJson) { console.log(`\n👷 TypeScript Compilation Tasks (${totalTasks} task${totalTasks !== 1 ? 's' : ''}):`); Object.entries(globPatterns).forEach(([source, dest]) => { console.log(` 📂 ${source} → ${dest}`); }); console.log(''); } for (const pattern of Object.keys(globPatterns)) { const destPath = globPatterns[pattern]; if (!pattern || !destPath) continue; // Get files matching the glob pattern const files = await FsHelpers.listFilesWithGlob(this.cwd, pattern); // Transform to absolute paths const absoluteFiles = smartpath.transform.toAbsolute(files, this.cwd) as string[]; // Get destination directory as absolute path const destDir = smartpath.transform.toAbsolute(destPath, this.cwd) as string; // Update compiler options with the output directory const options: CompilerOptions = { ...customOptions, outDir: destDir, }; currentTask++; const taskInfo: ITaskInfo = { taskNumber: currentTask, totalTasks, sourcePattern: pattern, destDir: destPath, fileCount: absoluteFiles.length, }; const result = await this.compileFiles(absoluteFiles, options, taskInfo); emittedFiles.push(...result.emittedFiles); errorSummaries.push(result.errorSummary); // Perform unpack if compilation succeeded if (result.errorSummary.totalErrors === 0) { await performUnpack(pattern, destDir, this.cwd); } } // Merge all error summaries const finalErrorSummary = this.mergeErrorSummaries(errorSummaries); // Output summary based on mode if (isJson) { const result = { success: finalErrorSummary.totalErrors === 0, totals: { errors: finalErrorSummary.totalErrors, filesWithErrors: finalErrorSummary.totalFiles, tasks: totalTasks, }, errorsByFile: Object.fromEntries( Object.entries(finalErrorSummary.errorsByFile).map(([file, diags]) => [ file, diags.map((d) => ({ code: d.code, message: typescript.flattenDiagnosticMessageText(d.messageText as any, '\n'), })), ]) ), }; console.log(JSON.stringify(result)); } else if (!isQuiet) { this.displayFinalSummary(finalErrorSummary); } // Attach summary to argvArg for CLI exit behavior if (this.argvArg && typeof this.argvArg === 'object') { (this.argvArg as any).__tsbuildFinalErrorSummary = finalErrorSummary; } return { emittedFiles, errorSummary: finalErrorSummary, }; } /** * Check if files can be emitted without actually emitting */ public async checkEmit(fileNames: string[], customOptions: CompilerOptions = {}): Promise { const options = { ...this.createOptions(customOptions), noEmit: true }; const fileCount = fileNames.length; console.log(`\n🔍 Checking if ${fileCount} file${fileCount !== 1 ? 's' : ''} can be emitted...`); const program = this.createProgram(fileNames, options); const preEmitDiagnostics = typescript.getPreEmitDiagnostics(program); const preEmitErrorSummary = this.processDiagnostics(preEmitDiagnostics); const emitResult = program.emit(undefined, undefined, undefined, true); const emitErrorSummary = this.processDiagnostics(emitResult.diagnostics); const combinedErrorSummary: IErrorSummary = { errorsByFile: { ...preEmitErrorSummary.errorsByFile, ...emitErrorSummary.errorsByFile }, generalErrors: [...preEmitErrorSummary.generalErrors, ...emitErrorSummary.generalErrors], totalErrors: preEmitErrorSummary.totalErrors + emitErrorSummary.totalErrors, totalFiles: Object.keys({ ...preEmitErrorSummary.errorsByFile, ...emitErrorSummary.errorsByFile }).length, }; const success = combinedErrorSummary.totalErrors === 0 && !emitResult.emitSkipped; if (success) { console.log('\n✅ TypeScript emit check passed! All files can be emitted successfully.'); console.log(` ${fileCount} file${fileCount !== 1 ? 's' : ''} ${fileCount !== 1 ? 'are' : 'is'} ready to be compiled.\n`); } else { this.displayErrorSummary(combinedErrorSummary); console.error('\n❌ TypeScript emit check failed. Please fix the issues listed above.'); console.error(' The compilation cannot proceed until these errors are resolved.\n'); } return success; } /** * Check TypeScript files for type errors without emission */ public async checkTypes(fileNames: string[], customOptions: CompilerOptions = {}): Promise { const options = { ...this.createOptions(customOptions), noEmit: true }; const fileCount = fileNames.length; console.log(`\n🔍 Type checking ${fileCount} TypeScript file${fileCount !== 1 ? 's' : ''}...`); const program = this.createProgram(fileNames, options); const diagnostics = typescript.getPreEmitDiagnostics(program); const errorSummary = this.processDiagnostics(diagnostics); const success = errorSummary.totalErrors === 0; if (success) { console.log('\n✅ TypeScript type check passed! No type errors found.'); console.log(` All ${fileCount} file${fileCount !== 1 ? 's' : ''} passed type checking successfully.\n`); } else { this.displayErrorSummary(errorSummary); console.error('\n❌ TypeScript type check failed. Please fix the type errors listed above.'); console.error(' The type checker found issues that need to be resolved.\n'); } return success; } /** * Merge multiple error summaries into one */ private mergeErrorSummaries(summaries: IErrorSummary[]): IErrorSummary { const mergedErrorsByFile: Record = {}; const mergedGeneralErrors: Diagnostic[] = []; let totalErrors = 0; summaries.forEach((summary) => { Object.entries(summary.errorsByFile).forEach(([fileName, errors]) => { if (!mergedErrorsByFile[fileName]) { mergedErrorsByFile[fileName] = []; } mergedErrorsByFile[fileName] = mergedErrorsByFile[fileName].concat(errors); }); mergedGeneralErrors.push(...summary.generalErrors); totalErrors += summary.totalErrors; }); return { errorsByFile: mergedErrorsByFile, generalErrors: mergedGeneralErrors, totalErrors, totalFiles: Object.keys(mergedErrorsByFile).length, }; } /** * Display final compilation summary */ private displayFinalSummary(errorSummary: IErrorSummary): void { if (errorSummary.totalErrors === 0) { console.log('\n📊 \x1b[32mCompilation Summary: All tasks completed successfully! ✅\x1b[0m\n'); return; } const colors = { reset: '\x1b[0m', red: '\x1b[31m', yellow: '\x1b[33m', cyan: '\x1b[36m', brightRed: '\x1b[91m', brightYellow: '\x1b[93m', }; console.log('\n' + '='.repeat(80)); console.log(`📊 ${colors.brightYellow}Final Compilation Summary${colors.reset}`); console.log('='.repeat(80)); if (errorSummary.totalFiles > 0) { console.log(`${colors.brightRed}❌ Files with errors (${errorSummary.totalFiles}):${colors.reset}`); Object.entries(errorSummary.errorsByFile).forEach(([fileName, errors]) => { const displayPath = fileName.replace(process.cwd(), '').replace(/^\//, ''); console.log( ` ${colors.red}•${colors.reset} ${colors.cyan}${displayPath}${colors.reset} ${colors.yellow}(${errors.length} error${errors.length !== 1 ? 's' : ''})${colors.reset}` ); }); } if (errorSummary.generalErrors.length > 0) { console.log(`${colors.brightRed}❌ General errors: ${errorSummary.generalErrors.length}${colors.reset}`); } console.log( `\n${colors.brightRed}Total: ${errorSummary.totalErrors} error${errorSummary.totalErrors !== 1 ? 's' : ''} across ${errorSummary.totalFiles} file${errorSummary.totalFiles !== 1 ? 's' : ''}${colors.reset}` ); console.log('='.repeat(80) + '\n'); } }