Files
tsbuild/ts/mod_compiler/classes.tscompiler.ts

543 lines
18 KiB
TypeScript

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';
import { TsPathRewriter } from '../mod_pathrewrite/index.js';
import { TsBuildLogger as log } from '../mod_logger/index.js';
/**
* Interface for error summary data
*/
export interface IErrorSummary {
errorsByFile: Record<string, Diagnostic[]>;
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<string, Diagnostic[]> = {};
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;
const c = log.c;
console.log('');
console.log(c.dim + log.separator() + c.reset);
console.log(
`${c.red}❌ Found ${totalErrors} error${totalErrors !== 1 ? 's' : ''} in ${totalFiles} file${totalFiles !== 1 ? 's' : ''}${c.reset}`
);
console.log(c.dim + log.separator() + c.reset);
// Print file-specific errors
Object.entries(errorsByFile).forEach(([fileName, fileErrors]) => {
const displayPath = fileName.replace(process.cwd(), '').replace(/^\//, '');
console.log(
`\n ${c.cyan}File: ${displayPath}${c.reset} ${c.yellow}(${fileErrors.length} error${fileErrors.length !== 1 ? 's' : ''})${c.reset}`
);
console.log(` ${c.dim}${'─'.repeat(40)}${c.reset}`);
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(
` ${c.white}Line ${line + 1}, Col ${character + 1}${c.reset}: ${c.brightRed}${errorCode}${c.reset} - ${message}`
);
try {
const lineContent = diagnostic.file.text.split('\n')[line];
if (lineContent) {
console.log(` ${lineContent.trimEnd()}`);
const indicator = ' '.repeat(character) + `${c.red}^${c.reset}`;
console.log(` ${indicator}`);
}
} catch {
// Failed to get source text
}
}
});
});
// Print general errors
if (generalErrors.length > 0) {
console.log(`\n ${c.yellow}General Errors:${c.reset}`);
console.log(` ${c.dim}${'─'.repeat(40)}${c.reset}`);
generalErrors.forEach((diagnostic) => {
const message = typescript.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
const errorCode = diagnostic.code ? `TS${diagnostic.code}` : 'Error';
console.log(` ${c.brightRed}${errorCode}${c.reset}: ${message}`);
});
}
console.log('');
console.log(c.dim + log.separator() + c.reset);
}
/**
* Handle skipLibCheck warning display
*/
private async handleSkipLibCheckWarning(): Promise<void> {
if (this.argvArg?.confirmskiplibcheck) {
log.warn('WARNING: You are skipping libcheck... Is that really wanted?');
log.indent('Continuing in 5 seconds...');
await smartdelay.delayFor(5000);
} else if (!this.argvArg?.quiet && !this.argvArg?.json) {
log.warn('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<ICompileResult> {
const options = this.createOptions(customOptions);
if (options.skipLibCheck) {
await this.handleSkipLibCheckWarning();
}
const startTime = Date.now();
if (taskInfo) {
const { taskNumber, totalTasks, sourcePattern, fileCount } = taskInfo;
const relativeDestDir = taskInfo.destDir.replace(process.cwd(), '').replace(/^\//, '');
log.step('🔨', `[${taskNumber}/${totalTasks}] Compiling ${fileCount} file${fileCount !== 1 ? 's' : ''} from ${sourcePattern}`);
log.detail('📁', `Output: ${relativeDestDir}`);
} else {
log.step('🔨', `Compiling ${fileNames.length} files...`);
}
const done = smartpromise.defer<ICompileResult>();
const program = this.createProgram(fileNames, options);
// Check for pre-emit diagnostics first
const preEmitDiagnostics = typescript.getPreEmitDiagnostics(program);
const preEmitErrorSummary = this.processDiagnostics(preEmitDiagnostics);
if (preEmitErrorSummary.totalErrors > 0) {
this.displayErrorSummary(preEmitErrorSummary);
log.error('Pre-emit checks failed');
done.resolve({ emittedFiles: [], errorSummary: preEmitErrorSummary });
return done.promise;
}
// If no pre-emit errors, proceed with emit
const emitResult = program.emit();
// Yield to the event loop so any pending microtasks, nextTick callbacks,
// or deferred I/O from TypeScript's emit (e.g. libuv write completions)
// can settle before we read or modify the output directory.
await new Promise<void>((resolve) => process.nextTick(resolve));
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;
log.success(`[${taskNumber}/${totalTasks}] Completed in ${duration}ms`);
} else {
log.success(`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) {
log.detail('📄', `Generated ${emitResult.emittedFiles.length} files: ${jsFiles} .js, ${dtsFiles} .d.ts, ${mapFiles} source maps`);
}
done.resolve({ emittedFiles: emitResult.emittedFiles || [], errorSummary: combinedErrorSummary });
} else {
this.displayErrorSummary(combinedErrorSummary);
log.error('TypeScript emit failed');
done.resolve({ emittedFiles: [], errorSummary: combinedErrorSummary });
}
return done.promise;
}
/**
* Compile files (throws on error)
*/
public async compileFilesOrThrow(fileNames: string[], customOptions: CompilerOptions = {}): Promise<string[]> {
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<string, string>,
customOptions: CompilerOptions = {}
): Promise<ICompileResult> {
const emittedFiles: string[] = [];
const errorSummaries: IErrorSummary[] = [];
const successfulOutputDirs: string[] = [];
const totalTasks = Object.keys(globPatterns).length;
let currentTask = 0;
const isQuiet = this.argvArg?.quiet === true;
const isJson = this.argvArg?.json === true;
if (!isQuiet && !isJson) {
log.header('👷', `TypeScript Compilation Tasks (${totalTasks} task${totalTasks !== 1 ? 's' : ''})`);
Object.entries(globPatterns).forEach(([source, dest]) => {
log.detail('📂', `${source}${dest}`);
});
}
// Phase 1: Resolve glob patterns and clean ALL output directories upfront.
interface IResolvedTask {
pattern: string;
destPath: string;
destDir: string;
absoluteFiles: string[];
}
const resolvedTasks: IResolvedTask[] = [];
for (const pattern of Object.keys(globPatterns)) {
const destPath = globPatterns[pattern];
if (!pattern || !destPath) continue;
const files = await FsHelpers.listFilesWithGlob(this.cwd, pattern);
const absoluteFiles = smartpath.transform.toAbsolute(files, this.cwd) as string[];
const destDir = smartpath.transform.toAbsolute(destPath, this.cwd) as string;
if (await FsHelpers.directoryExists(destDir)) {
if (!isQuiet && !isJson) {
log.step('🧹', `Clearing output directory: ${destPath}`);
}
await FsHelpers.removeDirectory(destDir);
}
resolvedTasks.push({ pattern, destPath, destDir, absoluteFiles });
}
// Phase 2: Compile all tasks.
const pendingUnpacks: Array<{ pattern: string; destDir: string }> = [];
for (const task of resolvedTasks) {
const options: CompilerOptions = {
...customOptions,
outDir: task.destDir,
listEmittedFiles: true,
};
currentTask++;
const taskInfo: ITaskInfo = {
taskNumber: currentTask,
totalTasks,
sourcePattern: task.pattern,
destDir: task.destPath,
fileCount: task.absoluteFiles.length,
};
const result = await this.compileFiles(task.absoluteFiles, options, taskInfo);
emittedFiles.push(...result.emittedFiles);
errorSummaries.push(result.errorSummary);
if (result.errorSummary.totalErrors === 0) {
pendingUnpacks.push({ pattern: task.pattern, destDir: task.destDir });
successfulOutputDirs.push(task.destDir);
}
}
// Phase 3: Perform all unpacks after all compilations are done.
for (const { pattern, destDir } of pendingUnpacks) {
await performUnpack(pattern, destDir, this.cwd);
}
// Rewrite import paths in all output directories
if (successfulOutputDirs.length > 0) {
const rewriter = await TsPathRewriter.fromProjectDirectory(this.cwd);
let totalRewritten = 0;
for (const outputDir of successfulOutputDirs) {
totalRewritten += await rewriter.rewriteDirectory(outputDir);
}
if (totalRewritten > 0 && !isQuiet && !isJson) {
log.detail('🔄', `Rewrote import paths in ${totalRewritten} file${totalRewritten !== 1 ? 's' : ''}`);
}
}
// 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<boolean> {
const options = { ...this.createOptions(customOptions), noEmit: true };
const fileCount = fileNames.length;
log.step('🔍', `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) {
log.success(`Emit check passed (${fileCount} file${fileCount !== 1 ? 's' : ''})`);
} else {
this.displayErrorSummary(combinedErrorSummary);
log.error('Emit check failed');
}
return success;
}
/**
* Check TypeScript files for type errors without emission
*/
public async checkTypes(fileNames: string[], customOptions: CompilerOptions = {}): Promise<boolean> {
const options = { ...this.createOptions(customOptions), noEmit: true };
const fileCount = fileNames.length;
log.step('🔍', `Type checking ${fileCount} 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) {
log.success(`Type check passed (${fileCount} file${fileCount !== 1 ? 's' : ''})`);
} else {
this.displayErrorSummary(errorSummary);
log.error('Type check failed');
}
return success;
}
/**
* Merge multiple error summaries into one
*/
private mergeErrorSummaries(summaries: IErrorSummary[]): IErrorSummary {
const mergedErrorsByFile: Record<string, Diagnostic[]> = {};
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 {
const c = log.c;
if (errorSummary.totalErrors === 0) {
log.header('📊', `${c.green}Compilation Summary: All tasks completed successfully! ✅${c.reset}`);
return;
}
log.header('📊', 'Compilation Summary');
console.log(
`${c.brightRed}${errorSummary.totalErrors} error${errorSummary.totalErrors !== 1 ? 's' : ''} across ${errorSummary.totalFiles} file${errorSummary.totalFiles !== 1 ? 's' : ''}${c.reset}`
);
Object.entries(errorSummary.errorsByFile).forEach(([fileName, errors]) => {
const displayPath = fileName.replace(process.cwd(), '').replace(/^\//, '');
log.indent(
`${c.red}${c.reset} ${c.cyan}${displayPath}${c.reset} ${c.yellow}(${errors.length} error${errors.length !== 1 ? 's' : ''})${c.reset}`
);
});
if (errorSummary.generalErrors.length > 0) {
console.log(`${c.brightRed}❌ General errors: ${errorSummary.generalErrors.length}${c.reset}`);
}
}
}