plugins_ts_program.js

/**
 * @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;