/**
* @file src/plugins/ts/program.js
* @copyright @spmhome @_2025
* @author Scott Meesseman @spmeesseman
*//** */
const WpwService = require("../../services/base");
const { withColor } = require("@spmhome/log-utils");
const { relativePath, resolvePath } = require("@spmhome/cmn-utils");
const { apply, isString, pluralize, readLinkedList, asArray } = require("@spmhome/type-utils");
/**
* @augments WpwService
*/
class WpwTsProgram extends WpwService
{
/**
* @private
* @type {TypeScript | undefined}
*/
static typescript;
/**
* @private
* @type {string}
*/
tsName = "ts.program";
/**
* @param {WpwPlugin} plugin
*/
constructor(plugin)
{
super({ build: plugin.build, slug: "program" });
this.plugin = plugin;
this.logger.ok("tsc.program initialization complete", 1);
};
/**
* @private
* @param {WpwLogger} logger
* @returns {Pick<TypeScriptCompilerOptions, "listEmittedFiles" | "extendedDiagnostics">}
*/
compilerOptionsBigMuscles(logger)
{
return {
listEmittedFiles: true,
extendedDiagnostics: logger.level >= 4
};
}
/**
* @private
* @param {any} host
* @param {any} directoryStructureHost
* @param {boolean} [trace]
* @param {string} [logPad]
* @returns {any}
*/
configFileHost(host, directoryStructureHost, trace, logPad = " ")
{
this.logger.writeMsgTag("create config file host", this.tsName, trace ? 1 : 3, logPad, null, true);
if (directoryStructureHost === void 0) { directoryStructureHost = host; }
const cfg = {
fileExists: (f) => directoryStructureHost.fileExists(f),
readDirectory: (root, extensions, excludes, includes, depth) => {
// ts.Debug.assertIsDefined(
// directoryStructureHost.readDirectory, "'CompilerHost.readDirectory' must be
// implemented to correctly process 'projectReferences'");
return directoryStructureHost.readDirectory(root, extensions, excludes, includes, depth);
},
readFile: (f) => directoryStructureHost.readFile(f),
// writeFile: (fileName, text, writeByteOrderMark, onError, sourceFiles, dataWriteCbData) => {
// const sources = this.build.wp.sources;
// this.plugin.build.ssCache.checkSnapshot(fileName, new sources.RawSource(text), " ");
// directoryStructureHost.writeFIle(fileName, text, writeByteOrderMark, onError, sourceFiles, dataWriteCbData);
// },
useCaseSensitiveFileNames: host.useCaseSensitiveFileNames(),
getCurrentDirectory: () => host.getCurrentDirectory(),
onUnRecoverableConfigFileDiagnostic: host.onUnRecoverableConfigFileDiagnostic ||
trace ? (msg) => { this.logger.write(msg, 1, logPad + " "); } : () => {},
trace: trace ? (msg) => { host.trace(msg); this.logger.write(msg, 1, logPad + " "); } :
(host.trace ? (s) => host.trace(s) : undefined)
};
return cfg;
}
/**
* @private
* @param {WpwTsCompilerOptions} compilerOpts typescript compiler options
* @param {string[]} [files]
* @param {WpwSourceCodeInfo} [srcInfo]
* @param {string} [logPad]
* @returns {TypeScriptProgram} TypeScriptProgram
* @throws {Error}
*/
createProgram(compilerOpts, files, srcInfo, logPad = " ")
{
// const baseDir = `${this.build.virtualEntry.dir}`,
const baseDir = `${srcInfo.dir}`,
tsconfigPath = srcInfo.path,
pOptions = compilerOpts || {},
trace = !!pOptions.verbose;
this.logger.writeMsgTag("create program", this.tsName, 1, logPad, null, true);
this.logger.value(" config file path", tsconfigPath, 1, logPad, null, null, [ this.tsName ]);
const ts = this.getTypescriptEngine();
Object.keys(pOptions).filter((k) =>isString(pOptions[k]) && pOptions[k].includes("\\")).forEach((k) =>
{
pOptions[k] = this._path_.fwdSlash(pOptions[k]);
});
const programOpts = ts.convertCompilerOptionsFromJson(pOptions, baseDir, tsconfigPath);
const options = apply({},
programOpts.options,
tsconfigPath ? { project: tsconfigPath, configFilePath: tsconfigPath } : {}
);
const host = ts.createCompilerHost(options);
// /** @type {TypeScriptProgramOptions} */
// const programOptions = {
// rootNames: [],
// options,
// host: configFileHost
// };
if (!files || files.length === 0)
{
this.logger.writeMsgTag(" determine included files", this.tsName, trace ? 1 : 3, logPad, null, true);
const configFileHost = this.configFileHost(host, host, trace),
parsedCmdLine = ts.getParsedCommandLineOfConfigFile(tsconfigPath, options, configFileHost);
if (!parsedCmdLine) {
throw new Error("could not set rootNames");
}
files = parsedCmdLine.fileNames;
}
else {
files = asArray(files).map((f) => resolvePath(baseDir, f).replace(/\\/g, "/"));
}
if (files.length)
{
const fCnt = files?.length || 0;
const prg = ts.createProgram({
options, /* TODO */projectReferences: undefined, host, rootNames: files
});
this.logger.write(` included ${fCnt} ${pluralize("file", fCnt)}`, trace ? 1 : 3, logPad);
return prg;
}
else {
throw new Error("0 files included with current configuration");
}
}
/**
* @private
* @returns {TypeScript}
* @throws { Error }
*/
getTypescriptEngine()
{
WpwTsProgram.typescript = (WpwTsProgram.typescript || require("typescript"));
if (!WpwTsProgram.typescript) {
throw new Error("error encounterted attempting to initialize typescript program module," +
"ensure that typescript v5.2 or later is installed locally");
}
return WpwTsProgram.typescript;
}
/**
* @private
* @param {Readonly<TypeScriptDiagnostic[]>} diagnostics
* @param {string} logPad
* @returns {boolean}
*/
processDiagnostics(diagnostics, logPad)
{
let dCount = 0, hasError = false;
if (!diagnostics?.length) {
return hasError;
}
const build = this.build,
l = this.logger,
maxErrorCt = 25,
tsName = this.tsName,
diagMsgCt = diagnostics.length,
diagMsgCtTag = l.tag(diagMsgCt.toString()),
ctMessage = `diagnostic ${pluralize("message", diagMsgCt)}`;
if (diagMsgCt <= maxErrorCt)
{
l.writeMsgTag(`examine ${diagMsgCt} ${ctMessage}`, tsName, 1, logPad);
}
else {
l.writeMsgTag(`examine ${maxErrorCt} of ${diagMsgCt} total ${ctMessage}`, tsName, 1, logPad);
}
for (let i = 0; i < diagnostics.length; i++)
{
const d = diagnostics[i];
let dMsg = d.messageText;
if (!isString(dMsg) && dMsg.next) {
dMsg = readLinkedList(dMsg, "messageText").join("\n");
}
const c = d.category,
category = c === 0 ? "warning" : (c === 1 ? "error" : (c === 2 ? "suggestion" : "message")),
categoryTag = l.tag(category),
err = new Error(`${l.tag("TS" + d.code)} ${dMsg}`),
tMsg = `${categoryTag} diagnostic message ${l.tag((++dCount).toString())} of ${diagMsgCtTag}`;
hasError ||= (c === 1);
l.writeMsgTag(` ${tMsg}`, tsName, 1, logPad);
l.write(` [TS${d.code}] ${dMsg}`, 1, logPad);
if (d.file)
{ if (d.start)
{ const pos = d.file.getLineAndCharacterOfPosition(d.start),
fileRelPath = relativePath(this.build.getContextPath(), d.file.fileName);
err.stack = `${fileRelPath}\n at ${d.file.fileName}:${pos.line}:${pos.character}`;
}
else { err.stack = d.file.fileName; }
}
else {
err.stack = "";
}
const code = c === 1 ? this.MsgCode.ERROR_TYPES_EMIT_FAILED :
(c === 0 ? this.MsgCode.WARNING_TYPES_EMIT_MESSAGE :
(c === 2 ? this.MsgCode.INFO_TYPES_EMIT_SUGGESTION :
this.MsgCode.INFO_TYPES_EMIT_MESSAGE)),
relInfo = d.relatedInformation?.length ? `\n${asArray(d.relatedInformation, false).join("\n")}` : "";
this.build.addMessage({
exception: err, code, message: err.message,
detail: `${ctMessage} ${l.tag((i + 1).toString())} of ${l.tag(diagMsgCt.toString())}${relInfo}`
});
if (dCount === maxErrorCt)
{
const skippedCt = diagMsgCt - maxErrorCt;
l.writeMsgTag(" maximum logged error count reached", tsName, 1, logPad);
l.writeMsgTag(` skip ${skippedCt} remaining diagnostic messages`, tsName, 1, logPad);
break;
}
}
l.write(
`examined ${diagMsgCt} ${ctMessage}`, 1, logPad, l.icons.color.error, null, false, null,
[ hasError ? "0 errors" : `${build.errorCount} ${pluralize("error", build.errorCount)}`, tsName ], "error"
);
return hasError;
}
/**
* @param {WpwTsCompilerOptions | undefined} [compilerOpts] typescript compiler options
* @param {WpwSourceCodeInfo} [srcInfo]
* @param {boolean} [dtsOnly]
* @param {string} [logPad]
* @returns {Promise<WpwPluginTaskObjResult | undefined>} list of emitted files / files with updated content
*/
execTsc(compilerOpts, srcInfo, dtsOnly, logPad = " ")
{
let tm, cancel = false;
/** @type {TypeScriptCancellationToken} */
let cancelToken =
{ isCancellationRequested: () => { return cancel; },
throwIfCancellationRequested: () =>
{ if (cancel)
{ throw this.build.addMessage({
code: this.MsgCode.ERROR_BUILD_TIMEOUT,
message: "tsc.program timed out, cancelled execution"
});
} } };
// return this.build.ssCache.checkSnapshot(this.build.virtualEntry.dirBuild, null, logPad + " ")
// .then((_cached) => // TODO - TEST SNAPSHOT CACHE
// {
return new Promise((ok, fail) =>
{
const l = this.logger,
tsName = this.tsName,
files = this.build.tsc.tsconfig.files,
// gEvent = this.build.global.globalEvent,
// gErrEvent = promiseFromEvent(gEvent.emitter, GLOBAL_EVENT_BUILD_ERROR, 15000);
/** @type {SpmhLogIconString} */
errIcon = withColor(l.icons.error, l.color),
actTxt = dtsOnly ? "create type declarations" : "transpile source files",
compilerOptions = apply({}, this.compilerOptionsBigMuscles(l), compilerOpts);
l.write(`create ${tsName} instance [task: ${actTxt}]`, 1, logPad, null, null, false, null, [ tsName ]);
try
{ const program = this.createProgram(compilerOptions, files, srcInfo, logPad),
rootFiles = this.getRootFiles(program, files, logPad);
this.printCompilerOptions(program, logPad);
l.write(` execute and wait for ${tsName} to exit...`, 1, logPad, null, null, false, null, [ tsName ]);
tm = setTimeout(() => {
cancel = true;
fail(new Error(`${actTxt} timed out`));
}, 750 * rootFiles.length);
const result = program.emit(undefined, undefined, cancelToken, dtsOnly);
if (this.processDiagnostics(result.diagnostics, logPad))
{
l.write(`exec finished with errors [${actTxt}]`, 1, logPad, errIcon, null, false, null, [ tsName ]);
fail(this.build.lastError);
}
else
{ l.write(
`exec finished [${actTxt}] [${result.emittedFiles.length} output files]`,
1, logPad, null, null, false, null, [ tsName ]
);
ok({
glob: "**/*.d.ts", paths: result.emittedFiles, srcpaths: rootFiles.slice(), immutable: false, tag: "dts"
});
}
}
catch(e)
{ this.build.addMessage({
exception: e,
message: `failed to execute ${tsName} task`,
code: this.MsgCode.ERROR_TYPES_FAILED
});
}
finally {
if (tm) { clearTimeout(tm); }
cancelToken = null;
}
});
}
/**
* @private
* @param {TypeScriptProgram} program
* @param {string[]} files
* @param {string} logPad
* @returns {Readonly<string[]>}
*/
getRootFiles(program, files, logPad)
{
const l = this.logger, tsName = this.tsName;
l.write("determine included files", 1, logPad, null, null, false, null, [ tsName ]);
const rootFiles = program.getRootFileNames();
l.value(` ${files.length} files`, files.join(" | "), 1, logPad, null, null, [ tsName ]);
if (l.level >= 4)
{
l.value(
` ${rootFiles.length} ${tsName} root files`, rootFiles.join(" | "), 2, logPad, null, null, [ tsName ]
);
}
else if (l.level >= 2)
{
const someFIles = rootFiles.slice(0, rootFiles.length < 3 ? rootFiles.length : 3);
l.value(
` ${rootFiles.length} ${tsName} root files`, someFIles.join(" | ") + "...", 2, logPad, null, null, [ tsName ]
);
}
else {
l.write(` ${rootFiles.length} ${tsName} root files`, 2, logPad, null, null, 0, 0, [ tsName ]);
}
return rootFiles;
}
/**
* @private
* @param {TypeScriptProgram} program
* @param {string} logPad
*/
printCompilerOptions(program, logPad)
{
if (this.logger.level >= 2)
{
this.logger.write("modified compiler options", 2, logPad, null, null, false, null, [ this.tsName ]);
const pOptsArgs = program.getCompilerOptions();
this.logger.value(` ${this.tsName} options`, pOptsArgs, 2, logPad, null, null, [ this.tsName ]);
}
}
}
module.exports = WpwTsProgram;